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:
@@ -29,6 +29,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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
|
// 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) {
|
func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*Restored, error) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
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"}
|
r := &Restored{Module: module, From: chosen, Live: "untouched — swapping it in is a person's act"}
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
target := p + ".restored-" + stamp
|
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) {
|
if b.exists(ctx, target) {
|
||||||
return nil, fmt.Errorf("%s already exists; nothing is restored over anything", 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
|
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
|
// 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
|
// 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) —
|
// 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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func tools(b *Backups) []stdio.Tool {
|
|||||||
}
|
}
|
||||||
return map[string]any{"started": started, "follow": Seat + ".backed-up"}, nil
|
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{
|
map[string]any{
|
||||||
"module": str("the module"),
|
"module": str("the module"),
|
||||||
"snapshot": str("the restore point (the newest when omitted)"),
|
"snapshot": str("the restore point (the newest when omitted)"),
|
||||||
|
|||||||
Reference in New Issue
Block a user