Files
sgard/PROJECT_PLAN.md
Kyle Isom e37e788885 Step 32: Phase 5 polish.
E2e test covering targeting labels through push/pull cycle. Updated
README with targeting docs and commands. All project docs updated.
Phase 5 complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:57:59 -07:00

16 KiB

PROJECT_PLAN.md

Implementation plan for sgard. See ARCHITECTURE.md for design details.

Step 1: Project Scaffolding

Remove old C++ source files and set up the Go project.

  • Remove old files: sgard.cc, proto/, CMakeLists.txt, scripts/, .clang-format, .clang-tidy, .idea/, .trunk/
  • go mod init github.com/kisom/sgard
  • Add dependencies: gopkg.in/yaml.v3, github.com/spf13/cobra
  • Create directory structure: cmd/sgard/, manifest/, store/, garden/
  • Set up cmd/sgard/main.go with cobra root command and --repo persistent flag
  • Update CLAUDE.md to reflect Go project
  • Verify: go build ./... compiles clean

Step 2: Manifest Package

Can be done in parallel with Step 3.

  • manifest/manifest.go: Manifest and Entry structs with YAML tags
    • Entry types: file, directory, link
    • Mode as string type to avoid YAML octal coercion
    • Per-file updated timestamp
  • manifest/manifest.go: Load(path) and Save(path) functions
    • Save uses atomic write (write to .tmp, rename)
  • manifest/manifest_test.go: round-trip marshal/unmarshal, atomic save, entry type validation

Step 3: Store Package

Can be done in parallel with Step 2.

  • store/store.go: Store struct with root path
  • store/store.go: Write(data) (hash, error) — hash content, write to blobs/XX/YY/<hash>
  • store/store.go: Read(hash) ([]byte, error) — read blob by hash
  • store/store.go: Exists(hash) bool — check if blob exists
  • store/store.go: Delete(hash) error — remove a blob
  • store/store_test.go: write/read round-trip, integrity check, missing blob error

Step 4: Garden Core — Init and Add

Depends on Steps 2 and 3.

  • garden/hasher.go: HashFile(path) (string, error) — SHA-256 of a file
  • garden/garden.go: Garden struct tying manifest + store + root path
  • garden/garden.go: Open(root) (*Garden, error) — load existing repo
  • garden/garden.go: Init(root) (*Garden, error) — create new repo (dirs + empty manifest)
  • garden/garden.go: Add(paths []string) error — hash files, store blobs, add manifest entries
  • garden/garden_test.go: init creates correct structure, add stores blob and updates manifest
  • Wire up CLI: cmd/sgard/init.go, cmd/sgard/add.go
  • Verify: go build ./cmd/sgard && ./sgard init && ./sgard add ~/.bashrc

Step 5: Checkpoint and Status

Depends on Step 4.

  • garden/garden.go: Checkpoint(message string) error — re-hash all tracked files, store changed blobs, update manifest timestamps
  • garden/garden.go: Status() ([]FileStatus, error) — compare current hashes to manifest; report modified/missing/ok
  • garden/garden_test.go: checkpoint detects changed files, status reports correctly
  • Wire up CLI: cmd/sgard/checkpoint.go, cmd/sgard/status.go

Step 6: Restore

Depends on Step 5.

  • garden/garden.go: Restore(paths []string, force bool, confirm func) error
    • Restore all files if paths is empty, otherwise just the specified paths
    • Timestamp comparison: skip prompt if manifest updated is newer than file mtime
    • Prompt user if file on disk is newer or times match (unless --force)
    • Create parent directories as needed
    • Recreate symlinks for link type entries
    • Set file permissions from manifest mode
  • garden/garden_test.go: restore writes correct content, respects permissions, handles symlinks
  • Wire up CLI: cmd/sgard/restore.go

Step 7: Remaining Commands

These can be done in parallel with each other.

  • garden/remove.go: Remove(paths []string) error — remove manifest entries
  • garden/verify.go: Verify() ([]VerifyResult, error) — check blobs against manifest hashes
  • garden/list.go: List() []Entry — return all manifest entries
  • garden/diff.go: Diff(path string) (string, error) — diff stored blob vs current file
  • Wire up CLI: cmd/sgard/remove.go, cmd/sgard/verify.go, cmd/sgard/list.go, cmd/sgard/diff.go
  • Tests for each

