1package restic
2
3import (
4 "bufio"
5 "bytes"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os/exec"
10 "time"
11
12 "git.secluded.site/keld/internal/config"
13)
14
15// LsNode represents a single file or directory entry from
16// `restic ls --json`. Each JSON line with message_type "node"
17// produces one LsNode.
18type LsNode struct {
19 Name string `json:"name"`
20 Type string `json:"type"` // "dir", "file", etc.
21 Path string `json:"path"`
22 Size int64 `json:"size,omitempty"` // absent for directories
23 Mode int `json:"mode"`
24 Permissions string `json:"permissions"`
25 Mtime time.Time `json:"mtime"`
26}
27
28// lsNodeEnvelope wraps LsNode with the message_type discriminator so
29// we can unmarshal each JSON line in a single pass — check the type and
30// extract node fields at once, avoiding a double decode per line.
31type lsNodeEnvelope struct {
32 MessageType string `json:"message_type"`
33 LsNode
34}
35
36// ParseLsNodes parses the JSON-lines output of `restic ls --json`,
37// skipping the snapshot header line (message_type "snapshot") and
38// returning all node entries in their original order.
39//
40// This is a convenience wrapper around parseLsReader for callers that
41// already have the complete output in memory (e.g. tests).
42func ParseLsNodes(data []byte) ([]LsNode, error) {
43 return parseLsReader(bytes.NewReader(data))
44}
45
46// parseLsReader scans JSON lines from r, skipping non-node lines
47// (snapshot headers, blank lines), and returns all node entries in
48// order. This is the core parser used by both ParseLsNodes and RunLs.
49func parseLsReader(r io.Reader) ([]LsNode, error) {
50 var nodes []LsNode
51 scanner := bufio.NewScanner(r)
52 for scanner.Scan() {
53 line := scanner.Bytes()
54 if len(bytes.TrimSpace(line)) == 0 {
55 continue
56 }
57
58 var envelope lsNodeEnvelope
59 if err := json.Unmarshal(line, &envelope); err != nil {
60 return nil, fmt.Errorf("parsing ls JSON line: %w", err)
61 }
62 if envelope.MessageType != "node" {
63 continue
64 }
65
66 nodes = append(nodes, envelope.LsNode)
67 }
68 if err := scanner.Err(); err != nil {
69 return nil, fmt.Errorf("scanning ls output: %w", err)
70 }
71
72 return nodes, nil
73}
74
75// RunLs runs `restic ls --json <snapshotID>` using connection details
76// from the given resolved config and returns the parsed nodes.
77//
78// Stdout is stream-parsed rather than buffered entirely into memory,
79// so large snapshots don't require holding the full JSON output at
80// once. Stderr is captured for error reporting.
81//
82// The config's environ map is copied before resolving _COMMAND entries
83// so the caller's config is not mutated.
84func RunLs(cfg *config.ResolvedConfig, snapshotID string) ([]LsNode, error) {
85 argv, err := buildLsCmd(cfg, snapshotID)
86 if err != nil {
87 return nil, err
88 }
89
90 env := copyEnviron(cfg.Environ)
91 if err := resolveEnvironCommands(env, cfg.Workdir); err != nil {
92 return nil, fmt.Errorf("resolving environ for ls: %w", err)
93 }
94
95 cmd := exec.Command(argv[0], argv[1:]...) //nolint:gosec
96 cmd.Env = buildEnv(env)
97
98 if cfg.Workdir != "" {
99 cmd.Dir = cfg.Workdir
100 }
101
102 stdout, err := cmd.StdoutPipe()
103 if err != nil {
104 return nil, fmt.Errorf("creating stdout pipe for restic ls: %w", err)
105 }
106
107 var stderr bytes.Buffer
108 cmd.Stderr = &stderr
109
110 if err := cmd.Start(); err != nil {
111 return nil, fmt.Errorf("starting restic ls: %w", err)
112 }
113
114 nodes, parseErr := parseLsReader(stdout)
115
116 // Always wait for the process to finish, even if parsing failed.
117 waitErr := cmd.Wait()
118
119 if parseErr != nil {
120 return nil, fmt.Errorf("parsing restic ls output: %w", parseErr)
121 }
122 if waitErr != nil {
123 return nil, fmt.Errorf("running restic ls: %w\n%s",
124 waitErr, bytes.TrimSpace(stderr.Bytes()))
125 }
126
127 return nodes, nil
128}
129
130// snapshotSelectors are flags that affect which snapshot the special ID
131// "latest" resolves to. They are accepted by both `restic ls` and
132// `restic restore`, so we forward them from the resolved restore config
133// to ensure the picker browses the same snapshot the restore will use.
134//
135// Sourced from `restic ls --help`.
136var snapshotSelectors = map[string]bool{
137 "--host": true,
138 "-H": true,
139 "--path": true,
140 "--tag": true,
141}
142
143// buildLsCmd constructs the argument vector for running
144// `restic ls --json <snapshotID>`, extracting global flags and
145// snapshot-selection flags from the given resolved config. The snapshot
146// ID is always the last argument. Returns an error if no repository
147// source is available.
148func buildLsCmd(cfg *config.ResolvedConfig, snapshotID string) ([]string, error) {
149 if !hasRepoSource(cfg) {
150 return nil, ErrNoRepo
151 }
152
153 argv := []string{executable(), "ls", "--json"}
154
155 for _, f := range cfg.Flags {
156 if !globalFlags[f.Name] && !snapshotSelectors[f.Name] {
157 continue
158 }
159 argv = append(argv, f.Name)
160 if f.Value != "" {
161 argv = append(argv, f.Value)
162 }
163 }
164
165 argv = append(argv, snapshotID)
166
167 return argv, nil
168}