Add restic ls JSON parsing with test fixture repo

Amolith created

Change summary

internal/restic/lsnodes.go                                                                                                                                             | 171 
internal/restic/lsnodes_test.go                                                                                                                                        | 411 
internal/restic/testdata/repo/config                                                                                                                                   |   0 
internal/restic/testdata/repo/data/63/6354facc46c38cc3e9ac18e747e307c5a6da50bb5999855d610a422f6447fb25                                                                 |   0 
internal/restic/testdata/repo/data/b2/b2b71324d483e3599b4e0d6110cce4816fa78b562b35b92340a194588b885f28                                                                 |   0 
internal/restic/testdata/repo/index/ac37b4026a4655d219044edb4f66fb18f844b63e7e9198f757a04ab400dce843                                                                   |   0 
internal/restic/testdata/repo/keys/2f82b6a71d8b04bbf33fa5865e61521b64ec76a343d33b6c6c2dfcbd1e3c163c                                                                    |   1 
internal/restic/testdata/repo/snapshots/03b061a61be6cc57836effa9a8585468141672577a9310128ae7674d1deed1bd                                                               |   2 
internal/restic/testdata/source/.config/keld/config.toml                                                                                                               |   1 
internal/restic/testdata/source/documents/notes/2026/february.md                                                                                                       |   1 
internal/restic/testdata/source/documents/notes/2026/january.md                                                                                                        |   1 
internal/restic/testdata/source/documents/notes/todo.txt                                                                                                               |   1 
internal/restic/testdata/source/documents/tax returns/2025.pdf                                                                                                         |   0 
internal/restic/testdata/source/music/Jazz Caf\303\251/live/01 - Take Five.flac                                                                                        |   1 
internal/restic/testdata/source/music/Jazz Caf\303\251/live/02 - So What.flac                                                                                          |   1 
internal/restic/testdata/source/music/Jazz Caf\303\251/live/03 - Blue in Green.flac                                                                                    |   1 
internal/restic/testdata/source/music/\343\202\242\343\203\274\343\203\206\343\202\243\343\202\271\343\203\210/album-01/01 - \345\244\234\346\230\216\343\201\221.flac |   1 
internal/restic/testdata/source/music/\343\202\242\343\203\274\343\203\206\343\202\243\343\202\271\343\203\210/album-01/02 - \351\242\250\343\201\256\346\255\214.flac |   1 
internal/restic/testdata/source/music/\343\202\242\343\203\274\343\203\206\343\202\243\343\202\271\343\203\210/album-01/03 - \346\230\237\347\251\272.flac             |   1 
internal/restic/testdata/source/music/\343\202\242\343\203\274\343\203\206\343\202\243\343\202\271\343\203\210/album-02/01 - \346\265\267\350\276\272.flac             |   1 
internal/restic/testdata/source/music/\343\202\242\343\203\274\343\203\206\343\202\243\343\202\271\343\203\210/album-02/02 - \346\243\256\343\201\256\344\270\255.flac |   1 
internal/restic/testdata/source/photos/2025/february/IMG_0010.jpg                                                                                                      |   1 
internal/restic/testdata/source/photos/2025/january/IMG_0001.jpg                                                                                                       |   1 
internal/restic/testdata/source/photos/2025/january/IMG_0002.jpg                                                                                                       |   1 
internal/restic/testdata/source/photos/2025/january/IMG_0003.jpg                                                                                                       |   1 
internal/restic/testdata/source/projects/keld/README.md                                                                                                                |   1 
internal/restic/testdata/source/projects/keld/cmd/root.go                                                                                                              |   1 
internal/restic/testdata/source/projects/keld/go.mod                                                                                                                   |   1 
internal/restic/testdata/source/projects/keld/internal/config/config.go                                                                                                |   1 
internal/restic/testdata/source/projects/keld/internal/config/resolve.go                                                                                               |   1 
internal/restic/testdata/source/projects/keld/internal/restic/exec.go                                                                                                  |   1 
internal/restic/testdata/source/projects/keld/internal/restic/snapshots.go                                                                                             |   1 
internal/restic/testdata/source/projects/web app/package.json                                                                                                          |   1 
internal/restic/testdata/source/projects/web app/public/index.html                                                                                                     |   1 
internal/restic/testdata/source/projects/web app/public/style.css                                                                                                      |   1 
internal/restic/testdata/source/projects/web app/src/components/App.tsx                                                                                                |   1 
internal/restic/testdata/source/projects/web app/src/components/Nav.tsx                                                                                                |   1 
37 files changed, 613 insertions(+)

