diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eeb6749..a7dfb13 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -123,6 +123,7 @@ All commands operate on a repository directory (default: `~/.sgard`, override wi | `sgard restore [...] [--force]` | Restore files from manifest to their original locations | | `sgard status` | Compare current files against manifest: modified, missing, ok | | `sgard verify` | Check all blobs against manifest hashes (integrity check) | +| `sgard info ` | Show detailed information about a tracked file | | `sgard list` | List all tracked files | | `sgard diff ` | Show content diff between current file and stored blob | | `sgard prune` | Remove orphaned blobs not referenced by the manifest | @@ -675,7 +676,7 @@ sgard/ encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase push.go pull.go prune.go mirror.go init.go add.go remove.go checkpoint.go - restore.go status.go verify.go list.go diff.go version.go + restore.go status.go verify.go list.go info.go diff.go version.go cmd/sgardd/ # gRPC server daemon main.go # --listen, --repo, --authorized-keys, --tls-cert, --tls-key flags @@ -686,7 +687,7 @@ sgard/ encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution fido2_hardware.go # Real FIDO2 via go-libfido2 (//go:build fido2) fido2_nohardware.go # Stub returning nil (//go:build !fido2) - restore.go mirror.go prune.go remove.go verify.go list.go diff.go + restore.go mirror.go prune.go remove.go verify.go list.go info.go diff.go hasher.go # SHA-256 file hashing manifest/ # YAML manifest parsing @@ -734,6 +735,7 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) func (g *Garden) Status() ([]FileStatus, error) func (g *Garden) Verify() ([]VerifyResult, error) func (g *Garden) List() []manifest.Entry +func (g *Garden) Info(path string) (*FileInfo, error) func (g *Garden) Diff(path string) (string, error) func (g *Garden) Prune() (int, error) func (g *Garden) MirrorUp(paths []string) error diff --git a/PROGRESS.md b/PROGRESS.md index 71a5287..f60565c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -44,6 +44,8 @@ ARCHITECTURE.md for design details. Phase 6: Manifest Signing (to be planned). +## Standalone Additions + ## Known Issues / Decisions Deferred - **Manifest signing**: deferred — trust model (which key signs, how do @@ -97,3 +99,4 @@ Phase 6: Manifest Signing (to be planned). | 2026-03-24 | 30 | Targeting CLI: tag add/remove/list, identity, --only/--never on add, target command. | | 2026-03-24 | 31 | Proto + sync: only/never fields on ManifestEntry, conversion, round-trip test. | | 2026-03-24 | 32 | Phase 5 polish: e2e test (targeting + push/pull + restore), docs updated. Phase 5 complete. | +| 2026-03-25 | — | `sgard info` command: shows detailed file information (status, hash, timestamps, mode, encryption, targeting). 5 tests. | diff --git a/cmd/sgard/info.go b/cmd/sgard/info.go new file mode 100644 index 0000000..cbecb02 --- /dev/null +++ b/cmd/sgard/info.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var infoCmd = &cobra.Command{ + Use: "info ", + Short: "Show detailed information about a tracked file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + fi, err := g.Info(args[0]) + if err != nil { + return err + } + + fmt.Printf("Path: %s\n", fi.Path) + fmt.Printf("Type: %s\n", fi.Type) + fmt.Printf("Status: %s\n", fi.State) + fmt.Printf("Mode: %s\n", fi.Mode) + + if fi.Locked { + fmt.Printf("Locked: yes\n") + } + if fi.Encrypted { + fmt.Printf("Encrypted: yes\n") + } + + if fi.Updated != "" { + fmt.Printf("Updated: %s\n", fi.Updated) + } + if fi.DiskModTime != "" { + fmt.Printf("Disk mtime: %s\n", fi.DiskModTime) + } + + switch fi.Type { + case "file": + fmt.Printf("Hash: %s\n", fi.Hash) + if fi.CurrentHash != "" && fi.CurrentHash != fi.Hash { + fmt.Printf("Disk hash: %s\n", fi.CurrentHash) + } + if fi.PlaintextHash != "" { + fmt.Printf("PT hash: %s\n", fi.PlaintextHash) + } + if fi.BlobStored { + fmt.Printf("Blob: stored\n") + } else { + fmt.Printf("Blob: missing\n") + } + case "link": + fmt.Printf("Target: %s\n", fi.Target) + if fi.CurrentTarget != "" && fi.CurrentTarget != fi.Target { + fmt.Printf("Disk target: %s\n", fi.CurrentTarget) + } + } + + if len(fi.Only) > 0 { + fmt.Printf("Only: %s\n", strings.Join(fi.Only, ", ")) + } + if len(fi.Never) > 0 { + fmt.Printf("Never: %s\n", strings.Join(fi.Never, ", ")) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(infoCmd) +} diff --git a/garden/info.go b/garden/info.go new file mode 100644 index 0000000..de31716 --- /dev/null +++ b/garden/info.go @@ -0,0 +1,158 @@ +package garden + +import ( + "fmt" + "os" + "strings" +) + +// FileInfo holds detailed information about a single tracked entry. +type FileInfo struct { + Path string // tilde path from manifest + Type string // "file", "link", or "directory" + State string // "ok", "modified", "drifted", "missing", "skipped" + Mode string // octal file mode from manifest + Hash string // blob hash from manifest (files only) + PlaintextHash string // plaintext hash (encrypted files only) + CurrentHash string // SHA-256 of current file on disk (files only, empty if missing) + Encrypted bool + Locked bool + Updated string // manifest timestamp (RFC 3339) + DiskModTime string // filesystem modification time (RFC 3339, empty if missing) + Target string // symlink target (links only) + CurrentTarget string // current symlink target on disk (links only, empty if missing) + Only []string // targeting: only these labels + Never []string // targeting: never these labels + BlobStored bool // whether the blob exists in the store +} + +// Info returns detailed information about a tracked file. +func (g *Garden) Info(path string) (*FileInfo, error) { + abs, err := resolvePath(path) + if err != nil { + return nil, err + } + tilded := toTildePath(abs) + + entry := g.findEntry(tilded) + if entry == nil { + // Also try the path as given (it might already be a tilde path). + entry = g.findEntry(path) + if entry == nil { + return nil, fmt.Errorf("not tracked: %s", path) + } + } + + fi := &FileInfo{ + Path: entry.Path, + Type: entry.Type, + Mode: entry.Mode, + Hash: entry.Hash, + PlaintextHash: entry.PlaintextHash, + Encrypted: entry.Encrypted, + Locked: entry.Locked, + Target: entry.Target, + Only: entry.Only, + Never: entry.Never, + } + + if !entry.Updated.IsZero() { + fi.Updated = entry.Updated.Format("2006-01-02 15:04:05 UTC") + } + + // Check blob existence for files. + if entry.Type == "file" && entry.Hash != "" { + fi.BlobStored = g.store.Exists(entry.Hash) + } + + // Determine state and filesystem info. + labels := g.Identity() + applies, err := EntryApplies(entry, labels) + if err != nil { + return nil, err + } + if !applies { + fi.State = "skipped" + return fi, nil + } + + entryAbs, err := ExpandTildePath(entry.Path) + if err != nil { + return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err) + } + + info, err := os.Lstat(entryAbs) + if os.IsNotExist(err) { + fi.State = "missing" + return fi, nil + } + if err != nil { + return nil, fmt.Errorf("stat %s: %w", entryAbs, err) + } + + fi.DiskModTime = info.ModTime().UTC().Format("2006-01-02 15:04:05 UTC") + + switch entry.Type { + case "file": + hash, err := HashFile(entryAbs) + if err != nil { + return nil, fmt.Errorf("hashing %s: %w", entryAbs, err) + } + fi.CurrentHash = hash + + compareHash := entry.Hash + if entry.Encrypted && entry.PlaintextHash != "" { + compareHash = entry.PlaintextHash + } + if hash != compareHash { + if entry.Locked { + fi.State = "drifted" + } else { + fi.State = "modified" + } + } else { + fi.State = "ok" + } + + case "link": + target, err := os.Readlink(entryAbs) + if err != nil { + return nil, fmt.Errorf("reading symlink %s: %w", entryAbs, err) + } + fi.CurrentTarget = target + if target != entry.Target { + fi.State = "modified" + } else { + fi.State = "ok" + } + + case "directory": + fi.State = "ok" + } + + return fi, nil +} + +// resolvePath resolves a user-provided path to an absolute path, handling +// tilde expansion and relative paths. +func resolvePath(path string) (string, error) { + if path == "~" || strings.HasPrefix(path, "~/") { + return ExpandTildePath(path) + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + // If it looks like a tilde path already, just expand it. + if strings.HasPrefix(path, home) { + return path, nil + } + abs, err := os.Getwd() + if err != nil { + return "", err + } + if !strings.HasPrefix(path, "/") { + path = abs + "/" + path + } + return path, nil +} diff --git a/garden/info_test.go b/garden/info_test.go new file mode 100644 index 0000000..01c7537 --- /dev/null +++ b/garden/info_test.go @@ -0,0 +1,191 @@ +package garden + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInfoTrackedFile(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Create a file to track. + filePath := filepath.Join(root, "hello.txt") + if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := g.Add([]string{filePath}); err != nil { + t.Fatalf("Add: %v", err) + } + + fi, err := g.Info(filePath) + if err != nil { + t.Fatalf("Info: %v", err) + } + + if fi.Type != "file" { + t.Errorf("Type = %q, want %q", fi.Type, "file") + } + if fi.State != "ok" { + t.Errorf("State = %q, want %q", fi.State, "ok") + } + if fi.Hash == "" { + t.Error("Hash is empty") + } + if fi.CurrentHash == "" { + t.Error("CurrentHash is empty") + } + if fi.Hash != fi.CurrentHash { + t.Errorf("Hash = %q != CurrentHash = %q", fi.Hash, fi.CurrentHash) + } + if fi.Updated == "" { + t.Error("Updated is empty") + } + if fi.DiskModTime == "" { + t.Error("DiskModTime is empty") + } + if !fi.BlobStored { + t.Error("BlobStored = false, want true") + } + if fi.Mode != "0644" { + t.Errorf("Mode = %q, want %q", fi.Mode, "0644") + } +} + +func TestInfoModifiedFile(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + filePath := filepath.Join(root, "hello.txt") + if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := g.Add([]string{filePath}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Modify the file. + if err := os.WriteFile(filePath, []byte("changed\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + fi, err := g.Info(filePath) + if err != nil { + t.Fatalf("Info: %v", err) + } + + if fi.State != "modified" { + t.Errorf("State = %q, want %q", fi.State, "modified") + } + if fi.CurrentHash == fi.Hash { + t.Error("CurrentHash should differ from Hash after modification") + } +} + +func TestInfoMissingFile(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + filePath := filepath.Join(root, "hello.txt") + if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := g.Add([]string{filePath}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Remove the file. + if err := os.Remove(filePath); err != nil { + t.Fatalf("Remove: %v", err) + } + + fi, err := g.Info(filePath) + if err != nil { + t.Fatalf("Info: %v", err) + } + + if fi.State != "missing" { + t.Errorf("State = %q, want %q", fi.State, "missing") + } + if fi.DiskModTime != "" { + t.Errorf("DiskModTime = %q, want empty for missing file", fi.DiskModTime) + } +} + +func TestInfoUntracked(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + filePath := filepath.Join(root, "nope.txt") + if err := os.WriteFile(filePath, []byte("nope\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err = g.Info(filePath) + if err == nil { + t.Fatal("Info should fail for untracked file") + } +} + +func TestInfoSymlink(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + target := filepath.Join(root, "target.txt") + if err := os.WriteFile(target, []byte("target\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + linkPath := filepath.Join(root, "link.txt") + if err := os.Symlink(target, linkPath); err != nil { + t.Fatalf("Symlink: %v", err) + } + + if err := g.Add([]string{linkPath}); err != nil { + t.Fatalf("Add: %v", err) + } + + fi, err := g.Info(linkPath) + if err != nil { + t.Fatalf("Info: %v", err) + } + + if fi.Type != "link" { + t.Errorf("Type = %q, want %q", fi.Type, "link") + } + if fi.State != "ok" { + t.Errorf("State = %q, want %q", fi.State, "ok") + } + if fi.Target != target { + t.Errorf("Target = %q, want %q", fi.Target, target) + } +}