1package restic
2
3import (
4 "io"
5 "io/fs"
6 "sort"
7 "strings"
8 "time"
9)
10
11// Compile-time interface checks.
12var (
13 _ fs.FS = (*SnapshotFS)(nil)
14 _ fs.ReadDirFS = (*SnapshotFS)(nil)
15 _ fs.StatFS = (*SnapshotFS)(nil)
16)
17
18// fsNode is the internal tree node used by SnapshotFS. It holds
19// metadata for one file or directory and, for directories, an index of
20// immediate children keyed by name.
21type fsNode struct {
22 name string
23 isDir bool
24 size int64
25 mode fs.FileMode
26 modTime time.Time
27 children map[string]*fsNode // nil for files
28}
29
30// SnapshotFS is a read-only, metadata-only fs.FS built from a flat
31// list of LsNode values (as produced by ParseLsNodes / RunLs).
32//
33// Paths inside the FS are slash-separated and relative; the root is ".".
34// No file content is available — Open returns files whose Read always
35// returns io.EOF. This is a browsing-only filesystem for the file picker.
36type SnapshotFS struct {
37 root *fsNode
38}
39
40// NewSnapshotFS constructs a SnapshotFS from a flat slice of LsNode
41// values. Absolute paths (as restic produces) are converted to
42// relative paths. Intermediate directories that lack explicit node
43// entries are synthesized with conservative metadata.
44func NewSnapshotFS(nodes []LsNode) *SnapshotFS {
45 root := &fsNode{
46 name: ".",
47 isDir: true,
48 mode: fs.ModeDir | 0o555,
49 children: make(map[string]*fsNode),
50 }
51
52 sfs := &SnapshotFS{root: root}
53
54 for _, n := range nodes {
55 // Strip leading slash to make paths relative.
56 rel := strings.TrimPrefix(n.Path, "/")
57 if rel == "" {
58 continue
59 }
60
61 isDir := n.Type == "dir"
62 mode := fs.FileMode(n.Mode) & fs.ModePerm
63 if isDir {
64 mode |= fs.ModeDir
65 }
66
67 leaf := &fsNode{
68 name: n.Name,
69 isDir: isDir,
70 size: n.Size,
71 mode: mode,
72 modTime: n.Mtime,
73 }
74 if isDir {
75 leaf.children = make(map[string]*fsNode)
76 }
77
78 sfs.insert(rel, leaf)
79 }
80
81 return sfs
82}
83
84// insert places leaf at the given relative path, synthesizing any
85// missing intermediate directories. If a synthesized directory already
86// exists at the target path (created as a parent of an earlier node),
87// the explicit metadata from leaf replaces it while preserving existing
88// children.
89func (sfs *SnapshotFS) insert(rel string, leaf *fsNode) {
90 parts := strings.Split(rel, "/")
91 cur := sfs.root
92
93 // Walk/create all intermediate directories.
94 for _, seg := range parts[:len(parts)-1] {
95 child, ok := cur.children[seg]
96 if !ok {
97 child = &fsNode{
98 name: seg,
99 isDir: true,
100 mode: fs.ModeDir | 0o555,
101 children: make(map[string]*fsNode),
102 }
103 cur.children[seg] = child
104 }
105 cur = child
106 }
107
108 // Place the leaf. If a synthesized dir already occupies this slot,
109 // preserve its children but upgrade metadata.
110 name := parts[len(parts)-1]
111 if existing, ok := cur.children[name]; ok && existing.isDir && leaf.isDir {
112 existing.name = leaf.name
113 existing.mode = leaf.mode
114 existing.modTime = leaf.modTime
115 existing.size = leaf.size
116 } else {
117 cur.children[name] = leaf
118 }
119}
120
121// Open opens the named file or directory. The name must be a valid
122// fs.FS path (slash-separated, no leading slash, root is ".").
123func (sfs *SnapshotFS) Open(name string) (fs.File, error) {
124 if !fs.ValidPath(name) {
125 return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
126 }
127
128 node := sfs.lookup(name)
129 if node == nil {
130 return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
131 }
132
133 return &snapshotFile{node: node, name: name}, nil
134}
135
136// ReadDir reads the named directory and returns a list of directory
137// entries sorted by name.
138func (sfs *SnapshotFS) ReadDir(name string) ([]fs.DirEntry, error) {
139 if !fs.ValidPath(name) {
140 return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
141 }
142
143 node := sfs.lookup(name)
144 if node == nil {
145 return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
146 }
147 if !node.isDir {
148 return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
149 }
150
151 return sortedEntries(node), nil
152}
153
154// sortedEntries returns the children of a directory node as a
155// name-sorted slice of fs.DirEntry values.
156func sortedEntries(node *fsNode) []fs.DirEntry {
157 entries := make([]fs.DirEntry, 0, len(node.children))
158 for _, child := range node.children {
159 entries = append(entries, &snapshotDirEntry{node: child})
160 }
161 sort.Slice(entries, func(i, j int) bool {
162 return entries[i].Name() < entries[j].Name()
163 })
164 return entries
165}
166
167// Stat returns a FileInfo describing the named file or directory.
168func (sfs *SnapshotFS) Stat(name string) (fs.FileInfo, error) {
169 if !fs.ValidPath(name) {
170 return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
171 }
172
173 node := sfs.lookup(name)
174 if node == nil {
175 return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
176 }
177
178 return &snapshotFileInfo{node: node}, nil
179}
180
181// lookup resolves a valid fs.FS path to the corresponding fsNode,
182// or nil if not found.
183func (sfs *SnapshotFS) lookup(name string) *fsNode {
184 if name == "." {
185 return sfs.root
186 }
187
188 cur := sfs.root
189 for seg := range strings.SplitSeq(name, "/") {
190 child, ok := cur.children[seg]
191 if !ok {
192 return nil
193 }
194 cur = child
195 }
196 return cur
197}
198
199// --- fs.File implementation ---
200
201// snapshotFile is a metadata-only fs.File. Read always returns io.EOF.
202// For directories, ReadDir is stateful: successive calls with n > 0
203// advance through the sorted entry list per the fs.ReadDirFile contract.
204type snapshotFile struct {
205 node *fsNode
206 name string
207 dirRead int // offset into sorted children for ReadDir(n)
208}
209
210func (f *snapshotFile) Stat() (fs.FileInfo, error) {
211 return &snapshotFileInfo{node: f.node}, nil
212}
213
214func (f *snapshotFile) Read([]byte) (int, error) {
215 return 0, io.EOF
216}
217
218func (f *snapshotFile) Close() error {
219 return nil
220}
221
222// ReadDir implements fs.ReadDirFile. For directories, successive calls
223// with n > 0 advance through the sorted entry list. Calling with n <= 0
224// returns all remaining entries.
225func (f *snapshotFile) ReadDir(n int) ([]fs.DirEntry, error) {
226 if !f.node.isDir {
227 return nil, &fs.PathError{Op: "readdir", Path: f.name, Err: fs.ErrInvalid}
228 }
229
230 entries := sortedEntries(f.node)
231 remaining := entries[f.dirRead:]
232
233 if n <= 0 {
234 f.dirRead = len(entries)
235 return remaining, nil
236 }
237
238 if len(remaining) == 0 {
239 return nil, io.EOF
240 }
241 if n > len(remaining) {
242 f.dirRead = len(entries)
243 return remaining, io.EOF
244 }
245 f.dirRead += n
246 return remaining[:n], nil
247}
248
249// --- fs.FileInfo implementation ---
250
251type snapshotFileInfo struct {
252 node *fsNode
253}
254
255func (fi *snapshotFileInfo) Name() string { return fi.node.name }
256func (fi *snapshotFileInfo) Size() int64 { return fi.node.size }
257func (fi *snapshotFileInfo) Mode() fs.FileMode { return fi.node.mode }
258func (fi *snapshotFileInfo) ModTime() time.Time { return fi.node.modTime }
259func (fi *snapshotFileInfo) IsDir() bool { return fi.node.isDir }
260func (fi *snapshotFileInfo) Sys() any { return nil }
261func (fi *snapshotFileInfo) Info() (fs.FileInfo, error) { return fi, nil }
262
263// --- fs.DirEntry implementation ---
264
265type snapshotDirEntry struct {
266 node *fsNode
267}
268
269func (de *snapshotDirEntry) Name() string { return de.node.name }
270func (de *snapshotDirEntry) IsDir() bool { return de.node.isDir }
271func (de *snapshotDirEntry) Type() fs.FileMode { return de.node.mode.Type() }
272func (de *snapshotDirEntry) Info() (fs.FileInfo, error) { return &snapshotFileInfo{node: de.node}, nil }