snapshotfs.go

  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 }