diff --git a/cmd/sgard/verify.go b/cmd/sgard/verify.go new file mode 100644 index 0000000..9f514c8 --- /dev/null +++ b/cmd/sgard/verify.go @@ -0,0 +1,43 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var verifyCmd = &cobra.Command{ + Use: "verify", + Short: "Check all blobs against manifest hashes", + RunE: func(cmd *cobra.Command, args []string) error { + g, err := garden.Open(repoFlag) + if err != nil { + return err + } + + results, err := g.Verify() + if err != nil { + return err + } + + allOK := true + for _, r := range results { + fmt.Printf("%-14s %s\n", r.Detail, r.Path) + if !r.OK { + allOK = false + } + } + + if !allOK { + return errors.New("verification failed: one or more blobs are corrupt or missing") + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(verifyCmd) +} diff --git a/garden/verify.go b/garden/verify.go new file mode 100644 index 0000000..d2b1f4f --- /dev/null +++ b/garden/verify.go @@ -0,0 +1,61 @@ +package garden + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// VerifyResult reports the integrity of a single tracked blob. +type VerifyResult struct { + Path string // tilde path from manifest + OK bool + Detail string // e.g. "ok", "hash mismatch", "blob missing" +} + +// Verify checks every file entry in the manifest against the blob store. +// It confirms that the blob exists and that its content still matches +// the recorded hash. Directories and symlinks are skipped because they +// have no blobs. +func (g *Garden) Verify() ([]VerifyResult, error) { + var results []VerifyResult + + for _, entry := range g.manifest.Files { + if entry.Type != "file" { + continue + } + + if !g.store.Exists(entry.Hash) { + results = append(results, VerifyResult{ + Path: entry.Path, + OK: false, + Detail: "blob missing", + }) + continue + } + + data, err := g.store.Read(entry.Hash) + if err != nil { + return nil, fmt.Errorf("reading blob for %s: %w", entry.Path, err) + } + + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + + if got != entry.Hash { + results = append(results, VerifyResult{ + Path: entry.Path, + OK: false, + Detail: "hash mismatch", + }) + } else { + results = append(results, VerifyResult{ + Path: entry.Path, + OK: true, + Detail: "ok", + }) + } + } + + return results, nil +} diff --git a/garden/verify_test.go b/garden/verify_test.go new file mode 100644 index 0000000..b480cee --- /dev/null +++ b/garden/verify_test.go @@ -0,0 +1,126 @@ +package garden + +import ( + "os" + "path/filepath" + "testing" +) + +func TestVerifyAllOK(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + testFile := filepath.Join(root, "testfile") + if err := os.WriteFile(testFile, []byte("hello world\n"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + results, err := g.Verify() + if err != nil { + t.Fatalf("Verify: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + if !results[0].OK { + t.Errorf("expected OK, got %s", results[0].Detail) + } + if results[0].Detail != "ok" { + t.Errorf("expected detail 'ok', got %q", results[0].Detail) + } +} + +func TestVerifyHashMismatch(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + testFile := filepath.Join(root, "testfile") + if err := os.WriteFile(testFile, []byte("hello world\n"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Corrupt the blob on disk. + hash := g.manifest.Files[0].Hash + blobPath := filepath.Join(repoDir, "blobs", hash[:2], hash[2:4], hash) + if err := os.WriteFile(blobPath, []byte("corrupted data"), 0o644); err != nil { + t.Fatalf("corrupting blob: %v", err) + } + + results, err := g.Verify() + if err != nil { + t.Fatalf("Verify: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + if results[0].OK { + t.Error("expected not OK for corrupted blob") + } + if results[0].Detail != "hash mismatch" { + t.Errorf("expected detail 'hash mismatch', got %q", results[0].Detail) + } +} + +func TestVerifyBlobMissing(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + testFile := filepath.Join(root, "testfile") + if err := os.WriteFile(testFile, []byte("hello world\n"), 0o644); err != nil { + t.Fatalf("writing test file: %v", err) + } + + if err := g.Add([]string{testFile}); err != nil { + t.Fatalf("Add: %v", err) + } + + // Delete the blob from disk. + hash := g.manifest.Files[0].Hash + blobPath := filepath.Join(repoDir, "blobs", hash[:2], hash[2:4], hash) + if err := os.Remove(blobPath); err != nil { + t.Fatalf("removing blob: %v", err) + } + + results, err := g.Verify() + if err != nil { + t.Fatalf("Verify: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + if results[0].OK { + t.Error("expected not OK for missing blob") + } + if results[0].Detail != "blob missing" { + t.Errorf("expected detail 'blob missing', got %q", results[0].Detail) + } +}