diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0049300..16ae4f3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -152,7 +152,9 @@ releases the lock between batches). This is the only lock in the data path. own (tiny) size. `same-filesystem` / `all` follow directory symlinks; every descended directory's `(dev, inode)` is recorded and a directory already visited is never descended twice — loops terminate and nothing is counted - twice. + twice. On platforms without inode identity (Windows, other portable + fallbacks) loop detection is impossible, so symlinks are never followed + there and scan info says so. * A directory whose device differs from its parent is flagged `MountPoint`. With `--one-file-system` it is not descended. On Linux, virtual filesystems (proc, sysfs, cgroup, debugfs, …) are never descended — `/proc/kcore` would @@ -292,9 +294,11 @@ Filenames and snapshot contents are untrusted. * Text is clipped by display width (grapheme clusters, East-Asian wide characters) so labels never overflow their rectangle. * Snapshot decoding validates magic, version, every length, every parent - reference (a parent must precede its child, so cycles and recursion are - impossible), caps decompressed size and node count, and verifies a SHA-256 - trailer. Aggregates are recomputed rather than trusted. + reference (a parent must precede its child, so cycles are impossible), + depth (≤ 4096 levels, bounding recursion in exporters), per-node sizes + (≤ 1 EiB) and node count; caps the decompressed body at 64× the file size + (decompression bombs fail fast); and verifies a SHA-256 trailer. + Aggregates are recomputed with saturating arithmetic rather than trusted. * No shell is ever invoked. "Open/reveal" and trash use direct `exec` with argument vectors or native APIs; paths are passed as single arguments. * `--read-only` disables every mutating action in the UI. diff --git a/internal/export/export.go b/internal/export/export.go index 8880137..4d8dd3e 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -10,6 +10,7 @@ import ( "io" "path/filepath" "strconv" + "strings" "time" "github.com/andydixon/mapsize/internal/brand" @@ -203,7 +204,7 @@ func Summary(w io.Writer, t *inventory.Tree) { } } if st.Excluded > 0 { - fmt.Fprintf(w, "Excluded %s entries (%v)\n", textutil.Count(st.Excluded), st.Excludes) + fmt.Fprintf(w, "Excluded %s entries (%s)\n", textutil.Count(st.Excluded), textutil.Sanitize(strings.Join(st.Excludes, ", "))) } if st.SkippedMounts > 0 { fmt.Fprintf(w, "Other FS skipped %s\n", textutil.Count(st.SkippedMounts)) diff --git a/internal/inventory/tree.go b/internal/inventory/tree.go index c38d9f6..dd50123 100644 --- a/internal/inventory/tree.go +++ b/internal/inventory/tree.go @@ -8,6 +8,7 @@ package inventory import ( + "math" "path/filepath" "slices" "sync" @@ -210,6 +211,14 @@ func (t *Tree) Propagate(id NodeID, d Delta) { } } +// satAdd adds non-negative int64s, saturating at the maximum. +func satAdd(a, b int64) int64 { + if b > 0 && a > math.MaxInt64-b { + return math.MaxInt64 + } + return a + b +} + func addU32(a uint32, d int64) uint32 { v := int64(a) + d if v < 0 { @@ -300,23 +309,25 @@ func (t *Tree) Recompute() { *t.dirs.at(n.dir) = [NumCategories]int64{} } } + // Sums saturate: snapshot data is untrusted and must not wrap to + // negative totals. for i := t.Len() - 1; i > 0; i-- { n := t.Node(NodeID(i)) p := t.Node(n.Parent) - p.TotSize += n.TotSize - p.TotAlloc += n.TotAlloc - p.Errors += n.Errors + p.TotSize = satAdd(p.TotSize, n.TotSize) + p.TotAlloc = satAdd(p.TotAlloc, n.TotAlloc) + p.Errors = addU32(p.Errors, int64(n.Errors)) cs := t.dirs.at(p.dir) if n.Kind == KindDir { - p.Files += n.Files - p.Dirs += n.Dirs + 1 + p.Files = addU32(p.Files, int64(n.Files)) + p.Dirs = addU32(p.Dirs, int64(n.Dirs)+1) for c, v := range t.dirs.at(n.dir) { - cs[c] += v + cs[c] = satAdd(cs[c], v) } } else { - p.Files++ + p.Files = addU32(p.Files, 1) if n.Flags&FlagHardlinkDup == 0 { - cs[n.Cat] += n.Alloc + cs[n.Cat] = satAdd(cs[n.Cat], n.Alloc) } } } diff --git a/internal/platform/ops_linux.go b/internal/platform/ops_linux.go index bca1bab..9f75f57 100644 --- a/internal/platform/ops_linux.go +++ b/internal/platform/ops_linux.go @@ -60,11 +60,19 @@ func Trash(path string) error { infoPath = rel } files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info") - if err := os.MkdirAll(files, 0o700); err != nil { - return err + if trash == home { + if err := os.MkdirAll(trash, 0o700); err != nil { + return err + } + } else if err := ensurePrivateDir(trash, true); err != nil { + // On a shared filesystem another user could plant a symlink or a + // world-writable directory here; refuse rather than follow it. + return fmt.Errorf("trash directory %s is unsafe: %w", trash, err) } - if err := os.MkdirAll(info, 0o700); err != nil { - return err + for _, d := range []string{files, info} { + if err := ensurePrivateDir(d, trash != home); err != nil { + return fmt.Errorf("trash directory %s is unsafe: %w", d, err) + } } base := filepath.Base(path) for i := 1; i < 10000; i++ { @@ -72,6 +80,10 @@ func Trash(path string) error { if i > 1 { name = fmt.Sprintf("%s.%d", base, i) } + dst := filepath.Join(files, name) + if _, err := os.Lstat(dst); err == nil { + continue // never overwrite an earlier trashed item (even without .trashinfo) + } ip := filepath.Join(info, name+".trashinfo") f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if errors.Is(err, os.ErrExist) { @@ -87,7 +99,7 @@ func Trash(path string) error { os.Remove(ip) return errors.Join(werr, cerr) } - if err := os.Rename(path, filepath.Join(files, name)); err != nil { + if err := os.Rename(path, dst); err != nil { os.Remove(ip) return err } @@ -96,6 +108,31 @@ func Trash(path string) error { return errors.New("trash: too many items with the same name") } +// ensurePrivateDir creates dir (mode 0700) if missing; an existing entry must +// be a real directory (not a symlink) owned by us. With strict (trash on a +// shared filesystem) it must also be inaccessible to others, as the +// freedesktop.org spec requires; home trash directories made by desktop +// environments are sometimes 0755, which is harmless there. +func ensurePrivateDir(dir string, strict bool) error { + if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return err + } + fi, err := os.Lstat(dir) + if err != nil { + return err + } + st := fi.Sys().(*syscall.Stat_t) + switch { + case !fi.IsDir(): + return errors.New("not a directory (symlink?)") + case int(st.Uid) != os.Getuid(): + return errors.New("owned by another user") + case strict && fi.Mode().Perm()&0o077 != 0: + return errors.New("accessible to other users") + } + return nil +} + // mountTop returns the top directory of the filesystem containing path. func mountTop(path string, dev uint64) string { cur := filepath.Dir(path) diff --git a/internal/platform/ops_linux_test.go b/internal/platform/ops_linux_test.go index d86712a..3f32da0 100644 --- a/internal/platform/ops_linux_test.go +++ b/internal/platform/ops_linux_test.go @@ -42,3 +42,23 @@ func TestTrashFreedesktop(t *testing.T) { t.Fatal("relative paths must be refused") } } + +func TestEnsurePrivateDirRejectsSymlink(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "attacker") + os.Mkdir(target, 0o700) + link := filepath.Join(base, ".Trash-1000") + os.Symlink(target, link) + if err := ensurePrivateDir(link, false); err == nil { + t.Fatal("symlinked trash dir accepted") + } + open := filepath.Join(base, "open") + os.Mkdir(open, 0o777) + os.Chmod(open, 0o777) + if err := ensurePrivateDir(open, true); err == nil { + t.Fatal("world-accessible trash dir accepted") + } + if err := ensurePrivateDir(filepath.Join(base, "fresh"), true); err != nil { + t.Fatal(err) + } +} diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 3d7294b..876ec02 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -176,7 +176,13 @@ func Start(ctx context.Context, root string, opts Options) (*Scanner, error) { } t.Stats.Dirs = 1 c := &controller{s: s, t: t, opts: opts, links: map[inodeKey]struct{}{}} - if opts.Follow != FollowNone { + // Loop detection needs inode identity; without it (Windows and other + // portable-fallback platforms) symlinks are not followed at all. + if opts.Follow != FollowNone && !m.HasIno { + c.opts.Follow = FollowNone + t.Stats.Follow = "none (no inode identity on this platform)" + } + if c.opts.Follow != FollowNone { c.visited = map[inodeKey]struct{}{{m.Dev, m.Ino}: {}} } c.rootDev = m.Dev @@ -271,7 +277,7 @@ func (c *controller) run(ctx context.Context) { } c.t.Lock() for i := range batch { - if c.apply(&batch[i], cancelled) { + if c.apply(&batch[i]) { inflight-- } batch[i] = result{} @@ -292,7 +298,7 @@ func (c *controller) run(ctx context.Context) { st := &c.t.Stats if cancelled { st.Cancelled = true - st.Unscanned = int64(len(c.pending) - c.head) + st.Unscanned += int64(len(c.pending) - c.head) for _, p := range c.pending[c.head:] { c.t.Node(p.id).Flags |= inventory.FlagIncomplete } @@ -352,7 +358,7 @@ func setMeta(n *inventory.Node, m platform.Meta) { // apply merges one result into the tree. It reports whether the result // completed its directory. Called with the tree write-locked. -func (c *controller) apply(r *result, cancelled bool) bool { +func (c *controller) apply(r *result) bool { t := c.t j := r.job dir := j.id @@ -366,7 +372,7 @@ func (c *controller) apply(r *result, cancelled bool) bool { switch { case r.err == nil: dn.Flags |= inventory.FlagScanned - case errors.Is(r.err, context.Canceled) || errors.Is(r.err, context.DeadlineExceeded) || cancelled: + case errors.Is(r.err, context.Canceled) || errors.Is(r.err, context.DeadlineExceeded): dn.Flags |= inventory.FlagIncomplete t.Stats.Unscanned++ default: @@ -447,7 +453,7 @@ func (c *controller) addEntry(j *job, m platform.Meta, d *inventory.Delta) { case err != nil: st.BrokenLinks++ broken = true - case tm.Mode.IsDir() && (c.opts.Follow == FollowAll || c.opts.Follow == FollowSameFS && tm.Dev == j.dev): + case tm.Mode.IsDir() && tm.HasIno && (c.opts.Follow == FollowAll || c.opts.Follow == FollowSameFS && tm.Dev == j.dev): target, followed = tm, true kind = inventory.KindDir } diff --git a/internal/snapshot/hostile_test.go b/internal/snapshot/hostile_test.go new file mode 100644 index 0000000..321305a --- /dev/null +++ b/internal/snapshot/hostile_test.go @@ -0,0 +1,131 @@ +package snapshot + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/binary" + "io" + "strings" + "testing" + + "github.com/andydixon/mapsize/internal/brand" + "github.com/andydixon/mapsize/internal/export" + "github.com/andydixon/mapsize/internal/inventory" +) + +// craft builds a well-formed (correctly checksummed) but hostile snapshot. +func craft(n int, meta string, rec func(i int, w *writer)) []byte { + var out bytes.Buffer + out.WriteString(brand.SnapshotMagic) + out.Write(binary.LittleEndian.AppendUint16(nil, Version)) + out.Write(binary.LittleEndian.AppendUint16(nil, flagGzipped)) + gz, _ := gzip.NewWriterLevel(&out, gzip.BestCompression) + h := sha256.New() + w := &writer{w: io.MultiWriter(gz, h)} + w.uvarint(uint64(len(meta))) + w.bytes([]byte(meta)) + w.uvarint(1) + w.str("") + w.uvarint(uint64(n)) + for i := range n { + rec(i, w) + } + w.uvarint(0) + gz.Close() + out.Write(h.Sum(nil)) + return out.Bytes() +} + +func node(w *writer, pd uint64, kind inventory.Kind, name string, size int64) { + w.uvarint(pd) + w.bytes([]byte{byte(kind), 0}) + w.uvarint(0) + w.str(name) + w.varint(size) + w.varint(size) + w.varint(0) + for range 5 { + w.uvarint(0) + } +} + +func chain(n int) []byte { + return craft(n, `{"root":"/r"}`, func(i int, w *writer) { + if i == 0 { + node(w, 0, inventory.KindDir, "", 0) + } else { + node(w, 1, inventory.KindDir, "a", 0) + } + }) +} + +func TestDeepChainRejected(t *testing.T) { + if _, err := Load(bytes.NewReader(chain(MaxDepth + 1))); err != nil { + t.Fatalf("depth at the limit must load: %v", err) + } + _, err := Load(bytes.NewReader(chain(MaxDepth + 2))) + if err == nil || !strings.Contains(err.Error(), "deeper") { + t.Fatalf("over-deep chain accepted: %v", err) + } +} + +func TestDecompressionBombLimited(t *testing.T) { + flat := craft(100_000, `{"root":"/r"}`, func(i int, w *writer) { + if i == 0 { + node(w, 0, inventory.KindDir, "", 0) + } else { + node(w, uint64(i), inventory.KindFile, "f", 0) + } + }) + _, err := LoadLimit(bytes.NewReader(flat), 64<<10) + if err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("bomb not limited: %v", err) + } + if got := bodyLimit(int64(len(flat))); got < 64<<20 { + t.Fatalf("bodyLimit floor: %d", got) + } +} + +func TestHugeSizesDoNotWrap(t *testing.T) { + data := craft(3, `{"root":"/r"}`, func(i int, w *writer) { + if i == 0 { + node(w, 0, inventory.KindDir, "", 0) + } else { + node(w, uint64(i), inventory.KindFile, "f", MaxSize) + } + }) + tr, err := Load(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + if tr.Node(0).TotSize < 0 { + t.Fatal("aggregate wrapped negative") + } + over := craft(2, `{"root":"/r"}`, func(i int, w *writer) { + if i == 0 { + node(w, 0, inventory.KindDir, "", 0) + } else { + node(w, 1, inventory.KindFile, "f", MaxSize+1) + } + }) + if _, err := Load(bytes.NewReader(over)); err == nil { + t.Fatal("size above MaxSize accepted") + } +} + +func TestHostileMetadataSanitisedInSummary(t *testing.T) { + data := craft(1, `{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}`, func(i int, w *writer) { + node(w, 0, inventory.KindDir, "", 0) + }) + tr, err := Load(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + tr.Stats.Excluded = 1 + var b bytes.Buffer + export.Summary(&b, tr) + if strings.ContainsAny(b.String(), "\x1b\x07") { + t.Fatalf("raw control characters in summary: %q", b.String()) + } +} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 52c1980..2fca2e4 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -47,13 +47,18 @@ const Version = 1 // Limits applied when reading untrusted snapshots. const ( - MaxNodes = 1<<31 - 1 - MaxNameLen = 1 << 16 - MaxMetaLen = 16 << 20 - MaxExtLen = 64 - MaxMsgLen = 4096 - maxExts = 1<<16 - 1 - flagGzipped = 1 + MaxNodes = 1<<31 - 1 + MaxDepth = 4096 // far beyond any real path; bounds recursion in exporters + MaxSize = 1 << 60 // per-node size cap (1 EiB); keeps aggregates from overflowing + // DefaultMaxBody caps the decompressed body for Load. LoadFile uses a + // limit proportional to the file size instead (see bodyLimit). + DefaultMaxBody = 4 << 30 + MaxNameLen = 1 << 16 + MaxMetaLen = 16 << 20 + MaxExtLen = 64 + MaxMsgLen = 4096 + maxExts = 1<<16 - 1 + flagGzipped = 1 ) type meta struct { @@ -305,8 +310,35 @@ func (r *reader) str(max uint64, what string) string { return string(r.bytesN(r.uvarint(max, what+" length"))) } -// Load reads and validates a snapshot. -func Load(in io.Reader) (*inventory.Tree, error) { +// limitedReader fails (rather than silently truncating) past n bytes, so a +// decompression bomb is reported as such. +type limitedReader struct { + r io.Reader + n int64 +} + +func (l *limitedReader) Read(p []byte) (int, error) { + if l.n <= 0 { + return 0, errors.New("snapshot: decompressed size exceeds limit (possible decompression bomb)") + } + if int64(len(p)) > l.n { + p = p[:l.n] + } + n, err := l.r.Read(p) + l.n -= int64(n) + return n, err +} + +// bodyLimit allows decompressed bodies up to 64× the compressed file size +// (real snapshots compress ~6×), with a 64 MiB floor for tiny files. +func bodyLimit(fileSize int64) int64 { return max(64<<20, fileSize*64) } + +// Load reads and validates a snapshot, allowing up to DefaultMaxBody bytes +// of decompressed data. +func Load(in io.Reader) (*inventory.Tree, error) { return LoadLimit(in, DefaultMaxBody) } + +// LoadLimit is Load with an explicit decompressed-size limit. +func LoadLimit(in io.Reader, maxBody int64) (*inventory.Tree, error) { br := bufio.NewReaderSize(in, 1<<20) hdr := make([]byte, len(brand.SnapshotMagic)+4) if _, err := io.ReadFull(br, hdr); err != nil { @@ -333,7 +365,7 @@ func Load(in io.Reader) (*inventory.Tree, error) { pr, pw := io.Pipe() defer pr.Close() // unblocks the copier if we bail out early go func() { - _, err := io.CopyBuffer(pw, io.TeeReader(gz, h), make([]byte, 256<<10)) + _, err := io.CopyBuffer(pw, io.TeeReader(&limitedReader{gz, maxBody}, h), make([]byte, 256<<10)) pw.CloseWithError(err) }() r := &reader{r: bufio.NewReaderSize(pr, 1<<16)} @@ -358,6 +390,7 @@ func Load(in io.Reader) (*inventory.Tree, error) { } extIndex := t.ExtIndexer() nNodes := r.uvarint(MaxNodes, "node count") + depth := []uint16{0} // per node, for the depth limit; parents precede children if nNodes == 0 { return nil, errors.New("snapshot: no root node") } @@ -380,8 +413,8 @@ func Load(in io.Reader) (*inventory.Tree, error) { r.fail("node %d: invalid kind/category", i) break } - if size < 0 || alloc < 0 { - r.fail("node %d: negative size", i) + if size < 0 || alloc < 0 || size > MaxSize || alloc > MaxSize { + r.fail("node %d: size out of range", i) break } var id inventory.NodeID @@ -407,7 +440,12 @@ func Load(in io.Reader) (*inventory.Tree, error) { r.fail("node %d: invalid name %q", i, name) break } + if d := depth[parent] + 1; d > MaxDepth { + r.fail("node %d: deeper than %d levels", i, MaxDepth) + break + } id = t.Add(parent, name, kind) + depth = append(depth, depth[parent]+1) } n := t.Node(id) n.Cat, n.Flags = cat, flags @@ -469,5 +507,9 @@ func LoadFile(path string) (*inventory.Tree, error) { return nil, err } defer f.Close() - return Load(f) + limit := int64(DefaultMaxBody) + if fi, err := f.Stat(); err == nil { + limit = bodyLimit(fi.Size()) + } + return LoadLimit(f, limit) } diff --git a/internal/tui/actions.go b/internal/tui/actions.go index eaabe4e..cf7af22 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -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 diff --git a/internal/tui/model.go b/internal/tui/model.go index aa5756e..5f9ad5a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -378,10 +378,22 @@ func (m *Model) rescan() tea.Cmd { m.scanner, m.tree, m.cancel, m.scanning, m.ctrlC = s, t, cancel, s != nil, false m.zoom, m.sel = t.Root(), noSel m.kidsCache = map[inventory.NodeID]*kids{} - m.dups = nil + if m.dups != nil { + m.dups.cancel() + m.dups = nil + // Drop the duplicates view: its rows referred to the old tree. + for i, v := range m.views { + if _, ok := v.(*dupView); ok { + m.views = append(m.views[:i], m.views[i+1:]...) + m.view = 0 + break + } + } + } m.search.result = nil + m.search.gen++ // results computed against the old tree are discarded m.invalidate() - return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…")) + return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…"), m.applyFilter()) } // resolvePendingZoom re-zooms into the remembered directory once the new