Correct scanner accounting and verify release packaging #2

Open
gamertan wants to merge 9 commits from sizequeen-scan-hardening into main
22 changed files with 1515 additions and 319 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Sized Linux checks
on:
push:
branches: [main, sizequeen-scan-hardening]
paths: ['Cargo.toml', 'Cargo.lock', 'src/**', 'tests/**', 'benches/**', 'scripts/**', '.gitea/workflows/**']
pull_request:
branches: [main]
paths: ['Cargo.toml', 'Cargo.lock', 'src/**', 'tests/**', 'benches/**', 'scripts/**', '.gitea/workflows/**']
workflow_dispatch:
permissions:
contents: read
jobs:
linux-amd64:
name: Linux AMD64 / Rust 1.88.0
# Keep the existing shared runner limited to owner-triggered, same-repo code.
if: ${{ gitea.actor == 'gamertan' && (gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository) }}
runs-on: himesan-node24
timeout-minutes: 20
container:
image: sha256:271ca1d26057c95a6fd30df7ccc0c053ab394c0e81cd1e4efa182b5b0b60ee97
options: >-
--user 65532:65532
--tmpfs /tmp/sized-mount-test:rw,nosuid,nodev,noexec,size=16m,uid=65532,gid=65532,mode=0700
--tmpfs /tmp/sized-mount-test/foreign:rw,nosuid,nodev,noexec,size=16m,uid=65532,gid=65532,mode=0700
env:
CARGO_HOME: /tmp/sized-cargo
CARGO_BUILD_JOBS: '2'
SIZED_TEST_SOURCE: ${{ gitea.workspace }}
SIZED_TEST_MOUNT_ROOT: /tmp/sized-mount-test
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Unprivileged Linux tests, real mount boundary, Clippy and release smoke
run: ./scripts/check-linux-container.sh
- name: Require an unchanged checkout
run: test -z "$(git status --porcelain=v1 --untracked-files=all)"
+26 -1
View File
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- Include the executable before creating release archives. Use explicit host
targets, build provenance and checksums; refuse existing outputs and symlinks.
- Replace stale installation links and unavailable package-manager claims with
verified Gitea availability and explicit source-build instructions.
- Count hard-link allocation once in a deterministic traversal order while
retaining apparent sizes per pathname. Filtered comparison has independent
allocation ownership, including when the first link is ignored.
- Report filesystem errors and incomplete ancestor totals instead of silently
dropping errors or treating vanished entries as accessible empty directories.
- Inherit ignore rules when scanning nested directories; accept dangling
symlinks as scan targets and distinguish special files from directories.
### Added
- Host archive extraction/executable checks on macOS and in Linux CI, plus
the Sized project page and its connection to SizeQueen's scanning core.
- A scanner module with structured reports, file identities, progress counters,
cooperative cancellation, and per-scan thread pools.
- Optional `-x` / `--one-file-system` boundary handling. Partial CLI reports
return a nonzero exit status and include `complete` in JSON output.
- Repeatable Linux ARM64/AMD64 Docker checks with unprivileged permission
tests, a real mounted-filesystem boundary and an optimized-binary smoke test.
## [1.0.0] - 2026-01-22 ## [1.0.0] - 2026-01-22
This stable release marks the official launch of `sized` under the GNU General Public License v3.0. This stable release marks the official launch of `sized` under the GNU General Public License v3.0.
@@ -19,4 +44,4 @@ This stable release marks the official launch of `sized` under the GNU General P
- **Exporting**: Support for CSV and NDJSON output formats. - **Exporting**: Support for CSV and NDJSON output formats.
- **Documentation**: Comprehensive standard Unix man pages and a detailed user guide. - **Documentation**: Comprehensive standard Unix man pages and a detailed user guide.
- **Shell Completions**: Automatic generation for Bash, Zsh, and Fish. - **Shell Completions**: Automatic generation for Bash, Zsh, and Fish.
- **Licensing**: GPL-3.0 to ensure the tool remains a shared public resource. - **Licensing**: GPL-3.0 to ensure the tool remains a shared public resource.
+2 -1
View File
@@ -4,7 +4,8 @@ version = "1.0.0"
edition = "2021" edition = "2021"
description = "A modern, fast, and concurrent disk usage analyzer" description = "A modern, fast, and concurrent disk usage analyzer"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://gitlab.speelman.ca/gamertan/sized" repository = "https://gitea.speelman.ca/gamertan/sized"
homepage = "https://gamertan.com/projects/sized/"
readme = "README.md" readme = "README.md"
categories = ["command-line-utilities", "filesystem"] categories = ["command-line-utilities", "filesystem"]
+35 -8
View File
@@ -16,16 +16,28 @@
## Installation ## Installation
### From Binaries (Recommended) ### From source
Download the latest pre-compiled binaries from the [Releases](https://gitlab.speelman.ca/gamertan/sized/releases) page.
The following installs the current review branch, including scanner hardening
not present in the published v1.0.0 release:
### From Source
```bash ```bash
git clone https://gitlab.speelman.ca/gamertan/sized.git git clone --branch sizequeen-scan-hardening https://gitea.speelman.ca/gamertan/sized.git
cd sized cd sized
make install # Installs binary and man page cargo install --locked --path .
``` ```
Cargo installs to `~/.cargo/bin`. For the published source instead, check out
`v1.0.0` before installing. `make install PREFIX="$HOME/.local"` additionally
installs the man page without requiring administrator privileges.
### Existing release files
The [v1.0.0 release](https://gitea.speelman.ca/gamertan/sized/releases/tag/v1.0.0)
has source archives, a macOS arm64 executable, a man page and completions.
It has no Linux binary, architecture-labelled binary archive or `.deb` attachment.
See the [README](README.md#installation) for current installation guidance.
## Basic Usage ## Basic Usage
By default, `sized` analyzes the current directory recursively and displays a table of the immediate children. By default, `sized` analyzes the current directory recursively and displays a table of the immediate children.
@@ -35,9 +47,24 @@ sized [path]
``` ```
### Key Concepts ### Key Concepts
- **Disk Usage** (Default): The actual physical space consumed on disk (Blocks * 512 bytes). This is the "true" footprint and the metric `sized` prioritizes. - **Disk Usage** (Default): Filesystem-reported allocation (Blocks * 512 bytes), with hard links counted once per scan. Shared extents and snapshots can make this differ from the space recovered by deletion.
- **Apparent Size**: The logical size of the entry (file length). Available via the `-a` or `--apparent` flag. - **Apparent Size**: The logical size of the entry (file length). Available via the `-a` or `--apparent` flag.
- **Blocks**: The actual filesystem blocks allocated. - **Blocks**: Filesystem-reported 512-byte units of allocation. Directory metadata is included.
Symlinks are scanned as leaves, including dangling links; their targets are not
followed. Missing targets or unreadable entries are reported on stderr. Partial
scans return a nonzero exit status and JSON includes `complete: false`; inspect
that status before relying on a total.
### Filesystem Boundary (`-x` / `--one-file-system`)
Skip entries whose device differs from the scan root, useful when a directory
contains mounted filesystems. Boundary entries contribute no size, and JSON
marks them with `skipped_mount`.
```bash
sized -x /path/to/volume
```
### Path Display ### Path Display
- **Relative Path** (Default): `sized` shows paths relative to the current directory. - **Relative Path** (Default): `sized` shows paths relative to the current directory.
@@ -53,7 +80,7 @@ sized -d 1
``` ```
> [!NOTE] > [!NOTE]
> Regardless of the display depth, `sized` always calculates the *total* size of all subdirectories accurately by traversing the entire tree. > Display depth limits output, not scanning. The tree is traversed unless filtered or excluded by a filesystem boundary; scan errors mark totals incomplete.
## Filtering and Sorting ## Filtering and Sorting
+1 -1
View File
@@ -7,7 +7,7 @@ MANDIR = $(PREFIX)/share/man/man1
all: build all: build
build: build:
cargo build --release cargo build --locked --release
install: build install: build
install -d $(BINDIR) install -d $(BINDIR)
+94 -53
View File
@@ -1,11 +1,20 @@
# Sized # Sized
A fast, concurrent, and feature-rich command-line tool for visualizing disk usage, written in Rust. **Know what’s taking up space. Without leaving your terminal.**
Sized is a Rust command-line disk usage tool for macOS and Linux. Inspect large
folders, filter the noise, and export reports for your own scripts.
[Project page](https://gamertan.com/projects/sized/) ·
[Prefer a visual map? Meet SizeQueen](https://gamertan.com/projects/sizequeen/).
This branch contains scanner hardening under review. The published **v1.0.0**
release predates the hard-link, partial-scan and filesystem-boundary changes
below. No new release is implied by a branch build.
## Features ## Features
- **Physical by Default**: Prioritizes **Disk Usage** (actual blocks consumed) as the default metric, essential for accurately seeing how much storage is actually gone. - **Allocation by Default**: Prioritizes filesystem-reported allocated blocks; sparse files retain their separate apparent size, and hard-link allocation is counted once per scan. Reported allocation is not a promise of bytes freed by deletion on filesystems with shared extents or snapshots.
- **Fast & Concurrent**: Uses `rayon` to process directories in parallel, making it extremely fast on modern multi-core systems. - **Fast & Concurrent**: Processes directories in parallel with `rayon`; scan time depends on the filesystem and workload.
- **Rich Output**: Beautifully formatted tables with colors, distinguishing files and directories. - **Rich Output**: Beautifully formatted tables with colors, distinguishing files and directories.
- **Apparent Size**: Toggle logical file length with `-a` or `--apparent`. - **Apparent Size**: Toggle logical file length with `-a` or `--apparent`.
- **Unit Selection**: Switch between Binary (IEC) and Decimal (SI) unit systems via `--units`. - **Unit Selection**: Switch between Binary (IEC) and Decimal (SI) unit systems via `--units`.
@@ -18,56 +27,40 @@ A fast, concurrent, and feature-rich command-line tool for visualizing disk usag
## Installation ## Installation
### 🚀 Direct Download (macOS & Linux) ### Build the current review branch
Download the latest archive for your architecture from the [Releases](https://gitlab.speelman.ca/gamertan/sized/releases) page.
1. **Extract the archive**: With a Rust toolchain and Git installed:
```bash
tar -xzf sized-v1.0.0-Darwin-arm64.tar.gz
```
2. **Install Binary & Man Page**:
```bash
# Move binary to path
sudo mv sized /usr/local/bin/
# Install man page
sudo mkdir -p /usr/local/share/man/man1
sudo cp sized.1 /usr/local/share/man/man1/
```
3. **macOS Security (Gatekeeper)**:
Since the binary isn't code-signed for the App Store, macOS may block it. To allow it:
```bash
sudo xattr -d com.apple.quarantine /usr/local/bin/sized
```
*Alternatively, run `sized` once, let it fail, then go to **System Settings > Privacy & Security** and click **"Allow Anyway"**.*
### 🍺 Homebrew (macOS & Linux)
If you have a homebrew tap:
```bash ```bash
brew install gamertan/tap/sized git clone --branch sizequeen-scan-hardening https://gitea.speelman.ca/gamertan/sized.git
```
### 📦 Debian / Ubuntu (.deb)
1. Download the `.deb` package from the [Releases](https://gitlab.speelman.ca/gamertan/sized/releases) page.
2. Install using `dpkg`:
```bash
sudo dpkg -i sized_1.0.0_amd64.deb
```
### 🦀 From Source (Rust toolchain required)
```bash
git clone https://gitlab.speelman.ca/gamertan/sized.git
cd sized cd sized
make install # Installs binary and man page cargo install --locked --path .
sized --help
``` ```
Alternatively, via Cargo: Cargo installs into `~/.cargo/bin`; include it in your `PATH`. For the published
source instead, check out `v1.0.0` before the install command. Source builds run
locally; they are separate from SizeQueen's signed and notarized Mac app.
To install the binary and man page together without administrator privileges:
```bash ```bash
cargo install --path . make install PREFIX="$HOME/.local"
``` ```
This uses `~/.local/bin` and `~/.local/share/man/man1`; configure `PATH` and your
manual-page search path as needed.
### Existing release downloads
The [v1.0.0 release](https://gitea.speelman.ca/gamertan/sized/releases/tag/v1.0.0)
contains source, one legacy executable named `sized`, a man page and shell
completions. The executable was inspected as macOS arm64 (Apple silicon); it is not a Linux download.
There are currently no attached architecture-labelled archives or `.deb`
packages, and no verified Homebrew tap. Build from source for the current
review changes. Platform-labelled archives will be advertised after a new
release is reviewed and published.
## Documentation ## Documentation
- **[Manual](MANUAL.md)**: Detailed explanations of all flags and features. - **[Manual](MANUAL.md)**: Detailed explanations of all flags and features.
- **[Man Page](sized.1)**: Standard unix man pages (installed via `make install`). - **[Man Page](sized.1)**: Standard unix man pages (installed via `make install`).
@@ -98,6 +91,7 @@ sized /path/to/directory
| `-f`, `--path-full` | Force absolute paths in headers | `sized -f` | | `-f`, `--path-full` | Force absolute paths in headers | `sized -f` |
| `-i`, `--ignore` | Respect .gitignore files | `sized -i` | | `-i`, `--ignore` | Respect .gitignore files | `sized -i` |
| `-j`, `--threads` | Set number of threads | `sized -j 4` | | `-j`, `--threads` | Set number of threads | `sized -j 4` |
| `-x`, `--one-file-system` | Skip entries on other filesystems | `sized -x /` |
| `--format` | Output format (text, csv, json) | `sized --format json` | | `--format` | Output format (text, csv, json) | `sized --format json` |
| `--save` | Save output to file | `sized --save` | | `--save` | Save output to file | `sized --save` |
| `-c`, `--compare` | Compare total vs. non-ignored files | `sized -i -c` | | `-c`, `--compare` | Compare total vs. non-ignored files | `sized -i -c` |
@@ -137,17 +131,64 @@ autoload -Uz compinit && compinit
sized --completions fish > ~/.config/fish/completions/sized.fish sized --completions fish > ~/.config/fish/completions/sized.fish
``` ```
## Scan status and library API
Scans report missing or unreadable entries on stderr and return a nonzero status
when incomplete. JSON reports include `complete` so automation can distinguish
a partial report from a fully scanned tree. Symlinks are not followed.
Library users can call `scan_tree` with `ScanOptions` and a fresh `ScanControl`
for each request. Clones of the control expose progress and cancellation;
cancellation is cooperative between filesystem calls. `build_tree` remains as
a convenience helper, while `scan_tree` retains structured diagnostics.
## Development checks
Run `cargo test --locked` locally as an unprivileged user. For Linux build,
permission, filesystem-boundary and release smoke checks with Docker:
```bash
./scripts/check-linux.sh linux/arm64
./scripts/check-linux.sh linux/amd64
```
The script pins the official Rust 1.88.0 Bookworm image by digest, adds rustfmt
and Clippy, then runs as UID 65532 with dropped capabilities. Source is mounted
read-only and copied into the temporary container. Fixtures live on Linux
filesystems, including two distinct tmpfs mounts; no privileged container or
host directory scan is required. AMD64 on an ARM64 host requires emulation.
Logs are saved in `target/linux-checks/`; containers and their build output are
removed on exit. Docker retains the reusable check images/build cache.
`SIZED_LINUX_JOBS` changes the default two build workers; `SIZED_LINUX_IMAGE`
can select a different toolchain image for an explicit compatibility check.
These are backend checks; desktop X11/Wayland acceptance belongs to SizeQueen.
Gitea's `Sized Linux checks` workflow runs the same checks natively on cliff-mads
for main/review-branch source changes and owner-triggered, same-repository PRs.
It uses a pinned Rust job image, UID 65532 and real tmpfs boundaries without
Docker access inside the job. Fork contributions can run the local script;
maintainers can bring reviewed changes onto a repository branch for CI.
See SHIPMENT for runner-image setup. Documentation-only pushes skip builds.
Run `./scripts/check-release.sh` to verify the actual archive, checksum and
extracted executable on the host. Linux CI also runs this packaging check.
See [source review and release process](SHIPMENT.md) and the [live queue](TODO.md).
## Sized, SizeQueen and the shared scanner
Sized began as terminal tooling. Its filesystem scanner was extracted into
`sized-core`, which SizeQueen now uses directly beneath its native Mac interface.
SizeQueen adds the treemap and desktop interactions; it does not run the CLI.
Sized currently retains its own scanner copy while shared-core adoption is
reviewed. A public checkout of Sized does not need access to the private core
repository. SizeQueen's matching source download includes its pinned core.
## License ## License
This project is licensed under the **GNU General Public License v3.0 (GPL-3.0)**. **GPL-3.0-only.** See [LICENSE](LICENSE) for the complete terms. All Sized features
are free; optional [support for Gamertan](https://gamertan.com/store/) does not
### Why GPL-3.0? (The "Insulin" Philosophy) unlock features.
We believe that core diagnostic tools like `sized` should remain a public good. Inspired by the philosophy behind open-access medicine like insulin, this license ensures that:
- The tool remains **free and open** for everyone to use.
- Any improvements or forks made by others **must also be shared** with the community.
- It prevents proprietary "vampire" versions from taking private credit for public efforts.
For more details, see the [LICENSE](LICENSE) file.
## Contributing ## Contributing
+97 -23
View File
@@ -2,6 +2,66 @@
This document defines the process for versioning and distributing the `sized` project independently of the Git hosting provider (GitHub, GitLab, Gitea). This document defines the process for versioning and distributing the `sized` project independently of the Git hosting provider (GitHub, GitLab, Gitea).
## Source review before a release
Use a named branch from `main` and keep a pull request focused on one outcome.
The `sizequeen-scan-hardening` branch corrects accounting/error handling and
adds the scan controls needed by SizeQueen; the scanner has also been
extracted into private `sized-core` for SizeQueen. Sized retains its own copy
until adoption preserves public source access.
1. Run local locked tests and strict Clippy as an unprivileged user. Run
`./scripts/check-linux.sh linux/arm64` and
`./scripts/check-linux.sh linux/amd64` for the repeatable Linux checks,
including actual mount boundaries and an optimized-binary smoke test.
2. Push the review branch. Open a PR against `main` when its scope is ready,
explaining changed behaviour, test evidence and remaining limits. For this
branch, call out nonzero status on partial scans, added JSON status fields,
and library API changes. Record current work and evidence in `TODO.md`.
3. Review and merge independently of distribution. A branch push or PR merge
does not publish a package, change repository visibility or create a tag.
4. Choose the release version after reviewing library/API compatibility, then
use the release steps below when explicitly authorized. Verify the exact archive
contents, target and installation instructions before announcing a release.
### Gitea Linux CI
`.gitea/workflows/linux.yml` uses the existing `himesan-node24` label on
cliff-mads, overriding the job image with the pinned Sized Rust runtime.
The runner itself launches the two tmpfs mounts. Jobs have no Docker socket
and run `scripts/check-linux-container.sh` as UID 65532, using the checked-out
workspace as `SIZED_TEST_SOURCE`. Its tests, strict Clippy, formatting and
optimized-binary smoke test and extracted release-archive verification are the
same as the local Docker workflow.
The runtime contains Rust 1.88.0, rustfmt/Clippy and Node for the pinned checkout
action. On cliff-mads, rebuild it explicitly with:
```bash
ssh cliff-mads 'docker build --platform linux/amd64 --tag local/gamertan-ci:sized-rust188 -' < scripts/gitea-linux.Dockerfile
ssh cliff-mads 'docker image inspect local/gamertan-ci:sized-rust188 --format "{{.Id}}"'
```
Review and update the workflow's image ID after an intentional rebuild; images
stay local to the runner host. The image pre-owns `/workspace/gamertan/sized`
for UID 65532 so the runner's workspace setup permits unprivileged checkout.
No runner labels, global isolation settings or
publishing credentials are required. Repository Actions must be enabled.
Only owner-triggered, same-repository code runs on this shared homelab runner.
Source changes on main/the review branch and PRs to main trigger checks;
documentation-only pushes skip builds. Manual dispatch is also available.
This workflow does not publish or tag releases.
The first complete native AMD64 push check is
[Gitea run 1048 (number 2)](https://gitea.speelman.ca/gamertan/sized/actions/runs/1048),
at `74b30bbf750417121f3b0c26017d2f011a2a8286`. All 23 tests, formatting, strict
Clippy, release smoke and unchanged-checkout verification passed as UID 65532
on `cliff-himesan-linux-amd64`. The packaging source checkpoint `77b11a8`
subsequently passed [push run 1065](https://gitea.speelman.ca/gamertan/sized/actions/runs/1065)
and [PR run 1066](https://gitea.speelman.ca/gamertan/sized/actions/runs/1066),
including executable/archive checks and output/symlink refusal. Manual dispatch
is configured but not independently exercised. See `TODO.md` for current work.
## 1. Versioning and Tagging ## 1. Versioning and Tagging
The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -13,40 +73,54 @@ The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
git tag -a v1.0.0 -m "Release v1.0.0" git tag -a v1.0.0 -m "Release v1.0.0"
``` ```
## 2. Asset Generation ## 2. Asset generation and verification
The `scripts/release.sh` script is the authoritative way to generate release assets locally or in any CI environment.
```bash ```bash
./scripts/check-release.sh
./scripts/release.sh ./scripts/release.sh
``` ```
This script generates: `release.sh` uses the locked dependencies and the Rust toolchain's host target.
- **Optimized Binary**: The production-ready `sized` executable. It packages the executable **before** creating the archive, together with the
- **Documentation**: The `sized.1` man page. man page, Bash/Zsh/Fish completions, licence, README, manual and build metadata.
- **Shell Completions**: Scripts for Bash, Zsh, and Fish. Outputs are `dist/sized-v<VERSION>-<RUST-TARGET>-<REVISION>/`, a sibling `.tar.gz`
- **System Installers**: A `.deb` package for Debian-based Linux distributions. and `.tar.gz.sha256`. Existing outputs are never replaced. Use
`--output-dir /absolute/new/path` for an explicitly named local candidate.
`CARGO_TARGET_DIR` is respected. The script does not sign, upload, tag or publish.
All assets are gathered in the `dist/v<VERSION>/` directory. `check-release.sh` creates temporary outputs, extracts the archive, checks all
required files and the checksum, then runs the packaged executable against an
owned fixture. It also checks the refusal to overwrite. Gitea's Linux check
runs this same test; a successful compile alone is insufficient.
## 3. Distribution Channels Only build from a clean, reviewed commit for distribution. `BUILD-INFO.txt`
records the commit, target, toolchain and whether tracked inputs were modified.
Exported source can provide `SIZED_SOURCE_REVISION`; verify that provenance
against the original checkout. Retain matching source and complete dependency
licences with any public binary distribution. A host build is unsigned; do not
advertise it as notarized or as a Mac App Store application.
### crates.io ## 3. Distribution channels
To update the project on the central Rust registry:
```bash
cargo publish
```
### System Package Managers The existing public Gitea v1.0.0 release contains a macOS arm64 executable,
For Homebrew, GitLab, or Gitea-based distribution: man page and completions, plus generated source archives. It predates the
1. Push the git tag to your SCM. scanner hardening branch. No architecture-labelled binary archive, `.deb` or
2. Run the release script to generate assets. verified Homebrew tap is currently offered.
3. Attach the contents of the `dist/` folder to your SCM's "Release" or "Tag" entry.
4. Update downstream formulas (like `homebrew-tap`) with the new source URL and SHA256 of the generated tarball.
## 4. Automation & Hooks Future public releases need their own reviewed version, source, target-specific
archives, checksums and installation acceptance. Do not replace historical
v1.0.0 assets with newly built bytes. Add a new version only after review.
To automate asset generation locally, you can use a git `post-checkout` or a wrapper script. Since gitea and gitlab use different CI formats, it is recommended to simply call `./scripts/release.sh` within your preferred CI runner (e.g., `gitlab-ci.yml` or `gitea-actions`). Debian/Alpine packaging is separate from the portable host archive. Do not
implicitly invoke `cargo deb` or `abuild` just because they are installed: a Mac
binary is not a Debian package. Validate each installer on its target OS before
publishing it. Registry and Homebrew publication are separate decisions too.
## 4. Automation
CI tests and packages temporary candidates without publishing credentials.
Do not run release generation automatically from Git hooks. Publication remains
an intentional operation after the checks above.
## 5. Manual Installation ## 5. Manual Installation
For system-wide installation from source, use the `Makefile`: For system-wide installation from source, use the `Makefile`:
+162
View File
@@ -0,0 +1,162 @@
# Live queue
## Current scope
Harden Sized for reuse by the native SpaceMonger-inspired SizeQueen project.
The user authorized fixes and updates discovered during that investigation.
Keep the existing CLI useful and preserve the GPL-3.0-only license.
- [x] Correct allocated-size totals for hard links; retain apparent per-path
sizes and define deterministic ownership within each scan.
- [x] Preserve scan errors and expose incomplete results instead of silently
treating missing paths as accessible empty directories.
- [x] Expose cancellable scans, progress counters, per-scan worker selection,
and an optional filesystem boundary for the native UI.
- [x] Add accounting and control regressions; run the existing CLI tests and
compare bounded release-build scan measurements.
- [x] Run reproducible Linux ARM64 and AMD64 Docker checks as an unprivileged
user, including actual mount boundaries, strict Clippy and a release smoke test.
- [x] Commit the scanner hardening and Linux check tooling; push the existing
`sizequeen-scan-hardening` review branch. Remote equality is verified.
PR #2 is open; release/tag/package publication remains separate.
- [x] Enable repository Actions and run the same Linux checks on the existing
cliff-mads runner. Keep its container isolation and other jobs unchanged;
verify a real workflow result before treating CI as proven.
## Approved project page and release cleanup
The owner approved a Sized project page in the shared Gamertan CMS, truthful
installation/release guidance, packaging repairs, and a Built on Sized section
on SizeQueen. Website delivery is tracked in that repository's existing queue.
- [x] Inspect the public v1.0.0 assets; the unlabelled executable is macOS arm64,
ad hoc signed, SHA-256 `50fde4d8215babbb64f4a4f55e030dd5c941a97ed1bedfa80392e4301c52d2bf`.
There are no attached Linux binaries, labelled archives or Debian packages.
- [x] Replace stale GitLab/unsupported package-manager installation claims with
explicit review-branch source builds; explain the published release boundary.
- [x] Package the executable before the archive; include licence/docs/build
provenance, target-labelled names and checksums. Reject output replacement.
Remove incidental installer generation; host binaries must not become `.deb`s.
- [x] Verify archive contents/checksum, run the extracted CLI and test overwrite
refusal on macOS arm64. Add the same verification to Linux CI.
- [x] Verify the updated cliff-mads workflow, push the review checkpoint and
record the public CMS delivery. PR #2 is open; no new version, release assets
or merge. Historical v1.0.0 notes are corrected and asset bytes preserved.
## Deferred / next release
SizeQueen now pins the extracted private `sized-core`; Sized retains its own
scanner copy. Adoption must keep public builds independently fetchable.
Review scanner/API/status changes before merging the existing branch. Select a
new release version, verify target-specific installation and package complete
corresponding source/dependency notices before publishing fresh binary assets.
Do not replace historical v1.0.0 files. Windows allocation and very-large/deep
scan tuning remain separate work. See `SHIPMENT.md` for the release process.
## Resume note
The approved Sized project page and release cleanup are delivered. Source
checkpoint `77b11a8c29b5ca3435fdd962717f8e8285abe972` is pushed on
`sizequeen-scan-hardening`; [PR #2](https://gitea.speelman.ca/gamertan/sized/pulls/2)
is open against unchanged `main`. Repository visibility remains public.
Final cliff-mads checks passed for both events:
[push run 1065](https://gitea.speelman.ca/gamertan/sized/actions/runs/1065)
(4m 57s) and
[PR run 1066](https://gitea.speelman.ca/gamertan/sized/actions/runs/1066)
(5m 0s). Rust 1.88.0 / Linux AMD64 / UID 65532 passed all 23 tests, formatting,
strict Clippy, optimized smoke, archive extraction/checksum/executable and
existing-output/dangling-symlink guards, plus unchanged-checkout verification.
Mac arm64 passed the same package checks and documented Cargo/Make installs into
temporary prefixes. Ignored local evidence is in `target/release-audit/`,
including `gitea-run-1065.txt`, `package-macos.log` and `install-macos.log`.
[Sized](https://gamertan.com/projects/sized/) is published through the shared
Gamertan project template (CMS revision 6), with a compact status sidebar,
verified synthetic CLI example, source instructions and accurate release limits.
It appears on the homepage, project index and sitemap. SizeQueen's
[Built on Sized section](https://gamertan.com/projects/sizequeen/) is published
in revision 14; all prior sections and app downloads are preserved. Desktop
and 320px mobile checks passed. Website evidence and recovery are recorded in
`../gamertancom-web-foundations/docs/PRODUCTION_SANDBOX_2026-09-04.md` under
“Sized project and Built on Sized — 10 October 2026”.
Historical v1.0.0 release notes now identify the attached executable as macOS
arm64 and distinguish the newer review branch. Asset IDs, lengths and executable
SHA-256 above remain unchanged. No new binary release, tag, merge or private-core
source publication occurred. Next: review PR #2's CLI exit status, JSON and
library API changes, then choose a release version and complete corresponding
source/dependency notices before authorizing new binary publication.
### Earlier verification history
The records below describe previous checkpoints; current delivery and next
actions are above.
Previous CI checkpoint: repository Actions is enabled and a real native AMD64
push run passed on cliff-mads. [Gitea run 1048 (number 2)](https://gitea.speelman.ca/gamertan/sized/actions/runs/1048)
tested `74b30bbf750417121f3b0c26017d2f011a2a8286` on
`cliff-himesan-linux-amd64` (`gitea-runner v3.1.0`): all 23 tests, formatting,
strict Clippy, optimized-binary smoke and unchanged-checkout verification passed
as UID 65532 with Rust 1.88.0. It finished in 3m 30s. Filtered local evidence is
in ignored `target/linux-checks/gitea-run-1048.log`; full logs remain in Gitea.
The initial checkout failed because the repository directory was root-owned;
the corrected runtime pre-owns it for the job user. The workflow reuses the
existing `himesan-node24` label and a local, pinned Rust/Node image. Runner
configuration, other jobs and repository visibility are unchanged. Temporary
setup credentials were revoked and their files removed. Push execution is
proven; PR execution was subsequently proven in run 1066; manual dispatch remains
configured but not independently exercised.
Linux validation and the requested remote review branch are complete.
Scanner hardening is `8b177e2`; repeatable Linux checks and equivalent format
interpolations are `988aad9`. Both implementation commits are pushed to
`origin/sizequeen-scan-hardening`, followed by CI setup `67fe175` and the
unprivileged checkout fix `74b30bb`. The source checkpoint is verified remotely
at `74b30bbf750417121f3b0c26017d2f011a2a8286`; later documentation-only
checkpoint commits do not rerun the source checks. Gitea main remains `9c8d1d4`.
The repository was already public (`private: false`); visibility is unchanged.
At that checkpoint, no PR, merge, tag, package publication or release occurred.
`scripts/check-linux.sh` owns the container workflow. Linux ARM64 (native in
Docker's VM) and AMD64 (emulated on this Mac) each passed 23 tests as UID 65532:
5 library, 6 CLI, 11 scan integrations and one explicitly enabled real-mount
integration. `tests/linux_mount.rs` checks two distinct tmpfs devices through
the library and CLI, with boundaries enabled and disabled. Formatting, strict
all-target Clippy and an optimized-binary fixture smoke test passed on both.
Mac passed its 22 applicable tests, formatting and strict Clippy. Commands are
documented in README/SHIPMENT; full local logs are in ignored
`target/linux-checks/{arm64,amd64}.log`. Cargo.lock and unrelated `.DS_Store`
hashes are unchanged; only source inputs and owned fixtures enter the checks.
Rust 1.88 Clippy identified format-string style warnings; equivalent
interpolations fix those without suppressions. SizeQueen's included scanner
still matches `8b177e2` exactly; the follow-up changes only one scanner Display
format string, not scan behaviour. SizeQueen itself was unchanged in this pass.
The planned PR and packaging repairs are now complete; review and version
selection remain next. These checks do not prove desktop X11/Wayland interaction,
Windows allocation or performance on very large/cold/remote trees.
Previous local checkpoint: baseline `9c8d1d4` matched Gitea main;
branch `sizequeen-scan-hardening`.
All six baseline tests passed. SizeQueen's independent probe reproduced
hard-link overcount and missing-path misclassification, and confirmed sparse
allocation, symlink leaf handling, and hidden-file inclusion. Source inspection
found discarded walker errors and no scan control API. These scoped corrections
are implemented locally, with no push, tag, or release. `cargo test --locked`
passed 22 tests (5 library, 6 CLI, 11 scan integrations); strict all-target
Clippy passed. The independent SizeQueen probe passed 6 accounting tests.
Man page was regenerated. Cargo.lock is unchanged and unrelated `.DS_Store`
was preserved.
Release probes on the Mac took 49–56ms for 20,000 files; sorting, identity
tracking, diagnostics and control cost more than the baseline (29–39ms for
the grouped tree). Inline Node size grew from 88 to 136 bytes; whole-process
peak RSS was about 8.2MiB grouped / 16.7MiB wide. These are bounded warm-metadata
fixtures, not evidence for million-entry, cold-disk, or remote-filesystem speed.
Cancellation is cooperative between filesystem calls. Allocation is reported
blocks, not guaranteed bytes recoverable from shared extents or snapshots.
Detailed audit and bounded probes are maintained in the sibling SizeQueen
project at `../sizequeen/research/SIZED-AUDIT.md`; that project's product queue
remains separate from this backend's fix queue.
+3 -3
View File
@@ -10,12 +10,12 @@ fn setup_test_dir() -> TempDir {
// Create a moderately deep structure // Create a moderately deep structure
for i in 0..10 { for i in 0..10 {
let dir_path = base_path.join(format!("dir_{}", i)); let dir_path = base_path.join(format!("dir_{i}"));
fs::create_dir(&dir_path).unwrap(); fs::create_dir(&dir_path).unwrap();
for j in 0..10 { for j in 0..10 {
let file_path = dir_path.join(format!("file_{}.txt", j)); let file_path = dir_path.join(format!("file_{j}.txt"));
let mut file = File::create(file_path).unwrap(); let mut file = File::create(file_path).unwrap();
writeln!(file, "Some content for file {}-{}", i, j).unwrap(); writeln!(file, "Some content for file {i}-{j}").unwrap();
} }
} }
temp_dir temp_dir
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
test "$(uname -s)" = Linux
test "$(id -u)" != 0
printf 'Linux checks: uid=%s architecture=%s\n' "$(id -u)" "$(uname -m)"
rustc --version
cargo --version
# Only source inputs are copied. Cargo output and all fixtures disappear with
# the container; the host checkout is read-only and no personal tree is scanned.
check_root=$(mktemp -d /tmp/sized-check.XXXXXX)
source_root=${SIZED_TEST_SOURCE:-/source}
cp "$source_root/Cargo.toml" "$source_root/Cargo.lock" "$check_root/"
cp -R "$source_root/src" "$source_root/tests" "$source_root/benches" "$check_root/"
cp "$source_root/LICENSE" "$source_root/README.md" "$source_root/MANUAL.md" "$check_root/"
mkdir "$check_root/scripts"
cp "$source_root/scripts/release.sh" "$source_root/scripts/check-release.sh" "$check_root/scripts/"
export SIZED_SOURCE_REVISION="$(git -C "$source_root" rev-parse HEAD 2>/dev/null || printf unknown)"
cd "$check_root"
cargo fmt --all -- --check
cargo test --locked
cargo test --locked --test linux_mount -- --ignored
cargo clippy --locked --all-targets -- -D warnings
cargo build --locked --release
fixture_root=$(mktemp -d /tmp/sized-release.XXXXXX)
printf 'Linux release smoke test\n' > "$fixture_root/payload"
./target/release/sized --version
./target/release/sized "$fixture_root" --threads 2 --one-file-system --apparent --format json
./scripts/check-release.sh
printf 'Linux checks passed.\n'
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# Pin the multi-platform official image, not a moving tag. Tests run on Linux
# filesystems rather than the source bind mount, whose permission semantics vary.
repo_root=$(cd "$(dirname "$0")/.." && pwd)
platform=${1:-linux/$(docker version --format '{{.Server.Arch}}')}
case "$platform" in
linux/arm64|linux/amd64) ;;
*) printf 'Usage: %s [linux/arm64|linux/amd64]\n' "$0" >&2; exit 2 ;;
esac
image=${SIZED_LINUX_IMAGE:-rust:1.88.0-bookworm@sha256:af306cfa71d987911a781c37b59d7d67d934f49684058f96cf72079c3626bfe0}
check_image="sized-linux-check:${platform#linux/}"
mkdir -p "$repo_root/target/linux-checks"
log_file="$repo_root/target/linux-checks/${platform#linux/}.log"
docker build --platform "$platform" --build-arg "BASE_IMAGE=$image" \
--tag "$check_image" - < "$repo_root/scripts/linux-check.Dockerfile"
docker run --rm --platform "$platform" \
--user 65532:65532 --cap-drop ALL --security-opt no-new-privileges \
--mount "type=bind,src=$repo_root,dst=/source,readonly" \
--tmpfs /tmp/sized-mount-test:rw,nosuid,nodev,noexec,size=16m,uid=65532,gid=65532,mode=0700 \
--tmpfs /tmp/sized-mount-test/foreign:rw,nosuid,nodev,noexec,size=16m,uid=65532,gid=65532,mode=0700 \
--env CARGO_HOME=/tmp/sized-cargo \
--env CARGO_BUILD_JOBS="${SIZED_LINUX_JOBS:-2}" \
--env SIZED_TEST_MOUNT_ROOT=/tmp/sized-mount-test \
"$check_image" bash /source/scripts/check-linux-container.sh 2>&1 | tee "$log_file"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(cd "$(dirname "$0")/.." && pwd)
check_root=$(mktemp -d "${TMPDIR:-/tmp}/sized-package.XXXXXX")
trap 'rm -rf "$check_root"' EXIT
"$repo_root/scripts/release.sh" --output-dir "$check_root/bundle"
mkdir "$check_root/extracted" "$check_root/fixture"
tar -xzf "$check_root/bundle.tar.gz" -C "$check_root/extracted"
for required in sized sized.1 sized.bash _sized sized.fish LICENSE README.md MANUAL.md BUILD-INFO.txt; do
test -s "$check_root/extracted/$required"
done
test -x "$check_root/extracted/sized"
(
cd "$check_root"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c bundle.tar.gz.sha256
else
shasum -a 256 -c bundle.tar.gz.sha256
fi
)
printf 'owned package fixture\n' > "$check_root/fixture/payload"
"$check_root/extracted/sized" --version
"$check_root/extracted/sized" "$check_root/fixture" --apparent --format json > "$check_root/result.json"
grep -q 'payload' "$check_root/result.json"
grep -q '"complete":true' "$check_root/result.json"
if "$repo_root/scripts/release.sh" --output-dir "$check_root/bundle" > "$check_root/repeat.log" 2>&1; then
printf 'Packaging unexpectedly replaced an existing output.\n' >&2
exit 1
fi
grep -q 'Refusing to replace' "$check_root/repeat.log"
ln -s "$check_root/untouched" "$check_root/linked.tar.gz"
if "$repo_root/scripts/release.sh" --output-dir "$check_root/linked" > "$check_root/linked.log" 2>&1; then
printf 'Packaging unexpectedly followed an existing archive symlink.\n' >&2
exit 1
fi
grep -q 'Refusing to replace' "$check_root/linked.log"
test ! -e "$check_root/untouched"
printf 'Archive contents, checksum, extracted CLI and overwrite guard passed.\n'
+10
View File
@@ -0,0 +1,10 @@
# Node supports the pinned checkout action; application code remains Rust.
FROM node:24-bookworm@sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584 AS node-runtime
FROM rust:1.88.0-bookworm@sha256:af306cfa71d987911a781c37b59d7d67d934f49684058f96cf72079c3626bfe0
RUN rustup component add rustfmt clippy
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
# A new runner workspace volume inherits this ownership. No setuid step or
# Docker socket is needed in jobs, even with all capabilities dropped.
RUN mkdir -p /workspace/gamertan/sized && chown -R 65532:65532 /workspace
USER 65532:65532
WORKDIR /workspace
+3
View File
@@ -0,0 +1,3 @@
ARG BASE_IMAGE=rust:1.88.0-bookworm@sha256:af306cfa71d987911a781c37b59d7d67d934f49684058f96cf72079c3626bfe0
FROM ${BASE_IMAGE}
RUN rustup component add rustfmt clippy
+52 -49
View File
@@ -1,53 +1,56 @@
#!/bin/bash #!/usr/bin/env bash
set -e set -euo pipefail
VERSION=$(grep '^version =' Cargo.toml | cut -d '"' -f 2) # Build a host-target archive only. No tags, uploads, installers or signing.
DIST_DIR="dist/v$VERSION" repo_root=$(cd "$(dirname "$0")/.." && pwd)
cd "$repo_root"
echo "Building release assets for sized v$VERSION..." version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml)
target=$(rustc -vV | sed -n 's/^host: //p')
# 1. Clean and Prepare revision=${SIZED_SOURCE_REVISION:-$(git rev-parse HEAD 2>/dev/null || printf unknown)}
rm -rf "$DIST_DIR" destination="$repo_root/dist/sized-v$version-$target-${revision:0:12}"
mkdir -p "$DIST_DIR" if [[ $# == 2 && $1 == --output-dir ]]; then
destination=$2
# 2. Build Release Binary elif [[ $# != 0 ]]; then
cargo build --release printf 'Usage: %s [--output-dir ABSOLUTE_PATH]\n' "$0" >&2
exit 2
# 3. Generate Man Page
cargo run --release -- --generate-man-page "$DIST_DIR"
# 4. Generate Completions
cargo run --release -- --completions bash > "$DIST_DIR/sized.bash"
cargo run --release -- --completions zsh > "$DIST_DIR/_sized"
cargo run --release -- --completions fish > "$DIST_DIR/sized.fish"
# 5. Build Debian Package (if cargo-deb is installed)
if command -v cargo-deb &> /dev/null; then
echo "Building Debian package..."
# On macOS, we use --no-strip to avoid 'unrecognized option: --strip-unneeded'
# and --no-build because we already built the release binary.
cargo deb --no-build --no-strip
cp target/debian/*.deb "$DIST_DIR/"
echo "Note: .deb package contains the binary for $(uname -s)-$(uname -m)"
else
echo "Warning: cargo-deb not found. Skipping .deb packaging."
fi fi
[[ $destination == /* ]] || { printf 'Output directory must be absolute.\n' >&2; exit 2; }
for output in "$destination" "$destination.tar.gz" "$destination.tar.gz.sha256"; do
[[ ! -e $output && ! -L $output ]] || { printf 'Refusing to replace %s\n' "$output" >&2; exit 1; }
done
# 6. Build Alpine Package (Placeholder/Hook) target_dir=${CARGO_TARGET_DIR:-"$repo_root/target"}
# Note: For real .apk building, one usually uses a docker container or abuild. [[ $target_dir == /* ]] || target_dir="$repo_root/$target_dir"
# This serves as a reminder for Alpine users. cargo build --locked --release --target "$target" --target-dir "$target_dir"
if [ -f "APKBUILD" ] && command -v abuild &> /dev/null; then binary="$target_dir/$target/release/sized"
echo "Building Alpine package..." mkdir -p "$destination"
abuild -r install -m 755 "$binary" "$destination/sized"
fi "$binary" --generate-man-page "$destination"
"$binary" --completions bash > "$destination/sized.bash"
"$binary" --completions zsh > "$destination/_sized"
"$binary" --completions fish > "$destination/sized.fish"
cp LICENSE README.md MANUAL.md "$destination/"
{
printf 'Version: %s\nTarget: %s\nSource revision: %s\n' "$version" "$target" "$revision"
printf 'Source worktree: '
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if git diff --quiet HEAD --; then printf 'clean tracked files\n'; else printf 'modified tracked files\n'; fi
else
printf 'exported source; verify against supplied revision\n'
fi
rustc --version
cargo --version
printf 'Unsigned host build; not a notarized Mac application.\n'
} > "$destination/BUILD-INFO.txt"
# 7. Create General Release Tarball tar -czf "$destination.tar.gz" -C "$destination" .
echo "Creating release tarball..." (
tar -czf "dist/sized-v$VERSION-$(uname -s)-$(uname -m).tar.gz" -C "$DIST_DIR" . cd "$(dirname "$destination")"
archive="$(basename "$destination").tar.gz"
# 8. Copy Binary if command -v sha256sum >/dev/null 2>&1; then
cp target/release/sized "$DIST_DIR/" sha256sum "$archive" > "$archive.sha256"
else
echo "Success! Assets are ready in $DIST_DIR" shasum -a 256 "$archive" > "$archive.sha256"
ls -F "$DIST_DIR" fi
echo "Tarball: dist/sized-v$VERSION-$(uname -s)-$(uname -m).tar.gz" )
printf 'Archive: %s.tar.gz\nChecksum: %s.tar.gz.sha256\n' "$destination" "$destination"
+5 -5
View File
@@ -1,10 +1,10 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH sized 1 "sized 1.0.0" .TH sized 1 "sized 1.0.0"
.SH NAME .SH NAME
sized \- A modern, fast, and concurrent disk usage analyzer sized \- A modern, fast, and concurrent disk usage analyzer
.SH SYNOPSIS .SH SYNOPSIS
\fBsized\fR [\fB\-v\fR|\fB\-\-version\fR] [\fB\-m\fR|\fB\-\-min\-size\fR] [\fB\-n\fR|\fB\-\-number\fR] [\fB\-\-sort\fR] [\fB\-a\fR|\fB\-\-apparent\fR] [\fB\-d\fR|\fB\-\-depth\fR] [\fB\-f\fR|\fB\-\-path\-full\fR] [\fB\-\-path\-relative\fR] [\fB\-j\fR|\fB\-\-threads\fR] [\fB\-\-completions\fR] [\fB\-i\fR|\fB\-\-ignore\fR] [\fB\-\-format\fR] [\fB\-\-save\fR] [\fB\-\-precision\fR] [\fB\-\-units\fR] [\fB\-c\fR|\fB\-\-compare\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIPATH\fR] \fBsized\fR [\fB\-v\fR|\fB\-\-version\fR] [\fB\-m\fR|\fB\-\-min\-size\fR] [\fB\-n\fR|\fB\-\-number\fR] [\fB\-\-sort\fR] [\fB\-a\fR|\fB\-\-apparent\fR] [\fB\-d\fR|\fB\-\-depth\fR] [\fB\-f\fR|\fB\-\-path\-full\fR] [\fB\-\-path\-relative\fR] [\fB\-j\fR|\fB\-\-threads\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-completions\fR] [\fB\-i\fR|\fB\-\-ignore\fR] [\fB\-\-format\fR] [\fB\-\-save\fR] [\fB\-\-precision\fR] [\fB\-\-units\fR] [\fB\-c\fR|\fB\-\-compare\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIPATH\fR]
.SH DESCRIPTION .SH DESCRIPTION
A modern, fast, and concurrent disk usage analyzer A modern, fast, and concurrent disk usage analyzer
.SH OPTIONS .SH OPTIONS
@@ -36,6 +36,9 @@ Display paths relative to current directory (default)
\fB\-j\fR, \fB\-\-threads\fR \fI<THREADS>\fR \fB\-j\fR, \fB\-\-threads\fR \fI<THREADS>\fR
Number of threads to use (defaults to available logical CPUs) Number of threads to use (defaults to available logical CPUs)
.TP .TP
\fB\-x\fR, \fB\-\-one\-file\-system\fR
Stay on the target\*(Aqs filesystem instead of descending into other mounts
.TP
\fB\-\-completions\fR \fI<COMPLETIONS>\fR \fB\-\-completions\fR \fI<COMPLETIONS>\fR
Generate shell completions Generate shell completions
.br .br
@@ -76,6 +79,3 @@ Print help
Directory to analyze Directory to analyze
.SH VERSION .SH VERSION
v1.0.0 v1.0.0
.SH COPYRIGHT
sized is licensed under the GNU General Public License v3.0 (GPL-3.0).
+64 -173
View File
@@ -6,15 +6,17 @@ use colored::*;
use comfy_table::presets::UTF8_FULL; use comfy_table::presets::UTF8_FULL;
use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table}; use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
use csv::WriterBuilder; use csv::WriterBuilder;
use ignore::WalkBuilder;
use rayon::prelude::*;
use serde::Serialize;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::io::Write; use std::io::Write;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
pub mod scan;
pub use scan::{
scan_tree, EntryType, FileIdentity, Node, ScanControl, ScanError, ScanIssue, ScanOptions,
ScanReport,
};
#[derive(Parser, Debug, Clone)] #[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
#[command(disable_version_flag = true)] #[command(disable_version_flag = true)]
@@ -61,6 +63,10 @@ pub struct Args {
#[arg(short = 'j', long = "threads")] #[arg(short = 'j', long = "threads")]
pub threads: Option<usize>, pub threads: Option<usize>,
/// Stay on the target's filesystem instead of descending into other mounts
#[arg(short = 'x', long)]
pub one_file_system: bool,
/// Generate shell completions /// Generate shell completions
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub completions: Option<Shell>, pub completions: Option<Shell>,
@@ -125,7 +131,7 @@ impl FromStr for SortColumn {
"size" | "s" | "disk" | "d" => Ok(SortColumn::Disk), "size" | "s" | "disk" | "d" => Ok(SortColumn::Disk),
"apparent" | "a" => Ok(SortColumn::Apparent), "apparent" | "a" => Ok(SortColumn::Apparent),
"blocks" | "b" => Ok(SortColumn::Blocks), "blocks" | "b" => Ok(SortColumn::Blocks),
_ => Err(format!("Unknown column: {}", s)), _ => Err(format!("Unknown column: {s}")),
} }
} }
} }
@@ -142,45 +148,19 @@ impl FromStr for SortDirection {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"asc" | "a" => Ok(SortDirection::Asc), "asc" | "a" => Ok(SortDirection::Asc),
"dsc" | "d" | "desc" => Ok(SortDirection::Dsc), "dsc" | "d" | "desc" => Ok(SortDirection::Dsc),
_ => Err(format!("Unknown direction: {}", s)), _ => Err(format!("Unknown direction: {s}")),
} }
} }
} }
#[derive(Clone, Serialize, Debug)] /// Returns whether the scan completed without missing entries. The caller
pub struct Node { /// chooses the exit status; partial reports remain available to the user.
pub path: PathBuf, pub fn run(args: Args, mut writer: &mut dyn Write) -> bool {
pub size_bytes: u64,
pub blocks: u64,
pub size_bytes_filtered: u64,
pub blocks_filtered: u64,
pub entry_type: EntryType,
pub accessible: bool,
#[serde(skip)]
pub children: Vec<Node>,
}
#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum EntryType {
File,
Dir,
}
impl std::fmt::Display for EntryType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EntryType::File => write!(f, "File"),
EntryType::Dir => write!(f, "Dir"),
}
}
}
pub fn run(args: Args, mut writer: &mut dyn Write) {
if let Some(shell) = args.completions { if let Some(shell) = args.completions {
let mut cmd = Args::command(); let mut cmd = Args::command();
let name = cmd.get_name().to_string(); let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut std::io::stdout()); generate(shell, &mut cmd, name, &mut std::io::stdout());
return; return true;
} }
if let Some(out_dir) = args.generate_man_page { if let Some(out_dir) = args.generate_man_page {
@@ -193,28 +173,16 @@ pub fn run(args: Args, mut writer: &mut dyn Write) {
let file_path = out_dir.join("sized.1"); let file_path = out_dir.join("sized.1");
std::fs::write(&file_path, buffer).expect("Failed to write man page"); std::fs::write(&file_path, buffer).expect("Failed to write man page");
println!("Man page generated at {}", file_path.display()); println!("Man page generated at {}", file_path.display());
return; return true;
}
if let Some(threads) = args.threads {
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build_global()
.ok(); // Ignore error if initialized called multiple times (e.g. in tests)
} }
let target_path = args.path.clone(); let target_path = args.path.clone();
if !target_path.exists() {
eprintln!("Error: Path '{}' does not exist.", target_path.display());
std::process::exit(1);
}
let min_bytes = if let Some(size_str) = &args.min_size { let min_bytes = if let Some(size_str) = &args.min_size {
match Byte::parse_str(size_str, true) { match Byte::parse_str(size_str, true) {
Ok(byte) => byte.as_u64(), Ok(byte) => byte.as_u64(),
Err(e) => { Err(e) => {
eprintln!("Error parsing size '{}': {}", size_str, e); eprintln!("Error parsing size '{size_str}': {e}");
std::process::exit(1); std::process::exit(1);
} }
} }
@@ -234,134 +202,55 @@ pub fn run(args: Args, mut writer: &mut dyn Write) {
} }
} }
let root_node = build_tree(&target_path, args.ignore, args.compare); let report = match scan_tree(
&target_path,
&ScanOptions {
respect_ignore: args.ignore,
compare_ignore: args.compare,
stay_on_filesystem: args.one_file_system,
threads: args.threads,
},
&ScanControl::default(),
) {
Ok(report) => report,
Err(e) => {
eprintln!("Error: {e}");
return false;
}
};
for issue in &report.issues {
eprintln!(
"Warning: incomplete scan at '{}': {}",
issue.path.display(),
issue.message
);
}
let root_node = report.root;
if root_node.size_bytes < min_bytes { if root_node.size_bytes < min_bytes {
if args.format == OutputFormat::Text { if args.format == OutputFormat::Text {
writeln!(writer, "Root directory is smaller than minimum size.").ok(); writeln!(writer, "Root directory is smaller than minimum size.").ok();
} }
return; return root_node.complete;
} }
process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria); process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria);
root_node.complete
} }
pub fn build_tree(path: &Path, ignore: bool, compare: bool) -> Node { pub fn build_tree(path: &Path, ignore: bool, compare: bool) -> Node {
let metadata = path.symlink_metadata(); // Compatibility helper; callers needing diagnostics should use scan_tree.
scan_tree(
if let Ok(meta) = metadata { path,
// Treat symlinks as files (nodes) but do not recurse &ScanOptions {
if meta.is_file() || meta.is_symlink() { respect_ignore: ignore,
return Node { compare_ignore: compare,
path: path.to_path_buf(), ..ScanOptions::default()
size_bytes: meta.len(), },
blocks: meta.blocks(), &ScanControl::default(),
size_bytes_filtered: meta.len(), // Single file is its own filtered size for now )
blocks_filtered: meta.blocks(), .map(|report| report.root)
entry_type: EntryType::File, .unwrap_or_else(|_| Node::unavailable(path))
accessible: true,
children: vec![],
};
}
}
// Check if we can read the directory (handle permissions)
if let Err(e) = std::fs::read_dir(path) {
if e.kind() == std::io::ErrorKind::PermissionDenied {
// Return an "empty" directory node marked as inaccessible
let meta = path.symlink_metadata().ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let blocks = meta.as_ref().map(|m| m.blocks()).unwrap_or(0);
return Node {
path: path.to_path_buf(),
size_bytes: size,
blocks,
size_bytes_filtered: size,
blocks_filtered: blocks,
entry_type: EntryType::Dir,
accessible: false,
children: vec![],
};
}
}
// Total walker (always everything if compare is true, else respects 'ignore' arg)
let total_ignore = if compare { false } else { ignore };
let walker_total = WalkBuilder::new(path)
.standard_filters(false)
.hidden(false)
.git_ignore(total_ignore)
.ignore(total_ignore)
.max_depth(Some(1))
.build();
let child_paths_total: Vec<PathBuf> = walker_total
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path() != path)
.map(|e| e.path().to_path_buf())
.collect();
// Filtered set (only if compare is true)
let non_ignored_set: std::collections::HashSet<PathBuf> = if compare {
let walker_filtered = WalkBuilder::new(path)
.standard_filters(false)
.hidden(false)
.git_ignore(true)
.ignore(true)
.max_depth(Some(1))
.build();
walker_filtered
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path() != path)
.map(|e| e.path().to_path_buf())
.collect()
} else {
std::collections::HashSet::new()
};
let children: Vec<Node> = child_paths_total
.par_iter()
.map(|p| build_tree(p, ignore, compare))
.collect();
let mut size_bytes = 0;
let mut blocks = 0;
let mut size_bytes_filtered = 0;
let mut blocks_filtered = 0;
for child in &children {
size_bytes += child.size_bytes;
blocks += child.blocks;
if compare {
if non_ignored_set.contains(&child.path) {
size_bytes_filtered += child.size_bytes_filtered;
blocks_filtered += child.blocks_filtered;
}
} else {
size_bytes_filtered += child.size_bytes_filtered;
blocks_filtered += child.blocks_filtered;
}
}
let (self_size, self_blocks) = path
.symlink_metadata()
.map(|m| (m.len(), m.blocks()))
.unwrap_or((0, 0));
Node {
path: path.to_path_buf(),
size_bytes: size_bytes + self_size,
blocks: blocks + self_blocks,
size_bytes_filtered: size_bytes_filtered + self_size, // self is always part of self
blocks_filtered: blocks_filtered + self_blocks,
entry_type: EntryType::Dir,
accessible: true,
children,
}
} }
fn process_node_recursive( fn process_node_recursive(
@@ -372,8 +261,6 @@ fn process_node_recursive(
min_bytes: u64, min_bytes: u64,
sort_criteria: &[(SortColumn, SortDirection)], sort_criteria: &[(SortColumn, SortDirection)],
) { ) {
if node.children.is_empty() && node.entry_type == EntryType::Dir {}
let mut display_children: Vec<&Node> = node let mut display_children: Vec<&Node> = node
.children .children
.iter() .iter()
@@ -406,7 +293,7 @@ fn process_node_recursive(
print_output( print_output(
writer, writer,
&node, node,
&display_children, &display_children,
args, args,
&relative_path, &relative_path,
@@ -591,7 +478,7 @@ fn print_text(
} }
} }
writeln!(writer, "{}", summary).ok(); writeln!(writer, "{summary}").ok();
} else { } else {
writeln!(writer, "Total size: {}", "Access Denied".bold().red()).ok(); writeln!(writer, "Total size: {}", "Access Denied".bold().red()).ok();
} }
@@ -735,7 +622,7 @@ fn print_text(
Cell::new("N/A") Cell::new("N/A")
.fg(Color::Red) .fg(Color::Red)
.set_alignment(CellAlignment::Right), .set_alignment(CellAlignment::Right),
Cell::new("Access Denied") Cell::new("Unavailable")
.fg(Color::Red) .fg(Color::Red)
.set_alignment(CellAlignment::Right), .set_alignment(CellAlignment::Right),
]; ];
@@ -762,7 +649,7 @@ fn print_text(
table.add_row(row); table.add_row(row);
} }
} }
writeln!(writer, "{}", table).ok(); writeln!(writer, "{table}").ok();
} }
fn print_csv(writer: &mut dyn Write, children: &[&Node], args: &Args) { fn print_csv(writer: &mut dyn Write, children: &[&Node], args: &Args) {
@@ -794,6 +681,9 @@ fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node], ar
"path": &c.path, "path": &c.path,
"entry_type": c.entry_type.to_string(), "entry_type": c.entry_type.to_string(),
"accessible": c.accessible, "accessible": c.accessible,
"complete": c.complete,
"hard_link_duplicate": c.hard_link_duplicate,
"skipped_mount": c.skipped_mount,
"disk_usage": c.blocks * 512, "disk_usage": c.blocks * 512,
"blocks": c.blocks, "blocks": c.blocks,
}); });
@@ -831,6 +721,7 @@ fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node], ar
"total_blocks": parent_node.blocks, "total_blocks": parent_node.blocks,
"entries": entries_view, "entries": entries_view,
"accessible": parent_node.accessible, "accessible": parent_node.accessible,
"complete": parent_node.complete,
}); });
if args.apparent { if args.apparent {
+4 -2
View File
@@ -18,7 +18,7 @@ fn main() {
OutputFormat::Json => "json", OutputFormat::Json => "json",
_ => "txt", _ => "txt",
}; };
PathBuf::from(format!("{}_{}.{}", timestamp, dir_name, ext)) PathBuf::from(format!("{timestamp}_{dir_name}.{ext}"))
} else { } else {
path_arg.clone() path_arg.clone()
}; };
@@ -29,5 +29,7 @@ fn main() {
Box::new(std::io::stdout()) Box::new(std::io::stdout())
}; };
run(args, &mut writer); if !run(args, &mut writer) {
std::process::exit(1);
}
} }
+417
View File
@@ -0,0 +1,417 @@
//! Filesystem scanning, independent of CLI parsing and presentation.
//! Allocation is the filesystem's reported 512-byte blocks, not a prediction
//! of bytes freed by deletion (snapshots and shared extents can differ).
use ignore::WalkBuilder;
use rayon::prelude::*;
use serde::Serialize;
use std::{
collections::HashSet,
fs::{self, Metadata},
io,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
};
#[derive(Clone, Copy, Serialize, Debug, PartialEq, Eq)]
pub struct FileIdentity {
pub device: u64,
pub inode: u64,
pub links: u64,
}
#[derive(Clone, Serialize, Debug)]
pub struct Node {
pub path: PathBuf,
pub size_bytes: u64,
pub blocks: u64,
pub size_bytes_filtered: u64,
pub blocks_filtered: u64,
pub entry_type: EntryType,
pub accessible: bool,
/// False when this node or any descendant could not be scanned.
pub complete: bool,
pub symlink: bool,
pub skipped_mount: bool,
pub hard_link_duplicate: bool,
pub identity: Option<FileIdentity>,
#[serde(skip)]
pub children: Vec<Node>,
#[serde(skip)]
pub own_size_bytes: u64,
#[serde(skip)]
pub own_blocks: u64,
#[serde(skip)]
included_by_filter: bool,
}
#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum EntryType {
File,
Dir,
Special,
Unknown,
}
impl std::fmt::Display for EntryType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
impl Node {
pub(crate) fn unavailable(path: &Path) -> Self {
Self {
path: path.into(),
size_bytes: 0,
blocks: 0,
size_bytes_filtered: 0,
blocks_filtered: 0,
entry_type: EntryType::Unknown,
accessible: false,
complete: false,
symlink: false,
skipped_mount: false,
hard_link_duplicate: false,
identity: None,
children: Vec::new(),
own_size_bytes: 0,
own_blocks: 0,
included_by_filter: true,
}
}
fn from_metadata(path: &Path, meta: &Metadata) -> Self {
let mut node = Self::unavailable(path);
node.entry_type = if meta.is_dir() {
EntryType::Dir
} else if meta.is_file() || meta.is_symlink() {
EntryType::File
} else {
EntryType::Special
};
node.accessible = true;
node.complete = true;
node.symlink = meta.is_symlink();
node.identity = Some(FileIdentity {
device: meta.dev(),
inode: meta.ino(),
links: meta.nlink(),
});
node.own_size_bytes = meta.len();
node.own_blocks = meta.blocks();
node
}
}
#[derive(Clone, Debug, Default)]
pub struct ScanOptions {
pub respect_ignore: bool,
pub compare_ignore: bool,
pub stay_on_filesystem: bool,
/// None or zero selects Rayon's default worker count, scoped to this scan.
pub threads: Option<usize>,
}
/// Create a fresh control for each scan; clones share cancellation and progress.
#[derive(Clone, Debug, Default)]
pub struct ScanControl {
cancelled: Arc<AtomicBool>,
visited: Arc<AtomicU64>,
}
impl ScanControl {
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
pub fn entries_scanned(&self) -> u64 {
self.visited.load(Ordering::Relaxed)
}
fn check(&self) -> Result<(), ScanError> {
if self.is_cancelled() {
Err(ScanError::Cancelled)
} else {
Ok(())
}
}
}
#[derive(Debug)]
pub struct ScanIssue {
pub path: PathBuf,
pub kind: io::ErrorKind,
pub message: String,
}
#[derive(Debug)]
pub struct ScanReport {
pub root: Node,
pub issues: Vec<ScanIssue>,
pub entries_scanned: u64,
}
#[derive(Debug)]
pub enum ScanError {
Root { path: PathBuf, source: io::Error },
WorkerPool(rayon::ThreadPoolBuildError),
Cancelled,
}
impl std::fmt::Display for ScanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Root { path, source } => {
write!(f, "cannot scan '{}': {}", path.display(), source)
}
Self::WorkerPool(e) => write!(f, "cannot start scan workers: {e}"),
Self::Cancelled => write!(f, "scan cancelled"),
}
}
}
impl std::error::Error for ScanError {}
/// The completed tree is published only after deterministic hard-link accounting.
/// Cancellation is cooperative between filesystem calls, not syscall preemption.
pub fn scan_tree(
path: &Path,
options: &ScanOptions,
control: &ScanControl,
) -> Result<ScanReport, ScanError> {
control.check()?;
let metadata = fs::symlink_metadata(path).map_err(|source| ScanError::Root {
path: path.into(),
source,
})?;
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(options.threads.unwrap_or(0))
.build()
.map_err(ScanError::WorkerPool)?;
let context = Context {
options,
control,
root_device: metadata.dev(),
issues: Mutex::new(Vec::new()),
};
let mut root = pool.install(|| context.visit(path, Some(metadata)))?;
control.check()?;
reduce(
&mut root,
true,
&mut HashSet::new(),
&mut HashSet::new(),
control,
)?;
control.check()?;
let mut issues = context.issues.into_inner().unwrap();
issues.sort_by(|a, b| a.path.cmp(&b.path).then(a.message.cmp(&b.message)));
Ok(ScanReport {
root,
issues,
entries_scanned: control.entries_scanned(),
})
}
struct Context<'a> {
options: &'a ScanOptions,
control: &'a ScanControl,
root_device: u64,
issues: Mutex<Vec<ScanIssue>>,
}
impl Context<'_> {
fn issue(&self, path: &Path, kind: io::ErrorKind, message: String) {
self.issues.lock().unwrap().push(ScanIssue {
path: path.into(),
kind,
message,
});
}
fn visit(&self, path: &Path, metadata: Option<Metadata>) -> Result<Node, ScanError> {
self.control.check()?;
self.control.visited.fetch_add(1, Ordering::Relaxed);
let metadata = match metadata
.map(Ok)
.unwrap_or_else(|| fs::symlink_metadata(path))
{
Ok(meta) => meta,
Err(e) => {
self.issue(path, e.kind(), e.to_string());
return Ok(Node::unavailable(path));
}
};
let mut node = Node::from_metadata(path, &metadata);
if self.options.stay_on_filesystem && metadata.dev() != self.root_device {
node.skipped_mount = true;
node.own_size_bytes = 0;
node.own_blocks = 0;
return Ok(node);
}
if node.entry_type != EntryType::Dir {
return Ok(node);
}
if let Err(e) = fs::read_dir(path) {
self.issue(path, e.kind(), e.to_string());
node.accessible = false;
node.complete = false;
return Ok(node);
}
let total_ignore = self.options.respect_ignore && !self.options.compare_ignore;
let (paths, total_complete) = self.paths(path, total_ignore)?;
let (filtered, filtered_complete) = if self.options.compare_ignore {
let (paths, complete) = self.paths(path, true)?;
(paths.into_iter().collect::<HashSet<_>>(), complete)
} else {
(HashSet::new(), true)
};
node.complete = total_complete && filtered_complete;
node.children = paths
.par_iter()
.map(|child| {
let mut node = self.visit(child, None)?;
node.included_by_filter = !self.options.compare_ignore || filtered.contains(child);
Ok(node)
})
.collect::<Result<Vec<_>, ScanError>>()?;
Ok(node)
}
fn paths(&self, path: &Path, respect_ignore: bool) -> Result<(Vec<PathBuf>, bool), ScanError> {
let walker = WalkBuilder::new(path)
.standard_filters(false)
.hidden(false)
.parents(respect_ignore)
.git_ignore(respect_ignore)
.ignore(respect_ignore)
.max_depth(Some(1))
.build();
let mut paths = Vec::new();
let mut complete = true;
for entry in walker {
self.control.check()?;
match entry {
Ok(entry) if entry.path() != path => paths.push(entry.into_path()),
Ok(_) => {}
Err(e) => {
complete = false;
self.issue(
path,
e.io_error().map_or(io::ErrorKind::Other, |e| e.kind()),
e.to_string(),
);
}
}
}
// Parallel collection preserves this order; ownership of a shared inode
// is then stable across worker counts and repeated scans.
paths.sort();
Ok((paths, complete))
}
}
fn reduce(
node: &mut Node,
included: bool,
all_seen: &mut HashSet<(u64, u64)>,
filtered_seen: &mut HashSet<(u64, u64)>,
control: &ScanControl,
) -> Result<(), ScanError> {
control.check()?;
node.size_bytes = node.own_size_bytes;
node.blocks = node.own_blocks;
node.size_bytes_filtered = if included { node.own_size_bytes } else { 0 };
node.blocks_filtered = if included { node.own_blocks } else { 0 };
if node.entry_type != EntryType::Dir {
if let Some(id) = node.identity.filter(|id| id.links > 1) {
let key = (id.device, id.inode);
if !all_seen.insert(key) {
node.blocks = 0;
node.hard_link_duplicate = true;
}
if included && !filtered_seen.insert(key) {
node.blocks_filtered = 0;
}
}
}
for child in &mut node.children {
reduce(
child,
included && child.included_by_filter,
all_seen,
filtered_seen,
control,
)?;
node.size_bytes += child.size_bytes;
node.blocks += child.blocks;
node.size_bytes_filtered += child.size_bytes_filtered;
node.blocks_filtered += child.blocks_filtered;
node.complete &= child.complete;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn foreign_device_entry_is_marked_without_scanning_its_contents() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("content"), b"not visited").unwrap();
let meta = fs::symlink_metadata(dir.path()).unwrap();
let control = ScanControl::default();
let options = ScanOptions {
stay_on_filesystem: true,
..ScanOptions::default()
};
// Inject the parent scan's different device; this exercises the boundary
// policy without creating mounts or requiring privileged test setup.
let context = Context {
options: &options,
control: &control,
root_device: meta.dev().wrapping_add(1),
issues: Mutex::new(Vec::new()),
};
let mut node = context.visit(dir.path(), Some(meta)).unwrap();
reduce(
&mut node,
true,
&mut HashSet::new(),
&mut HashSet::new(),
&control,
)
.unwrap();
assert!(node.skipped_mount);
assert!(node.children.is_empty());
assert_eq!(node.blocks, 0);
assert_eq!(control.entries_scanned(), 1);
assert!(node.complete);
}
#[test]
fn vanished_child_is_reported_without_fabricating_accessibility() {
let dir = tempdir().unwrap();
let path = dir.path().join("vanished-after-discovery");
let options = ScanOptions::default();
let control = ScanControl::default();
let context = Context {
options: &options,
control: &control,
root_device: fs::metadata(dir.path()).unwrap().dev(),
issues: Mutex::new(Vec::new()),
};
let node = context.visit(&path, None).unwrap();
assert!(!node.accessible);
assert!(!node.complete);
let issues = context.issues.into_inner().unwrap();
assert_eq!(issues[0].path, path);
assert_eq!(issues[0].kind, io::ErrorKind::NotFound);
}
}
+52
View File
@@ -45,3 +45,55 @@ fn test_json_output() {
.stdout(predicate::str::contains("\"entry_type\":\"File\"")) .stdout(predicate::str::contains("\"entry_type\":\"File\""))
.stdout(predicate::str::contains("\"path\":")); .stdout(predicate::str::contains("\"path\":"));
} }
#[test]
fn missing_path_fails_without_a_success_report() {
let dir = TempDir::new().unwrap();
Command::new(env!("CARGO_BIN_EXE_sized"))
.arg(dir.path().join("missing"))
.args(["--format", "json"])
.assert()
.failure()
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains("cannot scan"));
}
#[cfg(unix)]
#[test]
fn partial_scan_has_nonzero_status_and_explicit_json_flag() {
use std::{fs, os::unix::fs::PermissionsExt};
let dir = TempDir::new().unwrap();
let blocked = dir.path().join("blocked");
fs::create_dir(&blocked).unwrap();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();
let result = std::panic::catch_unwind(|| {
assert!(
fs::read_dir(&blocked).is_err(),
"permission tests require an unprivileged user"
);
Command::new(env!("CARGO_BIN_EXE_sized"))
.arg(dir.path())
.args(["--format", "json"])
.assert()
.failure()
.stdout(predicate::str::contains("\"complete\":false"))
.stderr(predicate::str::contains("incomplete scan"));
});
fs::set_permissions(blocked, fs::Permissions::from_mode(0o700)).unwrap();
result.unwrap();
}
#[cfg(unix)]
#[test]
fn dangling_symlink_is_a_valid_scan_target() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let path = dir.path().join("dangling");
symlink(dir.path().join("missing"), &path).unwrap();
Command::new(env!("CARGO_BIN_EXE_sized"))
.arg(path)
.args(["--format", "json"])
.assert()
.success()
.stdout(predicate::str::contains("\"complete\":true"));
}
+96
View File
@@ -0,0 +1,96 @@
#![cfg(target_os = "linux")]
use sized::{scan_tree, ScanControl, ScanOptions};
use std::{fs, io::Write, os::unix::fs::MetadataExt, path::PathBuf, process::Command};
use tempfile::NamedTempFile;
#[test]
#[ignore = "requires the two owned tmpfs mounts from scripts/check-linux.sh"]
fn real_mount_boundary_is_respected_by_scanner_and_cli() {
let root = PathBuf::from(
std::env::var_os("SIZED_TEST_MOUNT_ROOT").expect("missing mounted Linux fixture"),
);
let foreign = root.join("foreign");
let root_metadata = fs::metadata(&root).unwrap();
let foreign_metadata = fs::metadata(&foreign).unwrap();
assert_ne!(
root_metadata.dev(),
foreign_metadata.dev(),
"not a real boundary"
);
let mut local_file = NamedTempFile::new_in(&root).unwrap();
let mut mounted_file = NamedTempFile::new_in(&foreign).unwrap();
local_file.write_all(b"local allocation").unwrap();
mounted_file.write_all(&[7; 8192]).unwrap();
local_file.as_file().sync_all().unwrap();
mounted_file.as_file().sync_all().unwrap();
for bounded in [false, true] {
let report = scan_tree(
&root,
&ScanOptions {
stay_on_filesystem: bounded,
threads: Some(2),
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
assert!(report.root.complete);
assert!(report.issues.is_empty());
let mount = report
.root
.children
.iter()
.find(|n| n.path == foreign)
.unwrap();
assert_eq!(mount.identity.unwrap().device, foreign_metadata.dev());
assert_eq!(mount.skipped_mount, bounded);
assert_eq!(report.entries_scanned, if bounded { 3 } else { 4 });
let expected_blocks =
root_metadata.blocks() + local_file.as_file().metadata().unwrap().blocks();
if bounded {
assert!(mount.children.is_empty());
assert_eq!(mount.blocks, 0);
assert_eq!(mount.size_bytes, 0);
assert_eq!(report.root.blocks, expected_blocks);
} else {
assert_eq!(mount.children.len(), 1);
assert_eq!(mount.children[0].path, mounted_file.path());
assert_eq!(mount.children[0].size_bytes, 8192);
assert_eq!(
report.root.blocks,
expected_blocks
+ foreign_metadata.blocks()
+ mounted_file.as_file().metadata().unwrap().blocks()
);
}
let mut command = Command::new(env!("CARGO_BIN_EXE_sized"));
command
.arg(&root)
.args(["--format", "json", "--apparent", "--threads", "2"]);
if bounded {
command.arg("--one-file-system");
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stderr.is_empty());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["complete"], true);
assert_eq!(json["total_blocks"], report.root.blocks);
let mount_json = json["entries"]
.as_array()
.unwrap()
.iter()
.find(|entry| entry["path"] == foreign.to_str().unwrap())
.unwrap();
assert_eq!(mount_json["skipped_mount"], bounded);
assert_eq!(mount_json["blocks"], mount.blocks);
assert_eq!(mount_json["apparent_size"], mount.size_bytes);
}
}
+254
View File
@@ -0,0 +1,254 @@
#![cfg(unix)]
use sized::{build_tree, scan_tree, EntryType, ScanControl, ScanError, ScanOptions};
use std::{
fs,
io::Write,
os::unix::fs::{symlink, MetadataExt, PermissionsExt},
};
use tempfile::tempdir;
fn scan(path: &std::path::Path) -> sized::ScanReport {
scan_tree(path, &ScanOptions::default(), &ScanControl::default()).unwrap()
}
#[test]
fn hard_links_count_allocation_once_but_keep_apparent_sizes() {
let dir = tempdir().unwrap();
let original = dir.path().join("a-original");
fs::write(&original, [9; 65536]).unwrap();
fs::hard_link(&original, dir.path().join("z-alias")).unwrap();
let expected =
fs::metadata(&original).unwrap().blocks() + fs::metadata(dir.path()).unwrap().blocks();
for threads in [1, 4] {
let report = scan_tree(
dir.path(),
&ScanOptions {
threads: Some(threads),
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
assert_eq!(report.root.blocks, expected);
assert_eq!(report.root.children[0].size_bytes, 65536);
assert_eq!(report.root.children[1].size_bytes, 65536);
assert!(!report.root.children[0].hard_link_duplicate);
assert!(report.root.children[1].hard_link_duplicate);
assert_eq!(report.root.children[1].blocks, 0);
assert_eq!(report.entries_scanned, 3);
assert!(report.root.complete);
}
}
#[test]
fn ignored_hard_link_does_not_steal_filtered_allocation() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".ignore"), "a-ignored\n").unwrap();
let original = dir.path().join("a-ignored");
fs::write(&original, [9; 65536]).unwrap();
fs::hard_link(&original, dir.path().join("z-included")).unwrap();
let report = scan_tree(
dir.path(),
&ScanOptions {
compare_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
let expected = fs::metadata(&original).unwrap().blocks()
+ fs::metadata(dir.path()).unwrap().blocks()
+ fs::metadata(dir.path().join(".ignore")).unwrap().blocks();
assert_eq!(report.root.blocks, expected);
assert_eq!(report.root.blocks_filtered, expected);
assert_eq!(report.root.children.last().unwrap().blocks, 0);
assert!(report.root.children.last().unwrap().blocks_filtered > 0);
}
#[test]
fn hidden_files_are_counted_without_ignore_filtering() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".hidden"), b"hidden").unwrap();
fs::write(dir.path().join(".ignore"), "build\n").unwrap();
fs::write(dir.path().join("build"), b"build").unwrap();
assert_eq!(scan(dir.path()).root.children.len(), 3);
let report = scan_tree(
dir.path(),
&ScanOptions {
respect_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
assert_eq!(report.root.children.len(), 2);
assert!(report
.root
.children
.iter()
.any(|n| n.path.ends_with(".hidden")));
}
#[test]
fn ignore_rules_are_inherited_by_nested_directories() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".ignore"), "*.tmp\n").unwrap();
let nested = dir.path().join("nested");
fs::create_dir(&nested).unwrap();
fs::write(nested.join("skip.tmp"), b"ignored").unwrap();
fs::write(nested.join("keep.bin"), b"included").unwrap();
let report = scan_tree(
dir.path(),
&ScanOptions {
respect_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
let children = &report
.root
.children
.iter()
.find(|n| n.path == nested)
.unwrap()
.children;
assert_eq!(children.len(), 1);
assert!(children[0].path.ends_with("keep.bin"));
let compared = scan_tree(
dir.path(),
&ScanOptions {
compare_ignore: true,
..ScanOptions::default()
},
&ScanControl::default(),
)
.unwrap();
assert_eq!(compared.root.size_bytes_filtered, report.root.size_bytes);
assert_eq!(compared.root.blocks_filtered, report.root.blocks);
}
#[test]
fn sparse_file_reports_blocks_and_logical_length_separately() {
let dir = tempdir().unwrap();
let path = dir.path().join("sparse");
let mut file = fs::File::create(&path).unwrap();
file.write_all(&[7; 4096]).unwrap();
file.set_len(128 * 1024 * 1024).unwrap();
file.sync_all().unwrap();
let node = scan(&path).root;
assert_eq!(node.size_bytes, 128 * 1024 * 1024);
assert_eq!(node.blocks, fs::metadata(path).unwrap().blocks());
assert!(node.blocks * 512 < node.size_bytes);
}
#[test]
fn symlinks_including_loop_and_dangling_link_are_leaves() {
let dir = tempdir().unwrap();
symlink(dir.path(), dir.path().join("loop")).unwrap();
symlink(dir.path().join("missing"), dir.path().join("dangling")).unwrap();
let report = scan(dir.path());
assert_eq!(report.root.children.len(), 2);
for node in report.root.children {
assert!(node.symlink);
assert!(node.children.is_empty());
assert!(node.complete);
}
}
#[test]
fn missing_root_returns_error_and_legacy_helper_marks_unavailable() {
let dir = tempdir().unwrap();
let path = dir.path().join("vanished");
assert!(
matches!(scan_tree(&path, &ScanOptions::default(), &ScanControl::default()),
Err(ScanError::Root { source, .. }) if source.kind() == std::io::ErrorKind::NotFound)
);
let node = build_tree(&path, false, false);
assert_eq!(node.entry_type, EntryType::Unknown);
assert!(!node.accessible);
assert!(!node.complete);
}
#[test]
fn permission_error_propagates_incomplete_status_to_root() {
let dir = tempdir().unwrap();
let blocked = dir.path().join("blocked");
fs::create_dir(&blocked).unwrap();
fs::write(blocked.join("unreadable"), b"data").unwrap();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();
let result = std::panic::catch_unwind(|| {
assert!(
fs::read_dir(&blocked).is_err(),
"permission tests require an unprivileged user"
);
let report = scan(dir.path());
assert!(report.root.accessible);
assert!(!report.root.complete);
assert!(!report.root.children[0].accessible);
assert_eq!(report.issues.len(), 1);
assert_eq!(report.issues[0].path, blocked);
assert_eq!(report.issues[0].kind, std::io::ErrorKind::PermissionDenied);
});
fs::set_permissions(blocked, fs::Permissions::from_mode(0o700)).unwrap();
result.unwrap();
}
#[test]
fn cancelled_scan_never_returns_completed_tree() {
let dir = tempdir().unwrap();
let control = ScanControl::default();
control.cancel();
assert!(matches!(
scan_tree(dir.path(), &ScanOptions::default(), &control),
Err(ScanError::Cancelled)
));
assert_eq!(control.entries_scanned(), 0);
}
#[test]
fn cancellation_and_progress_work_during_scan() {
let dir = tempdir().unwrap();
for i in 0..2000 {
fs::write(dir.path().join(format!("file-{i}")), b"x").unwrap();
}
let control = ScanControl::default();
let watcher = control.clone();
let cancel = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while watcher.entries_scanned() < 10 {
assert!(
std::time::Instant::now() < deadline,
"scan made no progress"
);
std::thread::yield_now();
}
watcher.cancel();
});
let result = scan_tree(
dir.path(),
&ScanOptions {
threads: Some(1),
..ScanOptions::default()
},
&control,
);
cancel.join().unwrap();
assert!(matches!(result, Err(ScanError::Cancelled)));
assert!(control.entries_scanned() >= 10);
}
#[test]
fn special_file_is_not_treated_as_directory() {
let dir = tempdir().unwrap();
let path = dir.path().join("fifo");
assert!(std::process::Command::new("mkfifo")
.arg(&path)
.status()
.unwrap()
.success());
let node = scan(&path).root;
assert_eq!(node.entry_type, EntryType::Special);
assert!(node.children.is_empty());
assert!(node.complete);
}