Detailed changes

internal/restic/lsnodes.go 🔗

@@ -0,0 +1,171 @@
+package restic
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os/exec"
+	"time"
+
+	"git.secluded.site/keld/internal/config"
+)
+
+// LsNode represents a single file or directory entry from
+// `restic ls --json`. Each JSON line with message_type "node"
+// produces one LsNode.
+type LsNode struct {
+	Name        string    `json:"name"`
+	Type        string    `json:"type"` // "dir", "file", etc.
+	Path        string    `json:"path"`
+	Size        int64     `json:"size,omitempty"` // absent for directories
+	Mode        int       `json:"mode"`
+	Permissions string    `json:"permissions"`
+	Mtime       time.Time `json:"mtime"`
+}
+
+// lsLine is the minimal envelope used to distinguish snapshot headers
+// from node entries when scanning `restic ls --json` output.
+type lsLine struct {
+	MessageType string `json:"message_type"`
+}
+
+// ParseLsNodes parses the JSON-lines output of `restic ls --json`,
+// skipping the snapshot header line (message_type "snapshot") and
+// returning all node entries in their original order.
+//
+// This is a convenience wrapper around parseLsReader for callers that
+// already have the complete output in memory (e.g. tests).
+func ParseLsNodes(data []byte) ([]LsNode, error) {
+	return parseLsReader(bytes.NewReader(data))
+}
+
+// parseLsReader scans JSON lines from r, skipping non-node lines
+// (snapshot headers, blank lines), and returns all node entries in
+// order. This is the core parser used by both ParseLsNodes and RunLs.
+func parseLsReader(r io.Reader) ([]LsNode, error) {
+	var nodes []LsNode
+	scanner := bufio.NewScanner(r)
+	for scanner.Scan() {
+		line := scanner.Bytes()
+		if len(bytes.TrimSpace(line)) == 0 {
+			continue
+		}
+
+		// Peek at message_type to decide whether to skip.
+		var envelope lsLine
+		if err := json.Unmarshal(line, &envelope); err != nil {
+			return nil, fmt.Errorf("parsing ls JSON line: %w", err)
+		}
+		if envelope.MessageType != "node" {
+			continue
+		}
+
+		var node LsNode
+		if err := json.Unmarshal(line, &node); err != nil {
+			return nil, fmt.Errorf("parsing ls node: %w", err)
+		}
+		nodes = append(nodes, node)
+	}
+	if err := scanner.Err(); err != nil {
+		return nil, fmt.Errorf("scanning ls output: %w", err)
+	}
+
+	return nodes, nil
+}
+
+// RunLs runs `restic ls --json <snapshotID>` using connection details
+// from the given resolved config and returns the parsed nodes.
+//
+// Stdout is stream-parsed rather than buffered entirely into memory,
+// so large snapshots don't require holding the full JSON output at
+// once. Stderr is captured for error reporting.
+//
+// The config's environ map is copied before resolving _COMMAND entries
+// so the caller's config is not mutated.
+func RunLs(cfg *config.ResolvedConfig, snapshotID string) ([]LsNode, error) {
+	argv, err := buildLsCmd(cfg, snapshotID)
+	if err != nil {
+		return nil, err
+	}
+
+	env := copyEnviron(cfg.Environ)
+	if err := resolveEnvironCommands(env); err != nil {
+		return nil, fmt.Errorf("resolving environ for ls: %w", err)
+	}
+
+	cmd := exec.Command(argv[0], argv[1:]...) //nolint:gosec
+	cmd.Env = buildEnv(env)
+
+	if cfg.Workdir != "" {
+		cmd.Dir = cfg.Workdir
+	}
+
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return nil, fmt.Errorf("creating stdout pipe for restic ls: %w", err)
+	}
+
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+
+	if err := cmd.Start(); err != nil {
+		return nil, fmt.Errorf("starting restic ls: %w", err)
+	}
+
+	nodes, parseErr := parseLsReader(stdout)
+
+	// Always wait for the process to finish, even if parsing failed.
+	waitErr := cmd.Wait()
+
+	if parseErr != nil {
+		return nil, fmt.Errorf("parsing restic ls output: %w", parseErr)
+	}
+	if waitErr != nil {
+		return nil, fmt.Errorf("running restic ls: %w\n%s",
+			waitErr, bytes.TrimSpace(stderr.Bytes()))
+	}
+
+	return nodes, nil
+}
+
+// snapshotSelectors are flags that affect which snapshot the special ID
+// "latest" resolves to. They are accepted by both `restic ls` and
+// `restic restore`, so we forward them from the resolved restore config
+// to ensure the picker browses the same snapshot the restore will use.
+//
+// Sourced from `restic ls --help`.
+var snapshotSelectors = map[string]bool{
+	"--host": true,
+	"-H":     true,
+	"--path": true,
+	"--tag":  true,
+}
+
+// buildLsCmd constructs the argument vector for running
+// `restic ls --json <snapshotID>`, extracting global flags and
+// snapshot-selection flags from the given resolved config. The snapshot
+// ID is always the last argument. Returns an error if no repository
+// source is available.
+func buildLsCmd(cfg *config.ResolvedConfig, snapshotID string) ([]string, error) {
+	if !hasRepoSource(cfg) {
+		return nil, ErrNoRepo
+	}
+
+	argv := []string{executable(), "ls", "--json"}
+
+	for _, f := range cfg.Flags {
+		if !globalFlags[f.Name] && !snapshotSelectors[f.Name] {
+			continue
+		}
+		argv = append(argv, f.Name)
+		if f.Value != "" {
+			argv = append(argv, f.Value)
+		}
+	}
+
+	argv = append(argv, snapshotID)
+
+	return argv, nil
+}

