f0dfa689fe
Three small follow-ups from review:
1. Restore target is now operator-editable. Default value is the
literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
run time using os.UserHomeDir(); also handles \${HOME} and ~/
prefixes). Operator can replace with any absolute path.
- ui_restore.go validates the input is either absolute or starts
with one of the recognised prefixes; other env-var refs (\$PATH
etc.) are deliberately rejected so operator paths can't pick up
arbitrary agent env values.
- host_restore.html replaces the read-only mono-text display with
a real <input>; help text spells out that \$HOME resolves
agent-side and <job-id> is substituted on dispatch.
- install.sh + the systemd unit prep /root/rm-restore so the
default works under the sandbox: ReadWritePaths gains a soft
'-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
if missing, but install.sh pre-creates it root-owned 0700).
2. --no-ownership flag now gated on restic version. The flag was
added in restic 0.17 and 0.16 rejects it. Previously dropped it
wholesale — that meant new-dir restores silently preserved
ownership against design intent on 0.17+. Now the agent threads
its detected restic version (sysinfo already collects it) through
runner.Config -> restic.Env, and RunRestore appends --no-ownership
only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
restore with original uid/gid; help text in the wizard explicitly
notes this. The previous 'Original ownership is preserved' copy
was wrong for new-dir mode and is corrected.
3. golangci-lint misspell locale switched US -> UK and the codebase
swept (73 corrections, mostly behaviour/serialise/recognise/honour).
Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
contract change but the agent doesn't parse those codes today and
no external API consumers exist yet. Tests passed before + after.
Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
edge cases (empty, exact match, patch above, minor below, non-
numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
with the job_id substituted into the placeholder.
Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
141 lines
3.9 KiB
Go
141 lines
3.9 KiB
Go
package restic
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
// LsEntry is one node from `restic ls --json`. Restic emits these as
|
|
// line-delimited JSON; we keep only the fields the restore wizard
|
|
// needs.
|
|
type LsEntry struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size,omitempty"`
|
|
Struct string `json:"struct_type,omitempty"`
|
|
}
|
|
|
|
// ListTreeChildren runs `restic ls --json <snapshot> <dirPath>` and
|
|
// returns only the direct children of dirPath. Restic ls is recursive
|
|
// by default, so we filter post-hoc — for a typical interactive
|
|
// drill-down ("expand /etc/nginx") the subtree is small (a few KB of
|
|
// JSON); for huge subtrees this is suboptimal but correct.
|
|
//
|
|
// The first emitted line is restic's "snapshot" preamble (struct_type
|
|
// = "snapshot") which we discard. Subsequent lines are nodes; we
|
|
// match on path equal to dirPath + "/" + name (with normalisation so
|
|
// trailing slashes don't break the comparison).
|
|
//
|
|
// dirPath="" or "/" lists the snapshot root.
|
|
func (e Env) ListTreeChildren(ctx context.Context, snapshotID, dirPath string) ([]LsEntry, error) {
|
|
if snapshotID == "" {
|
|
return nil, fmt.Errorf("restic ls: snapshot id required")
|
|
}
|
|
parent := normalizeTreePath(dirPath)
|
|
|
|
args := []string{"ls", "--json", snapshotID}
|
|
if parent != "/" {
|
|
args = append(args, parent)
|
|
}
|
|
cmd := e.resticCmd(ctx, args...)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("restic ls: stdout pipe: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("restic ls: start: %w", err)
|
|
}
|
|
|
|
out, parseErr := parseLsChildren(stdout, parent)
|
|
|
|
werr := cmd.Wait()
|
|
if werr != nil {
|
|
var ee *exec.ExitError
|
|
if errors.As(werr, &ee) {
|
|
return nil, fmt.Errorf("restic ls: exit %d: %s",
|
|
ee.ExitCode(), strings.TrimSpace(stderr.String()))
|
|
}
|
|
return nil, fmt.Errorf("restic ls: %w", werr)
|
|
}
|
|
if parseErr != nil {
|
|
return nil, parseErr
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseLsChildren reads line-delimited JSON from r and returns nodes
|
|
// whose Path is a direct child of parent. Exposed for testing.
|
|
func parseLsChildren(r io.Reader, parent string) ([]LsEntry, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
var out []LsEntry
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var entry LsEntry
|
|
if err := json.Unmarshal(line, &entry); err != nil {
|
|
return nil, fmt.Errorf("restic ls: parse line: %w", err)
|
|
}
|
|
// Skip the snapshot preamble and any future struct_type
|
|
// entries we don't care about.
|
|
if entry.Struct == "snapshot" || entry.Path == "" {
|
|
continue
|
|
}
|
|
if isDirectChild(entry.Path, parent) {
|
|
out = append(out, entry)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("restic ls: read output: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// normalizeTreePath turns "" / "/" / "/etc/" / "etc" all into a
|
|
// canonical absolute form with a leading slash and no trailing slash
|
|
// (except the root, which is "/" alone).
|
|
func normalizeTreePath(p string) string {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" || p == "/" {
|
|
return "/"
|
|
}
|
|
if !strings.HasPrefix(p, "/") {
|
|
p = "/" + p
|
|
}
|
|
cleaned := path.Clean(p)
|
|
return cleaned
|
|
}
|
|
|
|
// isDirectChild reports whether childPath is a direct child of parent.
|
|
// "/etc/nginx" is a direct child of "/etc"; "/etc/nginx/conf" is not.
|
|
// "/etc" is a direct child of "/".
|
|
func isDirectChild(childPath, parent string) bool {
|
|
cp := normalizeTreePath(childPath)
|
|
pp := normalizeTreePath(parent)
|
|
if pp == "/" {
|
|
// Direct children of root: exactly one slash-delimited segment.
|
|
return cp != "/" && strings.Count(cp, "/") == 1
|
|
}
|
|
// Must start with parent + "/" and have no further slashes.
|
|
prefix := pp + "/"
|
|
if !strings.HasPrefix(cp, prefix) {
|
|
return false
|
|
}
|
|
rest := cp[len(prefix):]
|
|
return rest != "" && !strings.Contains(rest, "/")
|
|
}
|