Step 8: Polish

  • Lint setup (golangci-lint config)
  • Clock abstraction: inject jonboulle/clockwork into Garden for deterministic timestamp tests
  • End-to-end test: init → add → checkpoint → modify file → status → restore → verify
  • Ensure go vet ./... and go test ./... pass clean
  • Update CLAUDE.md, ARCHITECTURE.md, PROGRESS.md

Phase 2: gRPC Remote Sync

Step 9: Proto Definitions + Code Gen

  • Write proto/sgard/v1/sgard.proto — 5 RPCs (PushManifest, PushBlobs, PullManifest, PullBlobs, Prune), all messages
  • Add Makefile target for protoc code generation
  • Add grpc, protobuf, x/crypto deps to go.mod
  • Update flake.nix devShell with protoc tools
  • Verify: go build ./sgardpb compiles

Step 10: Garden Accessor Methods

Can be done in parallel with Step 11.

  • garden/garden.go: GetManifest(), BlobExists(), ReadBlob(), WriteBlob(), ReplaceManifest()
  • Tests for each accessor
  • Verify: go test ./garden/...

Step 11: Proto-Manifest Conversion

Can be done in parallel with Step 10.

  • server/convert.go: ManifestToProto, ProtoToManifest, entry helpers
  • server/convert_test.go: round-trip test
  • Verify: go test ./server/...

Step 12: Server Implementation (No Auth)

Depends on Steps 9, 10, 11.

  • server/server.go: Server struct with RWMutex, 5 RPC handlers (+ Prune)
  • PushManifest: timestamp compare, compute missing blobs
  • PushBlobs: receive stream, write to store, replace manifest
  • PullManifest: return manifest
  • PullBlobs: stream requested blobs (64 KiB chunks)
  • Prune: remove orphaned blobs (added store.List + garden.ListBlobs/DeleteBlob)
  • server/server_test.go: in-process test with bufconn, push+pull+prune

Step 12b: Directory Recursion and Mirror Command

  • garden/garden.go: Add recurses directories — walk all files/symlinks, add each as its own entry
  • garden/mirror.go: MirrorUp(paths []string) error — walk directory, add new files, remove entries for files gone from disk, re-hash changed
  • garden/mirror.go: MirrorDown(paths []string, force bool, confirm func(string) bool) error — restore all tracked files under path, delete anything not in manifest
  • garden/mirror_test.go: tests for recursive add, mirror up (detects new/removed), mirror down (cleans extras)
  • cmd/sgard/mirror.go: sgard mirror up <path>, sgard mirror down <path> [--force]
  • Update existing add tests for directory recursion

Step 13: Client Library (No Auth)

Depends on Step 12.

  • client/client.go: Client struct, Push(), Pull(), Prune() methods
  • client/client_test.go: integration tests (push+pull cycle, server newer, up-to-date, prune)

Step 14: SSH Key Auth

  • server/auth.go: AuthInterceptor, parse authorized_keys, verify SSH signatures
  • client/auth.go: LoadSigner (ssh-agent or key file), SSHCredentials (PerRPCCredentials)
  • server/auth_test.go: valid key, reject unauthenticated, reject unauthorized key, reject expired timestamp
  • client/auth_test.go: metadata generation, no-transport-security
  • Integration tests: authenticated push/pull succeeds, unauthenticated is rejected

Step 15: CLI Wiring + Prune

Depends on Steps 13, 14.

  • garden/prune.go: Prune() (int, error) — collect referenced hashes, delete orphaned blobs
  • garden/prune_test.go: prune removes orphaned, keeps referenced
  • server/server.go: Prune RPC (done in Step 12)
  • proto/sgard/v1/sgard.proto: Prune RPC (done in Step 9)
  • client/client.go: Prune() method (done in Step 13)
  • cmd/sgard/prune.go: local prune; with --remote prunes remote instead
  • cmd/sgard/main.go: add --remote, --ssh-key persistent flags, resolveRemote()
  • cmd/sgard/push.go, cmd/sgard/pull.go
  • cmd/sgardd/main.go: flags, garden open, auth interceptor, gRPC serve
  • Verify: both binaries compile

Step 16: Polish + Release

  • Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
  • Update flake.nix (add sgardd, updated vendorHash)
  • Update .goreleaser.yaml (add sgardd build)
  • E2e integration test: init two repos, push from one, pull into other (with auth)
  • Verify: all tests pass, full push/pull cycle works

Phase 3: Encryption

Step 17: Encryption Core (Passphrase Only)

  • manifest/manifest.go: add Encrypted, PlaintextHash fields to Entry; add Encryption section with KekSlots map to Manifest
  • garden/encrypt.go: EncryptInit(passphrase string) error — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section
  • garden/encrypt.go: UnlockDEK(prompt) error — read slots, try passphrase, unwrap DEK; cache in memory for command duration
  • garden/encrypt.go: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open)
  • garden/garden.go: modify Add to accept encrypt flag — encrypt blob before storing, set encrypted: true and plaintext_hash on entry
  • garden/garden.go: modify Checkpoint to re-encrypt changed encrypted entries (compares plaintext_hash)
  • garden/garden.go: modify Restore to decrypt encrypted blobs before writing
  • garden/diff.go: modify Diff to decrypt stored blob before diffing
  • garden/garden.go: modify Status to use plaintext_hash for encrypted entries
  • Tests: 10 encryption tests (init, persist, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK)
  • Verify: go test ./... && go vet ./... && golangci-lint run ./...

Step 18: FIDO2 Support

