Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f6ef11371 | |||
| 7713d071c2 | |||
| de5759ac77 | |||
| b9b9082008 | |||
| bd54491c1d | |||
| 57d252cee4 | |||
| 78030230c5 | |||
| adfb087037 | |||
| 5570f82eb4 | |||
| bffe7bde12 | |||
| 3e0aabef4a | |||
| 4ec71eae00 | |||
| d2161fdadc | |||
| cefa9b7970 | |||
| e37e788885 | |||
| 2ff9fe2f50 | |||
| 60c0c50acb | |||
| d4d1d316db | |||
| 589f76c10e | |||
| 7797de7d48 | |||
| c8281398d1 | |||
| 3cac9a3530 | |||
| 490db0599c | |||
| 5529fff649 | |||
| 3fabd86150 | |||
| c00d9c65c3 | |||
| d2bba75365 | |||
| 0cf81ab6a1 | |||
| 1eb801fe63 | |||
| 11202940c9 | |||
| 0929d77e90 | |||
| 7accc6cac6 | |||
| 76a53320c1 | |||
| 5bb65795c8 | |||
| 3b961b5d8a | |||
| 582f2116d2 | |||
| 529d45f8eb | |||
| f6bdb93066 | |||
| e24b66776c | |||
| 079b235c9d | |||
| 4d9e156eea | |||
| c6b92a70b1 | |||
| edef642025 | |||
| b7b1b27064 | |||
| 66af104155 | |||
| 92d64d5540 | |||
| 5f1bc4e14c | |||
| 94963bb8d6 | |||
| 4b841cdd82 | |||
| 525c3f0b4f | |||
| 0078b6b0f4 | |||
| 19217ec216 | |||
| b4bfce1291 | |||
| 153cc9c203 | |||
| ebf55bb570 | |||
| 34330a35ef | |||
| 0113703908 | |||
| b1313c1048 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
/sgard
|
||||
.claude/
|
||||
result
|
||||
|
||||
@@ -8,6 +8,11 @@ linters:
|
||||
- unused
|
||||
- errorlint
|
||||
- staticcheck
|
||||
- copyloopvar
|
||||
- durationcheck
|
||||
- makezero
|
||||
- nilerr
|
||||
- bodyclose
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
|
||||
@@ -5,7 +5,9 @@ before:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./cmd/sgard
|
||||
- id: sgard
|
||||
main: ./cmd/sgard
|
||||
binary: sgard
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
@@ -17,10 +19,22 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
- id: sgardd
|
||||
main: ./cmd/sgardd
|
||||
binary: sgardd
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
archives:
|
||||
- formats: [binary]
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}_v{{ .Version }}
|
||||
{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}_v{{ .Version }}
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
||||
740
ARCHITECTURE.md
740
ARCHITECTURE.md
@@ -34,6 +34,7 @@ A sgard repository is a single directory with this structure:
|
||||
```
|
||||
<repo>/
|
||||
manifest.yaml # single manifest tracking all files
|
||||
.gitignore # excludes blobs/ (created by sgard init)
|
||||
blobs/
|
||||
a1/b2/a1b2c3d4... # content-addressable file storage
|
||||
```
|
||||
@@ -47,29 +48,48 @@ updated: "2026-03-23T14:30:00Z"
|
||||
message: "pre-upgrade checkpoint" # optional
|
||||
|
||||
files:
|
||||
- path: ~/.bashrc # original location (default restore target)
|
||||
- path: ~/.bashrc # plaintext file
|
||||
hash: a1b2c3d4e5f6... # SHA-256 of file contents
|
||||
type: file # file | directory | link
|
||||
mode: "0644" # permissions (quoted to avoid YAML coercion)
|
||||
updated: "2026-03-23T14:30:00Z" # last checkpoint time for this file
|
||||
|
||||
- path: ~/.config/nvim
|
||||
type: directory
|
||||
mode: "0755"
|
||||
type: file
|
||||
mode: "0644"
|
||||
updated: "2026-03-23T14:30:00Z"
|
||||
# directories have no hash or blob — they're structural entries
|
||||
|
||||
- path: ~/.vimrc
|
||||
type: link
|
||||
target: ~/.config/nvim/init.vim # symlink target
|
||||
target: ~/.config/nvim/init.vim
|
||||
updated: "2026-03-23T14:30:00Z"
|
||||
# links have no hash or blob — just the target path
|
||||
|
||||
- path: ~/.ssh/config
|
||||
hash: d4e5f6a1b2c3...
|
||||
- path: ~/.ssh/config # encrypted file
|
||||
hash: f8e9d0c1... # SHA-256 of encrypted blob
|
||||
plaintext_hash: e5f6a7... # SHA-256 of plaintext
|
||||
encrypted: true
|
||||
type: file
|
||||
mode: "0600"
|
||||
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
|
||||
@@ -92,19 +112,25 @@ Properties:
|
||||
|
||||
All commands operate on a repository directory (default: `~/.sgard`, override with `--repo`).
|
||||
|
||||
### Phase 1 — Local
|
||||
### Local
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `sgard init [--repo <path>]` | Create a new repository |
|
||||
| `sgard add <path>...` | Track files; copies them into the blob store and adds manifest entries |
|
||||
| `sgard remove <path>...` | Untrack files; removes manifest entries (blobs cleaned up on next checkpoint) |
|
||||
| `sgard checkpoint [-m <message>]` | Re-hash all tracked files, store any changed blobs, update manifest |
|
||||
| `sgard add <path>...` | Track files, directories (recursed), or symlinks |
|
||||
| `sgard remove <path>...` | Untrack files; run `prune` to clean orphaned blobs |
|
||||
| `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 status` | Compare current files against manifest: modified, missing, ok |
|
||||
| `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 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:**
|
||||
|
||||
@@ -122,77 +148,631 @@ sgard checkpoint -m "initial" --repo /mnt/usb/dotfiles
|
||||
sgard restore --repo /mnt/usb/dotfiles
|
||||
```
|
||||
|
||||
### Phase 2 — Remote (Future)
|
||||
### Remote
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `sgard push` | Push checkpoint to 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
|
||||
|
||||
```
|
||||
sgard/
|
||||
cmd/
|
||||
sgard/ # CLI entry point
|
||||
main.go # cobra root command, --repo flag
|
||||
init.go # sgard init
|
||||
add.go # sgard add
|
||||
remove.go # sgard remove
|
||||
checkpoint.go # sgard checkpoint
|
||||
restore.go # sgard restore
|
||||
status.go # sgard status
|
||||
verify.go # sgard verify
|
||||
list.go # sgard list
|
||||
diff.go # sgard diff
|
||||
sgardd/ # gRPC server entry point (Phase 2)
|
||||
cmd/sgard/ # CLI entry point — one file per command
|
||||
main.go # cobra root command, --repo/--remote/--ssh-key/--tls/--tls-ca flags
|
||||
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
|
||||
restore.go status.go verify.go list.go info.go diff.go version.go
|
||||
|
||||
garden/ # Core business logic
|
||||
garden.go # Garden struct: orchestrates manifest + store + filesystem
|
||||
garden_test.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.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors
|
||||
encrypt.go # EncryptInit, UnlockDEK, RotateDEK, encrypt/decrypt blobs, slot mgmt
|
||||
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
|
||||
diff.go # File diff generation
|
||||
|
||||
manifest/ # YAML manifest parsing
|
||||
manifest.go # Manifest and Entry structs, Load/Save
|
||||
manifest_test.go
|
||||
|
||||
store/ # Content-addressable blob storage
|
||||
store.go # Store struct: Write/Read/Exists/Delete
|
||||
store_test.go
|
||||
store.go # Store struct: Write/Read/Exists/Delete/List
|
||||
|
||||
proto/ # gRPC service definition (Phase 2)
|
||||
sgard/v1/
|
||||
sgard.proto
|
||||
server/ # gRPC server implementation
|
||||
server.go # GardenSync RPC handlers with RWMutex
|
||||
auth.go # JWT token + SSH key auth interceptor, Authenticate RPC
|
||||
convert.go # proto ↔ manifest type conversion (incl. encryption)
|
||||
|
||||
server/ # gRPC server implementation (Phase 2)
|
||||
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
|
||||
|
||||
**The `garden` package contains all logic. The `cmd` package is pure CLI wiring.**
|
||||
|
||||
The `Garden` struct is the central coordinator:
|
||||
**The `garden` package contains all logic. The `cmd` package is pure CLI
|
||||
wiring. The `server` package wraps `Garden` methods as gRPC endpoints.**
|
||||
|
||||
```go
|
||||
type Garden struct {
|
||||
manifest *manifest.Manifest
|
||||
store *store.Store
|
||||
root string // repository root directory
|
||||
manifest *manifest.Manifest
|
||||
store *store.Store
|
||||
root string
|
||||
manifestPath string
|
||||
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) Checkpoint(message string) error
|
||||
func (g *Garden) Restore(paths []string, force bool) error
|
||||
func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error
|
||||
func (g *Garden) Status() ([]FileStatus, error)
|
||||
func (g *Garden) Verify() ([]VerifyResult, error)
|
||||
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) 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
|
||||
as the CLI — no logic duplication.
|
||||
The gRPC server calls the same `Garden` methods as the CLI — no logic
|
||||
duplication.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -200,9 +780,21 @@ as the CLI — no logic duplication.
|
||||
`$HOME` at runtime. This makes the manifest portable across machines with
|
||||
different usernames.
|
||||
|
||||
**No history.** Phase 1 stores only the latest checkpoint. For versioning,
|
||||
place the manifest under git. The `blobs/` directory should be gitignored —
|
||||
blob durability (backup, replication) is deferred to a future phase.
|
||||
**Adding a directory recurses.** `Add` walks directories and adds each
|
||||
file/symlink individually. Directories are not tracked as entries — only
|
||||
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
|
||||
set at checkpoint time. On restore, if the manifest entry is newer than the
|
||||
@@ -210,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.
|
||||
`--force` always skips the prompt.
|
||||
|
||||
**Atomic writes.** Checkpoint writes `manifest.yaml.tmp` then renames to
|
||||
`manifest.yaml`. A crash cannot corrupt the manifest.
|
||||
**Atomic writes.** Manifest saves write to a temp file then rename.
|
||||
|
||||
**Old C++/proto source files** are retained in the git history for reference
|
||||
and will be removed as part of the Go rewrite.
|
||||
**Timestamp comparison truncates to seconds** for cross-platform filesystem
|
||||
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`.
|
||||
|
||||
33
CLAUDE.md
33
CLAUDE.md
@@ -21,7 +21,14 @@ Module: `github.com/kisom/sgard`. Author: K. Isom <kyle@imap.cc>.
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build ./cmd/sgard
|
||||
go build ./... # both sgard and sgardd
|
||||
go build -tags fido2 ./... # with real FIDO2 hardware support (requires libfido2)
|
||||
```
|
||||
|
||||
Nix:
|
||||
```bash
|
||||
nix build .#sgard # builds both binaries (no CGo)
|
||||
nix build .#sgard-fido2 # with FIDO2 hardware support (links libfido2)
|
||||
```
|
||||
|
||||
Run tests:
|
||||
@@ -34,24 +41,38 @@ Lint:
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
Regenerate proto (requires protoc toolchain):
|
||||
```bash
|
||||
make proto
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `gopkg.in/yaml.v3` — manifest serialization
|
||||
- `github.com/spf13/cobra` — CLI framework
|
||||
- `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
|
||||
|
||||
```
|
||||
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)
|
||||
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
|
||||
and calls `Garden` methods. This enables the future gRPC server to reuse
|
||||
the same logic with zero duplication.
|
||||
and calls `Garden` methods. The `server` wraps `Garden` as gRPC endpoints.
|
||||
No logic duplication.
|
||||
|
||||
Each garden operation (remove, verify, list, diff) lives in its own file
|
||||
(`garden/<op>.go`) to minimize merge conflicts during parallel development.
|
||||
Each garden operation lives in its own file (`garden/<op>.go`) to minimize
|
||||
merge conflicts during parallel development.
|
||||
|
||||
25
Makefile
Normal file
25
Makefile
Normal 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
|
||||
67
PROGRESS.md
67
PROGRESS.md
@@ -7,9 +7,9 @@ ARCHITECTURE.md for design details.
|
||||
|
||||
## Current Status
|
||||
|
||||
**Phase:** Phase 1 complete (Steps 1–8). 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
|
||||
|
||||
@@ -42,16 +42,26 @@ ARCHITECTURE.md for design details.
|
||||
|
||||
## 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
|
||||
|
||||
- **Blob durability**: blobs are not stored in git. A strategy for backup or
|
||||
replication is deferred to a future phase.
|
||||
- **gRPC remote mode**: Phase 2. Package structure is designed to accommodate
|
||||
it (garden core separates logic from CLI wiring).
|
||||
- **Clock abstraction**: Done — `jonboulle/clockwork` injected. E2e test
|
||||
uses fake clock for deterministic timestamps.
|
||||
- **Manifest signing**: deferred — trust model (which key signs, how do
|
||||
pulling clients verify) needs design.
|
||||
- **DEK rotation**: `sgard encrypt rotate-dek` (re-encrypt all blobs)
|
||||
deferred to future work.
|
||||
- **FIDO2 testing**: hardware-dependent, may need mocks or CI skip.
|
||||
|
||||
## Change Log
|
||||
|
||||
@@ -66,3 +76,42 @@ Phase 1 is complete. Future work: blob durability, gRPC remote mode.
|
||||
| 2026-03-23 | 6 | Restore complete. Selective paths, force/confirm, timestamp logic, symlinks, permissions. 6 tests. |
|
||||
| 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 | — | README, goreleaser config, version command, Nix flake, homebrew formula, release pipeline validated (v0.1.0–v0.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 21–27): 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 28–32): 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. |
|
||||
|
||||
243
PROJECT_PLAN.md
243
PROJECT_PLAN.md
@@ -92,9 +92,242 @@ Depends on Step 5.
|
||||
- [x] Ensure `go vet ./...` and `go test ./...` pass clean
|
||||
- [x] Update CLAUDE.md, ARCHITECTURE.md, PROGRESS.md
|
||||
|
||||
## Future Steps (Not Phase 1)
|
||||
## Phase 2: gRPC Remote Sync
|
||||
|
||||
- Blob durability (backup/replication strategy)
|
||||
- gRPC remote mode (push/pull/serve)
|
||||
- Proto definitions for wire format
|
||||
- Shell completion via cobra
|
||||
### Step 9: Proto Definitions + Code Gen
|
||||
|
||||
- [x] Write `proto/sgard/v1/sgard.proto` — 5 RPCs (PushManifest, PushBlobs, PullManifest, PullBlobs, Prune), all messages
|
||||
- [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)
|
||||
|
||||
215
README.md
215
README.md
@@ -19,18 +19,43 @@ From source:
|
||||
|
||||
```
|
||||
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`:
|
||||
|
||||
```
|
||||
go install github.com/kisom/sgard/cmd/sgard@latest
|
||||
go install github.com/kisom/sgard/cmd/sgardd@latest
|
||||
```
|
||||
|
||||
NixOS (flake):
|
||||
|
||||
```
|
||||
nix profile install github:kisom/sgard
|
||||
```
|
||||
|
||||
Or add to your flake inputs and include `sgard.packages.${system}.default`
|
||||
in your packages.
|
||||
|
||||
Binaries are also available on the
|
||||
[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
|
||||
|
||||
```sh
|
||||
@@ -58,12 +83,88 @@ sgard add ~/.bashrc --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
|
||||
|
||||
### Local
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
||||
| `restore [path...] [-f]` | Restore files to their original locations |
|
||||
@@ -71,8 +172,115 @@ sgard restore --repo /mnt/usb/dotfiles
|
||||
| `diff <path>` | Show content diff between stored and current file |
|
||||
| `list` | List all tracked files |
|
||||
| `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 |
|
||||
|
||||
### 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
|
||||
|
||||
sgard stores files in a content-addressable blob store keyed by SHA-256.
|
||||
@@ -91,6 +299,7 @@ mtime. If the manifest is newer, the file is restored without prompting.
|
||||
Otherwise, sgard asks for confirmation (`--force` skips the prompt).
|
||||
|
||||
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.
|
||||
248
client/auth.go
Normal file
248
client/auth.go
Normal 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
57
client/auth_test.go
Normal 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
320
client/client.go
Normal 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
314
client/client_test.go
Normal 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
160
client/e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,19 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kisom/sgard/garden"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
encryptFlag bool
|
||||
lockFlag bool
|
||||
dirOnlyFlag bool
|
||||
onlyFlag []string
|
||||
neverFlag []string
|
||||
)
|
||||
|
||||
var addCmd = &cobra.Command{
|
||||
@@ -17,7 +27,28 @@ var addCmd = &cobra.Command{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ var checkpointCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
if g.HasEncryption() && g.NeedsDEK(g.List()) {
|
||||
if err := unlockDEK(g); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := g.Checkpoint(checkpointMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ var diffCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
if g.HasEncryption() && g.NeedsDEK(g.List()) {
|
||||
if err := unlockDEK(g); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
d, err := g.Diff(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
214
cmd/sgard/encrypt.go
Normal file
214
cmd/sgard/encrypt.go
Normal 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
65
cmd/sgard/exclude.go
Normal 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
12
cmd/sgard/fido2.go
Normal 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
27
cmd/sgard/identity.go
Normal 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
79
cmd/sgard/info.go
Normal 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)
|
||||
}
|
||||
@@ -1,41 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kisom/sgard/garden"
|
||||
"github.com/kisom/sgard/manifest"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
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 {
|
||||
g, err := garden.Open(repoFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
addr, _, _, _ := resolveRemoteConfig()
|
||||
if addr != "" {
|
||||
return listRemote()
|
||||
}
|
||||
|
||||
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
|
||||
return listLocal()
|
||||
},
|
||||
}
|
||||
|
||||
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() {
|
||||
rootCmd.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
51
cmd/sgard/lock.go
Normal file
51
cmd/sgard/lock.go
Normal 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)
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kisom/sgard/client"
|
||||
"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{
|
||||
Use: "sgard",
|
||||
@@ -23,8 +37,107 @@ func defaultRepo() string {
|
||||
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() {
|
||||
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 {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
|
||||
72
cmd/sgard/mirror.go
Normal file
72
cmd/sgard/mirror.go
Normal 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
60
cmd/sgard/prune.go
Normal 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
61
cmd/sgard/pull.go
Normal 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
46
cmd/sgard/push.go
Normal 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
97
cmd/sgard/remote.go
Normal 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)
|
||||
}
|
||||
@@ -21,6 +21,12 @@ var restoreCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
if g.HasEncryption() && g.NeedsDEK(g.List()) {
|
||||
if err := unlockDEK(g); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
confirm := func(path string) bool {
|
||||
fmt.Printf("Overwrite %s? [y/N] ", path)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
76
cmd/sgard/tag.go
Normal file
76
cmd/sgard/tag.go
Normal 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
48
cmd/sgard/target.go
Normal 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
92
cmd/sgardd/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
deploy/docker/Dockerfile
Normal file
23
deploy/docker/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
|
||||
COPY --from=builder /sgardd /usr/local/bin/sgardd
|
||||
|
||||
WORKDIR /srv/sgard
|
||||
EXPOSE 9473
|
||||
|
||||
ENTRYPOINT ["sgardd"]
|
||||
16
deploy/docker/docker-compose-rift.yml
Normal file
16
deploy/docker/docker-compose-rift.yml
Normal 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
|
||||
38
flake.nix
38
flake.nix
@@ -11,17 +11,20 @@
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
let
|
||||
version = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./VERSION);
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
sgard = pkgs.buildGoModule {
|
||||
sgard = pkgs.buildGoModule rec {
|
||||
pname = "sgard";
|
||||
version = "0.1.0";
|
||||
inherit version;
|
||||
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 = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -36,6 +59,11 @@
|
||||
buildInputs = with pkgs; [
|
||||
go
|
||||
golangci-lint
|
||||
protobuf
|
||||
protoc-gen-go
|
||||
protoc-gen-go-grpc
|
||||
libfido2
|
||||
pkg-config
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,16 @@ func (g *Garden) Diff(path string) (string, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading current file: %w", err)
|
||||
|
||||
449
garden/encrypt.go
Normal file
449
garden/encrypt.go
Normal 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
221
garden/encrypt_e2e_test.go
Normal 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
161
garden/encrypt_fido2.go
Normal 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
|
||||
}
|
||||
263
garden/encrypt_fido2_test.go
Normal file
263
garden/encrypt_fido2_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
239
garden/encrypt_rotate_test.go
Normal file
239
garden/encrypt_rotate_test.go
Normal 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
379
garden/encrypt_test.go
Normal 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
80
garden/exclude.go
Normal 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
331
garden/exclude_test.go
Normal 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
156
garden/fido2_hardware.go
Normal 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
|
||||
}
|
||||
10
garden/fido2_nohardware.go
Normal file
10
garden/fido2_nohardware.go
Normal 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
|
||||
}
|
||||
317
garden/garden.go
317
garden/garden.go
@@ -22,6 +22,7 @@ type Garden struct {
|
||||
root string // repository root directory
|
||||
manifestPath string // path to manifest.yaml
|
||||
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
|
||||
@@ -47,7 +48,7 @@ func Init(root string) (*Garden, error) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -98,10 +99,128 @@ func (g *Garden) SetClock(c clockwork.Clock) {
|
||||
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
|
||||
// to an absolute path, inspected for its type, and added to the manifest.
|
||||
// Regular files are hashed and stored in the blob store.
|
||||
func (g *Garden) Add(paths []string) error {
|
||||
// Regular files are hashed and stored in the blob store. Directories are
|
||||
// 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()
|
||||
|
||||
for _, p := range paths {
|
||||
@@ -115,45 +234,53 @@ func (g *Garden) Add(paths []string) error {
|
||||
return fmt.Errorf("stat %s: %w", abs, err)
|
||||
}
|
||||
|
||||
tilded := toTildePath(abs)
|
||||
|
||||
// Check if already tracked.
|
||||
if g.findEntry(tilded) != nil {
|
||||
return fmt.Errorf("already tracking %s", tilded)
|
||||
}
|
||||
|
||||
entry := manifest.Entry{
|
||||
Path: tilded,
|
||||
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||
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)
|
||||
if info.IsDir() {
|
||||
if o.DirOnly {
|
||||
// Track the directory itself as a structural entry.
|
||||
tilded := toTildePath(abs)
|
||||
if g.findEntry(tilded) != nil {
|
||||
continue
|
||||
}
|
||||
entry := manifest.Entry{
|
||||
Path: tilded,
|
||||
Type: "directory",
|
||||
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||
Locked: o.Lock,
|
||||
Only: o.Only,
|
||||
Never: o.Never,
|
||||
Updated: now,
|
||||
}
|
||||
g.manifest.Files = append(g.manifest.Files, entry)
|
||||
} 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"
|
||||
entry.Target = target
|
||||
|
||||
case info.IsDir():
|
||||
entry.Type = "directory"
|
||||
|
||||
default:
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading file %s: %w", abs, err)
|
||||
} else {
|
||||
if err := g.addEntry(abs, info, now, true, o); err != nil {
|
||||
return 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
|
||||
@@ -175,10 +302,19 @@ type FileStatus struct {
|
||||
// the manifest.
|
||||
func (g *Garden) Checkpoint(message string) error {
|
||||
now := g.clock.Now().UTC()
|
||||
labels := g.Identity()
|
||||
|
||||
for i := range g.manifest.Files {
|
||||
entry := &g.manifest.Files[i]
|
||||
|
||||
applies, err := EntryApplies(entry, labels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !applies {
|
||||
continue
|
||||
}
|
||||
|
||||
abs, err := ExpandTildePath(entry.Path)
|
||||
if err != nil {
|
||||
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())
|
||||
|
||||
// Locked entries are repo-authoritative — checkpoint skips them.
|
||||
if entry.Locked {
|
||||
continue
|
||||
}
|
||||
|
||||
switch entry.Type {
|
||||
case "file":
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", abs, err)
|
||||
}
|
||||
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
|
||||
|
||||
if entry.Encrypted {
|
||||
// For encrypted entries, check plaintext hash to detect changes.
|
||||
ptHash := plaintextHash(data)
|
||||
if ptHash != entry.PlaintextHash {
|
||||
if g.dek == nil {
|
||||
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":
|
||||
@@ -236,10 +399,20 @@ func (g *Garden) Checkpoint(message string) error {
|
||||
// and returns a status for each.
|
||||
func (g *Garden) Status() ([]FileStatus, error) {
|
||||
var results []FileStatus
|
||||
labels := g.Identity()
|
||||
|
||||
for i := range g.manifest.Files {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||
@@ -260,8 +433,17 @@ func (g *Garden) Status() ([]FileStatus, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hashing %s: %w", abs, err)
|
||||
}
|
||||
if hash != entry.Hash {
|
||||
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
||||
// For encrypted entries, compare against plaintext hash.
|
||||
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 {
|
||||
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 {
|
||||
entry := &entries[i]
|
||||
|
||||
applies, err := EntryApplies(entry, labels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !applies {
|
||||
continue
|
||||
}
|
||||
|
||||
abs, err := ExpandTildePath(entry.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||
}
|
||||
|
||||
// Check if the file exists and whether we need confirmation.
|
||||
if !force {
|
||||
// Locked entries always restore if content differs — no prompt.
|
||||
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 {
|
||||
// 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)
|
||||
entryTime := entry.Updated.Truncate(time.Second)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package garden
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kisom/sgard/manifest"
|
||||
)
|
||||
|
||||
func TestInitCreatesStructure(t *testing.T) {
|
||||
@@ -29,8 +33,8 @@ func TestInitCreatesStructure(t *testing.T) {
|
||||
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
|
||||
if err != nil {
|
||||
t.Errorf(".gitignore not found: %v", err)
|
||||
} else if string(gitignore) != "blobs/\n" {
|
||||
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n")
|
||||
} else if string(gitignore) != "blobs/\ntags\n" {
|
||||
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\ntags\n")
|
||||
}
|
||||
|
||||
if g.manifest.Version != 1 {
|
||||
@@ -135,17 +139,29 @@ func TestAddDirectory(t *testing.T) {
|
||||
if err := os.Mkdir(testDir, 0o755); err != nil {
|
||||
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 {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
entry := g.manifest.Files[0]
|
||||
if entry.Type != "directory" {
|
||||
t.Errorf("expected type directory, got %s", entry.Type)
|
||||
if len(g.manifest.Files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||
}
|
||||
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()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
@@ -202,8 +218,19 @@ func TestAddDuplicateRejected(t *testing.T) {
|
||||
t.Fatalf("first Add: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Add([]string{testFile}); err == nil {
|
||||
t.Fatal("second Add of same path should fail")
|
||||
if err := g.Add([]string{testFile}); err != nil {
|
||||
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) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
37
garden/identity.go
Normal file
37
garden/identity.go
Normal 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
158
garden/info.go
Normal 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
191
garden/info_test.go
Normal 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
39
garden/lock.go
Normal 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
197
garden/lock_test.go
Normal 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
192
garden/locked_combo_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package garden
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptedLockedFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
if err := g.EncryptInit("passphrase"); err != nil {
|
||||
t.Fatalf("EncryptInit: %v", err)
|
||||
}
|
||||
|
||||
testFile := filepath.Join(root, "secret")
|
||||
if err := os.WriteFile(testFile, []byte("locked secret"), 0o600); err != nil {
|
||||
t.Fatalf("writing: %v", err)
|
||||
}
|
||||
|
||||
// Add as both encrypted and locked.
|
||||
if err := g.Add([]string{testFile}, AddOptions{Encrypt: true, Lock: true}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
entry := g.manifest.Files[0]
|
||||
if !entry.Encrypted {
|
||||
t.Error("should be encrypted")
|
||||
}
|
||||
if !entry.Locked {
|
||||
t.Error("should be locked")
|
||||
}
|
||||
if entry.PlaintextHash == "" {
|
||||
t.Error("should have plaintext hash")
|
||||
}
|
||||
|
||||
origHash := entry.Hash
|
||||
|
||||
// Modify the file — checkpoint should skip (locked).
|
||||
if err := os.WriteFile(testFile, []byte("system overwrote"), 0o600); err != nil {
|
||||
t.Fatalf("modifying: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Checkpoint(""); err != nil {
|
||||
t.Fatalf("Checkpoint: %v", err)
|
||||
}
|
||||
|
||||
if g.manifest.Files[0].Hash != origHash {
|
||||
t.Error("checkpoint should skip locked file even if encrypted")
|
||||
}
|
||||
|
||||
// Status should report drifted.
|
||||
statuses, err := g.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].State != "drifted" {
|
||||
t.Errorf("expected drifted, got %v", statuses)
|
||||
}
|
||||
|
||||
// Restore should decrypt and overwrite without prompting.
|
||||
if err := g.Restore(nil, false, nil); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading: %v", err)
|
||||
}
|
||||
if string(got) != "locked secret" {
|
||||
t.Errorf("content = %q, want %q", got, "locked secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirOnlyLocked(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
testDir := filepath.Join(root, "lockdir")
|
||||
if err := os.MkdirAll(testDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
// Add as dir-only and locked.
|
||||
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true, Lock: true}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
entry := g.manifest.Files[0]
|
||||
if entry.Type != "directory" {
|
||||
t.Errorf("type = %s, want directory", entry.Type)
|
||||
}
|
||||
if !entry.Locked {
|
||||
t.Error("should be locked")
|
||||
}
|
||||
|
||||
// Remove the directory.
|
||||
if err := os.RemoveAll(testDir); err != nil {
|
||||
t.Fatalf("removing: %v", err)
|
||||
}
|
||||
|
||||
// Restore should recreate it.
|
||||
if err := g.Restore(nil, false, nil); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(testDir)
|
||||
if err != nil {
|
||||
t.Fatalf("directory not restored: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Error("should be a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockUnlockEncryptedToggle(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
if err := g.EncryptInit("passphrase"); err != nil {
|
||||
t.Fatalf("EncryptInit: %v", err)
|
||||
}
|
||||
|
||||
testFile := filepath.Join(root, "secret")
|
||||
if err := os.WriteFile(testFile, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("writing: %v", err)
|
||||
}
|
||||
|
||||
// Add encrypted but not locked.
|
||||
if err := g.Add([]string{testFile}, AddOptions{Encrypt: true}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
if g.manifest.Files[0].Locked {
|
||||
t.Fatal("should not be locked initially")
|
||||
}
|
||||
|
||||
// Lock it.
|
||||
if err := g.Lock([]string{testFile}); err != nil {
|
||||
t.Fatalf("Lock: %v", err)
|
||||
}
|
||||
|
||||
if !g.manifest.Files[0].Locked {
|
||||
t.Error("should be locked")
|
||||
}
|
||||
if !g.manifest.Files[0].Encrypted {
|
||||
t.Error("should still be encrypted")
|
||||
}
|
||||
|
||||
// Modify — checkpoint should skip.
|
||||
origHash := g.manifest.Files[0].Hash
|
||||
if err := os.WriteFile(testFile, []byte("changed"), 0o600); err != nil {
|
||||
t.Fatalf("modifying: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Checkpoint(""); err != nil {
|
||||
t.Fatalf("Checkpoint: %v", err)
|
||||
}
|
||||
|
||||
if g.manifest.Files[0].Hash != origHash {
|
||||
t.Error("checkpoint should skip locked encrypted file")
|
||||
}
|
||||
|
||||
// Unlock — checkpoint should now pick up changes.
|
||||
if err := g.Unlock([]string{testFile}); err != nil {
|
||||
t.Fatalf("Unlock: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Checkpoint(""); err != nil {
|
||||
t.Fatalf("Checkpoint: %v", err)
|
||||
}
|
||||
|
||||
if g.manifest.Files[0].Hash == origHash {
|
||||
t.Error("unlocked: checkpoint should update encrypted file hash")
|
||||
}
|
||||
}
|
||||
229
garden/locked_test.go
Normal file
229
garden/locked_test.go
Normal 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
201
garden/mirror.go
Normal 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
297
garden/mirror_test.go
Normal 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
31
garden/prune.go
Normal 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
79
garden/prune_test.go
Normal 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
65
garden/tags.go
Normal 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
34
garden/target.go
Normal 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
48
garden/targeting.go
Normal 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
|
||||
}
|
||||
190
garden/targeting_ops_test.go
Normal file
190
garden/targeting_ops_test.go
Normal 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
238
garden/targeting_test.go
Normal 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
24
go.mod
@@ -3,9 +3,23 @@ module github.com/kisom/sgard
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jonboulle/clockwork v0.5.0
|
||||
github.com/keys-pub/go-libfido2 v1.5.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
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
59
go.sum
@@ -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/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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
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/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=
|
||||
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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
265
integration/phase4_test.go
Normal file
265
integration/phase4_test.go
Normal 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
148
integration/phase5_test.go
Normal 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.
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -11,21 +12,67 @@ import (
|
||||
|
||||
// Entry represents a single tracked file, directory, or symlink.
|
||||
type Entry struct {
|
||||
Path string `yaml:"path"`
|
||||
Hash string `yaml:"hash,omitempty"`
|
||||
Type string `yaml:"type"`
|
||||
Mode string `yaml:"mode,omitempty"`
|
||||
Target string `yaml:"target,omitempty"`
|
||||
Updated time.Time `yaml:"updated"`
|
||||
Path string `yaml:"path"`
|
||||
Hash string `yaml:"hash,omitempty"`
|
||||
PlaintextHash string `yaml:"plaintext_hash,omitempty"`
|
||||
Encrypted bool `yaml:"encrypted,omitempty"`
|
||||
Locked bool `yaml:"locked,omitempty"`
|
||||
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.
|
||||
type Manifest struct {
|
||||
Version int `yaml:"version"`
|
||||
Created time.Time `yaml:"created"`
|
||||
Updated time.Time `yaml:"updated"`
|
||||
Message string `yaml:"message,omitempty"`
|
||||
Files []Entry `yaml:"files"`
|
||||
Version int `yaml:"version"`
|
||||
Created time.Time `yaml:"created"`
|
||||
Updated time.Time `yaml:"updated"`
|
||||
Message string `yaml:"message,omitempty"`
|
||||
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.
|
||||
|
||||
145
proto/sgard/v1/sgard.proto
Normal file
145
proto/sgard/v1/sgard.proto
Normal 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
301
server/auth.go
Normal 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
146
server/auth_test.go
Normal 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
122
server/convert.go
Normal 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
164
server/convert_test.go
Normal 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
239
server/server.go
Normal 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
336
server/server_test.go
Normal 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
237
server/tls_test.go
Normal 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
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
320
sgardpb/sgard_grpc.pb.go
Normal 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",
|
||||
}
|
||||
@@ -131,6 +131,32 @@ func (s *Store) Delete(hash string) error {
|
||||
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.
|
||||
// Layout: blobs/<first 2 hex chars>/<next 2 hex chars>/<full 64-char hash>
|
||||
func (s *Store) blobPath(hash string) string {
|
||||
|
||||
Reference in New Issue
Block a user