package client import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "github.com/m3tam3re/agent-lib/internal/gitsource" "github.com/m3tam3re/agent-lib/internal/lockfile" ) // ManifestVersion is the local manifest schema version. const ManifestVersion = 1 // ManifestItem records one deployed item: name, type, origin, the work-repo // revision it came from, and the content hash at deploy time. type ManifestItem struct { Name string `json:"name"` Type string `json:"type"` Origin string `json:"origin"` Revision string `json:"revision"` Hash string `json:"hash"` } // Manifest is the local record of everything agent-lib deployed. type Manifest struct { Version int `json:"version"` Revision string `json:"revision"` Items map[string]*ManifestItem `json:"items"` } // NewManifest returns an empty manifest v1. func NewManifest() *Manifest { return &Manifest{Version: ManifestVersion, Items: map[string]*ManifestItem{}} } // LoadManifest reads the manifest at path; a missing file yields an empty // manifest, not an error (first sync). func LoadManifest(path string) (*Manifest, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return NewManifest(), nil } return nil, err } var m Manifest if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("manifest %s is not valid JSON: %w", path, err) } if m.Items == nil { m.Items = map[string]*ManifestItem{} } return &m, nil } // Save writes the manifest deterministically (sorted item keys, two-space // indent, trailing newline). func (m *Manifest) Save(path string) error { data, err := marshalManifest(m) if err != nil { return err } return os.WriteFile(path, data, 0o644) } func marshalManifest(m *Manifest) ([]byte, error) { data, err := json.MarshalIndent(m, "", " ") if err != nil { return nil, err } return append(data, '\n'), nil } // Key identifies an item in the manifest: "/". func manifestKey(typ, name string) string { return typ + "/" + name } // HashItem computes the content hash of an inventory item from its git tree: // single files hash their bytes; folders hash "relpath 0x00 hash" lines over // all contained files, sorted. func HashItem(tree *gitsource.GitTree, item InventoryItem) (string, error) { if item.Type != lockfile.TypeSkill { data, err := tree.ReadFile(item.RelPath) if err != nil { return "", err } sum := sha256.Sum256(data) return "sha256:" + hex.EncodeToString(sum[:]), nil } files, err := tree.FilesUnder(item.RelPath) if err != nil { return "", err } prefix := strings.TrimSuffix(item.RelPath, "/") + "/" h := sha256.New() for _, f := range files { data, err := tree.ReadFile(f) if err != nil { return "", err } fsum := sha256.Sum256(data) fmt.Fprintf(h, "%s\x00%s\n", strings.TrimPrefix(f, prefix), hex.EncodeToString(fsum[:])) } return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil } // HashDiskPath computes the same content hash for a deployed path on disk. func HashDiskPath(typ, path string) (string, error) { info, err := os.Stat(path) if err != nil { return "", err } if !info.IsDir() { data, err := os.ReadFile(path) if err != nil { return "", err } sum := sha256.Sum256(data) return "sha256:" + hex.EncodeToString(sum[:]), nil } var lines []string err = filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } rel, err := filepath.Rel(path, p) if err != nil { return err } data, err := os.ReadFile(p) if err != nil { return err } fsum := sha256.Sum256(data) lines = append(lines, fmt.Sprintf("%s\x00%s", filepath.ToSlash(rel), hex.EncodeToString(fsum[:]))) return nil }) if err != nil { return "", err } sort.Strings(lines) h := sha256.New() for _, l := range lines { fmt.Fprintf(h, "%s\n", l) } return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil }