56 Commits

Author SHA1 Message Date
7713d071c2 Make add idempotent: skip already-tracked files instead of erroring.
Enables glob workflows like `sgard add ~/.config/mcp/services/*` to
pick up new files without failing on ones already tracked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:52:37 -07:00
de5759ac77 Add file exclusion support (sgard exclude/include).
Paths added to the manifest's exclude list are skipped during Add,
MirrorUp, and MirrorDown directory walks. Excluding a directory
excludes everything under it. Already-tracked entries matching a
new exclusion are removed from the manifest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:25:42 -07:00
b9b9082008 Bump VERSION to 3.1.7.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:57:26 -07:00
bd54491c1d Pull auto-inits repo, restores files, and add -r global shorthand.
pull now works on a fresh machine: inits ~/.sgard if missing, always
pulls when local manifest is empty, and restores all files after
downloading blobs. -r is now a global shorthand for --remote; list
uses resolveRemoteConfig() like prune.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:57:16 -07:00
57d252cee4 Bump VERSION to 3.1.6.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:27:17 -07:00
78030230c5 Update docs for VERSION file and build versioning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:27:12 -07:00
adfb087037 Derive build version from git tags via VERSION file.
flake.nix reads from VERSION instead of hardcoding; Makefile gains
a version target that syncs VERSION from the latest git tag and
injects it into go build ldflags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:26:16 -07:00
5570f82eb4 Add version in flake. 2026-03-26 11:14:28 -07:00
bffe7bde12 Add remote listing support to sgard list via -r flag.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:22:59 -07:00
3e0aabef4a Suppress passphrase echo in terminal prompts.
Use golang.org/x/term.ReadPassword so passphrases are not displayed
while typing, matching ssh behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:49:56 -07:00
4ec71eae00 Deploy sgardd to rift and add persistent remote config.
Deployment: Dockerfile + docker-compose for sgardd on rift behind mc-proxy
(L4 SNI passthrough on :9443, multiplexed with metacrypt gRPC). TLS via
Metacrypt-issued cert, SSH-key auth.

