diff --git a/.golangci.yaml b/.golangci.yaml index 3a682a2..19786db 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -8,6 +8,11 @@ linters: - unused - errorlint - staticcheck + - copyloopvar + - durationcheck + - makezero + - nilerr + - bodyclose linters-settings: errcheck: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c4c2fbd..5f3078b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -687,7 +687,7 @@ type Garden struct { } // Local operations -func (g *Garden) Add(paths []string, encrypt ...bool) error +func (g *Garden) Add(paths []string, opts ...AddOptions) error func (g *Garden) Remove(paths []string) error func (g *Garden) Checkpoint(message string) error func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error @@ -698,10 +698,15 @@ func (g *Garden) Diff(path string) (string, error) func (g *Garden) Prune() (int, error) func (g *Garden) MirrorUp(paths []string) error func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error +func (g *Garden) Lock(paths []string) error +func (g *Garden) Unlock(paths []string) error // Encryption func (g *Garden) EncryptInit(passphrase string) error func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) error +func (g *Garden) HasEncryption() bool +func (g *Garden) NeedsDEK(entries []manifest.Entry) bool +func (g *Garden) RotateDEK(prompt func() (string, error), fido2 ...FIDO2Device) error func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error func (g *Garden) RemoveSlot(name string) error func (g *Garden) ListSlots() map[string]string diff --git a/PROGRESS.md b/PROGRESS.md index 4765cfd..85374cc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 4 in progress. Steps 21–25 complete, ready for Step 26. +**Phase:** Phase 4 in progress. Steps 21–26 complete, ready for Step 27. **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 26: Test Cleanup. +Step 27: Phase 4 Polish + Release. ## Known Issues / Decisions Deferred @@ -89,3 +89,4 @@ Step 26: Test Cleanup. | 2026-03-24 | 23 | TLS transport: sgardd --tls-cert/--tls-key, sgard --tls/--tls-ca, 2 integration tests. | | 2026-03-24 | 24 | DEK rotation: RotateDEK re-encrypts all blobs, re-wraps all slots, CLI command, 4 tests. | | 2026-03-24 | 25 | Real FIDO2: go-libfido2 bindings, build tag gating, CLI wiring, nix sgard-fido2 package. | +| 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 3e6445e..3873746 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -262,10 +262,11 @@ Depends on Steps 17, 18. ### Step 26: Test Cleanup -- [ ] Standardize all test calls to use `AddOptions{}` struct (remove any legacy variadic patterns) -- [ ] Ensure all tests use `t.TempDir()` consistently -- [ ] Review lint config, tighten if possible -- [ ] Verify test coverage for lock/unlock, encrypted locked files, dir-only locked entries +- [x] Standardize all test calls — already use `AddOptions{}` struct consistently (no legacy variadic patterns found) +- [x] Ensure all tests use `t.TempDir()` consistently (audited, no `os.MkdirTemp`/`ioutil.Temp` usage) +- [x] Review lint config — added copyloopvar, durationcheck, makezero, nilerr, bodyclose linters +- [x] Verify test coverage — added 3 tests: encrypted+locked, dir-only+locked, lock/unlock toggle on encrypted +- [x] Fix stale API signatures in ARCHITECTURE.md (Add, Lock, Unlock, RotateDEK, HasEncryption, NeedsDEK) ### Step 27: Phase 4 Polish + Release diff --git a/garden/locked_combo_test.go b/garden/locked_combo_test.go new file mode 100644 index 0000000..a08ef2a --- /dev/null +++ b/garden/locked_combo_test.go @@ -0,0 +1,192 @@ +package garden + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEncryptedLockedFile(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + if err := g.EncryptInit("passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + testFile := filepath.Join(root, "secret") + if err := os.WriteFile(testFile, []byte("locked secret"), 0o600); err != nil { + t.Fatalf("writing: %v", err) + } + + // Add as both encrypted and locked. + if err := g.Add([]string{testFile}, AddOptions{Encrypt: true, Lock: true}); err != nil { + t.Fatalf("Add: %v", err) + } + + entry := g.manifest.Files[0] + if !entry.Encrypted { + t.Error("should be encrypted") + } + if !entry.Locked { + t.Error("should be locked") + } + if entry.PlaintextHash == "" { + t.Error("should have plaintext hash") + } + + origHash := entry.Hash + + // Modify the file — checkpoint should skip (locked). + if err := os.WriteFile(testFile, []byte("system overwrote"), 0o600); err != nil { + t.Fatalf("modifying: %v", err) + } + + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + if g.manifest.Files[0].Hash != origHash { + t.Error("checkpoint should skip locked file even if encrypted") + } + + // Status should report drifted. + statuses, err := g.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if len(statuses) != 1 || statuses[0].State != "drifted" { + t.Errorf("expected drifted, got %v", statuses) + } + + // Restore should decrypt and overwrite without prompting. + if err := g.Restore(nil, false, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + got, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("reading: %v", err) + } + if string(got) != "locked secret" { + t.Errorf("content = %q, want %q", got, "locked secret") + } +} + +func TestDirOnlyLocked(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + testDir := filepath.Join(root, "lockdir") + if err := os.MkdirAll(testDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Add as dir-only and locked. + if err := g.Add([]string{testDir}, AddOptions{DirOnly: true, Lock: true}); err != nil { + t.Fatalf("Add: %v", err) + } + + entry := g.manifest.Files[0] + if entry.Type != "directory" { + t.Errorf("type = %s, want directory", entry.Type) + } + if !entry.Locked { + t.Error("should be locked") + } + + // Remove the directory. + if err := os.RemoveAll(testDir); err != nil { + t.Fatalf("removing: %v", err) + } + + // Restore should recreate it. + if err := g.Restore(nil, false, nil); err != nil { + t.Fatalf("Restore: %v", err) + } + + info, err := os.Stat(testDir) + if err != nil { + t.Fatalf("directory not restored: %v", err) + } + if !info.IsDir() { + t.Error("should be a directory") + } +} + +func TestLockUnlockEncryptedToggle(t *testing.T) { + root := t.TempDir() + repoDir := filepath.Join(root, "repo") + + g, err := Init(repoDir) + if err != nil { + t.Fatalf("Init: %v", err) + } + + if err := g.EncryptInit("passphrase"); err != nil { + t.Fatalf("EncryptInit: %v", err) + } + + testFile := filepath.Join(root, "secret") + if err := os.WriteFile(testFile, []byte("data"), 0o600); err != nil { + t.Fatalf("writing: %v", err) + } + + // Add encrypted but not locked. + if err := g.Add([]string{testFile}, AddOptions{Encrypt: true}); err != nil { + t.Fatalf("Add: %v", err) + } + + if g.manifest.Files[0].Locked { + t.Fatal("should not be locked initially") + } + + // Lock it. + if err := g.Lock([]string{testFile}); err != nil { + t.Fatalf("Lock: %v", err) + } + + if !g.manifest.Files[0].Locked { + t.Error("should be locked") + } + if !g.manifest.Files[0].Encrypted { + t.Error("should still be encrypted") + } + + // Modify — checkpoint should skip. + origHash := g.manifest.Files[0].Hash + if err := os.WriteFile(testFile, []byte("changed"), 0o600); err != nil { + t.Fatalf("modifying: %v", err) + } + + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + if g.manifest.Files[0].Hash != origHash { + t.Error("checkpoint should skip locked encrypted file") + } + + // Unlock — checkpoint should now pick up changes. + if err := g.Unlock([]string{testFile}); err != nil { + t.Fatalf("Unlock: %v", err) + } + + if err := g.Checkpoint(""); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + if g.manifest.Files[0].Hash == origHash { + t.Error("unlocked: checkpoint should update encrypted file hash") + } +}