internal/restic/lsnodes_test.go 🔗

@@ -0,0 +1,411 @@
+package restic
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"git.secluded.site/keld/internal/config"
+)
+
+// fixtureLsJSON is real output from:
+//
+//	keld --preset music-final@b2 ls --json --recursive 8bd729b1 /home/amolith/Music/Final/3nd
+//
+// 24 lines: 1 snapshot header + 4 directories + 19 .flac files across 3 album
+// directories. The snapshot header line has message_type "snapshot" and must be
+// skipped; all remaining lines have message_type "node".
+const fixtureLsJSON = `{"time":"2026-03-24T00:20:53.867687549-06:00","parent":"2a1eb755327d446b938ef5a6e991f83f86abf12911f3bc87602fc696a7d1d4af","tree":"011fe38da2e96cb6ea9b2bb8bbc710d5b92ab9f4ea2d91be089bba532d329aae","paths":["/home/amolith/Music/Final"],"hostname":"angmar","username":"amolith","uid":1000,"gid":1000,"program_version":"restic 0.18.1","summary":{"backup_start":"2026-03-24T00:20:53.867687549-06:00","backup_end":"2026-03-24T00:20:58.420948969-06:00","files_new":0,"files_changed":4,"files_unmodified":6010,"dirs_new":0,"dirs_changed":4,"dirs_unmodified":862,"data_blobs":1,"tree_blobs":5,"data_added":89361,"data_added_packed":13072,"total_files_processed":6014,"total_bytes_processed":121289853644},"id":"8bd729b16043cef621423ca2f6100832e8add471374a3a54c555d624b011988c","short_id":"8bd729b1","message_type":"snapshot","struct_type":"snapshot"}
+{"name":"3nd","type":"dir","path":"/home/amolith/Music/Final/3nd","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2021-04-17T18:01:57.376612-06:00","atime":"2021-04-17T18:01:57.376612-06:00","ctime":"2024-09-26T19:41:45.573886986-06:00","inode":42207275,"message_type":"node","struct_type":"node"}
+{"name":"World Tour","type":"dir","path":"/home/amolith/Music/Final/3nd/World Tour","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2024-06-10T09:00:35.86776-06:00","atime":"2024-06-10T09:00:35.86776-06:00","ctime":"2024-09-26T19:41:45.563886805-06:00","inode":42207421,"message_type":"node","struct_type":"node"}
+{"name":"01 - monsoon.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/01 - monsoon.flac","uid":1000,"gid":1000,"size":30037820,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:20.840315-07:00","atime":"2021-11-25T17:31:20.840315-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213020,"message_type":"node","struct_type":"node"}
+{"name":"02 - china.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/02 - china.flac","uid":1000,"gid":1000,"size":17705986,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:22.00033-07:00","atime":"2021-11-25T17:31:22.00033-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213321,"message_type":"node","struct_type":"node"}
+{"name":"03 - SSK.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/03 - SSK.flac","uid":1000,"gid":1000,"size":32263802,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:22.753673-07:00","atime":"2021-11-25T17:31:22.753673-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213685,"message_type":"node","struct_type":"node"}
+{"name":"04 - midroll.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/04 - midroll.flac","uid":1000,"gid":1000,"size":37431281,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:23.643684-07:00","atime":"2021-11-25T17:31:23.643684-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213686,"message_type":"node","struct_type":"node"}
+{"name":"05 - LOTUS.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/05 - LOTUS.flac","uid":1000,"gid":1000,"size":33509064,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.157024-07:00","atime":"2021-11-25T17:31:24.157024-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213687,"message_type":"node","struct_type":"node"}
+{"name":"06 - 眠る.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/06 - 眠る.flac","uid":1000,"gid":1000,"size":21319060,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.383693-07:00","atime":"2021-11-25T17:31:24.383693-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213249,"message_type":"node","struct_type":"node"}
+{"name":"07 - filter in dust.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/07 - filter in dust.flac","uid":1000,"gid":1000,"size":21849635,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.613696-07:00","atime":"2021-11-25T17:31:24.613696-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42213250,"message_type":"node","struct_type":"node"}
+{"name":"08 - 夏終る.flac","type":"file","path":"/home/amolith/Music/Final/3nd/World Tour/08 - 夏終る.flac","uid":1000,"gid":1000,"size":28061640,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:24.817032-07:00","atime":"2021-11-25T17:31:24.817032-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212532,"message_type":"node","struct_type":"node"}
+{"name":"view from here","type":"dir","path":"/home/amolith/Music/Final/3nd/view from here","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2024-06-10T09:00:35.874427-06:00","atime":"2024-06-10T09:00:35.874427-06:00","ctime":"2024-09-26T19:41:45.570553593-06:00","inode":42207422,"message_type":"node","struct_type":"node"}
+{"name":"01 - clockworker.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/01 - clockworker.flac","uid":1000,"gid":1000,"size":38342556,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:12.526878-07:00","atime":"2021-11-25T17:31:12.526878-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42214320,"message_type":"node","struct_type":"node"}
+{"name":"02 - augustline.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/02 - augustline.flac","uid":1000,"gid":1000,"size":27357365,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:13.413555-07:00","atime":"2021-11-25T17:31:13.413555-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212371,"message_type":"node","struct_type":"node"}
+{"name":"03 - season.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/03 - season.flac","uid":1000,"gid":1000,"size":26918886,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:14.256899-07:00","atime":"2021-11-25T17:31:14.256899-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212245,"message_type":"node","struct_type":"node"}
+{"name":"04 - ハルオト.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/04 - ハルオト.flac","uid":1000,"gid":1000,"size":45722382,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:15.253578-07:00","atime":"2021-11-25T17:31:15.253578-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212533,"message_type":"node","struct_type":"node"}
+{"name":"05 - walk in the brown.flac","type":"file","path":"/home/amolith/Music/Final/3nd/view from here/05 - walk in the brown.flac","uid":1000,"gid":1000,"size":38267170,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:16.300258-07:00","atime":"2021-11-25T17:31:16.300258-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42214321,"message_type":"node","struct_type":"node"}
+{"name":"we dance × avec toi","type":"dir","path":"/home/amolith/Music/Final/3nd/we dance × avec toi","uid":1000,"gid":1000,"mode":2147484157,"permissions":"drwxrwxr-x","mtime":"2024-06-10T09:00:35.87776-06:00","atime":"2024-06-10T09:00:35.87776-06:00","ctime":"2024-09-26T19:41:45.573886986-06:00","inode":42207423,"message_type":"node","struct_type":"node"}
+{"name":"01 - Bender.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/01 - Bender.flac","uid":1000,"gid":1000,"size":24044464,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:17.563608-07:00","atime":"2021-11-25T17:31:17.563608-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42211884,"message_type":"node","struct_type":"node"}
+{"name":"02 - テレプシコラー.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/02 - テレプシコラー.flac","uid":1000,"gid":1000,"size":29003735,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:18.106948-07:00","atime":"2021-11-25T17:31:18.106948-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42211885,"message_type":"node","struct_type":"node"}
+{"name":"03 - コロコロ転がる.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/03 - コロコロ転がる.flac","uid":1000,"gid":1000,"size":27127530,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:18.643621-07:00","atime":"2021-11-25T17:31:18.643621-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212285,"message_type":"node","struct_type":"node"}
+{"name":"04 - algorhythm.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/04 - algorhythm.flac","uid":1000,"gid":1000,"size":36728278,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:19.070293-07:00","atime":"2021-11-25T17:31:19.070293-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212286,"message_type":"node","struct_type":"node"}
+{"name":"05 - Rain Song.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/05 - Rain Song.flac","uid":1000,"gid":1000,"size":27059422,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:19.540299-07:00","atime":"2021-11-25T17:31:19.540299-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42212287,"message_type":"node","struct_type":"node"}
+{"name":"06 - untroubled terror.flac","type":"file","path":"/home/amolith/Music/Final/3nd/we dance × avec toi/06 - untroubled terror.flac","uid":1000,"gid":1000,"size":6965876,"mode":509,"permissions":"-rwxrwxr-x","mtime":"2021-11-25T17:31:19.853636-07:00","atime":"2021-11-25T17:31:19.853636-07:00","ctime":"2024-09-26T14:45:44.103763451-06:00","inode":42211886,"message_type":"node","struct_type":"node"}
+`
+
+func TestParseLsNodes(t *testing.T) {
+	t.Parallel()
+
+	nodes, err := ParseLsNodes([]byte(fixtureLsJSON))
+	if err != nil {
+		t.Fatalf("ParseLsNodes(): unexpected error: %v", err)
+	}
+
+	// 23 node lines (4 dirs + 19 files), snapshot header skipped.
+	if got := len(nodes); got != 23 {
+		t.Fatalf("len(nodes): got %d, want 23", got)
+	}
+
+	// First node should be the root dir of the listing.
+	first := nodes[0]
+	if first.Name != "3nd" {
+		t.Errorf("first node name: got %q, want %q", first.Name, "3nd")
+	}
+	if first.Type != "dir" {
+		t.Errorf("first node type: got %q, want %q", first.Type, "dir")
+	}
+	if first.Path != "/home/amolith/Music/Final/3nd" {
+		t.Errorf("first node path: got %q, want %q", first.Path, "/home/amolith/Music/Final/3nd")
+	}
+	if first.Size != 0 {
+		t.Errorf("first node size: got %d, want 0 (dirs have no size)", first.Size)
+	}
+	if first.Mode != 2147484157 {
+		t.Errorf("first node mode: got %d, want %d", first.Mode, 2147484157)
+	}
+	if first.Permissions != "drwxrwxr-x" {
+		t.Errorf("first node permissions: got %q, want %q", first.Permissions, "drwxrwxr-x")
+	}
+	wantMtime, _ := time.Parse(time.RFC3339Nano, "2021-04-17T18:01:57.376612-06:00")
+	if !first.Mtime.Equal(wantMtime) {
+		t.Errorf("first node mtime: got %v, want %v", first.Mtime, wantMtime)
+	}
+
+	// Spot-check a file node: first .flac in "World Tour".
+	monsoon := nodes[2]
+	if monsoon.Name != "01 - monsoon.flac" {
+		t.Errorf("monsoon name: got %q, want %q", monsoon.Name, "01 - monsoon.flac")
+	}
+	if monsoon.Type != "file" {
+		t.Errorf("monsoon type: got %q, want %q", monsoon.Type, "file")
+	}
+	if monsoon.Path != "/home/amolith/Music/Final/3nd/World Tour/01 - monsoon.flac" {
+		t.Errorf("monsoon path: got %q, want %q",
+			monsoon.Path, "/home/amolith/Music/Final/3nd/World Tour/01 - monsoon.flac")
+	}
+	if monsoon.Size != 30037820 {
+		t.Errorf("monsoon size: got %d, want %d", monsoon.Size, 30037820)
+	}
+	if monsoon.Mode != 509 {
+		t.Errorf("monsoon mode: got %d, want %d", monsoon.Mode, 509)
+	}
+	if monsoon.Permissions != "-rwxrwxr-x" {
+		t.Errorf("monsoon permissions: got %q, want %q", monsoon.Permissions, "-rwxrwxr-x")
+	}
+
+	// Spot-check a file with Unicode in the name.
+	nemuru := nodes[7]
+	if nemuru.Name != "06 - 眠る.flac" {
+		t.Errorf("nemuru name: got %q, want %q", nemuru.Name, "06 - 眠る.flac")
+	}
+	if nemuru.Size != 21319060 {
+		t.Errorf("nemuru size: got %d, want %d", nemuru.Size, 21319060)
+	}
+
+	// Count dirs vs files.
+	var dirs, files int
+	for _, n := range nodes {
+		switch n.Type {
+		case "dir":
+			dirs++
+		case "file":
+			files++
+		default:
+			t.Errorf("unexpected node type %q for %q", n.Type, n.Path)
+		}
+	}
+	if dirs != 4 {
+		t.Errorf("dir count: got %d, want 4", dirs)
+	}
+	if files != 19 {
+		t.Errorf("file count: got %d, want 19", files)
+	}
+}
+
+func TestParseLsNodesEmpty(t *testing.T) {
+	t.Parallel()
+
+	nodes, err := ParseLsNodes([]byte(""))
+	if err != nil {
+		t.Fatalf("ParseLsNodes(empty): unexpected error: %v", err)
+	}
+	if len(nodes) != 0 {
+		t.Errorf("expected 0 nodes from empty input, got %d", len(nodes))
+	}
+}
+
+func TestParseLsNodesSnapshotOnly(t *testing.T) {
+	t.Parallel()
+
+	// A snapshot header with no node lines — e.g. an empty snapshot or a
+	// path filter that matched nothing.
+	input := `{"time":"2026-03-24T00:20:53.867687549-06:00","paths":["/home/amolith/Music/Final"],"hostname":"angmar","id":"8bd729b1","short_id":"8bd729b1","message_type":"snapshot","struct_type":"snapshot"}
+`
+
+	nodes, err := ParseLsNodes([]byte(input))
+	if err != nil {
+		t.Fatalf("ParseLsNodes(snapshot-only): unexpected error: %v", err)
+	}
+	if len(nodes) != 0 {
+		t.Errorf("expected 0 nodes from snapshot-only input, got %d", len(nodes))
+	}
+}
+
+func TestBuildLsCmd(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		cfg      *config.ResolvedConfig
+		snapshot string
+		wantArgv []string
+		wantErr  bool
+	}{
+		{
+			name: "basic repo flag",
+			cfg: &config.ResolvedConfig{
+				Command: "restore",
+				Flags: []config.Flag{
+					{Name: "--repo", Value: "/srv/backup"},
+					{Name: "--target", Value: "/tmp/restore"},
+				},
+			},
+			snapshot: "8bd729b1",
+			wantArgv: []string{"restic", "ls", "--json", "--repo", "/srv/backup", "8bd729b1"},
+		},
+		{
+			name: "repo with password-file and non-global flags stripped",
+			cfg: &config.ResolvedConfig{
+				Command: "restore",
+				Flags: []config.Flag{
+					{Name: "--repo", Value: "rclone:remote:/backup"},
+					{Name: "--password-file", Value: "/etc/restic/pw"},
+					{Name: "--target", Value: "/tmp/restore"},
+					{Name: "--overwrite", Value: "if-changed"},
+				},
+			},
+			snapshot: "latest",
+			wantArgv: []string{
+				"restic", "ls", "--json",
+				"--repo", "rclone:remote:/backup",
+				"--password-file", "/etc/restic/pw",
+				"latest",
+			},
+		},
+		{
+			name: "cache-dir and no-lock forwarded",
+			cfg: &config.ResolvedConfig{
+				Command: "restore",
+				Flags: []config.Flag{
+					{Name: "--repo", Value: "/srv/backup"},
+					{Name: "--cache-dir", Value: "/tmp/cache"},
+					{Name: "--no-lock"},
+					{Name: "--target", Value: "/tmp/restore"},
+				},
+			},
+			snapshot: "abc123",
+			wantArgv: []string{
+				"restic", "ls", "--json",
+				"--repo", "/srv/backup",
+				"--cache-dir", "/tmp/cache",
+				"--no-lock",
+				"abc123",
+			},
+		},
+		{
+			name: "snapshot selectors forwarded for latest",
+			cfg: &config.ResolvedConfig{
+				Command: "restore",
+				Flags: []config.Flag{
+					{Name: "--repo", Value: "/srv/backup"},
+					{Name: "--host", Value: "angmar"},
+					{Name: "--path", Value: "/home/amolith/Music"},
+					{Name: "--tag", Value: "daily"},
+					{Name: "--target", Value: "/tmp/restore"},
+				},
+			},
+			snapshot: "latest",
+			wantArgv: []string{
+				"restic", "ls", "--json",
+				"--repo", "/srv/backup",
+				"--host", "angmar",
+				"--path", "/home/amolith/Music",
+				"--tag", "daily",
+				"latest",
+			},
+		},
+		{
+			name: "short host flag forwarded",
+			cfg: &config.ResolvedConfig{
+				Command: "restore",
+				Flags: []config.Flag{
+					{Name: "--repo", Value: "/srv/backup"},
+					{Name: "-H", Value: "angmar"},
+				},
+			},
+			snapshot: "latest",
+			wantArgv: []string{
+				"restic", "ls", "--json",
+				"--repo", "/srv/backup",
+				"-H", "angmar",
+				"latest",
+			},
+		},
+		{
+			name: "repo via environ only",
+			cfg: &config.ResolvedConfig{
+				Command: "restore",
+				Flags: []config.Flag{
+					{Name: "--target", Value: "/tmp/restore"},
+				},
+				Environ: map[string]string{
+					"RESTIC_REPOSITORY": "/srv/backup",
+				},
+			},
+			snapshot: "8bd729b1",
+			wantArgv: []string{"restic", "ls", "--json", "8bd729b1"},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			argv, err := buildLsCmd(tt.cfg, tt.snapshot)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("buildLsCmd(): err=%v, wantErr=%v", err, tt.wantErr)
+			}
+			if err != nil {
+				return
+			}
+
+			if len(argv) != len(tt.wantArgv) {
+				t.Fatalf("argv length: got %d %v, want %d %v",
+					len(argv), argv, len(tt.wantArgv), tt.wantArgv)
+			}
+			for i := range argv {
+				if argv[i] != tt.wantArgv[i] {
+					t.Errorf("argv[%d]: got %q, want %q", i, argv[i], tt.wantArgv[i])
+				}
+			}
+		})
+	}
+}
+
+func TestBuildLsCmdNoRepo(t *testing.T) {
+	t.Setenv("RESTIC_REPOSITORY", "")
+	t.Setenv("RESTIC_REPOSITORY_FILE", "")
+
+	cfg := &config.ResolvedConfig{
+		Command: "restore",
+		Flags: []config.Flag{
+			{Name: "--target", Value: "/tmp/restore"},
+		},
+	}
+
+	_, err := buildLsCmd(cfg, "8bd729b1")
+	if !errors.Is(err, ErrNoRepo) {
+		t.Errorf("expected ErrNoRepo, got %v", err)
+	}
+}
+
+// testdataRepoPath returns the absolute path to the committed test
+// restic repository. Tests using this fixture require restic to be
+// installed.
+func testdataRepoPath(t *testing.T) string {
+	t.Helper()
+	// Discover the repo relative to this test file's package dir.
+	path, err := filepath.Abs("testdata/repo")
+	if err != nil {
+		t.Fatalf("resolving testdata/repo: %v", err)
+	}
+	if _, err := os.Stat(path); err != nil {
+		t.Fatalf("testdata/repo not found at %s: %v", path, err)
+	}
+	return path
+}
+
+func TestRunLs(t *testing.T) {
+	t.Parallel()
+
+	repoPath := testdataRepoPath(t)
+	cfg := &config.ResolvedConfig{
+		Command: "restore",
+		Flags: []config.Flag{
+			{Name: "--repo", Value: repoPath},
+		},
+		Environ: map[string]string{
+			"RESTIC_PASSWORD": "test",
+		},
+	}
+
+	nodes, err := RunLs(cfg, "03b061a6")
+	if err != nil {
+		t.Fatalf("RunLs(): unexpected error: %v", err)
+	}
+
+	// The test repo has 29 files + 26 dirs = 55 nodes.
+	if got := len(nodes); got != 55 {
+		t.Fatalf("len(nodes): got %d, want 55", got)
+	}
+
+	// Count types.
+	var dirs, files int
+	for _, n := range nodes {
+		switch n.Type {
+		case "dir":
+			dirs++
+		case "file":
+			files++
+		default:
+			t.Errorf("unexpected node type %q for %q", n.Type, n.Path)
+		}
+	}
+	if dirs != 26 {
+		t.Errorf("dir count: got %d, want 26", dirs)
+	}
+	if files != 29 {
+		t.Errorf("file count: got %d, want 29", files)
+	}
+
+	// Spot-check a Unicode path.
+	var found bool
+	for _, n := range nodes {
+		if n.Name == "01 - 夜明け.flac" {
+			found = true
+			if n.Type != "file" {
+				t.Errorf("夜明け type: got %q, want %q", n.Type, "file")
+			}
+			break
+		}
+	}
+	if !found {
+		t.Error("expected to find node '01 - 夜明け.flac'")
+	}
+
+	// Spot-check a space-in-path entry.
+	found = false
+	for _, n := range nodes {
+		if n.Name == "Jazz Café" {
+			found = true
+			if n.Type != "dir" {
+				t.Errorf("Jazz Café type: got %q, want %q", n.Type, "dir")
+			}
+			break
+		}
+	}
+	if !found {
+		t.Error("expected to find node 'Jazz Café'")
+	}
+}

internal/restic/testdata/repo/keys/2f82b6a71d8b04bbf33fa5865e61521b64ec76a343d33b6c6c2dfcbd1e3c163c 🔗

@@ -0,0 +1 @@
+{"created":"2026-03-25T22:39:06.12977768-06:00","username":"amolith","hostname":"angmar","kdf":"scrypt","N":32768,"r":8,"p":5,"salt":"5kzsKx80K6wVrcqKvyQXAHi3TgY0aZ0fAyw/6Nezt7pFqplrZl2yCul9FzR+660VjAqjjQ1E4pwenUWWeNtmpQ==","data":"oTOqX5z0nwCnKgNmVwUkI57AZviSTh1e6yuS7BXskq/OHkkh4UDA3VrFkpHt6g+CxancYtuoHCqMdiusjPLFCgJRUMnNvlDKPEFeXbTsaRJnairTs1OK5f89+QAPByey+fNvBko5aJyBcfKBnl6l5BuQKWlwdIb1I1gEvARTyC7yEuOQ9CihydCMV8hjMTmkiJNhds2wjpofT+tg0VODmA=="}