CLI: `sgard remote set/show` saves addr, TLS, and CA path to
<repo>/remote.yaml so push/pull work without flags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:23:21 -07:00
d2161fdadc fix vendorHash for default (non-fido2) package
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:00:58 -07:00
cefa9b7970 Add sgard info command for detailed file inspection.
Shows path, type, status, mode, hash, timestamps, encryption,
lock state, and targeting labels for a single tracked file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:24:23 -07:00
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
2ff9fe2f50 Step 31: Proto + sync update for targeting.
Added only/never repeated string fields to ManifestEntry proto.
Updated convert.go for round-trip. Targeting test in convert_test.go.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:55:02 -07:00
60c0c50acb Step 30: Targeting CLI commands.
tag add/remove/list for machine-local tags. identity prints full label
set. --only/--never flags on add. target command to set/clear targeting
on existing entries. SetTargeting garden method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:53:07 -07:00
d4d1d316db Step 29: Operations respect targeting.
Checkpoint, Restore, and Status now skip entries that don't match the
machine's identity labels. Status reports non-matching as "skipped".
Add accepts Only/Never in AddOptions, propagated through addEntry.
6 tests covering skip/process/skipped-status/restore-skip/add-with.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:51:27 -07:00
589f76c10e Step 28: Machine identity and targeting core.
Entry gains Only/Never fields for per-machine targeting. Machine
identity = short hostname + os:<GOOS> + arch:<GOARCH> + tag:<name>.
Tags stored in local <repo>/tags file (added to .gitignore by init).
EntryApplies() matching: only=any-match, never=no-match, both=error.
13 tests covering matching, identity, tags CRUD, gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:47:02 -07:00
7797de7d48 Plan Phase 5: per-machine targeting with only/never labels.
Machine identity = hostname + os:<GOOS> + arch:<GOARCH> + tag:<name>.
Entry-level only/never fields for selective restore/checkpoint.
Local tags file for machine-specific labels. Steps 28–32 planned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:42:56 -07:00
c8281398d1 Step 27: Phase 4 polish.
E2e integration test covering TLS + encryption + locked files in a
push/pull cycle (integration/phase4_test.go). Final doc updates.
Phase 4 complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:18:42 -07:00
3cac9a3530 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>
2026-03-24 12:45:29 -07:00
490db0599c Step 25: Real FIDO2 hardware key support.
HardwareFIDO2 implements FIDO2Device via go-libfido2 (CGo bindings to
Yubico's libfido2). Gated behind //go:build fido2 tag to keep default
builds CGo-free. Nix flake adds sgard-fido2 package variant.

CLI changes: --fido2-pin flag, unlockDEK helper tries FIDO2 first,
add-fido2/encrypt init --fido2 use real hardware, auto-unlock added
to restore/checkpoint/diff for encrypted entries.

Tested manually: add-fido2, add --encrypt, restore, checkpoint, diff
all work with hardware FIDO2 key (touch-to-unlock, no passphrase).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:40:46 -07:00
5529fff649 Step 24: DEK rotation.
RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
re-wraps with all existing KEK slots (passphrase + FIDO2). CLI wired
as `sgard encrypt rotate-dek`. 4 tests covering rotation, persistence,
FIDO2 re-wrap, and requires-unlock guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:01:57 -07:00
3fabd86150 Step 23: TLS transport for sgardd and sgard client.
Server: --tls-cert/--tls-key flags enable TLS (min TLS 1.2).
Client: --tls enables TLS transport, --tls-ca for custom CA certs.
Two integration tests: push/pull over TLS, reject untrusted client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:57:03 -07:00
c00d9c65c3 Step 22: Shell completion docs for bash, zsh, fish.
Cobra provides built-in sgard completion subcommand — no additional
code needed. README updated with installation instructions for each
shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:10:28 -07:00
d2bba75365 Step 21: Lock/unlock toggle commands.
garden/lock.go: Lock() and Unlock() toggle the locked flag on
existing tracked entries. Errors on untracked paths. Persists
to manifest.

cmd/sgard/lock.go: sgard lock <path>..., sgard unlock <path>...

6 tests: lock/unlock existing entry, persistence, error on untracked,
checkpoint behavior changes after lock, status changes between
drifted and modified after unlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:07:40 -07:00
0cf81ab6a1 Add Phase 4-6 roadmap to ARCHITECTURE.md.
Phase 4: TLS transport, DEK rotation.
Phase 5: Multi-repo + per-machine inclusion.
Phase 6: Manifest signing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:00:13 -07:00
1eb801fe63 Plan Phase 4: lock/unlock, shell completion, TLS, DEK rotation, FIDO2 hardware, test cleanup.
Steps 21-27. Phase 5 (multi-repo + per-machine) and Phase 6
(manifest signing) noted as future.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:57:05 -07:00
11202940c9 Add motivating examples for locked files and --dir to README.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:14:40 -07:00
0929d77e90 Add locked files and directory-only entries.
Locked files (--lock): repo-authoritative entries. Checkpoint skips
them (preserves repo version). Status reports "drifted" instead of
"modified". Restore always overwrites if hash differs, no prompt.
Use case: system-managed files the OS overwrites.

Directory-only entries (--dir): track directory itself without
recursing. Restore ensures directory exists with correct permissions.
Use case: directories that must exist but contents are managed
elsewhere.

Add refactored to use AddOptions struct (Encrypt, Lock, DirOnly)
instead of variadic bools.

Proto: ManifestEntry gains locked field. convert.go updated.
7 new tests. ARCHITECTURE.md and README.md updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:56:57 -07:00
7accc6cac6 Step 20: Encryption polish — e2e test, docs, flake.
E2e encryption test: full lifecycle covering init, add encrypted +
plaintext, checkpoint, modify, status (no DEK needed), re-checkpoint,
restore, verify, re-open with unlock, diff, slot management, passphrase
change, old passphrase rejection.

Docs updated:
- ARCHITECTURE.md: package structure (encrypt.go, encrypt_fido2.go,
  encrypt CLI), Garden struct (dek field, encryption methods), auth.go
  descriptions updated for JWT
- README.md: encryption commands table, encryption section with usage
- CLAUDE.md: added jwt/argon2/chacha20 deps, encryption file mentions

flake.nix: vendorHash updated for new deps.

Phase 3 complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:34:05 -07:00
76a53320c1 Step 19: Encryption CLI, slot management, proto updates.
CLI: sgard encrypt init [--fido2], add-fido2 [--label], remove-slot,
list-slots, change-passphrase. sgard add --encrypt flag with
passphrase prompt for DEK unlock.

Garden: RemoveSlot (refuses last slot), ListSlots, ChangePassphrase
(re-wraps DEK with new passphrase, fresh salt).

Proto: ManifestEntry gains encrypted + plaintext_hash fields. New
KekSlot and Encryption messages. Manifest gains encryption field.

server/convert.go: full round-trip conversion for encryption section
including KekSlot map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:25:20 -07:00
5bb65795c8 Step 18: FIDO2 support with interface and mock.
FIDO2Device interface abstracts hardware interaction (Register, Derive,
Available, MatchesCredential). Real libfido2 implementation deferred;
mock device used for full test coverage.

AddFIDO2Slot: registers FIDO2 credential, derives KEK via HMAC-secret,
wraps DEK, adds fido2/<label> slot to manifest.

UnlockDEK: tries all fido2/* slots first (checks credential_id against
connected device), falls back to passphrase. User never specifies
which method.

6 tests: add slot, reject duplicate, unlock via FIDO2, fallback to
passphrase when device unavailable, slot persistence, encrypted
round-trip unlocked via FIDO2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:15:20 -07:00
3b961b5d8a Step 17: Encryption core — passphrase-only, selective per-file.
Manifest schema: Entry gains Encrypted, PlaintextHash fields.
Manifest gains Encryption section with KekSlots map (passphrase slot
with Argon2id params, salt, and wrapped DEK as base64).

garden/encrypt.go: EncryptInit (generate DEK, wrap with passphrase KEK),
UnlockDEK (derive KEK, unwrap), encryptBlob/decryptBlob using
XChaCha20-Poly1305 with random 24-byte nonces.

Modified operations:
- Add: optional encrypt flag, stores encrypted blob + plaintext_hash
- Checkpoint: detects changes via plaintext_hash, re-encrypts
- Restore: decrypts encrypted blobs before writing
- Diff: decrypts stored blob before comparing
- Status: compares against plaintext_hash for encrypted entries

10 tests covering init, persistence, unlock, add-encrypted, restore
round-trip, checkpoint, status, diff, requires-DEK guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:50:53 -07:00
582f2116d2 Change sgardd default repo path to /srv/sgard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:40:55 -07:00
529d45f8eb Add Phase 3 encryption plan (Steps 17-20) and update progress.
Step 17: Encryption core — Argon2id KEK, XChaCha20 DEK wrapping,
  selective per-file encryption, manifest schema changes.
Step 18: FIDO2 support — hmac-secret slots, credential_id matching,
  automatic unlock resolution.
Step 19: CLI + slot management — encrypt init/add-fido2/remove-slot/
  list-slots/change-passphrase, proto updates.
Step 20: Polish — e2e encrypted push/pull test, doc updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:35:29 -07:00
f6bdb93066 KEK slots: named map with passphrase + fido2/<label> convention.
Slots are a map keyed by user-chosen label. One passphrase slot
(universal fallback), zero or more fido2/<label> slots (default to
hostname, overridable via --label).

FIDO2 slots carry credential_id to match connected devices without
prompting for touch. Unlock tries all fido2/* slots first, falls
back to passphrase.

CLI: add-fido2 [--label], remove-slot, list-slots, change-passphrase.
New FIDO2 slots propagate to server on next push.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:32:55 -07:00
e24b66776c Encryption config lives in the manifest, syncs with push/pull.
Wrapped DEKs and salts stored inline as base64 in the manifest's
encryption section. No separate files (encryption.yaml, salt files,
dek.enc.*) — the manifest is fully self-contained.

Pulling to a new machine gives you everything needed to decrypt.
Server never has the DEK. FIDO2 cross-machine note: device-bound
hmac-secret requires add-fido2 on each machine; passphrase fallback
enables cross-machine decryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:23:25 -07:00
079b235c9d Refine encryption: FIDO2 preferred with passphrase fallback.
Automatic unlock resolution: try FIDO2 first (no typing, just touch),
fall back to passphrase if device not present. User never specifies
which method — sgard reads encryption.yaml and walks sources in order.

encrypt init --fido2 creates both sources (FIDO2 primary + passphrase
fallback) to prevent lockout on FIDO2 key loss. Separate salt files
per KEK source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:18:51 -07:00
4d9e156eea Update encryption design: selective per-file encryption, punt signing.
Encryption is per-file (--encrypt flag on add), not per-repo. A repo
can have a mix of encrypted and plaintext blobs. Commands that only
touch plaintext entries never prompt for the DEK.

Manifest signing deferred — the trust model (which key signs, how do
pulling clients verify across multiple machines) needs proper design.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:06:27 -07:00
c6b92a70b1 Document encryption design in ARCHITECTURE.md.
Two-layer key hierarchy: DEK (random, encrypts blobs) wrapped by
KEK (derived from passphrase via Argon2id or FIDO2 hmac-secret).

XChaCha20-Poly1305 for both blob encryption and DEK wrapping.
Post-encryption hashing (manifest hash = SHA-256 of ciphertext).
Plaintext hash stored separately for efficient status checks.
Multiple KEK sources per repo. Server never sees the DEK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 07:36:44 -07:00
edef642025 Implement JWT token auth with transparent auto-renewal.
Replace per-call SSH signing with a two-layer auth system:

Server: AuthInterceptor verifies JWT tokens (HMAC-SHA256 signed with
repo-local jwt.key). Authenticate RPC accepts SSH-signed challenges
and issues 30-day JWTs. Expired-but-valid tokens return a
ReauthChallenge in error details (server-provided nonce for fast
re-auth). Authenticate RPC is exempt from token requirement.

Client: TokenCredentials replaces SSHCredentials as the primary
PerRPCCredentials. NewWithAuth creates clients with auto-renewal —
EnsureAuth obtains initial token, retryOnAuth catches Unauthenticated
errors and re-authenticates transparently. Token cached at
$XDG_STATE_HOME/sgard/token (0600).

CLI: dialRemote() helper handles token loading, connection setup,
and initial auth. Push/pull/prune commands simplified to use it.

Proto: Added Authenticate RPC, AuthenticateRequest/Response,
ReauthChallenge messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:52:16 -07:00
b7b1b27064 Refine auth flow: server-provided reauth challenge for expired tokens.
Two rejection paths: expired-but-valid tokens get a ReauthChallenge
with a server-generated nonce (fast path, saves a round trip).
Invalid/corrupted tokens get plain Unauthenticated (client falls back
to full self-generated auth flow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:40:26 -07:00
66af104155 Document JWT token auth design in ARCHITECTURE.md.
Two-layer auth: SSH key signing to obtain a 30-day JWT, then
token-based auth for all subsequent requests. Auto-renewal is
transparent — client interceptor catches Unauthenticated, re-signs,
caches new token, retries. Server is stateless (JWT signed with
repo-local secret key). Token cached at $XDG_STATE_HOME/sgard/token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:36:58 -07:00
92d64d5540 Fix doc inconsistencies between README and ARCHITECTURE.
- ARCHITECTURE.md: move mirror/prune to local command table, fix
  remove description (prune cleans blobs, not checkpoint), fix
  Phase 2 section to only list remote commands
- README.md: add --force to mirror down, fix prune --remote
  description, build instructions include both binaries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:26:35 -07:00
5f1bc4e14c Step 16: Polish — docs, flake, goreleaser, e2e test.
Phase 2 complete.

ARCHITECTURE.md: full rewrite covering gRPC protocol, SSH auth,
updated package structure, all Garden methods, design decisions.
README.md: add remote sync section, mirror/prune commands, sgardd usage.
CLAUDE.md: add gRPC/proto/x-crypto deps, server/client/sgardpb packages.
flake.nix: build both sgard + sgardd, updated vendorHash.
goreleaser: add sgardd build target.
E2e test: full push/pull cycle with SSH auth between two clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:10:04 -07:00
94963bb8d6 Step 15: CLI wiring, prune, and sgardd daemon.
Local prune: garden.Prune() removes orphaned blobs. 2 tests.

CLI commands: sgard push, sgard pull (with SSH auth via --ssh-key
or ssh-agent), sgard prune (local by default, remote with --remote).

Server daemon: cmd/sgardd with --listen, --repo, --authorized-keys
flags. Runs gRPC server with optional SSH key auth interceptor.

Root command gains --remote and --ssh-key persistent flags with
resolveRemote() (flag > env > repo/remote file).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:03:51 -07:00
4b841cdd82 Step 14: SSH key auth for gRPC.
Server: AuthInterceptor parses authorized_keys, extracts SSH signature
from gRPC metadata (nonce + timestamp signed by client's SSH key),
verifies against authorized public keys with 5-minute timestamp window.

Client: SSHCredentials implements PerRPCCredentials, signs nonce+timestamp
per request. LoadSigner resolves key from flag, ssh-agent, or default paths.

8 tests: valid auth, reject unauthenticated, reject unauthorized key,
reject expired timestamp, metadata generation, plus 2 integration tests
(authenticated succeeds, unauthenticated rejected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:58:09 -07:00
525c3f0b4f Step 13: Client library with Push, Pull, and Prune.
Client orchestrates the two-step push/pull protocol: manifest exchange
followed by chunked blob streaming. Push detects server-newer (returns
ErrServerNewer) and up-to-date states. Pull computes missing blobs
locally and streams only what's needed. Prune delegates to server RPC.

6 integration tests via in-process bufconn server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:53:03 -07:00
0078b6b0f4 Steps 12 & 12b: gRPC server and directory recursion + mirror.
Step 12: GardenSync gRPC server with 5 RPC handlers — PushManifest
(timestamp comparison, missing blob detection), PushBlobs (chunked
streaming, manifest replacement), PullManifest, PullBlobs, Prune.
Added store.List() and garden.ListBlobs()/DeleteBlob() for prune.
In-process tests via bufconn.

Step 12b: Add now recurses directories (walks files/symlinks, skips
dir entries). Mirror up syncs filesystem → manifest (add new, remove
deleted, rehash changed). Mirror down syncs manifest → filesystem
(restore + delete untracked with optional confirm). 7 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:48:04 -07:00
19217ec216 Merge branch 'worktree-agent-a0166844'
# Conflicts:
#	garden/garden.go
2026-03-23 23:44:30 -07:00
b4bfce1291 Add directory recursion for Add and mirror up/down commands.
Add now recursively walks directories instead of creating a single
"directory" type entry. Extract addEntry helper for reuse. Implement
MirrorUp (sync filesystem state into manifest) and MirrorDown (sync
manifest state to filesystem with untracked file cleanup). Add CLI
mirror command with up/down subcommands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:42:58 -07:00
153cc9c203 Add Step 12b: directory recursion and mirror command.
Add recurses directories. mirror up syncs filesystem -> manifest,
mirror down syncs manifest -> filesystem (exact restore with cleanup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:34:25 -07:00
ebf55bb570 Steps 10 & 11: Garden accessors and proto-manifest conversion.
Step 10: GetManifest, BlobExists, ReadBlob, WriteBlob, ReplaceManifest
accessor methods on Garden. 5 tests.

Step 11: ManifestToProto/ProtoToManifest conversion functions in
server package with time.Time <-> timestamppb handling. Round-trip
test covering all 3 entry types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:25:07 -07:00
34330a35ef Add Garden accessor methods for manifest and blob store access.
Expose GetManifest, BlobExists, ReadBlob, WriteBlob, and
ReplaceManifest on *Garden to support future gRPC and higher-level
operations without breaking encapsulation. Includes 5 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:23:46 -07:00
0113703908 Step 9: Proto definitions and gRPC code generation.
Define GardenSync service with 5 RPCs: PushManifest, PushBlobs,
PullManifest, PullBlobs, Prune. Messages for manifest, entries,
blob chunks (64 KiB streaming), and push/pull protocol flow.

Generated Go code in sgardpb/. Added Makefile proto target, gRPC +
protobuf + x/crypto deps, protoc tools to flake devShell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:12:10 -07:00
83 changed files with 12508 additions and 178 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/sgard /sgard
.claude/
result

View File

@@ -8,6 +8,11 @@ linters:
- unused - unused
- errorlint - errorlint
- staticcheck - staticcheck
- copyloopvar
- durationcheck
- makezero
- nilerr
- bodyclose
linters-settings: linters-settings:
errcheck: errcheck:

View File

@@ -5,7 +5,9 @@ before:
- go mod tidy - go mod tidy
builds: builds:
- main: ./cmd/sgard - id: sgard
main: ./cmd/sgard
binary: sgard
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=0
ldflags: ldflags:
@@ -17,10 +19,22 @@ builds:
- amd64 - amd64
- arm64 - arm64
- id: sgardd
main: ./cmd/sgardd
binary: sgardd
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
archives: archives:
- formats: [binary] - formats: [binary]
name_template: >- name_template: >-
{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}_v{{ .Version }} {{ .Binary }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}_v{{ .Version }}
changelog: changelog:
sort: asc sort: asc

View File

@@ -48,29 +48,48 @@ updated: "2026-03-23T14:30:00Z"
message: "pre-upgrade checkpoint" # optional message: "pre-upgrade checkpoint" # optional
files: files:
- path: ~/.bashrc # original location (default restore target) - path: ~/.bashrc # plaintext file
hash: a1b2c3d4e5f6... # SHA-256 of file contents hash: a1b2c3d4e5f6... # SHA-256 of file contents
type: file # file | directory | link type: file
mode: "0644" # permissions (quoted to avoid YAML coercion) mode: "0644"
updated: "2026-03-23T14:30:00Z" # last checkpoint time for this file
- path: ~/.config/nvim
type: directory
mode: "0755"
updated: "2026-03-23T14:30:00Z" updated: "2026-03-23T14:30:00Z"
# directories have no hash or blob — they're structural entries
- path: ~/.vimrc - path: ~/.vimrc
type: link type: link
target: ~/.config/nvim/init.vim # symlink target target: ~/.config/nvim/init.vim
updated: "2026-03-23T14:30:00Z" updated: "2026-03-23T14:30:00Z"
# links have no hash or blob — just the target path
- path: ~/.ssh/config - path: ~/.ssh/config # encrypted file
hash: d4e5f6a1b2c3... hash: f8e9d0c1... # SHA-256 of encrypted blob
plaintext_hash: e5f6a7... # SHA-256 of plaintext
encrypted: true
type: file type: file
mode: "0600" mode: "0600"
updated: "2026-03-23T14:30:00Z" updated: "2026-03-23T14:30:00Z"
# Encryption config — only present if sgard encrypt init has been run.
# Travels with the manifest so a new machine can decrypt after pull.
# KEK slots are a map keyed by user-chosen label.
encryption:
algorithm: xchacha20-poly1305
kek_slots:
passphrase:
type: passphrase
argon2_time: 3
argon2_memory: 65536
argon2_threads: 4
salt: "base64..."
wrapped_dek: "base64..."
fido2/workstation:
type: fido2
credential_id: "base64..."
salt: "base64..."
wrapped_dek: "base64..."
fido2/laptop:
type: fido2
credential_id: "base64..."
salt: "base64..."
wrapped_dek: "base64..."
``` ```
### Blob Store ### Blob Store
@@ -93,19 +112,25 @@ Properties:
All commands operate on a repository directory (default: `~/.sgard`, override with `--repo`). All commands operate on a repository directory (default: `~/.sgard`, override with `--repo`).
### Phase 1 — Local ### Local
| Command | Description | | Command | Description |
|---|---| |---|---|
| `sgard init [--repo <path>]` | Create a new repository | | `sgard init [--repo <path>]` | Create a new repository |
| `sgard add <path>...` | Track files; copies them into the blob store and adds manifest entries | | `sgard add <path>...` | Track files, directories (recursed), or symlinks |
| `sgard remove <path>...` | Untrack files; removes manifest entries (blobs cleaned up on next checkpoint) | | `sgard remove <path>...` | Untrack files; run `prune` to clean orphaned blobs |
| `sgard checkpoint [-m <message>]` | Re-hash all tracked files, store any changed blobs, update manifest | | `sgard checkpoint [-m <message>]` | Re-hash all tracked files, store changed blobs, update manifest |
| `sgard restore [<path>...] [--force]` | Restore files from manifest to their original locations | | `sgard restore [<path>...] [--force]` | Restore files from manifest to their original locations |
| `sgard status` | Compare current files against manifest: modified, missing, ok | | `sgard status` | Compare current files against manifest: modified, missing, ok |
| `sgard verify` | Check all blobs against manifest hashes (integrity check) | | `sgard verify` | Check all blobs against manifest hashes (integrity check) |
| `sgard info <path>` | Show detailed information about a tracked file |
| `sgard list` | List all tracked files | | `sgard list` | List all tracked files |
| `sgard diff [<path>]` | Show content diff between current file and stored blob | | `sgard diff <path>` | Show content diff between current file and stored blob |
| `sgard prune` | Remove orphaned blobs not referenced by the manifest |
| `sgard mirror up <path>...` | Sync filesystem → manifest (add new, remove deleted, rehash) |
| `sgard mirror down <path>... [--force]` | Sync manifest → filesystem (restore + delete untracked) |
| `sgard exclude <path>... [--list]` | Exclude paths from tracking; `--list` shows current exclusions |
| `sgard include <path>...` | Remove paths from the exclusion list |
**Workflow example:** **Workflow example:**
@@ -123,69 +148,631 @@ sgard checkpoint -m "initial" --repo /mnt/usb/dotfiles
sgard restore --repo /mnt/usb/dotfiles sgard restore --repo /mnt/usb/dotfiles
``` ```
### Phase 2 — Remote (Future) ### Remote
| Command | Description | | Command | Description |
|---|---| |---|---|
| `sgard push` | Push checkpoint to remote gRPC server | | `sgard push` | Push checkpoint to remote gRPC server |
| `sgard pull` | Pull checkpoint from remote gRPC server | | `sgard pull` | Pull checkpoint from remote gRPC server |
| `sgard serve` | Run the gRPC daemon | | `sgard prune` | With `--remote`, prunes orphaned blobs on the server |
| `sgardd` | Run the gRPC sync daemon |
## gRPC Protocol
The GardenSync service uses four RPCs for sync plus one for maintenance:
```
service GardenSync {
rpc PushManifest(PushManifestRequest) returns (PushManifestResponse);
rpc PushBlobs(stream PushBlobsRequest) returns (PushBlobsResponse);
rpc PullManifest(PullManifestRequest) returns (PullManifestResponse);
rpc PullBlobs(PullBlobsRequest) returns (stream PullBlobsResponse);
rpc Prune(PruneRequest) returns (PruneResponse);
}
```
**Push flow:** Client sends manifest → server compares `manifest.Updated`
timestamps → if client newer, server returns list of missing blob hashes →
client streams those blobs (64 KiB chunks) → server replaces its manifest.
**Pull flow:** Client requests server manifest → compares timestamps locally →
if server newer, requests missing blobs → server streams them → client
replaces its manifest.
**Last timestamp wins** for conflict resolution (single-user, personal sync).
## Authentication
Authentication is designed to be transparent — the user never explicitly
logs in or manages credentials. It uses SSH keys they already have.
### Overview
Two mechanisms, layered:
1. **SSH key signing** — used to obtain a token or when no valid token exists
2. **JWT token** — used for all subsequent requests, cached on disk
From the user's perspective, authentication is automatic. The client
handles token acquisition, caching, and renewal without prompting.
### Token-Based Auth (Primary Path)
The server issues signed JWT tokens valid for 30 days. The client caches
the token and attaches it as gRPC metadata on every call.
```
service GardenSync {
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
// ... other RPCs
}
```
**Authenticate RPC:**
- Client sends an SSH-signed challenge (nonce + timestamp + public key)
- Server verifies the signature against its `authorized_keys` file
- Server returns a JWT signed with its own secret key
- JWT claims: public key fingerprint, issued-at, 30-day expiry
**Normal request flow:**
1. Client reads cached token from `$XDG_STATE_HOME/sgard/token`
(falls back to `~/.local/state/sgard/token`)
2. Client attaches token as `x-sgard-auth-token` gRPC metadata
3. Server verifies JWT signature and expiry
4. If valid → request proceeds
**Token rejection — two cases:**
The server distinguishes between an expired-but-previously-valid token
and a completely invalid one:
- **Expired token** (valid signature, known fingerprint still in
authorized_keys, but past expiry): server returns `Unauthenticated`
with a `ReauthChallenge` — a server-generated nonce embedded in the
error details. This is the fast path.
- **Invalid token** (bad signature, unknown fingerprint, corrupted):
server returns a plain `Unauthenticated` with no challenge. The client
falls back to the full Authenticate flow.
**Fast re-auth flow (expired token, transparent to user):**
1. Client sends request with expired token
2. Server returns `Unauthenticated` + `ReauthChallenge{nonce, timestamp}`
3. Client signs the server-provided nonce+timestamp with SSH key
4. Client calls `Authenticate` with the signature
5. Server verifies, issues new JWT
6. Client caches new token to disk
7. Client retries the original request with the new token
This saves a round trip compared to full re-auth — the server provides
the nonce, so the client doesn't need to generate one and hope it's
accepted. The server controls the challenge, which also prevents any
client-side nonce reuse.
**Full auth flow (no valid token, transparent to user):**
1. Client has no cached token or token is completely invalid
2. Client calls `Authenticate` with a self-generated nonce+timestamp,
signed with SSH key
3. Server verifies, issues JWT
4. Client caches token, proceeds with original request
### SSH Key Signing
Used during the `Authenticate` RPC to prove possession of an authorized
SSH private key. The challenge can come from the server (re-auth fast
path) or be generated by the client (initial auth).
**Challenge payload:** `nonce (32 random bytes) || timestamp (big-endian int64)`
**Authenticate RPC request fields:**
- `nonce` — 32-byte nonce (from server's ReauthChallenge or client-generated)
- `timestamp` — Unix seconds
- `signature` — SSH signature over (nonce || timestamp)
- `public_key` — SSH public key in authorized_keys format
**Server verification:**
- Parse public key, check fingerprint against `authorized_keys` file
- Verify SSH signature over the payload
- Check timestamp is within 5-minute window (prevents replay)
### Server-Side Token Management
The server does not store tokens. JWTs are stateless — the server signs
them with a secret key and verifies its own signature on each request.
**Secret key:** Generated on first startup, stored at `<repo>/jwt.key`
(32 random bytes). If the key file is deleted, all outstanding tokens
become invalid and clients re-authenticate automatically.
**No revocation mechanism.** For a single-user personal sync tool,
revocation is unnecessary. Removing a key from `authorized_keys`
prevents new token issuance. Existing tokens expire naturally within
30 days. Deleting `jwt.key` invalidates all tokens immediately.
### Client-Side Token Storage
Token cached at `$XDG_STATE_HOME/sgard/token` (per XDG Base Directory
spec, state is "data that should persist between restarts but isn't
important enough to back up"). Falls back to `~/.local/state/sgard/token`.
The token file contains the raw JWT string. File permissions are set to
`0600`.
### Key Resolution
SSH key resolution order (for initial authentication):
1. `--ssh-key` flag (explicit path to private key)
2. `SGARD_SSH_KEY` environment variable
3. ssh-agent (if `SSH_AUTH_SOCK` is set, uses first available key)
4. Default paths: `~/.ssh/id_ed25519`, `~/.ssh/id_rsa`
## Encryption
sgard supports optional at-rest encryption for individual files.
Encryption is per-file, not per-repo — any file can be marked as
encrypted at add time. A repo may contain a mix of encrypted and
plaintext blobs.
### Key Hierarchy
A two-layer key hierarchy separates the encryption key from the user's
secret (passphrase or FIDO2 key):
```
User Secret (passphrase or FIDO2 hmac-secret)
KEK (Key Encryption Key) — derived from user secret
DEK (Data Encryption Key) — random, encrypts/decrypts file blobs
```
**DEK (Data Encryption Key):**
- 256-bit random key, generated once when encryption is first enabled
- Used with XChaCha20-Poly1305 (AEAD) to encrypt file blobs
- Never stored in plaintext — always wrapped by the KEK
- Each KEK source stores its own wrapped copy in the manifest
(`encryption.kek_sources[].wrapped_dek`, base64-encoded)
**KEK (Key Encryption Key):**
- Derived from the user's secret
- Used only to wrap/unwrap the DEK, never to encrypt data directly
- Never stored on disk — derived on demand
This separation means changing a passphrase or adding a FIDO2 key only
requires re-wrapping the DEK, not re-encrypting every blob.
### KEK Derivation
Two slot types. A repo has one `passphrase` slot and zero or more
`fido2/<label>` slots:
**Passphrase slot** (at most one per repo):
- KEK = Argon2id(passphrase, salt, time=3, memory=64MB, threads=4)
- Salt and Argon2id parameters stored in the slot entry
- Slot key: `passphrase`
**FIDO2 slots** (one per device, labeled):
- KEK = HMAC-SHA256 output from the FIDO2 authenticator
- The authenticator computes `HMAC(device_secret, salt)` using the
credential registered for this slot
- `credential_id` in the slot entry ties it to a specific FIDO2
registration, allowing sgard to skip non-matching devices
- Slot key: `fido2/<label>` (defaults to hostname, overridable)
### Blob Encryption
**Algorithm:** XChaCha20-Poly1305 (from `golang.org/x/crypto/chacha20poly1305`)
- 24-byte nonce (random per blob), 16-byte auth tag
- AEAD — provides both confidentiality and integrity
- XChaCha20 variant chosen for its 24-byte nonce, which is safe to
generate randomly without collision risk
**Encrypted blob format:**
```
[24-byte nonce][ciphertext + 16-byte Poly1305 tag]
```
**Encryption flow (during Add/Checkpoint):**
1. Read file plaintext
2. Generate random 24-byte nonce
3. Encrypt: `ciphertext = XChaCha20-Poly1305.Seal(nonce, DEK, plaintext)`
4. Compute SHA-256 hash of the encrypted blob (nonce + ciphertext)
5. Store the encrypted blob in the content-addressable store
**Decryption flow (during Restore/Diff):**
1. Read encrypted blob from store
2. Extract 24-byte nonce prefix
3. Decrypt: `plaintext = XChaCha20-Poly1305.Open(nonce, DEK, ciphertext)`
4. Write plaintext to disk
### Hashing: Post-Encryption
The manifest hash is the SHA-256 of the **ciphertext**, not the plaintext.
Rationale:
- `verify` checks blob integrity without needing the DEK
- The hash matches what's actually stored on disk
- The server never needs the DEK — it handles only encrypted blobs
- `status` needs the DEK to compare against the current file (hash
the plaintext, encrypt it, compare encrypted hash — or keep a
plaintext hash in the manifest)
**Manifest changes for encryption:**
Encrypted entries gain two fields: `encrypted: true` and
`plaintext_hash` (SHA-256 of the plaintext, for efficient `status`
checks without decryption):
```yaml
files:
- path: ~/.bashrc
hash: a1b2c3d4... # SHA-256 of plaintext — not encrypted
type: file
mode: "0644"
updated: "2026-03-24T..."
- path: ~/.ssh/config
hash: f8e9d0c1... # SHA-256 of encrypted blob (post-encryption)
plaintext_hash: e5f6a7... # SHA-256 of plaintext (pre-encryption)
encrypted: true
type: file
mode: "0600"
updated: "2026-03-24T..."
```
For unencrypted entries, `hash` is the SHA-256 of the plaintext (current
behavior), and `plaintext_hash` and `encrypted` are omitted.
`status` hashes the current file on disk and compares against
`plaintext_hash` (for encrypted entries) or `hash` (for plaintext).
`verify` always uses `hash` to check store integrity without the DEK.
### DEK Storage
Each slot wraps the DEK independently using XChaCha20-Poly1305,
stored as base64 in the slot's `wrapped_dek` field:
```
wrapped_dek = base64([24-byte nonce][encrypted DEK + 16-byte tag])
```
The manifest is fully self-contained — pulling it to a new machine
gives you everything needed to decrypt (given the user's secret).
### Unlock Resolution
When sgard needs the DEK, it reads `encryption.kek_slots` from the
manifest and tries slots automatically:
1. **FIDO2 slots** (all `fido2/*` slots, in map order):
- For each: check if a connected FIDO2 device matches the
slot's `credential_id`
- If match found → prompt for touch, derive KEK, unwrap DEK
- If no device matches or touch times out → try next slot
2. **Passphrase slot** (if `passphrase` slot exists):
- Prompt for passphrase on stdin
- Derive KEK via Argon2id, unwrap DEK
3. **No slots succeed** → error
FIDO2 is tried first because it requires no typing — just a touch.
The `credential_id` check avoids prompting for touch on a device that
can't unwrap the slot, which matters when multiple FIDO2 keys are
connected. The passphrase slot is the universal fallback.
The user never specifies which slot to use. The presence of the
`encryption` section indicates the repo has encryption capability.
Individual files opt in via `--encrypt` at add time.
### CLI Integration
**Setting up encryption (creates DEK, adds `encryption` to manifest):**
```sh
sgard encrypt init # passphrase slot only
sgard encrypt init --fido2 # fido2/<hostname> + passphrase slots
```
When `--fido2` is specified, sgard creates both slots: the FIDO2 slot
(named `fido2/<hostname>` by default) and immediately prompts for a
passphrase to create the fallback slot. This ensures the user is never
locked out if they lose the FIDO2 key.
Without `--fido2`, only the `passphrase` slot is created.
**Adding encrypted files:**
```sh
sgard add --encrypt ~/.ssh/config ~/.aws/credentials
sgard add ~/.bashrc # not encrypted
```
**Managing slots:**
```sh
sgard encrypt add-fido2 # adds fido2/<hostname>
sgard encrypt add-fido2 --label yubikey-5 # adds fido2/yubikey-5
sgard encrypt remove-slot fido2/old-laptop # removes a slot
sgard encrypt list-slots # shows all slot names and types
sgard encrypt change-passphrase # prompts for old and new
```
Adding a slot auto-unlocks the DEK via an existing slot first (e.g.,
`add-fido2` will prompt for the passphrase to unwrap the DEK, then
re-wrap it with the new FIDO2 key).
**Unlocking:**
Operations that touch encrypted entries (add --encrypt, checkpoint,
restore, diff, mirror on encrypted files) trigger automatic unlock
via the resolution order above. The DEK is cached in memory for the
duration of the command.
Operations that only touch plaintext entries never prompt — they work
exactly as before, even if the repo has encryption configured.
There is no long-lived unlock state — each command invocation that needs
the DEK obtains it fresh. This is intentional: dotfile operations are
infrequent, and caching the DEK across invocations would require a
daemon or on-disk secret, both of which expand the attack surface.
### Security Properties
- **Selective confidentiality:** Only files marked `--encrypt` are
encrypted. The manifest contains paths and hashes but not file
contents for encrypted entries.
- **Server ignorance:** The server never has the DEK. Push/pull
transfers encrypted blobs opaquely. The server cannot read encrypted
file contents.
- **Key rotation:** Changing the passphrase re-wraps the DEK without
re-encrypting blobs.
- **Compromise recovery:** If the DEK is compromised, all encrypted
blobs must be re-encrypted (not just re-wrapped). This is an
explicit `sgard encrypt rotate-dek` operation.
- **No plaintext leaks:** `diff` decrypts in memory, never writes
decrypted blobs to disk.
- **Graceful degradation:** Commands that don't touch encrypted entries
work without the DEK. A `status` on a mixed repo can check plaintext
entries without prompting.
### Repos Without Encryption
A manifest with no `encryption` section has no DEK and cannot have
encrypted entries. The `--encrypt` flag on `add` will error, prompting
the user to run `sgard encrypt init` first. All existing behavior is
unchanged.
### Encryption and Remote Sync
The server never has the DEK. Push/pull transfers the manifest
(including the `encryption` section with wrapped DEKs and salts) and
encrypted blobs as opaque bytes. The server cannot decrypt file
contents.
When pulling to a new machine:
1. The manifest arrives with all `kek_slots` intact
2. The user provides their passphrase (universal fallback)
3. sgard derives the KEK, unwraps the DEK, decrypts blobs on restore
No additional setup is needed beyond having the passphrase.
**Adding FIDO2 on a new machine:** FIDO2 hmac-secret is device-bound —
a different physical key produces a different KEK. After pulling to a
new machine, the user runs `sgard encrypt add-fido2` which:
1. Unlocks the DEK via the passphrase slot
2. Registers a new FIDO2 credential on the local device
3. Wraps the DEK with the new FIDO2 KEK
4. Adds a `fido2/<hostname>` slot to the manifest
On next push, the new slot propagates to the server and other machines.
Each machine accumulates its own FIDO2 slot over time.
### TLS Transport
sgardd supports optional TLS via `--tls-cert` and `--tls-key` flags.
When provided, the server uses `credentials.NewTLS()` with a minimum
of TLS 1.2. Without them, it runs insecure (for local/trusted networks).
The client gains `--tls` and `--tls-ca` flags:
- `--tls` — enables TLS transport (uses system CA pool by default)
- `--tls-ca <path>` — custom CA certificate for self-signed server certs
Both flags must be specified together on the server side; on the client
side `--tls` alone uses the system trust store, and `--tls-ca` adds a
custom root.
### FIDO2 Hardware Support
Real FIDO2 hardware support uses `go-libfido2` (CGo bindings to
Yubico's libfido2 C library). It is gated behind the `fido2` build
tag to avoid requiring CGo and libfido2 for users who don't need it:
- `go build ./...` — default build, no FIDO2 hardware support
- `go build -tags fido2 ./...` — links against libfido2 for real keys
The implementation (`garden/fido2_hardware.go`) wraps
`libfido2.Device.MakeCredential` and `Assertion` with the
`HMACSecretExtension` to derive 32-byte HMAC secrets from hardware
keys. A `--fido2-pin` flag is available for PIN-protected devices.
The Nix flake provides two packages: `sgard` (default, no CGo) and
`sgard-fido2` (links libfido2).
### DEK Rotation
`sgard encrypt rotate-dek` generates a new DEK, re-encrypts all
encrypted blobs with the new key, and re-wraps the new DEK with all
existing KEK slots. Required when the DEK is suspected compromised
(re-wrapping alone is insufficient since the old DEK could decrypt
the existing blobs).
The rotation process:
1. Generate a new random 256-bit DEK
2. For each encrypted entry: decrypt with old DEK, re-encrypt with new DEK,
write new blob to store, update manifest hash (plaintext hash unchanged)
3. Re-derive each KEK (passphrase via Argon2id, FIDO2 via device) and
re-wrap the new DEK. FIDO2 slots without a matching connected device
are dropped during rotation.
4. Save updated manifest
Plaintext entries are untouched.
### Per-Machine Targeting (Phase 5)
Entries can be targeted to specific machines using `only` and `never`
labels. A machine's identity is a set of labels computed at runtime:
- **Short hostname:** `vade` (before the first dot, lowercased)
- **OS:** `os:linux`, `os:darwin`, `os:windows` (from `runtime.GOOS`)
- **Architecture:** `arch:amd64`, `arch:arm64` (from `runtime.GOARCH`)
- **Tags:** `tag:work`, `tag:server` (from `<repo>/tags`, local-only)
**Manifest fields on Entry:**
```yaml
files:
- path: ~/.bashrc.linux
only: [os:linux] # restore/checkpoint only on Linux
...
- path: ~/.ssh/work-config
only: [tag:work] # only on machines tagged "work"
...
- path: ~/.config/heavy
never: [arch:arm64] # everywhere except ARM
...
- path: ~/.special
only: [vade] # only on host "vade"
...
```
**Matching rules:**
- `only` set → entry applies if *any* label matches the machine
- `never` set → entry excluded if *any* label matches
- Both set → error (mutually exclusive)
- Neither set → applies everywhere (current behavior)
**Operations affected:**
- `restore` — skip non-matching entries
- `checkpoint` — skip non-matching entries (don't clobber stored version)
- `status` — report non-matching entries as `skipped`
- `add`, `list`, `verify`, `diff` — operate on all entries regardless
**Tags file:** `<repo>/tags`, one tag per line, not synced. Each
machine defines its own tags. `sgard init` adds `tags` to `.gitignore`.
**Label format:** bare string = hostname, `prefix:value` = typed matcher.
The `tag:` prefix in `only`/`never` maps to bare names in the tags file.
### Future: Manifest Signing (Phase 6)
Manifest signing (to detect tampering) is deferred. The challenge is
the trust model: which key signs, and how does a pulling client verify
the signature when multiple machines with different SSH keys push to
the same server? This requires a proper trust/key-authority design.
## Go Package Structure ## Go Package Structure
``` ```
sgard/ sgard/
cmd/sgard/ # CLI entry point — one file per command cmd/sgard/ # CLI entry point — one file per command
main.go # cobra root command, --repo flag main.go # cobra root command, --repo/--remote/--ssh-key/--tls/--tls-ca flags
version.go # sgard version (ldflags-injected) encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase
exclude.go # sgard exclude/include
push.go pull.go prune.go mirror.go
init.go add.go remove.go checkpoint.go init.go add.go remove.go checkpoint.go
restore.go status.go verify.go list.go diff.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
garden/ # Core business logic — one file per operation garden/ # Core business logic — one file per operation
garden.go # Garden struct, Init, Open, Add, Checkpoint, Status garden.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors
restore.go # Restore with timestamp comparison and confirm callback encrypt.go # EncryptInit, UnlockDEK, RotateDEK, encrypt/decrypt blobs, slot mgmt
remove.go verify.go list.go diff.go 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)
exclude.go # Exclude/Include methods
restore.go mirror.go prune.go remove.go verify.go list.go info.go diff.go
hasher.go # SHA-256 file hashing hasher.go # SHA-256 file hashing
e2e_test.go # Full lifecycle integration test
manifest/ # YAML manifest parsing manifest/ # YAML manifest parsing
manifest.go # Manifest and Entry structs, Load/Save manifest.go # Manifest and Entry structs, Load/Save
store/ # Content-addressable blob storage store/ # Content-addressable blob storage
store.go # Store struct: Write/Read/Exists/Delete store.go # Store struct: Write/Read/Exists/Delete/List
flake.nix # Nix flake for building on NixOS server/ # gRPC server implementation
.goreleaser.yaml # GoReleaser config for releases server.go # GardenSync RPC handlers with RWMutex
.github/workflows/ # GitHub Actions release pipeline auth.go # JWT token + SSH key auth interceptor, Authenticate RPC
convert.go # proto ↔ manifest type conversion (incl. encryption)
client/ # gRPC client library
client.go # Push, Pull, Prune with auto-auth retry
auth.go # TokenCredentials, LoadSigner, Authenticate, token caching
sgardpb/ # Generated protobuf + gRPC Go code
proto/sgard/v1/ # Proto source definitions
VERSION # Semver string, read by flake.nix; synced from latest git tag via `make version`
flake.nix # Nix flake (builds sgard + sgardd, version from VERSION file)
.goreleaser.yaml # GoReleaser (builds both binaries)
``` ```
### Key Architectural Rule ### Key Architectural Rule
**The `garden` package contains all logic. The `cmd` package is pure CLI wiring.** **The `garden` package contains all logic. The `cmd` package is pure CLI
wiring. The `server` package wraps `Garden` methods as gRPC endpoints.**
The `Garden` struct is the central coordinator:
```go ```go
type Garden struct { type Garden struct {
manifest *manifest.Manifest manifest *manifest.Manifest
store *store.Store store *store.Store
root string // repository root directory root string
manifestPath string manifestPath string
clock clockwork.Clock // injectable for testing clock clockwork.Clock
dek []byte // unlocked data encryption key
} }
func (g *Garden) Add(paths []string) error // Local operations
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(path string) bool) error func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error
func (g *Garden) Status() ([]FileStatus, error) func (g *Garden) Status() ([]FileStatus, error)
func (g *Garden) Verify() ([]VerifyResult, error) func (g *Garden) Verify() ([]VerifyResult, error)
func (g *Garden) List() []manifest.Entry 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) 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
func (g *Garden) Exclude(paths []string) error
func (g *Garden) Include(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
func (g *Garden) ChangePassphrase(newPassphrase string) error
// Accessors (used by server package)
func (g *Garden) GetManifest() *manifest.Manifest
func (g *Garden) BlobExists(hash string) bool
func (g *Garden) ReadBlob(hash string) ([]byte, error)
func (g *Garden) WriteBlob(data []byte) (string, error)
func (g *Garden) ReplaceManifest(m *manifest.Manifest) error
func (g *Garden) ListBlobs() ([]string, error)
func (g *Garden) DeleteBlob(hash string) error
``` ```
This separation means the future gRPC server calls the same `Garden` methods The gRPC server calls the same `Garden` methods as the CLI — no logic
as the CLI — no logic duplication. duplication.
## Design Decisions ## Design Decisions
@@ -193,9 +780,21 @@ as the CLI — no logic duplication.
`$HOME` at runtime. This makes the manifest portable across machines with `$HOME` at runtime. This makes the manifest portable across machines with
different usernames. different usernames.
**No history.** Phase 1 stores only the latest checkpoint. For versioning, **Adding a directory recurses.** `Add` walks directories and adds each
place the repo under git — `sgard init` creates a `.gitignore` that excludes file/symlink individually. Directories are not tracked as entries — only
`blobs/`. Blob durability (backup, replication) is deferred to a future phase. leaf files and symlinks. Excluded paths (see below) are skipped during walks.
**File exclusion.** The manifest stores an `exclude` list of tilde-form
paths that should never be tracked. Excluding a directory excludes
everything under it. Exclusions are checked during `Add` directory walks,
`MirrorUp` walks, and `MirrorDown` cleanup (excluded files are left alone
on disk). `sgard exclude` adds paths; `sgard include` removes them. When a
path is excluded, any already-tracked entries matching it are removed from
the manifest.
**No history.** Only the latest checkpoint is stored. For versioning, place
the repo under git — `sgard init` creates a `.gitignore` that excludes
`blobs/`.
**Per-file timestamps.** Each manifest entry records an `updated` timestamp **Per-file timestamps.** Each manifest entry records an `updated` timestamp
set at checkpoint time. On restore, if the manifest entry is newer than the set at checkpoint time. On restore, if the manifest entry is newer than the
@@ -203,8 +802,34 @@ file on disk (by mtime), the restore proceeds without prompting. If the file
on disk is newer or the times match, sgard prompts for confirmation. on disk is newer or the times match, sgard prompts for confirmation.
`--force` always skips the prompt. `--force` always skips the prompt.
**Atomic writes.** Checkpoint writes `manifest.yaml.tmp` then renames to **Atomic writes.** Manifest saves write to a temp file then rename.
`manifest.yaml`. A crash cannot corrupt the manifest.
**Timestamp comparison truncates to seconds** for cross-platform filesystem **Timestamp comparison truncates to seconds** for cross-platform filesystem
compatibility. compatibility.
**Locked files (`--lock`).** A locked entry is repo-authoritative — the
on-disk copy is treated as potentially corrupted by the system, not as
a user edit. Semantics:
- **`add --lock`** — tracks the file normally, marks it as locked
- **`checkpoint`** — skips locked files entirely (preserves the repo version)
- **`status`** — reports locked files with changed hashes as `drifted`
(distinct from `modified`, which implies a user edit)
- **`restore`** — always restores locked files if the hash differs,
regardless of timestamp, without prompting. Skips if hash matches.
- **`add`** (without `--lock`) — can be used to explicitly update a locked
file in the repo when the on-disk version is intentionally new
Use case: system-managed files like `~/.config/user-dirs.dirs` that get
overwritten by the OS but should be kept at a known-good state.
**Directory-only entries (`--dir`).** `add --dir <path>` tracks the
directory itself as a structural entry without recursing into its
contents. On restore, sgard ensures the directory exists with the
correct permissions. Use case: directories that must exist for other
software to function, but whose contents are managed elsewhere.
**Remote config resolution:** `--remote` flag > `SGARD_REMOTE` env >
`<repo>/remote` file.
**SSH key resolution:** `--ssh-key` flag > `SGARD_SSH_KEY` env > ssh-agent >
`~/.ssh/id_ed25519` > `~/.ssh/id_rsa`.

View File

@@ -21,12 +21,14 @@ Module: `github.com/kisom/sgard`. Author: K. Isom <kyle@imap.cc>.
## Build ## Build
```bash ```bash
go build ./cmd/sgard go build ./... # both sgard and sgardd
go build -tags fido2 ./... # with real FIDO2 hardware support (requires libfido2)
``` ```
Nix: Nix:
```bash ```bash
nix build .#sgard nix build .#sgard # builds both binaries (no CGo)
nix build .#sgard-fido2 # with FIDO2 hardware support (links libfido2)
``` ```
Run tests: Run tests:
@@ -39,24 +41,38 @@ Lint:
golangci-lint run ./... golangci-lint run ./...
``` ```
Regenerate proto (requires protoc toolchain):
```bash
make proto
```
## Dependencies ## Dependencies
- `gopkg.in/yaml.v3` — manifest serialization - `gopkg.in/yaml.v3` — manifest serialization
- `github.com/spf13/cobra` — CLI framework - `github.com/spf13/cobra` — CLI framework
- `github.com/jonboulle/clockwork` — injectable clock for deterministic tests - `github.com/jonboulle/clockwork` — injectable clock for deterministic tests
- `google.golang.org/grpc` — gRPC runtime
- `google.golang.org/protobuf` — protobuf runtime
- `golang.org/x/crypto` — SSH key auth (ssh, ssh/agent), Argon2id, XChaCha20-Poly1305
- `github.com/golang-jwt/jwt/v5` — JWT token auth
- `github.com/keys-pub/go-libfido2` — FIDO2 hardware key support (build tag `fido2`, requires libfido2)
## Package Structure ## Package Structure
``` ```
cmd/sgard/ CLI entry point (cobra commands, pure wiring) cmd/sgard/ CLI entry point (cobra commands, pure wiring)
garden/ Core business logic (Garden struct orchestrating everything) cmd/sgardd/ gRPC server daemon
garden/ Core business logic (Garden struct, encryption, FIDO2 hardware via build tags)
manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save) manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save)
store/ Content-addressable blob storage (SHA-256 keyed) store/ Content-addressable blob storage (SHA-256 keyed)
server/ gRPC server (RPC handlers, JWT/SSH auth interceptor, proto conversion)
client/ gRPC client library (Push, Pull, Prune, token auth with auto-renewal)
sgardpb/ Generated protobuf + gRPC Go code
``` ```
Key rule: all logic lives in `garden/`. The `cmd/` layer only parses flags Key rule: all logic lives in `garden/`. The `cmd/` layer only parses flags
and calls `Garden` methods. This enables the future gRPC server to reuse and calls `Garden` methods. The `server` wraps `Garden` as gRPC endpoints.
the same logic with zero duplication. No logic duplication.
Each garden operation (remove, verify, list, diff) lives in its own file Each garden operation lives in its own file (`garden/<op>.go`) to minimize
(`garden/<op>.go`) to minimize merge conflicts during parallel development. merge conflicts during parallel development.

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
VERSION := $(shell git describe --tags --abbrev=0 | sed 's/^v//')
.PHONY: proto build test lint clean version
proto:
protoc \
--go_out=. --go_opt=module=github.com/kisom/sgard \
--go-grpc_out=. --go-grpc_opt=module=github.com/kisom/sgard \
-I proto \
proto/sgard/v1/sgard.proto
version:
@echo $(VERSION) > VERSION
build:
go build -ldflags "-X main.version=$(VERSION)" ./...
test:
go test ./...
lint:
golangci-lint run ./...
clean:
rm -f sgard

View File

@@ -7,9 +7,9 @@ ARCHITECTURE.md for design details.
## Current Status ## Current Status
**Phase:** Phase 1 complete (Steps 18). All local commands implemented. **Phase:** Phase 5 complete. File exclusion feature added. Add is now idempotent.
**Last updated:** 2026-03-23 **Last updated:** 2026-03-30
## Completed Steps ## Completed Steps
@@ -42,16 +42,26 @@ ARCHITECTURE.md for design details.
## Up Next ## Up Next
Phase 1 is complete. Future work: blob durability, gRPC remote mode. Phase 6: Manifest Signing (to be planned).
## Standalone Additions
- **Deployment to rift**: sgardd deployed as Podman container on rift behind
mc-proxy (L4 SNI passthrough on :9443, multiplexed with metacrypt gRPC).
TLS cert issued by Metacrypt, SSH-key auth. DNS at
`sgard.svc.mcp.metacircular.net`.
- **Default remote config**: `sgard remote set/show` commands. Saves addr,
TLS, and CA path to `<repo>/remote.yaml`. `dialRemote` merges saved config
with CLI flags (flags win). Removes need for `--remote`/`--tls` on every
push/pull.
## Known Issues / Decisions Deferred ## Known Issues / Decisions Deferred
- **Blob durability**: blobs are not stored in git. A strategy for backup or - **Manifest signing**: deferred — trust model (which key signs, how do
replication is deferred to a future phase. pulling clients verify) needs design.
- **gRPC remote mode**: Phase 2. Package structure is designed to accommodate - **DEK rotation**: `sgard encrypt rotate-dek` (re-encrypt all blobs)
it (garden core separates logic from CLI wiring). deferred to future work.
- **Clock abstraction**: Done — `jonboulle/clockwork` injected. E2e test - **FIDO2 testing**: hardware-dependent, may need mocks or CI skip.
uses fake clock for deterministic timestamps.
## Change Log ## Change Log
@@ -67,3 +77,41 @@ Phase 1 is complete. Future work: blob durability, gRPC remote mode.
| 2026-03-23 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. | | 2026-03-23 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. |
| 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. | | 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. |
| 2026-03-23 | — | README, goreleaser config, version command, Nix flake, homebrew formula, release pipeline validated (v0.1.0v0.1.2). | | 2026-03-23 | — | README, goreleaser config, version command, Nix flake, homebrew formula, release pipeline validated (v0.1.0v0.1.2). |
| 2026-03-23 | — | v1.0.0 released. Docs updated for release. |
| 2026-03-23 | 9 | Proto definitions: 5 RPCs (Push/Pull manifest+blobs, Prune), generated sgardpb, Makefile, deps added. |
| 2026-03-23 | 10 | Garden accessor methods: GetManifest, BlobExists, ReadBlob, WriteBlob, ReplaceManifest. 5 tests. |
| 2026-03-23 | 11 | Proto-manifest conversion: ManifestToProto/ProtoToManifest with round-trip tests. |
| 2026-03-23 | 12 | gRPC server: 5 RPC handlers (push/pull manifest+blobs, prune), bufconn tests, store.List. |
| 2026-03-23 | 12b | Directory recursion in Add, mirror up/down commands, 7 tests. |
| 2026-03-23 | 13 | Client library: Push, Pull, Prune with chunked blob streaming. 6 integration tests. |
| 2026-03-23 | 14 | SSH key auth: server interceptor (authorized_keys, signature verification), client PerRPCCredentials (ssh-agent/key file). 8 tests including auth integration. |
| 2026-03-24 | 15 | CLI wiring: push, pull, prune commands, sgardd daemon binary, --remote/--ssh-key flags, local prune with 2 tests. |
| 2026-03-24 | 16 | Polish: updated all docs, flake.nix (sgardd + vendorHash), goreleaser (both binaries), e2e push/pull test with auth. |
| 2026-03-24 | — | JWT token auth implemented (transparent auto-renewal, XDG token cache, ReauthChallenge fast path). |
| 2026-03-24 | — | Phase 3 encryption design: selective per-file encryption, KEK slots (passphrase + fido2/label), manifest-embedded config. |
| 2026-03-24 | 17 | Encryption core: Argon2id KEK, XChaCha20 DEK wrap/unwrap, selective per-file encrypt in Add/Checkpoint/Restore/Diff/Status. 10 tests. |
| 2026-03-24 | 18 | FIDO2: FIDO2Device interface, AddFIDO2Slot, unlock resolution (fido2 first → passphrase fallback), mock device, 6 tests. |
| 2026-03-24 | 19 | Encryption CLI: encrypt init/add-fido2/remove-slot/list-slots/change-passphrase, --encrypt on add, proto + convert updates. |
| 2026-03-24 | 20 | Polish: encryption e2e test, all docs updated, flake vendorHash updated. |
| 2026-03-24 | — | Locked files + dir-only entries. v2.0.0 released. |
| 2026-03-24 | — | Phase 4 planned (Steps 2127): lock/unlock, shell completion, TLS, DEK rotation, real FIDO2, test cleanup. |
| 2026-03-24 | 21 | Lock/unlock toggle commands. garden/lock.go, cmd/sgard/lock.go, 6 tests. |
| 2026-03-24 | 22 | Shell completion: cobra built-in, README docs for bash/zsh/fish. |
| 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. |
| 2026-03-24 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. |
| 2026-03-24 | — | Phase 5 planned (Steps 2832): machine identity, targeting, tags, proto update, polish. |
| 2026-03-24 | 28 | Machine identity + targeting core: Entry Only/Never, Identity(), EntryApplies(), tags file. 13 tests. |
| 2026-03-24 | 29 | Operations respect targeting: checkpoint/restore/status skip non-matching. 6 tests. |
| 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. |
| 2026-03-25 | — | Deploy sgardd to rift: Dockerfile, docker-compose, mc-proxy L4 route on :9443, Metacrypt TLS cert, DNS. |
| 2026-03-25 | — | `sgard remote set/show`: persistent remote config in `<repo>/remote.yaml` (addr, tls, tls_ca). |
| 2026-03-26 | — | `sgard list` remote support: uses `resolveRemoteConfig()` to list server manifest via `PullManifest` RPC. Client `List()` method added. |
| 2026-03-26 | — | Version derived from git tags via `VERSION` file. flake.nix reads `VERSION`; Makefile `version` target syncs from latest tag, `build` injects via ldflags. |
| 2026-03-27 | — | File exclusion: `sgard exclude`/`include` commands, `Manifest.Exclude` field, Add/MirrorUp/MirrorDown respect exclusions, directory exclusion support. 8 tests. |
| 2026-03-30 | — | Idempotent add: `sgard add` silently skips already-tracked files/directories instead of erroring, enabling glob-based workflows. |

View File

@@ -92,9 +92,242 @@ Depends on Step 5.
- [x] Ensure `go vet ./...` and `go test ./...` pass clean - [x] Ensure `go vet ./...` and `go test ./...` pass clean
- [x] Update CLAUDE.md, ARCHITECTURE.md, PROGRESS.md - [x] Update CLAUDE.md, ARCHITECTURE.md, PROGRESS.md
## Future Steps (Not Phase 1) ## Phase 2: gRPC Remote Sync
- Blob durability (backup/replication strategy) ### Step 9: Proto Definitions + Code Gen
- gRPC remote mode (push/pull/serve)
- Proto definitions for wire format - [x] Write `proto/sgard/v1/sgard.proto` — 5 RPCs (PushManifest, PushBlobs, PullManifest, PullBlobs, Prune), all messages
- Shell completion via cobra - [x] Add Makefile target for protoc code generation
- [x] Add grpc, protobuf, x/crypto deps to go.mod
- [x] Update flake.nix devShell with protoc tools
- [x] Verify: `go build ./sgardpb` compiles
### Step 10: Garden Accessor Methods
*Can be done in parallel with Step 11.*
- [x] `garden/garden.go`: `GetManifest()`, `BlobExists()`, `ReadBlob()`, `WriteBlob()`, `ReplaceManifest()`
- [x] Tests for each accessor
- [x] Verify: `go test ./garden/...`
### Step 11: Proto-Manifest Conversion
*Can be done in parallel with Step 10.*
- [x] `server/convert.go`: `ManifestToProto`, `ProtoToManifest`, entry helpers
- [x] `server/convert_test.go`: round-trip test
- [x] Verify: `go test ./server/...`
### Step 12: Server Implementation (No Auth)
Depends on Steps 9, 10, 11.
- [x] `server/server.go`: Server struct with RWMutex, 5 RPC handlers (+ Prune)
- [x] PushManifest: timestamp compare, compute missing blobs
- [x] PushBlobs: receive stream, write to store, replace manifest
- [x] PullManifest: return manifest
- [x] PullBlobs: stream requested blobs (64 KiB chunks)
- [x] Prune: remove orphaned blobs (added store.List + garden.ListBlobs/DeleteBlob)
- [x] `server/server_test.go`: in-process test with bufconn, push+pull+prune
### Step 12b: Directory Recursion and Mirror Command
- [x] `garden/garden.go`: `Add` recurses directories — walk all files/symlinks, add each as its own entry
- [x] `garden/mirror.go`: `MirrorUp(paths []string) error` — walk directory, add new files, remove entries for files gone from disk, re-hash changed
- [x] `garden/mirror.go`: `MirrorDown(paths []string, force bool, confirm func(string) bool) error` — restore all tracked files under path, delete anything not in manifest
- [x] `garden/mirror_test.go`: tests for recursive add, mirror up (detects new/removed), mirror down (cleans extras)
- [x] `cmd/sgard/mirror.go`: `sgard mirror up <path>`, `sgard mirror down <path> [--force]`
- [x] Update existing add tests for directory recursion
### Step 13: Client Library (No Auth)
Depends on Step 12.
- [x] `client/client.go`: Client struct, `Push()`, `Pull()`, `Prune()` methods
- [x] `client/client_test.go`: integration tests (push+pull cycle, server newer, up-to-date, prune)
### Step 14: SSH Key Auth
- [x] `server/auth.go`: AuthInterceptor, parse authorized_keys, verify SSH signatures
- [x] `client/auth.go`: LoadSigner (ssh-agent or key file), SSHCredentials (PerRPCCredentials)
- [x] `server/auth_test.go`: valid key, reject unauthenticated, reject unauthorized key, reject expired timestamp
- [x] `client/auth_test.go`: metadata generation, no-transport-security
- [x] Integration tests: authenticated push/pull succeeds, unauthenticated is rejected
### Step 15: CLI Wiring + Prune
Depends on Steps 13, 14.
- [x] `garden/prune.go`: `Prune() (int, error)` — collect referenced hashes, delete orphaned blobs
- [x] `garden/prune_test.go`: prune removes orphaned, keeps referenced
- [x] `server/server.go`: Prune RPC (done in Step 12)
- [x] `proto/sgard/v1/sgard.proto`: Prune RPC (done in Step 9)
- [x] `client/client.go`: Prune() method (done in Step 13)
- [x] `cmd/sgard/prune.go`: local prune; with `--remote` prunes remote instead
- [x] `cmd/sgard/main.go`: add `--remote`, `--ssh-key` persistent flags, resolveRemote()
- [x] `cmd/sgard/push.go`, `cmd/sgard/pull.go`
- [x] `cmd/sgardd/main.go`: flags, garden open, auth interceptor, gRPC serve
- [x] Verify: both binaries compile
### Step 16: Polish + Release
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
- [x] Update flake.nix (add sgardd, updated vendorHash)
- [x] Update .goreleaser.yaml (add sgardd build)
- [x] E2e integration test: init two repos, push from one, pull into other (with auth)
- [x] Verify: all tests pass, full push/pull cycle works
## Phase 3: Encryption
### Step 17: Encryption Core (Passphrase Only)
- [x] `manifest/manifest.go`: add `Encrypted`, `PlaintextHash` fields to Entry; add `Encryption` section with `KekSlots` map to Manifest
- [x] `garden/encrypt.go`: `EncryptInit(passphrase string) error` — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section
- [x] `garden/encrypt.go`: `UnlockDEK(prompt) error` — read slots, try passphrase, unwrap DEK; cache in memory for command duration
- [x] `garden/encrypt.go`: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open)
- [x] `garden/garden.go`: modify Add to accept encrypt flag — encrypt blob before storing, set `encrypted: true` and `plaintext_hash` on entry
- [x] `garden/garden.go`: modify Checkpoint to re-encrypt changed encrypted entries (compares plaintext_hash)
- [x] `garden/garden.go`: modify Restore to decrypt encrypted blobs before writing
- [x] `garden/diff.go`: modify Diff to decrypt stored blob before diffing
- [x] `garden/garden.go`: modify Status to use `plaintext_hash` for encrypted entries
- [x] Tests: 10 encryption tests (init, persist, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK)
- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
### Step 18: FIDO2 Support
Depends on Step 17.
- [x] `garden/encrypt_fido2.go`: FIDO2Device interface, AddFIDO2Slot, unlockFIDO2, defaultFIDO2Label
- [x] `garden/encrypt.go`: UnlockDEK tries fido2/* slots first (credential_id matching), falls back to passphrase
- [x] `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)
- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
### Step 19: Encryption CLI + Slot Management
Depends on Steps 17, 18.
- [x] `cmd/sgard/encrypt.go`: `sgard encrypt init [--fido2]`, `add-fido2 [--label]`, `remove-slot`, `list-slots`, `change-passphrase`
- [x] `garden/encrypt.go`: `RemoveSlot`, `ListSlots`, `ChangePassphrase` methods
- [x] `cmd/sgard/add.go`: add `--encrypt` flag with passphrase prompt
- [x] Update proto: add `encrypted`, `plaintext_hash` to ManifestEntry; add KekSlot, Encryption messages, encryption field on Manifest
- [x] Update `server/convert.go`: full encryption section conversion (Encryption, KekSlot)
- [x] Verify: both binaries compile, `go test ./...`, lint clean
### Step 20: Encryption Polish + Release
- [x] E2e test: full encryption lifecycle (init, add encrypted+plaintext, checkpoint, modify, status, restore, verify, diff, slot management, passphrase change)
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md
- [x] Update flake.nix vendorHash
- [x] Verify: all tests pass, lint clean
## Future Steps (Not Phase 3)
## Phase 4: Hardening + Completeness
### Step 21: Lock/Unlock Toggle Commands
- [x] `garden/lock.go`: `Lock(paths)`, `Unlock(paths)` — toggle locked flag on existing entries
- [x] `cmd/sgard/lock.go`: `sgard lock <path>...`, `sgard unlock <path>...`
- [x] Tests: lock/unlock existing entry, persist, error on untracked, checkpoint/status behavior changes (6 tests)
### Step 22: Shell Completion
- [x] Cobra provides built-in `sgard completion` for bash, zsh, fish, powershell — no code needed
- [x] README updated with shell completion installation instructions
### Step 23: TLS Transport for sgardd
- [x] `cmd/sgardd/main.go`: add `--tls-cert`, `--tls-key` flags
- [x] Server uses `credentials.NewTLS()` when cert/key provided, insecure otherwise
- [x] Client: add `--tls` flag and `--tls-ca` for custom CA
- [x] Update `cmd/sgard/main.go` and `dialRemote()` for TLS
- [x] Tests: TLS connection with self-signed cert (push/pull cycle, reject untrusted client)
- [x] Update ARCHITECTURE.md and README.md
### Step 24: DEK Rotation
- [x] `garden/encrypt.go`: `RotateDEK(promptPassphrase, fido2Device)` — generate new DEK, re-encrypt all encrypted blobs, re-wrap with all existing KEK slots
- [x] `cmd/sgard/encrypt.go`: `sgard encrypt rotate-dek`
- [x] Tests: rotate DEK, verify decryption, verify plaintext untouched, FIDO2 re-wrap, requires-unlock (4 tests)
### Step 25: Real FIDO2 Hardware Binding
- [x] Evaluate approach: go-libfido2 CGo bindings (keys-pub/go-libfido2 v1.5.3)
- [x] `garden/fido2_hardware.go`: HardwareFIDO2 implementing FIDO2Device via libfido2 (`//go:build fido2`)
- [x] `garden/fido2_nohardware.go`: stub returning nil (`//go:build !fido2`)
- [x] `cmd/sgard/fido2.go`: unlockDEK helper, --fido2-pin flag
- [x] `cmd/sgard/encrypt.go`: add-fido2 uses real hardware, encrypt init --fido2 registers slot, all unlock calls use FIDO2-first resolution
- [x] `flake.nix`: sgard-fido2 package variant, libfido2+pkg-config in devShell
- [x] Tests: existing mock-based tests still pass; hardware tests require manual testing with a FIDO2 key
### Step 26: Test Cleanup
- [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
- [x] Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md)
- [x] Update flake.nix vendorHash (done in Step 25)
- [x] .goreleaser.yaml — no changes needed (CGO_ENABLED=0 is correct for release binaries)
- [x] E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull
- [x] Verify: all tests pass, lint clean, both binaries compile
## Phase 5: Per-Machine Targeting
### Step 28: Machine Identity + Targeting Core
- [x] `manifest/manifest.go`: add `Only []string` and `Never []string` to Entry
- [x] `garden/identity.go`: `Identity()` returns machine label set
- [x] `garden/targeting.go`: `EntryApplies(entry, labels)` match logic
- [x] `garden/tags.go`: `LoadTags`, `SaveTag`, `RemoveTag` for `<repo>/tags`
- [x] `garden/garden.go`: `Init` appends `tags` to `.gitignore`
- [x] Tests: 13 tests (identity, tags, matching: only, never, both-error, hostname, os, arch, tag, case-insensitive, multiple)
### Step 29: Operations Respect Targeting
- [x] `Checkpoint` skips entries where `!EntryApplies`
- [x] `Restore` skips entries where `!EntryApplies`
- [x] `Status` reports `skipped` for non-matching entries
- [x] `Add` accepts `Only`/`Never` in `AddOptions`, propagated through `addEntry`
- [x] Tests: 6 tests (checkpoint skip/process, status skipped, restore skip, add with only/never)
### Step 30: Targeting CLI Commands
- [x] `cmd/sgard/tag.go`: tag add/remove/list
- [x] `cmd/sgard/identity.go`: identity command
- [x] `cmd/sgard/add.go`: --only/--never flags
- [x] `cmd/sgard/target.go`: target command with --only/--never/--clear
- [x] `garden/target.go`: SetTargeting method
### Step 31: Proto + Sync Update
- [x] `proto/sgard/v1/sgard.proto`: only/never fields on ManifestEntry
- [x] `server/convert.go`: updated conversion
- [x] Regenerated proto
- [x] Tests: targeting round-trip test
### Step 32: Phase 5 Polish
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
- [x] E2e test: push/pull with targeting labels, restore respects targeting
- [x] Verify: all tests pass, lint clean, both binaries compile
## Standalone: File Exclusion
- [x] `manifest/manifest.go`: `Exclude []string` field on Manifest, `IsExcluded(tildePath)` method (exact match + directory prefix)
- [x] `garden/exclude.go`: `Exclude(paths)` and `Include(paths)` methods; Exclude removes already-tracked matching entries
- [x] `garden/garden.go`: Add's WalkDir checks `IsExcluded`, returns `filepath.SkipDir` for excluded directories
- [x] `garden/mirror.go`: MirrorUp skips excluded paths; MirrorDown leaves excluded files on disk
- [x] `cmd/sgard/exclude.go`: `sgard exclude <path>... [--list]`, `sgard include <path>...`
- [x] `proto/sgard/v1/sgard.proto`: `repeated string exclude = 7` on Manifest; regenerated
- [x] `server/convert.go`: round-trip Exclude field
- [x] `garden/exclude_test.go`: 8 tests (add/dedup/remove-tracked/include, Add skips file/dir, MirrorUp skips, MirrorDown leaves alone, IsExcluded prefix matching)
- [x] Update ARCHITECTURE.md, PROJECT_PLAN.md, PROGRESS.md
## Phase 6: Manifest Signing
(To be planned — requires trust model design)

206
README.md
View File

@@ -19,13 +19,14 @@ From source:
``` ```
git clone https://github.com/kisom/sgard && cd sgard git clone https://github.com/kisom/sgard && cd sgard
go build -o sgard ./cmd/sgard go build ./cmd/sgard ./cmd/sgardd
``` ```
Or install into `$GOBIN`: Or install into `$GOBIN`:
``` ```
go install github.com/kisom/sgard/cmd/sgard@latest go install github.com/kisom/sgard/cmd/sgard@latest
go install github.com/kisom/sgard/cmd/sgardd@latest
``` ```
NixOS (flake): NixOS (flake):
@@ -40,6 +41,21 @@ in your packages.
Binaries are also available on the Binaries are also available on the
[releases page](https://github.com/kisom/sgard/releases). [releases page](https://github.com/kisom/sgard/releases).
### Shell completion
```sh
# Bash (add to ~/.bashrc)
source <(sgard completion bash)
# Zsh (add to ~/.zshrc)
source <(sgard completion zsh)
# Fish
sgard completion fish | source
# To load on startup:
sgard completion fish > ~/.config/fish/completions/sgard.fish
```
## Quick start ## Quick start
```sh ```sh
@@ -67,12 +83,88 @@ sgard add ~/.bashrc --repo /mnt/usb/dotfiles
sgard restore --repo /mnt/usb/dotfiles sgard restore --repo /mnt/usb/dotfiles
``` ```
### Locked files
Some files get overwritten by the system (desktop environments,
package managers, etc.) but you want to keep them at a known-good
state. Locked files are repo-authoritative — `restore` always
overwrites them, and `checkpoint` never picks up the system's changes:
```sh
# XDG user-dirs.dirs gets reset by the desktop environment on login
sgard add --lock ~/.config/user-dirs.dirs
# The system overwrites it — status reports "drifted", not "modified"
sgard status
# drifted ~/.config/user-dirs.dirs
# Restore puts it back without prompting
sgard restore
```
Use `add` (without `--lock`) when you intentionally want to update the
repo with a new version of a locked file.
### Directory-only entries
Sometimes a directory must exist for software to work, but its
contents are managed elsewhere. `--dir` tracks the directory itself
without recursing:
```sh
# Ensure ~/.local/share/applications exists (some apps break without it)
sgard add --dir ~/.local/share/applications
```
On `restore`, sgard creates the directory with the correct permissions
but doesn't touch its contents.
### Per-machine targeting
Some files only apply to certain machines. Use `--only` and `--never`
to control where entries are active:
```sh
# Only restore on Linux
sgard add --only os:linux ~/.bashrc.linux
# Never restore on ARM
sgard add --never arch:arm64 ~/.config/heavy-tool
# Only on machines tagged "work"
sgard tag add work
sgard add --only tag:work ~/.ssh/work-config
# Only on a specific host
sgard add --only vade ~/.special-config
# See this machine's identity
sgard identity
# Change targeting on an existing entry
sgard target ~/.bashrc.linux --only os:linux,tag:desktop
sgard target ~/.bashrc.linux --clear
```
Labels: bare string = hostname, `os:linux`/`os:darwin`, `arch:amd64`/`arch:arm64`,
`tag:<name>` from local `<repo>/tags` file. `checkpoint`, `restore`, and
`status` skip non-matching entries automatically.
## Commands ## Commands
### Local
| Command | Description | | Command | Description |
|---|---| |---|---|
| `init` | Create a new repository | | `init` | Create a new repository |
| `add <path>...` | Track files, directories, or symlinks | | `add <path>...` | Track files, directories (recursed), or symlinks |
| `add --lock <path>...` | Track as locked (repo-authoritative, auto-restores on drift) |
| `add --dir <path>` | Track directory itself without recursing into contents |
| `add --only <labels>` | Track with per-machine targeting (only on matching) |
| `add --never <labels>` | Track with per-machine targeting (never on matching) |
| `target <path> --only/--never/--clear` | Set or clear targeting on existing entry |
| `tag add/remove/list` | Manage machine-local tags |
| `identity` | Show this machine's identity labels |
| `remove <path>...` | Stop tracking files | | `remove <path>...` | Stop tracking files |
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest | | `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
| `restore [path...] [-f]` | Restore files to their original locations | | `restore [path...] [-f]` | Restore files to their original locations |
@@ -80,8 +172,115 @@ sgard restore --repo /mnt/usb/dotfiles
| `diff <path>` | Show content diff between stored and current file | | `diff <path>` | Show content diff between stored and current file |
| `list` | List all tracked files | | `list` | List all tracked files |
| `verify` | Check blob store integrity against manifest hashes | | `verify` | Check blob store integrity against manifest hashes |
| `prune` | Remove orphaned blobs not referenced by the manifest |
| `mirror up <path>` | Sync filesystem → manifest (add new, remove deleted) |
| `mirror down <path> [-f]` | Sync manifest → filesystem (restore + delete untracked) |
| `version` | Print the version | | `version` | Print the version |
### Encryption
| Command | Description |
|---|---|
| `encrypt init` | Set up encryption (creates DEK + passphrase slot) |
| `encrypt add-fido2 [--label]` | Add a FIDO2 KEK slot |
| `encrypt remove-slot <name>` | Remove a KEK slot |
| `encrypt list-slots` | List all KEK slots |
| `encrypt change-passphrase` | Change the passphrase |
| `encrypt rotate-dek` | Generate new DEK and re-encrypt all encrypted blobs |
| `add --encrypt <path>...` | Track files with encryption |
### Remote sync
| Command | Description |
|---|---|
| `push` | Push checkpoint to remote gRPC server |
| `pull` | Pull checkpoint from remote gRPC server |
| `prune` | With `--remote`, prunes orphaned blobs on the server |
Remote commands require `--remote host:port` (or `SGARD_REMOTE` env, or a
`<repo>/remote` config file) and authenticate via SSH keys.
The server daemon `sgardd` is a separate binary (included in releases and
Nix builds).
## Remote sync
Start the daemon on your server:
```sh
sgard init --repo /srv/sgard
sgardd --authorized-keys ~/.ssh/authorized_keys
```
Push and pull from client machines:
```sh
sgard push --remote myserver:9473
sgard pull --remote myserver:9473
```
Authentication uses your existing SSH keys (ssh-agent, `~/.ssh/id_ed25519`,
or `--ssh-key`). No passwords or certificates to manage.
### TLS
To encrypt the connection with TLS:
```sh
# Server: provide cert and key
sgardd --tls-cert server.crt --tls-key server.key --authorized-keys ~/.ssh/authorized_keys
# Client: enable TLS (uses system CA pool)
sgard push --remote myserver:9473 --tls
# Client: with a custom/self-signed CA
sgard push --remote myserver:9473 --tls --tls-ca ca.crt
```
Without `--tls-cert`/`--tls-key`, sgardd runs without TLS (suitable for
localhost or trusted networks).
## Encryption
Sensitive files can be encrypted individually:
```sh
# Set up encryption (once per repo)
sgard encrypt init
# Add encrypted files
sgard add --encrypt ~/.ssh/config ~/.aws/credentials
# Plaintext files work as before
sgard add ~/.bashrc
```
Encrypted blobs use XChaCha20-Poly1305. The data encryption key (DEK)
is wrapped by a passphrase-derived key (Argon2id). FIDO2 hardware keys
are also supported as an alternative KEK source — sgard tries FIDO2
first and falls back to passphrase automatically.
### FIDO2 hardware keys
Build with `-tags fido2` (requires libfido2) to enable real hardware
key support, or use `nix build .#sgard-fido2`:
```sh
# Register a FIDO2 key (touch required)
sgard encrypt add-fido2
# With a PIN-protected device
sgard encrypt add-fido2 --fido2-pin 1234
# Unlock is automatic — FIDO2 is tried first, passphrase as fallback
sgard restore # touch your key when prompted
```
The encryption config (wrapped DEKs, salts) lives in the manifest, so
it syncs with push/pull. The server never has the DEK.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full encryption design.
## How it works ## How it works
sgard stores files in a content-addressable blob store keyed by SHA-256. sgard stores files in a content-addressable blob store keyed by SHA-256.
@@ -100,6 +299,7 @@ mtime. If the manifest is newer, the file is restored without prompting.
Otherwise, sgard asks for confirmation (`--force` skips the prompt). Otherwise, sgard asks for confirmation (`--force` skips the prompt).
Paths under `$HOME` are stored as `~/...` in the manifest, making it Paths under `$HOME` are stored as `~/...` in the manifest, making it
portable across machines with different usernames. portable across machines with different usernames. Adding a directory
recursively tracks all files and symlinks inside.
See [ARCHITECTURE.md](ARCHITECTURE.md) for full design details. See [ARCHITECTURE.md](ARCHITECTURE.md) for full design details.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
3.2.1

248
client/auth.go Normal file
View File

@@ -0,0 +1,248 @@
package client
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/status"
)
// TokenCredentials implements grpc.PerRPCCredentials using a cached JWT token.
// It is safe for concurrent use.
type TokenCredentials struct {
mu sync.RWMutex
token string
}
// NewTokenCredentials creates credentials with an initial token (may be empty).
func NewTokenCredentials(token string) *TokenCredentials {
return &TokenCredentials{token: token}
}
// SetToken updates the cached token.
func (c *TokenCredentials) SetToken(token string) {
c.mu.Lock()
defer c.mu.Unlock()
c.token = token
}
// GetRequestMetadata returns the token as gRPC metadata.
func (c *TokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.token == "" {
return nil, nil
}
return map[string]string{"x-sgard-auth-token": c.token}, nil
}
// RequireTransportSecurity returns false.
func (c *TokenCredentials) RequireTransportSecurity() bool {
return false
}
var _ credentials.PerRPCCredentials = (*TokenCredentials)(nil)
// TokenPath returns the XDG-compliant path for the token cache.
// Uses $XDG_STATE_HOME/sgard/token, falling back to ~/.local/state/sgard/token.
func TokenPath() (string, error) {
stateHome := os.Getenv("XDG_STATE_HOME")
if stateHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("determining home directory: %w", err)
}
stateHome = filepath.Join(home, ".local", "state")
}
return filepath.Join(stateHome, "sgard", "token"), nil
}
// LoadCachedToken reads the token from the XDG state path.
// Returns empty string if the file doesn't exist.
func LoadCachedToken() string {
path, err := TokenPath()
if err != nil {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// SaveToken writes the token to the XDG state path with 0600 permissions.
func SaveToken(token string) error {
path, err := TokenPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating token directory: %w", err)
}
return os.WriteFile(path, []byte(token+"\n"), 0o600)
}
// Authenticate calls the server's Authenticate RPC with an SSH-signed challenge.
// If challenge is non-nil (reauth fast path), uses the server-provided nonce.
// Otherwise generates a fresh nonce.
func Authenticate(ctx context.Context, rpc sgardpb.GardenSyncClient, signer ssh.Signer, challenge *sgardpb.ReauthChallenge) (string, error) {
var nonce []byte
var tsUnix int64
if challenge != nil {
nonce = challenge.GetNonce()
tsUnix = challenge.GetTimestamp()
} else {
var err error
nonce = make([]byte, 32)
if _, err = rand.Read(nonce); err != nil {
return "", fmt.Errorf("generating nonce: %w", err)
}
tsUnix = time.Now().Unix()
}
payload := buildPayload(nonce, tsUnix)
sig, err := signer.Sign(rand.Reader, payload)
if err != nil {
return "", fmt.Errorf("signing challenge: %w", err)
}
pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
resp, err := rpc.Authenticate(ctx, &sgardpb.AuthenticateRequest{
Nonce: nonce,
Timestamp: tsUnix,
Signature: ssh.Marshal(sig),
PublicKey: pubkeyStr,
})
if err != nil {
return "", fmt.Errorf("authenticate RPC: %w", err)
}
return resp.GetToken(), nil
}
// ExtractReauthChallenge extracts a ReauthChallenge from a gRPC error's
// details, if present. Returns nil if not found.
func ExtractReauthChallenge(err error) *sgardpb.ReauthChallenge {
st, ok := status.FromError(err)
if !ok {
return nil
}
for _, detail := range st.Details() {
if challenge, ok := detail.(*sgardpb.ReauthChallenge); ok {
return challenge
}
}
return nil
}
// buildPayload constructs nonce || timestamp (big-endian int64).
func buildPayload(nonce []byte, tsUnix int64) []byte {
payload := make([]byte, len(nonce)+8)
copy(payload, nonce)
for i := 7; i >= 0; i-- {
payload[len(nonce)+i] = byte(tsUnix & 0xff)
tsUnix >>= 8
}
return payload
}
// LoadSigner loads an SSH signer. Resolution order:
// 1. keyPath (if non-empty)
// 2. SSH agent (if SSH_AUTH_SOCK is set)
// 3. Default key paths: ~/.ssh/id_ed25519, ~/.ssh/id_rsa
func LoadSigner(keyPath string) (ssh.Signer, error) {
if keyPath != "" {
return loadSignerFromFile(keyPath)
}
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
conn, err := net.Dial("unix", sock)
if err == nil {
ag := agent.NewClient(conn)
signers, err := ag.Signers()
if err == nil && len(signers) > 0 {
return signers[0], nil
}
_ = conn.Close()
}
}
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("no SSH key found: %w", err)
}
for _, name := range []string{"id_ed25519", "id_rsa"} {
path := home + "/.ssh/" + name
signer, err := loadSignerFromFile(path)
if err == nil {
return signer, nil
}
}
return nil, fmt.Errorf("no SSH key found (tried --ssh-key, agent, ~/.ssh/id_ed25519, ~/.ssh/id_rsa)")
}
func loadSignerFromFile(path string) (ssh.Signer, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading key %s: %w", path, err)
}
signer, err := ssh.ParsePrivateKey(data)
if err != nil {
return nil, fmt.Errorf("parsing key %s: %w", path, err)
}
return signer, nil
}
// SSHCredentials is kept for backward compatibility in tests.
// It signs every request with SSH (the old approach).
type SSHCredentials struct {
signer ssh.Signer
}
func NewSSHCredentials(signer ssh.Signer) *SSHCredentials {
return &SSHCredentials{signer: signer}
}
func (c *SSHCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generating nonce: %w", err)
}
tsUnix := time.Now().Unix()
payload := buildPayload(nonce, tsUnix)
sig, err := c.signer.Sign(rand.Reader, payload)
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(c.signer.PublicKey())))
// Send as both token-style metadata (won't work) AND the old SSH fields
// for the Authenticate RPC. But this is only used in legacy tests.
return map[string]string{
"x-sgard-auth-nonce": base64.StdEncoding.EncodeToString(nonce),
"x-sgard-auth-timestamp": fmt.Sprintf("%d", tsUnix),
"x-sgard-auth-signature": base64.StdEncoding.EncodeToString(ssh.Marshal(sig)),
"x-sgard-auth-pubkey": pubkeyStr,
}, nil
}
func (c *SSHCredentials) RequireTransportSecurity() bool { return false }
var _ credentials.PerRPCCredentials = (*SSHCredentials)(nil)

57
client/auth_test.go Normal file
View File

@@ -0,0 +1,57 @@
package client
import (
"context"
"crypto/ed25519"
"crypto/rand"
"testing"
"golang.org/x/crypto/ssh"
)
func TestSSHCredentialsMetadata(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("creating signer: %v", err)
}
creds := NewSSHCredentials(signer)
md, err := creds.GetRequestMetadata(context.Background())
if err != nil {
t.Fatalf("GetRequestMetadata: %v", err)
}
// Verify all required fields are present and non-empty.
for _, key := range []string{
"x-sgard-auth-nonce",
"x-sgard-auth-timestamp",
"x-sgard-auth-signature",
"x-sgard-auth-pubkey",
} {
val, ok := md[key]
if !ok || val == "" {
t.Errorf("missing or empty metadata key %s", key)
}
}
}
func TestSSHCredentialsNoTransportSecurity(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("creating signer: %v", err)
}
creds := NewSSHCredentials(signer)
if creds.RequireTransportSecurity() {
t.Error("RequireTransportSecurity should be false")
}
}

320
client/client.go Normal file
View File

@@ -0,0 +1,320 @@
// Package client provides a gRPC client for the sgard GardenSync service.
package client
import (
"context"
"errors"
"fmt"
"io"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const chunkSize = 64 * 1024 // 64 KiB
// Client wraps a gRPC connection to a GardenSync server.
type Client struct {
rpc sgardpb.GardenSyncClient
creds *TokenCredentials // may be nil (no auth)
signer ssh.Signer // may be nil (no auth)
}
// New creates a Client from an existing gRPC connection (no auth).
func New(conn grpc.ClientConnInterface) *Client {
return &Client{rpc: sgardpb.NewGardenSyncClient(conn)}
}
// NewWithAuth creates a Client with token-based auth and auto-renewal.
// Loads any cached token automatically.
func NewWithAuth(conn grpc.ClientConnInterface, creds *TokenCredentials, signer ssh.Signer) *Client {
return &Client{
rpc: sgardpb.NewGardenSyncClient(conn),
creds: creds,
signer: signer,
}
}
// EnsureAuth ensures the client has a valid token. If no token is cached,
// authenticates with the server using the SSH signer.
func (c *Client) EnsureAuth(ctx context.Context) error {
if c.creds == nil || c.signer == nil {
return nil
}
// If we already have a token, assume it's valid until the server says otherwise.
md, _ := c.creds.GetRequestMetadata(ctx)
if md != nil && md["x-sgard-auth-token"] != "" {
return nil
}
// No token — do full auth.
return c.authenticate(ctx, nil)
}
// authenticate calls the Authenticate RPC and caches the resulting token.
func (c *Client) authenticate(ctx context.Context, challenge *sgardpb.ReauthChallenge) error {
token, err := Authenticate(ctx, c.rpc, c.signer, challenge)
if err != nil {
return err
}
c.creds.SetToken(token)
_ = SaveToken(token)
return nil
}
// retryOnAuth retries a function once after re-authenticating if it fails
// with Unauthenticated.
func (c *Client) retryOnAuth(ctx context.Context, fn func() error) error {
err := fn()
if err == nil || c.signer == nil {
return err
}
st, ok := status.FromError(err)
if !ok || st.Code() != codes.Unauthenticated {
return err
}
// Extract reauth challenge if present (fast path).
challenge := ExtractReauthChallenge(err)
if authErr := c.authenticate(ctx, challenge); authErr != nil {
return fmt.Errorf("re-authentication failed: %w", authErr)
}
// Retry the original call.
return fn()
}
// Push sends the local manifest and any missing blobs to the server.
// Returns the number of blobs sent, or an error. If the server is newer,
// returns ErrServerNewer. Automatically re-authenticates if the token expires.
func (c *Client) Push(ctx context.Context, g *garden.Garden) (int, error) {
var result int
err := c.retryOnAuth(ctx, func() error {
n, err := c.doPush(ctx, g)
result = n
return err
})
return result, err
}
func (c *Client) doPush(ctx context.Context, g *garden.Garden) (int, error) {
localManifest := g.GetManifest()
resp, err := c.rpc.PushManifest(ctx, &sgardpb.PushManifestRequest{
Manifest: server.ManifestToProto(localManifest),
})
if err != nil {
return 0, fmt.Errorf("push manifest: %w", err)
}
switch resp.Decision {
case sgardpb.PushManifestResponse_REJECTED:
return 0, ErrServerNewer
case sgardpb.PushManifestResponse_UP_TO_DATE:
return 0, nil
case sgardpb.PushManifestResponse_ACCEPTED:
// continue
default:
return 0, fmt.Errorf("unexpected decision: %v", resp.Decision)
}
// Step 2: stream missing blobs.
if len(resp.MissingBlobs) == 0 {
// Manifest accepted but no blobs needed — still need to call PushBlobs
// to trigger manifest replacement on the server.
stream, err := c.rpc.PushBlobs(ctx)
if err != nil {
return 0, fmt.Errorf("push blobs: %w", err)
}
_, err = stream.CloseAndRecv()
if err != nil {
return 0, fmt.Errorf("close push blobs: %w", err)
}
return 0, nil
}
stream, err := c.rpc.PushBlobs(ctx)
if err != nil {
return 0, fmt.Errorf("push blobs: %w", err)
}
for _, hash := range resp.MissingBlobs {
data, err := g.ReadBlob(hash)
if err != nil {
return 0, fmt.Errorf("reading local blob %s: %w", hash, err)
}
for i := 0; i < len(data); i += chunkSize {
end := i + chunkSize
if end > len(data) {
end = len(data)
}
chunk := &sgardpb.BlobChunk{Data: data[i:end]}
if i == 0 {
chunk.Hash = hash
}
if err := stream.Send(&sgardpb.PushBlobsRequest{Chunk: chunk}); err != nil {
return 0, fmt.Errorf("sending blob chunk: %w", err)
}
}
// Handle empty blobs.
if len(data) == 0 {
if err := stream.Send(&sgardpb.PushBlobsRequest{
Chunk: &sgardpb.BlobChunk{Hash: hash},
}); err != nil {
return 0, fmt.Errorf("sending empty blob: %w", err)
}
}
}
blobResp, err := stream.CloseAndRecv()
if err != nil {
return 0, fmt.Errorf("close push blobs: %w", err)
}
return int(blobResp.BlobsReceived), nil
}
// Pull downloads the server's manifest and any missing blobs to the local garden.
// Returns the number of blobs received, or an error. If the local manifest is
// newer or equal, returns 0 with no error. Automatically re-authenticates if needed.
func (c *Client) Pull(ctx context.Context, g *garden.Garden) (int, error) {
var result int
err := c.retryOnAuth(ctx, func() error {
n, err := c.doPull(ctx, g)
result = n
return err
})
return result, err
}
func (c *Client) doPull(ctx context.Context, g *garden.Garden) (int, error) {
pullResp, err := c.rpc.PullManifest(ctx, &sgardpb.PullManifestRequest{})
if err != nil {
return 0, fmt.Errorf("pull manifest: %w", err)
}
serverManifest := server.ProtoToManifest(pullResp.GetManifest())
localManifest := g.GetManifest()
// If local has files and is newer or equal, nothing to do.
if len(localManifest.Files) > 0 && !serverManifest.Updated.After(localManifest.Updated) {
return 0, nil
}
// Step 2: compute missing blobs.
var missingHashes []string
for _, e := range serverManifest.Files {
if e.Type == "file" && e.Hash != "" && !g.BlobExists(e.Hash) {
missingHashes = append(missingHashes, e.Hash)
}
}
// Step 3: pull missing blobs.
blobCount := 0
if len(missingHashes) > 0 {
stream, err := c.rpc.PullBlobs(ctx, &sgardpb.PullBlobsRequest{
Hashes: missingHashes,
})
if err != nil {
return 0, fmt.Errorf("pull blobs: %w", err)
}
var currentHash string
var buf []byte
for {
resp, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return 0, fmt.Errorf("receiving blob chunk: %w", err)
}
chunk := resp.GetChunk()
if chunk.GetHash() != "" {
// New blob starting. Write out the previous one.
if currentHash != "" {
if err := writeAndVerify(g, currentHash, buf); err != nil {
return 0, err
}
blobCount++
}
currentHash = chunk.GetHash()
buf = append([]byte(nil), chunk.GetData()...)
} else {
buf = append(buf, chunk.GetData()...)
}
}
// Write the last blob.
if currentHash != "" {
if err := writeAndVerify(g, currentHash, buf); err != nil {
return 0, err
}
blobCount++
}
}
// Step 4: replace local manifest.
if err := g.ReplaceManifest(serverManifest); err != nil {
return 0, fmt.Errorf("replacing local manifest: %w", err)
}
return blobCount, nil
}
// List fetches the server's manifest and returns its entries without
// downloading any blobs. Automatically re-authenticates if needed.
func (c *Client) List(ctx context.Context) ([]manifest.Entry, error) {
var entries []manifest.Entry
err := c.retryOnAuth(ctx, func() error {
resp, err := c.rpc.PullManifest(ctx, &sgardpb.PullManifestRequest{})
if err != nil {
return fmt.Errorf("list remote: %w", err)
}
m := server.ProtoToManifest(resp.GetManifest())
entries = m.Files
return nil
})
return entries, err
}
// Prune requests the server to remove orphaned blobs. Returns the count removed.
// Automatically re-authenticates if needed.
func (c *Client) Prune(ctx context.Context) (int, error) {
var result int
err := c.retryOnAuth(ctx, func() error {
resp, err := c.rpc.Prune(ctx, &sgardpb.PruneRequest{})
if err != nil {
return fmt.Errorf("prune: %w", err)
}
result = int(resp.BlobsRemoved)
return nil
})
return result, err
}
func writeAndVerify(g *garden.Garden, expectedHash string, data []byte) error {
gotHash, err := g.WriteBlob(data)
if err != nil {
return fmt.Errorf("writing blob: %w", err)
}
if gotHash != expectedHash {
return fmt.Errorf("blob hash mismatch: expected %s, got %s", expectedHash, gotHash)
}
return nil
}
// ErrServerNewer indicates the server's manifest is newer than the local one.
var ErrServerNewer = errors.New("server manifest is newer; pull first")

314
client/client_test.go Normal file
View File

@@ -0,0 +1,314 @@
package client
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
const bufSize = 1024 * 1024
// setupTest creates a gRPC client, server garden, and client garden
// connected via in-process bufconn.
func setupTest(t *testing.T) (*Client, *garden.Garden, *garden.Garden) {
t.Helper()
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
clientDir := t.TempDir()
clientGarden, err := garden.Init(clientDir)
if err != nil {
t.Fatalf("init client garden: %v", err)
}
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer()
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
t.Cleanup(func() { srv.Stop() })
go func() {
_ = srv.Serve(lis)
}()
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial bufconn: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
c := New(conn)
return c, serverGarden, clientGarden
}
func TestPushAndPull(t *testing.T) {
c, serverGarden, clientGarden := setupTest(t)
ctx := context.Background()
// Create files in a temp directory and add them to the client garden.
root := t.TempDir()
bashrc := filepath.Join(root, "bashrc")
gitconfig := filepath.Join(root, "gitconfig")
if err := os.WriteFile(bashrc, []byte("export PS1='$ '\n"), 0o644); err != nil {
t.Fatalf("writing bashrc: %v", err)
}
if err := os.WriteFile(gitconfig, []byte("[user]\n\tname = test\n"), 0o644); err != nil {
t.Fatalf("writing gitconfig: %v", err)
}
if err := clientGarden.Add([]string{bashrc, gitconfig}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := clientGarden.Checkpoint("initial"); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
// Push from client to server.
pushed, err := c.Push(ctx, clientGarden)
if err != nil {
t.Fatalf("Push: %v", err)
}
if pushed != 2 {
t.Errorf("pushed %d blobs, want 2", pushed)
}
// Verify server has the blobs.
clientManifest := clientGarden.GetManifest()
for _, e := range clientManifest.Files {
if e.Type == "file" && !serverGarden.BlobExists(e.Hash) {
t.Errorf("server missing blob for %s", e.Path)
}
}
// Verify server manifest matches.
serverManifest := serverGarden.GetManifest()
if len(serverManifest.Files) != len(clientManifest.Files) {
t.Errorf("server has %d entries, want %d", len(serverManifest.Files), len(clientManifest.Files))
}
// Pull into a fresh garden. Backdate its manifest so the server is "newer".
freshDir := t.TempDir()
freshGarden, err := garden.Init(freshDir)
if err != nil {
t.Fatalf("init fresh garden: %v", err)
}
oldManifest := freshGarden.GetManifest()
oldManifest.Updated = oldManifest.Updated.Add(-2 * time.Hour)
if err := freshGarden.ReplaceManifest(oldManifest); err != nil {
t.Fatalf("backdate fresh manifest: %v", err)
}
pulled, err := c.Pull(ctx, freshGarden)
if err != nil {
t.Fatalf("Pull: %v", err)
}
if pulled != 2 {
t.Errorf("pulled %d blobs, want 2", pulled)
}
// Verify fresh garden has the correct manifest and blobs.
freshManifest := freshGarden.GetManifest()
if len(freshManifest.Files) != len(clientManifest.Files) {
t.Fatalf("fresh garden has %d entries, want %d", len(freshManifest.Files), len(clientManifest.Files))
}
for _, e := range freshManifest.Files {
if e.Type == "file" && !freshGarden.BlobExists(e.Hash) {
t.Errorf("fresh garden missing blob for %s", e.Path)
}
}
}
func TestPushServerNewer(t *testing.T) {
c, serverGarden, clientGarden := setupTest(t)
ctx := context.Background()
// Make server newer by checkpointing it.
root := t.TempDir()
f := filepath.Join(root, "file")
if err := os.WriteFile(f, []byte("server file"), 0o644); err != nil {
t.Fatalf("writing file: %v", err)
}
if err := serverGarden.Add([]string{f}); err != nil {
t.Fatalf("server Add: %v", err)
}
if err := serverGarden.Checkpoint("server ahead"); err != nil {
t.Fatalf("server Checkpoint: %v", err)
}
_, err := c.Push(ctx, clientGarden)
if !errors.Is(err, ErrServerNewer) {
t.Errorf("expected ErrServerNewer, got %v", err)
}
}
func TestPushUpToDate(t *testing.T) {
c, _, clientGarden := setupTest(t)
ctx := context.Background()
// Both gardens are freshly initialized with same timestamp (approximately).
// Push should return 0 blobs.
pushed, err := c.Push(ctx, clientGarden)
if err != nil {
t.Fatalf("Push: %v", err)
}
if pushed != 0 {
t.Errorf("pushed %d blobs, want 0 for up-to-date", pushed)
}
}
func TestPullUpToDate(t *testing.T) {
c, _, clientGarden := setupTest(t)
ctx := context.Background()
pulled, err := c.Pull(ctx, clientGarden)
if err != nil {
t.Fatalf("Pull: %v", err)
}
if pulled != 0 {
t.Errorf("pulled %d blobs, want 0 for up-to-date", pulled)
}
}
func TestPrune(t *testing.T) {
c, serverGarden, _ := setupTest(t)
ctx := context.Background()
// Write an orphan blob to the server.
_, err := serverGarden.WriteBlob([]byte("orphan"))
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
removed, err := c.Prune(ctx)
if err != nil {
t.Fatalf("Prune: %v", err)
}
if removed != 1 {
t.Errorf("removed %d blobs, want 1", removed)
}
}
var testJWTKey = []byte("test-jwt-secret-key-32-bytes!!")
func TestTokenAuthIntegration(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("creating signer: %v", err)
}
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()}, testJWTKey)
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer(
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
grpc.StreamInterceptor(auth.StreamInterceptor()),
)
sgardpb.RegisterGardenSyncServer(srv, server.NewWithAuth(serverGarden, auth))
t.Cleanup(func() { srv.Stop() })
go func() { _ = srv.Serve(lis) }()
// Client with token auth + auto-renewal.
creds := NewTokenCredentials("")
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithPerRPCCredentials(creds),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
c := NewWithAuth(conn, creds, signer)
// No token yet — EnsureAuth should authenticate via SSH.
ctx := context.Background()
if err := c.EnsureAuth(ctx); err != nil {
t.Fatalf("EnsureAuth: %v", err)
}
// Now requests should work.
_, err = c.Pull(ctx, serverGarden)
if err != nil {
t.Fatalf("authenticated Pull should succeed: %v", err)
}
}
func TestAuthRejectsUnauthenticated(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("creating signer: %v", err)
}
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()}, testJWTKey)
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer(
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
grpc.StreamInterceptor(auth.StreamInterceptor()),
)
sgardpb.RegisterGardenSyncServer(srv, server.NewWithAuth(serverGarden, auth))
t.Cleanup(func() { srv.Stop() })
go func() { _ = srv.Serve(lis) }()
// Client WITHOUT credentials — no token, no signer.
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
c := New(conn)
_, err = c.Pull(context.Background(), serverGarden)
if err == nil {
t.Fatal("unauthenticated Pull should fail")
}
}

160
client/e2e_test.go Normal file
View File

@@ -0,0 +1,160 @@
package client
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
// TestE2EPushPullCycle tests the full lifecycle:
// init two repos → add files to client → checkpoint → push → pull into fresh repo → verify
func TestE2EPushPullCycle(t *testing.T) {
// Generate auth key.
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("creating signer: %v", err)
}
// Set up server.
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server: %v", err)
}
jwtKey := []byte("e2e-test-jwt-secret-key-32bytes!")
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()}, jwtKey)
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer(
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
grpc.StreamInterceptor(auth.StreamInterceptor()),
)
sgardpb.RegisterGardenSyncServer(srv, server.NewWithAuth(serverGarden, auth))
t.Cleanup(func() { srv.Stop() })
go func() { _ = srv.Serve(lis) }()
dial := func(t *testing.T) *Client {
t.Helper()
creds := NewTokenCredentials("")
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithPerRPCCredentials(creds),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
c := NewWithAuth(conn, creds, signer)
if err := c.EnsureAuth(context.Background()); err != nil {
t.Fatalf("EnsureAuth: %v", err)
}
return c
}
ctx := context.Background()
// --- Client A: add files and push ---
clientADir := t.TempDir()
clientA, err := garden.Init(clientADir)
if err != nil {
t.Fatalf("init client A: %v", err)
}
// Create test dotfiles.
root := t.TempDir()
bashrc := filepath.Join(root, "bashrc")
sshConfig := filepath.Join(root, "ssh_config")
if err := os.WriteFile(bashrc, []byte("export PS1='$ '\n"), 0o644); err != nil {
t.Fatalf("writing bashrc: %v", err)
}
if err := os.WriteFile(sshConfig, []byte("Host *\n AddKeysToAgent yes\n"), 0o600); err != nil {
t.Fatalf("writing ssh_config: %v", err)
}
if err := clientA.Add([]string{bashrc, sshConfig}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := clientA.Checkpoint("from machine A"); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
c := dial(t)
pushed, err := c.Push(ctx, clientA)
if err != nil {
t.Fatalf("Push: %v", err)
}
if pushed != 2 {
t.Errorf("pushed %d blobs, want 2", pushed)
}
// --- Client B: pull from server ---
clientBDir := t.TempDir()
clientB, err := garden.Init(clientBDir)
if err != nil {
t.Fatalf("init client B: %v", err)
}
// Backdate so server is newer.
bm := clientB.GetManifest()
bm.Updated = bm.Updated.Add(-2 * time.Hour)
if err := clientB.ReplaceManifest(bm); err != nil {
t.Fatalf("backdate: %v", err)
}
c2 := dial(t)
pulled, err := c2.Pull(ctx, clientB)
if err != nil {
t.Fatalf("Pull: %v", err)
}
if pulled != 2 {
t.Errorf("pulled %d blobs, want 2", pulled)
}
// Verify client B has the same manifest and blobs as client A.
manifestA := clientA.GetManifest()
manifestB := clientB.GetManifest()
if len(manifestB.Files) != len(manifestA.Files) {
t.Fatalf("client B has %d entries, want %d", len(manifestB.Files), len(manifestA.Files))
}
for _, e := range manifestB.Files {
if e.Type == "file" {
dataA, err := clientA.ReadBlob(e.Hash)
if err != nil {
t.Fatalf("read blob from A: %v", err)
}
dataB, err := clientB.ReadBlob(e.Hash)
if err != nil {
t.Fatalf("read blob from B: %v", err)
}
if string(dataA) != string(dataB) {
t.Errorf("blob %s content mismatch between A and B", e.Hash)
}
}
}
// Verify manifest message survived.
if manifestB.Message != "from machine A" {
t.Errorf("message = %q, want 'from machine A'", manifestB.Message)
}
}

View File

@@ -2,9 +2,19 @@ package main
import ( import (
"fmt" "fmt"
"os"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
)
var (
encryptFlag bool
lockFlag bool
dirOnlyFlag bool
onlyFlag []string
neverFlag []string
) )
var addCmd = &cobra.Command{ var addCmd = &cobra.Command{
@@ -17,7 +27,28 @@ var addCmd = &cobra.Command{
return err return err
} }
if err := g.Add(args); err != nil { if encryptFlag {
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized; run sgard encrypt init first")
}
if err := unlockDEK(g); err != nil {
return err
}
}
if len(onlyFlag) > 0 && len(neverFlag) > 0 {
return fmt.Errorf("--only and --never are mutually exclusive")
}
opts := garden.AddOptions{
Encrypt: encryptFlag,
Lock: lockFlag,
DirOnly: dirOnlyFlag,
Only: onlyFlag,
Never: neverFlag,
}
if err := g.Add(args, opts); err != nil {
return err return err
} }
@@ -26,6 +57,25 @@ var addCmd = &cobra.Command{
}, },
} }
func promptPassphrase() (string, error) {
fmt.Fprint(os.Stderr, "Passphrase: ")
fd := int(os.Stdin.Fd())
passphrase, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("reading passphrase: %w", err)
}
if len(passphrase) == 0 {
return "", fmt.Errorf("no passphrase provided")
}
return string(passphrase), nil
}
func init() { func init() {
addCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "encrypt file contents before storing")
addCmd.Flags().BoolVar(&lockFlag, "lock", false, "mark as locked (repo-authoritative, restore always overwrites)")
addCmd.Flags().BoolVar(&dirOnlyFlag, "dir", false, "track directory itself without recursing into contents")
addCmd.Flags().StringSliceVar(&onlyFlag, "only", nil, "only apply on machines matching these labels (comma-separated)")
addCmd.Flags().StringSliceVar(&neverFlag, "never", nil, "never apply on machines matching these labels (comma-separated)")
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
} }

View File

@@ -18,6 +18,12 @@ var checkpointCmd = &cobra.Command{
return err return err
} }
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
if err := g.Checkpoint(checkpointMessage); err != nil { if err := g.Checkpoint(checkpointMessage); err != nil {
return err return err
} }

View File

@@ -17,6 +17,12 @@ var diffCmd = &cobra.Command{
return err return err
} }
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
d, err := g.Diff(args[0]) d, err := g.Diff(args[0])
if err != nil { if err != nil {
return err return err

214
cmd/sgard/encrypt.go Normal file
View File

@@ -0,0 +1,214 @@
package main
import (
"fmt"
"sort"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var encryptCmd = &cobra.Command{
Use: "encrypt",
Short: "Manage encryption keys and slots",
}
var fido2InitFlag bool
var encryptInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize encryption (creates DEK and passphrase slot)",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
passphrase, err := promptPassphrase()
if err != nil {
return err
}
if err := g.EncryptInit(passphrase); err != nil {
return err
}
fmt.Println("Encryption initialized with passphrase slot.")
if fido2InitFlag {
device := garden.DetectHardwareFIDO2(fido2PinFlag)
if device == nil {
fmt.Println("No FIDO2 device detected. Run 'sgard encrypt add-fido2' when one is connected.")
} else {
fmt.Println("Touch your FIDO2 device to register...")
if err := g.AddFIDO2Slot(device, fido2LabelFlag); err != nil {
return fmt.Errorf("adding FIDO2 slot: %w", err)
}
fmt.Println("FIDO2 slot added.")
}
}
return nil
},
}
var fido2LabelFlag string
var addFido2Cmd = &cobra.Command{
Use: "add-fido2",
Short: "Add a FIDO2 KEK slot",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized; run sgard encrypt init first")
}
if err := unlockDEK(g); err != nil {
return err
}
device := garden.DetectHardwareFIDO2(fido2PinFlag)
if device == nil {
return fmt.Errorf("no FIDO2 device detected; connect a FIDO2 key and try again")
}
fmt.Println("Touch your FIDO2 device to register...")
if err := g.AddFIDO2Slot(device, fido2LabelFlag); err != nil {
return err
}
fmt.Println("FIDO2 slot added.")
return nil
},
}
var removeSlotCmd = &cobra.Command{
Use: "remove-slot <name>",
Short: "Remove a KEK slot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.RemoveSlot(args[0]); err != nil {
return err
}
fmt.Printf("Removed slot %q.\n", args[0])
return nil
},
}
var listSlotsCmd = &cobra.Command{
Use: "list-slots",
Short: "List all KEK slots",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
slots := g.ListSlots()
if len(slots) == 0 {
fmt.Println("No encryption configured.")
return nil
}
// Sort for consistent output.
names := make([]string, 0, len(slots))
for name := range slots {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Printf("%-30s %s\n", name, slots[name])
}
return nil
},
}
var changePassphraseCmd = &cobra.Command{
Use: "change-passphrase",
Short: "Change the passphrase for the passphrase KEK slot",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized")
}
// Unlock with current credentials.
if err := unlockDEK(g); err != nil {
return err
}
// Get new passphrase.
fmt.Println("Enter new passphrase:")
newPassphrase, err := promptPassphrase()
if err != nil {
return err
}
if err := g.ChangePassphrase(newPassphrase); err != nil {
return err
}
fmt.Println("Passphrase changed.")
return nil
},
}
var rotateDEKCmd = &cobra.Command{
Use: "rotate-dek",
Short: "Generate a new DEK and re-encrypt all encrypted blobs",
Long: "Generates a new data encryption key, re-encrypts all encrypted blobs, and re-wraps the DEK with all KEK slots. Required when the DEK is suspected compromised.",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized")
}
// Unlock with current credentials.
if err := unlockDEK(g); err != nil {
return err
}
// Rotate — re-prompts for passphrase to re-wrap slot.
fmt.Println("Enter passphrase to re-wrap DEK:")
device := garden.DetectHardwareFIDO2(fido2PinFlag)
if err := g.RotateDEK(promptPassphrase, device); err != nil {
return err
}
fmt.Println("DEK rotated. All encrypted blobs re-encrypted.")
return nil
},
}
func init() {
encryptInitCmd.Flags().BoolVar(&fido2InitFlag, "fido2", false, "also register a FIDO2 hardware key")
addFido2Cmd.Flags().StringVar(&fido2LabelFlag, "label", "", "slot label (default: fido2/<hostname>)")
encryptCmd.AddCommand(encryptInitCmd)
encryptCmd.AddCommand(addFido2Cmd)
encryptCmd.AddCommand(removeSlotCmd)
encryptCmd.AddCommand(listSlotsCmd)
encryptCmd.AddCommand(changePassphraseCmd)
encryptCmd.AddCommand(rotateDEKCmd)
rootCmd.AddCommand(encryptCmd)
}

65
cmd/sgard/exclude.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import (
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var listExclude bool
var excludeCmd = &cobra.Command{
Use: "exclude [path...]",
Short: "Exclude paths from tracking",
Long: "Add paths to the exclusion list. Excluded paths are skipped during add and mirror operations. Use --list to show current exclusions.",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if listExclude {
for _, p := range g.GetManifest().Exclude {
fmt.Println(p)
}
return nil
}
if len(args) == 0 {
return fmt.Errorf("provide paths to exclude, or use --list")
}
if err := g.Exclude(args); err != nil {
return err
}
fmt.Printf("Excluded %d path(s)\n", len(args))
return nil
},
}
var includeCmd = &cobra.Command{
Use: "include <path>...",
Short: "Remove paths from the exclusion list",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.Include(args); err != nil {
return err
}
fmt.Printf("Included %d path(s)\n", len(args))
return nil
},
}
func init() {
excludeCmd.Flags().BoolVarP(&listExclude, "list", "l", false, "list current exclusions")
rootCmd.AddCommand(excludeCmd)
rootCmd.AddCommand(includeCmd)
}

12
cmd/sgard/fido2.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import "github.com/kisom/sgard/garden"
var fido2PinFlag string
// unlockDEK attempts to unlock the DEK, trying FIDO2 hardware first
// (if available) and falling back to passphrase.
func unlockDEK(g *garden.Garden) error {
device := garden.DetectHardwareFIDO2(fido2PinFlag)
return g.UnlockDEK(promptPassphrase, device)
}

27
cmd/sgard/identity.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var identityCmd = &cobra.Command{
Use: "identity",
Short: "Show this machine's identity labels",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
for _, label := range g.Identity() {
fmt.Println(label)
}
return nil
},
}
func init() {
rootCmd.AddCommand(identityCmd)
}

79
cmd/sgard/info.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"fmt"
"strings"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var infoCmd = &cobra.Command{
Use: "info <path>",
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)
}

View File

@@ -1,41 +1,72 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all tracked files", Short: "List all tracked files",
Long: "List all tracked files locally, or on the remote server with -r.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag) addr, _, _, _ := resolveRemoteConfig()
if err != nil { if addr != "" {
return err return listRemote()
} }
return listLocal()
entries := g.List()
for _, e := range entries {
switch e.Type {
case "file":
hash := e.Hash
if len(hash) > 8 {
hash = hash[:8]
}
fmt.Printf("%-6s %s\t%s\n", "file", e.Path, hash)
case "link":
fmt.Printf("%-6s %s\t-> %s\n", "link", e.Path, e.Target)
case "directory":
fmt.Printf("%-6s %s\n", "dir", e.Path)
}
}
return nil
}, },
} }
func listLocal() error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
printEntries(g.List())
return nil
}
func listRemote() error {
ctx := context.Background()
c, cleanup, err := dialRemote(ctx)
if err != nil {
return err
}
defer cleanup()
entries, err := c.List(ctx)
if err != nil {
return err
}
printEntries(entries)
return nil
}
func printEntries(entries []manifest.Entry) {
for _, e := range entries {
switch e.Type {
case "file":
hash := e.Hash
if len(hash) > 8 {
hash = hash[:8]
}
fmt.Printf("%-6s %s\t%s\n", "file", e.Path, hash)
case "link":
fmt.Printf("%-6s %s\t-> %s\n", "link", e.Path, e.Target)
case "directory":
fmt.Printf("%-6s %s\n", "dir", e.Path)
}
}
}
func init() { func init() {
rootCmd.AddCommand(listCmd) rootCmd.AddCommand(listCmd)
} }

51
cmd/sgard/lock.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var lockCmd = &cobra.Command{
Use: "lock <path>...",
Short: "Mark tracked files as locked (repo-authoritative)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.Lock(args); err != nil {
return err
}
fmt.Printf("Locked %d path(s).\n", len(args))
return nil
},
}
var unlockCmd = &cobra.Command{
Use: "unlock <path>...",
Short: "Remove locked flag from tracked files",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.Unlock(args); err != nil {
return err
}
fmt.Printf("Unlocked %d path(s).\n", len(args))
return nil
},
}
func init() {
rootCmd.AddCommand(lockCmd)
rootCmd.AddCommand(unlockCmd)
}

View File

@@ -1,14 +1,28 @@
package main package main
import ( import (
"context"
"crypto/tls"
"crypto/x509"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/kisom/sgard/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
) )
var repoFlag string var (
repoFlag string
remoteFlag string
sshKeyFlag string
tlsFlag bool
tlsCAFlag string
)
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "sgard", Use: "sgard",
@@ -23,8 +37,107 @@ func defaultRepo() string {
return filepath.Join(home, ".sgard") return filepath.Join(home, ".sgard")
} }
// resolveRemoteConfig returns the effective remote address, TLS flag, and CA
// path by merging CLI flags, environment, and the saved remote.yaml config.
// CLI flags take precedence, then env, then the saved config.
func resolveRemoteConfig() (addr string, useTLS bool, caPath string, err error) {
// Start with saved config as baseline.
saved, _ := loadRemoteConfig()
// Address: flag > env > saved > legacy file.
addr = remoteFlag
if addr == "" {
addr = os.Getenv("SGARD_REMOTE")
}
if addr == "" && saved != nil {
addr = saved.Addr
}
if addr == "" {
data, ferr := os.ReadFile(filepath.Join(repoFlag, "remote"))
if ferr == nil {
addr = strings.TrimSpace(string(data))
}
}
if addr == "" {
return "", false, "", fmt.Errorf("no remote configured; use 'sgard remote set' or --remote")
}
// TLS: flag wins if explicitly set, otherwise use saved.
useTLS = tlsFlag
if !useTLS && saved != nil {
useTLS = saved.TLS
}
// CA: flag wins if set, otherwise use saved.
caPath = tlsCAFlag
if caPath == "" && saved != nil {
caPath = saved.TLSCA
}
return addr, useTLS, caPath, nil
}
// dialRemote creates a gRPC client with token-based auth and auto-renewal.
func dialRemote(ctx context.Context) (*client.Client, func(), error) {
addr, useTLS, caPath, err := resolveRemoteConfig()
if err != nil {
return nil, nil, err
}
signer, err := client.LoadSigner(sshKeyFlag)
if err != nil {
return nil, nil, err
}
cachedToken := client.LoadCachedToken()
creds := client.NewTokenCredentials(cachedToken)
var transportCreds grpc.DialOption
if useTLS {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if caPath != "" {
caPEM, err := os.ReadFile(caPath)
if err != nil {
return nil, nil, fmt.Errorf("reading CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, nil, fmt.Errorf("failed to parse CA cert %s", caPath)
}
tlsCfg.RootCAs = pool
}
transportCreds = grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))
} else {
transportCreds = grpc.WithTransportCredentials(insecure.NewCredentials())
}
conn, err := grpc.NewClient(addr,
transportCreds,
grpc.WithPerRPCCredentials(creds),
)
if err != nil {
return nil, nil, fmt.Errorf("connecting to %s: %w", addr, err)
}
c := client.NewWithAuth(conn, creds, signer)
// Ensure we have a valid token before proceeding.
if err := c.EnsureAuth(ctx); err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("authentication: %w", err)
}
cleanup := func() { _ = conn.Close() }
return c, cleanup, nil
}
func main() { func main() {
rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository") rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository")
rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "gRPC server address (host:port)")
rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key")
rootCmd.PersistentFlags().BoolVar(&tlsFlag, "tls", false, "use TLS for remote connection")
rootCmd.PersistentFlags().StringVar(&tlsCAFlag, "tls-ca", "", "path to CA certificate for TLS verification")
rootCmd.PersistentFlags().StringVar(&fido2PinFlag, "fido2-pin", "", "PIN for FIDO2 device (if PIN-protected)")
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)

72
cmd/sgard/mirror.go Normal file
View File

@@ -0,0 +1,72 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var forceMirror bool
var mirrorCmd = &cobra.Command{
Use: "mirror",
Short: "Sync directory contents between filesystem and manifest",
}
var mirrorUpCmd = &cobra.Command{
Use: "up <path>...",
Short: "Sync filesystem state into manifest (add new, remove deleted, rehash changed)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.MirrorUp(args); err != nil {
return err
}
fmt.Println("Mirror up complete.")
return nil
},
}
var mirrorDownCmd = &cobra.Command{
Use: "down <path>...",
Short: "Sync manifest state to filesystem (restore tracked, delete untracked)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
confirm := func(path string) bool {
fmt.Printf("Delete untracked file %s? [y/N] ", path)
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "y" || answer == "yes"
}
return false
}
if err := g.MirrorDown(args, forceMirror, confirm); err != nil {
return err
}
fmt.Println("Mirror down complete.")
return nil
},
}
func init() {
mirrorDownCmd.Flags().BoolVarP(&forceMirror, "force", "f", false, "delete untracked files without prompting")
mirrorCmd.AddCommand(mirrorUpCmd, mirrorDownCmd)
rootCmd.AddCommand(mirrorCmd)
}

60
cmd/sgard/prune.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"context"
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var pruneCmd = &cobra.Command{
Use: "prune",
Short: "Remove orphaned blobs not referenced by the manifest",
Long: "Remove orphaned blobs locally, or on the remote server with --remote.",
RunE: func(cmd *cobra.Command, args []string) error {
addr, _, _, _ := resolveRemoteConfig()
if addr != "" {
return pruneRemote()
}
return pruneLocal()
},
}
func pruneLocal() error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
removed, err := g.Prune()
if err != nil {
return err
}
fmt.Printf("Pruned %d orphaned blob(s).\n", removed)
return nil
}
func pruneRemote() error {
ctx := context.Background()
c, cleanup, err := dialRemote(ctx)
if err != nil {
return err
}
defer cleanup()
removed, err := c.Prune(ctx)
if err != nil {
return err
}
fmt.Printf("Pruned %d orphaned blob(s) on remote.\n", removed)
return nil
}
func init() {
rootCmd.AddCommand(pruneCmd)
}

61
cmd/sgard/pull.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"context"
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var pullCmd = &cobra.Command{
Use: "pull",
Short: "Pull checkpoint from remote server and restore files",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
g, err := garden.Open(repoFlag)
if err != nil {
// Repo doesn't exist yet — init it so pull can populate it.
g, err = garden.Init(repoFlag)
if err != nil {
return fmt.Errorf("init repo for pull: %w", err)
}
}
c, cleanup, err := dialRemote(ctx)
if err != nil {
return err
}
defer cleanup()
pulled, err := c.Pull(ctx, g)
if err != nil {
return err
}
if pulled == 0 {
fmt.Println("Already up to date.")
return nil
}
fmt.Printf("Pulled %d blob(s).\n", pulled)
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
if err := g.Restore(nil, true, nil); err != nil {
return fmt.Errorf("restore after pull: %w", err)
}
fmt.Println("Restore complete.")
return nil
},
}
func init() {
rootCmd.AddCommand(pullCmd)
}

46
cmd/sgard/push.go Normal file
View File

@@ -0,0 +1,46 @@
package main
import (
"context"
"errors"
"fmt"
"github.com/kisom/sgard/client"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var pushCmd = &cobra.Command{
Use: "push",
Short: "Push local checkpoint to remote server",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
c, cleanup, err := dialRemote(ctx)
if err != nil {
return err
}
defer cleanup()
pushed, err := c.Push(ctx, g)
if errors.Is(err, client.ErrServerNewer) {
fmt.Println("Server is newer; run sgard pull instead.")
return nil
}
if err != nil {
return err
}
fmt.Printf("Pushed %d blob(s).\n", pushed)
return nil
},
}
func init() {
rootCmd.AddCommand(pushCmd)
}

97
cmd/sgard/remote.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
type remoteConfig struct {
Addr string `yaml:"addr"`
TLS bool `yaml:"tls"`
TLSCA string `yaml:"tls_ca,omitempty"`
}
func remoteConfigPath() string {
return filepath.Join(repoFlag, "remote.yaml")
}
func loadRemoteConfig() (*remoteConfig, error) {
data, err := os.ReadFile(remoteConfigPath())
if err != nil {
return nil, err
}
var cfg remoteConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing remote config: %w", err)
}
return &cfg, nil
}
func saveRemoteConfig(cfg *remoteConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("encoding remote config: %w", err)
}
return os.WriteFile(remoteConfigPath(), data, 0o644)
}
var remoteCmd = &cobra.Command{
Use: "remote",
Short: "Manage default remote server",
}
var remoteSetCmd = &cobra.Command{
Use: "set <addr>",
Short: "Set the default remote address",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg := &remoteConfig{
Addr: args[0],
TLS: tlsFlag,
TLSCA: tlsCAFlag,
}
if err := saveRemoteConfig(cfg); err != nil {
return err
}
fmt.Printf("Remote set: %s", cfg.Addr)
if cfg.TLS {
fmt.Print(" (TLS")
if cfg.TLSCA != "" {
fmt.Printf(", CA: %s", cfg.TLSCA)
}
fmt.Print(")")
}
fmt.Println()
return nil
},
}
var remoteShowCmd = &cobra.Command{
Use: "show",
Short: "Show the configured remote",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadRemoteConfig()
if err != nil {
if os.IsNotExist(err) {
fmt.Println("No remote configured.")
return nil
}
return err
}
fmt.Printf("addr: %s\n", cfg.Addr)
fmt.Printf("tls: %v\n", cfg.TLS)
if cfg.TLSCA != "" {
fmt.Printf("tls-ca: %s\n", cfg.TLSCA)
}
return nil
},
}
func init() {
remoteCmd.AddCommand(remoteSetCmd, remoteShowCmd)
rootCmd.AddCommand(remoteCmd)
}

View File

@@ -21,6 +21,12 @@ var restoreCmd = &cobra.Command{
return err return err
} }
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
confirm := func(path string) bool { confirm := func(path string) bool {
fmt.Printf("Overwrite %s? [y/N] ", path) fmt.Printf("Overwrite %s? [y/N] ", path)
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)

76
cmd/sgard/tag.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"fmt"
"sort"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var tagCmd = &cobra.Command{
Use: "tag",
Short: "Manage machine tags for per-machine targeting",
}
var tagAddCmd = &cobra.Command{
Use: "add <name>",
Short: "Add a tag to this machine",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.SaveTag(args[0]); err != nil {
return err
}
fmt.Printf("Tag %q added.\n", args[0])
return nil
},
}
var tagRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a tag from this machine",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.RemoveTag(args[0]); err != nil {
return err
}
fmt.Printf("Tag %q removed.\n", args[0])
return nil
},
}
var tagListCmd = &cobra.Command{
Use: "list",
Short: "List tags on this machine",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
tags := g.LoadTags()
if len(tags) == 0 {
fmt.Println("No tags set.")
return nil
}
sort.Strings(tags)
for _, tag := range tags {
fmt.Println(tag)
}
return nil
},
}
func init() {
tagCmd.AddCommand(tagAddCmd)
tagCmd.AddCommand(tagRemoveCmd)
tagCmd.AddCommand(tagListCmd)
rootCmd.AddCommand(tagCmd)
}

48
cmd/sgard/target.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var (
targetOnlyFlag []string
targetNeverFlag []string
targetClearFlag bool
)
var targetCmd = &cobra.Command{
Use: "target <path>",
Short: "Set or clear targeting labels on a tracked entry",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if len(targetOnlyFlag) > 0 && len(targetNeverFlag) > 0 {
return fmt.Errorf("--only and --never are mutually exclusive")
}
if err := g.SetTargeting(args[0], targetOnlyFlag, targetNeverFlag, targetClearFlag); err != nil {
return err
}
if targetClearFlag {
fmt.Printf("Cleared targeting for %s.\n", args[0])
} else {
fmt.Printf("Updated targeting for %s.\n", args[0])
}
return nil
},
}
func init() {
targetCmd.Flags().StringSliceVar(&targetOnlyFlag, "only", nil, "only apply on matching machines")
targetCmd.Flags().StringSliceVar(&targetNeverFlag, "never", nil, "never apply on matching machines")
targetCmd.Flags().BoolVar(&targetClearFlag, "clear", false, "remove all targeting labels")
rootCmd.AddCommand(targetCmd)
}

92
cmd/sgardd/main.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"crypto/tls"
"fmt"
"net"
"os"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
var (
listenAddr string
repoPath string
authKeysPath string
tlsCertPath string
tlsKeyPath string
)
var rootCmd = &cobra.Command{
Use: "sgardd",
Short: "sgard gRPC sync daemon",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoPath)
if err != nil {
return fmt.Errorf("opening repo: %w", err)
}
var opts []grpc.ServerOption
if tlsCertPath != "" && tlsKeyPath != "" {
cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
if err != nil {
return fmt.Errorf("loading TLS cert/key: %w", err)
}
opts = append(opts, grpc.Creds(credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})))
fmt.Println("TLS enabled")
} else if tlsCertPath != "" || tlsKeyPath != "" {
return fmt.Errorf("both --tls-cert and --tls-key must be specified together")
}
var srvInstance *server.Server
if authKeysPath != "" {
auth, err := server.NewAuthInterceptor(authKeysPath, repoPath)
if err != nil {
return fmt.Errorf("loading authorized keys: %w", err)
}
opts = append(opts,
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
grpc.StreamInterceptor(auth.StreamInterceptor()),
)
srvInstance = server.NewWithAuth(g, auth)
fmt.Printf("Auth enabled: %s\n", authKeysPath)
} else {
srvInstance = server.New(g)
fmt.Println("WARNING: no --authorized-keys specified, running without authentication")
}
srv := grpc.NewServer(opts...)
sgardpb.RegisterGardenSyncServer(srv, srvInstance)
lis, err := net.Listen("tcp", listenAddr)
if err != nil {
return fmt.Errorf("listening on %s: %w", listenAddr, err)
}
fmt.Printf("sgardd serving on %s (repo: %s)\n", listenAddr, repoPath)
return srv.Serve(lis)
},
}
func main() {
rootCmd.Flags().StringVar(&listenAddr, "listen", ":9473", "gRPC listen address")
rootCmd.Flags().StringVar(&repoPath, "repo", "/srv/sgard", "path to sgard repository")
rootCmd.Flags().StringVar(&authKeysPath, "authorized-keys", "", "path to authorized SSH public keys file")
rootCmd.Flags().StringVar(&tlsCertPath, "tls-cert", "", "path to TLS certificate file")
rootCmd.Flags().StringVar(&tlsKeyPath, "tls-key", "", "path to TLS private key file")
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

30
deploy/docker/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /sgardd ./cmd/sgardd
# Runtime stage
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -h /srv/sgard sgard
COPY --from=builder /sgardd /usr/local/bin/sgardd
VOLUME /srv/sgard
EXPOSE 9473
USER sgard
ENTRYPOINT ["sgardd", \
"--repo", "/srv/sgard", \
"--authorized-keys", "/srv/sgard/authorized_keys", \
"--tls-cert", "/srv/sgard/certs/sgard.pem", \
"--tls-key", "/srv/sgard/certs/sgard.key"]

View File

@@ -0,0 +1,16 @@
services:
sgardd:
image: localhost/sgardd:latest
container_name: sgardd
restart: unless-stopped
user: "0:0"
ports:
- "127.0.0.1:19473:9473"
volumes:
- /srv/sgard:/srv/sgard
healthcheck:
test: ["CMD", "true"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

View File

@@ -11,17 +11,20 @@
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
in in
let
version = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./VERSION);
in
{ {
packages = { packages = {
sgard = pkgs.buildGoModule { sgard = pkgs.buildGoModule rec {
pname = "sgard"; pname = "sgard";
version = "0.1.0"; inherit version;
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" ]; subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-uJMkp08SqZaZ6d64Li4Tx8I9OYjaErLexBrJaf6Vb60="; vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY=";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" "-X main.version=${version}" ];
meta = { meta = {
description = "Shimmering Clarity Gardener: dotfile management"; description = "Shimmering Clarity Gardener: dotfile management";
@@ -29,6 +32,26 @@
}; };
}; };
sgard-fido2 = pkgs.buildGoModule rec {
pname = "sgard-fido2";
inherit version;
src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY=";
buildInputs = [ pkgs.libfido2 ];
nativeBuildInputs = [ pkgs.pkg-config ];
tags = [ "fido2" ];
ldflags = [ "-s" "-w" "-X main.version=${version}" ];
meta = {
description = "Shimmering Clarity Gardener: dotfile management (with FIDO2 hardware support)";
mainProgram = "sgard";
};
};
default = self.packages.${system}.sgard; default = self.packages.${system}.sgard;
}; };
@@ -36,6 +59,11 @@
buildInputs = with pkgs; [ buildInputs = with pkgs; [
go go
golangci-lint golangci-lint
protobuf
protoc-gen-go
protoc-gen-go-grpc
libfido2
pkg-config
]; ];
}; };
} }

View File

@@ -33,6 +33,16 @@ func (g *Garden) Diff(path string) (string, error) {
return "", fmt.Errorf("reading stored blob: %w", err) return "", fmt.Errorf("reading stored blob: %w", err)
} }
if entry.Encrypted {
if g.dek == nil {
return "", fmt.Errorf("DEK not unlocked; cannot diff encrypted file %s", tilded)
}
stored, err = g.decryptBlob(stored)
if err != nil {
return "", fmt.Errorf("decrypting stored blob: %w", err)
}
}
current, err := os.ReadFile(abs) current, err := os.ReadFile(abs)
if err != nil { if err != nil {
return "", fmt.Errorf("reading current file: %w", err) return "", fmt.Errorf("reading current file: %w", err)

449
garden/encrypt.go Normal file
View File

@@ -0,0 +1,449 @@
package garden
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/kisom/sgard/manifest"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
)
const (
dekSize = 32 // 256-bit DEK
saltSize = 16
algorithmName = "xchacha20-poly1305"
defaultArgon2Time = 3
defaultArgon2Memory = 64 * 1024 // 64 MiB in KiB
defaultArgon2Threads = 4
)
// EncryptInit sets up encryption on the repo by generating a DEK and
// wrapping it with a passphrase-derived KEK. The encryption config is
// stored in the manifest.
func (g *Garden) EncryptInit(passphrase string) error {
if g.manifest.Encryption != nil {
return fmt.Errorf("encryption already initialized")
}
// Generate DEK.
dek := make([]byte, dekSize)
if _, err := rand.Read(dek); err != nil {
return fmt.Errorf("generating DEK: %w", err)
}
// Generate salt for passphrase KEK.
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
// Derive KEK from passphrase.
kek := derivePassphraseKEK(passphrase, salt, defaultArgon2Time, defaultArgon2Memory, defaultArgon2Threads)
// Wrap DEK.
wrappedDEK, err := wrapDEK(dek, kek)
if err != nil {
return fmt.Errorf("wrapping DEK: %w", err)
}
g.manifest.Encryption = &manifest.Encryption{
Algorithm: algorithmName,
KekSlots: map[string]*manifest.KekSlot{
"passphrase": {
Type: "passphrase",
Argon2Time: defaultArgon2Time,
Argon2Memory: defaultArgon2Memory,
Argon2Threads: defaultArgon2Threads,
Salt: base64.StdEncoding.EncodeToString(salt),
WrappedDEK: base64.StdEncoding.EncodeToString(wrappedDEK),
},
},
}
g.dek = dek
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// UnlockDEK attempts to unwrap the DEK using available KEK slots.
// Resolution order: try all fido2/* slots first (if a device is provided),
// then fall back to the passphrase slot. The DEK is cached on the Garden
// for the duration of the command.
func (g *Garden) UnlockDEK(promptPassphrase func() (string, error), fido2Device ...FIDO2Device) error {
if g.dek != nil {
return nil // already unlocked
}
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized; run sgard encrypt init")
}
// 1. Try FIDO2 slots first.
if len(fido2Device) > 0 && fido2Device[0] != nil {
if g.unlockFIDO2(fido2Device[0]) {
return nil
}
}
// 2. Fall back to passphrase slot.
if slot, ok := enc.KekSlots["passphrase"]; ok {
if promptPassphrase == nil {
return fmt.Errorf("passphrase required but no prompt available")
}
passphrase, err := promptPassphrase()
if err != nil {
return fmt.Errorf("reading passphrase: %w", err)
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
return fmt.Errorf("decoding salt: %w", err)
}
kek := derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
wrappedDEK, err := base64.StdEncoding.DecodeString(slot.WrappedDEK)
if err != nil {
return fmt.Errorf("decoding wrapped DEK: %w", err)
}
dek, err := unwrapDEK(wrappedDEK, kek)
if err != nil {
return fmt.Errorf("wrong passphrase or corrupted DEK: %w", err)
}
g.dek = dek
return nil
}
return fmt.Errorf("no usable KEK slot found")
}
// HasEncryption reports whether the repo has encryption configured.
func (g *Garden) HasEncryption() bool {
return g.manifest.Encryption != nil
}
// RemoveSlot removes a KEK slot by name. Refuses to remove the last slot.
func (g *Garden) RemoveSlot(name string) error {
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized")
}
if _, ok := enc.KekSlots[name]; !ok {
return fmt.Errorf("slot %q not found", name)
}
if len(enc.KekSlots) <= 1 {
return fmt.Errorf("cannot remove the last KEK slot")
}
delete(enc.KekSlots, name)
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// ListSlots returns the slot names and types.
func (g *Garden) ListSlots() map[string]string {
enc := g.manifest.Encryption
if enc == nil {
return nil
}
result := make(map[string]string, len(enc.KekSlots))
for name, slot := range enc.KekSlots {
result[name] = slot.Type
}
return result
}
// ChangePassphrase re-wraps the DEK with a new passphrase. The DEK must
// already be unlocked.
func (g *Garden) ChangePassphrase(newPassphrase string) error {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked")
}
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized")
}
slot, ok := enc.KekSlots["passphrase"]
if !ok {
return fmt.Errorf("no passphrase slot to change")
}
// Generate new salt.
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
kek := derivePassphraseKEK(newPassphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
wrappedDEK, err := wrapDEK(g.dek, kek)
if err != nil {
return fmt.Errorf("wrapping DEK: %w", err)
}
slot.Salt = base64.StdEncoding.EncodeToString(salt)
slot.WrappedDEK = base64.StdEncoding.EncodeToString(wrappedDEK)
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
// re-wraps the new DEK with all existing KEK slots. The old DEK must
// already be unlocked. A passphrase prompt is required to re-derive
// the KEK for the passphrase slot. An optional FIDO2 device re-wraps
// FIDO2 slots; FIDO2 slots without a matching device are dropped.
func (g *Garden) RotateDEK(promptPassphrase func() (string, error), fido2Device ...FIDO2Device) error {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked")
}
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized")
}
oldDEK := g.dek
// Generate new DEK.
newDEK := make([]byte, dekSize)
if _, err := rand.Read(newDEK); err != nil {
return fmt.Errorf("generating new DEK: %w", err)
}
// Re-encrypt all encrypted blobs.
for i := range g.manifest.Files {
entry := &g.manifest.Files[i]
if !entry.Encrypted || entry.Hash == "" {
continue
}
// Read encrypted blob.
ciphertext, err := g.store.Read(entry.Hash)
if err != nil {
return fmt.Errorf("reading blob %s for %s: %w", entry.Hash, entry.Path, err)
}
// Decrypt with old DEK.
g.dek = oldDEK
plaintext, err := g.decryptBlob(ciphertext)
if err != nil {
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
}
// Re-encrypt with new DEK.
g.dek = newDEK
newCiphertext, err := g.encryptBlob(plaintext)
if err != nil {
return fmt.Errorf("re-encrypting %s: %w", entry.Path, err)
}
// Write new blob.
newHash, err := g.store.Write(newCiphertext)
if err != nil {
return fmt.Errorf("writing re-encrypted blob for %s: %w", entry.Path, err)
}
entry.Hash = newHash
// PlaintextHash stays the same — the plaintext didn't change.
}
// Re-wrap new DEK with all existing KEK slots.
for name, slot := range enc.KekSlots {
var kek []byte
switch slot.Type {
case "passphrase":
if promptPassphrase == nil {
return fmt.Errorf("passphrase required to re-wrap slot %q", name)
}
passphrase, err := promptPassphrase()
if err != nil {
return fmt.Errorf("reading passphrase: %w", err)
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
return fmt.Errorf("decoding salt for slot %q: %w", name, err)
}
kek = derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
case "fido2":
var device FIDO2Device
if len(fido2Device) > 0 {
device = fido2Device[0]
}
if device == nil || !device.Available() {
// Drop FIDO2 slots without a matching device.
delete(enc.KekSlots, name)
continue
}
credID, err := base64.StdEncoding.DecodeString(slot.CredentialID)
if err != nil {
delete(enc.KekSlots, name)
continue
}
if !device.MatchesCredential(credID) {
delete(enc.KekSlots, name)
continue
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
delete(enc.KekSlots, name)
continue
}
fido2KEK, err := device.Derive(credID, salt)
if err != nil {
delete(enc.KekSlots, name)
continue
}
if len(fido2KEK) < dekSize {
delete(enc.KekSlots, name)
continue
}
kek = fido2KEK[:dekSize]
default:
return fmt.Errorf("unknown slot type %q for slot %q", slot.Type, name)
}
wrappedDEK, err := wrapDEK(newDEK, kek)
if err != nil {
return fmt.Errorf("re-wrapping DEK for slot %q: %w", name, err)
}
slot.WrappedDEK = base64.StdEncoding.EncodeToString(wrappedDEK)
}
g.dek = newDEK
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// NeedsDEK reports whether any of the given entries are encrypted.
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool {
for _, e := range entries {
if e.Encrypted {
return true
}
}
return false
}
// encryptBlob encrypts plaintext with the DEK and returns the ciphertext.
func (g *Garden) encryptBlob(plaintext []byte) ([]byte, error) {
if g.dek == nil {
return nil, fmt.Errorf("DEK not unlocked")
}
aead, err := chacha20poly1305.NewX(g.dek)
if err != nil {
return nil, fmt.Errorf("creating cipher: %w", err)
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generating nonce: %w", err)
}
ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// decryptBlob decrypts ciphertext with the DEK and returns the plaintext.
func (g *Garden) decryptBlob(ciphertext []byte) ([]byte, error) {
if g.dek == nil {
return nil, fmt.Errorf("DEK not unlocked")
}
aead, err := chacha20poly1305.NewX(g.dek)
if err != nil {
return nil, fmt.Errorf("creating cipher: %w", err)
}
nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce := ciphertext[:nonceSize]
ct := ciphertext[nonceSize:]
plaintext, err := aead.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed: %w", err)
}
return plaintext, nil
}
// plaintextHash computes the SHA-256 hash of plaintext data.
func plaintextHash(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// derivePassphraseKEK derives a KEK from a passphrase using Argon2id.
func derivePassphraseKEK(passphrase string, salt []byte, time, memory, threads int) []byte {
return argon2.IDKey([]byte(passphrase), salt, uint32(time), uint32(memory), uint8(threads), dekSize)
}
// wrapDEK encrypts the DEK with the KEK using XChaCha20-Poly1305.
func wrapDEK(dek, kek []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(kek)
if err != nil {
return nil, err
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return aead.Seal(nonce, nonce, dek, nil), nil
}
// unwrapDEK decrypts the DEK with the KEK.
func unwrapDEK(wrapped, kek []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(kek)
if err != nil {
return nil, err
}
nonceSize := aead.NonceSize()
if len(wrapped) < nonceSize {
return nil, fmt.Errorf("wrapped DEK too short")
}
nonce := wrapped[:nonceSize]
ct := wrapped[nonceSize:]
return aead.Open(nil, nonce, ct, nil)
}

221
garden/encrypt_e2e_test.go Normal file
View File

@@ -0,0 +1,221 @@
package garden
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/jonboulle/clockwork"
)
// TestEncryptionE2E exercises the full encryption lifecycle:
// encrypt init → add encrypted + plaintext files → checkpoint → modify →
// status → restore → verify → push/pull simulation via Garden accessors.
func TestEncryptionE2E(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
fakeClock := clockwork.NewFakeClockAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
// 1. Init repo and encryption.
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
g.SetClock(fakeClock)
if err := g.EncryptInit("test-passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// 2. Add a mix of encrypted and plaintext files.
sshConfig := filepath.Join(root, "ssh_config")
bashrc := filepath.Join(root, "bashrc")
awsCreds := filepath.Join(root, "aws_credentials")
if err := os.WriteFile(sshConfig, []byte("Host *\n AddKeysToAgent yes\n"), 0o600); err != nil {
t.Fatalf("writing ssh_config: %v", err)
}
if err := os.WriteFile(bashrc, []byte("export PS1='$ '\n"), 0o644); err != nil {
t.Fatalf("writing bashrc: %v", err)
}
if err := os.WriteFile(awsCreds, []byte("[default]\naws_access_key_id=AKIA...\n"), 0o600); err != nil {
t.Fatalf("writing aws_credentials: %v", err)
}
// Encrypted files.
if err := g.Add([]string{sshConfig, awsCreds}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
// Plaintext file.
if err := g.Add([]string{bashrc}); err != nil {
t.Fatalf("Add plaintext: %v", err)
}
if len(g.manifest.Files) != 3 {
t.Fatalf("expected 3 entries, got %d", len(g.manifest.Files))
}
// Verify encrypted blobs are not plaintext.
for _, e := range g.manifest.Files {
if e.Encrypted {
blob, err := g.ReadBlob(e.Hash)
if err != nil {
t.Fatalf("ReadBlob %s: %v", e.Path, err)
}
// The blob should NOT contain the plaintext.
if e.Path == toTildePath(sshConfig) && string(blob) == "Host *\n AddKeysToAgent yes\n" {
t.Error("ssh_config blob should be encrypted")
}
}
}
// 3. Checkpoint.
fakeClock.Advance(time.Hour)
if err := g.Checkpoint("encrypted checkpoint"); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
// 4. Modify an encrypted file.
if err := os.WriteFile(sshConfig, []byte("Host *\n ForwardAgent yes\n"), 0o600); err != nil {
t.Fatalf("modifying ssh_config: %v", err)
}
// 5. Status — should detect modification without DEK.
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
stateMap := make(map[string]string)
for _, s := range statuses {
stateMap[s.Path] = s.State
}
sshPath := toTildePath(sshConfig)
bashrcPath := toTildePath(bashrc)
awsPath := toTildePath(awsCreds)
if stateMap[sshPath] != "modified" {
t.Errorf("ssh_config should be modified, got %s", stateMap[sshPath])
}
if stateMap[bashrcPath] != "ok" {
t.Errorf("bashrc should be ok, got %s", stateMap[bashrcPath])
}
if stateMap[awsPath] != "ok" {
t.Errorf("aws_credentials should be ok, got %s", stateMap[awsPath])
}
// 6. Re-checkpoint after modification.
fakeClock.Advance(time.Hour)
if err := g.Checkpoint("after modification"); err != nil {
t.Fatalf("Checkpoint after mod: %v", err)
}
// 7. Delete all files, then restore.
_ = os.Remove(sshConfig)
_ = os.Remove(bashrc)
_ = os.Remove(awsCreds)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
// 8. Verify restored contents.
got, err := os.ReadFile(sshConfig)
if err != nil {
t.Fatalf("reading restored ssh_config: %v", err)
}
if string(got) != "Host *\n ForwardAgent yes\n" {
t.Errorf("ssh_config content = %q, want modified version", got)
}
got, err = os.ReadFile(bashrc)
if err != nil {
t.Fatalf("reading restored bashrc: %v", err)
}
if string(got) != "export PS1='$ '\n" {
t.Errorf("bashrc content = %q", got)
}
got, err = os.ReadFile(awsCreds)
if err != nil {
t.Fatalf("reading restored aws_credentials: %v", err)
}
if string(got) != "[default]\naws_access_key_id=AKIA...\n" {
t.Errorf("aws_credentials content = %q", got)
}
// 9. Verify blob integrity.
results, err := g.Verify()
if err != nil {
t.Fatalf("Verify: %v", err)
}
for _, r := range results {
if !r.OK {
t.Errorf("verify failed for %s: %s", r.Path, r.Detail)
}
}
// 10. Re-open repo, unlock via passphrase, verify diff works on encrypted file.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
if err := g2.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err != nil {
t.Fatalf("UnlockDEK: %v", err)
}
// Modify ssh_config again for diff.
if err := os.WriteFile(sshConfig, []byte("Host *\n ForwardAgent no\n"), 0o600); err != nil {
t.Fatalf("modifying ssh_config: %v", err)
}
d, err := g2.Diff(sshConfig)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if d == "" {
t.Error("expected non-empty diff for modified encrypted file")
}
// 11. Slot management.
slots := g2.ListSlots()
if len(slots) != 1 {
t.Errorf("expected 1 slot, got %d", len(slots))
}
if slots["passphrase"] != "passphrase" {
t.Errorf("expected passphrase slot, got %v", slots)
}
// Cannot remove the last slot.
if err := g2.RemoveSlot("passphrase"); err == nil {
t.Fatal("should not be able to remove last slot")
}
// Change passphrase.
if err := g2.ChangePassphrase("new-passphrase"); err != nil {
t.Fatalf("ChangePassphrase: %v", err)
}
// Re-open and unlock with new passphrase.
g3, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open after passphrase change: %v", err)
}
if err := g3.UnlockDEK(func() (string, error) { return "new-passphrase", nil }); err != nil {
t.Fatalf("UnlockDEK with new passphrase: %v", err)
}
// Old passphrase should fail.
g4, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
if err := g4.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err == nil {
t.Fatal("old passphrase should fail after change")
}
}

161
garden/encrypt_fido2.go Normal file
View File

@@ -0,0 +1,161 @@
package garden
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"strings"
"github.com/kisom/sgard/manifest"
)
// FIDO2Device abstracts the hardware interaction with a FIDO2 authenticator.
// The real implementation requires libfido2 (CGo); tests use a mock.
type FIDO2Device interface {
// Register creates a new credential with the hmac-secret extension.
// Returns the credential ID and the HMAC-secret output for the given salt.
Register(salt []byte) (credentialID []byte, hmacSecret []byte, err error)
// Derive computes HMAC(device_secret, salt) for an existing credential.
// Requires user touch.
Derive(credentialID []byte, salt []byte) (hmacSecret []byte, err error)
// Available reports whether a FIDO2 device is connected.
Available() bool
// MatchesCredential reports whether the connected device holds the
// given credential (by ID). This allows skipping devices that can't
// unwrap a particular slot without requiring a touch.
MatchesCredential(credentialID []byte) bool
}
// AddFIDO2Slot adds a FIDO2 KEK slot to an encrypted repo. The DEK must
// already be unlocked (via passphrase or another FIDO2 slot). The label
// defaults to "fido2/<hostname>" but can be overridden.
func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked; unlock via passphrase first")
}
if g.manifest.Encryption == nil {
return fmt.Errorf("encryption not initialized")
}
if !device.Available() {
return fmt.Errorf("no FIDO2 device connected")
}
// Normalize label.
if label == "" {
label = defaultFIDO2Label()
}
if !strings.HasPrefix(label, "fido2/") {
label = "fido2/" + label
}
if _, exists := g.manifest.Encryption.KekSlots[label]; exists {
return fmt.Errorf("slot %q already exists", label)
}
// Generate salt for this FIDO2 credential.
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
// Register credential and get HMAC-secret (the KEK).
credID, kek, err := device.Register(salt)
if err != nil {
return fmt.Errorf("FIDO2 registration: %w", err)
}
if len(kek) < dekSize {
return fmt.Errorf("FIDO2 HMAC-secret too short: got %d bytes, need %d", len(kek), dekSize)
}
kek = kek[:dekSize]
// Wrap DEK with the FIDO2-derived KEK.
wrappedDEK, err := wrapDEK(g.dek, kek)
if err != nil {
return fmt.Errorf("wrapping DEK: %w", err)
}
g.manifest.Encryption.KekSlots[label] = &manifest.KekSlot{
Type: "fido2",
CredentialID: base64.StdEncoding.EncodeToString(credID),
Salt: base64.StdEncoding.EncodeToString(salt),
WrappedDEK: base64.StdEncoding.EncodeToString(wrappedDEK),
}
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// unlockFIDO2 attempts to unlock the DEK using any available fido2/* slot.
// Returns true if successful.
func (g *Garden) unlockFIDO2(device FIDO2Device) bool {
if device == nil || !device.Available() {
return false
}
enc := g.manifest.Encryption
for name, slot := range enc.KekSlots {
if slot.Type != "fido2" || !strings.HasPrefix(name, "fido2/") {
continue
}
credID, err := base64.StdEncoding.DecodeString(slot.CredentialID)
if err != nil {
continue
}
// Check if the connected device holds this credential.
if !device.MatchesCredential(credID) {
continue
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
continue
}
kek, err := device.Derive(credID, salt)
if err != nil {
continue
}
if len(kek) < dekSize {
continue
}
kek = kek[:dekSize]
wrappedDEK, err := base64.StdEncoding.DecodeString(slot.WrappedDEK)
if err != nil {
continue
}
dek, err := unwrapDEK(wrappedDEK, kek)
if err != nil {
continue
}
g.dek = dek
return true
}
return false
}
// defaultFIDO2Label returns "<hostname>" as the default FIDO2 slot label.
func defaultFIDO2Label() string {
host, err := os.Hostname()
if err != nil {
return "fido2/device"
}
// Use short hostname (before first dot).
if idx := strings.IndexByte(host, '.'); idx >= 0 {
host = host[:idx]
}
return "fido2/" + host
}

View File

@@ -0,0 +1,263 @@
package garden
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"os"
"path/filepath"
"testing"
)
// mockFIDO2 simulates a FIDO2 device for testing.
type mockFIDO2 struct {
deviceSecret []byte // fixed secret for HMAC derivation
credentials map[string]bool
available bool
}
func newMockFIDO2() *mockFIDO2 {
secret := make([]byte, 32)
_, _ = rand.Read(secret)
return &mockFIDO2{
deviceSecret: secret,
credentials: make(map[string]bool),
available: true,
}
}
func (m *mockFIDO2) Register(salt []byte) ([]byte, []byte, error) {
// Generate a random credential ID.
credID := make([]byte, 32)
_, _ = rand.Read(credID)
m.credentials[string(credID)] = true
// Derive HMAC-secret.
mac := hmac.New(sha256.New, m.deviceSecret)
mac.Write(salt)
return credID, mac.Sum(nil), nil
}
func (m *mockFIDO2) Derive(credentialID []byte, salt []byte) ([]byte, error) {
mac := hmac.New(sha256.New, m.deviceSecret)
mac.Write(salt)
return mac.Sum(nil), nil
}
func (m *mockFIDO2) Available() bool {
return m.available
}
func (m *mockFIDO2) MatchesCredential(credentialID []byte) bool {
return m.credentials[string(credentialID)]
}
func TestAddFIDO2Slot(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)
}
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
slot, ok := g.manifest.Encryption.KekSlots["fido2/test-key"]
if !ok {
t.Fatal("fido2/test-key slot should exist")
}
if slot.Type != "fido2" {
t.Errorf("slot type = %s, want fido2", slot.Type)
}
if slot.CredentialID == "" {
t.Error("slot should have credential_id")
}
if slot.Salt == "" || slot.WrappedDEK == "" {
t.Error("slot should have salt and wrapped DEK")
}
}
func TestAddFIDO2SlotDuplicateRejected(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)
}
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "mykey"); err != nil {
t.Fatalf("first AddFIDO2Slot: %v", err)
}
if err := g.AddFIDO2Slot(device, "mykey"); err == nil {
t.Fatal("duplicate AddFIDO2Slot should fail")
}
}
func TestUnlockViaFIDO2(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)
}
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
// Re-open (DEK not cached).
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
// Unlock via FIDO2 — should succeed without passphrase prompt.
err = g2.UnlockDEK(nil, device)
if err != nil {
t.Fatalf("UnlockDEK via FIDO2: %v", err)
}
if g2.dek == nil {
t.Error("DEK should be cached after FIDO2 unlock")
}
}
func TestFIDO2FallbackToPassphrase(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)
}
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
// Re-open.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
// FIDO2 device is "unavailable" — should fall back to passphrase.
unavailable := newMockFIDO2()
unavailable.available = false
err = g2.UnlockDEK(
func() (string, error) { return "passphrase", nil },
unavailable,
)
if err != nil {
t.Fatalf("UnlockDEK fallback to passphrase: %v", err)
}
}
func TestFIDO2SlotPersists(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)
}
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
// Re-open and verify slot persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if _, ok := g2.manifest.Encryption.KekSlots["fido2/test-key"]; !ok {
t.Fatal("FIDO2 slot should persist after re-open")
}
}
func TestEncryptedRoundTripWithFIDO2(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)
}
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
// Add an encrypted file.
content := []byte("fido2-protected secret\n")
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, content, 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Re-open, unlock via FIDO2, restore.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := g2.UnlockDEK(nil, device); err != nil {
t.Fatalf("UnlockDEK: %v", err)
}
_ = os.Remove(secretFile)
if err := g2.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading restored: %v", err)
}
if string(got) != string(content) {
t.Errorf("content = %q, want %q", got, content)
}
}

View File

@@ -0,0 +1,239 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestRotateDEK(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "test-passphrase"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Add an encrypted file and a plaintext file.
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("secret data"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
plainFile := filepath.Join(root, "plain")
if err := os.WriteFile(plainFile, []byte("plain data"), 0o644); err != nil {
t.Fatalf("writing plain: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
if err := g.Add([]string{plainFile}); err != nil {
t.Fatalf("Add plain: %v", err)
}
// Record pre-rotation state.
var origEncHash, origEncPtHash, origPlainHash string
for _, e := range g.manifest.Files {
if e.Encrypted {
origEncHash = e.Hash
origEncPtHash = e.PlaintextHash
} else {
origPlainHash = e.Hash
}
}
oldDEK := make([]byte, len(g.dek))
copy(oldDEK, g.dek)
// Rotate.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// DEK should have changed.
if string(g.dek) == string(oldDEK) {
t.Error("DEK should change after rotation")
}
// Check manifest entries.
for _, e := range g.manifest.Files {
if e.Encrypted {
// Ciphertext hash should change (new nonce + new key).
if e.Hash == origEncHash {
t.Error("encrypted entry hash should change after rotation")
}
// Plaintext hash should NOT change.
if e.PlaintextHash != origEncPtHash {
t.Errorf("plaintext hash changed: %s → %s", origEncPtHash, e.PlaintextHash)
}
} else {
// Plaintext entry should be untouched.
if e.Hash != origPlainHash {
t.Errorf("plaintext entry hash changed: %s → %s", origPlainHash, e.Hash)
}
}
}
// Verify the new blob decrypts correctly.
_ = os.Remove(secretFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore after rotation: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading restored file: %v", err)
}
if string(got) != "secret data" {
t.Errorf("restored content = %q, want %q", got, "secret data")
}
}
func TestRotateDEK_UnlockWithNewPassphrase(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "original"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Rotate with the same passphrase.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// Re-open and verify unlock still works with the same passphrase.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := g2.UnlockDEK(prompt); err != nil {
t.Fatalf("UnlockDEK after rotation: %v", err)
}
// Verify restore works.
_ = os.Remove(secretFile)
if err := g2.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore after re-open: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "data" {
t.Errorf("got %q, want %q", got, "data")
}
}
func TestRotateDEK_WithFIDO2(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "passphrase"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Add a FIDO2 slot.
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "testkey"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("fido2 data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Rotate with both passphrase and FIDO2 device.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt, device); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// Both slots should still exist.
slots := g.ListSlots()
if _, ok := slots["passphrase"]; !ok {
t.Error("passphrase slot should still exist after rotation")
}
if _, ok := slots["fido2/testkey"]; !ok {
t.Error("fido2/testkey slot should still exist after rotation")
}
// Unlock via FIDO2 should work.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := g2.UnlockDEK(nil, device); err != nil {
t.Fatalf("UnlockDEK via FIDO2 after rotation: %v", err)
}
// Verify decryption.
_ = os.Remove(secretFile)
if err := g2.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "fido2 data" {
t.Errorf("got %q, want %q", got, "fido2 data")
}
}
func TestRotateDEK_RequiresUnlock(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("pass"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Re-open without unlocking.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
err = g2.RotateDEK(func() (string, error) { return "pass", nil })
if err == nil {
t.Fatal("RotateDEK without unlock should fail")
}
}

379
garden/encrypt_test.go Normal file
View File

@@ -0,0 +1,379 @@
package garden
import (
"os"
"path/filepath"
"testing"
"github.com/kisom/sgard/manifest"
)
func TestEncryptInit(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("test-passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
if g.manifest.Encryption == nil {
t.Fatal("encryption section should be present")
}
if g.manifest.Encryption.Algorithm != "xchacha20-poly1305" {
t.Errorf("algorithm = %s, want xchacha20-poly1305", g.manifest.Encryption.Algorithm)
}
slot, ok := g.manifest.Encryption.KekSlots["passphrase"]
if !ok {
t.Fatal("passphrase slot should exist")
}
if slot.Type != "passphrase" {
t.Errorf("slot type = %s, want passphrase", slot.Type)
}
if slot.Salt == "" || slot.WrappedDEK == "" {
t.Error("slot should have salt and wrapped DEK")
}
// DEK should be cached.
if g.dek == nil {
t.Error("DEK should be cached after EncryptInit")
}
// Double init should fail.
if err := g.EncryptInit("other"); err == nil {
t.Fatal("double EncryptInit should fail")
}
}
func TestEncryptInitPersists(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("test-passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Re-open and verify encryption section persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if g2.manifest.Encryption == nil {
t.Fatal("encryption section should persist after re-open")
}
}
func TestUnlockDEK(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("correct-passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Re-open (DEK not cached).
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
// Unlock with correct passphrase.
err = g2.UnlockDEK(func() (string, error) { return "correct-passphrase", nil })
if err != nil {
t.Fatalf("UnlockDEK with correct passphrase: %v", err)
}
// Re-open and try wrong passphrase.
g3, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
err = g3.UnlockDEK(func() (string, error) { return "wrong-passphrase", nil })
if err == nil {
t.Fatal("UnlockDEK with wrong passphrase should fail")
}
}
func TestAddEncrypted(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)
}
// Add an encrypted file.
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("secret data\n"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
// Add a plaintext file.
plainFile := filepath.Join(root, "plain")
if err := os.WriteFile(plainFile, []byte("plain data\n"), 0o644); err != nil {
t.Fatalf("writing plain file: %v", err)
}
if err := g.Add([]string{plainFile}); err != nil {
t.Fatalf("Add plaintext: %v", err)
}
if len(g.manifest.Files) != 2 {
t.Fatalf("expected 2 entries, got %d", len(g.manifest.Files))
}
// Check encrypted entry.
var secretEntry, plainEntry *manifest.Entry
for i := range g.manifest.Files {
if g.manifest.Files[i].Encrypted {
secretEntry = &g.manifest.Files[i]
} else {
plainEntry = &g.manifest.Files[i]
}
}
if secretEntry == nil {
t.Fatal("expected an encrypted entry")
}
if secretEntry.PlaintextHash == "" {
t.Error("encrypted entry should have plaintext_hash")
}
if secretEntry.Hash == "" {
t.Error("encrypted entry should have hash (of ciphertext)")
}
if plainEntry == nil {
t.Fatal("expected a plaintext entry")
}
if plainEntry.PlaintextHash != "" {
t.Error("plaintext entry should not have plaintext_hash")
}
if plainEntry.Encrypted {
t.Error("plaintext entry should not be encrypted")
}
// The stored blob for the encrypted file should NOT be the plaintext.
storedData, err := g.ReadBlob(secretEntry.Hash)
if err != nil {
t.Fatalf("ReadBlob: %v", err)
}
if string(storedData) == "secret data\n" {
t.Error("stored blob should be encrypted, not plaintext")
}
}
func TestEncryptedRestoreRoundTrip(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)
}
content := []byte("sensitive config data\n")
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, content, 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Delete and restore.
_ = os.Remove(secretFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading restored file: %v", err)
}
if string(got) != string(content) {
t.Errorf("restored content = %q, want %q", got, content)
}
}
func TestEncryptedCheckpoint(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)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("original"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
origPtHash := g.manifest.Files[0].PlaintextHash
// Modify file.
if err := os.WriteFile(secretFile, []byte("modified"), 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("encrypted hash should change after modification")
}
if g.manifest.Files[0].PlaintextHash == origPtHash {
t.Error("plaintext hash should change after modification")
}
}
func TestEncryptedStatus(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)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Unchanged — should be ok.
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 1 || statuses[0].State != "ok" {
t.Errorf("expected ok, got %v", statuses)
}
// Modify — should be modified.
if err := os.WriteFile(secretFile, []byte("changed"), 0o600); err != nil {
t.Fatalf("modifying: %v", err)
}
statuses, err = g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 1 || statuses[0].State != "modified" {
t.Errorf("expected modified, got %v", statuses)
}
}
func TestEncryptedDiff(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)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("original\n"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Unchanged — empty diff.
d, err := g.Diff(secretFile)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if d != "" {
t.Errorf("expected empty diff for unchanged encrypted file, got:\n%s", d)
}
// Modify.
if err := os.WriteFile(secretFile, []byte("modified\n"), 0o600); err != nil {
t.Fatalf("modifying: %v", err)
}
d, err = g.Diff(secretFile)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if d == "" {
t.Fatal("expected non-empty diff for modified encrypted file")
}
}
func TestAddEncryptedRequiresDEK(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// No encryption initialized.
testFile := filepath.Join(root, "file")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
err = g.Add([]string{testFile}, AddOptions{Encrypt: true})
if err == nil {
t.Fatal("Add --encrypt without DEK should fail")
}
}

80
garden/exclude.go Normal file
View File

@@ -0,0 +1,80 @@
package garden
import (
"fmt"
"path/filepath"
)
// Exclude adds the given paths to the manifest's exclusion list. Excluded
// paths are skipped during Add and MirrorUp directory walks. If any of the
// paths are already tracked, they are removed from the manifest.
func (g *Garden) Exclude(paths []string) error {
existing := make(map[string]bool, len(g.manifest.Exclude))
for _, e := range g.manifest.Exclude {
existing[e] = true
}
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
tilded := toTildePath(abs)
if existing[tilded] {
continue
}
g.manifest.Exclude = append(g.manifest.Exclude, tilded)
existing[tilded] = true
// Remove any already-tracked entries that match this exclusion.
g.removeExcludedEntries(tilded)
}
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// Include removes the given paths from the manifest's exclusion list,
// allowing them to be tracked again.
func (g *Garden) Include(paths []string) error {
remove := make(map[string]bool, len(paths))
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
remove[toTildePath(abs)] = true
}
filtered := g.manifest.Exclude[:0]
for _, e := range g.manifest.Exclude {
if !remove[e] {
filtered = append(filtered, e)
}
}
g.manifest.Exclude = filtered
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// removeExcludedEntries drops manifest entries that match the given
// exclusion path (exact match or under an excluded directory).
func (g *Garden) removeExcludedEntries(tildePath string) {
kept := g.manifest.Files[:0]
for _, e := range g.manifest.Files {
if !g.manifest.IsExcluded(e.Path) {
kept = append(kept, e)
}
}
g.manifest.Files = kept
}

331
garden/exclude_test.go Normal file
View File

@@ -0,0 +1,331 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestExcludeAddsToManifest(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if len(g.manifest.Exclude) != 1 {
t.Fatalf("expected 1 exclusion, got %d", len(g.manifest.Exclude))
}
expected := toTildePath(secretFile)
if g.manifest.Exclude[0] != expected {
t.Errorf("exclude[0] = %q, want %q", g.manifest.Exclude[0], expected)
}
// Verify persistence.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
if len(g2.manifest.Exclude) != 1 {
t.Errorf("persisted excludes = %d, want 1", len(g2.manifest.Exclude))
}
}
func TestExcludeDeduplicates(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("first Exclude: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("second Exclude: %v", err)
}
if len(g.manifest.Exclude) != 1 {
t.Errorf("expected 1 exclusion after dedup, got %d", len(g.manifest.Exclude))
}
}
func TestExcludeRemovesTrackedEntry(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
// Add the file first.
if err := g.Add([]string{secretFile}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
// Now exclude it — should remove from tracked files.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if len(g.manifest.Files) != 0 {
t.Errorf("expected 0 files after exclude, got %d", len(g.manifest.Files))
}
}
func TestIncludeRemovesFromExcludeList(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
secretFile := filepath.Join(root, "secret.key")
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if len(g.manifest.Exclude) != 1 {
t.Fatalf("expected 1 exclusion, got %d", len(g.manifest.Exclude))
}
if err := g.Include([]string{secretFile}); err != nil {
t.Fatalf("Include: %v", err)
}
if len(g.manifest.Exclude) != 0 {
t.Errorf("expected 0 exclusions after include, got %d", len(g.manifest.Exclude))
}
}
func TestAddSkipsExcludedFile(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, "config")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(testDir, "credentials.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Exclude the secret file before adding the directory.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if err := g.Add([]string{testDir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
expectedPath := toTildePath(normalFile)
if g.manifest.Files[0].Path != expectedPath {
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
}
}
func TestAddSkipsExcludedDirectory(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, "config")
subDir := filepath.Join(testDir, "secrets")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatalf("creating dirs: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(subDir, "token.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("token"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Exclude the entire secrets subdirectory.
if err := g.Exclude([]string{subDir}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if err := g.Add([]string{testDir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
expectedPath := toTildePath(normalFile)
if g.manifest.Files[0].Path != expectedPath {
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
}
}
func TestMirrorUpSkipsExcluded(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, "config")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(testDir, "credentials.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Exclude the secret file.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
if err := g.MirrorUp([]string{testDir}); err != nil {
t.Fatalf("MirrorUp: %v", err)
}
// Only the normal file should be tracked.
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
expectedPath := toTildePath(normalFile)
if g.manifest.Files[0].Path != expectedPath {
t.Errorf("tracked file = %q, want %q", g.manifest.Files[0].Path, expectedPath)
}
}
func TestMirrorDownLeavesExcludedAlone(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, "config")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
normalFile := filepath.Join(testDir, "settings.yaml")
secretFile := filepath.Join(testDir, "credentials.key")
if err := os.WriteFile(normalFile, []byte("settings"), 0o644); err != nil {
t.Fatalf("writing normal file: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing secret file: %v", err)
}
// Add only the normal file.
if err := g.Add([]string{normalFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Exclude the secret file.
if err := g.Exclude([]string{secretFile}); err != nil {
t.Fatalf("Exclude: %v", err)
}
// MirrorDown with force — excluded file should NOT be deleted.
if err := g.MirrorDown([]string{testDir}, true, nil); err != nil {
t.Fatalf("MirrorDown: %v", err)
}
if _, err := os.Stat(secretFile); err != nil {
t.Error("excluded file should not have been deleted by MirrorDown")
}
}
func TestIsExcludedDirectoryPrefix(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Exclude a directory.
g.manifest.Exclude = []string{"~/config/secrets"}
if !g.manifest.IsExcluded("~/config/secrets") {
t.Error("exact match should be excluded")
}
if !g.manifest.IsExcluded("~/config/secrets/token.key") {
t.Error("file under excluded dir should be excluded")
}
if !g.manifest.IsExcluded("~/config/secrets/nested/deep.key") {
t.Error("deeply nested file under excluded dir should be excluded")
}
if g.manifest.IsExcluded("~/config/secrets-backup/file.key") {
t.Error("path with similar prefix but different dir should not be excluded")
}
if g.manifest.IsExcluded("~/config/other.yaml") {
t.Error("unrelated path should not be excluded")
}
}

156
garden/fido2_hardware.go Normal file
View File

@@ -0,0 +1,156 @@
//go:build fido2
package garden
import (
"crypto/sha256"
"fmt"
libfido2 "github.com/keys-pub/go-libfido2"
)
const rpID = "sgard"
// HardwareFIDO2 implements FIDO2Device using a real hardware authenticator
// via libfido2.
type HardwareFIDO2 struct {
pin string // device PIN (empty if no PIN set)
}
// NewHardwareFIDO2 creates a HardwareFIDO2 device. The PIN is needed for
// operations on PIN-protected authenticators.
func NewHardwareFIDO2(pin string) *HardwareFIDO2 {
return &HardwareFIDO2{pin: pin}
}
// Available reports whether a FIDO2 device is connected.
func (h *HardwareFIDO2) Available() bool {
locs, err := libfido2.DeviceLocations()
if err != nil {
return false
}
return len(locs) > 0
}
// Register creates a new credential with the hmac-secret extension.
// Returns the credential ID and the HMAC-secret output for the given salt.
func (h *HardwareFIDO2) Register(salt []byte) ([]byte, []byte, error) {
dev, err := h.deviceForPath()
if err != nil {
return nil, nil, err
}
cdh := sha256.Sum256(salt)
// CTAP2 hmac-secret extension requires a 32-byte salt.
hmacSalt := fido2Salt(salt)
userID := sha256.Sum256([]byte("sgard-user"))
attest, err := dev.MakeCredential(
cdh[:],
libfido2.RelyingParty{ID: rpID, Name: "sgard"},
libfido2.User{ID: userID[:], Name: "sgard"},
libfido2.ES256,
h.pin,
&libfido2.MakeCredentialOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
RK: libfido2.False,
},
)
if err != nil {
return nil, nil, fmt.Errorf("fido2 make credential: %w", err)
}
// Do an assertion to get the HMAC-secret for this salt.
assertion, err := dev.Assertion(
rpID,
cdh[:],
[][]byte{attest.CredentialID},
h.pin,
&libfido2.AssertionOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
HMACSalt: hmacSalt,
UP: libfido2.True,
},
)
if err != nil {
return nil, nil, fmt.Errorf("fido2 assertion for hmac-secret: %w", err)
}
return attest.CredentialID, assertion.HMACSecret, nil
}
// Derive computes HMAC(device_secret, salt) for an existing credential.
// Requires user touch.
func (h *HardwareFIDO2) Derive(credentialID []byte, salt []byte) ([]byte, error) {
dev, err := h.deviceForPath()
if err != nil {
return nil, err
}
cdh := sha256.Sum256(salt)
hmacSalt := fido2Salt(salt)
assertion, err := dev.Assertion(
rpID,
cdh[:],
[][]byte{credentialID},
h.pin,
&libfido2.AssertionOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
HMACSalt: hmacSalt,
UP: libfido2.True,
},
)
if err != nil {
return nil, fmt.Errorf("fido2 assertion: %w", err)
}
return assertion.HMACSecret, nil
}
// MatchesCredential reports whether the connected device might hold the
// given credential. Since probing without user presence is unreliable
// across devices, we optimistically return true and let Derive handle
// the actual verification (which requires a touch).
func (h *HardwareFIDO2) MatchesCredential(_ []byte) bool {
return h.Available()
}
// fido2Salt returns a 32-byte salt suitable for the CTAP2 hmac-secret
// extension. If the input is already 32 bytes, it is returned as-is.
// Otherwise, SHA-256 is used to derive a 32-byte value deterministically.
func fido2Salt(salt []byte) []byte {
if len(salt) == 32 {
return salt
}
h := sha256.Sum256(salt)
return h[:]
}
// deviceForPath returns a Device handle for the first connected FIDO2
// device. The library manages open/close internally per operation.
func (h *HardwareFIDO2) deviceForPath() (*libfido2.Device, error) {
locs, err := libfido2.DeviceLocations()
if err != nil {
return nil, fmt.Errorf("listing fido2 devices: %w", err)
}
if len(locs) == 0 {
return nil, fmt.Errorf("no fido2 device found")
}
dev, err := libfido2.NewDevice(locs[0].Path)
if err != nil {
return nil, fmt.Errorf("opening fido2 device %s: %w", locs[0].Path, err)
}
return dev, nil
}
// DetectHardwareFIDO2 returns a HardwareFIDO2 device if hardware is available,
// or nil if no device is connected.
func DetectHardwareFIDO2(pin string) FIDO2Device {
d := NewHardwareFIDO2(pin)
if d.Available() {
return d
}
return nil
}

View File

@@ -0,0 +1,10 @@
//go:build !fido2
package garden
// DetectHardwareFIDO2 is a stub that returns nil when built without the
// fido2 build tag. Build with -tags fido2 and link against libfido2 to
// enable real hardware support.
func DetectHardwareFIDO2(_ string) FIDO2Device {
return nil
}

View File

@@ -22,6 +22,7 @@ type Garden struct {
root string // repository root directory root string // repository root directory
manifestPath string // path to manifest.yaml manifestPath string // path to manifest.yaml
clock clockwork.Clock clock clockwork.Clock
dek []byte // unlocked data encryption key (nil if not unlocked)
} }
// Init creates a new sgard repository at root. It creates the directory // Init creates a new sgard repository at root. It creates the directory
@@ -47,7 +48,7 @@ func Init(root string) (*Garden, error) {
} }
gitignorePath := filepath.Join(absRoot, ".gitignore") gitignorePath := filepath.Join(absRoot, ".gitignore")
if err := os.WriteFile(gitignorePath, []byte("blobs/\n"), 0o644); err != nil { if err := os.WriteFile(gitignorePath, []byte("blobs/\ntags\n"), 0o644); err != nil {
return nil, fmt.Errorf("creating .gitignore: %w", err) return nil, fmt.Errorf("creating .gitignore: %w", err)
} }
@@ -98,10 +99,128 @@ func (g *Garden) SetClock(c clockwork.Clock) {
g.clock = c g.clock = c
} }
// GetManifest returns the current manifest.
func (g *Garden) GetManifest() *manifest.Manifest {
return g.manifest
}
// BlobExists reports whether a blob with the given hash exists in the store.
func (g *Garden) BlobExists(hash string) bool {
return g.store.Exists(hash)
}
// ReadBlob returns the contents of the blob with the given hash.
func (g *Garden) ReadBlob(hash string) ([]byte, error) {
return g.store.Read(hash)
}
// WriteBlob writes data to the blob store and returns the hash.
func (g *Garden) WriteBlob(data []byte) (string, error) {
return g.store.Write(data)
}
// ReplaceManifest atomically replaces the current manifest.
func (g *Garden) ReplaceManifest(m *manifest.Manifest) error {
if err := m.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
g.manifest = m
return nil
}
// ListBlobs returns all blob hashes in the store.
func (g *Garden) ListBlobs() ([]string, error) {
return g.store.List()
}
// DeleteBlob removes a blob from the store by hash.
func (g *Garden) DeleteBlob(hash string) error {
return g.store.Delete(hash)
}
// addEntry adds a single file or symlink to the manifest. If skipDup is true,
// already-tracked paths are silently skipped.
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool, o AddOptions) error {
tilded := toTildePath(abs)
if g.findEntry(tilded) != nil {
if skipDup {
return nil
}
return fmt.Errorf("already tracking %s", tilded)
}
entry := manifest.Entry{
Path: tilded,
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
Locked: o.Lock,
Only: o.Only,
Never: o.Never,
Updated: now,
}
switch {
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(abs)
if err != nil {
return fmt.Errorf("reading symlink %s: %w", abs, err)
}
entry.Type = "link"
entry.Target = target
default:
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Errorf("reading file %s: %w", abs, err)
}
if o.Encrypt {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs)
}
entry.PlaintextHash = plaintextHash(data)
ct, err := g.encryptBlob(data)
if err != nil {
return fmt.Errorf("encrypting %s: %w", abs, err)
}
data = ct
entry.Encrypted = true
}
hash, err := g.store.Write(data)
if err != nil {
return fmt.Errorf("storing blob for %s: %w", abs, err)
}
entry.Type = "file"
entry.Hash = hash
}
g.manifest.Files = append(g.manifest.Files, entry)
return nil
}
// AddOptions controls the behavior of Add.
type AddOptions struct {
Encrypt bool // encrypt file blobs before storing
Lock bool // mark entries as locked (repo-authoritative)
DirOnly bool // for directories: track the directory itself, don't recurse
Only []string // per-machine targeting: only apply on matching machines
Never []string // per-machine targeting: never apply on matching machines
}
// Add tracks new files, directories, or symlinks. Each path is resolved // Add tracks new files, directories, or symlinks. Each path is resolved
// to an absolute path, inspected for its type, and added to the manifest. // to an absolute path, inspected for its type, and added to the manifest.
// Regular files are hashed and stored in the blob store. // Regular files are hashed and stored in the blob store. Directories are
func (g *Garden) Add(paths []string) error { // recursively walked unless opts.DirOnly is set.
func (g *Garden) Add(paths []string, opts ...AddOptions) error {
var o AddOptions
if len(opts) > 0 {
o = opts[0]
}
if o.Encrypt && g.dek == nil {
return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first")
}
now := g.clock.Now().UTC() now := g.clock.Now().UTC()
for _, p := range paths { for _, p := range paths {
@@ -115,45 +234,53 @@ func (g *Garden) Add(paths []string) error {
return fmt.Errorf("stat %s: %w", abs, err) return fmt.Errorf("stat %s: %w", abs, err)
} }
tilded := toTildePath(abs) if info.IsDir() {
if o.DirOnly {
// Check if already tracked. // Track the directory itself as a structural entry.
if g.findEntry(tilded) != nil { tilded := toTildePath(abs)
return fmt.Errorf("already tracking %s", tilded) if g.findEntry(tilded) != nil {
} continue
}
entry := manifest.Entry{ entry := manifest.Entry{
Path: tilded, Path: tilded,
Mode: fmt.Sprintf("%04o", info.Mode().Perm()), Type: "directory",
Updated: now, Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
} Locked: o.Lock,
Only: o.Only,
switch { Never: o.Never,
case info.Mode()&os.ModeSymlink != 0: Updated: now,
target, err := os.Readlink(abs) }
if err != nil { g.manifest.Files = append(g.manifest.Files, entry)
return fmt.Errorf("reading symlink %s: %w", abs, err) } else {
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
tilded := toTildePath(path)
if g.manifest.IsExcluded(tilded) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
fi, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
return g.addEntry(path, fi, now, true, o)
})
if err != nil {
return fmt.Errorf("walking directory %s: %w", abs, err)
}
} }
entry.Type = "link" } else {
entry.Target = target if err := g.addEntry(abs, info, now, true, o); err != nil {
return err
case info.IsDir():
entry.Type = "directory"
default:
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Errorf("reading file %s: %w", abs, err)
} }
hash, err := g.store.Write(data)
if err != nil {
return fmt.Errorf("storing blob for %s: %w", abs, err)
}
entry.Type = "file"
entry.Hash = hash
} }
g.manifest.Files = append(g.manifest.Files, entry)
} }
g.manifest.Updated = now g.manifest.Updated = now
@@ -175,10 +302,19 @@ type FileStatus struct {
// the manifest. // the manifest.
func (g *Garden) Checkpoint(message string) error { func (g *Garden) Checkpoint(message string) error {
now := g.clock.Now().UTC() now := g.clock.Now().UTC()
labels := g.Identity()
for i := range g.manifest.Files { for i := range g.manifest.Files {
entry := &g.manifest.Files[i] entry := &g.manifest.Files[i]
applies, err := EntryApplies(entry, labels)
if err != nil {
return err
}
if !applies {
continue
}
abs, err := ExpandTildePath(entry.Path) abs, err := ExpandTildePath(entry.Path)
if err != nil { if err != nil {
return fmt.Errorf("expanding path %s: %w", entry.Path, err) return fmt.Errorf("expanding path %s: %w", entry.Path, err)
@@ -193,19 +329,46 @@ func (g *Garden) Checkpoint(message string) error {
entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm()) entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
// Locked entries are repo-authoritative — checkpoint skips them.
if entry.Locked {
continue
}
switch entry.Type { switch entry.Type {
case "file": case "file":
data, err := os.ReadFile(abs) data, err := os.ReadFile(abs)
if err != nil { if err != nil {
return fmt.Errorf("reading %s: %w", abs, err) return fmt.Errorf("reading %s: %w", abs, err)
} }
hash, err := g.store.Write(data)
if err != nil { if entry.Encrypted {
return fmt.Errorf("storing blob for %s: %w", abs, err) // For encrypted entries, check plaintext hash to detect changes.
} ptHash := plaintextHash(data)
if hash != entry.Hash { if ptHash != entry.PlaintextHash {
entry.Hash = hash if g.dek == nil {
entry.Updated = now return fmt.Errorf("DEK not unlocked; cannot re-encrypt %s", abs)
}
ct, err := g.encryptBlob(data)
if err != nil {
return fmt.Errorf("encrypting %s: %w", abs, err)
}
hash, err := g.store.Write(ct)
if err != nil {
return fmt.Errorf("storing blob for %s: %w", abs, err)
}
entry.Hash = hash
entry.PlaintextHash = ptHash
entry.Updated = now
}
} else {
hash, err := g.store.Write(data)
if err != nil {
return fmt.Errorf("storing blob for %s: %w", abs, err)
}
if hash != entry.Hash {
entry.Hash = hash
entry.Updated = now
}
} }
case "link": case "link":
@@ -236,10 +399,20 @@ func (g *Garden) Checkpoint(message string) error {
// and returns a status for each. // and returns a status for each.
func (g *Garden) Status() ([]FileStatus, error) { func (g *Garden) Status() ([]FileStatus, error) {
var results []FileStatus var results []FileStatus
labels := g.Identity()
for i := range g.manifest.Files { for i := range g.manifest.Files {
entry := &g.manifest.Files[i] entry := &g.manifest.Files[i]
applies, err := EntryApplies(entry, labels)
if err != nil {
return nil, err
}
if !applies {
results = append(results, FileStatus{Path: entry.Path, State: "skipped"})
continue
}
abs, err := ExpandTildePath(entry.Path) abs, err := ExpandTildePath(entry.Path)
if err != nil { if err != nil {
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err) return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
@@ -260,8 +433,17 @@ func (g *Garden) Status() ([]FileStatus, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("hashing %s: %w", abs, err) return nil, fmt.Errorf("hashing %s: %w", abs, err)
} }
if hash != entry.Hash { // For encrypted entries, compare against plaintext hash.
results = append(results, FileStatus{Path: entry.Path, State: "modified"}) compareHash := entry.Hash
if entry.Encrypted && entry.PlaintextHash != "" {
compareHash = entry.PlaintextHash
}
if hash != compareHash {
if entry.Locked {
results = append(results, FileStatus{Path: entry.Path, State: "drifted"})
} else {
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
}
} else { } else {
results = append(results, FileStatus{Path: entry.Path, State: "ok"}) results = append(results, FileStatus{Path: entry.Path, State: "ok"})
} }
@@ -299,20 +481,39 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b
} }
} }
labels := g.Identity()
for i := range entries { for i := range entries {
entry := &entries[i] entry := &entries[i]
applies, err := EntryApplies(entry, labels)
if err != nil {
return err
}
if !applies {
continue
}
abs, err := ExpandTildePath(entry.Path) abs, err := ExpandTildePath(entry.Path)
if err != nil { if err != nil {
return fmt.Errorf("expanding path %s: %w", entry.Path, err) return fmt.Errorf("expanding path %s: %w", entry.Path, err)
} }
// Check if the file exists and whether we need confirmation. // Locked entries always restore if content differs — no prompt.
if !force { if entry.Locked && entry.Type == "file" {
if currentHash, err := HashFile(abs); err == nil {
compareHash := entry.Hash
if entry.Encrypted && entry.PlaintextHash != "" {
compareHash = entry.PlaintextHash
}
if currentHash == compareHash {
continue // already matches, skip
}
}
// File is missing or hash differs — proceed to restore.
} else if !force {
// Normal entries: check timestamp for confirmation.
if info, err := os.Lstat(abs); err == nil { if info, err := os.Lstat(abs); err == nil {
// File exists. If on-disk mtime >= manifest updated, ask.
// Truncate to seconds because filesystem mtime granularity
// varies across platforms.
diskTime := info.ModTime().Truncate(time.Second) diskTime := info.ModTime().Truncate(time.Second)
entryTime := entry.Updated.Truncate(time.Second) entryTime := entry.Updated.Truncate(time.Second)
if !diskTime.Before(entryTime) { if !diskTime.Before(entryTime) {
@@ -360,6 +561,16 @@ func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error {
return fmt.Errorf("reading blob for %s: %w", entry.Path, err) return fmt.Errorf("reading blob for %s: %w", entry.Path, err)
} }
if entry.Encrypted {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked; cannot decrypt %s", entry.Path)
}
data, err = g.decryptBlob(data)
if err != nil {
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
}
}
mode, err := parseMode(entry.Mode) mode, err := parseMode(entry.Mode)
if err != nil { if err != nil {
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err) return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)

View File

@@ -1,9 +1,13 @@
package garden package garden
import ( import (
"crypto/sha256"
"encoding/hex"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/kisom/sgard/manifest"
) )
func TestInitCreatesStructure(t *testing.T) { func TestInitCreatesStructure(t *testing.T) {
@@ -29,8 +33,8 @@ func TestInitCreatesStructure(t *testing.T) {
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore")) gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil { if err != nil {
t.Errorf(".gitignore not found: %v", err) t.Errorf(".gitignore not found: %v", err)
} else if string(gitignore) != "blobs/\n" { } else if string(gitignore) != "blobs/\ntags\n" {
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n") t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\ntags\n")
} }
if g.manifest.Version != 1 { if g.manifest.Version != 1 {
@@ -135,17 +139,29 @@ func TestAddDirectory(t *testing.T) {
if err := os.Mkdir(testDir, 0o755); err != nil { if err := os.Mkdir(testDir, 0o755); err != nil {
t.Fatalf("creating test dir: %v", err) t.Fatalf("creating test dir: %v", err)
} }
testFile := filepath.Join(testDir, "inside.txt")
if err := os.WriteFile(testFile, []byte("inside"), 0o644); err != nil {
t.Fatalf("writing file inside dir: %v", err)
}
if err := g.Add([]string{testDir}); err != nil { if err := g.Add([]string{testDir}); err != nil {
t.Fatalf("Add: %v", err) t.Fatalf("Add: %v", err)
} }
entry := g.manifest.Files[0] if len(g.manifest.Files) != 1 {
if entry.Type != "directory" { t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
t.Errorf("expected type directory, got %s", entry.Type)
} }
if entry.Hash != "" {
t.Error("directories should have no hash") entry := g.manifest.Files[0]
if entry.Type != "file" {
t.Errorf("expected type file, got %s", entry.Type)
}
if entry.Hash == "" {
t.Error("expected non-empty hash")
}
expectedPath := toTildePath(testFile)
if entry.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, entry.Path)
} }
} }
@@ -184,7 +200,7 @@ func TestAddSymlink(t *testing.T) {
} }
} }
func TestAddDuplicateRejected(t *testing.T) { func TestAddDuplicateIsIdempotent(t *testing.T) {
root := t.TempDir() root := t.TempDir()
repoDir := filepath.Join(root, "repo") repoDir := filepath.Join(root, "repo")
@@ -202,8 +218,19 @@ func TestAddDuplicateRejected(t *testing.T) {
t.Fatalf("first Add: %v", err) t.Fatalf("first Add: %v", err)
} }
if err := g.Add([]string{testFile}); err == nil { if err := g.Add([]string{testFile}); err != nil {
t.Fatal("second Add of same path should fail") t.Fatalf("second Add of same path should be idempotent: %v", err)
}
entries := g.List()
count := 0
for _, e := range entries {
if e.Path == toTildePath(testFile) {
count++
}
}
if count != 1 {
t.Fatalf("expected 1 entry, got %d", count)
} }
} }
@@ -619,6 +646,161 @@ func TestRestoreConfirmSkips(t *testing.T) {
} }
} }
func TestGetManifest(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"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
m := g.GetManifest()
if m == nil {
t.Fatal("GetManifest returned nil")
}
if len(m.Files) != 1 {
t.Errorf("expected 1 entry, got %d", len(m.Files))
}
}
func TestBlobExists(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("blob exists test"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
hash := g.GetManifest().Files[0].Hash
if !g.BlobExists(hash) {
t.Error("BlobExists returned false for a stored blob")
}
if g.BlobExists("0000000000000000000000000000000000000000000000000000000000000000") {
t.Error("BlobExists returned true for a fake hash")
}
}
func TestReadBlob(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
content := []byte("read blob test content")
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, content, 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
hash := g.GetManifest().Files[0].Hash
got, err := g.ReadBlob(hash)
if err != nil {
t.Fatalf("ReadBlob: %v", err)
}
if string(got) != string(content) {
t.Errorf("ReadBlob content = %q, want %q", got, content)
}
}
func TestWriteBlob(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
data := []byte("write blob test data")
hash, err := g.WriteBlob(data)
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
// Verify the hash is correct SHA-256.
sum := sha256.Sum256(data)
wantHash := hex.EncodeToString(sum[:])
if hash != wantHash {
t.Errorf("WriteBlob hash = %s, want %s", hash, wantHash)
}
if !g.BlobExists(hash) {
t.Error("BlobExists returned false after WriteBlob")
}
}
func TestReplaceManifest(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 new manifest with a custom entry.
newManifest := manifest.New()
newManifest.Files = append(newManifest.Files, manifest.Entry{
Path: "~/replaced-file",
Type: "file",
Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Mode: "0644",
})
if err := g.ReplaceManifest(newManifest); err != nil {
t.Fatalf("ReplaceManifest: %v", err)
}
// Verify in-memory manifest was updated.
m := g.GetManifest()
if len(m.Files) != 1 {
t.Fatalf("expected 1 entry, got %d", len(m.Files))
}
if m.Files[0].Path != "~/replaced-file" {
t.Errorf("expected path ~/replaced-file, got %s", m.Files[0].Path)
}
// Verify persistence by re-opening.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
m2 := g2.GetManifest()
if len(m2.Files) != 1 {
t.Fatalf("persisted manifest has %d entries, want 1", len(m2.Files))
}
if m2.Files[0].Path != "~/replaced-file" {
t.Errorf("persisted entry path = %s, want ~/replaced-file", m2.Files[0].Path)
}
}
func TestExpandTildePath(t *testing.T) { func TestExpandTildePath(t *testing.T) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {

37
garden/identity.go Normal file
View File

@@ -0,0 +1,37 @@
package garden
import (
"os"
"runtime"
"strings"
)
// Identity returns the machine's label set: short hostname, os:<GOOS>,
// arch:<GOARCH>, and tag:<name> for each tag in <repo>/tags.
func (g *Garden) Identity() []string {
labels := []string{
shortHostname(),
"os:" + runtime.GOOS,
"arch:" + runtime.GOARCH,
}
tags := g.LoadTags()
for _, tag := range tags {
labels = append(labels, "tag:"+tag)
}
return labels
}
// shortHostname returns the hostname before the first dot, lowercased.
func shortHostname() string {
host, err := os.Hostname()
if err != nil {
return "unknown"
}
host = strings.ToLower(host)
if idx := strings.IndexByte(host, '.'); idx >= 0 {
host = host[:idx]
}
return host
}

158
garden/info.go Normal file
View File

@@ -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
}

191
garden/info_test.go Normal file
View File

@@ -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)
}
}

39
garden/lock.go Normal file
View File

@@ -0,0 +1,39 @@
package garden
import (
"fmt"
"path/filepath"
)
// Lock marks existing tracked entries as locked (repo-authoritative).
func (g *Garden) Lock(paths []string) error {
return g.setLocked(paths, true)
}
// Unlock removes the locked flag from existing tracked entries.
func (g *Garden) Unlock(paths []string) error {
return g.setLocked(paths, false)
}
func (g *Garden) setLocked(paths []string, locked bool) error {
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
tilded := toTildePath(abs)
entry := g.findEntry(tilded)
if entry == nil {
return fmt.Errorf("not tracked: %s", tilded)
}
entry.Locked = locked
}
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}

197
garden/lock_test.go Normal file
View File

@@ -0,0 +1,197 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestLockExistingEntry(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("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add without lock.
if err := g.Add([]string{testFile}); 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 after Lock()")
}
// Verify persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !g2.manifest.Files[0].Locked {
t.Error("locked state should persist")
}
}
func TestUnlockExistingEntry(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("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Fatal("should be locked")
}
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
if g.manifest.Files[0].Locked {
t.Error("should not be locked after Unlock()")
}
}
func TestLockUntrackedErrors(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, "nottracked")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Lock([]string{testFile}); err == nil {
t.Fatal("Lock on untracked path should error")
}
}
func TestLockChangesCheckpointBehavior(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("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add unlocked, checkpoint picks up changes.
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
if err := os.WriteFile(testFile, []byte("changed"), 0o644); 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.Fatal("unlocked file: checkpoint should update hash")
}
newHash := g.manifest.Files[0].Hash
// Now lock it and modify again — checkpoint should NOT update.
if err := g.Lock([]string{testFile}); err != nil {
t.Fatalf("Lock: %v", err)
}
if err := os.WriteFile(testFile, []byte("system overwrote"), 0o644); err != nil {
t.Fatalf("overwriting: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash != newHash {
t.Error("locked file: checkpoint should not update hash")
}
}
func TestUnlockChangesStatusBehavior(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("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := os.WriteFile(testFile, []byte("changed"), 0o644); err != nil {
t.Fatalf("modifying: %v", err)
}
// Locked: should be "drifted".
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if statuses[0].State != "drifted" {
t.Errorf("locked: expected drifted, got %s", statuses[0].State)
}
// Unlock: should now be "modified".
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
statuses, err = g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if statuses[0].State != "modified" {
t.Errorf("unlocked: expected modified, got %s", statuses[0].State)
}
}

192
garden/locked_combo_test.go Normal file
View 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")
}
}

229
garden/locked_test.go Normal file
View File

@@ -0,0 +1,229 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestAddLocked(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("locked content\n"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Error("entry should be locked")
}
}
func TestCheckpointSkipsLocked(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("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
// Modify the file — checkpoint should NOT update the hash.
if err := os.WriteFile(testFile, []byte("system overwrote this"), 0o644); 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 files — hash should not change")
}
}
func TestStatusReportsDrifted(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("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Modify — status should report "drifted" not "modified".
if err := os.WriteFile(testFile, []byte("system changed this"), 0o644); err != nil {
t.Fatalf("modifying: %v", err)
}
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)
}
}
func TestRestoreAlwaysRestoresLocked(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("correct content"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// System overwrites the file.
if err := os.WriteFile(testFile, []byte("system garbage"), 0o644); err != nil {
t.Fatalf("overwriting: %v", err)
}
// Restore without --force — locked files should still be restored.
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) != "correct content" {
t.Errorf("content = %q, want %q", got, "correct content")
}
}
func TestRestoreSkipsLockedWhenHashMatches(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("content"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// File is unchanged — restore should skip it (no unnecessary writes).
if err := g.Restore(nil, false, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
// If we got here without error, it means it didn't try to overwrite
// an identical file, which is correct.
}
func TestAddDirOnly(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 directory with a file inside.
testDir := filepath.Join(root, "testdir")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
if err := os.WriteFile(filepath.Join(testDir, "file"), []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add with --dir — should NOT recurse.
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 entry (directory), got %d", len(g.manifest.Files))
}
if g.manifest.Files[0].Type != "directory" {
t.Errorf("type = %s, want directory", g.manifest.Files[0].Type)
}
if g.manifest.Files[0].Hash != "" {
t.Error("directory entry should have no hash")
}
}
func TestDirOnlyRestoreCreatesDirectory(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, "testdir")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Remove directory.
_ = os.RemoveAll(testDir)
// Restore should recreate it.
if err := g.Restore(nil, true, 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("restored path should be a directory")
}
}

201
garden/mirror.go Normal file
View File

@@ -0,0 +1,201 @@
package garden
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// MirrorUp synchronises the manifest with the current filesystem state for
// each given directory path. New files/symlinks are added, deleted files are
// removed from the manifest, and changed files are re-hashed.
func (g *Garden) MirrorUp(paths []string) error {
now := g.clock.Now().UTC()
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
tildePrefix := toTildePath(abs)
// Ensure we match entries *under* the directory, not just the dir itself.
if !strings.HasSuffix(tildePrefix, "/") {
tildePrefix += "/"
}
// 1. Walk the directory and add any new files/symlinks.
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
tilded := toTildePath(path)
if g.manifest.IsExcluded(tilded) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
fi, lstatErr := os.Lstat(path)
if lstatErr != nil {
return fmt.Errorf("stat %s: %w", path, lstatErr)
}
return g.addEntry(path, fi, now, true, AddOptions{})
})
if err != nil {
return fmt.Errorf("walking directory %s: %w", abs, err)
}
// 2. Remove manifest entries whose files no longer exist on disk.
kept := g.manifest.Files[:0]
for _, e := range g.manifest.Files {
if strings.HasPrefix(e.Path, tildePrefix) {
expanded, err := ExpandTildePath(e.Path)
if err != nil {
return fmt.Errorf("expanding path %s: %w", e.Path, err)
}
if _, err := os.Lstat(expanded); err != nil {
// File no longer exists — drop entry.
continue
}
}
kept = append(kept, e)
}
g.manifest.Files = kept
// 3. Re-hash remaining file entries under the prefix (like Checkpoint).
for i := range g.manifest.Files {
entry := &g.manifest.Files[i]
if !strings.HasPrefix(entry.Path, tildePrefix) {
continue
}
if entry.Type != "file" {
continue
}
expanded, err := ExpandTildePath(entry.Path)
if err != nil {
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
}
data, err := os.ReadFile(expanded)
if err != nil {
return fmt.Errorf("reading %s: %w", expanded, err)
}
hash, err := g.store.Write(data)
if err != nil {
return fmt.Errorf("storing blob for %s: %w", expanded, err)
}
if hash != entry.Hash {
entry.Hash = hash
entry.Updated = now
}
}
}
g.manifest.Updated = now
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// MirrorDown synchronises the filesystem with the manifest for each given
// directory path. Tracked entries are restored and untracked files on disk
// are deleted. If force is false, confirm is called before each deletion;
// a false return skips that file.
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error {
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
tildePrefix := toTildePath(abs)
if !strings.HasSuffix(tildePrefix, "/") {
tildePrefix += "/"
}
// 1. Collect manifest entries under this prefix.
tracked := make(map[string]bool)
for i := range g.manifest.Files {
entry := &g.manifest.Files[i]
if !strings.HasPrefix(entry.Path, tildePrefix) {
continue
}
expanded, err := ExpandTildePath(entry.Path)
if err != nil {
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
}
tracked[expanded] = true
// Create parent directories.
dir := filepath.Dir(expanded)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating directory %s: %w", dir, err)
}
// Restore the entry.
switch entry.Type {
case "file":
if err := g.restoreFile(expanded, entry); err != nil {
return err
}
case "link":
if err := restoreLink(expanded, entry); err != nil {
return err
}
}
}
// 2. Walk disk and delete files not in manifest.
var emptyDirs []string
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
if g.manifest.IsExcluded(toTildePath(path)) {
return filepath.SkipDir
}
// Collect directories for potential cleanup (post-order).
if path != abs {
emptyDirs = append(emptyDirs, path)
}
return nil
}
if tracked[path] {
return nil
}
// Excluded paths are left alone on disk.
if g.manifest.IsExcluded(toTildePath(path)) {
return nil
}
// Untracked file/symlink on disk.
if !force {
if confirm == nil || !confirm(path) {
return nil
}
}
_ = os.Remove(path)
return nil
})
if err != nil {
return fmt.Errorf("walking directory %s: %w", abs, err)
}
// 3. Clean up empty directories (reverse order so children come first).
for i := len(emptyDirs) - 1; i >= 0; i-- {
// os.Remove only removes empty directories.
_ = os.Remove(emptyDirs[i])
}
}
return nil
}

297
garden/mirror_test.go Normal file
View File

@@ -0,0 +1,297 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestAddRecursesDirectory(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 directory tree with nested files.
dir := filepath.Join(root, "dotfiles")
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatalf("creating dirs: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "a.conf"), []byte("aaa"), 0o644); err != nil {
t.Fatalf("writing a.conf: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "b.conf"), []byte("bbb"), 0o644); err != nil {
t.Fatalf("writing b.conf: %v", err)
}
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 2 {
t.Fatalf("expected 2 files, got %d", len(g.manifest.Files))
}
for _, e := range g.manifest.Files {
if e.Type == "directory" {
t.Errorf("should not have directory type entries, got %+v", e)
}
if e.Type != "file" {
t.Errorf("expected type file, got %s for %s", e.Type, e.Path)
}
if e.Hash == "" {
t.Errorf("expected non-empty hash for %s", e.Path)
}
}
}
func TestAddRecursesSkipsDuplicates(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
dir := filepath.Join(root, "dotfiles")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("data"), 0o644); err != nil {
t.Fatalf("writing file: %v", err)
}
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("first Add: %v", err)
}
// Second add of the same directory should not error or create duplicates.
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("second Add should not error: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Errorf("expected 1 entry, got %d", len(g.manifest.Files))
}
}
func TestMirrorUpAddsNew(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
dir := filepath.Join(root, "dotfiles")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("old"), 0o644); err != nil {
t.Fatalf("writing file: %v", err)
}
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file after Add, got %d", len(g.manifest.Files))
}
// Create a new file inside the directory.
if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644); err != nil {
t.Fatalf("writing new file: %v", err)
}
if err := g.MirrorUp([]string{dir}); err != nil {
t.Fatalf("MirrorUp: %v", err)
}
if len(g.manifest.Files) != 2 {
t.Fatalf("expected 2 files after MirrorUp, got %d", len(g.manifest.Files))
}
}
func TestMirrorUpRemovesDeleted(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
dir := filepath.Join(root, "dotfiles")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644); err != nil {
t.Fatalf("writing keep file: %v", err)
}
deleteFile := filepath.Join(dir, "delete.txt")
if err := os.WriteFile(deleteFile, []byte("delete"), 0o644); err != nil {
t.Fatalf("writing delete file: %v", err)
}
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 2 {
t.Fatalf("expected 2 files, got %d", len(g.manifest.Files))
}
// Delete one file from disk.
_ = os.Remove(deleteFile)
if err := g.MirrorUp([]string{dir}); err != nil {
t.Fatalf("MirrorUp: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file after MirrorUp, got %d", len(g.manifest.Files))
}
if g.manifest.Files[0].Path != toTildePath(filepath.Join(dir, "keep.txt")) {
t.Errorf("remaining entry should be keep.txt, got %s", g.manifest.Files[0].Path)
}
}
func TestMirrorUpRehashesChanged(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
dir := filepath.Join(root, "dotfiles")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
f := filepath.Join(dir, "config.txt")
if err := os.WriteFile(f, []byte("original"), 0o644); err != nil {
t.Fatalf("writing file: %v", err)
}
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
// Modify the file.
if err := os.WriteFile(f, []byte("modified"), 0o644); err != nil {
t.Fatalf("modifying file: %v", err)
}
if err := g.MirrorUp([]string{dir}); err != nil {
t.Fatalf("MirrorUp: %v", err)
}
if g.manifest.Files[0].Hash == origHash {
t.Error("MirrorUp did not update hash for modified file")
}
}
func TestMirrorDownRestoresAndCleans(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
dir := filepath.Join(root, "dotfiles")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
tracked := filepath.Join(dir, "tracked.txt")
if err := os.WriteFile(tracked, []byte("tracked"), 0o644); err != nil {
t.Fatalf("writing tracked file: %v", err)
}
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
// Modify the tracked file and create an untracked file.
if err := os.WriteFile(tracked, []byte("overwritten"), 0o644); err != nil {
t.Fatalf("modifying tracked file: %v", err)
}
extra := filepath.Join(dir, "extra.txt")
if err := os.WriteFile(extra, []byte("extra"), 0o644); err != nil {
t.Fatalf("writing extra file: %v", err)
}
if err := g.MirrorDown([]string{dir}, true, nil); err != nil {
t.Fatalf("MirrorDown: %v", err)
}
// Tracked file should be restored to original content.
got, err := os.ReadFile(tracked)
if err != nil {
t.Fatalf("reading tracked file: %v", err)
}
if string(got) != "tracked" {
t.Errorf("tracked file content = %q, want %q", got, "tracked")
}
// Extra file should be deleted.
if _, err := os.Stat(extra); err == nil {
t.Error("extra file should have been deleted by MirrorDown with force")
}
}
func TestMirrorDownConfirmSkips(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
dir := filepath.Join(root, "dotfiles")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("creating dir: %v", err)
}
tracked := filepath.Join(dir, "tracked.txt")
if err := os.WriteFile(tracked, []byte("tracked"), 0o644); err != nil {
t.Fatalf("writing tracked file: %v", err)
}
if err := g.Add([]string{dir}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
// Create an untracked file.
extra := filepath.Join(dir, "extra.txt")
if err := os.WriteFile(extra, []byte("extra"), 0o644); err != nil {
t.Fatalf("writing extra file: %v", err)
}
// Confirm returns false — should NOT delete.
alwaysNo := func(path string) bool { return false }
if err := g.MirrorDown([]string{dir}, false, alwaysNo); err != nil {
t.Fatalf("MirrorDown: %v", err)
}
if _, err := os.Stat(extra); err != nil {
t.Error("extra file should NOT have been deleted when confirm returns false")
}
}

31
garden/prune.go Normal file
View File

@@ -0,0 +1,31 @@
package garden
import "fmt"
// Prune removes orphaned blobs that are not referenced by any manifest entry.
// Returns the number of blobs removed.
func (g *Garden) Prune() (int, error) {
referenced := make(map[string]bool)
for _, e := range g.manifest.Files {
if e.Type == "file" && e.Hash != "" {
referenced[e.Hash] = true
}
}
allBlobs, err := g.store.List()
if err != nil {
return 0, fmt.Errorf("listing blobs: %w", err)
}
removed := 0
for _, hash := range allBlobs {
if !referenced[hash] {
if err := g.store.Delete(hash); err != nil {
return removed, fmt.Errorf("deleting blob %s: %w", hash, err)
}
removed++
}
}
return removed, nil
}

79
garden/prune_test.go Normal file
View File

@@ -0,0 +1,79 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestPruneRemovesOrphanedBlob(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Add a file, then remove it from manifest. The blob becomes orphaned.
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("orphan data"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
hash := g.manifest.Files[0].Hash
if !g.BlobExists(hash) {
t.Fatal("blob should exist before prune")
}
if err := g.Remove([]string{testFile}); err != nil {
t.Fatalf("Remove: %v", err)
}
removed, err := g.Prune()
if err != nil {
t.Fatalf("Prune: %v", err)
}
if removed != 1 {
t.Errorf("removed %d blobs, want 1", removed)
}
if g.BlobExists(hash) {
t.Error("orphaned blob should be deleted after prune")
}
}
func TestPruneKeepsReferencedBlobs(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("keep me"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
hash := g.manifest.Files[0].Hash
removed, err := g.Prune()
if err != nil {
t.Fatalf("Prune: %v", err)
}
if removed != 0 {
t.Errorf("removed %d blobs, want 0 (all referenced)", removed)
}
if !g.BlobExists(hash) {
t.Error("referenced blob should still exist after prune")
}
}

65
garden/tags.go Normal file
View File

@@ -0,0 +1,65 @@
package garden
import (
"os"
"path/filepath"
"strings"
)
// LoadTags reads the tags from <repo>/tags, one per line.
func (g *Garden) LoadTags() []string {
data, err := os.ReadFile(filepath.Join(g.root, "tags"))
if err != nil {
return nil
}
var tags []string
for _, line := range strings.Split(string(data), "\n") {
tag := strings.TrimSpace(line)
if tag != "" {
tags = append(tags, tag)
}
}
return tags
}
// SaveTag adds a tag to <repo>/tags if not already present.
func (g *Garden) SaveTag(tag string) error {
tag = strings.TrimSpace(tag)
if tag == "" {
return nil
}
tags := g.LoadTags()
for _, existing := range tags {
if existing == tag {
return nil // already present
}
}
tags = append(tags, tag)
return g.writeTags(tags)
}
// RemoveTag removes a tag from <repo>/tags.
func (g *Garden) RemoveTag(tag string) error {
tag = strings.TrimSpace(tag)
tags := g.LoadTags()
var filtered []string
for _, t := range tags {
if t != tag {
filtered = append(filtered, t)
}
}
return g.writeTags(filtered)
}
func (g *Garden) writeTags(tags []string) error {
content := strings.Join(tags, "\n")
if content != "" {
content += "\n"
}
return os.WriteFile(filepath.Join(g.root, "tags"), []byte(content), 0o644)
}

34
garden/target.go Normal file
View File

@@ -0,0 +1,34 @@
package garden
import "fmt"
// SetTargeting updates the Only/Never fields on an existing manifest entry.
// If clear is true, both fields are reset to nil.
func (g *Garden) SetTargeting(path string, only, never []string, clear bool) error {
abs, err := ExpandTildePath(path)
if err != nil {
return fmt.Errorf("expanding path: %w", err)
}
tilded := toTildePath(abs)
entry := g.findEntry(tilded)
if entry == nil {
return fmt.Errorf("not tracking %s", tilded)
}
if clear {
entry.Only = nil
entry.Never = nil
} else {
if len(only) > 0 {
entry.Only = only
entry.Never = nil
}
if len(never) > 0 {
entry.Never = never
entry.Only = nil
}
}
return g.manifest.Save(g.manifestPath)
}

48
garden/targeting.go Normal file
View File

@@ -0,0 +1,48 @@
package garden
import (
"fmt"
"strings"
"github.com/kisom/sgard/manifest"
)
// EntryApplies reports whether the given entry should be active on a
// machine with the given labels. Returns an error if both Only and
// Never are set on the same entry.
func EntryApplies(entry *manifest.Entry, labels []string) (bool, error) {
if len(entry.Only) > 0 && len(entry.Never) > 0 {
return false, fmt.Errorf("entry %s has both only and never set", entry.Path)
}
if len(entry.Only) > 0 {
for _, matcher := range entry.Only {
if matchesLabel(matcher, labels) {
return true, nil
}
}
return false, nil
}
if len(entry.Never) > 0 {
for _, matcher := range entry.Never {
if matchesLabel(matcher, labels) {
return false, nil
}
}
}
return true, nil
}
// matchesLabel checks if a matcher string matches any label in the set.
// Matching is case-insensitive.
func matchesLabel(matcher string, labels []string) bool {
matcher = strings.ToLower(matcher)
for _, label := range labels {
if strings.ToLower(label) == matcher {
return true
}
}
return false
}

View File

@@ -0,0 +1,190 @@
package garden
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestCheckpointSkipsNonMatching(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("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add with only:os:fakeos — won't match this machine.
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:fakeos"}}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
// Modify file.
if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
t.Fatalf("modifying: %v", err)
}
// Checkpoint should skip this entry.
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash != origHash {
t.Error("checkpoint should skip non-matching entry")
}
}
func TestCheckpointProcessesMatching(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("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add with only matching current OS.
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:" + runtime.GOOS}}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
if err := os.WriteFile(testFile, []byte("modified"), 0o644); 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 process matching entry")
}
}
func TestStatusReportsSkipped(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("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:fakeos"}}); err != nil {
t.Fatalf("Add: %v", err)
}
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 1 || statuses[0].State != "skipped" {
t.Errorf("expected skipped, got %v", statuses)
}
}
func TestRestoreSkipsNonMatching(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("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:fakeos"}}); err != nil {
t.Fatalf("Add: %v", err)
}
// Delete file and try to restore — should skip.
_ = os.Remove(testFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
// File should NOT have been restored.
if _, err := os.Stat(testFile); !os.IsNotExist(err) {
t.Error("restore should skip non-matching entry — file should not exist")
}
}
func TestAddWithTargeting(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("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{
Only: []string{"os:linux", "tag:work"},
}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if len(entry.Only) != 2 {
t.Fatalf("expected 2 only labels, got %d", len(entry.Only))
}
if entry.Only[0] != "os:linux" || entry.Only[1] != "tag:work" {
t.Errorf("only = %v, want [os:linux tag:work]", entry.Only)
}
}
func TestAddWithNever(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("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{
Never: []string{"arch:arm64"},
}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if len(entry.Never) != 1 || entry.Never[0] != "arch:arm64" {
t.Errorf("never = %v, want [arch:arm64]", entry.Never)
}
}

238
garden/targeting_test.go Normal file
View File

@@ -0,0 +1,238 @@
package garden
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/kisom/sgard/manifest"
)
func TestEntryApplies_NoTargeting(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc"}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Error("entry with no targeting should always apply")
}
}
func TestEntryApplies_OnlyMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"os:linux"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match os:linux")
}
}
func TestEntryApplies_OnlyNoMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"os:darwin"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("os:darwin should not match os:linux machine")
}
}
func TestEntryApplies_OnlyHostname(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"vade"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match hostname vade")
}
}
func TestEntryApplies_OnlyTag(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"tag:work"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "tag:work"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match tag:work")
}
ok, err = EntryApplies(entry, []string{"vade", "os:linux"})
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("should not match without tag:work")
}
}
func TestEntryApplies_NeverMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Never: []string{"arch:arm64"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:arm64"})
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("should be excluded by never:arch:arm64")
}
}
func TestEntryApplies_NeverNoMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Never: []string{"arch:arm64"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("arch:amd64 machine should not be excluded by never:arch:arm64")
}
}
func TestEntryApplies_BothError(t *testing.T) {
entry := &manifest.Entry{
Path: "~/.bashrc",
Only: []string{"os:linux"},
Never: []string{"arch:arm64"},
}
_, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err == nil {
t.Fatal("should error when both only and never are set")
}
}
func TestEntryApplies_CaseInsensitive(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"OS:Linux"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("matching should be case-insensitive")
}
}
func TestEntryApplies_OnlyMultiple(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"os:darwin", "os:linux"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match if any label in only matches")
}
}
func TestIdentity(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
labels := g.Identity()
// Should contain os and arch.
found := make(map[string]bool)
for _, l := range labels {
found[l] = true
}
osLabel := "os:" + runtime.GOOS
archLabel := "arch:" + runtime.GOARCH
if !found[osLabel] {
t.Errorf("identity should contain %s", osLabel)
}
if !found[archLabel] {
t.Errorf("identity should contain %s", archLabel)
}
// Should contain a hostname (non-empty, no dots).
hostname := labels[0]
if hostname == "" || strings.Contains(hostname, ".") || strings.Contains(hostname, ":") {
t.Errorf("first label should be short hostname, got %q", hostname)
}
}
func TestTags(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// No tags initially.
if tags := g.LoadTags(); len(tags) != 0 {
t.Fatalf("expected no tags, got %v", tags)
}
// Add tags.
if err := g.SaveTag("work"); err != nil {
t.Fatalf("SaveTag: %v", err)
}
if err := g.SaveTag("desktop"); err != nil {
t.Fatalf("SaveTag: %v", err)
}
tags := g.LoadTags()
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %v", tags)
}
// Duplicate add is idempotent.
if err := g.SaveTag("work"); err != nil {
t.Fatalf("SaveTag duplicate: %v", err)
}
if tags := g.LoadTags(); len(tags) != 2 {
t.Fatalf("expected 2 tags after duplicate add, got %v", tags)
}
// Remove.
if err := g.RemoveTag("work"); err != nil {
t.Fatalf("RemoveTag: %v", err)
}
tags = g.LoadTags()
if len(tags) != 1 || tags[0] != "desktop" {
t.Fatalf("expected [desktop], got %v", tags)
}
// Tags appear in identity.
labels := g.Identity()
found := false
for _, l := range labels {
if l == "tag:desktop" {
found = true
}
}
if !found {
t.Errorf("identity should contain tag:desktop, got %v", labels)
}
}
func TestInitCreatesGitignoreWithTags(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if _, err := Init(repoDir); err != nil {
t.Fatalf("Init: %v", err)
}
data, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil {
t.Fatalf("reading .gitignore: %v", err)
}
if !strings.Contains(string(data), "tags") {
t.Error(".gitignore should contain 'tags'")
}
}

24
go.mod
View File

@@ -3,9 +3,23 @@ module github.com/kisom/sgard
go 1.25.7 go 1.25.7
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0
github.com/spf13/cobra v1.10.2 // indirect github.com/keys-pub/go-libfido2 v1.5.3
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1 // indirect golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
) )

59
go.sum
View File

@@ -1,14 +1,73 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/keys-pub/go-libfido2 v1.5.3 h1:vtgHxlSB43u6lj0TSuA3VvT6z3E7VI+L1a2hvMFdECk=
github.com/keys-pub/go-libfido2 v1.5.3/go.mod h1:P0V19qHwJNY0htZwZDe9Ilvs/nokGhdFX7faKFyZ6+U=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

265
integration/phase4_test.go Normal file
View File

@@ -0,0 +1,265 @@
package integration
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/kisom/sgard/client"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func generateSelfSignedCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "sgard-e2e"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
DNSNames: []string{"localhost"},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("creating certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshaling key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatalf("loading key pair: %v", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(certPEM)
return cert, pool
}
// TestE2E_Phase4 exercises TLS + encryption + locked files in a push/pull cycle.
func TestE2E_Phase4(t *testing.T) {
// --- Setup TLS server ---
cert, caPool := generateSelfSignedCert(t)
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
serverCreds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
srv := grpc.NewServer(grpc.Creds(serverCreds))
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
t.Cleanup(func() { srv.Stop() })
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() { _ = srv.Serve(lis) }()
clientCreds := credentials.NewTLS(&tls.Config{
RootCAs: caPool,
MinVersion: tls.VersionTLS12,
})
// --- Build source garden with encryption + locked files ---
srcRoot := t.TempDir()
srcRepoDir := filepath.Join(srcRoot, "repo")
srcGarden, err := garden.Init(srcRepoDir)
if err != nil {
t.Fatalf("init source garden: %v", err)
}
if err := srcGarden.EncryptInit("test-passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
plainFile := filepath.Join(srcRoot, "plain")
secretFile := filepath.Join(srcRoot, "secret")
lockedFile := filepath.Join(srcRoot, "locked")
encLockedFile := filepath.Join(srcRoot, "enc-locked")
if err := os.WriteFile(plainFile, []byte("plain data"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(lockedFile, []byte("locked data"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(encLockedFile, []byte("enc+locked data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := srcGarden.Add([]string{plainFile}); err != nil {
t.Fatalf("Add plain: %v", err)
}
if err := srcGarden.Add([]string{secretFile}, garden.AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
if err := srcGarden.Add([]string{lockedFile}, garden.AddOptions{Lock: true}); err != nil {
t.Fatalf("Add locked: %v", err)
}
if err := srcGarden.Add([]string{encLockedFile}, garden.AddOptions{Encrypt: true, Lock: true}); err != nil {
t.Fatalf("Add encrypted+locked: %v", err)
}
// Bump timestamp so push wins.
srcManifest := srcGarden.GetManifest()
srcManifest.Updated = time.Now().UTC().Add(time.Hour)
if err := srcGarden.ReplaceManifest(srcManifest); err != nil {
t.Fatalf("ReplaceManifest: %v", err)
}
// --- Push over TLS ---
ctx := context.Background()
pushConn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(clientCreds),
)
if err != nil {
t.Fatalf("dial for push: %v", err)
}
defer func() { _ = pushConn.Close() }()
pushClient := client.New(pushConn)
pushed, err := pushClient.Push(ctx, srcGarden)
if err != nil {
t.Fatalf("Push: %v", err)
}
if pushed < 2 {
t.Errorf("expected at least 2 blobs pushed, got %d", pushed)
}
// --- Pull to a fresh garden over TLS ---
dstRoot := t.TempDir()
dstRepoDir := filepath.Join(dstRoot, "repo")
dstGarden, err := garden.Init(dstRepoDir)
if err != nil {
t.Fatalf("init dest garden: %v", err)
}
pullConn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(clientCreds),
)
if err != nil {
t.Fatalf("dial for pull: %v", err)
}
defer func() { _ = pullConn.Close() }()
pullClient := client.New(pullConn)
pulled, err := pullClient.Pull(ctx, dstGarden)
if err != nil {
t.Fatalf("Pull: %v", err)
}
if pulled < 2 {
t.Errorf("expected at least 2 blobs pulled, got %d", pulled)
}
// --- Verify the pulled manifest ---
dstManifest := dstGarden.GetManifest()
if len(dstManifest.Files) != 4 {
t.Fatalf("expected 4 entries, got %d", len(dstManifest.Files))
}
type entryInfo struct {
encrypted bool
locked bool
}
entryMap := make(map[string]entryInfo)
for _, e := range dstManifest.Files {
entryMap[e.Path] = entryInfo{e.Encrypted, e.Locked}
}
// Verify flags survived round trip.
for path, info := range entryMap {
switch {
case path == toTilde(secretFile):
if !info.encrypted {
t.Errorf("%s should be encrypted", path)
}
case path == toTilde(lockedFile):
if !info.locked {
t.Errorf("%s should be locked", path)
}
case path == toTilde(encLockedFile):
if !info.encrypted || !info.locked {
t.Errorf("%s should be encrypted+locked", path)
}
case path == toTilde(plainFile):
if info.encrypted || info.locked {
t.Errorf("%s should be plain", path)
}
}
}
// Verify encryption config survived.
if dstManifest.Encryption == nil {
t.Fatal("encryption config should survive push/pull")
}
if dstManifest.Encryption.Algorithm != "xchacha20-poly1305" {
t.Errorf("algorithm = %s, want xchacha20-poly1305", dstManifest.Encryption.Algorithm)
}
if _, ok := dstManifest.Encryption.KekSlots["passphrase"]; !ok {
t.Error("passphrase slot should survive push/pull")
}
// Verify all blobs arrived.
for _, e := range dstManifest.Files {
if e.Hash != "" && !dstGarden.BlobExists(e.Hash) {
t.Errorf("blob missing for %s (hash %s)", e.Path, e.Hash)
}
}
// Unlock on dest and verify DEK works.
if err := dstGarden.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err != nil {
t.Fatalf("UnlockDEK on dest: %v", err)
}
}
func toTilde(path string) string {
home, err := os.UserHomeDir()
if err != nil {
return path
}
rel, err := filepath.Rel(home, path)
if err != nil || len(rel) > 0 && rel[0] == '.' {
return path
}
return "~/" + rel
}

148
integration/phase5_test.go Normal file
View File

@@ -0,0 +1,148 @@
package integration
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/kisom/sgard/client"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
const bufSize = 1024 * 1024
// TestE2E_Phase5_Targeting verifies that targeting labels survive push/pull
// and that restore respects them.
func TestE2E_Phase5_Targeting(t *testing.T) {
// Set up bufconn server.
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server: %v", err)
}
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer()
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
t.Cleanup(func() { srv.Stop() })
go func() { _ = srv.Serve(lis) }()
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
// --- Build source garden with targeted entries ---
srcRoot := t.TempDir()
srcRepoDir := filepath.Join(srcRoot, "repo")
srcGarden, err := garden.Init(srcRepoDir)
if err != nil {
t.Fatalf("init source: %v", err)
}
linuxFile := filepath.Join(srcRoot, "linux-only")
everywhereFile := filepath.Join(srcRoot, "everywhere")
neverArmFile := filepath.Join(srcRoot, "never-arm")
if err := os.WriteFile(linuxFile, []byte("linux"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(everywhereFile, []byte("everywhere"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(neverArmFile, []byte("not arm"), 0o644); err != nil {
t.Fatal(err)
}
if err := srcGarden.Add([]string{linuxFile}, garden.AddOptions{Only: []string{"os:linux"}}); err != nil {
t.Fatalf("Add linux-only: %v", err)
}
if err := srcGarden.Add([]string{everywhereFile}); err != nil {
t.Fatalf("Add everywhere: %v", err)
}
if err := srcGarden.Add([]string{neverArmFile}, garden.AddOptions{Never: []string{"arch:arm64"}}); err != nil {
t.Fatalf("Add never-arm: %v", err)
}
// Bump timestamp.
m := srcGarden.GetManifest()
m.Updated = time.Now().UTC().Add(time.Hour)
if err := srcGarden.ReplaceManifest(m); err != nil {
t.Fatal(err)
}
// --- Push ---
ctx := context.Background()
pushClient := client.New(conn)
if _, err := pushClient.Push(ctx, srcGarden); err != nil {
t.Fatalf("Push: %v", err)
}
// --- Pull to fresh garden ---
dstRoot := t.TempDir()
dstRepoDir := filepath.Join(dstRoot, "repo")
dstGarden, err := garden.Init(dstRepoDir)
if err != nil {
t.Fatalf("init dest: %v", err)
}
pullClient := client.New(conn)
if _, err := pullClient.Pull(ctx, dstGarden); err != nil {
t.Fatalf("Pull: %v", err)
}
// --- Verify targeting survived ---
dm := dstGarden.GetManifest()
if len(dm.Files) != 3 {
t.Fatalf("expected 3 entries, got %d", len(dm.Files))
}
for _, e := range dm.Files {
switch {
case e.Path == toTilde(linuxFile):
if len(e.Only) != 1 || e.Only[0] != "os:linux" {
t.Errorf("%s: only = %v, want [os:linux]", e.Path, e.Only)
}
case e.Path == toTilde(everywhereFile):
if len(e.Only) != 0 || len(e.Never) != 0 {
t.Errorf("%s: should have no targeting", e.Path)
}
case e.Path == toTilde(neverArmFile):
if len(e.Never) != 1 || e.Never[0] != "arch:arm64" {
t.Errorf("%s: never = %v, want [arch:arm64]", e.Path, e.Never)
}
}
}
// Verify restore skips non-matching entries.
// Delete all files, then restore — only matching entries should appear.
_ = os.Remove(linuxFile)
_ = os.Remove(everywhereFile)
_ = os.Remove(neverArmFile)
if err := dstGarden.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
// "everywhere" should always be restored.
if _, err := os.Stat(everywhereFile); os.IsNotExist(err) {
t.Error("everywhere file should be restored")
}
// "linux-only" depends on current OS — we just verify no error occurred.
// "never-arm" depends on current arch.
}

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -11,21 +12,67 @@ import (
// Entry represents a single tracked file, directory, or symlink. // Entry represents a single tracked file, directory, or symlink.
type Entry struct { type Entry struct {
Path string `yaml:"path"` Path string `yaml:"path"`
Hash string `yaml:"hash,omitempty"` Hash string `yaml:"hash,omitempty"`
Type string `yaml:"type"` PlaintextHash string `yaml:"plaintext_hash,omitempty"`
Mode string `yaml:"mode,omitempty"` Encrypted bool `yaml:"encrypted,omitempty"`
Target string `yaml:"target,omitempty"` Locked bool `yaml:"locked,omitempty"`
Updated time.Time `yaml:"updated"` Type string `yaml:"type"`
Mode string `yaml:"mode,omitempty"`
Target string `yaml:"target,omitempty"`
Updated time.Time `yaml:"updated"`
Only []string `yaml:"only,omitempty"`
Never []string `yaml:"never,omitempty"`
}
// KekSlot describes a single KEK source that can unwrap the DEK.
type KekSlot struct {
Type string `yaml:"type"` // "passphrase" or "fido2"
Argon2Time int `yaml:"argon2_time,omitempty"` // passphrase only
Argon2Memory int `yaml:"argon2_memory,omitempty"` // passphrase only (KiB)
Argon2Threads int `yaml:"argon2_threads,omitempty"` // passphrase only
CredentialID string `yaml:"credential_id,omitempty"` // fido2 only (base64)
Salt string `yaml:"salt"` // base64-encoded
WrappedDEK string `yaml:"wrapped_dek"` // base64-encoded
}
// Encryption holds the encryption configuration embedded in the manifest.
type Encryption struct {
Algorithm string `yaml:"algorithm"`
KekSlots map[string]*KekSlot `yaml:"kek_slots"`
} }
// Manifest is the top-level manifest describing all tracked entries. // Manifest is the top-level manifest describing all tracked entries.
type Manifest struct { type Manifest struct {
Version int `yaml:"version"` Version int `yaml:"version"`
Created time.Time `yaml:"created"` Created time.Time `yaml:"created"`
Updated time.Time `yaml:"updated"` Updated time.Time `yaml:"updated"`
Message string `yaml:"message,omitempty"` Message string `yaml:"message,omitempty"`
Files []Entry `yaml:"files"` Files []Entry `yaml:"files"`
Exclude []string `yaml:"exclude,omitempty"`
Encryption *Encryption `yaml:"encryption,omitempty"`
}
// IsExcluded reports whether the given tilde path should be excluded from
// tracking. A path is excluded if it matches an exclude entry exactly, or
// if it falls under an excluded directory (an exclude entry that is a prefix
// followed by a path separator).
func (m *Manifest) IsExcluded(tildePath string) bool {
for _, ex := range m.Exclude {
if tildePath == ex {
return true
}
// Directory exclusion: if the exclude entry is a prefix of the
// path with a separator boundary, the path is under that directory.
prefix := ex
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
if strings.HasPrefix(tildePath, prefix) {
return true
}
}
return false
} }
// New creates a new empty manifest with Version 1 and timestamps set to now. // New creates a new empty manifest with Version 1 and timestamps set to now.

145
proto/sgard/v1/sgard.proto Normal file
View File

@@ -0,0 +1,145 @@
syntax = "proto3";
package sgard.v1;
option go_package = "github.com/kisom/sgard/sgardpb";
import "google/protobuf/timestamp.proto";
// ManifestEntry mirrors manifest.Entry from the YAML model.
message ManifestEntry {
string path = 1;
string hash = 2;
string type = 3; // "file", "directory", "link"
string mode = 4;
string target = 5;
google.protobuf.Timestamp updated = 6;
string plaintext_hash = 7; // SHA-256 of plaintext (encrypted entries only)
bool encrypted = 8;
bool locked = 9; // repo-authoritative; restore always overwrites
repeated string only = 10; // per-machine targeting: only apply on matching
repeated string never = 11; // per-machine targeting: never apply on matching
}
// KekSlot describes a single KEK source for unwrapping the DEK.
message KekSlot {
string type = 1; // "passphrase" or "fido2"
int32 argon2_time = 2;
int32 argon2_memory = 3; // KiB
int32 argon2_threads = 4;
string credential_id = 5; // base64, fido2 only
string salt = 6; // base64
string wrapped_dek = 7; // base64
}
// Encryption holds the encryption configuration.
message Encryption {
string algorithm = 1;
map<string, KekSlot> kek_slots = 2;
}
// Manifest mirrors the top-level manifest.Manifest.
message Manifest {
int32 version = 1;
google.protobuf.Timestamp created = 2;
google.protobuf.Timestamp updated = 3;
string message = 4;
repeated ManifestEntry files = 5;
Encryption encryption = 6;
repeated string exclude = 7;
}
// BlobChunk is one piece of a streamed blob. The first chunk for a given
// hash carries the hash field; subsequent chunks omit it.
message BlobChunk {
string hash = 1; // SHA-256 hex, present on the first chunk of each blob
bytes data = 2; // up to 64 KiB per chunk
}
// Push messages.
message PushManifestRequest {
Manifest manifest = 1;
}
message PushManifestResponse {
enum Decision {
DECISION_UNSPECIFIED = 0;
ACCEPTED = 1; // server is older; push proceeds
REJECTED = 2; // server is newer; client should pull
UP_TO_DATE = 3; // timestamps match; nothing to do
}
Decision decision = 1;
repeated string missing_blobs = 2; // hashes the server needs
google.protobuf.Timestamp server_updated = 3;
}
message PushBlobsRequest {
BlobChunk chunk = 1;
}
message PushBlobsResponse {
int32 blobs_received = 1;
}
// Pull messages.
message PullManifestRequest {}
message PullManifestResponse {
Manifest manifest = 1;
}
message PullBlobsRequest {
repeated string hashes = 1; // blobs the client needs
}
message PullBlobsResponse {
BlobChunk chunk = 1;
}
// Prune messages.
message PruneRequest {}
message PruneResponse {
int32 blobs_removed = 1;
}
// Auth messages.
message AuthenticateRequest {
bytes nonce = 1; // 32-byte nonce (server-provided or client-generated)
int64 timestamp = 2; // Unix seconds
bytes signature = 3; // SSH signature over (nonce || timestamp)
string public_key = 4; // SSH public key in authorized_keys format
}
message AuthenticateResponse {
string token = 1; // JWT valid for 30 days
}
// ReauthChallenge is embedded in Unauthenticated error details when a
// token is expired but was previously valid. The client signs this
// challenge to obtain a new token without generating its own nonce.
message ReauthChallenge {
bytes nonce = 1; // server-generated 32-byte nonce
int64 timestamp = 2; // server's current Unix timestamp
}
// GardenSync is the sgard remote sync service.
service GardenSync {
// Authenticate exchanges an SSH-signed challenge for a JWT token.
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
// Push flow: send manifest, then stream missing blobs.
rpc PushManifest(PushManifestRequest) returns (PushManifestResponse);
rpc PushBlobs(stream PushBlobsRequest) returns (PushBlobsResponse);
// Pull flow: get manifest, then stream requested blobs.
rpc PullManifest(PullManifestRequest) returns (PullManifestResponse);
rpc PullBlobs(PullBlobsRequest) returns (stream PullBlobsResponse);
// Prune removes orphaned blobs on the server.
rpc Prune(PruneRequest) returns (PruneResponse);
}

301
server/auth.go Normal file
View File

@@ -0,0 +1,301 @@
package server
import (
"context"
"crypto/rand"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
const (
metaToken = "x-sgard-auth-token"
authWindow = 5 * time.Minute
tokenTTL = 30 * 24 * time.Hour // 30 days
)
// AuthInterceptor verifies JWT tokens or SSH key signatures on gRPC requests.
type AuthInterceptor struct {
authorizedKeys map[string]ssh.PublicKey // keyed by fingerprint
jwtKey []byte // HMAC-SHA256 signing key
}
// NewAuthInterceptor creates an interceptor from an authorized_keys file
// and a repository path (for the JWT secret key).
func NewAuthInterceptor(authorizedKeysPath, repoPath string) (*AuthInterceptor, error) {
data, err := os.ReadFile(authorizedKeysPath)
if err != nil {
return nil, fmt.Errorf("reading authorized keys: %w", err)
}
keys := make(map[string]ssh.PublicKey)
rest := data
for len(rest) > 0 {
var key ssh.PublicKey
key, _, _, rest, err = ssh.ParseAuthorizedKey(rest)
if err != nil {
break
}
fp := ssh.FingerprintSHA256(key)
keys[fp] = key
}
if len(keys) == 0 {
return nil, fmt.Errorf("no valid keys found in %s", authorizedKeysPath)
}
jwtKey, err := loadOrGenerateJWTKey(repoPath)
if err != nil {
return nil, fmt.Errorf("loading JWT key: %w", err)
}
return &AuthInterceptor{authorizedKeys: keys, jwtKey: jwtKey}, nil
}
// NewAuthInterceptorFromKeys creates an interceptor from pre-parsed keys
// and a provided JWT key. Intended for testing.
func NewAuthInterceptorFromKeys(keys []ssh.PublicKey, jwtKey []byte) *AuthInterceptor {
m := make(map[string]ssh.PublicKey, len(keys))
for _, k := range keys {
m[ssh.FingerprintSHA256(k)] = k
}
return &AuthInterceptor{authorizedKeys: m, jwtKey: jwtKey}
}
// UnaryInterceptor returns a gRPC unary server interceptor.
func (a *AuthInterceptor) UnaryInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
// Authenticate RPC is exempt from auth — it's how you get a token.
if strings.HasSuffix(info.FullMethod, "/Authenticate") {
return handler(ctx, req)
}
if err := a.verifyToken(ctx); err != nil {
return nil, err
}
return handler(ctx, req)
}
}
// StreamInterceptor returns a gRPC stream server interceptor.
func (a *AuthInterceptor) StreamInterceptor() grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := a.verifyToken(ss.Context()); err != nil {
return err
}
return handler(srv, ss)
}
}
// Authenticate verifies an SSH-signed challenge and issues a JWT.
func (a *AuthInterceptor) Authenticate(_ context.Context, req *sgardpb.AuthenticateRequest) (*sgardpb.AuthenticateResponse, error) {
pubkeyStr := req.GetPublicKey()
pubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkeyStr))
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid public key")
}
fp := ssh.FingerprintSHA256(pubkey)
authorized, ok := a.authorizedKeys[fp]
if !ok {
return nil, status.Errorf(codes.PermissionDenied, "key %s not authorized", fp)
}
// Verify timestamp window.
tsUnix := req.GetTimestamp()
ts := time.Unix(tsUnix, 0)
if time.Since(ts).Abs() > authWindow {
return nil, status.Error(codes.Unauthenticated, "timestamp outside allowed window")
}
// Verify signature.
payload := buildPayload(req.GetNonce(), tsUnix)
sig, err := parseSSHSignature(req.GetSignature())
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid signature format")
}
if err := authorized.Verify(payload, sig); err != nil {
return nil, status.Error(codes.Unauthenticated, "signature verification failed")
}
// Issue JWT.
token, err := a.issueToken(fp)
if err != nil {
return nil, status.Errorf(codes.Internal, "issuing token: %v", err)
}
return &sgardpb.AuthenticateResponse{Token: token}, nil
}
func (a *AuthInterceptor) verifyToken(ctx context.Context) error {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "missing metadata")
}
tokenStr := mdFirst(md, metaToken)
if tokenStr == "" {
return status.Error(codes.Unauthenticated, "missing auth token")
}
claims := &jwt.RegisteredClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return a.jwtKey, nil
})
if err != nil || !token.Valid {
// Check if the token is expired but otherwise valid.
if a.isExpiredButValid(tokenStr, claims) {
return a.reauthError()
}
return status.Error(codes.Unauthenticated, "invalid token")
}
// Verify the fingerprint is still authorized.
fp := claims.Subject
if _, ok := a.authorizedKeys[fp]; !ok {
return status.Errorf(codes.PermissionDenied, "key %s no longer authorized", fp)
}
return nil
}
// isExpiredButValid checks if a token has a valid signature and the
// fingerprint is still in authorized_keys, but the token is expired.
func (a *AuthInterceptor) isExpiredButValid(tokenStr string, claims *jwt.RegisteredClaims) bool {
// Re-parse without time validation.
reClaims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(tokenStr, reClaims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return a.jwtKey, nil
}, jwt.WithoutClaimsValidation())
if err != nil {
return false
}
fp := reClaims.Subject
_, authorized := a.authorizedKeys[fp]
return authorized
}
// reauthError returns an Unauthenticated error with a ReauthChallenge
// embedded in the error details.
func (a *AuthInterceptor) reauthError() error {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return status.Error(codes.Internal, "generating reauth nonce")
}
challenge := &sgardpb.ReauthChallenge{
Nonce: nonce,
Timestamp: time.Now().Unix(),
}
st, err := status.New(codes.Unauthenticated, "token expired").
WithDetails(challenge)
if err != nil {
return status.Error(codes.Unauthenticated, "token expired")
}
return st.Err()
}
func (a *AuthInterceptor) issueToken(fingerprint string) (string, error) {
now := time.Now()
claims := &jwt.RegisteredClaims{
Subject: fingerprint,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(tokenTTL)),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(a.jwtKey)
}
func loadOrGenerateJWTKey(repoPath string) ([]byte, error) {
keyPath := filepath.Join(repoPath, "jwt.key")
data, err := os.ReadFile(keyPath)
if err == nil && len(data) >= 32 {
return data[:32], nil
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generating JWT key: %w", err)
}
if err := os.WriteFile(keyPath, key, 0o600); err != nil {
return nil, fmt.Errorf("writing JWT key: %w", err)
}
return key, nil
}
// buildPayload constructs the message that is signed: nonce || timestamp (big-endian int64).
func buildPayload(nonce []byte, tsUnix int64) []byte {
payload := make([]byte, len(nonce)+8)
copy(payload, nonce)
for i := 7; i >= 0; i-- {
payload[len(nonce)+i] = byte(tsUnix & 0xff)
tsUnix >>= 8
}
return payload
}
// GenerateNonce creates a 32-byte random nonce.
func GenerateNonce() ([]byte, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generating nonce: %w", err)
}
return nonce, nil
}
func mdFirst(md metadata.MD, key string) string {
vals := md.Get(key)
if len(vals) == 0 {
return ""
}
return vals[0]
}
// parseSSHSignature deserializes an SSH signature from its wire format.
func parseSSHSignature(data []byte) (*ssh.Signature, error) {
if len(data) < 4 {
return nil, fmt.Errorf("signature too short")
}
formatLen := int(data[0])<<24 | int(data[1])<<16 | int(data[2])<<8 | int(data[3])
if 4+formatLen > len(data) {
return nil, fmt.Errorf("invalid format length")
}
format := string(data[4 : 4+formatLen])
rest := data[4+formatLen:]
if len(rest) < 4 {
return nil, fmt.Errorf("missing blob length")
}
blobLen := int(rest[0])<<24 | int(rest[1])<<16 | int(rest[2])<<8 | int(rest[3])
if 4+blobLen > len(rest) {
return nil, fmt.Errorf("invalid blob length")
}
blob := rest[4 : 4+blobLen]
return &ssh.Signature{
Format: format,
Blob: blob,
}, nil
}

146
server/auth_test.go Normal file
View File

@@ -0,0 +1,146 @@
package server
import (
"context"
"crypto/ed25519"
"crypto/rand"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc/metadata"
)
var testJWTKey = []byte("test-jwt-secret-key-32-bytes!!")
func generateTestKey(t *testing.T) (ssh.Signer, ssh.PublicKey) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("creating signer: %v", err)
}
return signer, signer.PublicKey()
}
func TestAuthenticateAndVerifyToken(t *testing.T) {
signer, pubkey := generateTestKey(t)
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}, testJWTKey)
// Generate a signed challenge.
nonce, _ := GenerateNonce()
tsUnix := time.Now().Unix()
payload := buildPayload(nonce, tsUnix)
sig, err := signer.Sign(rand.Reader, payload)
if err != nil {
t.Fatalf("signing: %v", err)
}
pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
// Call Authenticate.
resp, err := auth.Authenticate(context.Background(), &sgardpb.AuthenticateRequest{
Nonce: nonce,
Timestamp: tsUnix,
Signature: ssh.Marshal(sig),
PublicKey: pubkeyStr,
})
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if resp.Token == "" {
t.Fatal("expected non-empty token")
}
// Use the token in metadata.
md := metadata.New(map[string]string{metaToken: resp.Token})
ctx := metadata.NewIncomingContext(context.Background(), md)
if err := auth.verifyToken(ctx); err != nil {
t.Fatalf("verifyToken should accept valid token: %v", err)
}
}
func TestRejectMissingToken(t *testing.T) {
_, pubkey := generateTestKey(t)
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}, testJWTKey)
// No metadata at all.
if err := auth.verifyToken(context.Background()); err == nil {
t.Fatal("should reject missing metadata")
}
// Empty metadata.
md := metadata.New(nil)
ctx := metadata.NewIncomingContext(context.Background(), md)
if err := auth.verifyToken(ctx); err == nil {
t.Fatal("should reject missing token")
}
}
func TestRejectUnauthorizedKey(t *testing.T) {
signer1, _ := generateTestKey(t)
_, pubkey2 := generateTestKey(t)
// Auth only knows pubkey2, but we authenticate with signer1.
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey2}, testJWTKey)
nonce, _ := GenerateNonce()
tsUnix := time.Now().Unix()
payload := buildPayload(nonce, tsUnix)
sig, _ := signer1.Sign(rand.Reader, payload)
pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer1.PublicKey())))
_, err := auth.Authenticate(context.Background(), &sgardpb.AuthenticateRequest{
Nonce: nonce,
Timestamp: tsUnix,
Signature: ssh.Marshal(sig),
PublicKey: pubkeyStr,
})
if err == nil {
t.Fatal("should reject unauthorized key")
}
}
func TestExpiredTokenReturnsChallenge(t *testing.T) {
signer, pubkey := generateTestKey(t)
auth := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey}, testJWTKey)
// Issue a token, then manually create an expired one.
fp := ssh.FingerprintSHA256(signer.PublicKey())
expiredToken, err := auth.issueExpiredToken(fp)
if err != nil {
t.Fatalf("issuing expired token: %v", err)
}
md := metadata.New(map[string]string{metaToken: expiredToken})
ctx := metadata.NewIncomingContext(context.Background(), md)
err = auth.verifyToken(ctx)
if err == nil {
t.Fatal("should reject expired token")
}
// The error should contain a ReauthChallenge in its details.
// We can't easily extract it here without the client helper,
// but verify the error message indicates expiry.
if !strings.Contains(err.Error(), "expired") {
t.Errorf("error should mention expiry, got: %v", err)
}
}
// issueExpiredToken is a test helper that creates an already-expired JWT.
func (a *AuthInterceptor) issueExpiredToken(fingerprint string) (string, error) {
past := time.Now().Add(-time.Hour)
claims := &jwt.RegisteredClaims{
Subject: fingerprint,
IssuedAt: jwt.NewNumericDate(past.Add(-24 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(past),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(a.jwtKey)
}

122
server/convert.go Normal file
View File

@@ -0,0 +1,122 @@
package server
import (
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// ManifestToProto converts a manifest.Manifest to its protobuf representation.
func ManifestToProto(m *manifest.Manifest) *sgardpb.Manifest {
files := make([]*sgardpb.ManifestEntry, len(m.Files))
for i, e := range m.Files {
files[i] = EntryToProto(e)
}
pb := &sgardpb.Manifest{
Version: int32(m.Version),
Created: timestamppb.New(m.Created),
Updated: timestamppb.New(m.Updated),
Message: m.Message,
Files: files,
Exclude: m.Exclude,
}
if m.Encryption != nil {
pb.Encryption = EncryptionToProto(m.Encryption)
}
return pb
}
// ProtoToManifest converts a protobuf Manifest to a manifest.Manifest.
func ProtoToManifest(p *sgardpb.Manifest) *manifest.Manifest {
pFiles := p.GetFiles()
files := make([]manifest.Entry, len(pFiles))
for i, e := range pFiles {
files[i] = ProtoToEntry(e)
}
m := &manifest.Manifest{
Version: int(p.GetVersion()),
Created: p.GetCreated().AsTime(),
Updated: p.GetUpdated().AsTime(),
Message: p.GetMessage(),
Files: files,
Exclude: p.GetExclude(),
}
if p.GetEncryption() != nil {
m.Encryption = ProtoToEncryption(p.GetEncryption())
}
return m
}
// EntryToProto converts a manifest.Entry to its protobuf representation.
func EntryToProto(e manifest.Entry) *sgardpb.ManifestEntry {
return &sgardpb.ManifestEntry{
Path: e.Path,
Hash: e.Hash,
Type: e.Type,
Mode: e.Mode,
Target: e.Target,
Updated: timestamppb.New(e.Updated),
PlaintextHash: e.PlaintextHash,
Encrypted: e.Encrypted,
Locked: e.Locked,
Only: e.Only,
Never: e.Never,
}
}
// ProtoToEntry converts a protobuf ManifestEntry to a manifest.Entry.
func ProtoToEntry(p *sgardpb.ManifestEntry) manifest.Entry {
return manifest.Entry{
Path: p.GetPath(),
Hash: p.GetHash(),
Type: p.GetType(),
Mode: p.GetMode(),
Target: p.GetTarget(),
Updated: p.GetUpdated().AsTime(),
PlaintextHash: p.GetPlaintextHash(),
Encrypted: p.GetEncrypted(),
Locked: p.GetLocked(),
Only: p.GetOnly(),
Never: p.GetNever(),
}
}
// EncryptionToProto converts a manifest.Encryption to its protobuf representation.
func EncryptionToProto(e *manifest.Encryption) *sgardpb.Encryption {
slots := make(map[string]*sgardpb.KekSlot, len(e.KekSlots))
for name, slot := range e.KekSlots {
slots[name] = &sgardpb.KekSlot{
Type: slot.Type,
Argon2Time: int32(slot.Argon2Time),
Argon2Memory: int32(slot.Argon2Memory),
Argon2Threads: int32(slot.Argon2Threads),
CredentialId: slot.CredentialID,
Salt: slot.Salt,
WrappedDek: slot.WrappedDEK,
}
}
return &sgardpb.Encryption{
Algorithm: e.Algorithm,
KekSlots: slots,
}
}
// ProtoToEncryption converts a protobuf Encryption to a manifest.Encryption.
func ProtoToEncryption(p *sgardpb.Encryption) *manifest.Encryption {
slots := make(map[string]*manifest.KekSlot, len(p.GetKekSlots()))
for name, slot := range p.GetKekSlots() {
slots[name] = &manifest.KekSlot{
Type: slot.GetType(),
Argon2Time: int(slot.GetArgon2Time()),
Argon2Memory: int(slot.GetArgon2Memory()),
Argon2Threads: int(slot.GetArgon2Threads()),
CredentialID: slot.GetCredentialId(),
Salt: slot.GetSalt(),
WrappedDEK: slot.GetWrappedDek(),
}
}
return &manifest.Encryption{
Algorithm: p.GetAlgorithm(),
KekSlots: slots,
}
}

164
server/convert_test.go Normal file
View File

@@ -0,0 +1,164 @@
package server
import (
"testing"
"time"
"github.com/kisom/sgard/manifest"
)
func TestManifestRoundTrip(t *testing.T) {
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
m := &manifest.Manifest{
Version: 1,
Created: now,
Updated: now,
Message: "test checkpoint",
Files: []manifest.Entry{
{Path: "~/.bashrc", Hash: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", Type: "file", Mode: "0644", Updated: now},
{Path: "~/.config/nvim", Type: "directory", Mode: "0755", Updated: now},
{Path: "~/.vimrc", Type: "link", Target: "~/.config/nvim/init.vim", Updated: now},
},
}
proto := ManifestToProto(m)
back := ProtoToManifest(proto)
if back.Version != m.Version {
t.Errorf("Version: got %d, want %d", back.Version, m.Version)
}
if !back.Created.Equal(m.Created) {
t.Errorf("Created: got %v, want %v", back.Created, m.Created)
}
if !back.Updated.Equal(m.Updated) {
t.Errorf("Updated: got %v, want %v", back.Updated, m.Updated)
}
if back.Message != m.Message {
t.Errorf("Message: got %q, want %q", back.Message, m.Message)
}
if len(back.Files) != len(m.Files) {
t.Fatalf("Files count: got %d, want %d", len(back.Files), len(m.Files))
}
for i, want := range m.Files {
got := back.Files[i]
if got.Path != want.Path {
t.Errorf("Files[%d].Path: got %q, want %q", i, got.Path, want.Path)
}
if got.Hash != want.Hash {
t.Errorf("Files[%d].Hash: got %q, want %q", i, got.Hash, want.Hash)
}
if got.Type != want.Type {
t.Errorf("Files[%d].Type: got %q, want %q", i, got.Type, want.Type)
}
if got.Mode != want.Mode {
t.Errorf("Files[%d].Mode: got %q, want %q", i, got.Mode, want.Mode)
}
if got.Target != want.Target {
t.Errorf("Files[%d].Target: got %q, want %q", i, got.Target, want.Target)
}
if !got.Updated.Equal(want.Updated) {
t.Errorf("Files[%d].Updated: got %v, want %v", i, got.Updated, want.Updated)
}
}
}
func TestEmptyManifestRoundTrip(t *testing.T) {
now := time.Date(2026, 6, 15, 8, 30, 0, 0, time.UTC)
m := &manifest.Manifest{
Version: 1,
Created: now,
Updated: now,
Files: []manifest.Entry{},
}
proto := ManifestToProto(m)
back := ProtoToManifest(proto)
if back.Version != m.Version {
t.Errorf("Version: got %d, want %d", back.Version, m.Version)
}
if !back.Created.Equal(m.Created) {
t.Errorf("Created: got %v, want %v", back.Created, m.Created)
}
if !back.Updated.Equal(m.Updated) {
t.Errorf("Updated: got %v, want %v", back.Updated, m.Updated)
}
if back.Message != "" {
t.Errorf("Message: got %q, want empty", back.Message)
}
if len(back.Files) != 0 {
t.Errorf("Files count: got %d, want 0", len(back.Files))
}
}
func TestTargetingRoundTrip(t *testing.T) {
now := time.Date(2026, 3, 24, 0, 0, 0, 0, time.UTC)
onlyEntry := manifest.Entry{
Path: "~/.bashrc.linux",
Type: "file",
Hash: "abcd",
Only: []string{"os:linux", "tag:work"},
Updated: now,
}
proto := EntryToProto(onlyEntry)
back := ProtoToEntry(proto)
if len(back.Only) != 2 || back.Only[0] != "os:linux" || back.Only[1] != "tag:work" {
t.Errorf("Only round-trip: got %v, want [os:linux tag:work]", back.Only)
}
if len(back.Never) != 0 {
t.Errorf("Never should be empty, got %v", back.Never)
}
neverEntry := manifest.Entry{
Path: "~/.config/heavy",
Type: "file",
Hash: "efgh",
Never: []string{"arch:arm64"},
Updated: now,
}
proto2 := EntryToProto(neverEntry)
back2 := ProtoToEntry(proto2)
if len(back2.Never) != 1 || back2.Never[0] != "arch:arm64" {
t.Errorf("Never round-trip: got %v, want [arch:arm64]", back2.Never)
}
if len(back2.Only) != 0 {
t.Errorf("Only should be empty, got %v", back2.Only)
}
}
func TestEntryEmptyOptionalFieldsRoundTrip(t *testing.T) {
now := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
e := manifest.Entry{
Path: "~/.profile",
Type: "file",
Updated: now,
// Hash, Mode, Target intentionally empty
}
proto := EntryToProto(e)
back := ProtoToEntry(proto)
if back.Path != e.Path {
t.Errorf("Path: got %q, want %q", back.Path, e.Path)
}
if back.Hash != "" {
t.Errorf("Hash: got %q, want empty", back.Hash)
}
if back.Type != e.Type {
t.Errorf("Type: got %q, want %q", back.Type, e.Type)
}
if back.Mode != "" {
t.Errorf("Mode: got %q, want empty", back.Mode)
}
if back.Target != "" {
t.Errorf("Target: got %q, want empty", back.Target)
}
if !back.Updated.Equal(e.Updated) {
t.Errorf("Updated: got %v, want %v", back.Updated, e.Updated)
}
}

239
server/server.go Normal file
View File

@@ -0,0 +1,239 @@
// Package server implements the GardenSync gRPC service.
package server
import (
"context"
"errors"
"io"
"sync"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
const chunkSize = 64 * 1024 // 64 KiB
// Server implements the sgardpb.GardenSyncServer interface.
type Server struct {
sgardpb.UnimplementedGardenSyncServer
garden *garden.Garden
mu sync.RWMutex
pendingManifest *manifest.Manifest
auth *AuthInterceptor // nil if auth is disabled
}
// New creates a new Server backed by the given Garden.
func New(g *garden.Garden) *Server {
return &Server{garden: g}
}
// NewWithAuth creates a new Server with authentication enabled.
func NewWithAuth(g *garden.Garden, auth *AuthInterceptor) *Server {
return &Server{garden: g, auth: auth}
}
// Authenticate handles the auth RPC by delegating to the AuthInterceptor.
func (s *Server) Authenticate(ctx context.Context, req *sgardpb.AuthenticateRequest) (*sgardpb.AuthenticateResponse, error) {
if s.auth == nil {
return nil, status.Error(codes.Unimplemented, "authentication not configured")
}
return s.auth.Authenticate(ctx, req)
}
// PushManifest compares the client manifest against the server manifest and
// decides whether to accept, reject, or report up-to-date.
func (s *Server) PushManifest(_ context.Context, req *sgardpb.PushManifestRequest) (*sgardpb.PushManifestResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
serverManifest := s.garden.GetManifest()
clientManifest := ProtoToManifest(req.GetManifest())
resp := &sgardpb.PushManifestResponse{
ServerUpdated: timestamppb.New(serverManifest.Updated),
}
switch {
case clientManifest.Updated.After(serverManifest.Updated):
resp.Decision = sgardpb.PushManifestResponse_ACCEPTED
var missing []string
for _, e := range clientManifest.Files {
if e.Type == "file" && e.Hash != "" && !s.garden.BlobExists(e.Hash) {
missing = append(missing, e.Hash)
}
}
resp.MissingBlobs = missing
s.pendingManifest = clientManifest
case serverManifest.Updated.After(clientManifest.Updated):
resp.Decision = sgardpb.PushManifestResponse_REJECTED
default:
resp.Decision = sgardpb.PushManifestResponse_UP_TO_DATE
}
return resp, nil
}
// PushBlobs receives a stream of blob chunks, reassembles them, writes each
// blob to the store, and then applies the pending manifest.
func (s *Server) PushBlobs(stream grpc.ClientStreamingServer[sgardpb.PushBlobsRequest, sgardpb.PushBlobsResponse]) error {
s.mu.Lock()
defer s.mu.Unlock()
var (
currentHash string
buf []byte
blobCount int32
)
for {
req, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return status.Errorf(codes.Internal, "receiving blob chunk: %v", err)
}
chunk := req.GetChunk()
if chunk == nil {
continue
}
if chunk.GetHash() != "" {
// New blob starting. Write out the previous one if any.
if currentHash != "" {
if err := s.writeAndVerify(currentHash, buf); err != nil {
return err
}
blobCount++
}
currentHash = chunk.GetHash()
buf = append([]byte(nil), chunk.GetData()...)
} else {
buf = append(buf, chunk.GetData()...)
}
}
// Write the last accumulated blob.
if currentHash != "" {
if err := s.writeAndVerify(currentHash, buf); err != nil {
return err
}
blobCount++
}
// Apply pending manifest.
if s.pendingManifest != nil {
if err := s.garden.ReplaceManifest(s.pendingManifest); err != nil {
return status.Errorf(codes.Internal, "replacing manifest: %v", err)
}
s.pendingManifest = nil
}
return stream.SendAndClose(&sgardpb.PushBlobsResponse{
BlobsReceived: blobCount,
})
}
// writeAndVerify writes data to the blob store and verifies the hash matches.
func (s *Server) writeAndVerify(expectedHash string, data []byte) error {
gotHash, err := s.garden.WriteBlob(data)
if err != nil {
return status.Errorf(codes.Internal, "writing blob: %v", err)
}
if gotHash != expectedHash {
return status.Errorf(codes.DataLoss, "blob hash mismatch: expected %s, got %s", expectedHash, gotHash)
}
return nil
}
// PullManifest returns the server's current manifest.
func (s *Server) PullManifest(_ context.Context, _ *sgardpb.PullManifestRequest) (*sgardpb.PullManifestResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return &sgardpb.PullManifestResponse{
Manifest: ManifestToProto(s.garden.GetManifest()),
}, nil
}
// PullBlobs streams the requested blobs back to the client in 64 KiB chunks.
func (s *Server) PullBlobs(req *sgardpb.PullBlobsRequest, stream grpc.ServerStreamingServer[sgardpb.PullBlobsResponse]) error {
s.mu.RLock()
defer s.mu.RUnlock()
for _, hash := range req.GetHashes() {
data, err := s.garden.ReadBlob(hash)
if err != nil {
return status.Errorf(codes.NotFound, "reading blob %s: %v", hash, err)
}
for i := 0; i < len(data); i += chunkSize {
end := i + chunkSize
if end > len(data) {
end = len(data)
}
chunk := &sgardpb.BlobChunk{
Data: data[i:end],
}
if i == 0 {
chunk.Hash = hash
}
if err := stream.Send(&sgardpb.PullBlobsResponse{Chunk: chunk}); err != nil {
return status.Errorf(codes.Internal, "sending blob chunk: %v", err)
}
}
// Handle empty blobs: send a single chunk with the hash.
if len(data) == 0 {
if err := stream.Send(&sgardpb.PullBlobsResponse{
Chunk: &sgardpb.BlobChunk{Hash: hash},
}); err != nil {
return status.Errorf(codes.Internal, "sending empty blob chunk: %v", err)
}
}
}
return nil
}
// Prune removes orphaned blobs that are not referenced by the current manifest.
func (s *Server) Prune(_ context.Context, _ *sgardpb.PruneRequest) (*sgardpb.PruneResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Collect all referenced hashes from the manifest.
referenced := make(map[string]bool)
for _, e := range s.garden.GetManifest().Files {
if e.Type == "file" && e.Hash != "" {
referenced[e.Hash] = true
}
}
// List all blobs in the store.
allBlobs, err := s.garden.ListBlobs()
if err != nil {
return nil, status.Errorf(codes.Internal, "listing blobs: %v", err)
}
// Delete orphans.
var removed int32
for _, hash := range allBlobs {
if !referenced[hash] {
if err := s.garden.DeleteBlob(hash); err != nil {
return nil, status.Errorf(codes.Internal, "deleting blob %s: %v", hash, err)
}
removed++
}
}
return &sgardpb.PruneResponse{BlobsRemoved: removed}, nil
}

336
server/server_test.go Normal file
View File

@@ -0,0 +1,336 @@
package server
import (
"context"
"errors"
"io"
"net"
"testing"
"time"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
const bufSize = 1024 * 1024
// setupTest creates a client-server pair using in-process bufconn.
// It returns a gRPC client, the server Garden, and a client Garden.
func setupTest(t *testing.T) (sgardpb.GardenSyncClient, *garden.Garden, *garden.Garden) {
t.Helper()
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
clientDir := t.TempDir()
clientGarden, err := garden.Init(clientDir)
if err != nil {
t.Fatalf("init client garden: %v", err)
}
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer()
sgardpb.RegisterGardenSyncServer(srv, New(serverGarden))
t.Cleanup(func() { srv.Stop() })
go func() {
_ = srv.Serve(lis)
}()
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial bufconn: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
client := sgardpb.NewGardenSyncClient(conn)
return client, serverGarden, clientGarden
}
func TestPushManifest_Accepted(t *testing.T) {
client, serverGarden, _ := setupTest(t)
ctx := context.Background()
// Server has an old manifest (default init time).
// Client has a newer manifest with a file entry.
now := time.Now().UTC()
clientManifest := &manifest.Manifest{
Version: 1,
Created: now,
Updated: now.Add(time.Hour),
Files: []manifest.Entry{
{
Path: "~/.bashrc",
Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Type: "file",
Mode: "0644",
Updated: now,
},
},
}
resp, err := client.PushManifest(ctx, &sgardpb.PushManifestRequest{
Manifest: ManifestToProto(clientManifest),
})
if err != nil {
t.Fatalf("PushManifest: %v", err)
}
if resp.Decision != sgardpb.PushManifestResponse_ACCEPTED {
t.Errorf("decision: got %v, want ACCEPTED", resp.Decision)
}
// The blob doesn't exist on server, so it should be in missing_blobs.
if len(resp.MissingBlobs) != 1 {
t.Fatalf("missing_blobs count: got %d, want 1", len(resp.MissingBlobs))
}
if resp.MissingBlobs[0] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
t.Errorf("missing_blobs[0]: got %s, want aaaa...", resp.MissingBlobs[0])
}
// Write the blob to server and try again: it should not be missing.
_, err = serverGarden.WriteBlob([]byte("test data"))
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
}
func TestPushManifest_Rejected(t *testing.T) {
client, serverGarden, _ := setupTest(t)
ctx := context.Background()
// Make the server manifest newer.
serverManifest := serverGarden.GetManifest()
serverManifest.Updated = time.Now().UTC().Add(2 * time.Hour)
if err := serverGarden.ReplaceManifest(serverManifest); err != nil {
t.Fatalf("ReplaceManifest: %v", err)
}
// Client manifest is at default init time (older).
clientManifest := &manifest.Manifest{
Version: 1,
Created: time.Now().UTC(),
Updated: time.Now().UTC(),
Files: []manifest.Entry{},
}
resp, err := client.PushManifest(ctx, &sgardpb.PushManifestRequest{
Manifest: ManifestToProto(clientManifest),
})
if err != nil {
t.Fatalf("PushManifest: %v", err)
}
if resp.Decision != sgardpb.PushManifestResponse_REJECTED {
t.Errorf("decision: got %v, want REJECTED", resp.Decision)
}
}
func TestPushManifest_UpToDate(t *testing.T) {
client, serverGarden, _ := setupTest(t)
ctx := context.Background()
// Set both to the same timestamp.
ts := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
serverManifest := serverGarden.GetManifest()
serverManifest.Updated = ts
if err := serverGarden.ReplaceManifest(serverManifest); err != nil {
t.Fatalf("ReplaceManifest: %v", err)
}
clientManifest := &manifest.Manifest{
Version: 1,
Created: ts,
Updated: ts,
Files: []manifest.Entry{},
}
resp, err := client.PushManifest(ctx, &sgardpb.PushManifestRequest{
Manifest: ManifestToProto(clientManifest),
})
if err != nil {
t.Fatalf("PushManifest: %v", err)
}
if resp.Decision != sgardpb.PushManifestResponse_UP_TO_DATE {
t.Errorf("decision: got %v, want UP_TO_DATE", resp.Decision)
}
}
func TestPushAndPullBlobs(t *testing.T) {
client, serverGarden, _ := setupTest(t)
ctx := context.Background()
// Write some test data as blobs directly to simulate a client garden.
blob1Data := []byte("hello world from bashrc")
blob2Data := []byte("vimrc content here")
// We need the actual hashes for our manifest entries.
// Write to a throwaway garden to get hashes.
tmpDir := t.TempDir()
tmpGarden, err := garden.Init(tmpDir)
if err != nil {
t.Fatalf("init tmp garden: %v", err)
}
hash1, err := tmpGarden.WriteBlob(blob1Data)
if err != nil {
t.Fatalf("WriteBlob 1: %v", err)
}
hash2, err := tmpGarden.WriteBlob(blob2Data)
if err != nil {
t.Fatalf("WriteBlob 2: %v", err)
}
now := time.Now().UTC().Add(time.Hour)
clientManifest := &manifest.Manifest{
Version: 1,
Created: now,
Updated: now,
Files: []manifest.Entry{
{Path: "~/.bashrc", Hash: hash1, Type: "file", Mode: "0644", Updated: now},
{Path: "~/.vimrc", Hash: hash2, Type: "file", Mode: "0644", Updated: now},
{Path: "~/.config", Type: "directory", Mode: "0755", Updated: now},
},
}
// Step 1: PushManifest.
pushResp, err := client.PushManifest(ctx, &sgardpb.PushManifestRequest{
Manifest: ManifestToProto(clientManifest),
})
if err != nil {
t.Fatalf("PushManifest: %v", err)
}
if pushResp.Decision != sgardpb.PushManifestResponse_ACCEPTED {
t.Fatalf("decision: got %v, want ACCEPTED", pushResp.Decision)
}
if len(pushResp.MissingBlobs) != 2 {
t.Fatalf("missing_blobs: got %d, want 2", len(pushResp.MissingBlobs))
}
// Step 2: PushBlobs.
stream, err := client.PushBlobs(ctx)
if err != nil {
t.Fatalf("PushBlobs: %v", err)
}
// Send blob1.
if err := stream.Send(&sgardpb.PushBlobsRequest{
Chunk: &sgardpb.BlobChunk{Hash: hash1, Data: blob1Data},
}); err != nil {
t.Fatalf("Send blob1: %v", err)
}
// Send blob2.
if err := stream.Send(&sgardpb.PushBlobsRequest{
Chunk: &sgardpb.BlobChunk{Hash: hash2, Data: blob2Data},
}); err != nil {
t.Fatalf("Send blob2: %v", err)
}
blobResp, err := stream.CloseAndRecv()
if err != nil {
t.Fatalf("CloseAndRecv: %v", err)
}
if blobResp.BlobsReceived != 2 {
t.Errorf("blobs_received: got %d, want 2", blobResp.BlobsReceived)
}
// Verify blobs exist on server.
if !serverGarden.BlobExists(hash1) {
t.Error("blob1 not found on server")
}
if !serverGarden.BlobExists(hash2) {
t.Error("blob2 not found on server")
}
// Verify manifest was applied on server.
sm := serverGarden.GetManifest()
if len(sm.Files) != 3 {
t.Fatalf("server manifest files: got %d, want 3", len(sm.Files))
}
// Step 3: PullManifest from the server.
pullMResp, err := client.PullManifest(ctx, &sgardpb.PullManifestRequest{})
if err != nil {
t.Fatalf("PullManifest: %v", err)
}
pulledManifest := ProtoToManifest(pullMResp.GetManifest())
if len(pulledManifest.Files) != 3 {
t.Fatalf("pulled manifest files: got %d, want 3", len(pulledManifest.Files))
}
// Step 4: PullBlobs from the server.
pullBResp, err := client.PullBlobs(ctx, &sgardpb.PullBlobsRequest{
Hashes: []string{hash1, hash2},
})
if err != nil {
t.Fatalf("PullBlobs: %v", err)
}
// Reassemble blobs from the stream.
pulledBlobs := make(map[string][]byte)
var currentHash string
for {
resp, err := pullBResp.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("PullBlobs Recv: %v", err)
}
chunk := resp.GetChunk()
if chunk.GetHash() != "" {
currentHash = chunk.GetHash()
}
pulledBlobs[currentHash] = append(pulledBlobs[currentHash], chunk.GetData()...)
}
if string(pulledBlobs[hash1]) != string(blob1Data) {
t.Errorf("blob1 data mismatch: got %q, want %q", pulledBlobs[hash1], blob1Data)
}
if string(pulledBlobs[hash2]) != string(blob2Data) {
t.Errorf("blob2 data mismatch: got %q, want %q", pulledBlobs[hash2], blob2Data)
}
}
func TestPrune(t *testing.T) {
client, serverGarden, _ := setupTest(t)
ctx := context.Background()
// Write a blob to the server.
blobData := []byte("orphan blob data")
hash, err := serverGarden.WriteBlob(blobData)
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
// The manifest does NOT reference this blob, so it is orphaned.
if !serverGarden.BlobExists(hash) {
t.Fatal("blob should exist before prune")
}
resp, err := client.Prune(ctx, &sgardpb.PruneRequest{})
if err != nil {
t.Fatalf("Prune: %v", err)
}
if resp.BlobsRemoved != 1 {
t.Errorf("blobs_removed: got %d, want 1", resp.BlobsRemoved)
}
if serverGarden.BlobExists(hash) {
t.Error("orphan blob should be deleted after prune")
}
}

237
server/tls_test.go Normal file
View File

@@ -0,0 +1,237 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"testing"
"time"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// generateSelfSignedCert creates a self-signed TLS certificate for testing.
func generateSelfSignedCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "sgard-test"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
DNSNames: []string{"localhost"},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("creating certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshaling key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatalf("loading key pair: %v", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(certPEM)
return cert, pool
}
// setupTLSTest creates a TLS-secured client-server pair.
func setupTLSTest(t *testing.T) (sgardpb.GardenSyncClient, *garden.Garden, *garden.Garden) {
t.Helper()
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
clientDir := t.TempDir()
clientGarden, err := garden.Init(clientDir)
if err != nil {
t.Fatalf("init client garden: %v", err)
}
cert, caPool := generateSelfSignedCert(t)
// Server with TLS.
serverCreds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
srv := grpc.NewServer(grpc.Creds(serverCreds))
sgardpb.RegisterGardenSyncServer(srv, New(serverGarden))
t.Cleanup(func() { srv.Stop() })
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
_ = srv.Serve(lis)
}()
// Client with TLS, trusting the self-signed CA.
clientCreds := credentials.NewTLS(&tls.Config{
RootCAs: caPool,
MinVersion: tls.VersionTLS12,
})
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(clientCreds),
)
if err != nil {
t.Fatalf("dial TLS: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
client := sgardpb.NewGardenSyncClient(conn)
return client, serverGarden, clientGarden
}
func TestTLS_PushPullCycle(t *testing.T) {
client, serverGarden, _ := setupTLSTest(t)
ctx := context.Background()
// Write test blobs to get real hashes.
tmpDir := t.TempDir()
tmpGarden, err := garden.Init(tmpDir)
if err != nil {
t.Fatalf("init tmp garden: %v", err)
}
blobData := []byte("TLS test blob content")
hash, err := tmpGarden.WriteBlob(blobData)
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
now := time.Now().UTC().Add(time.Hour)
clientManifest := &manifest.Manifest{
Version: 1,
Created: now,
Updated: now,
Files: []manifest.Entry{
{Path: "~/.tlstest", Hash: hash, Type: "file", Mode: "0644", Updated: now},
},
}
// Push manifest over TLS.
pushResp, err := client.PushManifest(ctx, &sgardpb.PushManifestRequest{
Manifest: ManifestToProto(clientManifest),
})
if err != nil {
t.Fatalf("PushManifest over TLS: %v", err)
}
if pushResp.Decision != sgardpb.PushManifestResponse_ACCEPTED {
t.Fatalf("decision: got %v, want ACCEPTED", pushResp.Decision)
}
// Push blob over TLS.
stream, err := client.PushBlobs(ctx)
if err != nil {
t.Fatalf("PushBlobs over TLS: %v", err)
}
if err := stream.Send(&sgardpb.PushBlobsRequest{
Chunk: &sgardpb.BlobChunk{Hash: hash, Data: blobData},
}); err != nil {
t.Fatalf("Send blob: %v", err)
}
blobResp, err := stream.CloseAndRecv()
if err != nil {
t.Fatalf("CloseAndRecv: %v", err)
}
if blobResp.BlobsReceived != 1 {
t.Errorf("blobs_received: got %d, want 1", blobResp.BlobsReceived)
}
// Verify blob arrived on server.
if !serverGarden.BlobExists(hash) {
t.Error("blob not found on server after TLS push")
}
// Pull manifest back over TLS.
pullResp, err := client.PullManifest(ctx, &sgardpb.PullManifestRequest{})
if err != nil {
t.Fatalf("PullManifest over TLS: %v", err)
}
pulledManifest := ProtoToManifest(pullResp.GetManifest())
if len(pulledManifest.Files) != 1 {
t.Fatalf("pulled manifest files: got %d, want 1", len(pulledManifest.Files))
}
if pulledManifest.Files[0].Path != "~/.tlstest" {
t.Errorf("pulled path: got %q, want %q", pulledManifest.Files[0].Path, "~/.tlstest")
}
}
func TestTLS_RejectsPlaintextClient(t *testing.T) {
cert, _ := generateSelfSignedCert(t)
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
serverCreds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
srv := grpc.NewServer(grpc.Creds(serverCreds))
sgardpb.RegisterGardenSyncServer(srv, New(serverGarden))
t.Cleanup(func() { srv.Stop() })
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
_ = srv.Serve(lis)
}()
// Try to connect without TLS — should fail.
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
// No RootCAs — won't trust the self-signed cert.
MinVersion: tls.VersionTLS12,
})),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
client := sgardpb.NewGardenSyncClient(conn)
_, err = client.PullManifest(context.Background(), &sgardpb.PullManifestRequest{})
if err == nil {
t.Fatal("expected error when connecting without trusted CA to TLS server")
}
}

1289
sgardpb/sgard.pb.go Normal file

File diff suppressed because it is too large Load Diff

320
sgardpb/sgard_grpc.pb.go Normal file
View File

@@ -0,0 +1,320 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: sgard/v1/sgard.proto
package sgardpb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GardenSync_Authenticate_FullMethodName = "/sgard.v1.GardenSync/Authenticate"
GardenSync_PushManifest_FullMethodName = "/sgard.v1.GardenSync/PushManifest"
GardenSync_PushBlobs_FullMethodName = "/sgard.v1.GardenSync/PushBlobs"
GardenSync_PullManifest_FullMethodName = "/sgard.v1.GardenSync/PullManifest"
GardenSync_PullBlobs_FullMethodName = "/sgard.v1.GardenSync/PullBlobs"
GardenSync_Prune_FullMethodName = "/sgard.v1.GardenSync/Prune"
)
// GardenSyncClient is the client API for GardenSync service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// GardenSync is the sgard remote sync service.
type GardenSyncClient interface {
// Authenticate exchanges an SSH-signed challenge for a JWT token.
Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateResponse, error)
// Push flow: send manifest, then stream missing blobs.
PushManifest(ctx context.Context, in *PushManifestRequest, opts ...grpc.CallOption) (*PushManifestResponse, error)
PushBlobs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushBlobsRequest, PushBlobsResponse], error)
// Pull flow: get manifest, then stream requested blobs.
PullManifest(ctx context.Context, in *PullManifestRequest, opts ...grpc.CallOption) (*PullManifestResponse, error)
PullBlobs(ctx context.Context, in *PullBlobsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PullBlobsResponse], error)
// Prune removes orphaned blobs on the server.
Prune(ctx context.Context, in *PruneRequest, opts ...grpc.CallOption) (*PruneResponse, error)
}
type gardenSyncClient struct {
cc grpc.ClientConnInterface
}
func NewGardenSyncClient(cc grpc.ClientConnInterface) GardenSyncClient {
return &gardenSyncClient{cc}
}
func (c *gardenSyncClient) Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AuthenticateResponse)
err := c.cc.Invoke(ctx, GardenSync_Authenticate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gardenSyncClient) PushManifest(ctx context.Context, in *PushManifestRequest, opts ...grpc.CallOption) (*PushManifestResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PushManifestResponse)
err := c.cc.Invoke(ctx, GardenSync_PushManifest_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gardenSyncClient) PushBlobs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushBlobsRequest, PushBlobsResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &GardenSync_ServiceDesc.Streams[0], GardenSync_PushBlobs_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[PushBlobsRequest, PushBlobsResponse]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GardenSync_PushBlobsClient = grpc.ClientStreamingClient[PushBlobsRequest, PushBlobsResponse]
func (c *gardenSyncClient) PullManifest(ctx context.Context, in *PullManifestRequest, opts ...grpc.CallOption) (*PullManifestResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PullManifestResponse)
err := c.cc.Invoke(ctx, GardenSync_PullManifest_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gardenSyncClient) PullBlobs(ctx context.Context, in *PullBlobsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PullBlobsResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &GardenSync_ServiceDesc.Streams[1], GardenSync_PullBlobs_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[PullBlobsRequest, PullBlobsResponse]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GardenSync_PullBlobsClient = grpc.ServerStreamingClient[PullBlobsResponse]
func (c *gardenSyncClient) Prune(ctx context.Context, in *PruneRequest, opts ...grpc.CallOption) (*PruneResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PruneResponse)
err := c.cc.Invoke(ctx, GardenSync_Prune_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GardenSyncServer is the server API for GardenSync service.
// All implementations must embed UnimplementedGardenSyncServer
// for forward compatibility.
//
// GardenSync is the sgard remote sync service.
type GardenSyncServer interface {
// Authenticate exchanges an SSH-signed challenge for a JWT token.
Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error)
// Push flow: send manifest, then stream missing blobs.
PushManifest(context.Context, *PushManifestRequest) (*PushManifestResponse, error)
PushBlobs(grpc.ClientStreamingServer[PushBlobsRequest, PushBlobsResponse]) error
// Pull flow: get manifest, then stream requested blobs.
PullManifest(context.Context, *PullManifestRequest) (*PullManifestResponse, error)
PullBlobs(*PullBlobsRequest, grpc.ServerStreamingServer[PullBlobsResponse]) error
// Prune removes orphaned blobs on the server.
Prune(context.Context, *PruneRequest) (*PruneResponse, error)
mustEmbedUnimplementedGardenSyncServer()
}
// UnimplementedGardenSyncServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGardenSyncServer struct{}
func (UnimplementedGardenSyncServer) Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Authenticate not implemented")
}
func (UnimplementedGardenSyncServer) PushManifest(context.Context, *PushManifestRequest) (*PushManifestResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PushManifest not implemented")
}
func (UnimplementedGardenSyncServer) PushBlobs(grpc.ClientStreamingServer[PushBlobsRequest, PushBlobsResponse]) error {
return status.Error(codes.Unimplemented, "method PushBlobs not implemented")
}
func (UnimplementedGardenSyncServer) PullManifest(context.Context, *PullManifestRequest) (*PullManifestResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PullManifest not implemented")
}
func (UnimplementedGardenSyncServer) PullBlobs(*PullBlobsRequest, grpc.ServerStreamingServer[PullBlobsResponse]) error {
return status.Error(codes.Unimplemented, "method PullBlobs not implemented")
}
func (UnimplementedGardenSyncServer) Prune(context.Context, *PruneRequest) (*PruneResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Prune not implemented")
}
func (UnimplementedGardenSyncServer) mustEmbedUnimplementedGardenSyncServer() {}
func (UnimplementedGardenSyncServer) testEmbeddedByValue() {}
// UnsafeGardenSyncServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GardenSyncServer will
// result in compilation errors.
type UnsafeGardenSyncServer interface {
mustEmbedUnimplementedGardenSyncServer()
}
func RegisterGardenSyncServer(s grpc.ServiceRegistrar, srv GardenSyncServer) {
// If the following call panics, it indicates UnimplementedGardenSyncServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GardenSync_ServiceDesc, srv)
}
func _GardenSync_Authenticate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AuthenticateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GardenSyncServer).Authenticate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GardenSync_Authenticate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GardenSyncServer).Authenticate(ctx, req.(*AuthenticateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GardenSync_PushManifest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PushManifestRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GardenSyncServer).PushManifest(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GardenSync_PushManifest_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GardenSyncServer).PushManifest(ctx, req.(*PushManifestRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GardenSync_PushBlobs_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(GardenSyncServer).PushBlobs(&grpc.GenericServerStream[PushBlobsRequest, PushBlobsResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GardenSync_PushBlobsServer = grpc.ClientStreamingServer[PushBlobsRequest, PushBlobsResponse]
func _GardenSync_PullManifest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PullManifestRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GardenSyncServer).PullManifest(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GardenSync_PullManifest_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GardenSyncServer).PullManifest(ctx, req.(*PullManifestRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GardenSync_PullBlobs_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(PullBlobsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(GardenSyncServer).PullBlobs(m, &grpc.GenericServerStream[PullBlobsRequest, PullBlobsResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GardenSync_PullBlobsServer = grpc.ServerStreamingServer[PullBlobsResponse]
func _GardenSync_Prune_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PruneRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GardenSyncServer).Prune(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GardenSync_Prune_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GardenSyncServer).Prune(ctx, req.(*PruneRequest))
}
return interceptor(ctx, in, info, handler)
}
// GardenSync_ServiceDesc is the grpc.ServiceDesc for GardenSync service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GardenSync_ServiceDesc = grpc.ServiceDesc{
ServiceName: "sgard.v1.GardenSync",
HandlerType: (*GardenSyncServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Authenticate",
Handler: _GardenSync_Authenticate_Handler,
},
{
MethodName: "PushManifest",
Handler: _GardenSync_PushManifest_Handler,
},
{
MethodName: "PullManifest",
Handler: _GardenSync_PullManifest_Handler,
},
{
MethodName: "Prune",
Handler: _GardenSync_Prune_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "PushBlobs",
Handler: _GardenSync_PushBlobs_Handler,
ClientStreams: true,
},
{
StreamName: "PullBlobs",
Handler: _GardenSync_PullBlobs_Handler,
ServerStreams: true,
},
},
Metadata: "sgard/v1/sgard.proto",
}

View File

@@ -131,6 +131,32 @@ func (s *Store) Delete(hash string) error {
return nil return nil
} }
// List returns all blob hashes in the store by walking the blobs directory.
func (s *Store) List() ([]string, error) {
blobsDir := filepath.Join(s.root, "blobs")
var hashes []string
err := filepath.WalkDir(blobsDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
name := d.Name()
if validHash(name) {
hashes = append(hashes, name)
}
return nil
})
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("store: listing blobs: %w", err)
}
return hashes, nil
}
// blobPath returns the filesystem path for a blob with the given hash. // blobPath returns the filesystem path for a blob with the given hash.
// Layout: blobs/<first 2 hex chars>/<next 2 hex chars>/<full 64-char hash> // Layout: blobs/<first 2 hex chars>/<next 2 hex chars>/<full 64-char hash>
func (s *Store) blobPath(hash string) string { func (s *Store) blobPath(hash string) string {