Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

885 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// diskmap.go — the size walk behind the disk map.
//
// ══════════════════════════════════════════════════════════════════════════
// THIS READS NAMES, SIZES AND MODIFICATION TIMES. NOTHING ELSE. EVER.
//
// It never opens a file, never reads a byte of content, never hashes, never
// extracts, never embeds. Every fact in this map comes from a directory entry
// and an lstat — the same two syscalls `ls -l` makes.
//
// That line is the whole reason this ships before whole-machine indexing. A
// SIZE WALK AND A CONTENT INDEX ARE DIFFERENT PRODUCTS WITH DIFFERENT CONSENT,
// and conflating them is how a privacy product becomes the thing that gets
// written about. Whole-machine indexing runs the opposite way to the grant
// model and deserves its own decision — this unit must not pre-empt that
// decision by drifting into it one convenience at a time.
//
// The line is held by TWO tests, because a comment is not a wall:
//
// - TestTheWalkerNeverOpensAFile parses THIS FILE with go/parser and fails
// on any call into os/io/bufio that is not on a short allow-list of
// metadata calls. An AST walk rather than a grep, so a mention in a
// comment or a string cannot satisfy it and cannot trip it.
// - TestTheWalkerDoesNotOpenAFifo puts a named pipe in the walked tree.
// Opening a FIFO for reading BLOCKS until a writer arrives, so a walker
// that opens what it finds hangs and the test fails on a deadline. An
// lstat on the same FIFO returns instantly.
//
// The rejected alternative was a substring check for "os.Open" over the file.
// It passes on a walker that reaches content through io.ReadAll(f) or a
// bufio.Scanner and it trips on the word appearing in this header, which is
// the worst of both.
// ══════════════════════════════════════════════════════════════════════════
//
// WHY A TREE IN MEMORY RATHER THAN A LIVE READ PER PAGE. The page resolves a
// drilled-into path by finding that NODE IN THE SCANNED TREE — it never joins
// a query-string path onto the filesystem. ui.go:442-448 already refuses to do
// that for an approval's host name ("a handler that joins a posted string onto
// a directory is the shape of bug that turns a local UI into a file-system
// reach"), and a path parameter is the same shape with the joining already
// done for you. Holding the tree means the rendering path touches no disk at
// all, so there is nothing for a crafted `p=` to reach.
//
// WHAT IT IS NOT BUILT ON. The treemap looks closer than it is, because
// handleDcnCatalog already answers a paged dirs/files catalog. That is true of
// a GRANTED FOLDER and not of this
// machine: handleDcnCatalog (freeagent.go:342-406) requires a store id, a
// signed grant carrying both the 'read' and 'index' masks, and a running
// local agent sidecar to proxy to — it answers the HOST about one approved
// folder. A disk map answers the person sitting here about their own disk,
// before any grant exists, which is the whole demo. So this is a direct local
// walk and shares nothing with that path.
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// ── the numbers, every one of them measured ─────────────────────────────────
// All four constants below were derived from walks of the machine this was
// written on: a whole-machine walk of / — device-bounded, pseudo-
// filesystems skipped — is 799,953 entries over 48.2 GB (87,016 directories,
// 712,907 files), i.e. ~16,600 entries per GB.
// dmMaxEntries is the ceiling on retained nodes, and it is a MEMORY number
// rather than a filesystem one. MEASURED BY THE SHIPPING CODE
// (TestMeasureARealMap, DISKMAP_MEASURE=/): a whole-machine map of 801,782
// entries HOLDS 63 MB after a collection — 82 bytes/entry — so 1,200,000
// entries is 94 MB. That is held only while a map exists and is released by
// the drop timer.
//
// Peak during the walk is higher — 114 MB, 149 bytes/entry — and the gap is
// the walk's own garbage rather than the map, which is why the cap is set
// against the held figure: the cap has to bound what is KEPT.
//
// 1,200,000 is the measured whole-machine total with half again on top: the
// machine this was developed on maps completely, and one 50% fuller still
// does. The cap is not a safety net nobody trips — a 2 TB disk at the measured
// density is 33 million entries and 2.5 GB of heap, so on a large consumer
// drive this is the operative limit and the page has to say so, which it does
// (dmResult.Capped, rendered as its own line above the map).
//
// PASSED INTO dmScan rather than read from here, so a test can exercise the
// cap by asking for three entries instead of by writing a package variable.
// It was a var for exactly one release of this file, and the race detector
// found what that costs: a background scan from an earlier test reading the
// value while the next test wrote it.
const dmMaxEntries = 1_200_000
// dmStaleAfter is when the page starts saying the map is old, AND the idle
// window after which an unviewed map is released. One constant with two
// consequences on purpose: the moment a map is worth re-taking is the moment
// it stops being worth 92 MB.
//
// Derived from measured churn rather than chosen. Share of entries whose mtime
// was inside the window, on this box:
//
// root <1h <24h <7d
// / 1.56% 7.03% 14.34%
// /root 3.75% 8.65% 20.35%
//
// Churn is front-loaded, so interpolate on log-time between the two measured
// points and solve for a 5%-of-entries error budget. The HOME root is the one
// that decides, because it is the one that changes: slope (8.65-3.75)/ln(24) =
// 1.54 %/ln-hour, so 5% lands at exp((5-3.75)/1.54) = 2.25 hours. The whole-
// machine root gives 7.4 hours by the same arithmetic and is the looser bound.
//
// It only changes a LABEL and a drop — nothing re-scans on a timer, because
// the one thing this feature must never do is walk the disk off a poll.
//
// HELD ON dmState (staleAfter) rather than read from here, so a test can
// shorten it to milliseconds on its own state. This was a package var until
// the race detector caught a timer goroutine from one test reading it while
// the next test wrote it — a real race, in the seam rather than in the
// feature, and the fix is that the window belongs to the map that is ageing.
const dmStaleAfter = 135 * time.Minute
// dmProgressEvery is how often a running scan publishes what it has counted.
// Measured rate is 300,000–530,000 entries/sec warm, so a per-entry update
// would be half a million mutex acquisitions a second to move a number a human
// reads three times. Every 20,000 entries is ~25 updates over the measured
// whole-machine walk — more than enough for a progress line that is only ever
// read by a 2-second poll.
const dmProgressEvery = 20_000
// dmMaxDenied is how many unreadable paths are kept BY NAME. The count is
// always exact; this bounds only the list. A user needs to see WHICH places
// the map could not look, and after a few dozen the answer is "a lot of them"
// and the list has stopped being information.
const dmMaxDenied = 64
// ── the tree ────────────────────────────────────────────────────────────────
// dmNode is one directory entry. NAME ONLY, never the path: a path is rebuilt
// by walking Parent, and every node in a deep tree storing its own absolute
// path is most of the memory.
//
// Name is strings.Clone'd at the call site and that is load-bearing, not
// hygiene. filepath.Base and fs.DirEntry.Name both hand back a string that may
// share the backing array of the buffer they came from, so storing one pins
// that whole buffer — the entry's whole absolute path — for the life of the
// map. MEASURED over 800k entries on this box, same walk, clone the only
// difference: 148.4 bytes/entry retained without it, 76.3 with it. The map
// costs half as much because of one call.
type dmNode struct {
Name string
// Size is the file's own size for a file, and the rolled-up total of
// everything beneath it for a directory. Rolled up after the walk, in
// dmRollUp, rather than incrementally: a walk that is cancelled or capped
// mid-flight must not leave half-summed directories that LOOK complete.
Size int64
Mtime int64 // unix seconds; 0 when the entry could not be stat'd
// Parent, FirstChild and NextSibling are indices into dmResult.Nodes.
// -1 is "none". Indices rather than pointers so the whole tree is one
// allocation the garbage collector does not have to trace through.
Parent int32
FirstChild int32
NextSibling int32
Dir bool
// Root marks a node this scan started at. A root's Name is its full path,
// because it has no parent to rebuild one from.
Root bool
}
// dmResult is a finished (or abandoned) map.
type dmResult struct {
// Scope is what this map COVERS. Stamped by dmStart from the scope that
// was asked for, never by the walker, so a stubbed or future walker cannot
// mislabel a map — and a map labelled "your files" that quietly held the
// whole machine would be the worst possible lie for this page to tell.
Scope dmScope
Nodes []dmNode
// Roots are indices into Nodes, in the order they were walked.
Roots []int32
// TakenAt is when the walk FINISHED. Every "how old is this" question on
// the page is asked against this and nothing else.
TakenAt time.Time
Elapsed time.Duration
Files int64
Dirs int64
Bytes int64
// Capped is dmMaxEntries reached, so the walk STOPPED and what is below
// CappedAt was never looked at. The page says this in words above the map;
// a map that quietly omits is worse than no map.
Capped bool
CappedAt string
// Denied counts every entry the OS refused, and DeniedPaths names the
// first dmMaxDenied of them. This is the honest half of "your disk is 48
// GB but this map adds up to 31": the OS is the wall, and where it stood
// is a fact about the machine the user is entitled to.
Denied int64
DeniedPaths []string
// Crossings are the mount points the walk refused to cross, and they are
// the OTHER half of the same accounting. A second disk mounted at
// /mnt/data is a real thing on a real machine, and it is NOT in this map:
// naming the boundary is what keeps the map from lying by omission.
Crossings []string
// Skipped are the pseudo-filesystems refused by name (dmSkipRoots).
Skipped []string
// Cancelled: ctx ended before the walk did. The partial tree is kept and
// labelled, never discarded — the user asked to stop, not to be told
// nothing.
Cancelled bool
}
// dmPath rebuilds a node's absolute path by walking up to its root.
func (r *dmResult) dmPath(i int32) string {
if r == nil || i < 0 || int(i) >= len(r.Nodes) {
return ""
}
var parts []string
for i >= 0 {
n := &r.Nodes[i]
parts = append(parts, n.Name)
if n.Root {
break
}
i = n.Parent
}
// Reverse, then join with the PLATFORM separator: this path is shown to a
// person looking at their own machine, so a Windows user sees backslashes.
for a, b := 0, len(parts)-1; a < b; a, b = a+1, b-1 {
parts[a], parts[b] = parts[b], parts[a]
}
if len(parts) == 1 {
return parts[0]
}
root := parts[0]
rest := filepath.Join(parts[1:]...)
// A root already carries its own separator (`/`, `C:\`), so Join would
// give `//usr` or `C:\\Users`. Trim exactly one.
return strings.TrimSuffix(root, string(filepath.Separator)) +
string(filepath.Separator) + rest
}
// dmChildren returns a node's children as indices, largest first. Largest
// first because that is the order the treemap needs (dmSquarify requires
// descending input) and the order the "biggest things here" list wants, and
// sorting once at the point of use beats holding a second ordering.
func (r *dmResult) dmChildren(i int32) []int32 {
if r == nil || i < 0 || int(i) >= len(r.Nodes) {
return nil
}
var out []int32
for c := r.Nodes[i].FirstChild; c >= 0; c = r.Nodes[c].NextSibling {
out = append(out, c)
}
sort.SliceStable(out, func(a, b int) bool {
return r.Nodes[out[a]].Size > r.Nodes[out[b]].Size
})
return out
}
// dmFind resolves a path to a node index, BY WALKING THE TREE. It never
// touches the filesystem and it never joins the argument onto anything, which
// is the whole point: the only paths that resolve are ones this scan actually
// saw. -1 for anything else.
func (r *dmResult) dmFind(path string) int32 {
if r == nil || strings.TrimSpace(path) == "" {
return -1
}
want := filepath.Clean(path)
// Longest matching root first, so a home directory nested inside a volume
// root resolves against the more specific one.
best := int32(-1)
bestLen := -1
for _, ri := range r.Roots {
rp := filepath.Clean(r.Nodes[ri].Name)
if want == rp || strings.HasPrefix(want, ensureSep(rp)) {
if len(rp) > bestLen {
best, bestLen = ri, len(rp)
}
}
}
if best < 0 {
return -1
}
rp := filepath.Clean(r.Nodes[best].Name)
if want == rp {
return best
}
rest := strings.TrimPrefix(want, ensureSep(rp))
at := best
for _, seg := range strings.Split(rest, string(filepath.Separator)) {
if seg == "" {
continue
}
found := int32(-1)
for c := r.Nodes[at].FirstChild; c >= 0; c = r.Nodes[c].NextSibling {
if r.Nodes[c].Name == seg {
found = c
break
}
}
if found < 0 {
return -1
}
at = found
}
return at
}
func ensureSep(p string) string {
if strings.HasSuffix(p, string(filepath.Separator)) {
return p
}
return p + string(filepath.Separator)
}
// ── what is never walked ────────────────────────────────────────────────────
// dmSkipRoots are refused BY NAME, before anything is read from them.
//
// The device check below would catch most of these on unix, since every one of
// them is its own mount. The name list is kept anyway for two reasons, and
// they are different reasons:
//
// 1. ONE FILE IN /proc DESTROYS THE WHOLE MAP. Measured on this box,
// /proc/kcore reports a size of 140,737,471,598,592 bytes — 128 TiB, the
// kernel's view of physical memory as a file. Include it and it is
// 99.9999% of every rectangle drawn; the map of a 48 GB disk becomes one
// grey square. The device check is a general rule and this is a specific
// guarantee, and the specific guarantee is the one worth having.
// 2. THE DEVICE CHECK DOES NOT EXIST ON WINDOWS. FileInfo.Sys() there is a
// *syscall.Win32FileAttributeData with no device field at all
// (diskmap_windows.go), so on the platform this UI was built for the name
// list is all there is.
var dmSkipRoots = []string{
// linux
"/proc", "/sys", "/dev", "/run", "/var/run", "/var/lock",
// darwin. /dev is shared with linux above; the rest are the synthesised
// mounts Apple hangs off the root. /Volumes holds OTHER volumes, which are
// other devices and therefore other maps — not this one's business.
"/System/Volumes/VM", "/System/Volumes/Preboot", "/System/Volumes/Update",
"/private/var/vm", "/Volumes",
}
func dmSkipped(path string) bool {
for _, s := range dmSkipRoots {
if path == s {
return true
}
}
return false
}
// ── the walk ────────────────────────────────────────────────────────────────
// dmScan walks every root and returns the tree.
//
// It follows handleDcnList's idiom for the parts that are the same problem
// (dcn.go:522-550): filepath.WalkDir, and an error on one entry returns nil so
// an unreadable subtree is skipped rather than aborting the listing. It
// differs in three ways that matter here:
//
// - errors are COUNTED and NAMED, not swallowed. dcn_list answers a host
// that asked for files it was granted; this answers the person who owns
// the disk, and "there is 12 GB I could not see" is the single most
// useful sentence on the page.
// - the walk never leaves the device it started on (dmSameDevice), so a
// network share mounted under the root cannot turn a local walk into a
// minutes-long round trip over someone's VPN, and a bind mount cannot
// make the same bytes appear twice.
// - it is cancellable. dcn_list is one bounded reply on a management frame;
// this can run for minutes, and the user must be able to stop it.
//
// SYMLINKS ARE NEVER FOLLOWED, and that needs no code: filepath.WalkDir reads
// directories with os.ReadDir, whose fs.DirEntry is lstat-shaped, so a symlink
// to a directory has IsDir() == false and WalkDir does not descend into it.
// d.Info() is lstat too, so a symlink contributes its OWN few bytes and not
// its target's. That is also the correct answer for a disk map: following a
// link would count the target twice and a link loop would never terminate.
// Asserted by TestTheWalkNeverFollowsASymlinkOutOfTheRoot rather than assumed,
// because it is a property of a function in another package.
func dmScan(ctx context.Context, roots []string, maxEntries int, progress func(dmProgress)) *dmResult {
r := &dmResult{Nodes: make([]dmNode, 0, 1<<16)}
if maxEntries <= 0 {
maxEntries = dmMaxEntries
}
start := time.Now()
seenRoot := map[string]bool{}
var seen int64
for _, root := range roots {
// CANCELLED BEFORE THE FIRST ENTRY IS STILL CANCELLED. This read
// `break` with no label, and the result then carried Cancelled=false —
// so a scan stopped between two roots, or stopped before it started at
// all, came back as an EMPTY MAP THAT CLAIMED TO BE COMPLETE. Caught by
// TestACancelledWalkKeepsWhatItCountedAndSaysSo, which cancels the
// context before calling.
if ctx.Err() != nil {
r.Cancelled = true
break
}
root = filepath.Clean(root)
if seenRoot[root] || r.Capped {
continue
}
seenRoot[root] = true
rootDev, haveDev := dmDeviceOf(root)
// open is the chain of directories the walk is currently inside, so a
// child can find its parent's index without a search.
//
// A STACK, NOT A MAP FROM PATH TO INDEX. The map was the first cut and
// it is the obvious one, and it holds a full absolute path for every
// directory in the tree — 87,016 of them on this box, which is most of
// what dmNode's whole name-not-path design exists to avoid, brought
// straight back in as scratch. filepath.WalkDir is depth-first
// pre-order, so the parent of the entry being visited is always the
// deepest still-open directory: pop until the top IS the parent.
// Bounded by the tree's DEPTH rather than its size — measured maximum
// 19 on a whole-machine walk here — so the scratch is 19 entries
// instead of 87,016 and the peak stops being something this file
// quotes a number for and cannot stand behind.
type dmOpen struct {
path string
idx int32
}
var open []dmOpen
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
// Permission denied, a file that vanished mid-walk, a broken
// mount. Per entry, never fatal: a map of 95% of a disk is
// worth having and the missing 5% is reported as missing.
r.Denied++
if len(r.DeniedPaths) < dmMaxDenied {
r.DeniedPaths = append(r.DeniedPaths, p)
}
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
// The skip applies to the ROOT too, not only to what is found under
// it. A root is whatever dmVolumeRoots and $HOME produced, and on a
// misconfigured box that can be one of these — at which point
// walking it is precisely the disaster the list exists to prevent,
// and "but you asked for it" is not a defence when one file in
// there claims to be 128 TiB.
if dmSkipped(p) {
r.Skipped = append(r.Skipped, p)
return filepath.SkipDir
}
info, ierr := d.Info()
if ierr != nil {
r.Denied++
if len(r.DeniedPaths) < dmMaxDenied {
r.DeniedPaths = append(r.DeniedPaths, p)
}
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() && p != root && haveDev {
if dev, ok := dmDeviceOfInfo(info); ok && dev != rootDev {
r.Crossings = append(r.Crossings, p)
return filepath.SkipDir
}
}
if len(r.Nodes) >= maxEntries {
r.Capped, r.CappedAt = true, p
return filepath.SkipAll
}
parent := int32(-1)
if p != root {
dir := filepath.Dir(p)
for len(open) > 0 && open[len(open)-1].path != dir {
open = open[:len(open)-1]
}
if len(open) == 0 {
// The parent is not open, which means it was skipped.
// Nothing to hang this off, so drop it rather than
// inventing a second root.
return nil
}
parent = open[len(open)-1].idx
}
name := d.Name()
if p == root {
name = root
}
// A DIRECTORY STARTS AT ZERO. info.Size() on a directory is the
// size of its own inode — 4096 on ext4 — which is not the space
// anyone is looking for. Counting it would add a phantom 340 MB
// to the map of this box (87,016 directories × 4 KB) spread
// evenly enough to be invisible and wrong everywhere.
own := info.Size()
if d.IsDir() {
own = 0
}
idx := int32(len(r.Nodes))
r.Nodes = append(r.Nodes, dmNode{
// strings.Clone: see dmNode. Measured 2x on retained memory.
Name: strings.Clone(name),
Size: own,
Mtime: info.ModTime().Unix(),
Parent: parent,
FirstChild: -1,
NextSibling: -1,
Dir: d.IsDir(),
Root: p == root,
})
if parent >= 0 {
// Prepend rather than append: O(1), and the order is
// irrelevant because dmChildren sorts by size anyway.
r.Nodes[idx].NextSibling = r.Nodes[parent].FirstChild
r.Nodes[parent].FirstChild = idx
}
if d.IsDir() {
open = append(open, dmOpen{path: strings.Clone(p), idx: idx})
r.Dirs++
} else {
r.Files++
r.Bytes += info.Size()
}
if p == root {
r.Roots = append(r.Roots, idx)
}
seen++
if progress != nil && seen%dmProgressEvery == 0 {
progress(dmProgress{At: p, Entries: seen, Bytes: r.Bytes})
}
return nil
})
// ASK THE CONTEXT, NOT THE ERROR. errors.Is(err, ctx.Err()) was the
// first cut and it is fragile in both directions: WalkDir wraps what
// the callback returns, and ctx.Err() is nil at the moment of
// comparison in the ordinary case, which makes errors.Is(nil) the
// question being asked. The context is the authority on whether it was
// cancelled; the error only says the walk stopped.
if err != nil && ctx.Err() != nil {
r.Cancelled = true
break
}
}
dmRollUp(r)
r.TakenAt, r.Elapsed = time.Now(), time.Since(start)
return r
}
// dmRollUp gives every directory the total of everything beneath it.
//
// ONE BACKWARDS PASS OVER THE SLICE, not a recursion, and the reason it is
// allowed to be that cheap is an invariant of the walk: filepath.WalkDir is
// pre-order, so a node is always appended AFTER its parent, so index(child) >
// index(parent) always. Walking the slice backwards therefore visits every
// child before its parent, and adding each node's size into its parent is
// complete in one pass with no stack. At the measured 800k nodes a recursion
// would be 19 levels deep per node for exactly the same answer.
//
// AFTER THE WALK, NOT DURING IT, and that is the point of having a function at
// all: a walk that is cancelled or capped mid-flight must not leave
// half-summed directories that LOOK complete. Running the roll-up once at the
// end means a partial map is partial everywhere and says so, rather than being
// confidently wrong in the subtree where the walk stopped.
func dmRollUp(r *dmResult) {
for i := len(r.Nodes) - 1; i >= 0; i-- {
if p := r.Nodes[i].Parent; p >= 0 {
r.Nodes[p].Size += r.Nodes[i].Size
}
}
}
// ── progress ────────────────────────────────────────────────────────────────
type dmProgress struct {
At string
Entries int64
Bytes int64
}
// ── the one map this machine holds ──────────────────────────────────────────
// dmState is the machine-wide disk map: at most one scan running, at most one
// result held.
//
// ONE AT A TIME, and that is the point rather than a simplification. Two
// concurrent full-disk walks do not go twice as fast; on anything but an NVMe
// they fight for the same head and both take longer than either alone would.
// A second "Scan" press while one is running is answered with the running
// scan's progress, which is also what the person actually wanted to know.
type dmState struct {
mu sync.Mutex
res *dmResult
running bool
prog dmProgress
started time.Time
cancel context.CancelFunc
viewed time.Time
// drop is the pending release timer. Held so a later view can push it out
// rather than leaving a map to die under someone who is looking at it.
drop *time.Timer
// scan is the walker, swapped in tests. Every seam below is a FIELD and
// not a package variable, and that is not taste: two of them started as
// package vars and the race detector found a timer goroutine from one test
// reading a value the next test was writing. A seam on the object races
// with nothing, because each test has its own object.
scan func(context.Context, []string, int, func(dmProgress)) *dmResult
// roots is what to walk FOR A SCOPE, swapped in tests so a test never walks
// a developer's real disk.
roots func(dmScope) ([]string, []string)
// scope is what the held map covers, and what a re-scan repeats. Kept on
// the state as well as the result because the page has to name it while a
// walk is still running and there is no result yet.
scope dmScope
// maxEntries and staleAfter default to the constants above when zero, so a
// zero-value dmState is a working one.
maxEntries int
staleAfter time.Duration
}
var diskMap = &dmState{scan: dmScan, roots: dmRootsFor}
func (s *dmState) staleWindowLocked() time.Duration {
if s.staleAfter > 0 {
return s.staleAfter
}
return dmStaleAfter
}
func (s *dmState) capLocked() int {
if s.maxEntries > 0 {
return s.maxEntries
}
return dmMaxEntries
}
// dmSnapshot is everything the page needs about the held map, read under one
// lock. A struct rather than five return values because staleness has to be
// decided in HERE — the window lives on the state, and handing the caller a
// duration to compare against would put the same arithmetic in two places.
type dmSnapshot struct {
Res *dmResult
Running bool
Prog dmProgress
Since time.Duration
Stale bool
// Scope is what is being walked now, or what the held map covers.
Scope dmScope
}
// dmView returns what the page should show and records that somebody looked,
// which is what keeps a map alive past its stale window while it is being read.
func (s *dmState) dmView() dmSnapshot {
s.mu.Lock()
defer s.mu.Unlock()
// Recording the view is ALL this does about the drop. Resetting the timer
// here as well was the first cut and it made the idle check inside the
// timer dead code — TestLookingAtAMapKeepsItAlive passed with that check
// deleted, which is the tautological-test shape this repo keeps catching.
// One mechanism: the timer fires on its own schedule and asks whether
// anyone has looked since.
s.viewed = uiNow()
// Scope comes off the STATE, not off the result: while a walk is running
// there is no result yet, and "mapping your files" versus "mapping this
// whole machine" is exactly what the person needs to read at that moment.
v := dmSnapshot{Res: s.res, Scope: s.scope}
if s.res != nil {
v.Stale = uiNow().Sub(s.res.TakenAt) >= s.staleWindowLocked()
}
if s.running {
v.Running, v.Prog, v.Since = true, s.prog, uiNow().Sub(s.started)
}
return v
}
// dmStart begins a scan of ONE scope unless one is already running.
func (s *dmState) dmStart(scope dmScope) bool {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return false
}
roots, _ := s.roots(scope)
s.scope = scope
// context.Background(), NOT the UI server's context, and that is the
// difference between "it keeps going if you leave this page" (which the
// page promises in those words) and a walk that dies when a browser tab
// closes. Process shutdown still ends it, because the process ending ends
// everything; nothing else does but dmStop, which is the Stop button.
ctx, cancel := context.WithCancel(context.Background())
s.running, s.cancel, s.started = true, cancel, uiNow()
s.prog = dmProgress{}
scan, cap := s.scan, s.capLocked()
s.mu.Unlock()
go func() {
defer cancel()
res := scan(ctx, roots, cap, func(p dmProgress) {
s.mu.Lock()
s.prog = p
s.mu.Unlock()
})
s.mu.Lock()
if res != nil {
res.Scope = scope
}
s.res, s.running, s.cancel = res, false, nil
s.dmArmDropLocked()
s.mu.Unlock()
}()
return true
}
// dmStop cancels a running scan. The partial tree is kept.
func (s *dmState) dmStop() {
s.mu.Lock()
c := s.cancel
s.mu.Unlock()
if c != nil {
c()
}
}
// dmArmDropLocked schedules the release of a map nobody is reading.
//
// A TIMER RATHER THAN A SWEEP IN startUIServer, deliberately: the lifetime of
// this memory is this file's business, and reaching into the server's start-up
// to add a goroutine would put half of one feature's bookkeeping in another
// unit's file. The timer re-arms itself if the map was viewed while it slept,
// so a page left open never has the map pulled out from under it.
func (s *dmState) dmArmDropLocked() {
if s.drop != nil {
s.drop.Stop()
}
window := s.staleWindowLocked()
s.drop = time.AfterFunc(window, func() {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return
}
w := s.staleWindowLocked()
if idle := uiNow().Sub(s.viewed); idle < w {
s.drop.Reset(w - idle)
return
}
s.res = nil
})
}
// ── the roots ───────────────────────────────────────────────────────────────
// dmDefaultRoots answers what "this machine's drives" means here, and returns
// the roots plus a plain-words note about anything it decided NOT to walk.
//
// THE RULE IS ONE RULE ON ALL THREE PLATFORMS: the machine's fixed volumes,
// plus the user's home directory when that is not already inside one of them.
// What differs is only what a volume is, which is the platform's question and
// is answered in diskmap_unix.go and diskmap_windows.go.
//
// - LINUX. The volume is `/`. The walk is device-bounded, so it stops at
// every mount — measured on this box, 16 boundaries refused on one walk of
// `/`, including /boot and /srv, which are real ext4 disks holding
// real data. That is a genuine limitation and it is NAMED on the page
// (dmResult.Crossings) rather than papered over: a second disk mounted at
// /mnt/data is not in this map. Parsing /proc/self/mountinfo to add local
// filesystems back was the rejected alternative — it is a fourth
// platform-specific parser for a v1 whose job is to make the machine
// visible, and a boundary the user can SEE is not the same as one hidden.
//
// - DARWIN. $HOME is listed separately from `/` and that is not belt and
// braces. Since Catalina the system volume and the data volume are two
// APFS volumes joined by firmlinks, so /Users can sit on a different
// st_dev from /, and a device-bounded walk from / would then map the OS
// and none of the user's files — the exact inverse of what was asked for.
// UNVERIFIED: no Mac was available to confirm the st_dev behaviour of a
// firmlink. Listing both makes the question moot, because dmScan dedupes
// an already-walked root (seenRoot) and dmFind resolves against the
// longest matching root, so the same tree can never be counted twice
// whichever way the kernel answers.
//
// - WINDOWS. The volumes are the FIXED drive letters, and only the fixed
// ones (diskmap_windows.go). A DRIVE_REMOTE walk goes over the network and
// can hang for minutes on a VPN that is not up; DRIVE_CDROM and
// DRIVE_REMOVABLE spin up hardware to answer. WinDirStat itself opens with
// "pick a drive" for the same reason.
//
// The home directory is resolved LAST and only added if it is outside every
// volume root, because on a normal machine it is inside one and adding it
// would walk the same bytes twice.
// dmScope is WHAT a map covers, and there are exactly two because there are
// exactly two questions people ask of a drive map.
//
// THE DEFAULT IS YOUR OWN FILES, and that is a decision rather than a
// convenience: a full drive map is a request.
// Mapping the whole machine reaches into every account on it, and on a shared
// box that is somebody else's private life rendered as rectangles. It is a
// bigger act than a button labelled "Map" implies, so it is asked for
// separately and the page says what it will cover before it runs.
//
// The scope is also what makes a files view safe for a person who is not the
// machine's admin: a walk done AS them can only read what they can already
// open, so the kernel draws the boundary and this code never has to decide.
type dmScope string
const (
dmScopeHome dmScope = "home"
dmScopeMachine dmScope = "machine"
)
// dmScopeOf reads a scope off a form value, defaulting to home. An unknown
// value is home rather than an error: the failure mode of a typo must be the
// SMALLER walk, never the bigger one.
func dmScopeOf(s string) dmScope {
if strings.TrimSpace(s) == string(dmScopeMachine) {
return dmScopeMachine
}
return dmScopeHome
}
func (s dmScope) words() string {
if s == dmScopeMachine {
return "this whole machine"
}
return "your files"
}
// dmRootsFor resolves the roots for a scope. The seam on dmState is keyed on
// this so a test swaps one function and gets both scopes.
func dmRootsFor(scope dmScope) (roots []string, notes []string) {
if scope == dmScopeMachine {
return dmMachineRoots()
}
return dmHomeRoots()
}
// dmHomeRoots is the default: the home directory of whoever this process is
// running as, and nothing else.
func dmHomeRoots() (roots []string, notes []string) {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
// Refusing to walk anything is the right answer here. Quietly widening
// to the volumes because this account has no home would be the exact
// thing the scope exists to stop, and it would happen on a service
// account — the case where the widening is least expected.
return nil, []string{"this account has no home directory of its own, so there is " +
"nothing to map as your files — map the whole machine instead, which covers every " +
"drive on it"}
}
return []string{filepath.Clean(home)}, nil
}
// dmMachineRoots is the explicit request: every fixed volume, plus the home
// directory when it lives outside them.
func dmMachineRoots() (roots []string, notes []string) {
vols, volNotes := dmVolumeRoots()
notes = append(notes, volNotes...)
for _, v := range vols {
roots = append(roots, filepath.Clean(v))
}
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
// Not a failure worth refusing over: the volume roots still map the
// machine. It IS worth saying, because a daemon with no HOME is a
// service account and the person reading the page is entitled to know
// their own directory was not singled out.
notes = append(notes, "this machine's daemon has no home directory of its own, so the map covers the volumes only")
return roots, notes
}
home = filepath.Clean(home)
for _, v := range roots {
if home == v || strings.HasPrefix(home, ensureSep(v)) {
return roots, notes
}
}
return append(roots, home), notes
}