lsnodes.go

  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}