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 ` 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, "/") }