Add locked files and directory-only entries.
Locked files (--lock): repo-authoritative entries. Checkpoint skips them (preserves repo version). Status reports "drifted" instead of "modified". Restore always overwrites if hash differs, no prompt. Use case: system-managed files the OS overwrites. Directory-only entries (--dir): track directory itself without recursing. Restore ensures directory exists with correct permissions. Use case: directories that must exist but contents are managed elsewhere. Add refactored to use AddOptions struct (Encrypt, Lock, DirOnly) instead of variadic bools. Proto: ManifestEntry gains locked field. convert.go updated. 7 new tests. ARCHITECTURE.md and README.md updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -687,6 +687,27 @@ on disk is newer or the times match, sgard prompts for confirmation.
|
|||||||
**Timestamp comparison truncates to seconds** for cross-platform filesystem
|
**Timestamp comparison truncates to seconds** for cross-platform filesystem
|
||||||
compatibility.
|
compatibility.
|
||||||
|
|
||||||
|
**Locked files (`--lock`).** A locked entry is repo-authoritative — the
|
||||||
|
on-disk copy is treated as potentially corrupted by the system, not as
|
||||||
|
a user edit. Semantics:
|
||||||
|
- **`add --lock`** — tracks the file normally, marks it as locked
|
||||||
|
- **`checkpoint`** — skips locked files entirely (preserves the repo version)
|
||||||
|
- **`status`** — reports locked files with changed hashes as `drifted`
|
||||||
|
(distinct from `modified`, which implies a user edit)
|
||||||
|
- **`restore`** — always restores locked files if the hash differs,
|
||||||
|
regardless of timestamp, without prompting. Skips if hash matches.
|
||||||
|
- **`add`** (without `--lock`) — can be used to explicitly update a locked
|
||||||
|
file in the repo when the on-disk version is intentionally new
|
||||||
|
|
||||||
|
Use case: system-managed files like `~/.config/user-dirs.dirs` that get
|
||||||
|
overwritten by the OS but should be kept at a known-good state.
|
||||||
|
|
||||||
|
**Directory-only entries (`--dir`).** `add --dir <path>` tracks the
|
||||||
|
directory itself as a structural entry without recursing into its
|
||||||
|
contents. On restore, sgard ensures the directory exists with the
|
||||||
|
correct permissions. Use case: directories that must exist for other
|
||||||
|
software to function, but whose contents are managed elsewhere.
|
||||||
|
|
||||||
**Remote config resolution:** `--remote` flag > `SGARD_REMOTE` env >
|
**Remote config resolution:** `--remote` flag > `SGARD_REMOTE` env >
|
||||||
`<repo>/remote` file.
|
`<repo>/remote` file.
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ sgard restore --repo /mnt/usb/dotfiles
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `init` | Create a new repository |
|
| `init` | Create a new repository |
|
||||||
| `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 --dir <path>` | Track directory itself without recursing into contents |
|
||||||
| `remove <path>...` | Stop tracking files |
|
| `remove <path>...` | Stop tracking files |
|
||||||
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
||||||
| `restore [path...] [-f]` | Restore files to their original locations |
|
| `restore [path...] [-f]` | Restore files to their original locations |
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var encryptFlag bool
|
var (
|
||||||
|
encryptFlag bool
|
||||||
|
lockFlag bool
|
||||||
|
dirOnlyFlag bool
|
||||||
|
)
|
||||||
|
|
||||||
var addCmd = &cobra.Command{
|
var addCmd = &cobra.Command{
|
||||||
Use: "add <path>...",
|
Use: "add <path>...",
|
||||||
@@ -31,7 +35,13 @@ var addCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add(args, encryptFlag); err != nil {
|
opts := garden.AddOptions{
|
||||||
|
Encrypt: encryptFlag,
|
||||||
|
Lock: lockFlag,
|
||||||
|
DirOnly: dirOnlyFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add(args, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,5 +61,7 @@ func promptPassphrase() (string, error) {
|
|||||||
|
|
||||||
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(&dirOnlyFlag, "dir", false, "track directory itself without recursing into contents")
|
||||||
rootCmd.AddCommand(addCmd)
|
rootCmd.AddCommand(addCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestEncryptionE2E(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypted files.
|
// Encrypted files.
|
||||||
if err := g.Add([]string{sshConfig, awsCreds}, true); err != nil {
|
if err := g.Add([]string{sshConfig, awsCreds}, AddOptions{Encrypt: true}); err != nil {
|
||||||
t.Fatalf("Add encrypted: %v", err)
|
t.Fatalf("Add encrypted: %v", err)
|
||||||
}
|
}
|
||||||
// Plaintext file.
|
// Plaintext file.
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ func TestEncryptedRoundTripWithFIDO2(t *testing.T) {
|
|||||||
t.Fatalf("writing: %v", err)
|
t.Fatalf("writing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add([]string{secretFile}, true); err != nil {
|
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
|
||||||
t.Fatalf("Add: %v", err)
|
t.Fatalf("Add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func TestAddEncrypted(t *testing.T) {
|
|||||||
t.Fatalf("writing secret file: %v", err)
|
t.Fatalf("writing secret file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add([]string{secretFile}, true); err != nil {
|
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
|
||||||
t.Fatalf("Add encrypted: %v", err)
|
t.Fatalf("Add encrypted: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ func TestEncryptedRestoreRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("writing: %v", err)
|
t.Fatalf("writing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add([]string{secretFile}, true); err != nil {
|
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
|
||||||
t.Fatalf("Add: %v", err)
|
t.Fatalf("Add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestEncryptedCheckpoint(t *testing.T) {
|
|||||||
t.Fatalf("writing: %v", err)
|
t.Fatalf("writing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add([]string{secretFile}, true); err != nil {
|
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
|
||||||
t.Fatalf("Add: %v", err)
|
t.Fatalf("Add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ func TestEncryptedStatus(t *testing.T) {
|
|||||||
t.Fatalf("writing: %v", err)
|
t.Fatalf("writing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add([]string{secretFile}, true); err != nil {
|
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
|
||||||
t.Fatalf("Add: %v", err)
|
t.Fatalf("Add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ func TestEncryptedDiff(t *testing.T) {
|
|||||||
t.Fatalf("writing: %v", err)
|
t.Fatalf("writing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Add([]string{secretFile}, true); err != nil {
|
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
|
||||||
t.Fatalf("Add: %v", err)
|
t.Fatalf("Add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ func TestAddEncryptedRequiresDEK(t *testing.T) {
|
|||||||
t.Fatalf("writing: %v", err)
|
t.Fatalf("writing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = g.Add([]string{testFile}, true)
|
err = g.Add([]string{testFile}, AddOptions{Encrypt: true})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Add --encrypt without DEK should fail")
|
t.Fatal("Add --encrypt without DEK should fail")
|
||||||
}
|
}
|
||||||
|
|||||||
104
garden/garden.go
104
garden/garden.go
@@ -138,11 +138,10 @@ func (g *Garden) DeleteBlob(hash string) error {
|
|||||||
return g.store.Delete(hash)
|
return g.store.Delete(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addEntry adds a single file or symlink to the manifest. The abs path must
|
// addEntry adds a single file or symlink to the manifest. If skipDup is true,
|
||||||
// already be resolved and info must come from os.Lstat. If skipDup is true,
|
// already-tracked paths are silently skipped. If encrypt is true, the file
|
||||||
// already-tracked paths are silently skipped instead of returning an error.
|
// blob is encrypted before storing. If lock is true, the entry is marked locked.
|
||||||
// If encrypt is true, the file blob is encrypted before storing.
|
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt, lock bool) error {
|
||||||
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt bool) error {
|
|
||||||
tilded := toTildePath(abs)
|
tilded := toTildePath(abs)
|
||||||
|
|
||||||
if g.findEntry(tilded) != nil {
|
if g.findEntry(tilded) != nil {
|
||||||
@@ -155,6 +154,7 @@ 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,
|
||||||
Updated: now,
|
Updated: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,15 +198,23 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddOptions controls the behavior of Add.
|
||||||
|
type AddOptions struct {
|
||||||
|
Encrypt bool // encrypt file blobs before storing
|
||||||
|
Lock bool // mark entries as locked (repo-authoritative)
|
||||||
|
DirOnly bool // for directories: track the directory itself, don't recurse
|
||||||
|
}
|
||||||
|
|
||||||
// Add tracks new files, directories, or symlinks. Each path is resolved
|
// Add tracks new files, directories, or symlinks. Each path is resolved
|
||||||
// to an absolute path, inspected for its type, and added to the manifest.
|
// to an absolute path, inspected for its type, and added to the manifest.
|
||||||
// Regular files are hashed and stored in the blob store. Directories are
|
// Regular files are hashed and stored in the blob store. Directories are
|
||||||
// recursively walked and all leaf files and symlinks are added individually.
|
// recursively walked unless opts.DirOnly is set.
|
||||||
// If encrypt is true, file blobs are encrypted before storing (requires
|
func (g *Garden) Add(paths []string, opts ...AddOptions) error {
|
||||||
// the DEK to be unlocked).
|
var o AddOptions
|
||||||
func (g *Garden) Add(paths []string, encrypt ...bool) error {
|
if len(opts) > 0 {
|
||||||
enc := len(encrypt) > 0 && encrypt[0]
|
o = opts[0]
|
||||||
if enc && g.dek == nil {
|
}
|
||||||
|
if o.Encrypt && g.dek == nil {
|
||||||
return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first")
|
return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,24 +232,40 @@ func (g *Garden) Add(paths []string, encrypt ...bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
if o.DirOnly {
|
||||||
|
// Track the directory itself as a structural entry.
|
||||||
|
tilded := toTildePath(abs)
|
||||||
|
if g.findEntry(tilded) != nil {
|
||||||
|
return fmt.Errorf("already tracking %s", tilded)
|
||||||
|
}
|
||||||
|
entry := manifest.Entry{
|
||||||
|
Path: tilded,
|
||||||
|
Type: "directory",
|
||||||
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||||
|
Locked: o.Lock,
|
||||||
|
Updated: now,
|
||||||
|
}
|
||||||
|
g.manifest.Files = append(g.manifest.Files, entry)
|
||||||
|
} else {
|
||||||
|
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fi, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return g.addEntry(path, fi, now, true, o.Encrypt, o.Lock)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fi, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("stat %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return g.addEntry(path, fi, now, true, enc)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("walking directory %s: %w", abs, err)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := g.addEntry(abs, info, now, false, enc); err != nil {
|
if err := g.addEntry(abs, info, now, false, o.Encrypt, o.Lock); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +308,11 @@ func (g *Garden) Checkpoint(message string) error {
|
|||||||
|
|
||||||
entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
|
entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
|
||||||
|
|
||||||
|
// Locked entries are repo-authoritative — checkpoint skips them.
|
||||||
|
if entry.Locked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
switch entry.Type {
|
switch entry.Type {
|
||||||
case "file":
|
case "file":
|
||||||
data, err := os.ReadFile(abs)
|
data, err := os.ReadFile(abs)
|
||||||
@@ -379,7 +408,11 @@ func (g *Garden) Status() ([]FileStatus, error) {
|
|||||||
compareHash = entry.PlaintextHash
|
compareHash = entry.PlaintextHash
|
||||||
}
|
}
|
||||||
if hash != compareHash {
|
if hash != compareHash {
|
||||||
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
if entry.Locked {
|
||||||
|
results = append(results, FileStatus{Path: entry.Path, State: "drifted"})
|
||||||
|
} else {
|
||||||
|
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
||||||
}
|
}
|
||||||
@@ -425,12 +458,21 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b
|
|||||||
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file exists and whether we need confirmation.
|
// Locked entries always restore if content differs — no prompt.
|
||||||
if !force {
|
if entry.Locked && entry.Type == "file" {
|
||||||
|
if currentHash, err := HashFile(abs); err == nil {
|
||||||
|
compareHash := entry.Hash
|
||||||
|
if entry.Encrypted && entry.PlaintextHash != "" {
|
||||||
|
compareHash = entry.PlaintextHash
|
||||||
|
}
|
||||||
|
if currentHash == compareHash {
|
||||||
|
continue // already matches, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// File is missing or hash differs — proceed to restore.
|
||||||
|
} else if !force {
|
||||||
|
// Normal entries: check timestamp for confirmation.
|
||||||
if info, err := os.Lstat(abs); err == nil {
|
if info, err := os.Lstat(abs); err == nil {
|
||||||
// File exists. If on-disk mtime >= manifest updated, ask.
|
|
||||||
// Truncate to seconds because filesystem mtime granularity
|
|
||||||
// varies across platforms.
|
|
||||||
diskTime := info.ModTime().Truncate(time.Second)
|
diskTime := info.ModTime().Truncate(time.Second)
|
||||||
entryTime := entry.Updated.Truncate(time.Second)
|
entryTime := entry.Updated.Truncate(time.Second)
|
||||||
if !diskTime.Before(entryTime) {
|
if !diskTime.Before(entryTime) {
|
||||||
|
|||||||
229
garden/locked_test.go
Normal file
229
garden/locked_test.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package garden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddLocked(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("locked content\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !g.manifest.Files[0].Locked {
|
||||||
|
t.Error("entry should be locked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointSkipsLocked(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origHash := g.manifest.Files[0].Hash
|
||||||
|
|
||||||
|
// Modify the file — checkpoint should NOT update the hash.
|
||||||
|
if err := os.WriteFile(testFile, []byte("system overwrote this"), 0o644); err != nil {
|
||||||
|
t.Fatalf("modifying: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Checkpoint(""); err != nil {
|
||||||
|
t.Fatalf("Checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.manifest.Files[0].Hash != origHash {
|
||||||
|
t.Error("checkpoint should skip locked files — hash should not change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusReportsDrifted(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify — status should report "drifted" not "modified".
|
||||||
|
if err := os.WriteFile(testFile, []byte("system changed this"), 0o644); err != nil {
|
||||||
|
t.Fatalf("modifying: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err := g.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Status: %v", err)
|
||||||
|
}
|
||||||
|
if len(statuses) != 1 || statuses[0].State != "drifted" {
|
||||||
|
t.Errorf("expected drifted, got %v", statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreAlwaysRestoresLocked(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("correct content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System overwrites the file.
|
||||||
|
if err := os.WriteFile(testFile, []byte("system garbage"), 0o644); err != nil {
|
||||||
|
t.Fatalf("overwriting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore without --force — locked files should still be restored.
|
||||||
|
if err := g.Restore(nil, false, nil); err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(testFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "correct content" {
|
||||||
|
t.Errorf("content = %q, want %q", got, "correct content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreSkipsLockedWhenHashMatches(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File is unchanged — restore should skip it (no unnecessary writes).
|
||||||
|
if err := g.Restore(nil, false, nil); err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got here without error, it means it didn't try to overwrite
|
||||||
|
// an identical file, which is correct.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddDirOnly(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a directory with a file inside.
|
||||||
|
testDir := filepath.Join(root, "testdir")
|
||||||
|
if err := os.MkdirAll(testDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("creating dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(testDir, "file"), []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add with --dir — should NOT recurse.
|
||||||
|
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.manifest.Files) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry (directory), got %d", len(g.manifest.Files))
|
||||||
|
}
|
||||||
|
if g.manifest.Files[0].Type != "directory" {
|
||||||
|
t.Errorf("type = %s, want directory", g.manifest.Files[0].Type)
|
||||||
|
}
|
||||||
|
if g.manifest.Files[0].Hash != "" {
|
||||||
|
t.Error("directory entry should have no hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirOnlyRestoreCreatesDirectory(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testDir := filepath.Join(root, "testdir")
|
||||||
|
if err := os.MkdirAll(testDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("creating dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove directory.
|
||||||
|
_ = os.RemoveAll(testDir)
|
||||||
|
|
||||||
|
// Restore should recreate it.
|
||||||
|
if err := g.Restore(nil, true, nil); err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(testDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("directory not restored: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Error("restored path should be a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
return g.addEntry(path, fi, now, true, false, false)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("walking directory %s: %w", abs, err)
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Entry struct {
|
|||||||
Hash string `yaml:"hash,omitempty"`
|
Hash string `yaml:"hash,omitempty"`
|
||||||
PlaintextHash string `yaml:"plaintext_hash,omitempty"`
|
PlaintextHash string `yaml:"plaintext_hash,omitempty"`
|
||||||
Encrypted bool `yaml:"encrypted,omitempty"`
|
Encrypted bool `yaml:"encrypted,omitempty"`
|
||||||
|
Locked bool `yaml:"locked,omitempty"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Mode string `yaml:"mode,omitempty"`
|
Mode string `yaml:"mode,omitempty"`
|
||||||
Target string `yaml:"target,omitempty"`
|
Target string `yaml:"target,omitempty"`
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ message ManifestEntry {
|
|||||||
google.protobuf.Timestamp updated = 6;
|
google.protobuf.Timestamp updated = 6;
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// KekSlot describes a single KEK source for unwrapping the DEK.
|
// KekSlot describes a single KEK source for unwrapping the DEK.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func EntryToProto(e manifest.Entry) *sgardpb.ManifestEntry {
|
|||||||
Updated: timestamppb.New(e.Updated),
|
Updated: timestamppb.New(e.Updated),
|
||||||
PlaintextHash: e.PlaintextHash,
|
PlaintextHash: e.PlaintextHash,
|
||||||
Encrypted: e.Encrypted,
|
Encrypted: e.Encrypted,
|
||||||
|
Locked: e.Locked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ func ProtoToEntry(p *sgardpb.ManifestEntry) manifest.Entry {
|
|||||||
Updated: p.GetUpdated().AsTime(),
|
Updated: p.GetUpdated().AsTime(),
|
||||||
PlaintextHash: p.GetPlaintextHash(),
|
PlaintextHash: p.GetPlaintextHash(),
|
||||||
Encrypted: p.GetEncrypted(),
|
Encrypted: p.GetEncrypted(),
|
||||||
|
Locked: p.GetLocked(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ type ManifestEntry struct {
|
|||||||
Updated *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=updated,proto3" json:"updated,omitempty"`
|
Updated *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=updated,proto3" json:"updated,omitempty"`
|
||||||
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
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -175,6 +176,13 @@ func (x *ManifestEntry) GetEncrypted() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ManifestEntry) GetLocked() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Locked
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 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"`
|
||||||
@@ -1071,7 +1079,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\"\xf2\x01\n" +
|
"\x14sgard/v1/sgard.proto\x12\bsgard.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8a\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" +
|
||||||
@@ -1080,7 +1088,8 @@ const file_sgard_v1_sgard_proto_rawDesc = "" +
|
|||||||
"\x06target\x18\x05 \x01(\tR\x06target\x124\n" +
|
"\x06target\x18\x05 \x01(\tR\x06target\x124\n" +
|
||||||
"\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\"\xe4\x01\n" +
|
"\tencrypted\x18\b \x01(\bR\tencrypted\x12\x16\n" +
|
||||||
|
"\x06locked\x18\t \x01(\bR\x06locked\"\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" +
|
||||||
|
|||||||
Reference in New Issue
Block a user