10 Commits

Author SHA1 Message Date
3e0aabef4a Suppress passphrase echo in terminal prompts.
Use golang.org/x/term.ReadPassword so passphrases are not displayed
while typing, matching ssh behavior.

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:24:23 -07:00
e37e788885 Step 32: Phase 5 polish.
E2e test covering targeting labels through push/pull cycle. Updated
README with targeting docs and commands. All project docs updated.
Phase 5 complete.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:42:56 -07:00
34 changed files with 1817 additions and 64 deletions

View File

@@ -123,6 +123,7 @@ All commands operate on a repository directory (default: `~/.sgard`, override wi
| `sgard restore [<path>...] [--force]` | Restore files from manifest to their original locations | | `sgard restore [<path>...] [--force]` | Restore files from manifest to their original locations |
| `sgard status` | Compare current files against manifest: modified, missing, ok | | `sgard status` | Compare current files against manifest: modified, missing, ok |
| `sgard verify` | Check all blobs against manifest hashes (integrity check) | | `sgard verify` | Check all blobs against manifest hashes (integrity check) |
| `sgard info <path>` | Show detailed information about a tracked file |
| `sgard list` | List all tracked files | | `sgard list` | List all tracked files |
| `sgard diff <path>` | Show content diff between current file and stored blob | | `sgard diff <path>` | Show content diff between current file and stored blob |
| `sgard prune` | Remove orphaned blobs not referenced by the manifest | | `sgard prune` | Remove orphaned blobs not referenced by the manifest |
@@ -613,11 +614,51 @@ The rotation process:
Plaintext entries are untouched. Plaintext entries are untouched.
### Planned: Multi-Repo + Per-Machine Inclusion (Phase 5) ### Per-Machine Targeting (Phase 5)
Support for multiple repos on a single server, and per-machine Entries can be targeted to specific machines using `only` and `never`
inclusion rules (e.g., "this file only applies to Linux machines" or labels. A machine's identity is a set of labels computed at runtime:
"this directory is only for the workstation"). Design TBD.
- **Short hostname:** `vade` (before the first dot, lowercased)
- **OS:** `os:linux`, `os:darwin`, `os:windows` (from `runtime.GOOS`)
- **Architecture:** `arch:amd64`, `arch:arm64` (from `runtime.GOARCH`)
- **Tags:** `tag:work`, `tag:server` (from `<repo>/tags`, local-only)
**Manifest fields on Entry:**
```yaml
files:
- path: ~/.bashrc.linux
only: [os:linux] # restore/checkpoint only on Linux
...
- path: ~/.ssh/work-config
only: [tag:work] # only on machines tagged "work"
...
- path: ~/.config/heavy
never: [arch:arm64] # everywhere except ARM
...
- path: ~/.special
only: [vade] # only on host "vade"
...
```
**Matching rules:**
- `only` set → entry applies if *any* label matches the machine
- `never` set → entry excluded if *any* label matches
- Both set → error (mutually exclusive)
- Neither set → applies everywhere (current behavior)
**Operations affected:**
- `restore` — skip non-matching entries
- `checkpoint` — skip non-matching entries (don't clobber stored version)
- `status` — report non-matching entries as `skipped`
- `add`, `list`, `verify`, `diff` — operate on all entries regardless
**Tags file:** `<repo>/tags`, one tag per line, not synced. Each
machine defines its own tags. `sgard init` adds `tags` to `.gitignore`.
**Label format:** bare string = hostname, `prefix:value` = typed matcher.
The `tag:` prefix in `only`/`never` maps to bare names in the tags file.
### Future: Manifest Signing (Phase 6) ### Future: Manifest Signing (Phase 6)
@@ -635,7 +676,7 @@ sgard/
encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase
push.go pull.go prune.go mirror.go 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 version.go restore.go status.go verify.go list.go info.go diff.go version.go
cmd/sgardd/ # gRPC server daemon cmd/sgardd/ # gRPC server daemon
main.go # --listen, --repo, --authorized-keys, --tls-cert, --tls-key flags main.go # --listen, --repo, --authorized-keys, --tls-cert, --tls-key flags
@@ -646,7 +687,7 @@ sgard/
encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution
fido2_hardware.go # Real FIDO2 via go-libfido2 (//go:build fido2) fido2_hardware.go # Real FIDO2 via go-libfido2 (//go:build fido2)
fido2_nohardware.go # Stub returning nil (//go:build !fido2) fido2_nohardware.go # Stub returning nil (//go:build !fido2)
restore.go mirror.go prune.go remove.go verify.go list.go diff.go restore.go mirror.go prune.go remove.go verify.go list.go info.go diff.go
hasher.go # SHA-256 file hashing hasher.go # SHA-256 file hashing
manifest/ # YAML manifest parsing manifest/ # YAML manifest parsing
@@ -694,6 +735,7 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool)
func (g *Garden) Status() ([]FileStatus, error) func (g *Garden) Status() ([]FileStatus, error)
func (g *Garden) Verify() ([]VerifyResult, error) func (g *Garden) Verify() ([]VerifyResult, error)
func (g *Garden) List() []manifest.Entry func (g *Garden) List() []manifest.Entry
func (g *Garden) Info(path string) (*FileInfo, error)
func (g *Garden) Diff(path string) (string, error) func (g *Garden) Diff(path string) (string, error)
func (g *Garden) Prune() (int, error) func (g *Garden) Prune() (int, error)
func (g *Garden) MirrorUp(paths []string) error func (g *Garden) MirrorUp(paths []string) error

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status ## Current Status
**Phase:** Phase 4 complete. All 7 steps done (2127). **Phase:** Phase 5 complete. All 5 steps done (2832).
**Last updated:** 2026-03-24 **Last updated:** 2026-03-24
@@ -42,7 +42,18 @@ ARCHITECTURE.md for design details.
## Up Next ## Up Next
Phase 5: Multi-Repo + Per-Machine Inclusion (to be planned). Phase 6: Manifest Signing (to be planned).
## Standalone Additions
- **Deployment to rift**: sgardd deployed as Podman container on rift behind
mc-proxy (L4 SNI passthrough on :9443, multiplexed with metacrypt gRPC).
TLS cert issued by Metacrypt, SSH-key auth. DNS at
`sgard.svc.mcp.metacircular.net`.
- **Default remote config**: `sgard remote set/show` commands. Saves addr,
TLS, and CA path to `<repo>/remote.yaml`. `dialRemote` merges saved config
with CLI flags (flags win). Removes need for `--remote`/`--tls` on every
push/pull.
## Known Issues / Decisions Deferred ## Known Issues / Decisions Deferred
@@ -91,3 +102,12 @@ Phase 5: Multi-Repo + Per-Machine Inclusion (to be planned).
| 2026-03-24 | 25 | Real FIDO2: go-libfido2 bindings, build tag gating, CLI wiring, nix sgard-fido2 package. | | 2026-03-24 | 25 | Real FIDO2: go-libfido2 bindings, build tag gating, CLI wiring, nix sgard-fido2 package. |
| 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. | | 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. |
| 2026-03-24 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. | | 2026-03-24 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. |
| 2026-03-24 | — | Phase 5 planned (Steps 2832): machine identity, targeting, tags, proto update, polish. |
| 2026-03-24 | 28 | Machine identity + targeting core: Entry Only/Never, Identity(), EntryApplies(), tags file. 13 tests. |
| 2026-03-24 | 29 | Operations respect targeting: checkpoint/restore/status skip non-matching. 6 tests. |
| 2026-03-24 | 30 | Targeting CLI: tag add/remove/list, identity, --only/--never on add, target command. |
| 2026-03-24 | 31 | Proto + sync: only/never fields on ManifestEntry, conversion, round-trip test. |
| 2026-03-24 | 32 | Phase 5 polish: e2e test (targeting + push/pull + restore), docs updated. Phase 5 complete. |
| 2026-03-25 | — | `sgard info` command: shows detailed file information (status, hash, timestamps, mode, encryption, targeting). 5 tests. |
| 2026-03-25 | — | Deploy sgardd to rift: Dockerfile, docker-compose, mc-proxy L4 route on :9443, Metacrypt TLS cert, DNS. |
| 2026-03-25 | — | `sgard remote set/show`: persistent remote config in `<repo>/remote.yaml` (addr, tls, tls_ca). |

View File

@@ -276,9 +276,45 @@ Depends on Steps 17, 18.
- [x] E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull - [x] E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull
- [x] Verify: all tests pass, lint clean, both binaries compile - [x] Verify: all tests pass, lint clean, both binaries compile
## Phase 5: Multi-Repo + Per-Machine Inclusion ## Phase 5: Per-Machine Targeting
(To be planned) ### Step 28: Machine Identity + Targeting Core
- [x] `manifest/manifest.go`: add `Only []string` and `Never []string` to Entry
- [x] `garden/identity.go`: `Identity()` returns machine label set
- [x] `garden/targeting.go`: `EntryApplies(entry, labels)` match logic
- [x] `garden/tags.go`: `LoadTags`, `SaveTag`, `RemoveTag` for `<repo>/tags`
- [x] `garden/garden.go`: `Init` appends `tags` to `.gitignore`
- [x] Tests: 13 tests (identity, tags, matching: only, never, both-error, hostname, os, arch, tag, case-insensitive, multiple)
### Step 29: Operations Respect Targeting
- [x] `Checkpoint` skips entries where `!EntryApplies`
- [x] `Restore` skips entries where `!EntryApplies`
- [x] `Status` reports `skipped` for non-matching entries
- [x] `Add` accepts `Only`/`Never` in `AddOptions`, propagated through `addEntry`
- [x] Tests: 6 tests (checkpoint skip/process, status skipped, restore skip, add with only/never)
### Step 30: Targeting CLI Commands
- [x] `cmd/sgard/tag.go`: tag add/remove/list
- [x] `cmd/sgard/identity.go`: identity command
- [x] `cmd/sgard/add.go`: --only/--never flags
- [x] `cmd/sgard/target.go`: target command with --only/--never/--clear
- [x] `garden/target.go`: SetTargeting method
### Step 31: Proto + Sync Update
- [x] `proto/sgard/v1/sgard.proto`: only/never fields on ManifestEntry
- [x] `server/convert.go`: updated conversion
- [x] Regenerated proto
- [x] Tests: targeting round-trip test
### Step 32: Phase 5 Polish
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
- [x] E2e test: push/pull with targeting labels, restore respects targeting
- [x] Verify: all tests pass, lint clean, both binaries compile
## Phase 6: Manifest Signing ## Phase 6: Manifest Signing

View File

@@ -119,6 +119,37 @@ sgard add --dir ~/.local/share/applications
On `restore`, sgard creates the directory with the correct permissions On `restore`, sgard creates the directory with the correct permissions
but doesn't touch its contents. but doesn't touch its contents.
### Per-machine targeting
Some files only apply to certain machines. Use `--only` and `--never`
to control where entries are active:
```sh
# Only restore on Linux
sgard add --only os:linux ~/.bashrc.linux
# Never restore on ARM
sgard add --never arch:arm64 ~/.config/heavy-tool
# Only on machines tagged "work"
sgard tag add work
sgard add --only tag:work ~/.ssh/work-config
# Only on a specific host
sgard add --only vade ~/.special-config
# See this machine's identity
sgard identity
# Change targeting on an existing entry
sgard target ~/.bashrc.linux --only os:linux,tag:desktop
sgard target ~/.bashrc.linux --clear
```
Labels: bare string = hostname, `os:linux`/`os:darwin`, `arch:amd64`/`arch:arm64`,
`tag:<name>` from local `<repo>/tags` file. `checkpoint`, `restore`, and
`status` skip non-matching entries automatically.
## Commands ## Commands
### Local ### Local
@@ -129,6 +160,11 @@ but doesn't touch its contents.
| `add <path>...` | Track files, directories (recursed), or symlinks | | `add <path>...` | Track files, directories (recursed), or symlinks |
| `add --lock <path>...` | Track as locked (repo-authoritative, auto-restores on drift) | | `add --lock <path>...` | Track as locked (repo-authoritative, auto-restores on drift) |
| `add --dir <path>` | Track directory itself without recursing into contents | | `add --dir <path>` | Track directory itself without recursing into contents |
| `add --only <labels>` | Track with per-machine targeting (only on matching) |
| `add --never <labels>` | Track with per-machine targeting (never on matching) |
| `target <path> --only/--never/--clear` | Set or clear targeting on existing entry |
| `tag add/remove/list` | Manage machine-local tags |
| `identity` | Show this machine's identity labels |
| `remove <path>...` | Stop tracking files | | `remove <path>...` | Stop tracking files |
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest | | `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
| `restore [path...] [-f]` | Restore files to their original locations | | `restore [path...] [-f]` | Restore files to their original locations |

View File

@@ -1,19 +1,20 @@
package main package main
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
) )
var ( var (
encryptFlag bool encryptFlag bool
lockFlag bool lockFlag bool
dirOnlyFlag bool dirOnlyFlag bool
onlyFlag []string
neverFlag []string
) )
var addCmd = &cobra.Command{ var addCmd = &cobra.Command{
@@ -35,10 +36,16 @@ var addCmd = &cobra.Command{
} }
} }
if len(onlyFlag) > 0 && len(neverFlag) > 0 {
return fmt.Errorf("--only and --never are mutually exclusive")
}
opts := garden.AddOptions{ opts := garden.AddOptions{
Encrypt: encryptFlag, Encrypt: encryptFlag,
Lock: lockFlag, Lock: lockFlag,
DirOnly: dirOnlyFlag, DirOnly: dirOnlyFlag,
Only: onlyFlag,
Never: neverFlag,
} }
if err := g.Add(args, opts); err != nil { if err := g.Add(args, opts); err != nil {
@@ -52,16 +59,23 @@ var addCmd = &cobra.Command{
func promptPassphrase() (string, error) { func promptPassphrase() (string, error) {
fmt.Fprint(os.Stderr, "Passphrase: ") fmt.Fprint(os.Stderr, "Passphrase: ")
scanner := bufio.NewScanner(os.Stdin) fd := int(os.Stdin.Fd())
if scanner.Scan() { passphrase, err := term.ReadPassword(fd)
return strings.TrimSpace(scanner.Text()), nil fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("reading passphrase: %w", err)
} }
return "", fmt.Errorf("no passphrase provided") if len(passphrase) == 0 {
return "", fmt.Errorf("no passphrase provided")
}
return string(passphrase), nil
} }
func init() { func init() {
addCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "encrypt file contents before storing") addCmd.Flags().BoolVar(&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(&lockFlag, "lock", false, "mark as locked (repo-authoritative, restore always overwrites)")
addCmd.Flags().BoolVar(&dirOnlyFlag, "dir", false, "track directory itself without recursing into contents") addCmd.Flags().BoolVar(&dirOnlyFlag, "dir", false, "track directory itself without recursing into contents")
addCmd.Flags().StringSliceVar(&onlyFlag, "only", nil, "only apply on machines matching these labels (comma-separated)")
addCmd.Flags().StringSliceVar(&neverFlag, "never", nil, "never apply on machines matching these labels (comma-separated)")
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
} }

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

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

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

@@ -0,0 +1,79 @@
package main
import (
"fmt"
"strings"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var infoCmd = &cobra.Command{
Use: "info <path>",
Short: "Show detailed information about a tracked file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
fi, err := g.Info(args[0])
if err != nil {
return err
}
fmt.Printf("Path: %s\n", fi.Path)
fmt.Printf("Type: %s\n", fi.Type)
fmt.Printf("Status: %s\n", fi.State)
fmt.Printf("Mode: %s\n", fi.Mode)
if fi.Locked {
fmt.Printf("Locked: yes\n")
}
if fi.Encrypted {
fmt.Printf("Encrypted: yes\n")
}
if fi.Updated != "" {
fmt.Printf("Updated: %s\n", fi.Updated)
}
if fi.DiskModTime != "" {
fmt.Printf("Disk mtime: %s\n", fi.DiskModTime)
}
switch fi.Type {
case "file":
fmt.Printf("Hash: %s\n", fi.Hash)
if fi.CurrentHash != "" && fi.CurrentHash != fi.Hash {
fmt.Printf("Disk hash: %s\n", fi.CurrentHash)
}
if fi.PlaintextHash != "" {
fmt.Printf("PT hash: %s\n", fi.PlaintextHash)
}
if fi.BlobStored {
fmt.Printf("Blob: stored\n")
} else {
fmt.Printf("Blob: missing\n")
}
case "link":
fmt.Printf("Target: %s\n", fi.Target)
if fi.CurrentTarget != "" && fi.CurrentTarget != fi.Target {
fmt.Printf("Disk target: %s\n", fi.CurrentTarget)
}
}
if len(fi.Only) > 0 {
fmt.Printf("Only: %s\n", strings.Join(fi.Only, ", "))
}
if len(fi.Never) > 0 {
fmt.Printf("Never: %s\n", strings.Join(fi.Never, ", "))
}
return nil
},
}
func init() {
rootCmd.AddCommand(infoCmd)
}

View File

@@ -37,28 +37,49 @@ 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. // resolveRemoteConfig returns the effective remote address, TLS flag, and CA
func resolveRemote() (string, error) { // path by merging CLI flags, environment, and the saved remote.yaml config.
if remoteFlag != "" { // CLI flags take precedence, then env, then the saved config.
return remoteFlag, nil func resolveRemoteConfig() (addr string, useTLS bool, caPath string, err error) {
// Start with saved config as baseline.
saved, _ := loadRemoteConfig()
// Address: flag > env > saved > legacy file.
addr = remoteFlag
if addr == "" {
addr = os.Getenv("SGARD_REMOTE")
} }
if env := os.Getenv("SGARD_REMOTE"); env != "" { if addr == "" && saved != nil {
return env, nil addr = saved.Addr
} }
// Try <repo>/remote file. if addr == "" {
data, err := os.ReadFile(filepath.Join(repoFlag, "remote")) data, ferr := os.ReadFile(filepath.Join(repoFlag, "remote"))
if err == nil { if ferr == nil {
addr := strings.TrimSpace(string(data)) 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) if addr == "" {
return "", false, "", fmt.Errorf("no remote configured; use 'sgard remote set' or --remote")
}
// TLS: flag wins if explicitly set, otherwise use saved.
useTLS = tlsFlag
if !useTLS && saved != nil {
useTLS = saved.TLS
}
// CA: flag wins if set, otherwise use saved.
caPath = tlsCAFlag
if caPath == "" && saved != nil {
caPath = saved.TLSCA
}
return addr, useTLS, caPath, nil
} }
// dialRemote creates a gRPC client with token-based auth and auto-renewal. // dialRemote creates a gRPC client with token-based auth and auto-renewal.
func dialRemote(ctx context.Context) (*client.Client, func(), error) { func dialRemote(ctx context.Context) (*client.Client, func(), error) {
addr, err := resolveRemote() addr, useTLS, caPath, err := resolveRemoteConfig()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -72,16 +93,16 @@ func dialRemote(ctx context.Context) (*client.Client, func(), error) {
creds := client.NewTokenCredentials(cachedToken) creds := client.NewTokenCredentials(cachedToken)
var transportCreds grpc.DialOption var transportCreds grpc.DialOption
if tlsFlag { if useTLS {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if tlsCAFlag != "" { if caPath != "" {
caPEM, err := os.ReadFile(tlsCAFlag) caPEM, err := os.ReadFile(caPath)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("reading CA cert: %w", err) return nil, nil, fmt.Errorf("reading CA cert: %w", err)
} }
pool := x509.NewCertPool() pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) { if !pool.AppendCertsFromPEM(caPEM) {
return nil, nil, fmt.Errorf("failed to parse CA cert %s", tlsCAFlag) return nil, nil, fmt.Errorf("failed to parse CA cert %s", caPath)
} }
tlsCfg.RootCAs = pool tlsCfg.RootCAs = pool
} }

View File

@@ -13,7 +13,7 @@ var pruneCmd = &cobra.Command{
Short: "Remove orphaned blobs not referenced by the manifest", Short: "Remove orphaned blobs not referenced by the manifest",
Long: "Remove orphaned blobs locally, or on the remote server with --remote.", Long: "Remove orphaned blobs locally, or on the remote server with --remote.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
addr, _ := resolveRemote() addr, _, _, _ := resolveRemoteConfig()
if addr != "" { if addr != "" {
return pruneRemote() return pruneRemote()

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

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

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

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

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

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

30
deploy/docker/Dockerfile Normal file
View File

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

View File

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

View File

@@ -19,7 +19,7 @@
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ]; subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-0aGo5EbvPWt9Oflq+GTq8nEBUWZj3O5Ni4Qwd5EBa7Y="; vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY=";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];
@@ -35,7 +35,7 @@
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ]; subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-LSz15iFsP4N3Cif1PFHEKg3udeqH/9WQQbZ50sxtWTk="; vendorHash = "sha256-Z/Ja4j7YesNYefQQcWWRG2v8WuIL+UNqPGwYD5AipZY=";
buildInputs = [ pkgs.libfido2 ]; buildInputs = [ pkgs.libfido2 ];
nativeBuildInputs = [ pkgs.pkg-config ]; nativeBuildInputs = [ pkgs.pkg-config ];

View File

@@ -48,7 +48,7 @@ func Init(root string) (*Garden, error) {
} }
gitignorePath := filepath.Join(absRoot, ".gitignore") gitignorePath := filepath.Join(absRoot, ".gitignore")
if err := os.WriteFile(gitignorePath, []byte("blobs/\n"), 0o644); err != nil { if err := os.WriteFile(gitignorePath, []byte("blobs/\ntags\n"), 0o644); err != nil {
return nil, fmt.Errorf("creating .gitignore: %w", err) return nil, fmt.Errorf("creating .gitignore: %w", err)
} }
@@ -139,9 +139,8 @@ func (g *Garden) DeleteBlob(hash string) error {
} }
// addEntry adds a single file or symlink to the manifest. If skipDup is true, // 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 // already-tracked paths are silently skipped.
// 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 bool, o AddOptions) error {
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt, lock bool) error {
tilded := toTildePath(abs) tilded := toTildePath(abs)
if g.findEntry(tilded) != nil { if g.findEntry(tilded) != nil {
@@ -154,7 +153,9 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
entry := manifest.Entry{ entry := manifest.Entry{
Path: tilded, Path: tilded,
Mode: fmt.Sprintf("%04o", info.Mode().Perm()), Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
Locked: lock, Locked: o.Lock,
Only: o.Only,
Never: o.Never,
Updated: now, Updated: now,
} }
@@ -173,7 +174,7 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
return fmt.Errorf("reading file %s: %w", abs, err) return fmt.Errorf("reading file %s: %w", abs, err)
} }
if encrypt { if o.Encrypt {
if g.dek == nil { if g.dek == nil {
return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs) return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs)
} }
@@ -200,9 +201,11 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
// AddOptions controls the behavior of Add. // AddOptions controls the behavior of Add.
type AddOptions struct { type AddOptions struct {
Encrypt bool // encrypt file blobs before storing Encrypt bool // encrypt file blobs before storing
Lock bool // mark entries as locked (repo-authoritative) Lock bool // mark entries as locked (repo-authoritative)
DirOnly bool // for directories: track the directory itself, don't recurse DirOnly bool // for directories: track the directory itself, don't recurse
Only []string // per-machine targeting: only apply on matching machines
Never []string // per-machine targeting: never apply on matching machines
} }
// Add tracks new files, directories, or symlinks. Each path is resolved // Add tracks new files, directories, or symlinks. Each path is resolved
@@ -243,6 +246,8 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
Type: "directory", Type: "directory",
Mode: fmt.Sprintf("%04o", info.Mode().Perm()), Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
Locked: o.Lock, Locked: o.Lock,
Only: o.Only,
Never: o.Never,
Updated: now, Updated: now,
} }
g.manifest.Files = append(g.manifest.Files, entry) g.manifest.Files = append(g.manifest.Files, entry)
@@ -258,14 +263,14 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
if err != nil { if err != nil {
return fmt.Errorf("stat %s: %w", path, err) return fmt.Errorf("stat %s: %w", path, err)
} }
return g.addEntry(path, fi, now, true, o.Encrypt, o.Lock) return g.addEntry(path, fi, now, true, o)
}) })
if err != nil { if err != nil {
return fmt.Errorf("walking directory %s: %w", abs, err) return fmt.Errorf("walking directory %s: %w", abs, err)
} }
} }
} else { } else {
if err := g.addEntry(abs, info, now, false, o.Encrypt, o.Lock); err != nil { if err := g.addEntry(abs, info, now, false, o); err != nil {
return err return err
} }
} }
@@ -290,10 +295,19 @@ type FileStatus struct {
// the manifest. // the manifest.
func (g *Garden) Checkpoint(message string) error { func (g *Garden) Checkpoint(message string) error {
now := g.clock.Now().UTC() now := g.clock.Now().UTC()
labels := g.Identity()
for i := range g.manifest.Files { for i := range g.manifest.Files {
entry := &g.manifest.Files[i] entry := &g.manifest.Files[i]
applies, err := EntryApplies(entry, labels)
if err != nil {
return err
}
if !applies {
continue
}
abs, err := ExpandTildePath(entry.Path) abs, err := ExpandTildePath(entry.Path)
if err != nil { if err != nil {
return fmt.Errorf("expanding path %s: %w", entry.Path, err) return fmt.Errorf("expanding path %s: %w", entry.Path, err)
@@ -378,10 +392,20 @@ func (g *Garden) Checkpoint(message string) error {
// and returns a status for each. // and returns a status for each.
func (g *Garden) Status() ([]FileStatus, error) { func (g *Garden) Status() ([]FileStatus, error) {
var results []FileStatus var results []FileStatus
labels := g.Identity()
for i := range g.manifest.Files { for i := range g.manifest.Files {
entry := &g.manifest.Files[i] entry := &g.manifest.Files[i]
applies, err := EntryApplies(entry, labels)
if err != nil {
return nil, err
}
if !applies {
results = append(results, FileStatus{Path: entry.Path, State: "skipped"})
continue
}
abs, err := ExpandTildePath(entry.Path) abs, err := ExpandTildePath(entry.Path)
if err != nil { if err != nil {
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err) return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
@@ -450,9 +474,19 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b
} }
} }
labels := g.Identity()
for i := range entries { for i := range entries {
entry := &entries[i] entry := &entries[i]
applies, err := EntryApplies(entry, labels)
if err != nil {
return err
}
if !applies {
continue
}
abs, err := ExpandTildePath(entry.Path) abs, err := ExpandTildePath(entry.Path)
if err != nil { if err != nil {
return fmt.Errorf("expanding path %s: %w", entry.Path, err) return fmt.Errorf("expanding path %s: %w", entry.Path, err)

View File

@@ -33,8 +33,8 @@ func TestInitCreatesStructure(t *testing.T) {
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore")) gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil { if err != nil {
t.Errorf(".gitignore not found: %v", err) t.Errorf(".gitignore not found: %v", err)
} else if string(gitignore) != "blobs/\n" { } else if string(gitignore) != "blobs/\ntags\n" {
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n") t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\ntags\n")
} }
if g.manifest.Version != 1 { if g.manifest.Version != 1 {

37
garden/identity.go Normal file
View File

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

158
garden/info.go Normal file
View File

@@ -0,0 +1,158 @@
package garden
import (
"fmt"
"os"
"strings"
)
// FileInfo holds detailed information about a single tracked entry.
type FileInfo struct {
Path string // tilde path from manifest
Type string // "file", "link", or "directory"
State string // "ok", "modified", "drifted", "missing", "skipped"
Mode string // octal file mode from manifest
Hash string // blob hash from manifest (files only)
PlaintextHash string // plaintext hash (encrypted files only)
CurrentHash string // SHA-256 of current file on disk (files only, empty if missing)
Encrypted bool
Locked bool
Updated string // manifest timestamp (RFC 3339)
DiskModTime string // filesystem modification time (RFC 3339, empty if missing)
Target string // symlink target (links only)
CurrentTarget string // current symlink target on disk (links only, empty if missing)
Only []string // targeting: only these labels
Never []string // targeting: never these labels
BlobStored bool // whether the blob exists in the store
}
// Info returns detailed information about a tracked file.
func (g *Garden) Info(path string) (*FileInfo, error) {
abs, err := resolvePath(path)
if err != nil {
return nil, err
}
tilded := toTildePath(abs)
entry := g.findEntry(tilded)
if entry == nil {
// Also try the path as given (it might already be a tilde path).
entry = g.findEntry(path)
if entry == nil {
return nil, fmt.Errorf("not tracked: %s", path)
}
}
fi := &FileInfo{
Path: entry.Path,
Type: entry.Type,
Mode: entry.Mode,
Hash: entry.Hash,
PlaintextHash: entry.PlaintextHash,
Encrypted: entry.Encrypted,
Locked: entry.Locked,
Target: entry.Target,
Only: entry.Only,
Never: entry.Never,
}
if !entry.Updated.IsZero() {
fi.Updated = entry.Updated.Format("2006-01-02 15:04:05 UTC")
}
// Check blob existence for files.
if entry.Type == "file" && entry.Hash != "" {
fi.BlobStored = g.store.Exists(entry.Hash)
}
// Determine state and filesystem info.
labels := g.Identity()
applies, err := EntryApplies(entry, labels)
if err != nil {
return nil, err
}
if !applies {
fi.State = "skipped"
return fi, nil
}
entryAbs, err := ExpandTildePath(entry.Path)
if err != nil {
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
}
info, err := os.Lstat(entryAbs)
if os.IsNotExist(err) {
fi.State = "missing"
return fi, nil
}
if err != nil {
return nil, fmt.Errorf("stat %s: %w", entryAbs, err)
}
fi.DiskModTime = info.ModTime().UTC().Format("2006-01-02 15:04:05 UTC")
switch entry.Type {
case "file":
hash, err := HashFile(entryAbs)
if err != nil {
return nil, fmt.Errorf("hashing %s: %w", entryAbs, err)
}
fi.CurrentHash = hash
compareHash := entry.Hash
if entry.Encrypted && entry.PlaintextHash != "" {
compareHash = entry.PlaintextHash
}
if hash != compareHash {
if entry.Locked {
fi.State = "drifted"
} else {
fi.State = "modified"
}
} else {
fi.State = "ok"
}
case "link":
target, err := os.Readlink(entryAbs)
if err != nil {
return nil, fmt.Errorf("reading symlink %s: %w", entryAbs, err)
}
fi.CurrentTarget = target
if target != entry.Target {
fi.State = "modified"
} else {
fi.State = "ok"
}
case "directory":
fi.State = "ok"
}
return fi, nil
}
// resolvePath resolves a user-provided path to an absolute path, handling
// tilde expansion and relative paths.
func resolvePath(path string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") {
return ExpandTildePath(path)
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
// If it looks like a tilde path already, just expand it.
if strings.HasPrefix(path, home) {
return path, nil
}
abs, err := os.Getwd()
if err != nil {
return "", err
}
if !strings.HasPrefix(path, "/") {
path = abs + "/" + path
}
return path, nil
}

191
garden/info_test.go Normal file
View File

@@ -0,0 +1,191 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestInfoTrackedFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create a file to track.
filePath := filepath.Join(root, "hello.txt")
if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := g.Add([]string{filePath}); err != nil {
t.Fatalf("Add: %v", err)
}
fi, err := g.Info(filePath)
if err != nil {
t.Fatalf("Info: %v", err)
}
if fi.Type != "file" {
t.Errorf("Type = %q, want %q", fi.Type, "file")
}
if fi.State != "ok" {
t.Errorf("State = %q, want %q", fi.State, "ok")
}
if fi.Hash == "" {
t.Error("Hash is empty")
}
if fi.CurrentHash == "" {
t.Error("CurrentHash is empty")
}
if fi.Hash != fi.CurrentHash {
t.Errorf("Hash = %q != CurrentHash = %q", fi.Hash, fi.CurrentHash)
}
if fi.Updated == "" {
t.Error("Updated is empty")
}
if fi.DiskModTime == "" {
t.Error("DiskModTime is empty")
}
if !fi.BlobStored {
t.Error("BlobStored = false, want true")
}
if fi.Mode != "0644" {
t.Errorf("Mode = %q, want %q", fi.Mode, "0644")
}
}
func TestInfoModifiedFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
filePath := filepath.Join(root, "hello.txt")
if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := g.Add([]string{filePath}); err != nil {
t.Fatalf("Add: %v", err)
}
// Modify the file.
if err := os.WriteFile(filePath, []byte("changed\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
fi, err := g.Info(filePath)
if err != nil {
t.Fatalf("Info: %v", err)
}
if fi.State != "modified" {
t.Errorf("State = %q, want %q", fi.State, "modified")
}
if fi.CurrentHash == fi.Hash {
t.Error("CurrentHash should differ from Hash after modification")
}
}
func TestInfoMissingFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
filePath := filepath.Join(root, "hello.txt")
if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := g.Add([]string{filePath}); err != nil {
t.Fatalf("Add: %v", err)
}
// Remove the file.
if err := os.Remove(filePath); err != nil {
t.Fatalf("Remove: %v", err)
}
fi, err := g.Info(filePath)
if err != nil {
t.Fatalf("Info: %v", err)
}
if fi.State != "missing" {
t.Errorf("State = %q, want %q", fi.State, "missing")
}
if fi.DiskModTime != "" {
t.Errorf("DiskModTime = %q, want empty for missing file", fi.DiskModTime)
}
}
func TestInfoUntracked(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
filePath := filepath.Join(root, "nope.txt")
if err := os.WriteFile(filePath, []byte("nope\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err = g.Info(filePath)
if err == nil {
t.Fatal("Info should fail for untracked file")
}
}
func TestInfoSymlink(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
target := filepath.Join(root, "target.txt")
if err := os.WriteFile(target, []byte("target\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
linkPath := filepath.Join(root, "link.txt")
if err := os.Symlink(target, linkPath); err != nil {
t.Fatalf("Symlink: %v", err)
}
if err := g.Add([]string{linkPath}); err != nil {
t.Fatalf("Add: %v", err)
}
fi, err := g.Info(linkPath)
if err != nil {
t.Fatalf("Info: %v", err)
}
if fi.Type != "link" {
t.Errorf("Type = %q, want %q", fi.Type, "link")
}
if fi.State != "ok" {
t.Errorf("State = %q, want %q", fi.State, "ok")
}
if fi.Target != target {
t.Errorf("Target = %q, want %q", fi.Target, target)
}
}

View File

@@ -37,7 +37,7 @@ func (g *Garden) MirrorUp(paths []string) error {
if lstatErr != nil { if lstatErr != nil {
return fmt.Errorf("stat %s: %w", path, lstatErr) return fmt.Errorf("stat %s: %w", path, lstatErr)
} }
return g.addEntry(path, fi, now, true, false, false) return g.addEntry(path, fi, now, true, AddOptions{})
}) })
if err != nil { if err != nil {
return fmt.Errorf("walking directory %s: %w", abs, err) return fmt.Errorf("walking directory %s: %w", abs, err)

65
garden/tags.go Normal file
View File

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

34
garden/target.go Normal file
View File

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

48
garden/targeting.go Normal file
View File

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

View File

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

238
garden/targeting_test.go Normal file
View File

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

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/keys-pub/go-libfido2 v1.5.3 github.com/keys-pub/go-libfido2 v1.5.3
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
google.golang.org/grpc v1.79.3 google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1

148
integration/phase5_test.go Normal file
View File

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

View File

@@ -20,6 +20,8 @@ type Entry struct {
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"`
Only []string `yaml:"only,omitempty"`
Never []string `yaml:"never,omitempty"`
} }
// KekSlot describes a single KEK source that can unwrap the DEK. // KekSlot describes a single KEK source that can unwrap the DEK.

