Step 26: Test cleanup.
Tightened lint config (added copyloopvar, durationcheck, makezero,
nilerr, bodyclose). Added 3 combo tests: encrypted+locked files,
dir-only+locked entries, lock/unlock toggle on encrypted entries.
Fixed stale API signatures in ARCHITECTURE.md. All tests already
used t.TempDir() and AddOptions{} consistently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,11 @@ linters:
|
|||||||
- unused
|
- unused
|
||||||
- errorlint
|
- errorlint
|
||||||
- staticcheck
|
- staticcheck
|
||||||
|
- copyloopvar
|
||||||
|
- durationcheck
|
||||||
|
- makezero
|
||||||
|
- nilerr
|
||||||
|
- bodyclose
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
errcheck:
|
errcheck:
|
||||||
|
|||||||
@@ -687,7 +687,7 @@ type Garden struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Local operations
|
// 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) Remove(paths []string) error
|
||||||
func (g *Garden) Checkpoint(message string) error
|
func (g *Garden) Checkpoint(message string) error
|
||||||
func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) 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) Prune() (int, error)
|
||||||
func (g *Garden) MirrorUp(paths []string) error
|
func (g *Garden) MirrorUp(paths []string) error
|
||||||
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) 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
|
// Encryption
|
||||||
func (g *Garden) EncryptInit(passphrase string) error
|
func (g *Garden) EncryptInit(passphrase string) error
|
||||||
func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) 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) AddFIDO2Slot(device FIDO2Device, label string) error
|
||||||
func (g *Garden) RemoveSlot(name string) error
|
func (g *Garden) RemoveSlot(name string) error
|
||||||
func (g *Garden) ListSlots() map[string]string
|
func (g *Garden) ListSlots() map[string]string
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## 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
|
**Last updated:** 2026-03-24
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Step 26: Test Cleanup.
|
Step 27: Phase 4 Polish + Release.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## 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 | 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 | 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 | 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. |
|
||||||
|
|||||||
@@ -262,10 +262,11 @@ Depends on Steps 17, 18.
|
|||||||
|
|
||||||
### Step 26: Test Cleanup
|
### Step 26: Test Cleanup
|
||||||
|
|
||||||
- [ ] Standardize all test calls to use `AddOptions{}` struct (remove any legacy variadic patterns)
|
- [x] Standardize all test calls — already use `AddOptions{}` struct consistently (no legacy variadic patterns found)
|
||||||
- [ ] Ensure all tests use `t.TempDir()` consistently
|
- [x] Ensure all tests use `t.TempDir()` consistently (audited, no `os.MkdirTemp`/`ioutil.Temp` usage)
|
||||||
- [ ] Review lint config, tighten if possible
|
- [x] Review lint config — added copyloopvar, durationcheck, makezero, nilerr, bodyclose linters
|
||||||
- [ ] Verify test coverage for lock/unlock, encrypted locked files, dir-only locked entries
|
- [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
|
### Step 27: Phase 4 Polish + Release
|
||||||
|
|
||||||
|
|||||||
192
garden/locked_combo_test.go
Normal file
192
garden/locked_combo_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user