9 Commits

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:24:23 -07:00
17 changed files with 734 additions and 59 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 |
@@ -675,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
@@ -686,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
@@ -707,7 +708,8 @@ sgard/
sgardpb/ # Generated protobuf + gRPC Go code sgardpb/ # Generated protobuf + gRPC Go code
proto/sgard/v1/ # Proto source definitions proto/sgard/v1/ # Proto source definitions
flake.nix # Nix flake (builds sgard + sgardd) VERSION # Semver string, read by flake.nix; synced from latest git tag via `make version`
flake.nix # Nix flake (builds sgard + sgardd, version from VERSION file)
.goreleaser.yaml # GoReleaser (builds both binaries) .goreleaser.yaml # GoReleaser (builds both binaries)
``` ```
@@ -734,6 +736,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

@@ -1,4 +1,6 @@
.PHONY: proto build test lint clean VERSION := $(shell git describe --tags --abbrev=0 | sed 's/^v//')
.PHONY: proto build test lint clean version
proto: proto:
protoc \ protoc \
@@ -7,8 +9,11 @@ proto:
-I proto \ -I proto \
proto/sgard/v1/sgard.proto proto/sgard/v1/sgard.proto
version:
@echo $(VERSION) > VERSION
build: build:
go build ./... go build -ldflags "-X main.version=$(VERSION)" ./...
test: test:
go test ./... go test ./...

View File

@@ -44,6 +44,17 @@ ARCHITECTURE.md for design details.
Phase 6: Manifest Signing (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
- **Manifest signing**: deferred — trust model (which key signs, how do - **Manifest signing**: deferred — trust model (which key signs, how do
@@ -97,3 +108,8 @@ Phase 6: Manifest Signing (to be planned).
| 2026-03-24 | 30 | Targeting CLI: tag add/remove/list, identity, --only/--never on add, target command. | | 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 | 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-24 | 32 | Phase 5 polish: e2e test (targeting + push/pull + restore), docs updated. Phase 5 complete. |
| 2026-03-25 | — | `sgard info` command: shows detailed file information (status, hash, timestamps, mode, encryption, targeting). 5 tests. |
| 2026-03-25 | — | Deploy sgardd to rift: Dockerfile, docker-compose, mc-proxy L4 route on :9443, Metacrypt TLS cert, DNS. |
| 2026-03-25 | — | `sgard remote set/show`: persistent remote config in `<repo>/remote.yaml` (addr, tls, tls_ca). |
| 2026-03-26 | — | `sgard list` remote support: uses `resolveRemoteConfig()` to list server manifest via `PullManifest` RPC. Client `List()` method added. |
| 2026-03-26 | — | Version derived from git tags via `VERSION` file. flake.nix reads `VERSION`; Makefile `version` target syncs from latest tag, `build` injects via ldflags. |

1
VERSION Normal file
View File

@@ -0,0 +1 @@
3.1.6

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/server" "github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb" "github.com/kisom/sgard/sgardpb"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
@@ -273,6 +274,22 @@ func (c *Client) doPull(ctx context.Context, g *garden.Garden) (int, error) {
return blobCount, nil return blobCount, nil
} }
// List fetches the server's manifest and returns its entries without
// downloading any blobs. Automatically re-authenticates if needed.
func (c *Client) List(ctx context.Context) ([]manifest.Entry, error) {
var entries []manifest.Entry
err := c.retryOnAuth(ctx, func() error {
resp, err := c.rpc.PullManifest(ctx, &sgardpb.PullManifestRequest{})
if err != nil {
return fmt.Errorf("list remote: %w", err)
}
m := server.ProtoToManifest(resp.GetManifest())
entries = m.Files
return nil
})
return entries, err
}
// Prune requests the server to remove orphaned blobs. Returns the count removed. // Prune requests the server to remove orphaned blobs. Returns the count removed.
// Automatically re-authenticates if needed. // Automatically re-authenticates if needed.
func (c *Client) Prune(ctx context.Context) (int, error) { func (c *Client) Prune(ctx context.Context) (int, error) {

View File

@@ -1,13 +1,12 @@
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 (
@@ -60,11 +59,16 @@ 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)
} }
if len(passphrase) == 0 {
return "", fmt.Errorf("no passphrase provided") return "", fmt.Errorf("no passphrase provided")
}
return string(passphrase), nil
} }
func init() { func init() {

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

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

View File

@@ -1,22 +1,57 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"github.com/kisom/sgard/garden" "github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var listRemoteFlag bool
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all tracked files", Short: "List all tracked files",
Long: "List all tracked files locally, or on the remote server with -r.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if listRemoteFlag {
return listRemote()
}
return listLocal()
},
}
func listLocal() error {
g, err := garden.Open(repoFlag) g, err := garden.Open(repoFlag)
if err != nil { if err != nil {
return err return err
} }
entries := g.List() printEntries(g.List())
return nil
}
func listRemote() error {
ctx := context.Background()
c, cleanup, err := dialRemote(ctx)
if err != nil {
return err
}
defer cleanup()
entries, err := c.List(ctx)
if err != nil {
return err
}
printEntries(entries)
return nil
}
func printEntries(entries []manifest.Entry) {
for _, e := range entries { for _, e := range entries {
switch e.Type { switch e.Type {
case "file": case "file":
@@ -31,11 +66,9 @@ var listCmd = &cobra.Command{
fmt.Printf("%-6s %s\n", "dir", e.Path) fmt.Printf("%-6s %s\n", "dir", e.Path)
} }
} }
return nil
},
} }
func init() { func init() {
listCmd.Flags().BoolVarP(&listRemoteFlag, "use-remote", "r", false, "list files on the remote server")
rootCmd.AddCommand(listCmd) rootCmd.AddCommand(listCmd)
} }

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

30
deploy/docker/Dockerfile Normal file
View File

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

View File

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

View File

@@ -11,17 +11,20 @@
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
in in
let
version = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./VERSION);
in
{ {
packages = { packages = {
sgard = pkgs.buildGoModule { sgard = pkgs.buildGoModule rec {
pname = "sgard"; pname = "sgard";
version = "2.1.0"; inherit version;
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" "-X main.version=${version}" ];
meta = { meta = {
description = "Shimmering Clarity Gardener: dotfile management"; description = "Shimmering Clarity Gardener: dotfile management";
@@ -29,19 +32,19 @@
}; };
}; };
sgard-fido2 = pkgs.buildGoModule { sgard-fido2 = pkgs.buildGoModule rec {
pname = "sgard-fido2"; pname = "sgard-fido2";
version = "2.1.0"; inherit version;
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 ];
tags = [ "fido2" ]; tags = [ "fido2" ];
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" "-X main.version=${version}" ];
meta = { meta = {
description = "Shimmering Clarity Gardener: dotfile management (with FIDO2 hardware support)"; description = "Shimmering Clarity Gardener: dotfile management (with FIDO2 hardware support)";

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

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