View File

@@ -17,6 +17,8 @@ message ManifestEntry {
string plaintext_hash = 7; // SHA-256 of plaintext (encrypted entries only) string plaintext_hash = 7; // SHA-256 of plaintext (encrypted entries only)
bool encrypted = 8; bool encrypted = 8;
bool locked = 9; // repo-authoritative; restore always overwrites bool locked = 9; // repo-authoritative; restore always overwrites
repeated string only = 10; // per-machine targeting: only apply on matching
repeated string never = 11; // per-machine targeting: never apply on matching
} }
// KekSlot describes a single KEK source for unwrapping the DEK. // KekSlot describes a single KEK source for unwrapping the DEK.

View File

@@ -57,6 +57,8 @@ func EntryToProto(e manifest.Entry) *sgardpb.ManifestEntry {
PlaintextHash: e.PlaintextHash, PlaintextHash: e.PlaintextHash,
Encrypted: e.Encrypted, Encrypted: e.Encrypted,
Locked: e.Locked, Locked: e.Locked,
Only: e.Only,
Never: e.Never,
} }
} }
@@ -72,6 +74,8 @@ func ProtoToEntry(p *sgardpb.ManifestEntry) manifest.Entry {
PlaintextHash: p.GetPlaintextHash(), PlaintextHash: p.GetPlaintextHash(),
Encrypted: p.GetEncrypted(), Encrypted: p.GetEncrypted(),
Locked: p.GetLocked(), Locked: p.GetLocked(),
Only: p.GetOnly(),
Never: p.GetNever(),
} }
} }

