Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
/sgard
|
/sgard
|
||||||
|
.claude/
|
||||||
|
result
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
595
ARCHITECTURE.md
595
ARCHITECTURE.md
@@ -48,29 +48,48 @@ updated: "2026-03-23T14:30:00Z"
|
|||||||
message: "pre-upgrade checkpoint" # optional
|
message: "pre-upgrade checkpoint" # optional
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- path: ~/.bashrc # original location (default restore target)
|
- path: ~/.bashrc # plaintext file
|
||||||
hash: a1b2c3d4e5f6... # SHA-256 of file contents
|
hash: a1b2c3d4e5f6... # SHA-256 of file contents
|
||||||
type: file # file | directory | link
|
type: file
|
||||||
mode: "0644" # permissions (quoted to avoid YAML coercion)
|
mode: "0644"
|
||||||
updated: "2026-03-23T14:30:00Z" # last checkpoint time for this file
|
|
||||||
|
|
||||||
- path: ~/.config/nvim
|
|
||||||
type: directory
|
|
||||||
mode: "0755"
|
|
||||||
updated: "2026-03-23T14:30:00Z"
|
updated: "2026-03-23T14:30:00Z"
|
||||||
# directories have no hash or blob — they're structural entries
|
|
||||||
|
|
||||||
- path: ~/.vimrc
|
- path: ~/.vimrc
|
||||||
type: link
|
type: link
|
||||||
target: ~/.config/nvim/init.vim # symlink target
|
target: ~/.config/nvim/init.vim
|
||||||
updated: "2026-03-23T14:30:00Z"
|
updated: "2026-03-23T14:30:00Z"
|
||||||
# links have no hash or blob — just the target path
|
|
||||||
|
|
||||||
- path: ~/.ssh/config
|
- path: ~/.ssh/config # encrypted file
|
||||||
hash: d4e5f6a1b2c3...
|
hash: f8e9d0c1... # SHA-256 of encrypted blob
|
||||||
|
plaintext_hash: e5f6a7... # SHA-256 of plaintext
|
||||||
|
encrypted: true
|
||||||
type: file
|
type: file
|
||||||
mode: "0600"
|
mode: "0600"
|
||||||
updated: "2026-03-23T14:30:00Z"
|
updated: "2026-03-23T14:30:00Z"
|
||||||
|
|
||||||
|
# Encryption config — only present if sgard encrypt init has been run.
|
||||||
|
# Travels with the manifest so a new machine can decrypt after pull.
|
||||||
|
# KEK slots are a map keyed by user-chosen label.
|
||||||
|
encryption:
|
||||||
|
algorithm: xchacha20-poly1305
|
||||||
|
kek_slots:
|
||||||
|
passphrase:
|
||||||
|
type: passphrase
|
||||||
|
argon2_time: 3
|
||||||
|
argon2_memory: 65536
|
||||||
|
argon2_threads: 4
|
||||||
|
salt: "base64..."
|
||||||
|
wrapped_dek: "base64..."
|
||||||
|
fido2/workstation:
|
||||||
|
type: fido2
|
||||||
|
credential_id: "base64..."
|
||||||
|
salt: "base64..."
|
||||||
|
wrapped_dek: "base64..."
|
||||||
|
fido2/laptop:
|
||||||
|
type: fido2
|
||||||
|
credential_id: "base64..."
|
||||||
|
salt: "base64..."
|
||||||
|
wrapped_dek: "base64..."
|
||||||
```
|
```
|
||||||
|
|
||||||
### Blob Store
|
### Blob Store
|
||||||
@@ -93,19 +112,22 @@ 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 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:**
|
||||||
|
|
||||||
@@ -123,69 +145,522 @@ 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.
|
||||||
|
|
||||||
|
### Future: Manifest Signing
|
||||||
|
|
||||||
|
Manifest signing (to detect tampering) is deferred. The challenge is
|
||||||
|
the trust model: which key signs, and how does a pulling client verify
|
||||||
|
the signature when multiple machines with different SSH keys push to
|
||||||
|
the same server? This requires a proper trust/key-authority design.
|
||||||
|
|
||||||
## Go Package Structure
|
## Go Package Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
sgard/
|
sgard/
|
||||||
cmd/sgard/ # CLI entry point — one file per command
|
cmd/sgard/ # CLI entry point — one file per command
|
||||||
main.go # cobra root command, --repo flag
|
main.go # cobra root command, --repo/--remote/--ssh-key flags
|
||||||
version.go # sgard version (ldflags-injected)
|
encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase
|
||||||
|
push.go pull.go prune.go mirror.go
|
||||||
init.go add.go remove.go checkpoint.go
|
init.go add.go remove.go checkpoint.go
|
||||||
restore.go status.go verify.go list.go diff.go
|
restore.go status.go verify.go list.go diff.go version.go
|
||||||
|
|
||||||
|
cmd/sgardd/ # gRPC server daemon
|
||||||
|
main.go # --listen, --repo, --authorized-keys flags
|
||||||
|
|
||||||
garden/ # Core business logic — one file per operation
|
garden/ # Core business logic — one file per operation
|
||||||
garden.go # Garden struct, Init, Open, Add, Checkpoint, Status
|
garden.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors
|
||||||
restore.go # Restore with timestamp comparison and confirm callback
|
encrypt.go # EncryptInit, UnlockDEK, encrypt/decrypt blobs, slot management
|
||||||
remove.go verify.go list.go diff.go
|
encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution
|
||||||
|
restore.go mirror.go prune.go remove.go verify.go list.go diff.go
|
||||||
hasher.go # SHA-256 file hashing
|
hasher.go # SHA-256 file hashing
|
||||||
e2e_test.go # Full lifecycle integration test
|
|
||||||
|
|
||||||
manifest/ # YAML manifest parsing
|
manifest/ # YAML manifest parsing
|
||||||
manifest.go # Manifest and Entry structs, Load/Save
|
manifest.go # Manifest and Entry structs, Load/Save
|
||||||
|
|
||||||
store/ # Content-addressable blob storage
|
store/ # Content-addressable blob storage
|
||||||
store.go # Store struct: Write/Read/Exists/Delete
|
store.go # Store struct: Write/Read/Exists/Delete/List
|
||||||
|
|
||||||
flake.nix # Nix flake for building on NixOS
|
server/ # gRPC server implementation
|
||||||
.goreleaser.yaml # GoReleaser config for releases
|
server.go # GardenSync RPC handlers with RWMutex
|
||||||
.github/workflows/ # GitHub Actions release pipeline
|
auth.go # JWT token + SSH key auth interceptor, Authenticate RPC
|
||||||
|
convert.go # proto ↔ manifest type conversion (incl. encryption)
|
||||||
|
|
||||||
|
client/ # gRPC client library
|
||||||
|
client.go # Push, Pull, Prune with auto-auth retry
|
||||||
|
auth.go # TokenCredentials, LoadSigner, Authenticate, token caching
|
||||||
|
|
||||||
|
sgardpb/ # Generated protobuf + gRPC Go code
|
||||||
|
proto/sgard/v1/ # Proto source definitions
|
||||||
|
|
||||||
|
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
|
manifestPath string
|
||||||
clock clockwork.Clock // injectable for testing
|
clock clockwork.Clock
|
||||||
|
dek []byte // unlocked data encryption key
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Garden) Add(paths []string) error
|
// Local operations
|
||||||
|
func (g *Garden) Add(paths []string, encrypt ...bool) error
|
||||||
func (g *Garden) Remove(paths []string) error
|
func (g *Garden) Remove(paths []string) error
|
||||||
func (g *Garden) Checkpoint(message string) error
|
func (g *Garden) Checkpoint(message string) error
|
||||||
func (g *Garden) Restore(paths []string, force bool, confirm func(path string) bool) error
|
func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error
|
||||||
func (g *Garden) Status() ([]FileStatus, error)
|
func (g *Garden) Status() ([]FileStatus, error)
|
||||||
func (g *Garden) Verify() ([]VerifyResult, error)
|
func (g *Garden) Verify() ([]VerifyResult, error)
|
||||||
func (g *Garden) List() []manifest.Entry
|
func (g *Garden) List() []manifest.Entry
|
||||||
func (g *Garden) 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
|
||||||
|
|
||||||
|
// Encryption
|
||||||
|
func (g *Garden) EncryptInit(passphrase string) error
|
||||||
|
func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) error
|
||||||
|
func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error
|
||||||
|
func (g *Garden) RemoveSlot(name string) error
|
||||||
|
func (g *Garden) ListSlots() map[string]string
|
||||||
|
func (g *Garden) ChangePassphrase(newPassphrase string) error
|
||||||
|
|
||||||
|
// Accessors (used by server package)
|
||||||
|
func (g *Garden) GetManifest() *manifest.Manifest
|
||||||
|
func (g *Garden) BlobExists(hash string) bool
|
||||||
|
func (g *Garden) ReadBlob(hash string) ([]byte, error)
|
||||||
|
func (g *Garden) WriteBlob(data []byte) (string, error)
|
||||||
|
func (g *Garden) ReplaceManifest(m *manifest.Manifest) error
|
||||||
|
func (g *Garden) ListBlobs() ([]string, error)
|
||||||
|
func (g *Garden) DeleteBlob(hash string) error
|
||||||
```
|
```
|
||||||
|
|
||||||
This separation means the future gRPC server calls the same `Garden` methods
|
The gRPC server calls the same `Garden` methods as the CLI — no logic
|
||||||
as the CLI — no logic duplication.
|
duplication.
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
@@ -193,9 +668,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 repo under git — `sgard init` creates a `.gitignore` that excludes
|
file/symlink individually. Directories are not tracked as entries — only
|
||||||
`blobs/`. Blob durability (backup, replication) is deferred to a future phase.
|
leaf files and symlinks.
|
||||||
|
|
||||||
|
**No history.** Only the latest checkpoint is stored. For versioning, place
|
||||||
|
the repo under git — `sgard init` creates a `.gitignore` that excludes
|
||||||
|
`blobs/`.
|
||||||
|
|
||||||
**Per-file timestamps.** Each manifest entry records an `updated` timestamp
|
**Per-file timestamps.** Each manifest entry records an `updated` timestamp
|
||||||
set at checkpoint time. On restore, if the manifest entry is newer than the
|
set at checkpoint time. On restore, if the manifest entry is newer than the
|
||||||
@@ -203,8 +682,34 @@ file on disk (by mtime), the restore proceeds without prompting. If the file
|
|||||||
on disk is newer or the times match, sgard prompts for confirmation.
|
on disk is newer or the times match, sgard prompts for confirmation.
|
||||||
`--force` always skips the prompt.
|
`--force` always skips the prompt.
|
||||||
|
|
||||||
**Atomic writes.** Checkpoint writes `manifest.yaml.tmp` then renames to
|
**Atomic writes.** Manifest saves write to a temp file then rename.
|
||||||
`manifest.yaml`. A crash cannot corrupt the manifest.
|
|
||||||
|
|
||||||
**Timestamp comparison truncates to seconds** for cross-platform filesystem
|
**Timestamp comparison truncates to seconds** for cross-platform filesystem
|
||||||
compatibility.
|
compatibility.
|
||||||
|
|
||||||
|
**Locked files (`--lock`).** A locked entry is repo-authoritative — the
|
||||||
|
on-disk copy is treated as potentially corrupted by the system, not as
|
||||||
|
a user edit. Semantics:
|
||||||
|
- **`add --lock`** — tracks the file normally, marks it as locked
|
||||||
|
- **`checkpoint`** — skips locked files entirely (preserves the repo version)
|
||||||
|
- **`status`** — reports locked files with changed hashes as `drifted`
|
||||||
|
(distinct from `modified`, which implies a user edit)
|
||||||
|
- **`restore`** — always restores locked files if the hash differs,
|
||||||
|
regardless of timestamp, without prompting. Skips if hash matches.
|
||||||
|
- **`add`** (without `--lock`) — can be used to explicitly update a locked
|
||||||
|
file in the repo when the on-disk version is intentionally new
|
||||||
|
|
||||||
|
Use case: system-managed files like `~/.config/user-dirs.dirs` that get
|
||||||
|
overwritten by the OS but should be kept at a known-good state.
|
||||||
|
|
||||||
|
**Directory-only entries (`--dir`).** `add --dir <path>` tracks the
|
||||||
|
directory itself as a structural entry without recursing into its
|
||||||
|
contents. On restore, sgard ensures the directory exists with the
|
||||||
|
correct permissions. Use case: directories that must exist for other
|
||||||
|
software to function, but whose contents are managed elsewhere.
|
||||||
|
|
||||||
|
**Remote config resolution:** `--remote` flag > `SGARD_REMOTE` env >
|
||||||
|
`<repo>/remote` file.
|
||||||
|
|
||||||
|
**SSH key resolution:** `--ssh-key` flag > `SGARD_SSH_KEY` env > ssh-agent >
|
||||||
|
`~/.ssh/id_ed25519` > `~/.ssh/id_rsa`.
|
||||||
|
|||||||
27
CLAUDE.md
27
CLAUDE.md
@@ -21,12 +21,12 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Nix:
|
Nix:
|
||||||
```bash
|
```bash
|
||||||
nix build .#sgard
|
nix build .#sgard # builds both binaries
|
||||||
```
|
```
|
||||||
|
|
||||||
Run tests:
|
Run tests:
|
||||||
@@ -39,24 +39,37 @@ 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
|
||||||
|
|
||||||
## 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 via encrypt.go/encrypt_fido2.go)
|
||||||
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
|
||||||
33
PROGRESS.md
33
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 3 complete (Steps 17–20). Encryption fully implemented.
|
||||||
|
|
||||||
**Last updated:** 2026-03-23
|
**Last updated:** 2026-03-24
|
||||||
|
|
||||||
## Completed Steps
|
## Completed Steps
|
||||||
|
|
||||||
@@ -42,16 +42,15 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Phase 1 is complete. Future work: blob durability, gRPC remote mode.
|
Phase 3 complete. Future: TLS transport, shell completions, manifest signing, real FIDO2 hardware binding.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## Known Issues / Decisions Deferred
|
||||||
|
|
||||||
- **Blob durability**: blobs are not stored in git. A strategy for backup or
|
- **Manifest signing**: deferred — trust model (which key signs, how do
|
||||||
replication is deferred to a future phase.
|
pulling clients verify) needs design.
|
||||||
- **gRPC remote mode**: Phase 2. Package structure is designed to accommodate
|
- **DEK rotation**: `sgard encrypt rotate-dek` (re-encrypt all blobs)
|
||||||
it (garden core separates logic from CLI wiring).
|
deferred to future work.
|
||||||
- **Clock abstraction**: Done — `jonboulle/clockwork` injected. E2e test
|
- **FIDO2 testing**: hardware-dependent, may need mocks or CI skip.
|
||||||
uses fake clock for deterministic timestamps.
|
|
||||||
|
|
||||||
## Change Log
|
## Change Log
|
||||||
|
|
||||||
@@ -67,3 +66,19 @@ Phase 1 is complete. Future work: blob durability, gRPC remote mode.
|
|||||||
| 2026-03-23 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. |
|
| 2026-03-23 | 7 | Remaining commands complete. Remove, Verify, List, Diff — 10 tests across 4 parallel units. |
|
||||||
| 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. |
|
| 2026-03-23 | 8 | Polish complete. golangci-lint, clockwork, e2e test, doc updates. |
|
||||||
| 2026-03-23 | — | README, goreleaser config, version command, Nix flake, homebrew formula, release pipeline validated (v0.1.0–v0.1.2). |
|
| 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. |
|
||||||
|
|||||||
137
PROJECT_PLAN.md
137
PROJECT_PLAN.md
@@ -92,9 +92,138 @@ 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
|
||||||
|
|
||||||
|
### Step 9: Proto Definitions + Code Gen
|
||||||
|
|
||||||
|
- [x] Write `proto/sgard/v1/sgard.proto` — 5 RPCs (PushManifest, PushBlobs, PullManifest, PullBlobs, Prune), all messages
|
||||||
|
- [x] Add Makefile target for protoc code generation
|
||||||
|
- [x] Add grpc, protobuf, x/crypto deps to go.mod
|
||||||
|
- [x] Update flake.nix devShell with protoc tools
|
||||||
|
- [x] Verify: `go build ./sgardpb` compiles
|
||||||
|
|
||||||
|
### Step 10: Garden Accessor Methods
|
||||||
|
|
||||||
|
*Can be done in parallel with Step 11.*
|
||||||
|
|
||||||
|
- [x] `garden/garden.go`: `GetManifest()`, `BlobExists()`, `ReadBlob()`, `WriteBlob()`, `ReplaceManifest()`
|
||||||
|
- [x] Tests for each accessor
|
||||||
|
- [x] Verify: `go test ./garden/...`
|
||||||
|
|
||||||
|
### Step 11: Proto-Manifest Conversion
|
||||||
|
|
||||||
|
*Can be done in parallel with Step 10.*
|
||||||
|
|
||||||
|
- [x] `server/convert.go`: `ManifestToProto`, `ProtoToManifest`, entry helpers
|
||||||
|
- [x] `server/convert_test.go`: round-trip test
|
||||||
|
- [x] Verify: `go test ./server/...`
|
||||||
|
|
||||||
|
### Step 12: Server Implementation (No Auth)
|
||||||
|
|
||||||
|
Depends on Steps 9, 10, 11.
|
||||||
|
|
||||||
|
- [x] `server/server.go`: Server struct with RWMutex, 5 RPC handlers (+ Prune)
|
||||||
|
- [x] PushManifest: timestamp compare, compute missing blobs
|
||||||
|
- [x] PushBlobs: receive stream, write to store, replace manifest
|
||||||
|
- [x] PullManifest: return manifest
|
||||||
|
- [x] PullBlobs: stream requested blobs (64 KiB chunks)
|
||||||
|
- [x] Prune: remove orphaned blobs (added store.List + garden.ListBlobs/DeleteBlob)
|
||||||
|
- [x] `server/server_test.go`: in-process test with bufconn, push+pull+prune
|
||||||
|
|
||||||
|
### Step 12b: Directory Recursion and Mirror Command
|
||||||
|
|
||||||
|
- [x] `garden/garden.go`: `Add` recurses directories — walk all files/symlinks, add each as its own entry
|
||||||
|
- [x] `garden/mirror.go`: `MirrorUp(paths []string) error` — walk directory, add new files, remove entries for files gone from disk, re-hash changed
|
||||||
|
- [x] `garden/mirror.go`: `MirrorDown(paths []string, force bool, confirm func(string) bool) error` — restore all tracked files under path, delete anything not in manifest
|
||||||
|
- [x] `garden/mirror_test.go`: tests for recursive add, mirror up (detects new/removed), mirror down (cleans extras)
|
||||||
|
- [x] `cmd/sgard/mirror.go`: `sgard mirror up <path>`, `sgard mirror down <path> [--force]`
|
||||||
|
- [x] Update existing add tests for directory recursion
|
||||||
|
|
||||||
|
### Step 13: Client Library (No Auth)
|
||||||
|
|
||||||
|
Depends on Step 12.
|
||||||
|
|
||||||
|
- [x] `client/client.go`: Client struct, `Push()`, `Pull()`, `Prune()` methods
|
||||||
|
- [x] `client/client_test.go`: integration tests (push+pull cycle, server newer, up-to-date, prune)
|
||||||
|
|
||||||
|
### Step 14: SSH Key Auth
|
||||||
|
|
||||||
|
- [x] `server/auth.go`: AuthInterceptor, parse authorized_keys, verify SSH signatures
|
||||||
|
- [x] `client/auth.go`: LoadSigner (ssh-agent or key file), SSHCredentials (PerRPCCredentials)
|
||||||
|
- [x] `server/auth_test.go`: valid key, reject unauthenticated, reject unauthorized key, reject expired timestamp
|
||||||
|
- [x] `client/auth_test.go`: metadata generation, no-transport-security
|
||||||
|
- [x] Integration tests: authenticated push/pull succeeds, unauthenticated is rejected
|
||||||
|
|
||||||
|
### Step 15: CLI Wiring + Prune
|
||||||
|
|
||||||
|
Depends on Steps 13, 14.
|
||||||
|
|
||||||
|
- [x] `garden/prune.go`: `Prune() (int, error)` — collect referenced hashes, delete orphaned blobs
|
||||||
|
- [x] `garden/prune_test.go`: prune removes orphaned, keeps referenced
|
||||||
|
- [x] `server/server.go`: Prune RPC (done in Step 12)
|
||||||
|
- [x] `proto/sgard/v1/sgard.proto`: Prune RPC (done in Step 9)
|
||||||
|
- [x] `client/client.go`: Prune() method (done in Step 13)
|
||||||
|
- [x] `cmd/sgard/prune.go`: local prune; with `--remote` prunes remote instead
|
||||||
|
- [x] `cmd/sgard/main.go`: add `--remote`, `--ssh-key` persistent flags, resolveRemote()
|
||||||
|
- [x] `cmd/sgard/push.go`, `cmd/sgard/pull.go`
|
||||||
|
- [x] `cmd/sgardd/main.go`: flags, garden open, auth interceptor, gRPC serve
|
||||||
|
- [x] Verify: both binaries compile
|
||||||
|
|
||||||
|
### Step 16: Polish + Release
|
||||||
|
|
||||||
|
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
|
||||||
|
- [x] Update flake.nix (add sgardd, updated vendorHash)
|
||||||
|
- [x] Update .goreleaser.yaml (add sgardd build)
|
||||||
|
- [x] E2e integration test: init two repos, push from one, pull into other (with auth)
|
||||||
|
- [x] Verify: all tests pass, full push/pull cycle works
|
||||||
|
|
||||||
|
## Phase 3: Encryption
|
||||||
|
|
||||||
|
### Step 17: Encryption Core (Passphrase Only)
|
||||||
|
|
||||||
|
- [x] `manifest/manifest.go`: add `Encrypted`, `PlaintextHash` fields to Entry; add `Encryption` section with `KekSlots` map to Manifest
|
||||||
|
- [x] `garden/encrypt.go`: `EncryptInit(passphrase string) error` — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section
|
||||||
|
- [x] `garden/encrypt.go`: `UnlockDEK(prompt) error` — read slots, try passphrase, unwrap DEK; cache in memory for command duration
|
||||||
|
- [x] `garden/encrypt.go`: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open)
|
||||||
|
- [x] `garden/garden.go`: modify Add to accept encrypt flag — encrypt blob before storing, set `encrypted: true` and `plaintext_hash` on entry
|
||||||
|
- [x] `garden/garden.go`: modify Checkpoint to re-encrypt changed encrypted entries (compares plaintext_hash)
|
||||||
|
- [x] `garden/garden.go`: modify Restore to decrypt encrypted blobs before writing
|
||||||
|
- [x] `garden/diff.go`: modify Diff to decrypt stored blob before diffing
|
||||||
|
- [x] `garden/garden.go`: modify Status to use `plaintext_hash` for encrypted entries
|
||||||
|
- [x] Tests: 10 encryption tests (init, persist, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK)
|
||||||
|
- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
|
||||||
|
|
||||||
|
### Step 18: FIDO2 Support
|
||||||
|
|
||||||
|
Depends on Step 17.
|
||||||
|
|
||||||
|
- [x] `garden/encrypt_fido2.go`: FIDO2Device interface, AddFIDO2Slot, unlockFIDO2, defaultFIDO2Label
|
||||||
|
- [x] `garden/encrypt.go`: UnlockDEK tries fido2/* slots first (credential_id matching), falls back to passphrase
|
||||||
|
- [x] `garden/encrypt_fido2_test.go`: mock FIDO2 device, 6 tests (add slot, duplicate rejected, unlock via FIDO2, fallback to passphrase, persistence, encrypted round-trip with FIDO2)
|
||||||
|
- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
|
||||||
|
|
||||||
|
### Step 19: Encryption CLI + Slot Management
|
||||||
|
|
||||||
|
Depends on Steps 17, 18.
|
||||||
|
|
||||||
|
- [x] `cmd/sgard/encrypt.go`: `sgard encrypt init [--fido2]`, `add-fido2 [--label]`, `remove-slot`, `list-slots`, `change-passphrase`
|
||||||
|
- [x] `garden/encrypt.go`: `RemoveSlot`, `ListSlots`, `ChangePassphrase` methods
|
||||||
|
- [x] `cmd/sgard/add.go`: add `--encrypt` flag with passphrase prompt
|
||||||
|
- [x] Update proto: add `encrypted`, `plaintext_hash` to ManifestEntry; add KekSlot, Encryption messages, encryption field on Manifest
|
||||||
|
- [x] Update `server/convert.go`: full encryption section conversion (Encryption, KekSlot)
|
||||||
|
- [x] Verify: both binaries compile, `go test ./...`, lint clean
|
||||||
|
|
||||||
|
### Step 20: Encryption Polish + Release
|
||||||
|
|
||||||
|
- [x] E2e test: full encryption lifecycle (init, add encrypted+plaintext, checkpoint, modify, status, restore, verify, diff, slot management, passphrase change)
|
||||||
|
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md
|
||||||
|
- [x] Update flake.nix vendorHash
|
||||||
|
- [x] Verify: all tests pass, lint clean
|
||||||
|
|
||||||
|
## Future Steps (Not Phase 3)
|
||||||
|
|
||||||
- Blob durability (backup/replication strategy)
|
|
||||||
- gRPC remote mode (push/pull/serve)
|
|
||||||
- Proto definitions for wire format
|
|
||||||
- Shell completion via cobra
|
- Shell completion via cobra
|
||||||
|
- TLS transport (optional --tls-cert/--tls-key on sgardd)
|
||||||
|
- Multiple repo support on server
|
||||||
|
- Manifest signing (requires trust model design)
|
||||||
|
- DEK rotation (`sgard encrypt rotate-dek` — re-encrypt all blobs)
|
||||||
|
|||||||
120
README.md
120
README.md
@@ -19,13 +19,14 @@ From source:
|
|||||||
|
|
||||||
```
|
```
|
||||||
git clone https://github.com/kisom/sgard && cd sgard
|
git clone https://github.com/kisom/sgard && cd sgard
|
||||||
go build -o sgard ./cmd/sgard
|
go build ./cmd/sgard ./cmd/sgardd
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install into `$GOBIN`:
|
Or install into `$GOBIN`:
|
||||||
|
|
||||||
```
|
```
|
||||||
go install github.com/kisom/sgard/cmd/sgard@latest
|
go install github.com/kisom/sgard/cmd/sgard@latest
|
||||||
|
go install github.com/kisom/sgard/cmd/sgardd@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
NixOS (flake):
|
NixOS (flake):
|
||||||
@@ -67,12 +68,52 @@ 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.
|
||||||
|
|
||||||
## 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 |
|
||||||
| `remove <path>...` | Stop tracking files |
|
| `remove <path>...` | Stop tracking files |
|
||||||
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
||||||
| `restore [path...] [-f]` | Restore files to their original locations |
|
| `restore [path...] [-f]` | Restore files to their original locations |
|
||||||
@@ -80,8 +121,80 @@ 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 |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
The encryption config (wrapped DEKs, salts) lives in the manifest, so
|
||||||
|
it syncs with push/pull. The server never has the DEK.
|
||||||
|
|
||||||
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full encryption design.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
sgard stores files in a content-addressable blob store keyed by SHA-256.
|
sgard stores files in a content-addressable blob store keyed by SHA-256.
|
||||||
@@ -100,6 +213,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/kisom/sgard/garden"
|
"github.com/kisom/sgard/garden"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
encryptFlag bool
|
||||||
|
lockFlag bool
|
||||||
|
dirOnlyFlag bool
|
||||||
|
)
|
||||||
|
|
||||||
var addCmd = &cobra.Command{
|
var addCmd = &cobra.Command{
|
||||||
Use: "add <path>...",
|
Use: "add <path>...",
|
||||||
Short: "Track files, directories, or symlinks",
|
Short: "Track files, directories, or symlinks",
|
||||||
@@ -17,7 +26,22 @@ 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 := g.UnlockDEK(promptPassphrase); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := garden.AddOptions{
|
||||||
|
Encrypt: encryptFlag,
|
||||||
|
Lock: lockFlag,
|
||||||
|
DirOnly: dirOnlyFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add(args, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +50,18 @@ var addCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptPassphrase() (string, error) {
|
||||||
|
fmt.Fprint(os.Stderr, "Passphrase: ")
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if scanner.Scan() {
|
||||||
|
return strings.TrimSpace(scanner.Text()), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no passphrase provided")
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
rootCmd.AddCommand(addCmd)
|
rootCmd.AddCommand(addCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
166
cmd/sgard/encrypt.go
Normal file
166
cmd/sgard/encrypt.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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 {
|
||||||
|
fmt.Println("FIDO2 support requires a hardware device implementation.")
|
||||||
|
fmt.Println("Run 'sgard encrypt add-fido2' when a FIDO2 device is available.")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := g.UnlockDEK(promptPassphrase); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real FIDO2 device implementation would go here.
|
||||||
|
// For now, this is a placeholder that explains the requirement.
|
||||||
|
return fmt.Errorf("FIDO2 hardware support not yet implemented; requires libfido2 binding")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 passphrase.
|
||||||
|
fmt.Println("Enter current passphrase:")
|
||||||
|
if err := g.UnlockDEK(promptPassphrase); 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
encryptInitCmd.Flags().BoolVar(&fido2InitFlag, "fido2", false, "also set up FIDO2 (placeholder)")
|
||||||
|
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)
|
||||||
|
|
||||||
|
rootCmd.AddCommand(encryptCmd)
|
||||||
|
}
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"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/insecure"
|
||||||
)
|
)
|
||||||
|
|
||||||
var repoFlag string
|
var (
|
||||||
|
repoFlag string
|
||||||
|
remoteFlag string
|
||||||
|
sshKeyFlag string
|
||||||
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "sgard",
|
Use: "sgard",
|
||||||
@@ -23,8 +32,64 @@ func defaultRepo() string {
|
|||||||
return filepath.Join(home, ".sgard")
|
return filepath.Join(home, ".sgard")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveRemote returns the remote address from flag, env, or repo config file.
|
||||||
|
func resolveRemote() (string, error) {
|
||||||
|
if remoteFlag != "" {
|
||||||
|
return remoteFlag, nil
|
||||||
|
}
|
||||||
|
if env := os.Getenv("SGARD_REMOTE"); env != "" {
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
// Try <repo>/remote file.
|
||||||
|
data, err := os.ReadFile(filepath.Join(repoFlag, "remote"))
|
||||||
|
if err == nil {
|
||||||
|
addr := strings.TrimSpace(string(data))
|
||||||
|
if addr != "" {
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no remote configured; use --remote, SGARD_REMOTE, or create %s/remote", repoFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialRemote creates a gRPC client with token-based auth and auto-renewal.
|
||||||
|
func dialRemote(ctx context.Context) (*client.Client, func(), error) {
|
||||||
|
addr, err := resolveRemote()
|
||||||
|
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)
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(addr,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
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")
|
||||||
|
|
||||||
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, _ := resolveRemote()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
71
cmd/sgardd/main.go
Normal file
71
cmd/sgardd/main.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
listenAddr string
|
||||||
|
repoPath string
|
||||||
|
authKeysPath 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
|
||||||
|
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")
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
packages = {
|
packages = {
|
||||||
sgard = pkgs.buildGoModule {
|
sgard = pkgs.buildGoModule {
|
||||||
pname = "sgard";
|
pname = "sgard";
|
||||||
version = "0.1.0";
|
version = "2.0.0";
|
||||||
src = pkgs.lib.cleanSource ./.;
|
src = pkgs.lib.cleanSource ./.;
|
||||||
subPackages = [ "cmd/sgard" ];
|
subPackages = [ "cmd/sgard" "cmd/sgardd" ];
|
||||||
|
|
||||||
vendorHash = "sha256-uJMkp08SqZaZ6d64Li4Tx8I9OYjaErLexBrJaf6Vb60=";
|
vendorHash = "sha256-0YpP1YfpAIAgY8k+7DlWosYN6MT5a2KLtNhQFvKT7pM=";
|
||||||
|
|
||||||
ldflags = [ "-s" "-w" ];
|
ldflags = [ "-s" "-w" ];
|
||||||
|
|
||||||
@@ -36,6 +36,9 @@
|
|||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
go
|
go
|
||||||
golangci-lint
|
golangci-lint
|
||||||
|
protobuf
|
||||||
|
protoc-gen-go
|
||||||
|
protoc-gen-go-grpc
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
315
garden/encrypt.go
Normal file
315
garden/encrypt.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
244
garden/garden.go
244
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
|
||||||
@@ -98,10 +99,125 @@ 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. If encrypt is true, the file
|
||||||
|
// blob is encrypted before storing. If lock is true, the entry is marked locked.
|
||||||
|
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt, lock bool) 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: lock,
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +231,44 @@ func (g *Garden) Add(paths []string) error {
|
|||||||
return fmt.Errorf("stat %s: %w", abs, err)
|
return fmt.Errorf("stat %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
if o.DirOnly {
|
||||||
|
// Track the directory itself as a structural entry.
|
||||||
tilded := toTildePath(abs)
|
tilded := toTildePath(abs)
|
||||||
|
|
||||||
// Check if already tracked.
|
|
||||||
if g.findEntry(tilded) != nil {
|
if g.findEntry(tilded) != nil {
|
||||||
return fmt.Errorf("already tracking %s", tilded)
|
return fmt.Errorf("already tracking %s", tilded)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := manifest.Entry{
|
entry := manifest.Entry{
|
||||||
Path: tilded,
|
Path: tilded,
|
||||||
|
Type: "directory",
|
||||||
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||||
|
Locked: o.Lock,
|
||||||
Updated: now,
|
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
|
|
||||||
|
|
||||||
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.Files = append(g.manifest.Files, entry)
|
||||||
|
} else {
|
||||||
|
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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.Encrypt, o.Lock)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := g.addEntry(abs, info, now, false, o.Encrypt, o.Lock); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g.manifest.Updated = now
|
g.manifest.Updated = now
|
||||||
@@ -193,12 +308,38 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if entry.Encrypted {
|
||||||
|
// For encrypted entries, check plaintext hash to detect changes.
|
||||||
|
ptHash := plaintextHash(data)
|
||||||
|
if ptHash != entry.PlaintextHash {
|
||||||
|
if g.dek == nil {
|
||||||
|
return fmt.Errorf("DEK not unlocked; cannot re-encrypt %s", abs)
|
||||||
|
}
|
||||||
|
ct, err := g.encryptBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
hash, err := g.store.Write(ct)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
entry.Hash = hash
|
||||||
|
entry.PlaintextHash = ptHash
|
||||||
|
entry.Updated = now
|
||||||
|
}
|
||||||
|
} else {
|
||||||
hash, err := g.store.Write(data)
|
hash, err := g.store.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
@@ -207,6 +348,7 @@ func (g *Garden) Checkpoint(message string) error {
|
|||||||
entry.Hash = hash
|
entry.Hash = hash
|
||||||
entry.Updated = now
|
entry.Updated = now
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
target, err := os.Readlink(abs)
|
target, err := os.Readlink(abs)
|
||||||
@@ -260,8 +402,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.
|
||||||
|
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"})
|
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"})
|
||||||
}
|
}
|
||||||
@@ -307,12 +458,21 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b
|
|||||||
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 +520,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) {
|
||||||
@@ -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 {
|
||||||
|
|||||||
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, false, false)
|
||||||
|
})
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
8
go.mod
8
go.mod
@@ -3,9 +3,17 @@ module github.com/kisom/sgard
|
|||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
golang.org/x/crypto v0.49.0 // 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
|
||||||
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
16
go.sum
16
go.sum
@@ -1,4 +1,6 @@
|
|||||||
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/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/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=
|
||||||
@@ -9,6 +11,20 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
|
|||||||
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=
|
||||||
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/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
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=
|
||||||
|
|||||||
@@ -13,12 +13,32 @@ import (
|
|||||||
type Entry struct {
|
type Entry struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Hash string `yaml:"hash,omitempty"`
|
Hash string `yaml:"hash,omitempty"`
|
||||||
|
PlaintextHash string `yaml:"plaintext_hash,omitempty"`
|
||||||
|
Encrypted bool `yaml:"encrypted,omitempty"`
|
||||||
|
Locked bool `yaml:"locked,omitempty"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Mode string `yaml:"mode,omitempty"`
|
Mode string `yaml:"mode,omitempty"`
|
||||||
Target string `yaml:"target,omitempty"`
|
Target string `yaml:"target,omitempty"`
|
||||||
Updated time.Time `yaml:"updated"`
|
Updated time.Time `yaml:"updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
@@ -26,6 +46,7 @@ type Manifest struct {
|
|||||||
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.
|
||||||
|
|||||||
142
proto/sgard/v1/sgard.proto
Normal file
142
proto/sgard/v1/sgard.proto
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
116
server/convert.go
Normal file
116
server/convert.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
124
server/convert_test.go
Normal file
124
server/convert_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
1261
sgardpb/sgard.pb.go
Normal file
1261
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.5.1
|
||||||
|
// - protoc v6.32.1
|
||||||
|
// source: sgard/v1/sgard.proto
|
||||||
|
|
||||||
|
package sgardpb
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
GardenSync_Authenticate_FullMethodName = "/sgard.v1.GardenSync/Authenticate"
|
||||||
|
GardenSync_PushManifest_FullMethodName = "/sgard.v1.GardenSync/PushManifest"
|
||||||
|
GardenSync_PushBlobs_FullMethodName = "/sgard.v1.GardenSync/PushBlobs"
|
||||||
|
GardenSync_PullManifest_FullMethodName = "/sgard.v1.GardenSync/PullManifest"
|
||||||
|
GardenSync_PullBlobs_FullMethodName = "/sgard.v1.GardenSync/PullBlobs"
|
||||||
|
GardenSync_Prune_FullMethodName = "/sgard.v1.GardenSync/Prune"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GardenSyncClient is the client API for GardenSync service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// GardenSync is the sgard remote sync service.
|
||||||
|
type GardenSyncClient interface {
|
||||||
|
// Authenticate exchanges an SSH-signed challenge for a JWT token.
|
||||||
|
Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateResponse, error)
|
||||||
|
// Push flow: send manifest, then stream missing blobs.
|
||||||
|
PushManifest(ctx context.Context, in *PushManifestRequest, opts ...grpc.CallOption) (*PushManifestResponse, error)
|
||||||
|
PushBlobs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushBlobsRequest, PushBlobsResponse], error)
|
||||||
|
// Pull flow: get manifest, then stream requested blobs.
|
||||||
|
PullManifest(ctx context.Context, in *PullManifestRequest, opts ...grpc.CallOption) (*PullManifestResponse, error)
|
||||||
|
PullBlobs(ctx context.Context, in *PullBlobsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PullBlobsResponse], error)
|
||||||
|
// Prune removes orphaned blobs on the server.
|
||||||
|
Prune(ctx context.Context, in *PruneRequest, opts ...grpc.CallOption) (*PruneResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type gardenSyncClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGardenSyncClient(cc grpc.ClientConnInterface) GardenSyncClient {
|
||||||
|
return &gardenSyncClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *gardenSyncClient) Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(AuthenticateResponse)
|
||||||
|
err := c.cc.Invoke(ctx, GardenSync_Authenticate_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *gardenSyncClient) PushManifest(ctx context.Context, in *PushManifestRequest, opts ...grpc.CallOption) (*PushManifestResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(PushManifestResponse)
|
||||||
|
err := c.cc.Invoke(ctx, GardenSync_PushManifest_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *gardenSyncClient) PushBlobs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushBlobsRequest, PushBlobsResponse], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &GardenSync_ServiceDesc.Streams[0], GardenSync_PushBlobs_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[PushBlobsRequest, PushBlobsResponse]{ClientStream: stream}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type GardenSync_PushBlobsClient = grpc.ClientStreamingClient[PushBlobsRequest, PushBlobsResponse]
|
||||||
|
|
||||||
|
func (c *gardenSyncClient) PullManifest(ctx context.Context, in *PullManifestRequest, opts ...grpc.CallOption) (*PullManifestResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(PullManifestResponse)
|
||||||
|
err := c.cc.Invoke(ctx, GardenSync_PullManifest_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *gardenSyncClient) PullBlobs(ctx context.Context, in *PullBlobsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PullBlobsResponse], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &GardenSync_ServiceDesc.Streams[1], GardenSync_PullBlobs_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[PullBlobsRequest, PullBlobsResponse]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type GardenSync_PullBlobsClient = grpc.ServerStreamingClient[PullBlobsResponse]
|
||||||
|
|
||||||
|
func (c *gardenSyncClient) Prune(ctx context.Context, in *PruneRequest, opts ...grpc.CallOption) (*PruneResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(PruneResponse)
|
||||||
|
err := c.cc.Invoke(ctx, GardenSync_Prune_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GardenSyncServer is the server API for GardenSync service.
|
||||||
|
// All implementations must embed UnimplementedGardenSyncServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// GardenSync is the sgard remote sync service.
|
||||||
|
type GardenSyncServer interface {
|
||||||
|
// Authenticate exchanges an SSH-signed challenge for a JWT token.
|
||||||
|
Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error)
|
||||||
|
// Push flow: send manifest, then stream missing blobs.
|
||||||
|
PushManifest(context.Context, *PushManifestRequest) (*PushManifestResponse, error)
|
||||||
|
PushBlobs(grpc.ClientStreamingServer[PushBlobsRequest, PushBlobsResponse]) error
|
||||||
|
// Pull flow: get manifest, then stream requested blobs.
|
||||||
|
PullManifest(context.Context, *PullManifestRequest) (*PullManifestResponse, error)
|
||||||
|
PullBlobs(*PullBlobsRequest, grpc.ServerStreamingServer[PullBlobsResponse]) error
|
||||||
|
// Prune removes orphaned blobs on the server.
|
||||||
|
Prune(context.Context, *PruneRequest) (*PruneResponse, error)
|
||||||
|
mustEmbedUnimplementedGardenSyncServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedGardenSyncServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedGardenSyncServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedGardenSyncServer) Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method Authenticate not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGardenSyncServer) PushManifest(context.Context, *PushManifestRequest) (*PushManifestResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method PushManifest not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGardenSyncServer) PushBlobs(grpc.ClientStreamingServer[PushBlobsRequest, PushBlobsResponse]) error {
|
||||||
|
return status.Errorf(codes.Unimplemented, "method PushBlobs not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGardenSyncServer) PullManifest(context.Context, *PullManifestRequest) (*PullManifestResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method PullManifest not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGardenSyncServer) PullBlobs(*PullBlobsRequest, grpc.ServerStreamingServer[PullBlobsResponse]) error {
|
||||||
|
return status.Errorf(codes.Unimplemented, "method PullBlobs not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedGardenSyncServer) Prune(context.Context, *PruneRequest) (*PruneResponse, error) {
|
||||||
|
return nil, status.Errorf(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 pancis, 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