restic: restore away from a directory another account owns

Root writes a restore beside the live data. In a directory another account
controls, such as the agent's home (hq ADR 0266), that account could put a
symbolic link where root writes and have root write anywhere; such a restore
now goes to the module's state.
This commit is contained in:
jochen
2026-10-09 10:14:18 +02:00
parent b57e6e8144
commit dae7bb848d
3 changed files with 80 additions and 2 deletions
+44 -1
View File
@@ -29,6 +29,7 @@ import (
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -432,7 +433,9 @@ type Restored struct {
}
// Restore puts a module's data from a restore point BESIDE the live data: each directory as
// <path>.restored-<stamp>. A target that already exists is refused, never overwritten.
// <path>.restored-<stamp> — or, where a directory on the way belongs to another account, in this module's
// state as restored/<stamp>/<module>/<path> (besideIsUnsafe). A target that already exists is refused, never
// overwritten.
func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*Restored, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -468,6 +471,17 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*
r := &Restored{Module: module, From: chosen, Live: "untouched — swapping it in is a person's act"}
for _, p := range paths {
target := p + ".restored-" + stamp
if why := b.besideIsUnsafe(ctx, target); why != "" {
// Root writes the restore. Beside a path another account controls, that account could put a
// symbolic link where root is about to write and have root write wherever it points (novox/hq ADR
// 0266: the agent's own home is such a place). Kept in this module's state instead, which only
// root and the runtime's account reach.
target = filepath.Join(b.Where.State, "restored", stamp, module) + p
if _, err := b.Run(ctx, "mkdir", "-p", "-m", "0700", filepath.Dir(target)); err != nil {
return nil, fmt.Errorf("%s: %s, and the place in this module's state could not be made: %w", p, why, err)
}
b.Say("%s: %s; restoring into this module's state instead", p, why)
}
if b.exists(ctx, target) {
return nil, fmt.Errorf("%s already exists; nothing is restored over anything", target)
}
@@ -480,6 +494,35 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*
return r, nil
}
// besideIsUnsafe says why root must not restore at target, beside the live data: a directory on the way to it
// is a symbolic link, or belongs to an account other than root and the one this process runs as — one that
// could swap a link in while root writes. Empty when every directory on the way is root's or the runtime's.
func (b *Backups) besideIsUnsafe(ctx context.Context, target string) string {
me := strconv.Itoa(os.Getuid())
dir := filepath.Dir(filepath.Clean(target))
var chain []string
for d := dir; ; d = filepath.Dir(d) {
chain = append(chain, d)
if d == "/" || d == "." {
break
}
}
for i := len(chain) - 1; i >= 0; i-- {
out, err := b.Run(ctx, "stat", "-c", "%u %F", chain[i])
if err != nil {
return chain[i] + " could not be read"
}
owner, kind, _ := strings.Cut(strings.TrimSpace(out), " ")
if kind == "symbolic link" {
return chain[i] + " is a symbolic link"
}
if owner != "0" && owner != me {
return chain[i] + " belongs to another account"
}
}
return ""
}
// restoreOne puts one kept path at target. A directory is restored as the snapshot's subfolder, so
// its contents land directly in target; a single file cannot be — restic restores a subfolder, not a
// file (a settings file refused with "not a directory" on the first restore of an app, 2026-10-05) —
@@ -284,3 +284,38 @@ func TestWhetherADirectoryIsThereIsAskedAsRoot(t *testing.T) {
t.Errorf("asked %v", asked)
}
}
// Beside a directory another account owns, root does not restore: a link swapped in there would point root's
// write anywhere (novox/hq ADR 0266). The restore goes to the module's state instead.
func TestARestoreBesideAnotherAccountsDirectoryGoesToTheState(t *testing.T) {
state := t.TempDir()
var ran []string
run := func(ctx context.Context, name string, args ...string) (string, error) {
ran = append(ran, name+" "+strings.Join(args, " "))
if name == "stat" {
if args[len(args)-1] == "/home/agent" {
return "1001 directory\n", nil
}
return "0 directory\n", nil
}
return "", nil
}
b := &Backups{Where: Where{State: state}, Run: run, Now: time.Now, Say: func(string, ...any) {}}
if why := b.besideIsUnsafe(context.Background(), "/home/agent/.claude.restored-x"); !strings.Contains(why, "another account") {
t.Fatalf("an agent's home is unsafe beside: %q", why)
}
if why := b.besideIsUnsafe(context.Background(), "/srv/data.restored-x"); why != "" {
t.Fatalf("root's directories are safe beside: %q", why)
}
run2 := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "stat" && args[len(args)-1] == "/home" {
return "0 symbolic link\n", nil
}
return "0 directory\n", nil
}
b.Run = run2
if why := b.besideIsUnsafe(context.Background(), "/home/agent/x"); !strings.Contains(why, "symbolic link") {
t.Fatalf("a link on the way is unsafe: %q", why)
}
_ = ran
}
+1 -1
View File
@@ -141,7 +141,7 @@ func tools(b *Backups) []stdio.Tool {
}
return map[string]any{"started": started, "follow": Seat + ".backed-up"}, nil
}),
verb("restore", "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as <path>.restored-<date>. Swapping it in is a person's act.",
verb("restore", "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as <path>.restored-<date>, or in this module's state when a directory on the way belongs to another account. Swapping it in is a person's act.",
map[string]any{
"module": str("the module"),
"snapshot": str("the restore point (the newest when omitted)"),