diff --git a/internal/restic/lsnodes.go b/internal/restic/lsnodes.go new file mode 100644 index 0000000000000000000000000000000000000000..9d73717b63d0e4a248041e67e9668f28b535e43a --- /dev/null +++ b/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 ` 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 `, 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 +} diff --git a/internal/restic/lsnodes_test.go b/internal/restic/lsnodes_test.go new file mode 100644 index 0000000000000000000000000000000000000000..268a5e754a4ceb57f1e51fc2a725793de6655767 --- /dev/null +++ b/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é'") + } +} diff --git a/internal/restic/testdata/repo/config b/internal/restic/testdata/repo/config new file mode 100644 index 0000000000000000000000000000000000000000..d730be7b96d8d5ae6175348577bfcfb88f4de75d Binary files /dev/null and b/internal/restic/testdata/repo/config differ diff --git a/internal/restic/testdata/repo/data/63/6354facc46c38cc3e9ac18e747e307c5a6da50bb5999855d610a422f6447fb25 b/internal/restic/testdata/repo/data/63/6354facc46c38cc3e9ac18e747e307c5a6da50bb5999855d610a422f6447fb25 new file mode 100644 index 0000000000000000000000000000000000000000..e314cb52633e6a638ed41a216074fc927dad0fc9 Binary files /dev/null and b/internal/restic/testdata/repo/data/63/6354facc46c38cc3e9ac18e747e307c5a6da50bb5999855d610a422f6447fb25 differ diff --git a/internal/restic/testdata/repo/data/b2/b2b71324d483e3599b4e0d6110cce4816fa78b562b35b92340a194588b885f28 b/internal/restic/testdata/repo/data/b2/b2b71324d483e3599b4e0d6110cce4816fa78b562b35b92340a194588b885f28 new file mode 100644 index 0000000000000000000000000000000000000000..b16d4d9f8fa713dc9b8f0892202ae291172c0923 Binary files /dev/null and b/internal/restic/testdata/repo/data/b2/b2b71324d483e3599b4e0d6110cce4816fa78b562b35b92340a194588b885f28 differ diff --git a/internal/restic/testdata/repo/index/ac37b4026a4655d219044edb4f66fb18f844b63e7e9198f757a04ab400dce843 b/internal/restic/testdata/repo/index/ac37b4026a4655d219044edb4f66fb18f844b63e7e9198f757a04ab400dce843 new file mode 100644 index 0000000000000000000000000000000000000000..2ac7b349fa87c8ba83ea1fd04d94a6282baef198 Binary files /dev/null and b/internal/restic/testdata/repo/index/ac37b4026a4655d219044edb4f66fb18f844b63e7e9198f757a04ab400dce843 differ diff --git a/internal/restic/testdata/repo/keys/2f82b6a71d8b04bbf33fa5865e61521b64ec76a343d33b6c6c2dfcbd1e3c163c b/internal/restic/testdata/repo/keys/2f82b6a71d8b04bbf33fa5865e61521b64ec76a343d33b6c6c2dfcbd1e3c163c new file mode 100644 index 0000000000000000000000000000000000000000..490588656a75c07ea3df4b131702a9bdebf1c181 --- /dev/null +++ b/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=="} \ No newline at end of file diff --git a/internal/restic/testdata/repo/snapshots/03b061a61be6cc57836effa9a8585468141672577a9310128ae7674d1deed1bd b/internal/restic/testdata/repo/snapshots/03b061a61be6cc57836effa9a8585468141672577a9310128ae7674d1deed1bd new file mode 100644 index 0000000000000000000000000000000000000000..48e1e2cbfe446de072a8aa3db49d365424844bd6 --- /dev/null +++ b/internal/restic/testdata/repo/snapshots/03b061a61be6cc57836effa9a8585468141672577a9310128ae7674d1deed1bd @@ -0,0 +1,2 @@ +!My:R{0\!cU#*|A0rGТ>E.z^ VmR*)/.x!TRįĈ"\x q +IPȀeHC>ϒݪG6Μzz(c|d1)gsBc}a'(A8`OcF8 TmZ_ulYPFvJVj7BWn/djE5B/l6=YS3o^OQc;7Ty`٧(UHF;6۬-GjbMq5V]vn;~n!b%eώp]+ gN7tIm9;P!~ Vyk`t|GLv)R) \ No newline at end of file diff --git a/internal/restic/testdata/source/.config/keld/config.toml b/internal/restic/testdata/source/.config/keld/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..488f610eb13533d3b9dd692a670336dec1a91316 --- /dev/null +++ b/internal/restic/testdata/source/.config/keld/config.toml @@ -0,0 +1 @@ +[global] diff --git a/internal/restic/testdata/source/documents/notes/2026/february.md b/internal/restic/testdata/source/documents/notes/2026/february.md new file mode 100644 index 0000000000000000000000000000000000000000..708c53dfa0c42967a5357561bf6d2d9c134362c7 --- /dev/null +++ b/internal/restic/testdata/source/documents/notes/2026/february.md @@ -0,0 +1 @@ +project ideas diff --git a/internal/restic/testdata/source/documents/notes/2026/january.md b/internal/restic/testdata/source/documents/notes/2026/january.md new file mode 100644 index 0000000000000000000000000000000000000000..1a5b6bf542892b3ccb05d6a3d033f5a140986c3e --- /dev/null +++ b/internal/restic/testdata/source/documents/notes/2026/january.md @@ -0,0 +1 @@ +meeting notes diff --git a/internal/restic/testdata/source/documents/notes/todo.txt b/internal/restic/testdata/source/documents/notes/todo.txt new file mode 100644 index 0000000000000000000000000000000000000000..5b7d43c4ba7123a39b781072082e7de493e15d37 --- /dev/null +++ b/internal/restic/testdata/source/documents/notes/todo.txt @@ -0,0 +1 @@ +grocery list diff --git a/internal/restic/testdata/source/documents/tax returns/2025.pdf b/internal/restic/testdata/source/documents/tax returns/2025.pdf new file mode 100644 index 0000000000000000000000000000000000000000..904db9984f87e39221a8e72754dad48c2b94638d --- /dev/null +++ b/internal/restic/testdata/source/documents/tax returns/2025.pdf @@ -0,0 +1 @@ +======================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================== \ No newline at end of file diff --git "a/internal/restic/testdata/source/music/Jazz Caf\303\251/live/01 - Take Five.flac" "b/internal/restic/testdata/source/music/Jazz Caf\303\251/live/01 - Take Five.flac" new file mode 100644 index 0000000000000000000000000000000000000000..fbd1b2f36fd2d78f015cea447a631262b5c0178e --- /dev/null +++ "b/internal/restic/testdata/source/music/Jazz Caf\303\251/live/01 - Take Five.flac" @@ -0,0 +1 @@ +take five diff --git "a/internal/restic/testdata/source/music/Jazz Caf\303\251/live/02 - So What.flac" "b/internal/restic/testdata/source/music/Jazz Caf\303\251/live/02 - So What.flac" new file mode 100644 index 0000000000000000000000000000000000000000..52ee6baa07d9f61cd8995512241751354a38cada --- /dev/null +++ "b/internal/restic/testdata/source/music/Jazz Caf\303\251/live/02 - So What.flac" @@ -0,0 +1 @@ +so what diff --git "a/internal/restic/testdata/source/music/Jazz Caf\303\251/live/03 - Blue in Green.flac" "b/internal/restic/testdata/source/music/Jazz Caf\303\251/live/03 - Blue in Green.flac" new file mode 100644 index 0000000000000000000000000000000000000000..a1cd8f5ae970773e96f69bf1ad7d30c2b1bddb9f --- /dev/null +++ "b/internal/restic/testdata/source/music/Jazz Caf\303\251/live/03 - Blue in Green.flac" @@ -0,0 +1 @@ +blue in green diff --git "a/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" "b/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" new file mode 100644 index 0000000000000000000000000000000000000000..abe334939e2268795b6c80774d288f03424c7e7d --- /dev/null +++ "b/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" @@ -0,0 +1 @@ +fake flac data 1 diff --git "a/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" "b/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" new file mode 100644 index 0000000000000000000000000000000000000000..c1a5f8ed9701cd1606fb4fd80d600cc183f7608f --- /dev/null +++ "b/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" @@ -0,0 +1 @@ +fake flac data 2 diff --git "a/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" "b/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" new file mode 100644 index 0000000000000000000000000000000000000000..f44399f140225b73a3ffd061ddd14e986ea41aad --- /dev/null +++ "b/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" @@ -0,0 +1 @@ +fake flac data 3 diff --git "a/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" "b/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" new file mode 100644 index 0000000000000000000000000000000000000000..14684b85aa23a1b6a54dd82092caf749fbd3deff --- /dev/null +++ "b/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" @@ -0,0 +1 @@ +fake flac data 4 diff --git "a/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" "b/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" new file mode 100644 index 0000000000000000000000000000000000000000..c926270e8e8e6645bbc0490607b34b6da581ab2d --- /dev/null +++ "b/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" @@ -0,0 +1 @@ +fake flac data 5 diff --git a/internal/restic/testdata/source/photos/2025/february/IMG_0010.jpg b/internal/restic/testdata/source/photos/2025/february/IMG_0010.jpg new file mode 100644 index 0000000000000000000000000000000000000000..716ef6939b5acf59552a05b3b52ba0a6506af3b7 --- /dev/null +++ b/internal/restic/testdata/source/photos/2025/february/IMG_0010.jpg @@ -0,0 +1 @@ +jpg4 diff --git a/internal/restic/testdata/source/photos/2025/january/IMG_0001.jpg b/internal/restic/testdata/source/photos/2025/january/IMG_0001.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a35fcb82c0450d6cde8096580e7020b93866389b --- /dev/null +++ b/internal/restic/testdata/source/photos/2025/january/IMG_0001.jpg @@ -0,0 +1 @@ +jpg1 diff --git a/internal/restic/testdata/source/photos/2025/january/IMG_0002.jpg b/internal/restic/testdata/source/photos/2025/january/IMG_0002.jpg new file mode 100644 index 0000000000000000000000000000000000000000..99b7d44acd0ba2aad4e51bd1bb740fe01db80277 --- /dev/null +++ b/internal/restic/testdata/source/photos/2025/january/IMG_0002.jpg @@ -0,0 +1 @@ +jpg2 diff --git a/internal/restic/testdata/source/photos/2025/january/IMG_0003.jpg b/internal/restic/testdata/source/photos/2025/january/IMG_0003.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9a4ae340b93de1abaa43e2ce451cba668d414dca --- /dev/null +++ b/internal/restic/testdata/source/photos/2025/january/IMG_0003.jpg @@ -0,0 +1 @@ +jpg3 diff --git a/internal/restic/testdata/source/projects/keld/README.md b/internal/restic/testdata/source/projects/keld/README.md new file mode 100644 index 0000000000000000000000000000000000000000..974907ec9aa244cef680f0c9c34f571cfee6c9cc --- /dev/null +++ b/internal/restic/testdata/source/projects/keld/README.md @@ -0,0 +1 @@ +# keld diff --git a/internal/restic/testdata/source/projects/keld/cmd/root.go b/internal/restic/testdata/source/projects/keld/cmd/root.go new file mode 100644 index 0000000000000000000000000000000000000000..06ab7d0f9a35a7d1070711496d6ca1cb892a258f --- /dev/null +++ b/internal/restic/testdata/source/projects/keld/cmd/root.go @@ -0,0 +1 @@ +package main diff --git a/internal/restic/testdata/source/projects/keld/go.mod b/internal/restic/testdata/source/projects/keld/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..daa75d78c187fd8300c668a2b1f1b53ab46d179f --- /dev/null +++ b/internal/restic/testdata/source/projects/keld/go.mod @@ -0,0 +1 @@ +module git.secluded.site/keld diff --git a/internal/restic/testdata/source/projects/keld/internal/config/config.go b/internal/restic/testdata/source/projects/keld/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..d912156bec00a9f00850ab2ec3a3baf1016c2141 --- /dev/null +++ b/internal/restic/testdata/source/projects/keld/internal/config/config.go @@ -0,0 +1 @@ +package config diff --git a/internal/restic/testdata/source/projects/keld/internal/config/resolve.go b/internal/restic/testdata/source/projects/keld/internal/config/resolve.go new file mode 100644 index 0000000000000000000000000000000000000000..d912156bec00a9f00850ab2ec3a3baf1016c2141 --- /dev/null +++ b/internal/restic/testdata/source/projects/keld/internal/config/resolve.go @@ -0,0 +1 @@ +package config diff --git a/internal/restic/testdata/source/projects/keld/internal/restic/exec.go b/internal/restic/testdata/source/projects/keld/internal/restic/exec.go new file mode 100644 index 0000000000000000000000000000000000000000..43aafe9dad6d25e8c01e008e8b19593571954a7d --- /dev/null +++ b/internal/restic/testdata/source/projects/keld/internal/restic/exec.go @@ -0,0 +1 @@ +package restic diff --git a/internal/restic/testdata/source/projects/keld/internal/restic/snapshots.go b/internal/restic/testdata/source/projects/keld/internal/restic/snapshots.go new file mode 100644 index 0000000000000000000000000000000000000000..43aafe9dad6d25e8c01e008e8b19593571954a7d --- /dev/null +++ b/internal/restic/testdata/source/projects/keld/internal/restic/snapshots.go @@ -0,0 +1 @@ +package restic diff --git a/internal/restic/testdata/source/projects/web app/package.json b/internal/restic/testdata/source/projects/web app/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5268a59e5092845b678dd7bc22e15636c9ed2a1c --- /dev/null +++ b/internal/restic/testdata/source/projects/web app/package.json @@ -0,0 +1 @@ +{"name":"app"} diff --git a/internal/restic/testdata/source/projects/web app/public/index.html b/internal/restic/testdata/source/projects/web app/public/index.html new file mode 100644 index 0000000000000000000000000000000000000000..1a189798ecbcf58d1f6be3999484b65829561fdd --- /dev/null +++ b/internal/restic/testdata/source/projects/web app/public/index.html @@ -0,0 +1 @@ + diff --git a/internal/restic/testdata/source/projects/web app/public/style.css b/internal/restic/testdata/source/projects/web app/public/style.css new file mode 100644 index 0000000000000000000000000000000000000000..208d16d4213b91be6d840400703325d41ca9cb5e --- /dev/null +++ b/internal/restic/testdata/source/projects/web app/public/style.css @@ -0,0 +1 @@ +body {} diff --git a/internal/restic/testdata/source/projects/web app/src/components/App.tsx b/internal/restic/testdata/source/projects/web app/src/components/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..93ad8ac773f4b3a8cd4b8442f090a46725849d56 --- /dev/null +++ b/internal/restic/testdata/source/projects/web app/src/components/App.tsx @@ -0,0 +1 @@ +export default App diff --git a/internal/restic/testdata/source/projects/web app/src/components/Nav.tsx b/internal/restic/testdata/source/projects/web app/src/components/Nav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d1a9f95648d29c6c45f297e67ce9a794144ced05 --- /dev/null +++ b/internal/restic/testdata/source/projects/web app/src/components/Nav.tsx @@ -0,0 +1 @@ +export default Nav