View File

@@ -91,6 +91,46 @@ func TestEmptyManifestRoundTrip(t *testing.T) {
} }
} }
func TestTargetingRoundTrip(t *testing.T) {
now := time.Date(2026, 3, 24, 0, 0, 0, 0, time.UTC)
onlyEntry := manifest.Entry{
Path: "~/.bashrc.linux",
Type: "file",
Hash: "abcd",
Only: []string{"os:linux", "tag:work"},
Updated: now,
}
proto := EntryToProto(onlyEntry)
back := ProtoToEntry(proto)
if len(back.Only) != 2 || back.Only[0] != "os:linux" || back.Only[1] != "tag:work" {
t.Errorf("Only round-trip: got %v, want [os:linux tag:work]", back.Only)
}
if len(back.Never) != 0 {
t.Errorf("Never should be empty, got %v", back.Never)
}
neverEntry := manifest.Entry{
Path: "~/.config/heavy",
Type: "file",
Hash: "efgh",
Never: []string{"arch:arm64"},
Updated: now,
}
proto2 := EntryToProto(neverEntry)
back2 := ProtoToEntry(proto2)
if len(back2.Never) != 1 || back2.Never[0] != "arch:arm64" {
t.Errorf("Never round-trip: got %v, want [arch:arm64]", back2.Never)
}
if len(back2.Only) != 0 {
t.Errorf("Only should be empty, got %v", back2.Only)
}
}
func TestEntryEmptyOptionalFieldsRoundTrip(t *testing.T) { func TestEntryEmptyOptionalFieldsRoundTrip(t *testing.T) {
now := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
e := manifest.Entry{ e := manifest.Entry{

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.10 // protoc-gen-go v1.36.11
// protoc v6.32.1 // protoc v7.34.0
// source: sgard/v1/sgard.proto // source: sgard/v1/sgard.proto
package sgardpb package sgardpb
@@ -86,6 +86,8 @@ type ManifestEntry struct {
PlaintextHash string `protobuf:"bytes,7,opt,name=plaintext_hash,json=plaintextHash,proto3" json:"plaintext_hash,omitempty"` // SHA-256 of plaintext (encrypted entries only) PlaintextHash string `protobuf:"bytes,7,opt,name=plaintext_hash,json=plaintextHash,proto3" json:"plaintext_hash,omitempty"` // SHA-256 of plaintext (encrypted entries only)
Encrypted bool `protobuf:"varint,8,opt,name=encrypted,proto3" json:"encrypted,omitempty"` Encrypted bool `protobuf:"varint,8,opt,name=encrypted,proto3" json:"encrypted,omitempty"`
Locked bool `protobuf:"varint,9,opt,name=locked,proto3" json:"locked,omitempty"` // repo-authoritative; restore always overwrites Locked bool `protobuf:"varint,9,opt,name=locked,proto3" json:"locked,omitempty"` // repo-authoritative; restore always overwrites
Only []string `protobuf:"bytes,10,rep,name=only,proto3" json:"only,omitempty"` // per-machine targeting: only apply on matching
Never []string `protobuf:"bytes,11,rep,name=never,proto3" json:"never,omitempty"` // per-machine targeting: never apply on matching
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -183,6 +185,20 @@ func (x *ManifestEntry) GetLocked() bool {
return false return false
} }
func (x *ManifestEntry) GetOnly() []string {
if x != nil {
return x.Only
}
return nil
}
func (x *ManifestEntry) GetNever() []string {
if x != nil {
return x.Never
}
return nil
}
// KekSlot describes a single KEK source for unwrapping the DEK. // KekSlot describes a single KEK source for unwrapping the DEK.
type KekSlot struct { type KekSlot struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -1079,7 +1095,7 @@ var File_sgard_v1_sgard_proto protoreflect.FileDescriptor
const file_sgard_v1_sgard_proto_rawDesc = "" + const file_sgard_v1_sgard_proto_rawDesc = "" +
"\n" + "\n" +
"\x14sgard/v1/sgard.proto\x12\bsgard.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8a\x02\n" + "\x14sgard/v1/sgard.proto\x12\bsgard.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xb4\x02\n" +
"\rManifestEntry\x12\x12\n" + "\rManifestEntry\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
"\x04hash\x18\x02 \x01(\tR\x04hash\x12\x12\n" + "\x04hash\x18\x02 \x01(\tR\x04hash\x12\x12\n" +
@@ -1089,7 +1105,10 @@ const file_sgard_v1_sgard_proto_rawDesc = "" +
"\aupdated\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\aupdated\x12%\n" + "\aupdated\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\aupdated\x12%\n" +
"\x0eplaintext_hash\x18\a \x01(\tR\rplaintextHash\x12\x1c\n" + "\x0eplaintext_hash\x18\a \x01(\tR\rplaintextHash\x12\x1c\n" +
"\tencrypted\x18\b \x01(\bR\tencrypted\x12\x16\n" + "\tencrypted\x18\b \x01(\bR\tencrypted\x12\x16\n" +
"\x06locked\x18\t \x01(\bR\x06locked\"\xe4\x01\n" + "\x06locked\x18\t \x01(\bR\x06locked\x12\x12\n" +
"\x04only\x18\n" +
" \x03(\tR\x04only\x12\x14\n" +
"\x05never\x18\v \x03(\tR\x05never\"\xe4\x01\n" +
"\aKekSlot\x12\x12\n" + "\aKekSlot\x12\x12\n" +
"\x04type\x18\x01 \x01(\tR\x04type\x12\x1f\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x1f\n" +
"\vargon2_time\x18\x02 \x01(\x05R\n" + "\vargon2_time\x18\x02 \x01(\x05R\n" +

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.5.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1 // - protoc v7.34.0
// source: sgard/v1/sgard.proto // source: sgard/v1/sgard.proto
package sgardpb package sgardpb
@@ -152,22 +152,22 @@ type GardenSyncServer interface {
type UnimplementedGardenSyncServer struct{} type UnimplementedGardenSyncServer struct{}
func (UnimplementedGardenSyncServer) Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error) { func (UnimplementedGardenSyncServer) Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Authenticate not implemented") return nil, status.Error(codes.Unimplemented, "method Authenticate not implemented")
} }
func (UnimplementedGardenSyncServer) PushManifest(context.Context, *PushManifestRequest) (*PushManifestResponse, error) { func (UnimplementedGardenSyncServer) PushManifest(context.Context, *PushManifestRequest) (*PushManifestResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PushManifest not implemented") return nil, status.Error(codes.Unimplemented, "method PushManifest not implemented")
} }
func (UnimplementedGardenSyncServer) PushBlobs(grpc.ClientStreamingServer[PushBlobsRequest, PushBlobsResponse]) error { func (UnimplementedGardenSyncServer) PushBlobs(grpc.ClientStreamingServer[PushBlobsRequest, PushBlobsResponse]) error {
return status.Errorf(codes.Unimplemented, "method PushBlobs not implemented") return status.Error(codes.Unimplemented, "method PushBlobs not implemented")
} }
func (UnimplementedGardenSyncServer) PullManifest(context.Context, *PullManifestRequest) (*PullManifestResponse, error) { func (UnimplementedGardenSyncServer) PullManifest(context.Context, *PullManifestRequest) (*PullManifestResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PullManifest not implemented") return nil, status.Error(codes.Unimplemented, "method PullManifest not implemented")
} }
func (UnimplementedGardenSyncServer) PullBlobs(*PullBlobsRequest, grpc.ServerStreamingServer[PullBlobsResponse]) error { func (UnimplementedGardenSyncServer) PullBlobs(*PullBlobsRequest, grpc.ServerStreamingServer[PullBlobsResponse]) error {
return status.Errorf(codes.Unimplemented, "method PullBlobs not implemented") return status.Error(codes.Unimplemented, "method PullBlobs not implemented")
} }
func (UnimplementedGardenSyncServer) Prune(context.Context, *PruneRequest) (*PruneResponse, error) { func (UnimplementedGardenSyncServer) Prune(context.Context, *PruneRequest) (*PruneResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Prune not implemented") return nil, status.Error(codes.Unimplemented, "method Prune not implemented")
} }
func (UnimplementedGardenSyncServer) mustEmbedUnimplementedGardenSyncServer() {} func (UnimplementedGardenSyncServer) mustEmbedUnimplementedGardenSyncServer() {}
func (UnimplementedGardenSyncServer) testEmbeddedByValue() {} func (UnimplementedGardenSyncServer) testEmbeddedByValue() {}
@@ -180,7 +180,7 @@ type UnsafeGardenSyncServer interface {
} }
func RegisterGardenSyncServer(s grpc.ServiceRegistrar, srv GardenSyncServer) { func RegisterGardenSyncServer(s grpc.ServiceRegistrar, srv GardenSyncServer) {
// If the following call pancis, it indicates UnimplementedGardenSyncServer was // If the following call panics, it indicates UnimplementedGardenSyncServer was
// embedded by pointer and is nil. This will cause panics if an // embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization // unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O. // time to prevent it from happening at runtime later due to I/O.