Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
/sgard
|
||||||
|
.claude/
|
||||||
|
result
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ linters:
|
|||||||
- unused
|
- unused
|
||||||
- errorlint
|
- errorlint
|
||||||
- staticcheck
|
- staticcheck
|
||||||
|
- copyloopvar
|
||||||
|
- durationcheck
|
||||||
|
- makezero
|
||||||
|
- nilerr
|
||||||
|
- bodyclose
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
errcheck:
|
errcheck:
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ before:
|
|||||||
- go mod tidy
|
- go mod tidy
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- main: ./cmd/sgard
|
- id: sgard
|
||||||
|
main: ./cmd/sgard
|
||||||
|
binary: sgard
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
ldflags:
|
ldflags:
|
||||||
@@ -17,10 +19,22 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|
||||||
|
- id: sgardd
|
||||||
|
main: ./cmd/sgardd
|
||||||
|
binary: sgardd
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [binary]
|
- formats: [binary]
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}_v{{ .Version }}
|
{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{- if .Arm }}v{{ .Arm }}{{ end }}_v{{ .Version }}
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|||||||
725
ARCHITECTURE.md
725
ARCHITECTURE.md
@@ -34,6 +34,7 @@ A sgard repository is a single directory with this structure:
|
|||||||
```
|
```
|
||||||
<repo>/
|
<repo>/
|
||||||
manifest.yaml # single manifest tracking all files
|
manifest.yaml # single manifest tracking all files
|
||||||
|
.gitignore # excludes blobs/ (created by sgard init)
|
||||||
blobs/
|
blobs/
|
||||||
a1/b2/a1b2c3d4... # content-addressable file storage
|
a1/b2/a1b2c3d4... # content-addressable file storage
|
||||||
```
|
```
|
||||||
@@ -47,29 +48,48 @@ updated: "2026-03-23T14:30:00Z"
|
|||||||
message: "pre-upgrade checkpoint" # optional
|
message: "pre-upgrade checkpoint" # optional
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- path: ~/.bashrc # original location (default restore target)
|
- path: ~/.bashrc # plaintext file
|
||||||
hash: a1b2c3d4e5f6... # SHA-256 of file contents
|
hash: a1b2c3d4e5f6... # SHA-256 of file contents
|
||||||
type: file # file | directory | link
|
type: file
|
||||||
mode: "0644" # permissions (quoted to avoid YAML coercion)
|
mode: "0644"
|
||||||
updated: "2026-03-23T14:30:00Z" # last checkpoint time for this file
|
|
||||||
|
|
||||||
- path: ~/.config/nvim
|
|
||||||
type: directory
|
|
||||||
mode: "0755"
|
|
||||||
updated: "2026-03-23T14:30:00Z"
|
updated: "2026-03-23T14:30:00Z"
|
||||||
# directories have no hash or blob — they're structural entries
|
|
||||||
|
|
||||||
- path: ~/.vimrc
|
- path: ~/.vimrc
|
||||||
type: link
|
type: link
|
||||||
target: ~/.config/nvim/init.vim # symlink target
|
target: ~/.config/nvim/init.vim
|
||||||
updated: "2026-03-23T14:30:00Z"
|
updated: "2026-03-23T14:30:00Z"
|
||||||
# links have no hash or blob — just the target path
|
|
||||||
|
|
||||||
- path: ~/.ssh/config
|
- path: ~/.ssh/config # encrypted file
|
||||||
hash: d4e5f6a1b2c3...
|
hash: f8e9d0c1... # SHA-256 of encrypted blob
|
||||||
|
plaintext_hash: e5f6a7... # SHA-256 of plaintext
|
||||||
|
encrypted: true
|
||||||
type: file
|
type: file
|
||||||
mode: "0600"
|
mode: "0600"
|
||||||
updated: "2026-03-23T14:30:00Z"
|
updated: "2026-03-23T14:30:00Z"
|
||||||
|
|
||||||
|
# Encryption config — only present if sgard encrypt init has been run.
|
||||||
|
# Travels with the manifest so a new machine can decrypt after pull.
|
||||||
|
# KEK slots are a map keyed by user-chosen label.
|
||||||
|
encryption:
|
||||||
|
algorithm: xchacha20-poly1305
|
||||||
|
kek_slots:
|
||||||
|
passphrase:
|
||||||
|
type: passphrase
|
||||||
|
argon2_time: 3
|
||||||
|
argon2_memory: 65536
|
||||||
|
argon2_threads: 4
|
||||||
|
salt: "base64..."
|
||||||
|
wrapped_dek: "base64..."
|
||||||
|
fido2/workstation:
|
||||||
|
type: fido2
|
||||||
|
credential_id: "base64..."
|
||||||
|
salt: "base64..."
|
||||||
|
wrapped_dek: "base64..."
|
||||||
|
fido2/laptop:
|
||||||
|
type: fido2
|
||||||
|
credential_id: "base64..."
|
||||||
|
salt: "base64..."
|
||||||
|
wrapped_dek: "base64..."
|
||||||
```
|
```
|
||||||
|
|
||||||
### Blob Store
|
### Blob Store
|
||||||
@@ -92,19 +112,23 @@ Properties:
|
|||||||
|
|
||||||
All commands operate on a repository directory (default: `~/.sgard`, override with `--repo`).
|
All commands operate on a repository directory (default: `~/.sgard`, override with `--repo`).
|
||||||
|
|
||||||
### Phase 1 — Local
|
### Local
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `sgard init [--repo <path>]` | Create a new repository |
|
| `sgard init [--repo <path>]` | Create a new repository |
|
||||||
| `sgard add <path>...` | Track files; copies them into the blob store and adds manifest entries |
|
| `sgard add <path>...` | Track files, directories (recursed), or symlinks |
|
||||||
| `sgard remove <path>...` | Untrack files; removes manifest entries (blobs cleaned up on next checkpoint) |
|
| `sgard remove <path>...` | Untrack files; run `prune` to clean orphaned blobs |
|
||||||
| `sgard checkpoint [-m <message>]` | Re-hash all tracked files, store any changed blobs, update manifest |
|
| `sgard checkpoint [-m <message>]` | Re-hash all tracked files, store changed blobs, update manifest |
|
||||||
| `sgard restore [<path>...] [--force]` | Restore files from manifest to their original locations |
|
| `sgard restore [<path>...] [--force]` | Restore files from manifest to their original locations |
|
||||||
| `sgard status` | Compare current files against manifest: modified, missing, ok |
|
| `sgard status` | Compare current files against manifest: modified, missing, ok |
|
||||||
| `sgard verify` | Check all blobs against manifest hashes (integrity check) |
|
| `sgard verify` | Check all blobs against manifest hashes (integrity check) |
|
||||||
|
| `sgard info <path>` | Show detailed information about a tracked file |
|
||||||
| `sgard list` | List all tracked files |
|
| `sgard list` | List all tracked files |
|
||||||
| `sgard diff [<path>]` | Show content diff between current file and stored blob |
|
| `sgard diff <path>` | Show content diff between current file and stored blob |
|
||||||
|
| `sgard prune` | Remove orphaned blobs not referenced by the manifest |
|
||||||
|
| `sgard mirror up <path>...` | Sync filesystem → manifest (add new, remove deleted, rehash) |
|
||||||
|
| `sgard mirror down <path>... [--force]` | Sync manifest → filesystem (restore + delete untracked) |
|
||||||
|
|
||||||
**Workflow example:**
|
**Workflow example:**
|
||||||
|
|
||||||
@@ -122,77 +146,626 @@ sgard checkpoint -m "initial" --repo /mnt/usb/dotfiles
|
|||||||
sgard restore --repo /mnt/usb/dotfiles
|
sgard restore --repo /mnt/usb/dotfiles
|
||||||
```
|
```
|
||||||
|
|
||||||
### Phase 2 — Remote (Future)
|
### Remote
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `sgard push` | Push checkpoint to remote gRPC server |
|
| `sgard push` | Push checkpoint to remote gRPC server |
|
||||||
| `sgard pull` | Pull checkpoint from remote gRPC server |
|
| `sgard pull` | Pull checkpoint from remote gRPC server |
|
||||||
| `sgard serve` | Run the gRPC daemon |
|
| `sgard prune` | With `--remote`, prunes orphaned blobs on the server |
|
||||||
|
| `sgardd` | Run the gRPC sync daemon |
|
||||||
|
|
||||||
|
## gRPC Protocol
|
||||||
|
|
||||||
|
The GardenSync service uses four RPCs for sync plus one for maintenance:
|
||||||
|
|
||||||
|
```
|
||||||
|
service GardenSync {
|
||||||
|
rpc PushManifest(PushManifestRequest) returns (PushManifestResponse);
|
||||||
|
rpc PushBlobs(stream PushBlobsRequest) returns (PushBlobsResponse);
|
||||||
|
rpc PullManifest(PullManifestRequest) returns (PullManifestResponse);
|
||||||
|
rpc PullBlobs(PullBlobsRequest) returns (stream PullBlobsResponse);
|
||||||
|
rpc Prune(PruneRequest) returns (PruneResponse);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Push flow:** Client sends manifest → server compares `manifest.Updated`
|
||||||
|
timestamps → if client newer, server returns list of missing blob hashes →
|
||||||
|
client streams those blobs (64 KiB chunks) → server replaces its manifest.
|
||||||
|
|
||||||
|
**Pull flow:** Client requests server manifest → compares timestamps locally →
|
||||||
|
if server newer, requests missing blobs → server streams them → client
|
||||||
|
replaces its manifest.
|
||||||
|
|
||||||
|
**Last timestamp wins** for conflict resolution (single-user, personal sync).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Authentication is designed to be transparent — the user never explicitly
|
||||||
|
logs in or manages credentials. It uses SSH keys they already have.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Two mechanisms, layered:
|
||||||
|
|
||||||
|
1. **SSH key signing** — used to obtain a token or when no valid token exists
|
||||||
|
2. **JWT token** — used for all subsequent requests, cached on disk
|
||||||
|
|
||||||
|
From the user's perspective, authentication is automatic. The client
|
||||||
|
handles token acquisition, caching, and renewal without prompting.
|
||||||
|
|
||||||
|
### Token-Based Auth (Primary Path)
|
||||||
|
|
||||||
|
The server issues signed JWT tokens valid for 30 days. The client caches
|
||||||
|
the token and attaches it as gRPC metadata on every call.
|
||||||
|
|
||||||
|
```
|
||||||
|
service GardenSync {
|
||||||
|
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
|
||||||
|
// ... other RPCs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authenticate RPC:**
|
||||||
|
- Client sends an SSH-signed challenge (nonce + timestamp + public key)
|
||||||
|
- Server verifies the signature against its `authorized_keys` file
|
||||||
|
- Server returns a JWT signed with its own secret key
|
||||||
|
- JWT claims: public key fingerprint, issued-at, 30-day expiry
|
||||||
|
|
||||||
|
**Normal request flow:**
|
||||||
|
1. Client reads cached token from `$XDG_STATE_HOME/sgard/token`
|
||||||
|
(falls back to `~/.local/state/sgard/token`)
|
||||||
|
2. Client attaches token as `x-sgard-auth-token` gRPC metadata
|
||||||
|
3. Server verifies JWT signature and expiry
|
||||||
|
4. If valid → request proceeds
|
||||||
|
|
||||||
|
**Token rejection — two cases:**
|
||||||
|
|
||||||
|
The server distinguishes between an expired-but-previously-valid token
|
||||||
|
and a completely invalid one:
|
||||||
|
|
||||||
|
- **Expired token** (valid signature, known fingerprint still in
|
||||||
|
authorized_keys, but past expiry): server returns `Unauthenticated`
|
||||||
|
with a `ReauthChallenge` — a server-generated nonce embedded in the
|
||||||
|
error details. This is the fast path.
|
||||||
|
|
||||||
|
- **Invalid token** (bad signature, unknown fingerprint, corrupted):
|
||||||
|
server returns a plain `Unauthenticated` with no challenge. The client
|
||||||
|
falls back to the full Authenticate flow.
|
||||||
|
|
||||||
|
**Fast re-auth flow (expired token, transparent to user):**
|
||||||
|
1. Client sends request with expired token
|
||||||
|
2. Server returns `Unauthenticated` + `ReauthChallenge{nonce, timestamp}`
|
||||||
|
3. Client signs the server-provided nonce+timestamp with SSH key
|
||||||
|
4. Client calls `Authenticate` with the signature
|
||||||
|
5. Server verifies, issues new JWT
|
||||||
|
6. Client caches new token to disk
|
||||||
|
7. Client retries the original request with the new token
|
||||||
|
|
||||||
|
This saves a round trip compared to full re-auth — the server provides
|
||||||
|
the nonce, so the client doesn't need to generate one and hope it's
|
||||||
|
accepted. The server controls the challenge, which also prevents any
|
||||||
|
client-side nonce reuse.
|
||||||
|
|
||||||
|
**Full auth flow (no valid token, transparent to user):**
|
||||||
|
1. Client has no cached token or token is completely invalid
|
||||||
|
2. Client calls `Authenticate` with a self-generated nonce+timestamp,
|
||||||
|
signed with SSH key
|
||||||
|
3. Server verifies, issues JWT
|
||||||
|
4. Client caches token, proceeds with original request
|
||||||
|
|
||||||
|
### SSH Key Signing
|
||||||
|
|
||||||
|
Used during the `Authenticate` RPC to prove possession of an authorized
|
||||||
|
SSH private key. The challenge can come from the server (re-auth fast
|
||||||
|
path) or be generated by the client (initial auth).
|
||||||
|
|
||||||
|
**Challenge payload:** `nonce (32 random bytes) || timestamp (big-endian int64)`
|
||||||
|
|
||||||
|
**Authenticate RPC request fields:**
|
||||||
|
- `nonce` — 32-byte nonce (from server's ReauthChallenge or client-generated)
|
||||||
|
- `timestamp` — Unix seconds
|
||||||
|
- `signature` — SSH signature over (nonce || timestamp)
|
||||||
|
- `public_key` — SSH public key in authorized_keys format
|
||||||
|
|
||||||
|
**Server verification:**
|
||||||
|
- Parse public key, check fingerprint against `authorized_keys` file
|
||||||
|
- Verify SSH signature over the payload
|
||||||
|
- Check timestamp is within 5-minute window (prevents replay)
|
||||||
|
|
||||||
|
### Server-Side Token Management
|
||||||
|
|
||||||
|
The server does not store tokens. JWTs are stateless — the server signs
|
||||||
|
them with a secret key and verifies its own signature on each request.
|
||||||
|
|
||||||
|
**Secret key:** Generated on first startup, stored at `<repo>/jwt.key`
|
||||||
|
(32 random bytes). If the key file is deleted, all outstanding tokens
|
||||||
|
become invalid and clients re-authenticate automatically.
|
||||||
|
|
||||||
|
**No revocation mechanism.** For a single-user personal sync tool,
|
||||||
|
revocation is unnecessary. Removing a key from `authorized_keys`
|
||||||
|
prevents new token issuance. Existing tokens expire naturally within
|
||||||
|
30 days. Deleting `jwt.key` invalidates all tokens immediately.
|
||||||
|
|
||||||
|
### Client-Side Token Storage
|
||||||
|
|
||||||
|
Token cached at `$XDG_STATE_HOME/sgard/token` (per XDG Base Directory
|
||||||
|
spec, state is "data that should persist between restarts but isn't
|
||||||
|
important enough to back up"). Falls back to `~/.local/state/sgard/token`.
|
||||||
|
|
||||||
|
The token file contains the raw JWT string. File permissions are set to
|
||||||
|
`0600`.
|
||||||
|
|
||||||
|
### Key Resolution
|
||||||
|
|
||||||
|
SSH key resolution order (for initial authentication):
|
||||||
|
1. `--ssh-key` flag (explicit path to private key)
|
||||||
|
2. `SGARD_SSH_KEY` environment variable
|
||||||
|
3. ssh-agent (if `SSH_AUTH_SOCK` is set, uses first available key)
|
||||||
|
4. Default paths: `~/.ssh/id_ed25519`, `~/.ssh/id_rsa`
|
||||||
|
|
||||||
|
## Encryption
|
||||||
|
|
||||||
|
sgard supports optional at-rest encryption for individual files.
|
||||||
|
Encryption is per-file, not per-repo — any file can be marked as
|
||||||
|
encrypted at add time. A repo may contain a mix of encrypted and
|
||||||
|
plaintext blobs.
|
||||||
|
|
||||||
|
### Key Hierarchy
|
||||||
|
|
||||||
|
A two-layer key hierarchy separates the encryption key from the user's
|
||||||
|
secret (passphrase or FIDO2 key):
|
||||||
|
|
||||||
|
```
|
||||||
|
User Secret (passphrase or FIDO2 hmac-secret)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
KEK (Key Encryption Key) — derived from user secret
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DEK (Data Encryption Key) — random, encrypts/decrypts file blobs
|
||||||
|
```
|
||||||
|
|
||||||
|
**DEK (Data Encryption Key):**
|
||||||
|
- 256-bit random key, generated once when encryption is first enabled
|
||||||
|
- Used with XChaCha20-Poly1305 (AEAD) to encrypt file blobs
|
||||||
|
- Never stored in plaintext — always wrapped by the KEK
|
||||||
|
- Each KEK source stores its own wrapped copy in the manifest
|
||||||
|
(`encryption.kek_sources[].wrapped_dek`, base64-encoded)
|
||||||
|
|
||||||
|
**KEK (Key Encryption Key):**
|
||||||
|
- Derived from the user's secret
|
||||||
|
- Used only to wrap/unwrap the DEK, never to encrypt data directly
|
||||||
|
- Never stored on disk — derived on demand
|
||||||
|
|
||||||
|
This separation means changing a passphrase or adding a FIDO2 key only
|
||||||
|
requires re-wrapping the DEK, not re-encrypting every blob.
|
||||||
|
|
||||||
|
### KEK Derivation
|
||||||
|
|
||||||
|
Two slot types. A repo has one `passphrase` slot and zero or more
|
||||||
|
`fido2/<label>` slots:
|
||||||
|
|
||||||
|
**Passphrase slot** (at most one per repo):
|
||||||
|
- KEK = Argon2id(passphrase, salt, time=3, memory=64MB, threads=4)
|
||||||
|
- Salt and Argon2id parameters stored in the slot entry
|
||||||
|
- Slot key: `passphrase`
|
||||||
|
|
||||||
|
**FIDO2 slots** (one per device, labeled):
|
||||||
|
- KEK = HMAC-SHA256 output from the FIDO2 authenticator
|
||||||
|
- The authenticator computes `HMAC(device_secret, salt)` using the
|
||||||
|
credential registered for this slot
|
||||||
|
- `credential_id` in the slot entry ties it to a specific FIDO2
|
||||||
|
registration, allowing sgard to skip non-matching devices
|
||||||
|
- Slot key: `fido2/<label>` (defaults to hostname, overridable)
|
||||||
|
|
||||||
|
### Blob Encryption
|
||||||
|
|
||||||
|
**Algorithm:** XChaCha20-Poly1305 (from `golang.org/x/crypto/chacha20poly1305`)
|
||||||
|
- 24-byte nonce (random per blob), 16-byte auth tag
|
||||||
|
- AEAD — provides both confidentiality and integrity
|
||||||
|
- XChaCha20 variant chosen for its 24-byte nonce, which is safe to
|
||||||
|
generate randomly without collision risk
|
||||||
|
|
||||||
|
**Encrypted blob format:**
|
||||||
|
```
|
||||||
|
[24-byte nonce][ciphertext + 16-byte Poly1305 tag]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Encryption flow (during Add/Checkpoint):**
|
||||||
|
1. Read file plaintext
|
||||||
|
2. Generate random 24-byte nonce
|
||||||
|
3. Encrypt: `ciphertext = XChaCha20-Poly1305.Seal(nonce, DEK, plaintext)`
|
||||||
|
4. Compute SHA-256 hash of the encrypted blob (nonce + ciphertext)
|
||||||
|
5. Store the encrypted blob in the content-addressable store
|
||||||
|
|
||||||
|
**Decryption flow (during Restore/Diff):**
|
||||||
|
1. Read encrypted blob from store
|
||||||
|
2. Extract 24-byte nonce prefix
|
||||||
|
3. Decrypt: `plaintext = XChaCha20-Poly1305.Open(nonce, DEK, ciphertext)`
|
||||||
|
4. Write plaintext to disk
|
||||||
|
|
||||||
|
### Hashing: Post-Encryption
|
||||||
|
|
||||||
|
The manifest hash is the SHA-256 of the **ciphertext**, not the plaintext.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- `verify` checks blob integrity without needing the DEK
|
||||||
|
- The hash matches what's actually stored on disk
|
||||||
|
- The server never needs the DEK — it handles only encrypted blobs
|
||||||
|
- `status` needs the DEK to compare against the current file (hash
|
||||||
|
the plaintext, encrypt it, compare encrypted hash — or keep a
|
||||||
|
plaintext hash in the manifest)
|
||||||
|
|
||||||
|
**Manifest changes for encryption:**
|
||||||
|
|
||||||
|
Encrypted entries gain two fields: `encrypted: true` and
|
||||||
|
`plaintext_hash` (SHA-256 of the plaintext, for efficient `status`
|
||||||
|
checks without decryption):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
files:
|
||||||
|
- path: ~/.bashrc
|
||||||
|
hash: a1b2c3d4... # SHA-256 of plaintext — not encrypted
|
||||||
|
type: file
|
||||||
|
mode: "0644"
|
||||||
|
updated: "2026-03-24T..."
|
||||||
|
|
||||||
|
- path: ~/.ssh/config
|
||||||
|
hash: f8e9d0c1... # SHA-256 of encrypted blob (post-encryption)
|
||||||
|
plaintext_hash: e5f6a7... # SHA-256 of plaintext (pre-encryption)
|
||||||
|
encrypted: true
|
||||||
|
type: file
|
||||||
|
mode: "0600"
|
||||||
|
updated: "2026-03-24T..."
|
||||||
|
```
|
||||||
|
|
||||||
|
For unencrypted entries, `hash` is the SHA-256 of the plaintext (current
|
||||||
|
behavior), and `plaintext_hash` and `encrypted` are omitted.
|
||||||
|
|
||||||
|
`status` hashes the current file on disk and compares against
|
||||||
|
`plaintext_hash` (for encrypted entries) or `hash` (for plaintext).
|
||||||
|
`verify` always uses `hash` to check store integrity without the DEK.
|
||||||
|
|
||||||
|
### DEK Storage
|
||||||
|
|
||||||
|
Each slot wraps the DEK independently using XChaCha20-Poly1305,
|
||||||
|
stored as base64 in the slot's `wrapped_dek` field:
|
||||||
|
|
||||||
|
```
|
||||||
|
wrapped_dek = base64([24-byte nonce][encrypted DEK + 16-byte tag])
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest is fully self-contained — pulling it to a new machine
|
||||||
|
gives you everything needed to decrypt (given the user's secret).
|
||||||
|
|
||||||
|
### Unlock Resolution
|
||||||
|
|
||||||
|
When sgard needs the DEK, it reads `encryption.kek_slots` from the
|
||||||
|
manifest and tries slots automatically:
|
||||||
|
|
||||||
|
1. **FIDO2 slots** (all `fido2/*` slots, in map order):
|
||||||
|
- For each: check if a connected FIDO2 device matches the
|
||||||
|
slot's `credential_id`
|
||||||
|
- If match found → prompt for touch, derive KEK, unwrap DEK
|
||||||
|
- If no device matches or touch times out → try next slot
|
||||||
|
|
||||||
|
2. **Passphrase slot** (if `passphrase` slot exists):
|
||||||
|
- Prompt for passphrase on stdin
|
||||||
|
- Derive KEK via Argon2id, unwrap DEK
|
||||||
|
|
||||||
|
3. **No slots succeed** → error
|
||||||
|
|
||||||
|
FIDO2 is tried first because it requires no typing — just a touch.
|
||||||
|
The `credential_id` check avoids prompting for touch on a device that
|
||||||
|
can't unwrap the slot, which matters when multiple FIDO2 keys are
|
||||||
|
connected. The passphrase slot is the universal fallback.
|
||||||
|
|
||||||
|
The user never specifies which slot to use. The presence of the
|
||||||
|
`encryption` section indicates the repo has encryption capability.
|
||||||
|
Individual files opt in via `--encrypt` at add time.
|
||||||
|
|
||||||
|
### CLI Integration
|
||||||
|
|
||||||
|
**Setting up encryption (creates DEK, adds `encryption` to manifest):**
|
||||||
|
```sh
|
||||||
|
sgard encrypt init # passphrase slot only
|
||||||
|
sgard encrypt init --fido2 # fido2/<hostname> + passphrase slots
|
||||||
|
```
|
||||||
|
|
||||||
|
When `--fido2` is specified, sgard creates both slots: the FIDO2 slot
|
||||||
|
(named `fido2/<hostname>` by default) and immediately prompts for a
|
||||||
|
passphrase to create the fallback slot. This ensures the user is never
|
||||||
|
locked out if they lose the FIDO2 key.
|
||||||
|
|
||||||
|
Without `--fido2`, only the `passphrase` slot is created.
|
||||||
|
|
||||||
|
**Adding encrypted files:**
|
||||||
|
```sh
|
||||||
|
sgard add --encrypt ~/.ssh/config ~/.aws/credentials
|
||||||
|
sgard add ~/.bashrc # not encrypted
|
||||||
|
```
|
||||||
|
|
||||||
|
**Managing slots:**
|
||||||
|
```sh
|
||||||
|
sgard encrypt add-fido2 # adds fido2/<hostname>
|
||||||
|
sgard encrypt add-fido2 --label yubikey-5 # adds fido2/yubikey-5
|
||||||
|
sgard encrypt remove-slot fido2/old-laptop # removes a slot
|
||||||
|
sgard encrypt list-slots # shows all slot names and types
|
||||||
|
sgard encrypt change-passphrase # prompts for old and new
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding a slot auto-unlocks the DEK via an existing slot first (e.g.,
|
||||||
|
`add-fido2` will prompt for the passphrase to unwrap the DEK, then
|
||||||
|
re-wrap it with the new FIDO2 key).
|
||||||
|
|
||||||
|
**Unlocking:**
|
||||||
|
Operations that touch encrypted entries (add --encrypt, checkpoint,
|
||||||
|
restore, diff, mirror on encrypted files) trigger automatic unlock
|
||||||
|
via the resolution order above. The DEK is cached in memory for the
|
||||||
|
duration of the command.
|
||||||
|
|
||||||
|
Operations that only touch plaintext entries never prompt — they work
|
||||||
|
exactly as before, even if the repo has encryption configured.
|
||||||
|
|
||||||
|
There is no long-lived unlock state — each command invocation that needs
|
||||||
|
the DEK obtains it fresh. This is intentional: dotfile operations are
|
||||||
|
infrequent, and caching the DEK across invocations would require a
|
||||||
|
daemon or on-disk secret, both of which expand the attack surface.
|
||||||
|
|
||||||
|
### Security Properties
|
||||||
|
|
||||||
|
- **Selective confidentiality:** Only files marked `--encrypt` are
|
||||||
|
encrypted. The manifest contains paths and hashes but not file
|
||||||
|
contents for encrypted entries.
|
||||||
|
- **Server ignorance:** The server never has the DEK. Push/pull
|
||||||
|
transfers encrypted blobs opaquely. The server cannot read encrypted
|
||||||
|
file contents.
|
||||||
|
- **Key rotation:** Changing the passphrase re-wraps the DEK without
|
||||||
|
re-encrypting blobs.
|
||||||
|
- **Compromise recovery:** If the DEK is compromised, all encrypted
|
||||||
|
blobs must be re-encrypted (not just re-wrapped). This is an
|
||||||
|
explicit `sgard encrypt rotate-dek` operation.
|
||||||
|
- **No plaintext leaks:** `diff` decrypts in memory, never writes
|
||||||
|
decrypted blobs to disk.
|
||||||
|
- **Graceful degradation:** Commands that don't touch encrypted entries
|
||||||
|
work without the DEK. A `status` on a mixed repo can check plaintext
|
||||||
|
entries without prompting.
|
||||||
|
|
||||||
|
### Repos Without Encryption
|
||||||
|
|
||||||
|
A manifest with no `encryption` section has no DEK and cannot have
|
||||||
|
encrypted entries. The `--encrypt` flag on `add` will error, prompting
|
||||||
|
the user to run `sgard encrypt init` first. All existing behavior is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### Encryption and Remote Sync
|
||||||
|
|
||||||
|
The server never has the DEK. Push/pull transfers the manifest
|
||||||
|
(including the `encryption` section with wrapped DEKs and salts) and
|
||||||
|
encrypted blobs as opaque bytes. The server cannot decrypt file
|
||||||
|
contents.
|
||||||
|
|
||||||
|
When pulling to a new machine:
|
||||||
|
1. The manifest arrives with all `kek_slots` intact
|
||||||
|
2. The user provides their passphrase (universal fallback)
|
||||||
|
3. sgard derives the KEK, unwraps the DEK, decrypts blobs on restore
|
||||||
|
|
||||||
|
No additional setup is needed beyond having the passphrase.
|
||||||
|
|
||||||
|
**Adding FIDO2 on a new machine:** FIDO2 hmac-secret is device-bound —
|
||||||
|
a different physical key produces a different KEK. After pulling to a
|
||||||
|
new machine, the user runs `sgard encrypt add-fido2` which:
|
||||||
|
1. Unlocks the DEK via the passphrase slot
|
||||||
|
2. Registers a new FIDO2 credential on the local device
|
||||||
|
3. Wraps the DEK with the new FIDO2 KEK
|
||||||
|
4. Adds a `fido2/<hostname>` slot to the manifest
|
||||||
|
|
||||||
|
On next push, the new slot propagates to the server and other machines.
|
||||||
|
Each machine accumulates its own FIDO2 slot over time.
|
||||||
|
|
||||||
|
### TLS Transport
|
||||||
|
|
||||||
|
sgardd supports optional TLS via `--tls-cert` and `--tls-key` flags.
|
||||||
|
When provided, the server uses `credentials.NewTLS()` with a minimum
|
||||||
|
of TLS 1.2. Without them, it runs insecure (for local/trusted networks).
|
||||||
|
|
||||||
|
The client gains `--tls` and `--tls-ca` flags:
|
||||||
|
- `--tls` — enables TLS transport (uses system CA pool by default)
|
||||||
|
- `--tls-ca <path>` — custom CA certificate for self-signed server certs
|
||||||
|
|
||||||
|
Both flags must be specified together on the server side; on the client
|
||||||
|
side `--tls` alone uses the system trust store, and `--tls-ca` adds a
|
||||||
|
custom root.
|
||||||
|
|
||||||
|
### FIDO2 Hardware Support
|
||||||
|
|
||||||
|
Real FIDO2 hardware support uses `go-libfido2` (CGo bindings to
|
||||||
|
Yubico's libfido2 C library). It is gated behind the `fido2` build
|
||||||
|
tag to avoid requiring CGo and libfido2 for users who don't need it:
|
||||||
|
|
||||||
|
- `go build ./...` — default build, no FIDO2 hardware support
|
||||||
|
- `go build -tags fido2 ./...` — links against libfido2 for real keys
|
||||||
|
|
||||||
|
The implementation (`garden/fido2_hardware.go`) wraps
|
||||||
|
`libfido2.Device.MakeCredential` and `Assertion` with the
|
||||||
|
`HMACSecretExtension` to derive 32-byte HMAC secrets from hardware
|
||||||
|
keys. A `--fido2-pin` flag is available for PIN-protected devices.
|
||||||
|
|
||||||
|
The Nix flake provides two packages: `sgard` (default, no CGo) and
|
||||||
|
`sgard-fido2` (links libfido2).
|
||||||
|
|
||||||
|
### DEK Rotation
|
||||||
|
|
||||||
|
`sgard encrypt rotate-dek` generates a new DEK, re-encrypts all
|
||||||
|
encrypted blobs with the new key, and re-wraps the new DEK with all
|
||||||
|
existing KEK slots. Required when the DEK is suspected compromised
|
||||||
|
(re-wrapping alone is insufficient since the old DEK could decrypt
|
||||||
|
the existing blobs).
|
||||||
|
|
||||||
|
The rotation process:
|
||||||
|
1. Generate a new random 256-bit DEK
|
||||||
|
2. For each encrypted entry: decrypt with old DEK, re-encrypt with new DEK,
|
||||||
|
write new blob to store, update manifest hash (plaintext hash unchanged)
|
||||||
|
3. Re-derive each KEK (passphrase via Argon2id, FIDO2 via device) and
|
||||||
|
re-wrap the new DEK. FIDO2 slots without a matching connected device
|
||||||
|
are dropped during rotation.
|
||||||
|
4. Save updated manifest
|
||||||
|
|
||||||
|
Plaintext entries are untouched.
|
||||||
|
|
||||||
|
### Per-Machine Targeting (Phase 5)
|
||||||
|
|
||||||
|
Entries can be targeted to specific machines using `only` and `never`
|
||||||
|
labels. A machine's identity is a set of labels computed at runtime:
|
||||||
|
|
||||||
|
- **Short hostname:** `vade` (before the first dot, lowercased)
|
||||||
|
- **OS:** `os:linux`, `os:darwin`, `os:windows` (from `runtime.GOOS`)
|
||||||
|
- **Architecture:** `arch:amd64`, `arch:arm64` (from `runtime.GOARCH`)
|
||||||
|
- **Tags:** `tag:work`, `tag:server` (from `<repo>/tags`, local-only)
|
||||||
|
|
||||||
|
**Manifest fields on Entry:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
files:
|
||||||
|
- path: ~/.bashrc.linux
|
||||||
|
only: [os:linux] # restore/checkpoint only on Linux
|
||||||
|
...
|
||||||
|
- path: ~/.ssh/work-config
|
||||||
|
only: [tag:work] # only on machines tagged "work"
|
||||||
|
...
|
||||||
|
- path: ~/.config/heavy
|
||||||
|
never: [arch:arm64] # everywhere except ARM
|
||||||
|
...
|
||||||
|
- path: ~/.special
|
||||||
|
only: [vade] # only on host "vade"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Matching rules:**
|
||||||
|
- `only` set → entry applies if *any* label matches the machine
|
||||||
|
- `never` set → entry excluded if *any* label matches
|
||||||
|
- Both set → error (mutually exclusive)
|
||||||
|
- Neither set → applies everywhere (current behavior)
|
||||||
|
|
||||||
|
**Operations affected:**
|
||||||
|
- `restore` — skip non-matching entries
|
||||||
|
- `checkpoint` — skip non-matching entries (don't clobber stored version)
|
||||||
|
- `status` — report non-matching entries as `skipped`
|
||||||
|
- `add`, `list`, `verify`, `diff` — operate on all entries regardless
|
||||||
|
|
||||||
|
**Tags file:** `<repo>/tags`, one tag per line, not synced. Each
|
||||||
|
machine defines its own tags. `sgard init` adds `tags` to `.gitignore`.
|
||||||
|
|
||||||
|
**Label format:** bare string = hostname, `prefix:value` = typed matcher.
|
||||||
|
The `tag:` prefix in `only`/`never` maps to bare names in the tags file.
|
||||||
|
|
||||||
|
### Future: Manifest Signing (Phase 6)
|
||||||
|
|
||||||
|
Manifest signing (to detect tampering) is deferred. The challenge is
|
||||||
|
the trust model: which key signs, and how does a pulling client verify
|
||||||
|
the signature when multiple machines with different SSH keys push to
|
||||||
|
the same server? This requires a proper trust/key-authority design.
|
||||||
|
|
||||||
## Go Package Structure
|
## Go Package Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
sgard/
|
sgard/
|
||||||
cmd/
|
cmd/sgard/ # CLI entry point — one file per command
|
||||||
sgard/ # CLI entry point
|
main.go # cobra root command, --repo/--remote/--ssh-key/--tls/--tls-ca flags
|
||||||
main.go # cobra root command, --repo flag
|
encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase
|
||||||
init.go # sgard init
|
push.go pull.go prune.go mirror.go
|
||||||
add.go # sgard add
|
init.go add.go remove.go checkpoint.go
|
||||||
remove.go # sgard remove
|
restore.go status.go verify.go list.go info.go diff.go version.go
|
||||||
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)
|
|
||||||
|
|
||||||
garden/ # Core business logic
|
cmd/sgardd/ # gRPC server daemon
|
||||||
garden.go # Garden struct: orchestrates manifest + store + filesystem
|
main.go # --listen, --repo, --authorized-keys, --tls-cert, --tls-key flags
|
||||||
garden_test.go
|
|
||||||
|
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)
|
||||||
|
restore.go mirror.go prune.go remove.go verify.go list.go info.go diff.go
|
||||||
hasher.go # SHA-256 file hashing
|
hasher.go # SHA-256 file hashing
|
||||||
diff.go # File diff generation
|
|
||||||
|
|
||||||
manifest/ # YAML manifest parsing
|
manifest/ # YAML manifest parsing
|
||||||
manifest.go # Manifest and Entry structs, Load/Save
|
manifest.go # Manifest and Entry structs, Load/Save
|
||||||
manifest_test.go
|
|
||||||
|
|
||||||
store/ # Content-addressable blob storage
|
store/ # Content-addressable blob storage
|
||||||
store.go # Store struct: Write/Read/Exists/Delete
|
store.go # Store struct: Write/Read/Exists/Delete/List
|
||||||
store_test.go
|
|
||||||
|
|
||||||
proto/ # gRPC service definition (Phase 2)
|
server/ # gRPC server implementation
|
||||||
sgard/v1/
|
server.go # GardenSync RPC handlers with RWMutex
|
||||||
sgard.proto
|
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
|
||||||
|
|
||||||
|
flake.nix # Nix flake (builds sgard + sgardd)
|
||||||
|
.goreleaser.yaml # GoReleaser (builds both binaries)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Architectural Rule
|
### Key Architectural Rule
|
||||||
|
|
||||||
**The `garden` package contains all logic. The `cmd` package is pure CLI wiring.**
|
**The `garden` package contains all logic. The `cmd` package is pure CLI
|
||||||
|
wiring. The `server` package wraps `Garden` methods as gRPC endpoints.**
|
||||||
The `Garden` struct is the central coordinator:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Garden struct {
|
type Garden struct {
|
||||||
manifest *manifest.Manifest
|
manifest *manifest.Manifest
|
||||||
store *store.Store
|
store *store.Store
|
||||||
root string // repository root directory
|
root string
|
||||||
|
manifestPath string
|
||||||
|
clock clockwork.Clock
|
||||||
|
dek []byte // unlocked data encryption key
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Garden) Add(paths []string) error
|
// Local operations
|
||||||
|
func (g *Garden) Add(paths []string, opts ...AddOptions) error
|
||||||
func (g *Garden) Remove(paths []string) error
|
func (g *Garden) Remove(paths []string) error
|
||||||
func (g *Garden) Checkpoint(message string) error
|
func (g *Garden) Checkpoint(message string) error
|
||||||
func (g *Garden) Restore(paths []string, force bool) error
|
func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error
|
||||||
func (g *Garden) Status() ([]FileStatus, error)
|
func (g *Garden) Status() ([]FileStatus, error)
|
||||||
func (g *Garden) Verify() ([]VerifyResult, error)
|
func (g *Garden) Verify() ([]VerifyResult, error)
|
||||||
|
func (g *Garden) List() []manifest.Entry
|
||||||
|
func (g *Garden) Info(path string) (*FileInfo, error)
|
||||||
func (g *Garden) Diff(path string) (string, error)
|
func (g *Garden) Diff(path string) (string, error)
|
||||||
|
func (g *Garden) Prune() (int, error)
|
||||||
|
func (g *Garden) MirrorUp(paths []string) error
|
||||||
|
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error
|
||||||
|
func (g *Garden) Lock(paths []string) error
|
||||||
|
func (g *Garden) Unlock(paths []string) error
|
||||||
|
|
||||||
|
// Encryption
|
||||||
|
func (g *Garden) EncryptInit(passphrase string) error
|
||||||
|
func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) error
|
||||||
|
func (g *Garden) HasEncryption() bool
|
||||||
|
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool
|
||||||
|
func (g *Garden) RotateDEK(prompt func() (string, error), fido2 ...FIDO2Device) error
|
||||||
|
func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error
|
||||||
|
func (g *Garden) RemoveSlot(name string) error
|
||||||
|
func (g *Garden) ListSlots() map[string]string
|
||||||
|
func (g *Garden) ChangePassphrase(newPassphrase string) error
|
||||||
|
|
||||||
|
// Accessors (used by server package)
|
||||||
|
func (g *Garden) GetManifest() *manifest.Manifest
|
||||||
|
func (g *Garden) BlobExists(hash string) bool
|
||||||
|
func (g *Garden) ReadBlob(hash string) ([]byte, error)
|
||||||
|
func (g *Garden) WriteBlob(data []byte) (string, error)
|
||||||
|
func (g *Garden) ReplaceManifest(m *manifest.Manifest) error
|
||||||
|
func (g *Garden) ListBlobs() ([]string, error)
|
||||||
|
func (g *Garden) DeleteBlob(hash string) error
|
||||||
```
|
```
|
||||||
|
|
||||||
This separation means the future gRPC server calls the same `Garden` methods
|
The gRPC server calls the same `Garden` methods as the CLI — no logic
|
||||||
as the CLI — no logic duplication.
|
duplication.
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
@@ -200,9 +773,13 @@ as the CLI — no logic duplication.
|
|||||||
`$HOME` at runtime. This makes the manifest portable across machines with
|
`$HOME` at runtime. This makes the manifest portable across machines with
|
||||||
different usernames.
|
different usernames.
|
||||||
|
|
||||||
**No history.** Phase 1 stores only the latest checkpoint. For versioning,
|
**Adding a directory recurses.** `Add` walks directories and adds each
|
||||||
place the manifest under git. The `blobs/` directory should be gitignored —
|
file/symlink individually. Directories are not tracked as entries — only
|
||||||
blob durability (backup, replication) is deferred to a future phase.
|
leaf files and symlinks.
|
||||||
|
|
||||||
|
**No history.** Only the latest checkpoint is stored. For versioning, place
|
||||||
|
the repo under git — `sgard init` creates a `.gitignore` that excludes
|
||||||
|
`blobs/`.
|
||||||
|
|
||||||
**Per-file timestamps.** Each manifest entry records an `updated` timestamp
|
**Per-file timestamps.** Each manifest entry records an `updated` timestamp
|
||||||
set at checkpoint time. On restore, if the manifest entry is newer than the
|
set at checkpoint time. On restore, if the manifest entry is newer than the
|
||||||
@@ -210,8 +787,34 @@ file on disk (by mtime), the restore proceeds without prompting. If the file
|
|||||||
on disk is newer or the times match, sgard prompts for confirmation.
|
on disk is newer or the times match, sgard prompts for confirmation.
|
||||||
`--force` always skips the prompt.
|
`--force` always skips the prompt.
|
||||||
|
|
||||||
**Atomic writes.** Checkpoint writes `manifest.yaml.tmp` then renames to
|
**Atomic writes.** Manifest saves write to a temp file then rename.
|
||||||
`manifest.yaml`. A crash cannot corrupt the manifest.
|
|
||||||
|
|
||||||
**Old C++/proto source files** are retained in the git history for reference
|
**Timestamp comparison truncates to seconds** for cross-platform filesystem
|
||||||
and will be removed as part of the Go rewrite.
|
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
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./cmd/sgard
|
go build ./... # both sgard and sgardd
|
||||||
|
go build -tags fido2 ./... # with real FIDO2 hardware support (requires libfido2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nix:
|
||||||
|
```bash
|
||||||
|
nix build .#sgard # builds both binaries (no CGo)
|
||||||
|
nix build .#sgard-fido2 # with FIDO2 hardware support (links libfido2)
|
||||||
```
|
```
|
||||||
|
|
||||||
Run tests:
|
Run tests:
|
||||||
@@ -34,24 +41,38 @@ Lint:
|
|||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Regenerate proto (requires protoc toolchain):
|
||||||
|
```bash
|
||||||
|
make proto
|
||||||
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `gopkg.in/yaml.v3` — manifest serialization
|
- `gopkg.in/yaml.v3` — manifest serialization
|
||||||
- `github.com/spf13/cobra` — CLI framework
|
- `github.com/spf13/cobra` — CLI framework
|
||||||
- `github.com/jonboulle/clockwork` — injectable clock for deterministic tests
|
- `github.com/jonboulle/clockwork` — injectable clock for deterministic tests
|
||||||
|
- `google.golang.org/grpc` — gRPC runtime
|
||||||
|
- `google.golang.org/protobuf` — protobuf runtime
|
||||||
|
- `golang.org/x/crypto` — SSH key auth (ssh, ssh/agent), Argon2id, XChaCha20-Poly1305
|
||||||
|
- `github.com/golang-jwt/jwt/v5` — JWT token auth
|
||||||
|
- `github.com/keys-pub/go-libfido2` — FIDO2 hardware key support (build tag `fido2`, requires libfido2)
|
||||||
|
|
||||||
## Package Structure
|
## Package Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/sgard/ CLI entry point (cobra commands, pure wiring)
|
cmd/sgard/ CLI entry point (cobra commands, pure wiring)
|
||||||
garden/ Core business logic (Garden struct orchestrating everything)
|
cmd/sgardd/ gRPC server daemon
|
||||||
|
garden/ Core business logic (Garden struct, encryption, FIDO2 hardware via build tags)
|
||||||
manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save)
|
manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save)
|
||||||
store/ Content-addressable blob storage (SHA-256 keyed)
|
store/ Content-addressable blob storage (SHA-256 keyed)
|
||||||
|
server/ gRPC server (RPC handlers, JWT/SSH auth interceptor, proto conversion)
|
||||||
|
client/ gRPC client library (Push, Pull, Prune, token auth with auto-renewal)
|
||||||
|
sgardpb/ Generated protobuf + gRPC Go code
|
||||||
```
|
```
|
||||||
|
|
||||||
Key rule: all logic lives in `garden/`. The `cmd/` layer only parses flags
|
Key rule: all logic lives in `garden/`. The `cmd/` layer only parses flags
|
||||||
and calls `Garden` methods. This enables the future gRPC server to reuse
|
and calls `Garden` methods. The `server` wraps `Garden` as gRPC endpoints.
|
||||||
the same logic with zero duplication.
|
No logic duplication.
|
||||||
|
|
||||||
Each garden operation (remove, verify, list, diff) lives in its own file
|
Each garden operation lives in its own file (`garden/<op>.go`) to minimize
|
||||||
(`garden/<op>.go`) to minimize merge conflicts during parallel development.
|
merge conflicts during parallel development.
|
||||||
|
|||||||
20
Makefile
Normal file
20
Makefile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.PHONY: proto build test lint clean
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f sgard
|
||||||
63
PROGRESS.md
63
PROGRESS.md
@@ -7,9 +7,9 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
**Phase:** Phase 1 complete (Steps 1–8). All local commands implemented.
|
**Phase:** Phase 5 complete. All 5 steps done (28–32).
|
||||||
|
|
||||||
**Last updated:** 2026-03-23
|
**Last updated:** 2026-03-24
|
||||||
|
|
||||||
## Completed Steps
|
## Completed Steps
|
||||||
|
|
||||||
@@ -42,16 +42,26 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Phase 1 is complete. Future work: blob durability, gRPC remote mode.
|
Phase 6: Manifest Signing (to be planned).
|
||||||
|
|
||||||
|
## Standalone Additions
|
||||||
|
|
||||||
|
- **Deployment to rift**: sgardd deployed as Podman container on rift behind
|
||||||
|
mc-proxy (L4 SNI passthrough on :9443, multiplexed with metacrypt gRPC).
|
||||||
|
TLS cert issued by Metacrypt, SSH-key auth. DNS at
|
||||||
|
`sgard.svc.mcp.metacircular.net`.
|
||||||
|
- **Default remote config**: `sgard remote set/show` commands. Saves addr,
|
||||||
|
TLS, and CA path to `<repo>/remote.yaml`. `dialRemote` merges saved config
|
||||||
|
with CLI flags (flags win). Removes need for `--remote`/`--tls` on every
|
||||||
|
push/pull.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## Known Issues / Decisions Deferred
|
||||||
|
|
||||||
- **Blob durability**: blobs are not stored in git. A strategy for backup or
|
- **Manifest signing**: deferred — trust model (which key signs, how do
|
||||||
replication is deferred to a future phase.
|
pulling clients verify) needs design.
|
||||||
- **gRPC remote mode**: Phase 2. Package structure is designed to accommodate
|
- **DEK rotation**: `sgard encrypt rotate-dek` (re-encrypt all blobs)
|
||||||
it (garden core separates logic from CLI wiring).
|
deferred to future work.
|
||||||
- **Clock abstraction**: Done — `jonboulle/clockwork` injected. E2e test
|
- **FIDO2 testing**: hardware-dependent, may need mocks or CI skip.
|
||||||
uses fake clock for deterministic timestamps.
|
|
||||||
|
|
||||||
## Change Log
|
## Change Log
|
||||||
|
|
||||||
@@ -66,3 +76,38 @@ 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 | 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 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. |
|
||||||
| 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. |
|
| 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. |
|
||||||
|
| 2026-03-23 | — | README, goreleaser config, version command, Nix flake, homebrew formula, release pipeline validated (v0.1.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). |
|
||||||
|
|||||||
231
PROJECT_PLAN.md
231
PROJECT_PLAN.md
@@ -92,9 +92,230 @@ Depends on Step 5.
|
|||||||
- [x] Ensure `go vet ./...` and `go test ./...` pass clean
|
- [x] Ensure `go vet ./...` and `go test ./...` pass clean
|
||||||
- [x] Update CLAUDE.md, ARCHITECTURE.md, PROGRESS.md
|
- [x] Update CLAUDE.md, ARCHITECTURE.md, PROGRESS.md
|
||||||
|
|
||||||
## Future Steps (Not Phase 1)
|
## Phase 2: gRPC Remote Sync
|
||||||
|
|
||||||
- Blob durability (backup/replication strategy)
|
### Step 9: Proto Definitions + Code Gen
|
||||||
- gRPC remote mode (push/pull/serve)
|
|
||||||
- Proto definitions for wire format
|
- [x] Write `proto/sgard/v1/sgard.proto` — 5 RPCs (PushManifest, PushBlobs, PullManifest, PullBlobs, Prune), all messages
|
||||||
- Shell completion via cobra
|
- [x] Add Makefile target for protoc code generation
|
||||||
|
- [x] Add grpc, protobuf, x/crypto deps to go.mod
|
||||||
|
- [x] Update flake.nix devShell with protoc tools
|
||||||
|
- [x] Verify: `go build ./sgardpb` compiles
|
||||||
|
|
||||||
|
### Step 10: Garden Accessor Methods
|
||||||
|
|
||||||
|
*Can be done in parallel with Step 11.*
|
||||||
|
|
||||||
|
- [x] `garden/garden.go`: `GetManifest()`, `BlobExists()`, `ReadBlob()`, `WriteBlob()`, `ReplaceManifest()`
|
||||||
|
- [x] Tests for each accessor
|
||||||
|
- [x] Verify: `go test ./garden/...`
|
||||||
|
|
||||||
|
### Step 11: Proto-Manifest Conversion
|
||||||
|
|
||||||
|
*Can be done in parallel with Step 10.*
|
||||||
|
|
||||||
|
- [x] `server/convert.go`: `ManifestToProto`, `ProtoToManifest`, entry helpers
|
||||||
|
- [x] `server/convert_test.go`: round-trip test
|
||||||
|
- [x] Verify: `go test ./server/...`
|
||||||
|
|
||||||
|
### Step 12: Server Implementation (No Auth)
|
||||||
|
|
||||||
|
Depends on Steps 9, 10, 11.
|
||||||
|
|
||||||
|
- [x] `server/server.go`: Server struct with RWMutex, 5 RPC handlers (+ Prune)
|
||||||
|
- [x] PushManifest: timestamp compare, compute missing blobs
|
||||||
|
- [x] PushBlobs: receive stream, write to store, replace manifest
|
||||||
|
- [x] PullManifest: return manifest
|
||||||
|
- [x] PullBlobs: stream requested blobs (64 KiB chunks)
|
||||||
|
- [x] Prune: remove orphaned blobs (added store.List + garden.ListBlobs/DeleteBlob)
|
||||||
|
- [x] `server/server_test.go`: in-process test with bufconn, push+pull+prune
|
||||||
|
|
||||||
|
### Step 12b: Directory Recursion and Mirror Command
|
||||||
|
|
||||||
|
- [x] `garden/garden.go`: `Add` recurses directories — walk all files/symlinks, add each as its own entry
|
||||||
|
- [x] `garden/mirror.go`: `MirrorUp(paths []string) error` — walk directory, add new files, remove entries for files gone from disk, re-hash changed
|
||||||
|
- [x] `garden/mirror.go`: `MirrorDown(paths []string, force bool, confirm func(string) bool) error` — restore all tracked files under path, delete anything not in manifest
|
||||||
|
- [x] `garden/mirror_test.go`: tests for recursive add, mirror up (detects new/removed), mirror down (cleans extras)
|
||||||
|
- [x] `cmd/sgard/mirror.go`: `sgard mirror up <path>`, `sgard mirror down <path> [--force]`
|
||||||
|
- [x] Update existing add tests for directory recursion
|
||||||
|
|
||||||
|
### Step 13: Client Library (No Auth)
|
||||||
|
|
||||||
|
Depends on Step 12.
|
||||||
|
|
||||||
|
- [x] `client/client.go`: Client struct, `Push()`, `Pull()`, `Prune()` methods
|
||||||
|
- [x] `client/client_test.go`: integration tests (push+pull cycle, server newer, up-to-date, prune)
|
||||||
|
|
||||||
|
### Step 14: SSH Key Auth
|
||||||
|
|
||||||
|
- [x] `server/auth.go`: AuthInterceptor, parse authorized_keys, verify SSH signatures
|
||||||
|
- [x] `client/auth.go`: LoadSigner (ssh-agent or key file), SSHCredentials (PerRPCCredentials)
|
||||||
|
- [x] `server/auth_test.go`: valid key, reject unauthenticated, reject unauthorized key, reject expired timestamp
|
||||||
|
- [x] `client/auth_test.go`: metadata generation, no-transport-security
|
||||||
|
- [x] Integration tests: authenticated push/pull succeeds, unauthenticated is rejected
|
||||||
|
|
||||||
|
### Step 15: CLI Wiring + Prune
|
||||||
|
|
||||||
|
Depends on Steps 13, 14.
|
||||||
|
|
||||||
|
- [x] `garden/prune.go`: `Prune() (int, error)` — collect referenced hashes, delete orphaned blobs
|
||||||
|
- [x] `garden/prune_test.go`: prune removes orphaned, keeps referenced
|
||||||
|
- [x] `server/server.go`: Prune RPC (done in Step 12)
|
||||||
|
- [x] `proto/sgard/v1/sgard.proto`: Prune RPC (done in Step 9)
|
||||||
|
- [x] `client/client.go`: Prune() method (done in Step 13)
|
||||||
|
- [x] `cmd/sgard/prune.go`: local prune; with `--remote` prunes remote instead
|
||||||
|
- [x] `cmd/sgard/main.go`: add `--remote`, `--ssh-key` persistent flags, resolveRemote()
|
||||||
|
- [x] `cmd/sgard/push.go`, `cmd/sgard/pull.go`
|
||||||
|
- [x] `cmd/sgardd/main.go`: flags, garden open, auth interceptor, gRPC serve
|
||||||
|
- [x] Verify: both binaries compile
|
||||||
|
|
||||||
|
### Step 16: Polish + Release
|
||||||
|
|
||||||
|
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
|
||||||
|
- [x] Update flake.nix (add sgardd, updated vendorHash)
|
||||||
|
- [x] Update .goreleaser.yaml (add sgardd build)
|
||||||
|
- [x] E2e integration test: init two repos, push from one, pull into other (with auth)
|
||||||
|
- [x] Verify: all tests pass, full push/pull cycle works
|
||||||
|
|
||||||
|
## Phase 3: Encryption
|
||||||
|
|
||||||
|
### Step 17: Encryption Core (Passphrase Only)
|
||||||
|
|
||||||
|
- [x] `manifest/manifest.go`: add `Encrypted`, `PlaintextHash` fields to Entry; add `Encryption` section with `KekSlots` map to Manifest
|
||||||
|
- [x] `garden/encrypt.go`: `EncryptInit(passphrase string) error` — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section
|
||||||
|
- [x] `garden/encrypt.go`: `UnlockDEK(prompt) error` — read slots, try passphrase, unwrap DEK; cache in memory for command duration
|
||||||
|
- [x] `garden/encrypt.go`: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open)
|
||||||
|
- [x] `garden/garden.go`: modify Add to accept encrypt flag — encrypt blob before storing, set `encrypted: true` and `plaintext_hash` on entry
|
||||||
|
- [x] `garden/garden.go`: modify Checkpoint to re-encrypt changed encrypted entries (compares plaintext_hash)
|
||||||
|
- [x] `garden/garden.go`: modify Restore to decrypt encrypted blobs before writing
|
||||||
|
- [x] `garden/diff.go`: modify Diff to decrypt stored blob before diffing
|
||||||
|
- [x] `garden/garden.go`: modify Status to use `plaintext_hash` for encrypted entries
|
||||||
|
- [x] Tests: 10 encryption tests (init, persist, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK)
|
||||||
|
- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
|
||||||
|
|
||||||
|
### Step 18: FIDO2 Support
|
||||||
|
|
||||||
|
Depends on Step 17.
|
||||||
|
|
||||||
|
- [x] `garden/encrypt_fido2.go`: FIDO2Device interface, AddFIDO2Slot, unlockFIDO2, defaultFIDO2Label
|
||||||
|
- [x] `garden/encrypt.go`: UnlockDEK tries fido2/* slots first (credential_id matching), falls back to passphrase
|
||||||
|
- [x] `garden/encrypt_fido2_test.go`: mock FIDO2 device, 6 tests (add slot, duplicate rejected, unlock via FIDO2, fallback to passphrase, persistence, encrypted round-trip with FIDO2)
|
||||||
|
- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
|
||||||
|
|
||||||
|
### Step 19: Encryption CLI + Slot Management
|
||||||
|
|
||||||
|
Depends on Steps 17, 18.
|
||||||
|
|
||||||
|
- [x] `cmd/sgard/encrypt.go`: `sgard encrypt init [--fido2]`, `add-fido2 [--label]`, `remove-slot`, `list-slots`, `change-passphrase`
|
||||||
|
- [x] `garden/encrypt.go`: `RemoveSlot`, `ListSlots`, `ChangePassphrase` methods
|
||||||
|
- [x] `cmd/sgard/add.go`: add `--encrypt` flag with passphrase prompt
|
||||||
|
- [x] Update proto: add `encrypted`, `plaintext_hash` to ManifestEntry; add KekSlot, Encryption messages, encryption field on Manifest
|
||||||
|
- [x] Update `server/convert.go`: full encryption section conversion (Encryption, KekSlot)
|
||||||
|
- [x] Verify: both binaries compile, `go test ./...`, lint clean
|
||||||
|
|
||||||
|
### Step 20: Encryption Polish + Release
|
||||||
|
|
||||||
|
- [x] E2e test: full encryption lifecycle (init, add encrypted+plaintext, checkpoint, modify, status, restore, verify, diff, slot management, passphrase change)
|
||||||
|
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md
|
||||||
|
- [x] Update flake.nix vendorHash
|
||||||
|
- [x] Verify: all tests pass, lint clean
|
||||||
|
|
||||||
|
## Future Steps (Not Phase 3)
|
||||||
|
|
||||||
|
## Phase 4: Hardening + Completeness
|
||||||
|
|
||||||
|
### Step 21: Lock/Unlock Toggle Commands
|
||||||
|
|
||||||
|
- [x] `garden/lock.go`: `Lock(paths)`, `Unlock(paths)` — toggle locked flag on existing entries
|
||||||
|
- [x] `cmd/sgard/lock.go`: `sgard lock <path>...`, `sgard unlock <path>...`
|
||||||
|
- [x] Tests: lock/unlock existing entry, persist, error on untracked, checkpoint/status behavior changes (6 tests)
|
||||||
|
|
||||||
|
### Step 22: Shell Completion
|
||||||
|
|
||||||
|
- [x] Cobra provides built-in `sgard completion` for bash, zsh, fish, powershell — no code needed
|
||||||
|
- [x] README updated with shell completion installation instructions
|
||||||
|
|
||||||
|
### Step 23: TLS Transport for sgardd
|
||||||
|
|
||||||
|
- [x] `cmd/sgardd/main.go`: add `--tls-cert`, `--tls-key` flags
|
||||||
|
- [x] Server uses `credentials.NewTLS()` when cert/key provided, insecure otherwise
|
||||||
|
- [x] Client: add `--tls` flag and `--tls-ca` for custom CA
|
||||||
|
- [x] Update `cmd/sgard/main.go` and `dialRemote()` for TLS
|
||||||
|
- [x] Tests: TLS connection with self-signed cert (push/pull cycle, reject untrusted client)
|
||||||
|
- [x] Update ARCHITECTURE.md and README.md
|
||||||
|
|
||||||
|
### Step 24: DEK Rotation
|
||||||
|
|
||||||
|
- [x] `garden/encrypt.go`: `RotateDEK(promptPassphrase, fido2Device)` — generate new DEK, re-encrypt all encrypted blobs, re-wrap with all existing KEK slots
|
||||||
|
- [x] `cmd/sgard/encrypt.go`: `sgard encrypt rotate-dek`
|
||||||
|
- [x] Tests: rotate DEK, verify decryption, verify plaintext untouched, FIDO2 re-wrap, requires-unlock (4 tests)
|
||||||
|
|
||||||
|
### Step 25: Real FIDO2 Hardware Binding
|
||||||
|
|
||||||
|
- [x] Evaluate approach: go-libfido2 CGo bindings (keys-pub/go-libfido2 v1.5.3)
|
||||||
|
- [x] `garden/fido2_hardware.go`: HardwareFIDO2 implementing FIDO2Device via libfido2 (`//go:build fido2`)
|
||||||
|
- [x] `garden/fido2_nohardware.go`: stub returning nil (`//go:build !fido2`)
|
||||||
|
- [x] `cmd/sgard/fido2.go`: unlockDEK helper, --fido2-pin flag
|
||||||
|
- [x] `cmd/sgard/encrypt.go`: add-fido2 uses real hardware, encrypt init --fido2 registers slot, all unlock calls use FIDO2-first resolution
|
||||||
|
- [x] `flake.nix`: sgard-fido2 package variant, libfido2+pkg-config in devShell
|
||||||
|
- [x] Tests: existing mock-based tests still pass; hardware tests require manual testing with a FIDO2 key
|
||||||
|
|
||||||
|
### Step 26: Test Cleanup
|
||||||
|
|
||||||
|
- [x] Standardize all test calls — already use `AddOptions{}` struct consistently (no legacy variadic patterns found)
|
||||||
|
- [x] Ensure all tests use `t.TempDir()` consistently (audited, no `os.MkdirTemp`/`ioutil.Temp` usage)
|
||||||
|
- [x] Review lint config — added copyloopvar, durationcheck, makezero, nilerr, bodyclose linters
|
||||||
|
- [x] Verify test coverage — added 3 tests: encrypted+locked, dir-only+locked, lock/unlock toggle on encrypted
|
||||||
|
- [x] Fix stale API signatures in ARCHITECTURE.md (Add, Lock, Unlock, RotateDEK, HasEncryption, NeedsDEK)
|
||||||
|
|
||||||
|
### Step 27: Phase 4 Polish + Release
|
||||||
|
|
||||||
|
- [x] Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md)
|
||||||
|
- [x] Update flake.nix vendorHash (done in Step 25)
|
||||||
|
- [x] .goreleaser.yaml — no changes needed (CGO_ENABLED=0 is correct for release binaries)
|
||||||
|
- [x] E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull
|
||||||
|
- [x] Verify: all tests pass, lint clean, both binaries compile
|
||||||
|
|
||||||
|
## Phase 5: Per-Machine Targeting
|
||||||
|
|
||||||
|
### Step 28: Machine Identity + Targeting Core
|
||||||
|
|
||||||
|
- [x] `manifest/manifest.go`: add `Only []string` and `Never []string` to Entry
|
||||||
|
- [x] `garden/identity.go`: `Identity()` returns machine label set
|
||||||
|
- [x] `garden/targeting.go`: `EntryApplies(entry, labels)` match logic
|
||||||
|
- [x] `garden/tags.go`: `LoadTags`, `SaveTag`, `RemoveTag` for `<repo>/tags`
|
||||||
|
- [x] `garden/garden.go`: `Init` appends `tags` to `.gitignore`
|
||||||
|
- [x] Tests: 13 tests (identity, tags, matching: only, never, both-error, hostname, os, arch, tag, case-insensitive, multiple)
|
||||||
|
|
||||||
|
### Step 29: Operations Respect Targeting
|
||||||
|
|
||||||
|
- [x] `Checkpoint` skips entries where `!EntryApplies`
|
||||||
|
- [x] `Restore` skips entries where `!EntryApplies`
|
||||||
|
- [x] `Status` reports `skipped` for non-matching entries
|
||||||
|
- [x] `Add` accepts `Only`/`Never` in `AddOptions`, propagated through `addEntry`
|
||||||
|
- [x] Tests: 6 tests (checkpoint skip/process, status skipped, restore skip, add with only/never)
|
||||||
|
|
||||||
|
### Step 30: Targeting CLI Commands
|
||||||
|
|
||||||
|
- [x] `cmd/sgard/tag.go`: tag add/remove/list
|
||||||
|
- [x] `cmd/sgard/identity.go`: identity command
|
||||||
|
- [x] `cmd/sgard/add.go`: --only/--never flags
|
||||||
|
- [x] `cmd/sgard/target.go`: target command with --only/--never/--clear
|
||||||
|
- [x] `garden/target.go`: SetTargeting method
|
||||||
|
|
||||||
|
### Step 31: Proto + Sync Update
|
||||||
|
|
||||||
|
- [x] `proto/sgard/v1/sgard.proto`: only/never fields on ManifestEntry
|
||||||
|
- [x] `server/convert.go`: updated conversion
|
||||||
|
- [x] Regenerated proto
|
||||||
|
- [x] Tests: targeting round-trip test
|
||||||
|
|
||||||
|
### Step 32: Phase 5 Polish
|
||||||
|
|
||||||
|
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
|
||||||
|
- [x] E2e test: push/pull with targeting labels, restore respects targeting
|
||||||
|
- [x] Verify: all tests pass, lint clean, both binaries compile
|
||||||
|
|
||||||
|
## 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
|
git clone https://github.com/kisom/sgard && cd sgard
|
||||||
go build -o sgard ./cmd/sgard
|
go build ./cmd/sgard ./cmd/sgardd
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install into `$GOBIN`:
|
Or install into `$GOBIN`:
|
||||||
|
|
||||||
```
|
```
|
||||||
go install github.com/kisom/sgard/cmd/sgard@latest
|
go install github.com/kisom/sgard/cmd/sgard@latest
|
||||||
|
go install github.com/kisom/sgard/cmd/sgardd@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
NixOS (flake):
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
Binaries are also available on the
|
||||||
[releases page](https://github.com/kisom/sgard/releases).
|
[releases page](https://github.com/kisom/sgard/releases).
|
||||||
|
|
||||||
|
### Shell completion
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Bash (add to ~/.bashrc)
|
||||||
|
source <(sgard completion bash)
|
||||||
|
|
||||||
|
# Zsh (add to ~/.zshrc)
|
||||||
|
source <(sgard completion zsh)
|
||||||
|
|
||||||
|
# Fish
|
||||||
|
sgard completion fish | source
|
||||||
|
# To load on startup:
|
||||||
|
sgard completion fish > ~/.config/fish/completions/sgard.fish
|
||||||
|
```
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -58,12 +83,88 @@ sgard add ~/.bashrc --repo /mnt/usb/dotfiles
|
|||||||
sgard restore --repo /mnt/usb/dotfiles
|
sgard restore --repo /mnt/usb/dotfiles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Locked files
|
||||||
|
|
||||||
|
Some files get overwritten by the system (desktop environments,
|
||||||
|
package managers, etc.) but you want to keep them at a known-good
|
||||||
|
state. Locked files are repo-authoritative — `restore` always
|
||||||
|
overwrites them, and `checkpoint` never picks up the system's changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# XDG user-dirs.dirs gets reset by the desktop environment on login
|
||||||
|
sgard add --lock ~/.config/user-dirs.dirs
|
||||||
|
|
||||||
|
# The system overwrites it — status reports "drifted", not "modified"
|
||||||
|
sgard status
|
||||||
|
# drifted ~/.config/user-dirs.dirs
|
||||||
|
|
||||||
|
# Restore puts it back without prompting
|
||||||
|
sgard restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `add` (without `--lock`) when you intentionally want to update the
|
||||||
|
repo with a new version of a locked file.
|
||||||
|
|
||||||
|
### Directory-only entries
|
||||||
|
|
||||||
|
Sometimes a directory must exist for software to work, but its
|
||||||
|
contents are managed elsewhere. `--dir` tracks the directory itself
|
||||||
|
without recursing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Ensure ~/.local/share/applications exists (some apps break without it)
|
||||||
|
sgard add --dir ~/.local/share/applications
|
||||||
|
```
|
||||||
|
|
||||||
|
On `restore`, sgard creates the directory with the correct permissions
|
||||||
|
but doesn't touch its contents.
|
||||||
|
|
||||||
|
### Per-machine targeting
|
||||||
|
|
||||||
|
Some files only apply to certain machines. Use `--only` and `--never`
|
||||||
|
to control where entries are active:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Only restore on Linux
|
||||||
|
sgard add --only os:linux ~/.bashrc.linux
|
||||||
|
|
||||||
|
# Never restore on ARM
|
||||||
|
sgard add --never arch:arm64 ~/.config/heavy-tool
|
||||||
|
|
||||||
|
# Only on machines tagged "work"
|
||||||
|
sgard tag add work
|
||||||
|
sgard add --only tag:work ~/.ssh/work-config
|
||||||
|
|
||||||
|
# Only on a specific host
|
||||||
|
sgard add --only vade ~/.special-config
|
||||||
|
|
||||||
|
# See this machine's identity
|
||||||
|
sgard identity
|
||||||
|
|
||||||
|
# Change targeting on an existing entry
|
||||||
|
sgard target ~/.bashrc.linux --only os:linux,tag:desktop
|
||||||
|
sgard target ~/.bashrc.linux --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Labels: bare string = hostname, `os:linux`/`os:darwin`, `arch:amd64`/`arch:arm64`,
|
||||||
|
`tag:<name>` from local `<repo>/tags` file. `checkpoint`, `restore`, and
|
||||||
|
`status` skip non-matching entries automatically.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
### Local
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `init` | Create a new repository |
|
| `init` | Create a new repository |
|
||||||
| `add <path>...` | Track files, directories, or symlinks |
|
| `add <path>...` | Track files, directories (recursed), or symlinks |
|
||||||
|
| `add --lock <path>...` | Track as locked (repo-authoritative, auto-restores on drift) |
|
||||||
|
| `add --dir <path>` | Track directory itself without recursing into contents |
|
||||||
|
| `add --only <labels>` | Track with per-machine targeting (only on matching) |
|
||||||
|
| `add --never <labels>` | Track with per-machine targeting (never on matching) |
|
||||||
|
| `target <path> --only/--never/--clear` | Set or clear targeting on existing entry |
|
||||||
|
| `tag add/remove/list` | Manage machine-local tags |
|
||||||
|
| `identity` | Show this machine's identity labels |
|
||||||
| `remove <path>...` | Stop tracking files |
|
| `remove <path>...` | Stop tracking files |
|
||||||
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
||||||
| `restore [path...] [-f]` | Restore files to their original locations |
|
| `restore [path...] [-f]` | Restore files to their original locations |
|
||||||
@@ -71,8 +172,115 @@ sgard restore --repo /mnt/usb/dotfiles
|
|||||||
| `diff <path>` | Show content diff between stored and current file |
|
| `diff <path>` | Show content diff between stored and current file |
|
||||||
| `list` | List all tracked files |
|
| `list` | List all tracked files |
|
||||||
| `verify` | Check blob store integrity against manifest hashes |
|
| `verify` | Check blob store integrity against manifest hashes |
|
||||||
|
| `prune` | Remove orphaned blobs not referenced by the manifest |
|
||||||
|
| `mirror up <path>` | Sync filesystem → manifest (add new, remove deleted) |
|
||||||
|
| `mirror down <path> [-f]` | Sync manifest → filesystem (restore + delete untracked) |
|
||||||
| `version` | Print the version |
|
| `version` | Print the version |
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `encrypt init` | Set up encryption (creates DEK + passphrase slot) |
|
||||||
|
| `encrypt add-fido2 [--label]` | Add a FIDO2 KEK slot |
|
||||||
|
| `encrypt remove-slot <name>` | Remove a KEK slot |
|
||||||
|
| `encrypt list-slots` | List all KEK slots |
|
||||||
|
| `encrypt change-passphrase` | Change the passphrase |
|
||||||
|
| `encrypt rotate-dek` | Generate new DEK and re-encrypt all encrypted blobs |
|
||||||
|
| `add --encrypt <path>...` | Track files with encryption |
|
||||||
|
|
||||||
|
### Remote sync
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `push` | Push checkpoint to remote gRPC server |
|
||||||
|
| `pull` | Pull checkpoint from remote gRPC server |
|
||||||
|
| `prune` | With `--remote`, prunes orphaned blobs on the server |
|
||||||
|
|
||||||
|
Remote commands require `--remote host:port` (or `SGARD_REMOTE` env, or a
|
||||||
|
`<repo>/remote` config file) and authenticate via SSH keys.
|
||||||
|
|
||||||
|
The server daemon `sgardd` is a separate binary (included in releases and
|
||||||
|
Nix builds).
|
||||||
|
|
||||||
|
## Remote sync
|
||||||
|
|
||||||
|
Start the daemon on your server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sgard init --repo /srv/sgard
|
||||||
|
sgardd --authorized-keys ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Push and pull from client machines:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sgard push --remote myserver:9473
|
||||||
|
sgard pull --remote myserver:9473
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication uses your existing SSH keys (ssh-agent, `~/.ssh/id_ed25519`,
|
||||||
|
or `--ssh-key`). No passwords or certificates to manage.
|
||||||
|
|
||||||
|
### TLS
|
||||||
|
|
||||||
|
To encrypt the connection with TLS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Server: provide cert and key
|
||||||
|
sgardd --tls-cert server.crt --tls-key server.key --authorized-keys ~/.ssh/authorized_keys
|
||||||
|
|
||||||
|
# Client: enable TLS (uses system CA pool)
|
||||||
|
sgard push --remote myserver:9473 --tls
|
||||||
|
|
||||||
|
# Client: with a custom/self-signed CA
|
||||||
|
sgard push --remote myserver:9473 --tls --tls-ca ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `--tls-cert`/`--tls-key`, sgardd runs without TLS (suitable for
|
||||||
|
localhost or trusted networks).
|
||||||
|
|
||||||
|
## Encryption
|
||||||
|
|
||||||
|
Sensitive files can be encrypted individually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Set up encryption (once per repo)
|
||||||
|
sgard encrypt init
|
||||||
|
|
||||||
|
# Add encrypted files
|
||||||
|
sgard add --encrypt ~/.ssh/config ~/.aws/credentials
|
||||||
|
|
||||||
|
# Plaintext files work as before
|
||||||
|
sgard add ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
Encrypted blobs use XChaCha20-Poly1305. The data encryption key (DEK)
|
||||||
|
is wrapped by a passphrase-derived key (Argon2id). FIDO2 hardware keys
|
||||||
|
are also supported as an alternative KEK source — sgard tries FIDO2
|
||||||
|
first and falls back to passphrase automatically.
|
||||||
|
|
||||||
|
### FIDO2 hardware keys
|
||||||
|
|
||||||
|
Build with `-tags fido2` (requires libfido2) to enable real hardware
|
||||||
|
key support, or use `nix build .#sgard-fido2`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Register a FIDO2 key (touch required)
|
||||||
|
sgard encrypt add-fido2
|
||||||
|
|
||||||
|
# With a PIN-protected device
|
||||||
|
sgard encrypt add-fido2 --fido2-pin 1234
|
||||||
|
|
||||||
|
# Unlock is automatic — FIDO2 is tried first, passphrase as fallback
|
||||||
|
sgard restore # touch your key when prompted
|
||||||
|
```
|
||||||
|
|
||||||
|
The encryption config (wrapped DEKs, salts) lives in the manifest, so
|
||||||
|
it syncs with push/pull. The server never has the DEK.
|
||||||
|
|
||||||
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full encryption design.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
sgard stores files in a content-addressable blob store keyed by SHA-256.
|
sgard stores files in a content-addressable blob store keyed by SHA-256.
|
||||||
@@ -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).
|
Otherwise, sgard asks for confirmation (`--force` skips the prompt).
|
||||||
|
|
||||||
Paths under `$HOME` are stored as `~/...` in the manifest, making it
|
Paths under `$HOME` are stored as `~/...` in the manifest, making it
|
||||||
portable across machines with different usernames.
|
portable across machines with different usernames. Adding a directory
|
||||||
|
recursively tracks all files and symlinks inside.
|
||||||
|
|
||||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for full design details.
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for full design details.
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
303
client/client.go
Normal file
303
client/client.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
// 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/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 is newer or equal, nothing to do.
|
||||||
|
if !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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/kisom/sgard/garden"
|
"github.com/kisom/sgard/garden"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
encryptFlag bool
|
||||||
|
lockFlag bool
|
||||||
|
dirOnlyFlag bool
|
||||||
|
onlyFlag []string
|
||||||
|
neverFlag []string
|
||||||
)
|
)
|
||||||
|
|
||||||
var addCmd = &cobra.Command{
|
var addCmd = &cobra.Command{
|
||||||
@@ -17,7 +27,28 @@ var addCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add(args); err != nil {
|
if encryptFlag {
|
||||||
|
if !g.HasEncryption() {
|
||||||
|
return fmt.Errorf("encryption not initialized; run sgard encrypt init first")
|
||||||
|
}
|
||||||
|
if err := unlockDEK(g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(onlyFlag) > 0 && len(neverFlag) > 0 {
|
||||||
|
return fmt.Errorf("--only and --never are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := garden.AddOptions{
|
||||||
|
Encrypt: encryptFlag,
|
||||||
|
Lock: lockFlag,
|
||||||
|
DirOnly: dirOnlyFlag,
|
||||||
|
Only: onlyFlag,
|
||||||
|
Never: neverFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add(args, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +57,25 @@ var addCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptPassphrase() (string, error) {
|
||||||
|
fmt.Fprint(os.Stderr, "Passphrase: ")
|
||||||
|
fd := int(os.Stdin.Fd())
|
||||||
|
passphrase, err := term.ReadPassword(fd)
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading passphrase: %w", err)
|
||||||
|
}
|
||||||
|
if len(passphrase) == 0 {
|
||||||
|
return "", fmt.Errorf("no passphrase provided")
|
||||||
|
}
|
||||||
|
return string(passphrase), nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
addCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "encrypt file contents before storing")
|
||||||
|
addCmd.Flags().BoolVar(&lockFlag, "lock", false, "mark as locked (repo-authoritative, restore always overwrites)")
|
||||||
|
addCmd.Flags().BoolVar(&dirOnlyFlag, "dir", false, "track directory itself without recursing into contents")
|
||||||
|
addCmd.Flags().StringSliceVar(&onlyFlag, "only", nil, "only apply on machines matching these labels (comma-separated)")
|
||||||
|
addCmd.Flags().StringSliceVar(&neverFlag, "never", nil, "never apply on machines matching these labels (comma-separated)")
|
||||||
rootCmd.AddCommand(addCmd)
|
rootCmd.AddCommand(addCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ var checkpointCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if g.HasEncryption() && g.NeedsDEK(g.List()) {
|
||||||
|
if err := unlockDEK(g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := g.Checkpoint(checkpointMessage); err != nil {
|
if err := g.Checkpoint(checkpointMessage); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ var diffCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if g.HasEncryption() && g.NeedsDEK(g.List()) {
|
||||||
|
if err := unlockDEK(g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
d, err := g.Diff(args[0])
|
d, err := g.Diff(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
214
cmd/sgard/encrypt.go
Normal file
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)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kisom/sgard/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
|
|
||||||
var repoFlag string
|
var (
|
||||||
|
repoFlag string
|
||||||
|
remoteFlag string
|
||||||
|
sshKeyFlag string
|
||||||
|
tlsFlag bool
|
||||||
|
tlsCAFlag string
|
||||||
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "sgard",
|
Use: "sgard",
|
||||||
@@ -23,8 +37,107 @@ func defaultRepo() string {
|
|||||||
return filepath.Join(home, ".sgard")
|
return filepath.Join(home, ".sgard")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveRemoteConfig returns the effective remote address, TLS flag, and CA
|
||||||
|
// path by merging CLI flags, environment, and the saved remote.yaml config.
|
||||||
|
// CLI flags take precedence, then env, then the saved config.
|
||||||
|
func resolveRemoteConfig() (addr string, useTLS bool, caPath string, err error) {
|
||||||
|
// Start with saved config as baseline.
|
||||||
|
saved, _ := loadRemoteConfig()
|
||||||
|
|
||||||
|
// Address: flag > env > saved > legacy file.
|
||||||
|
addr = remoteFlag
|
||||||
|
if addr == "" {
|
||||||
|
addr = os.Getenv("SGARD_REMOTE")
|
||||||
|
}
|
||||||
|
if addr == "" && saved != nil {
|
||||||
|
addr = saved.Addr
|
||||||
|
}
|
||||||
|
if addr == "" {
|
||||||
|
data, ferr := os.ReadFile(filepath.Join(repoFlag, "remote"))
|
||||||
|
if ferr == nil {
|
||||||
|
addr = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addr == "" {
|
||||||
|
return "", false, "", fmt.Errorf("no remote configured; use 'sgard remote set' or --remote")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS: flag wins if explicitly set, otherwise use saved.
|
||||||
|
useTLS = tlsFlag
|
||||||
|
if !useTLS && saved != nil {
|
||||||
|
useTLS = saved.TLS
|
||||||
|
}
|
||||||
|
|
||||||
|
// CA: flag wins if set, otherwise use saved.
|
||||||
|
caPath = tlsCAFlag
|
||||||
|
if caPath == "" && saved != nil {
|
||||||
|
caPath = saved.TLSCA
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr, useTLS, caPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialRemote creates a gRPC client with token-based auth and auto-renewal.
|
||||||
|
func dialRemote(ctx context.Context) (*client.Client, func(), error) {
|
||||||
|
addr, useTLS, caPath, err := resolveRemoteConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := client.LoadSigner(sshKeyFlag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedToken := client.LoadCachedToken()
|
||||||
|
creds := client.NewTokenCredentials(cachedToken)
|
||||||
|
|
||||||
|
var transportCreds grpc.DialOption
|
||||||
|
if useTLS {
|
||||||
|
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||||
|
if caPath != "" {
|
||||||
|
caPEM, err := os.ReadFile(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("reading CA cert: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(caPEM) {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse CA cert %s", caPath)
|
||||||
|
}
|
||||||
|
tlsCfg.RootCAs = pool
|
||||||
|
}
|
||||||
|
transportCreds = grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))
|
||||||
|
} else {
|
||||||
|
transportCreds = grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(addr,
|
||||||
|
transportCreds,
|
||||||
|
grpc.WithPerRPCCredentials(creds),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("connecting to %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := client.NewWithAuth(conn, creds, signer)
|
||||||
|
|
||||||
|
// Ensure we have a valid token before proceeding.
|
||||||
|
if err := c.EnsureAuth(ctx); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, nil, fmt.Errorf("authentication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { _ = conn.Close() }
|
||||||
|
return c, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository")
|
rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "gRPC server address (host:port)")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key")
|
||||||
|
rootCmd.PersistentFlags().BoolVar(&tlsFlag, "tls", false, "use TLS for remote connection")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&tlsCAFlag, "tls-ca", "", "path to CA certificate for TLS verification")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&fido2PinFlag, "fido2-pin", "", "PIN for FIDO2 device (if PIN-protected)")
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
|||||||
72
cmd/sgard/mirror.go
Normal file
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)
|
||||||
|
}
|
||||||
44
cmd/sgard/pull.go
Normal file
44
cmd/sgard/pull.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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",
|
||||||
|
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()
|
||||||
|
|
||||||
|
pulled, err := c.Pull(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pulled == 0 {
|
||||||
|
fmt.Println("Already up to date.")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Pulled %d blob(s).\n", pulled)
|
||||||
|
}
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if g.HasEncryption() && g.NeedsDEK(g.List()) {
|
||||||
|
if err := unlockDEK(g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
confirm := func(path string) bool {
|
confirm := func(path string) bool {
|
||||||
fmt.Printf("Overwrite %s? [y/N] ", path)
|
fmt.Printf("Overwrite %s? [y/N] ", path)
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|||||||
76
cmd/sgard/tag.go
Normal file
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
deploy/docker/Dockerfile
Normal file
30
deploy/docker/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /sgardd ./cmd/sgardd
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata \
|
||||||
|
&& adduser -D -h /srv/sgard sgard
|
||||||
|
|
||||||
|
COPY --from=builder /sgardd /usr/local/bin/sgardd
|
||||||
|
|
||||||
|
VOLUME /srv/sgard
|
||||||
|
EXPOSE 9473
|
||||||
|
|
||||||
|
USER sgard
|
||||||
|
|
||||||
|
ENTRYPOINT ["sgardd", \
|
||||||
|
"--repo", "/srv/sgard", \
|
||||||
|
"--authorized-keys", "/srv/sgard/authorized_keys", \
|
||||||
|
"--tls-cert", "/srv/sgard/certs/sgard.pem", \
|
||||||
|
"--tls-key", "/srv/sgard/certs/sgard.key"]
|
||||||
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
|
||||||
31
flake.nix
31
flake.nix
@@ -15,11 +15,11 @@
|
|||||||
packages = {
|
packages = {
|
||||||
sgard = pkgs.buildGoModule {
|
sgard = pkgs.buildGoModule {
|
||||||
pname = "sgard";
|
pname = "sgard";
|
||||||
version = "0.1.0";
|
version = "2.1.0";
|
||||||
src = pkgs.lib.cleanSource ./.;
|
src = pkgs.lib.cleanSource ./.;
|
||||||
subPackages = [ "cmd/sgard" ];
|
subPackages = [ "cmd/sgard" "cmd/sgardd" ];
|
||||||
|
|
||||||
vendorHash = "sha256-uJMkp08SqZaZ6d64Li4Tx8I9OYjaErLexBrJaf6Vb60=";
|
vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY=";
|
||||||
|
|
||||||
ldflags = [ "-s" "-w" ];
|
ldflags = [ "-s" "-w" ];
|
||||||
|
|
||||||
@@ -29,6 +29,26 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sgard-fido2 = pkgs.buildGoModule {
|
||||||
|
pname = "sgard-fido2";
|
||||||
|
version = "2.1.0";
|
||||||
|
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" ];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Shimmering Clarity Gardener: dotfile management (with FIDO2 hardware support)";
|
||||||
|
mainProgram = "sgard";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
default = self.packages.${system}.sgard;
|
default = self.packages.${system}.sgard;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +56,11 @@
|
|||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
go
|
go
|
||||||
golangci-lint
|
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)
|
return "", fmt.Errorf("reading stored blob: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if entry.Encrypted {
|
||||||
|
if g.dek == nil {
|
||||||
|
return "", fmt.Errorf("DEK not unlocked; cannot diff encrypted file %s", tilded)
|
||||||
|
}
|
||||||
|
stored, err = g.decryptBlob(stored)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypting stored blob: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
current, err := os.ReadFile(abs)
|
current, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("reading current file: %w", err)
|
return "", fmt.Errorf("reading current file: %w", err)
|
||||||
|
|||||||
449
garden/encrypt.go
Normal file
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
310
garden/garden.go
310
garden/garden.go
@@ -22,6 +22,7 @@ type Garden struct {
|
|||||||
root string // repository root directory
|
root string // repository root directory
|
||||||
manifestPath string // path to manifest.yaml
|
manifestPath string // path to manifest.yaml
|
||||||
clock clockwork.Clock
|
clock clockwork.Clock
|
||||||
|
dek []byte // unlocked data encryption key (nil if not unlocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init creates a new sgard repository at root. It creates the directory
|
// Init creates a new sgard repository at root. It creates the directory
|
||||||
@@ -47,7 +48,7 @@ func Init(root string) (*Garden, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gitignorePath := filepath.Join(absRoot, ".gitignore")
|
gitignorePath := filepath.Join(absRoot, ".gitignore")
|
||||||
if err := os.WriteFile(gitignorePath, []byte("blobs/\n"), 0o644); err != nil {
|
if err := os.WriteFile(gitignorePath, []byte("blobs/\ntags\n"), 0o644); err != nil {
|
||||||
return nil, fmt.Errorf("creating .gitignore: %w", err)
|
return nil, fmt.Errorf("creating .gitignore: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,10 +99,128 @@ func (g *Garden) SetClock(c clockwork.Clock) {
|
|||||||
g.clock = c
|
g.clock = c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetManifest returns the current manifest.
|
||||||
|
func (g *Garden) GetManifest() *manifest.Manifest {
|
||||||
|
return g.manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobExists reports whether a blob with the given hash exists in the store.
|
||||||
|
func (g *Garden) BlobExists(hash string) bool {
|
||||||
|
return g.store.Exists(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBlob returns the contents of the blob with the given hash.
|
||||||
|
func (g *Garden) ReadBlob(hash string) ([]byte, error) {
|
||||||
|
return g.store.Read(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBlob writes data to the blob store and returns the hash.
|
||||||
|
func (g *Garden) WriteBlob(data []byte) (string, error) {
|
||||||
|
return g.store.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceManifest atomically replaces the current manifest.
|
||||||
|
func (g *Garden) ReplaceManifest(m *manifest.Manifest) error {
|
||||||
|
if err := m.Save(g.manifestPath); err != nil {
|
||||||
|
return fmt.Errorf("saving manifest: %w", err)
|
||||||
|
}
|
||||||
|
g.manifest = m
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBlobs returns all blob hashes in the store.
|
||||||
|
func (g *Garden) ListBlobs() ([]string, error) {
|
||||||
|
return g.store.List()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBlob removes a blob from the store by hash.
|
||||||
|
func (g *Garden) DeleteBlob(hash string) error {
|
||||||
|
return g.store.Delete(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addEntry adds a single file or symlink to the manifest. If skipDup is true,
|
||||||
|
// already-tracked paths are silently skipped.
|
||||||
|
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool, o AddOptions) error {
|
||||||
|
tilded := toTildePath(abs)
|
||||||
|
|
||||||
|
if g.findEntry(tilded) != nil {
|
||||||
|
if skipDup {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("already tracking %s", tilded)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := manifest.Entry{
|
||||||
|
Path: tilded,
|
||||||
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||||
|
Locked: o.Lock,
|
||||||
|
Only: o.Only,
|
||||||
|
Never: o.Never,
|
||||||
|
Updated: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case info.Mode()&os.ModeSymlink != 0:
|
||||||
|
target, err := os.Readlink(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading symlink %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
entry.Type = "link"
|
||||||
|
entry.Target = target
|
||||||
|
|
||||||
|
default:
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading file %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.Encrypt {
|
||||||
|
if g.dek == nil {
|
||||||
|
return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs)
|
||||||
|
}
|
||||||
|
entry.PlaintextHash = plaintextHash(data)
|
||||||
|
ct, err := g.encryptBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
data = ct
|
||||||
|
entry.Encrypted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := g.store.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
entry.Type = "file"
|
||||||
|
entry.Hash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
g.manifest.Files = append(g.manifest.Files, entry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOptions controls the behavior of Add.
|
||||||
|
type AddOptions struct {
|
||||||
|
Encrypt bool // encrypt file blobs before storing
|
||||||
|
Lock bool // mark entries as locked (repo-authoritative)
|
||||||
|
DirOnly bool // for directories: track the directory itself, don't recurse
|
||||||
|
Only []string // per-machine targeting: only apply on matching machines
|
||||||
|
Never []string // per-machine targeting: never apply on matching machines
|
||||||
|
}
|
||||||
|
|
||||||
// Add tracks new files, directories, or symlinks. Each path is resolved
|
// Add tracks new files, directories, or symlinks. Each path is resolved
|
||||||
// to an absolute path, inspected for its type, and added to the manifest.
|
// to an absolute path, inspected for its type, and added to the manifest.
|
||||||
// Regular files are hashed and stored in the blob store.
|
// Regular files are hashed and stored in the blob store. Directories are
|
||||||
func (g *Garden) Add(paths []string) error {
|
// recursively walked unless opts.DirOnly is set.
|
||||||
|
func (g *Garden) Add(paths []string, opts ...AddOptions) error {
|
||||||
|
var o AddOptions
|
||||||
|
if len(opts) > 0 {
|
||||||
|
o = opts[0]
|
||||||
|
}
|
||||||
|
if o.Encrypt && g.dek == nil {
|
||||||
|
return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first")
|
||||||
|
}
|
||||||
|
|
||||||
now := g.clock.Now().UTC()
|
now := g.clock.Now().UTC()
|
||||||
|
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
@@ -115,45 +234,46 @@ func (g *Garden) Add(paths []string) error {
|
|||||||
return fmt.Errorf("stat %s: %w", abs, err)
|
return fmt.Errorf("stat %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tilded := toTildePath(abs)
|
if info.IsDir() {
|
||||||
|
if o.DirOnly {
|
||||||
// Check if already tracked.
|
// Track the directory itself as a structural entry.
|
||||||
if g.findEntry(tilded) != nil {
|
tilded := toTildePath(abs)
|
||||||
return fmt.Errorf("already tracking %s", tilded)
|
if g.findEntry(tilded) != nil {
|
||||||
}
|
return fmt.Errorf("already tracking %s", tilded)
|
||||||
|
}
|
||||||
entry := manifest.Entry{
|
entry := manifest.Entry{
|
||||||
Path: tilded,
|
Path: tilded,
|
||||||
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
Type: "directory",
|
||||||
Updated: now,
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||||
}
|
Locked: o.Lock,
|
||||||
|
Only: o.Only,
|
||||||
switch {
|
Never: o.Never,
|
||||||
case info.Mode()&os.ModeSymlink != 0:
|
Updated: now,
|
||||||
target, err := os.Readlink(abs)
|
}
|
||||||
if err != nil {
|
g.manifest.Files = append(g.manifest.Files, entry)
|
||||||
return fmt.Errorf("reading symlink %s: %w", abs, err)
|
} else {
|
||||||
|
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fi, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return g.addEntry(path, fi, now, true, o)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entry.Type = "link"
|
} else {
|
||||||
entry.Target = target
|
if err := g.addEntry(abs, info, now, false, o); err != nil {
|
||||||
|
return err
|
||||||
case info.IsDir():
|
|
||||||
entry.Type = "directory"
|
|
||||||
|
|
||||||
default:
|
|
||||||
data, err := os.ReadFile(abs)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading file %s: %w", abs, err)
|
|
||||||
}
|
}
|
||||||
hash, err := g.store.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
|
||||||
}
|
|
||||||
entry.Type = "file"
|
|
||||||
entry.Hash = hash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
g.manifest.Files = append(g.manifest.Files, entry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
g.manifest.Updated = now
|
g.manifest.Updated = now
|
||||||
@@ -175,10 +295,19 @@ type FileStatus struct {
|
|||||||
// the manifest.
|
// the manifest.
|
||||||
func (g *Garden) Checkpoint(message string) error {
|
func (g *Garden) Checkpoint(message string) error {
|
||||||
now := g.clock.Now().UTC()
|
now := g.clock.Now().UTC()
|
||||||
|
labels := g.Identity()
|
||||||
|
|
||||||
for i := range g.manifest.Files {
|
for i := range g.manifest.Files {
|
||||||
entry := &g.manifest.Files[i]
|
entry := &g.manifest.Files[i]
|
||||||
|
|
||||||
|
applies, err := EntryApplies(entry, labels)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
abs, err := ExpandTildePath(entry.Path)
|
abs, err := ExpandTildePath(entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
@@ -193,19 +322,46 @@ func (g *Garden) Checkpoint(message string) error {
|
|||||||
|
|
||||||
entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
|
entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
|
||||||
|
|
||||||
|
// Locked entries are repo-authoritative — checkpoint skips them.
|
||||||
|
if entry.Locked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
switch entry.Type {
|
switch entry.Type {
|
||||||
case "file":
|
case "file":
|
||||||
data, err := os.ReadFile(abs)
|
data, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading %s: %w", abs, err)
|
return fmt.Errorf("reading %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
hash, err := g.store.Write(data)
|
|
||||||
if err != nil {
|
if entry.Encrypted {
|
||||||
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
// For encrypted entries, check plaintext hash to detect changes.
|
||||||
}
|
ptHash := plaintextHash(data)
|
||||||
if hash != entry.Hash {
|
if ptHash != entry.PlaintextHash {
|
||||||
entry.Hash = hash
|
if g.dek == nil {
|
||||||
entry.Updated = now
|
return fmt.Errorf("DEK not unlocked; cannot re-encrypt %s", abs)
|
||||||
|
}
|
||||||
|
ct, err := g.encryptBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
hash, err := g.store.Write(ct)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
entry.Hash = hash
|
||||||
|
entry.PlaintextHash = ptHash
|
||||||
|
entry.Updated = now
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hash, err := g.store.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
if hash != entry.Hash {
|
||||||
|
entry.Hash = hash
|
||||||
|
entry.Updated = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
@@ -236,10 +392,20 @@ func (g *Garden) Checkpoint(message string) error {
|
|||||||
// and returns a status for each.
|
// and returns a status for each.
|
||||||
func (g *Garden) Status() ([]FileStatus, error) {
|
func (g *Garden) Status() ([]FileStatus, error) {
|
||||||
var results []FileStatus
|
var results []FileStatus
|
||||||
|
labels := g.Identity()
|
||||||
|
|
||||||
for i := range g.manifest.Files {
|
for i := range g.manifest.Files {
|
||||||
entry := &g.manifest.Files[i]
|
entry := &g.manifest.Files[i]
|
||||||
|
|
||||||
|
applies, err := EntryApplies(entry, labels)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
results = append(results, FileStatus{Path: entry.Path, State: "skipped"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
abs, err := ExpandTildePath(entry.Path)
|
abs, err := ExpandTildePath(entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
@@ -260,8 +426,17 @@ func (g *Garden) Status() ([]FileStatus, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hashing %s: %w", abs, err)
|
return nil, fmt.Errorf("hashing %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
if hash != entry.Hash {
|
// For encrypted entries, compare against plaintext hash.
|
||||||
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
compareHash := entry.Hash
|
||||||
|
if entry.Encrypted && entry.PlaintextHash != "" {
|
||||||
|
compareHash = entry.PlaintextHash
|
||||||
|
}
|
||||||
|
if hash != compareHash {
|
||||||
|
if entry.Locked {
|
||||||
|
results = append(results, FileStatus{Path: entry.Path, State: "drifted"})
|
||||||
|
} else {
|
||||||
|
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
||||||
}
|
}
|
||||||
@@ -299,20 +474,39 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
labels := g.Identity()
|
||||||
|
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
entry := &entries[i]
|
entry := &entries[i]
|
||||||
|
|
||||||
|
applies, err := EntryApplies(entry, labels)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
abs, err := ExpandTildePath(entry.Path)
|
abs, err := ExpandTildePath(entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file exists and whether we need confirmation.
|
// Locked entries always restore if content differs — no prompt.
|
||||||
if !force {
|
if entry.Locked && entry.Type == "file" {
|
||||||
|
if currentHash, err := HashFile(abs); err == nil {
|
||||||
|
compareHash := entry.Hash
|
||||||
|
if entry.Encrypted && entry.PlaintextHash != "" {
|
||||||
|
compareHash = entry.PlaintextHash
|
||||||
|
}
|
||||||
|
if currentHash == compareHash {
|
||||||
|
continue // already matches, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// File is missing or hash differs — proceed to restore.
|
||||||
|
} else if !force {
|
||||||
|
// Normal entries: check timestamp for confirmation.
|
||||||
if info, err := os.Lstat(abs); err == nil {
|
if info, err := os.Lstat(abs); err == nil {
|
||||||
// File exists. If on-disk mtime >= manifest updated, ask.
|
|
||||||
// Truncate to seconds because filesystem mtime granularity
|
|
||||||
// varies across platforms.
|
|
||||||
diskTime := info.ModTime().Truncate(time.Second)
|
diskTime := info.ModTime().Truncate(time.Second)
|
||||||
entryTime := entry.Updated.Truncate(time.Second)
|
entryTime := entry.Updated.Truncate(time.Second)
|
||||||
if !diskTime.Before(entryTime) {
|
if !diskTime.Before(entryTime) {
|
||||||
@@ -360,6 +554,16 @@ func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error {
|
|||||||
return fmt.Errorf("reading blob for %s: %w", entry.Path, err)
|
return fmt.Errorf("reading blob for %s: %w", entry.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if entry.Encrypted {
|
||||||
|
if g.dek == nil {
|
||||||
|
return fmt.Errorf("DEK not unlocked; cannot decrypt %s", entry.Path)
|
||||||
|
}
|
||||||
|
data, err = g.decryptBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mode, err := parseMode(entry.Mode)
|
mode, err := parseMode(entry.Mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
|
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package garden
|
package garden
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kisom/sgard/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInitCreatesStructure(t *testing.T) {
|
func TestInitCreatesStructure(t *testing.T) {
|
||||||
@@ -29,8 +33,8 @@ func TestInitCreatesStructure(t *testing.T) {
|
|||||||
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
|
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf(".gitignore not found: %v", err)
|
t.Errorf(".gitignore not found: %v", err)
|
||||||
} else if string(gitignore) != "blobs/\n" {
|
} else if string(gitignore) != "blobs/\ntags\n" {
|
||||||
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n")
|
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\ntags\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.manifest.Version != 1 {
|
if g.manifest.Version != 1 {
|
||||||
@@ -135,17 +139,29 @@ func TestAddDirectory(t *testing.T) {
|
|||||||
if err := os.Mkdir(testDir, 0o755); err != nil {
|
if err := os.Mkdir(testDir, 0o755); err != nil {
|
||||||
t.Fatalf("creating test dir: %v", err)
|
t.Fatalf("creating test dir: %v", err)
|
||||||
}
|
}
|
||||||
|
testFile := filepath.Join(testDir, "inside.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("inside"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing file inside dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := g.Add([]string{testDir}); err != nil {
|
if err := g.Add([]string{testDir}); err != nil {
|
||||||
t.Fatalf("Add: %v", err)
|
t.Fatalf("Add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := g.manifest.Files[0]
|
if len(g.manifest.Files) != 1 {
|
||||||
if entry.Type != "directory" {
|
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||||
t.Errorf("expected type directory, got %s", entry.Type)
|
|
||||||
}
|
}
|
||||||
if entry.Hash != "" {
|
|
||||||
t.Error("directories should have no hash")
|
entry := g.manifest.Files[0]
|
||||||
|
if entry.Type != "file" {
|
||||||
|
t.Errorf("expected type file, got %s", entry.Type)
|
||||||
|
}
|
||||||
|
if entry.Hash == "" {
|
||||||
|
t.Error("expected non-empty hash")
|
||||||
|
}
|
||||||
|
expectedPath := toTildePath(testFile)
|
||||||
|
if entry.Path != expectedPath {
|
||||||
|
t.Errorf("expected path %s, got %s", expectedPath, entry.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +635,161 @@ func TestRestoreConfirmSkips(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetManifest(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := g.GetManifest()
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("GetManifest returned nil")
|
||||||
|
}
|
||||||
|
if len(m.Files) != 1 {
|
||||||
|
t.Errorf("expected 1 entry, got %d", len(m.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobExists(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("blob exists test"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := g.GetManifest().Files[0].Hash
|
||||||
|
if !g.BlobExists(hash) {
|
||||||
|
t.Error("BlobExists returned false for a stored blob")
|
||||||
|
}
|
||||||
|
if g.BlobExists("0000000000000000000000000000000000000000000000000000000000000000") {
|
||||||
|
t.Error("BlobExists returned true for a fake hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadBlob(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := []byte("read blob test content")
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, content, 0o644); err != nil {
|
||||||
|
t.Fatalf("writing test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := g.GetManifest().Files[0].Hash
|
||||||
|
got, err := g.ReadBlob(hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadBlob: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(content) {
|
||||||
|
t.Errorf("ReadBlob content = %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBlob(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte("write blob test data")
|
||||||
|
hash, err := g.WriteBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteBlob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the hash is correct SHA-256.
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
wantHash := hex.EncodeToString(sum[:])
|
||||||
|
if hash != wantHash {
|
||||||
|
t.Errorf("WriteBlob hash = %s, want %s", hash, wantHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !g.BlobExists(hash) {
|
||||||
|
t.Error("BlobExists returned false after WriteBlob")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceManifest(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new manifest with a custom entry.
|
||||||
|
newManifest := manifest.New()
|
||||||
|
newManifest.Files = append(newManifest.Files, manifest.Entry{
|
||||||
|
Path: "~/replaced-file",
|
||||||
|
Type: "file",
|
||||||
|
Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
Mode: "0644",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := g.ReplaceManifest(newManifest); err != nil {
|
||||||
|
t.Fatalf("ReplaceManifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify in-memory manifest was updated.
|
||||||
|
m := g.GetManifest()
|
||||||
|
if len(m.Files) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(m.Files))
|
||||||
|
}
|
||||||
|
if m.Files[0].Path != "~/replaced-file" {
|
||||||
|
t.Errorf("expected path ~/replaced-file, got %s", m.Files[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify persistence by re-opening.
|
||||||
|
g2, err := Open(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-Open: %v", err)
|
||||||
|
}
|
||||||
|
m2 := g2.GetManifest()
|
||||||
|
if len(m2.Files) != 1 {
|
||||||
|
t.Fatalf("persisted manifest has %d entries, want 1", len(m2.Files))
|
||||||
|
}
|
||||||
|
if m2.Files[0].Path != "~/replaced-file" {
|
||||||
|
t.Errorf("persisted entry path = %s, want ~/replaced-file", m2.Files[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExpandTildePath(t *testing.T) {
|
func TestExpandTildePath(t *testing.T) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
37
garden/identity.go
Normal file
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
187
garden/mirror.go
Normal file
187
garden/mirror.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
// Collect directories for potential cleanup (post-order).
|
||||||
|
if path != abs {
|
||||||
|
emptyDirs = append(emptyDirs, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if tracked[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
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/keys-pub/go-libfido2 v1.5.3
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/cobra v1.10.2
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
golang.org/x/crypto v0.49.0
|
||||||
|
golang.org/x/term v0.41.0
|
||||||
|
google.golang.org/grpc v1.79.3
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
59
go.sum
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
|
github.com/keys-pub/go-libfido2 v1.5.3 h1:vtgHxlSB43u6lj0TSuA3VvT6z3E7VI+L1a2hvMFdECk=
|
||||||
|
github.com/keys-pub/go-libfido2 v1.5.3/go.mod h1:P0V19qHwJNY0htZwZDe9Ilvs/nokGhdFX7faKFyZ6+U=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
|
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
265
integration/phase4_test.go
Normal file
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.
|
||||||
|
}
|
||||||
@@ -11,21 +11,44 @@ import (
|
|||||||
|
|
||||||
// Entry represents a single tracked file, directory, or symlink.
|
// Entry represents a single tracked file, directory, or symlink.
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Hash string `yaml:"hash,omitempty"`
|
Hash string `yaml:"hash,omitempty"`
|
||||||
Type string `yaml:"type"`
|
PlaintextHash string `yaml:"plaintext_hash,omitempty"`
|
||||||
Mode string `yaml:"mode,omitempty"`
|
Encrypted bool `yaml:"encrypted,omitempty"`
|
||||||
Target string `yaml:"target,omitempty"`
|
Locked bool `yaml:"locked,omitempty"`
|
||||||
Updated time.Time `yaml:"updated"`
|
Type string `yaml:"type"`
|
||||||
|
Mode string `yaml:"mode,omitempty"`
|
||||||
|
Target string `yaml:"target,omitempty"`
|
||||||
|
Updated time.Time `yaml:"updated"`
|
||||||
|
Only []string `yaml:"only,omitempty"`
|
||||||
|
Never []string `yaml:"never,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KekSlot describes a single KEK source that can unwrap the DEK.
|
||||||
|
type KekSlot struct {
|
||||||
|
Type string `yaml:"type"` // "passphrase" or "fido2"
|
||||||
|
Argon2Time int `yaml:"argon2_time,omitempty"` // passphrase only
|
||||||
|
Argon2Memory int `yaml:"argon2_memory,omitempty"` // passphrase only (KiB)
|
||||||
|
Argon2Threads int `yaml:"argon2_threads,omitempty"` // passphrase only
|
||||||
|
CredentialID string `yaml:"credential_id,omitempty"` // fido2 only (base64)
|
||||||
|
Salt string `yaml:"salt"` // base64-encoded
|
||||||
|
WrappedDEK string `yaml:"wrapped_dek"` // base64-encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encryption holds the encryption configuration embedded in the manifest.
|
||||||
|
type Encryption struct {
|
||||||
|
Algorithm string `yaml:"algorithm"`
|
||||||
|
KekSlots map[string]*KekSlot `yaml:"kek_slots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest is the top-level manifest describing all tracked entries.
|
// Manifest is the top-level manifest describing all tracked entries.
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
Version int `yaml:"version"`
|
Version int `yaml:"version"`
|
||||||
Created time.Time `yaml:"created"`
|
Created time.Time `yaml:"created"`
|
||||||
Updated time.Time `yaml:"updated"`
|
Updated time.Time `yaml:"updated"`
|
||||||
Message string `yaml:"message,omitempty"`
|
Message string `yaml:"message,omitempty"`
|
||||||
Files []Entry `yaml:"files"`
|
Files []Entry `yaml:"files"`
|
||||||
|
Encryption *Encryption `yaml:"encryption,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new empty manifest with Version 1 and timestamps set to now.
|
// New creates a new empty manifest with Version 1 and timestamps set to now.
|
||||||
|
|||||||
144
proto/sgard/v1/sgard.proto
Normal file
144
proto/sgard/v1/sgard.proto
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
120
server/convert.go
Normal file
120
server/convert.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
1280
sgardpb/sgard.pb.go
Normal file
1280
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 v7.34.0
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List returns all blob hashes in the store by walking the blobs directory.
|
||||||
|
func (s *Store) List() ([]string, error) {
|
||||||
|
blobsDir := filepath.Join(s.root, "blobs")
|
||||||
|
var hashes []string
|
||||||
|
err := filepath.WalkDir(blobsDir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if validHash(name) {
|
||||||
|
hashes = append(hashes, name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("store: listing blobs: %w", err)
|
||||||
|
}
|
||||||
|
return hashes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// blobPath returns the filesystem path for a blob with the given hash.
|
// blobPath returns the filesystem path for a blob with the given hash.
|
||||||
// Layout: blobs/<first 2 hex chars>/<next 2 hex chars>/<full 64-char hash>
|
// Layout: blobs/<first 2 hex chars>/<next 2 hex chars>/<full 64-char hash>
|
||||||
func (s *Store) blobPath(hash string) string {
|
func (s *Store) blobPath(hash string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user