Correct scanner accounting and verify release packaging #2
@@ -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
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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`:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
Executable
+32
@@ -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'
|
||||||
Executable
+27
@@ -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"
|
||||||
Executable
+39
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user