Depends on Step 17.

  • garden/encrypt_fido2.go: FIDO2Device interface, AddFIDO2Slot, unlockFIDO2, defaultFIDO2Label
  • garden/encrypt.go: UnlockDEK tries fido2/* slots first (credential_id matching), falls back to passphrase
  • garden/encrypt_fido2_test.go: mock FIDO2 device, 6 tests (add slot, duplicate rejected, unlock via FIDO2, fallback to passphrase, persistence, encrypted round-trip with FIDO2)
  • Verify: go test ./... && go vet ./... && golangci-lint run ./...

Step 19: Encryption CLI + Slot Management

Depends on Steps 17, 18.

  • cmd/sgard/encrypt.go: sgard encrypt init [--fido2], add-fido2 [--label], remove-slot, list-slots, change-passphrase
  • garden/encrypt.go: RemoveSlot, ListSlots, ChangePassphrase methods
  • cmd/sgard/add.go: add --encrypt flag with passphrase prompt
  • Update proto: add encrypted, plaintext_hash to ManifestEntry; add KekSlot, Encryption messages, encryption field on Manifest
  • Update server/convert.go: full encryption section conversion (Encryption, KekSlot)
  • Verify: both binaries compile, go test ./..., lint clean

Step 20: Encryption Polish + Release

  • E2e test: full encryption lifecycle (init, add encrypted+plaintext, checkpoint, modify, status, restore, verify, diff, slot management, passphrase change)
  • Update ARCHITECTURE.md, README.md, CLAUDE.md
  • Update flake.nix vendorHash
  • Verify: all tests pass, lint clean

Future Steps (Not Phase 3)

Phase 4: Hardening + Completeness

Step 21: Lock/Unlock Toggle Commands

  • garden/lock.go: Lock(paths), Unlock(paths) — toggle locked flag on existing entries
  • cmd/sgard/lock.go: sgard lock <path>..., sgard unlock <path>...
  • Tests: lock/unlock existing entry, persist, error on untracked, checkpoint/status behavior changes (6 tests)

Step 22: Shell Completion

  • Cobra provides built-in sgard completion for bash, zsh, fish, powershell — no code needed
  • README updated with shell completion installation instructions

Step 23: TLS Transport for sgardd

  • cmd/sgardd/main.go: add --tls-cert, --tls-key flags
  • Server uses credentials.NewTLS() when cert/key provided, insecure otherwise
  • Client: add --tls flag and --tls-ca for custom CA
  • Update cmd/sgard/main.go and dialRemote() for TLS
  • Tests: TLS connection with self-signed cert (push/pull cycle, reject untrusted client)
  • Update ARCHITECTURE.md and README.md

Step 24: DEK Rotation

  • garden/encrypt.go: RotateDEK(promptPassphrase, fido2Device) — generate new DEK, re-encrypt all encrypted blobs, re-wrap with all existing KEK slots
  • cmd/sgard/encrypt.go: sgard encrypt rotate-dek
  • Tests: rotate DEK, verify decryption, verify plaintext untouched, FIDO2 re-wrap, requires-unlock (4 tests)

Step 25: Real FIDO2 Hardware Binding

  • Evaluate approach: go-libfido2 CGo bindings (keys-pub/go-libfido2 v1.5.3)
  • garden/fido2_hardware.go: HardwareFIDO2 implementing FIDO2Device via libfido2 (//go:build fido2)
  • garden/fido2_nohardware.go: stub returning nil (//go:build !fido2)
  • cmd/sgard/fido2.go: unlockDEK helper, --fido2-pin flag
  • cmd/sgard/encrypt.go: add-fido2 uses real hardware, encrypt init --fido2 registers slot, all unlock calls use FIDO2-first resolution
  • flake.nix: sgard-fido2 package variant, libfido2+pkg-config in devShell
  • Tests: existing mock-based tests still pass; hardware tests require manual testing with a FIDO2 key

Step 26: Test Cleanup

  • Standardize all test calls — already use AddOptions{} struct consistently (no legacy variadic patterns found)
  • Ensure all tests use t.TempDir() consistently (audited, no os.MkdirTemp/ioutil.Temp usage)
  • Review lint config — added copyloopvar, durationcheck, makezero, nilerr, bodyclose linters
  • Verify test coverage — added 3 tests: encrypted+locked, dir-only+locked, lock/unlock toggle on encrypted
  • Fix stale API signatures in ARCHITECTURE.md (Add, Lock, Unlock, RotateDEK, HasEncryption, NeedsDEK)

Step 27: Phase 4 Polish + Release

  • Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md)
  • Update flake.nix vendorHash (done in Step 25)
  • .goreleaser.yaml — no changes needed (CGO_ENABLED=0 is correct for release binaries)
  • E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull
  • Verify: all tests pass, lint clean, both binaries compile

Phase 5: Per-Machine Targeting

Step 28: Machine Identity + Targeting Core

  • manifest/manifest.go: add Only []string and Never []string to Entry
  • garden/identity.go: Identity() returns machine label set
  • garden/targeting.go: EntryApplies(entry, labels) match logic
  • garden/tags.go: LoadTags, SaveTag, RemoveTag for <repo>/tags
  • garden/garden.go: Init appends tags to .gitignore
  • Tests: 13 tests (identity, tags, matching: only, never, both-error, hostname, os, arch, tag, case-insensitive, multiple)

Step 29: Operations Respect Targeting

  • Checkpoint skips entries where !EntryApplies
  • Restore skips entries where !EntryApplies
  • Status reports skipped for non-matching entries
  • Add accepts Only/Never in AddOptions, propagated through addEntry
  • Tests: 6 tests (checkpoint skip/process, status skipped, restore skip, add with only/never)

Step 30: Targeting CLI Commands

  • cmd/sgard/tag.go: tag add/remove/list
  • cmd/sgard/identity.go: identity command
  • cmd/sgard/add.go: --only/--never flags
  • cmd/sgard/target.go: target command with --only/--never/--clear
  • garden/target.go: SetTargeting method

Step 31: Proto + Sync Update

  • proto/sgard/v1/sgard.proto: only/never fields on ManifestEntry
  • server/convert.go: updated conversion
  • Regenerated proto
  • Tests: targeting round-trip test

Step 32: Phase 5 Polish

  • Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
  • E2e test: push/pull with targeting labels, restore respects targeting
  • Verify: all tests pass, lint clean, both binaries compile

Phase 6: Manifest Signing

(To be planned — requires trust model design)