Fix review findings: hostile snapshots, stale async results, trash safety

- snapshot: depth limit (stack overflow in exporters), decompressed-size
  limit (gzip bombs), per-node size cap and saturating aggregation
- export: sanitise exclusion patterns from snapshot metadata
- tui: drop duplicate/trash/filter results that belong to a replaced
  tree; cancel duplicate search on rescan
- trash: refuse symlinked or foreign shared-filesystem trash dirs; never
  overwrite an existing trashed item
- scan: no symlink following without inode identity; correct unscanned
  count and keep real read errors after cancellation
This commit is contained in:
Andy Dixon
2026-10-02 08:46:52 +01:00
parent 11a0e401ee
commit f4ef12a0dd
10 changed files with 315 additions and 44 deletions
+12 -5
View File
@@ -17,8 +17,9 @@ import (
// ---- Trash ---------------------------------------------------------------
type trashDoneMsg struct {
id inventory.NodeID
err error
tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped
id inventory.NodeID
err error
}
// requestTrash asks for confirmation before moving the selection to the
@@ -49,7 +50,8 @@ func (m *Model) requestTrash() tea.Cmd {
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
yes: "move to " + platform.TrashName,
onYes: func(m *Model) tea.Cmd {
return func() tea.Msg { return trashDoneMsg{id, platform.Trash(path)} }
t := m.tree
return func() tea.Msg { return trashDoneMsg{t, id, platform.Trash(path)} }
},
})
return nil
@@ -59,6 +61,10 @@ func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
if msg.err != nil {
return m.warn("Trash failed: " + textutil.Sanitize(msg.err.Error()))
}
if msg.tree != m.tree || m.scanning {
// A rescan replaced the inventory; the new scan reflects the change.
return m.info("Moved to " + platform.TrashName)
}
// The scan has finished, so the UI goroutine is the tree's only writer.
t := m.tree
t.Lock()
@@ -96,6 +102,7 @@ type dupState struct {
type (
dupProgressMsg struct{}
dupDoneMsg struct {
finder *duplicate.Finder // identifies the search; stale results are dropped
groups []duplicate.Group
err error
}
@@ -128,7 +135,7 @@ func (m *Model) startDuplicates() tea.Cmd {
t := m.tree
return tea.Batch(m.startTicking(), func() tea.Msg {
g, err := f.Find(ctx, t, duplicate.Options{MinSize: 1, Workers: 4})
return dupDoneMsg{g, err}
return dupDoneMsg{f, g, err}
})
},
})
@@ -147,7 +154,7 @@ func (m *Model) ensureDupView() {
}
func (m *Model) dupMsg(msg tea.Msg) tea.Cmd {
if d, ok := msg.(dupDoneMsg); ok && m.dups != nil {
if d, ok := msg.(dupDoneMsg); ok && m.dups != nil && d.finder == m.dups.finder {
m.dups.running = false
m.dups.groups, m.dups.err = d.groups, d.err
m.dirty = true