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:
+8
-4
@@ -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
|
own (tiny) size. `same-filesystem` / `all` follow directory symlinks; every
|
||||||
descended directory's `(dev, inode)` is recorded and a directory already
|
descended directory's `(dev, inode)` is recorded and a directory already
|
||||||
visited is never descended twice — loops terminate and nothing is counted
|
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`.
|
* A directory whose device differs from its parent is flagged `MountPoint`.
|
||||||
With `--one-file-system` it is not descended. On Linux, virtual filesystems
|
With `--one-file-system` it is not descended. On Linux, virtual filesystems
|
||||||
(proc, sysfs, cgroup, debugfs, …) are never descended — `/proc/kcore` would
|
(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
|
* Text is clipped by display width (grapheme clusters, East-Asian wide
|
||||||
characters) so labels never overflow their rectangle.
|
characters) so labels never overflow their rectangle.
|
||||||
* Snapshot decoding validates magic, version, every length, every parent
|
* Snapshot decoding validates magic, version, every length, every parent
|
||||||
reference (a parent must precede its child, so cycles and recursion are
|
reference (a parent must precede its child, so cycles are impossible),
|
||||||
impossible), caps decompressed size and node count, and verifies a SHA-256
|
depth (≤ 4096 levels, bounding recursion in exporters), per-node sizes
|
||||||
trailer. Aggregates are recomputed rather than trusted.
|
(≤ 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
|
* No shell is ever invoked. "Open/reveal" and trash use direct `exec` with
|
||||||
argument vectors or native APIs; paths are passed as single arguments.
|
argument vectors or native APIs; paths are passed as single arguments.
|
||||||
* `--read-only` disables every mutating action in the UI.
|
* `--read-only` disables every mutating action in the UI.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/andydixon/mapsize/internal/brand"
|
"github.com/andydixon/mapsize/internal/brand"
|
||||||
@@ -203,7 +204,7 @@ func Summary(w io.Writer, t *inventory.Tree) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st.Excluded > 0 {
|
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 {
|
if st.SkippedMounts > 0 {
|
||||||
fmt.Fprintf(w, "Other FS skipped %s\n", textutil.Count(st.SkippedMounts))
|
fmt.Fprintf(w, "Other FS skipped %s\n", textutil.Count(st.SkippedMounts))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
package inventory
|
package inventory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"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 {
|
func addU32(a uint32, d int64) uint32 {
|
||||||
v := int64(a) + d
|
v := int64(a) + d
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
@@ -300,23 +309,25 @@ func (t *Tree) Recompute() {
|
|||||||
*t.dirs.at(n.dir) = [NumCategories]int64{}
|
*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-- {
|
for i := t.Len() - 1; i > 0; i-- {
|
||||||
n := t.Node(NodeID(i))
|
n := t.Node(NodeID(i))
|
||||||
p := t.Node(n.Parent)
|
p := t.Node(n.Parent)
|
||||||
p.TotSize += n.TotSize
|
p.TotSize = satAdd(p.TotSize, n.TotSize)
|
||||||
p.TotAlloc += n.TotAlloc
|
p.TotAlloc = satAdd(p.TotAlloc, n.TotAlloc)
|
||||||
p.Errors += n.Errors
|
p.Errors = addU32(p.Errors, int64(n.Errors))
|
||||||
cs := t.dirs.at(p.dir)
|
cs := t.dirs.at(p.dir)
|
||||||
if n.Kind == KindDir {
|
if n.Kind == KindDir {
|
||||||
p.Files += n.Files
|
p.Files = addU32(p.Files, int64(n.Files))
|
||||||
p.Dirs += n.Dirs + 1
|
p.Dirs = addU32(p.Dirs, int64(n.Dirs)+1)
|
||||||
for c, v := range t.dirs.at(n.dir) {
|
for c, v := range t.dirs.at(n.dir) {
|
||||||
cs[c] += v
|
cs[c] = satAdd(cs[c], v)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
p.Files++
|
p.Files = addU32(p.Files, 1)
|
||||||
if n.Flags&FlagHardlinkDup == 0 {
|
if n.Flags&FlagHardlinkDup == 0 {
|
||||||
cs[n.Cat] += n.Alloc
|
cs[n.Cat] = satAdd(cs[n.Cat], n.Alloc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,19 @@ func Trash(path string) error {
|
|||||||
infoPath = rel
|
infoPath = rel
|
||||||
}
|
}
|
||||||
files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info")
|
files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info")
|
||||||
if err := os.MkdirAll(files, 0o700); err != nil {
|
if trash == home {
|
||||||
return err
|
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 {
|
for _, d := range []string{files, info} {
|
||||||
return err
|
if err := ensurePrivateDir(d, trash != home); err != nil {
|
||||||
|
return fmt.Errorf("trash directory %s is unsafe: %w", d, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
base := filepath.Base(path)
|
base := filepath.Base(path)
|
||||||
for i := 1; i < 10000; i++ {
|
for i := 1; i < 10000; i++ {
|
||||||
@@ -72,6 +80,10 @@ func Trash(path string) error {
|
|||||||
if i > 1 {
|
if i > 1 {
|
||||||
name = fmt.Sprintf("%s.%d", base, i)
|
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")
|
ip := filepath.Join(info, name+".trashinfo")
|
||||||
f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
if errors.Is(err, os.ErrExist) {
|
if errors.Is(err, os.ErrExist) {
|
||||||
@@ -87,7 +99,7 @@ func Trash(path string) error {
|
|||||||
os.Remove(ip)
|
os.Remove(ip)
|
||||||
return errors.Join(werr, cerr)
|
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)
|
os.Remove(ip)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -96,6 +108,31 @@ func Trash(path string) error {
|
|||||||
return errors.New("trash: too many items with the same name")
|
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.
|
// mountTop returns the top directory of the filesystem containing path.
|
||||||
func mountTop(path string, dev uint64) string {
|
func mountTop(path string, dev uint64) string {
|
||||||
cur := filepath.Dir(path)
|
cur := filepath.Dir(path)
|
||||||
|
|||||||
@@ -42,3 +42,23 @@ func TestTrashFreedesktop(t *testing.T) {
|
|||||||
t.Fatal("relative paths must be refused")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-6
@@ -176,7 +176,13 @@ func Start(ctx context.Context, root string, opts Options) (*Scanner, error) {
|
|||||||
}
|
}
|
||||||
t.Stats.Dirs = 1
|
t.Stats.Dirs = 1
|
||||||
c := &controller{s: s, t: t, opts: opts, links: map[inodeKey]struct{}{}}
|
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.visited = map[inodeKey]struct{}{{m.Dev, m.Ino}: {}}
|
||||||
}
|
}
|
||||||
c.rootDev = m.Dev
|
c.rootDev = m.Dev
|
||||||
@@ -271,7 +277,7 @@ func (c *controller) run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
c.t.Lock()
|
c.t.Lock()
|
||||||
for i := range batch {
|
for i := range batch {
|
||||||
if c.apply(&batch[i], cancelled) {
|
if c.apply(&batch[i]) {
|
||||||
inflight--
|
inflight--
|
||||||
}
|
}
|
||||||
batch[i] = result{}
|
batch[i] = result{}
|
||||||
@@ -292,7 +298,7 @@ func (c *controller) run(ctx context.Context) {
|
|||||||
st := &c.t.Stats
|
st := &c.t.Stats
|
||||||
if cancelled {
|
if cancelled {
|
||||||
st.Cancelled = true
|
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:] {
|
for _, p := range c.pending[c.head:] {
|
||||||
c.t.Node(p.id).Flags |= inventory.FlagIncomplete
|
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
|
// apply merges one result into the tree. It reports whether the result
|
||||||
// completed its directory. Called with the tree write-locked.
|
// 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
|
t := c.t
|
||||||
j := r.job
|
j := r.job
|
||||||
dir := j.id
|
dir := j.id
|
||||||
@@ -366,7 +372,7 @@ func (c *controller) apply(r *result, cancelled bool) bool {
|
|||||||
switch {
|
switch {
|
||||||
case r.err == nil:
|
case r.err == nil:
|
||||||
dn.Flags |= inventory.FlagScanned
|
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
|
dn.Flags |= inventory.FlagIncomplete
|
||||||
t.Stats.Unscanned++
|
t.Stats.Unscanned++
|
||||||
default:
|
default:
|
||||||
@@ -447,7 +453,7 @@ func (c *controller) addEntry(j *job, m platform.Meta, d *inventory.Delta) {
|
|||||||
case err != nil:
|
case err != nil:
|
||||||
st.BrokenLinks++
|
st.BrokenLinks++
|
||||||
broken = true
|
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
|
target, followed = tm, true
|
||||||
kind = inventory.KindDir
|
kind = inventory.KindDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,13 +47,18 @@ const Version = 1
|
|||||||
|
|
||||||
// Limits applied when reading untrusted snapshots.
|
// Limits applied when reading untrusted snapshots.
|
||||||
const (
|
const (
|
||||||
MaxNodes = 1<<31 - 1
|
MaxNodes = 1<<31 - 1
|
||||||
MaxNameLen = 1 << 16
|
MaxDepth = 4096 // far beyond any real path; bounds recursion in exporters
|
||||||
MaxMetaLen = 16 << 20
|
MaxSize = 1 << 60 // per-node size cap (1 EiB); keeps aggregates from overflowing
|
||||||
MaxExtLen = 64
|
// DefaultMaxBody caps the decompressed body for Load. LoadFile uses a
|
||||||
MaxMsgLen = 4096
|
// limit proportional to the file size instead (see bodyLimit).
|
||||||
maxExts = 1<<16 - 1
|
DefaultMaxBody = 4 << 30
|
||||||
flagGzipped = 1
|
MaxNameLen = 1 << 16
|
||||||
|
MaxMetaLen = 16 << 20
|
||||||
|
MaxExtLen = 64
|
||||||
|
MaxMsgLen = 4096
|
||||||
|
maxExts = 1<<16 - 1
|
||||||
|
flagGzipped = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
type meta struct {
|
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")))
|
return string(r.bytesN(r.uvarint(max, what+" length")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and validates a snapshot.
|
// limitedReader fails (rather than silently truncating) past n bytes, so a
|
||||||
func Load(in io.Reader) (*inventory.Tree, error) {
|
// 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)
|
br := bufio.NewReaderSize(in, 1<<20)
|
||||||
hdr := make([]byte, len(brand.SnapshotMagic)+4)
|
hdr := make([]byte, len(brand.SnapshotMagic)+4)
|
||||||
if _, err := io.ReadFull(br, hdr); err != nil {
|
if _, err := io.ReadFull(br, hdr); err != nil {
|
||||||
@@ -333,7 +365,7 @@ func Load(in io.Reader) (*inventory.Tree, error) {
|
|||||||
pr, pw := io.Pipe()
|
pr, pw := io.Pipe()
|
||||||
defer pr.Close() // unblocks the copier if we bail out early
|
defer pr.Close() // unblocks the copier if we bail out early
|
||||||
go func() {
|
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)
|
pw.CloseWithError(err)
|
||||||
}()
|
}()
|
||||||
r := &reader{r: bufio.NewReaderSize(pr, 1<<16)}
|
r := &reader{r: bufio.NewReaderSize(pr, 1<<16)}
|
||||||
@@ -358,6 +390,7 @@ func Load(in io.Reader) (*inventory.Tree, error) {
|
|||||||
}
|
}
|
||||||
extIndex := t.ExtIndexer()
|
extIndex := t.ExtIndexer()
|
||||||
nNodes := r.uvarint(MaxNodes, "node count")
|
nNodes := r.uvarint(MaxNodes, "node count")
|
||||||
|
depth := []uint16{0} // per node, for the depth limit; parents precede children
|
||||||
if nNodes == 0 {
|
if nNodes == 0 {
|
||||||
return nil, errors.New("snapshot: no root node")
|
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)
|
r.fail("node %d: invalid kind/category", i)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if size < 0 || alloc < 0 {
|
if size < 0 || alloc < 0 || size > MaxSize || alloc > MaxSize {
|
||||||
r.fail("node %d: negative size", i)
|
r.fail("node %d: size out of range", i)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
var id inventory.NodeID
|
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)
|
r.fail("node %d: invalid name %q", i, name)
|
||||||
break
|
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)
|
id = t.Add(parent, name, kind)
|
||||||
|
depth = append(depth, depth[parent]+1)
|
||||||
}
|
}
|
||||||
n := t.Node(id)
|
n := t.Node(id)
|
||||||
n.Cat, n.Flags = cat, flags
|
n.Cat, n.Flags = cat, flags
|
||||||
@@ -469,5 +507,9 @@ func LoadFile(path string) (*inventory.Tree, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
return Load(f)
|
limit := int64(DefaultMaxBody)
|
||||||
|
if fi, err := f.Stat(); err == nil {
|
||||||
|
limit = bodyLimit(fi.Size())
|
||||||
|
}
|
||||||
|
return LoadLimit(f, limit)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -17,8 +17,9 @@ import (
|
|||||||
// ---- Trash ---------------------------------------------------------------
|
// ---- Trash ---------------------------------------------------------------
|
||||||
|
|
||||||
type trashDoneMsg struct {
|
type trashDoneMsg struct {
|
||||||
id inventory.NodeID
|
tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped
|
||||||
err error
|
id inventory.NodeID
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestTrash asks for confirmation before moving the selection to the
|
// 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."},
|
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
|
||||||
yes: "move to " + platform.TrashName,
|
yes: "move to " + platform.TrashName,
|
||||||
onYes: func(m *Model) tea.Cmd {
|
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
|
return nil
|
||||||
@@ -59,6 +61,10 @@ func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
return m.warn("Trash failed: " + textutil.Sanitize(msg.err.Error()))
|
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.
|
// The scan has finished, so the UI goroutine is the tree's only writer.
|
||||||
t := m.tree
|
t := m.tree
|
||||||
t.Lock()
|
t.Lock()
|
||||||
@@ -96,6 +102,7 @@ type dupState struct {
|
|||||||
type (
|
type (
|
||||||
dupProgressMsg struct{}
|
dupProgressMsg struct{}
|
||||||
dupDoneMsg struct {
|
dupDoneMsg struct {
|
||||||
|
finder *duplicate.Finder // identifies the search; stale results are dropped
|
||||||
groups []duplicate.Group
|
groups []duplicate.Group
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
@@ -128,7 +135,7 @@ func (m *Model) startDuplicates() tea.Cmd {
|
|||||||
t := m.tree
|
t := m.tree
|
||||||
return tea.Batch(m.startTicking(), func() tea.Msg {
|
return tea.Batch(m.startTicking(), func() tea.Msg {
|
||||||
g, err := f.Find(ctx, t, duplicate.Options{MinSize: 1, Workers: 4})
|
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 {
|
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.running = false
|
||||||
m.dups.groups, m.dups.err = d.groups, d.err
|
m.dups.groups, m.dups.err = d.groups, d.err
|
||||||
m.dirty = true
|
m.dirty = true
|
||||||
|
|||||||
+14
-2
@@ -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.scanner, m.tree, m.cancel, m.scanning, m.ctrlC = s, t, cancel, s != nil, false
|
||||||
m.zoom, m.sel = t.Root(), noSel
|
m.zoom, m.sel = t.Root(), noSel
|
||||||
m.kidsCache = map[inventory.NodeID]*kids{}
|
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.result = nil
|
||||||
|
m.search.gen++ // results computed against the old tree are discarded
|
||||||
m.invalidate()
|
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
|
// resolvePendingZoom re-zooms into the remembered directory once the new
|
||||||
|
|||||||
Reference in New Issue
Block a user