10 Commits

Author SHA1 Message Date
4b1c3cbf70 Make scan paths required operands; add -x/--one-file-system
All checks were successful
check / check (push) Successful in 4s
scan now takes one or more PATH operands (directories or regular
files) via cobra flags instead of the -root flag with its /srv
default; invoking scan with no operand is a usage error and a
nonexistent operand is fatal. Filesystem boundaries are crossed by
default; the new -x/--one-file-system flag (GNU du/rsync convention)
stops the walk at each operand's filesystem, implemented by comparing
lstat device IDs with build-tagged helpers for darwin's int32 Dev.
Verified against a real mounted disk image: default crosses, -x does
not, --one-file-system is identical to -x.
2026-07-23 08:55:17 +07:00
3391207f8d Check off completed repo policy compliance items in TODO.md 2026-07-23 07:59:12 +07:00
5b20171dbd Install pre-commit hook via make hooks; resolve shared hooks dir
The hooks target uses git rev-parse --git-common-dir so it works from
both the main checkout and linked worktrees.
2026-07-23 06:45:02 +07:00
375233fff4 Restructure README with required policy sections
Adds Description (name/purpose/category/license/author), Getting
Started, Rationale, TODO, License, and Author sections; the full
normative specification is preserved under Design. The stale non-goal
about having no git repository or CI is removed, and the Build section
now documents the Makefile targets.
2026-07-23 06:44:32 +07:00
b4d013cb34 Add hash-pinned Dockerfile, .dockerignore, and Gitea CI workflow
Multistage build per policy: a fail-fast lint stage on the pinned
golangci-lint image runs fmt-check and lint, the builder stage reuses
its linter binary (which also forces stage ordering), runs make check,
and builds; the runtime stage is pinned alpine with just the binary.
CI runs docker build . on push with the checkout action pinned by
commit SHA. All image references pinned by sha256 digest with
version/date comments.
2026-07-23 06:42:11 +07:00
7a6adae0d2 Add test suite covering parsing, grouping, digests, and scan passes
Covers splitNUL framing, parseScanStream (malformed records, tabs and
newlines in paths, unterminated trailing record), duplicate grouping
with ordering/tie-break/determinism, humanBytes units, Merkle digest
equality and name/content sensitivity, maximal-tree suppression
(including sibling and differing-parent cases), hashHeadTail edge
sizes and error paths, walkPass .zfs/symlink skipping, statPass
vanished files, and an end-to-end walk/stat/hash pipeline test of the
README smoke-test scenario. 64% statement coverage.
2026-07-23 05:42:38 +07:00
316ea9072d Rewrite Makefile with required policy targets
test/lint/fmt/fmt-check/check/docker/hooks plus build, all, and clean.
make check no longer builds the binary (it must not modify files); the
test target uses the 30s-timeout conditional -v rerun pattern. Version
is injected via -ldflags and exposed as sfdupes --version.
2026-07-23 05:37:29 +07:00
065a533224 Make code lint-clean under the canonical golangci-lint config
150 findings fixed: linter autofixes for whitespace style (wsl_v5,
nlreturn, noinlineerr), named returns removed, magic numbers replaced
with named constants, static errNotRegular error, explicit Close/Parse
error handling, unused cobra params renamed to _, and runReport/
runTrees/hashPass split into helpers to satisfy cyclop, gocognit, and
funlen. Behavior verified unchanged against the README smoke test.
2026-07-23 05:36:05 +07:00
733b33d9d1 Add canonical .golangci.yml 2026-07-22 22:24:04 +07:00
2512ae797b Add REPO_POLICIES.md from canonical source (2026-07-06) 2026-07-22 22:24:04 +07:00
18 changed files with 1933 additions and 265 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.claude
.DS_Store
sfdupes
files.dat
*.log
*.out
*.test

View File

@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .

32
.golangci.yml Normal file
View File

@@ -0,0 +1,32 @@
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.12.1, 2026-07-23
FROM golangci/golangci-lint@sha256:c9843d374ca80ecbac86081ec4dd7fe2bb6187b03224f59a0cc2f80759e1845b AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.25-alpine, 2026-07-23
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
RUN apk add --no-cache make
WORKDIR /src
# Reuse the linter binary from the lint stage; the copy also forces
# BuildKit to complete linting before this stage proceeds.
COPY --from=lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Fail the build unless the branch is green.
RUN make check
RUN make build
# Runtime stage
# alpine:3.22, 2026-07-23
FROM alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
COPY --from=builder /src/sfdupes /usr/local/bin/sfdupes
ENTRYPOINT ["sfdupes"]

View File

@@ -2,16 +2,47 @@
# toolchain. # toolchain.
export CGO_ENABLED = 0 export CGO_ENABLED = 0
.PHONY: all build check clean BINARY := sfdupes
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X main.Version=$(VERSION)
all: build .PHONY: all build test lint fmt fmt-check check docker hooks clean
all: check build
build: build:
go build go build -ldflags "$(LDFLAGS)" -o $(BINARY)
check: build test:
test -z "$$(gofmt -l .)" @go test -timeout 30s -cover ./... || \
go vet ./... { echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -v ./...; exit 1; }
lint:
golangci-lint run --config .golangci.yml ./...
fmt:
gofmt -s -w .
fmt-check:
@files="$$(gofmt -l -s .)"; if [ -n "$$files" ]; then \
echo "gofmt: files not formatted:"; echo "$$files"; exit 1; fi
check: test lint fmt-check
docker:
docker build -t $(BINARY) .
# Hooks are shared between the main checkout and all worktrees, so
# resolve the common git dir instead of assuming .git is a directory.
HOOKS_DIR := $(shell git rev-parse --git-common-dir)/hooks
hooks:
@printf '#!/bin/sh\nset -e\n' > $(HOOKS_DIR)/pre-commit
@printf 'go mod tidy\ngo fmt ./...\n' >> $(HOOKS_DIR)/pre-commit
@printf 'git diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> $(HOOKS_DIR)/pre-commit
@printf 'make check\n' >> $(HOOKS_DIR)/pre-commit
@chmod +x $(HOOKS_DIR)/pre-commit
clean: clean:
rm -f sfdupes files.dat rm -f $(BINARY) files.dat

176
README.md
View File

@@ -1,18 +1,52 @@
# sfdupes # sfdupes
`sfdupes` quickly identifies *candidate* duplicate files — and, ultimately, ## Description
entire duplicate directory trees — across very large filesystems without
reading full file contents. Files are considered duplicates when they have `sfdupes` is an MIT-licensed Go CLI tool by
identical size, identical SHA-256 of their first 1024 bytes, and identical [@sneak](https://sneak.berlin) that quickly identifies *candidate*
SHA-256 of their last 1024 bytes. This is a strong candidate signal, not duplicate files — and, ultimately, entire duplicate directory trees —
proof of identical content (the middle of the file is never read); the across very large filesystems without reading full file contents. Files
intended use is finding duplicate downloads and duplicated directory trees are considered duplicates when they have identical size, identical
on multi-terabyte ZFS servers where reading every byte is prohibitively SHA-256 of their first 1024 bytes, and identical SHA-256 of their last
1024 bytes. This is a strong candidate signal, not proof of identical
content (the middle of the file is never read); the intended use is
finding duplicate downloads and duplicated directory trees on
multi-terabyte ZFS servers where reading every byte is prohibitively
expensive. expensive.
This README is the complete and authoritative specification. This README is the complete and authoritative specification.
## Design goals ## Getting Started
```sh
make build
./sfdupes scan /srv > files.dat
./sfdupes report files.dat > dupes.tsv
./sfdupes trees files.dat > dupetrees.tsv
```
`scan` walks one or more filesystem trees and emits one record per
regular file (path, size, mtime, head hash, tail hash). `report`
ingests that stream and prints the file-level duplicates report.
`trees` ingests the same stream and prints the duplicate-tree report. A
missing/invalid subcommand — or a `scan` invocation with no `PATH`
operand — prints a usage message and exits 2.
## Rationale
Duplicate finders that hash entire files do not scale to the target
environment: ~10 million files and ~150 TB on possibly slow or busy
disks (a ZFS pool under resilver). Reading at most 2 KiB per file makes
a full-filesystem sweep tractable, and the resulting scan stream is
self-contained, so the expensive filesystem pass runs exactly once and
all analysis happens offline. The end goal is not individual files but
whole duplicated trees — duplicate extractions, duplicate downloads,
copied project trees — which an operator can consider removing as a
unit.
## Design
Goals, in order:
1. **Find whole duplicate trees, not just files.** The end goal is to 1. **Find whole duplicate trees, not just files.** The end goal is to
identify places where the exact same set of files and directories identify places where the exact same set of files and directories
@@ -33,10 +67,10 @@ This README is the complete and authoritative specification.
data. All progress, warnings, and summaries go to stderr. Never mix data. All progress, warnings, and summaries go to stderr. Never mix
them. them.
## Constraints ### Constraints
- Language: Go (module `sneak.berlin/go/sfdupes`, already initialized - Language: Go (module `sneak.berlin/go/sfdupes`). Binary name:
in `go.mod`). Binary name: `sfdupes`. `sfdupes`.
- Dependencies: standard library, `github.com/spf13/cobra` for the CLI, - Dependencies: standard library, `github.com/spf13/cobra` for the CLI,
and **one progress-bar library** and **one progress-bar library**
(`github.com/schollz/progressbar/v3`). `github.com/spf13/viper` is (`github.com/schollz/progressbar/v3`). `github.com/spf13/viper` is
@@ -47,43 +81,38 @@ This README is the complete and authoritative specification.
- Analysis modes (`report`, `trees`) must be deterministic: identical - Analysis modes (`report`, `trees`) must be deterministic: identical
input stream, identical output, regardless of record order. input stream, identical output, regardless of record order.
## Plan ### Subcommands
Three subcommands, built in this order: Three subcommands, all implemented:
1. `scan` — walk the filesystem and emit one signature record per 1. `scan` — walk the filesystem and emit one signature record per
regular file (implemented). regular file.
2. `report` — file-level duplicate report from the scan stream 2. `report` — file-level duplicate report from the scan stream.
(implemented).
3. `trees` — tree-level duplicate report: reconstruct the directory 3. `trees` — tree-level duplicate report: reconstruct the directory
hierarchy from the scan stream, compute a Merkle-style digest per hierarchy from the scan stream, compute a Merkle-style digest per
directory, and report maximal groups of identical trees directory, and report maximal groups of identical trees.
(implemented).
Possible later work, explicitly out of scope for now: full-content
verification of candidates, and helpers that emit removal scripts.
## Usage
``` ```
sfdupes scan [-root /srv] [-workers N] > files.dat sfdupes scan [--workers N] [-x] PATH... > files.dat
sfdupes report [files.dat|-] > dupes.tsv sfdupes report [files.dat|-] > dupes.tsv
sfdupes trees [files.dat|-] > dupetrees.tsv sfdupes trees [files.dat|-] > dupetrees.tsv
``` ```
`scan` walks a filesystem tree and emits one record per regular file ### `scan` mode
(path, size, mtime, head hash, tail hash). `report` ingests that stream
and prints the file-level duplicates report. `trees` ingests the same
stream and prints the duplicate-tree report. A missing/invalid subcommand
prints a usage message and exits 2.
## `scan` mode `scan` requires one or more `PATH` operands naming the trees to scan.
There is no default path; invoking `scan` with no operand is a usage
error (usage message on stderr, exit 2). An operand may be a directory
or a regular file; an operand that does not exist is a fatal error
(exit 1). Operands are walked in the order given; overlapping operands
(one containing another) emit their common files once per operand, so
callers should pass disjoint paths.
`scan` runs **three sequential passes**, in this order, so that every `scan` runs **three sequential passes**, in this order, so that every
expensive pass has an exact total for meaningful progress and ETA: expensive pass has an exact total for meaningful progress and ETA:
1. **walk** — recursively enumerate the tree under `-root` (default 1. **walk** — recursively enumerate the tree under each `PATH` in
`/srv`), collecting the list of regular-file paths. Total unknown turn, collecting the list of regular-file paths. Total unknown
while running: show a live count, not a percentage. while running: show a live count, not a percentage.
2. **stat**`lstat` every collected path, recording size and mtime. 2. **stat**`lstat` every collected path, recording size and mtime.
3. **hash** — for each file, read the first `min(1024, size)` bytes and 3. **hash** — for each file, read the first `min(1024, size)` bytes and
@@ -93,20 +122,25 @@ expensive pass has an exact total for meaningful progress and ETA:
Rules for the walk: Rules for the walk:
- Only regular files. Skip directories, symlinks (do not follow), - Only regular files. Skip directories, symlinks (do not follow,
sockets, FIFOs, and device nodes. including symlink operands), sockets, FIFOs, and device nodes.
- Never descend into a directory named `.zfs` (ZFS snapshot pseudo-dirs; - Never descend into a directory named `.zfs` (ZFS snapshot pseudo-dirs;
walking them would list every file once per snapshot). walking them would list every file once per snapshot).
- Filesystem boundaries are crossed by default. With `-x`
(long form `--one-file-system`, following the GNU `du`/`rsync`
convention), never descend into a directory on a different
filesystem than its `PATH` operand; each operand is bounded by its
own filesystem.
- On any per-path error (permission denied, file vanished between - On any per-path error (permission denied, file vanished between
passes, unreadable): print a one-line warning to stderr, skip the passes, unreadable): print a one-line warning to stderr, skip the
path, and continue. Per-file errors never abort the run; the final path, and continue. Per-file errors never abort the run; the final
summary reports how many were skipped. summary reports how many were skipped.
Concurrency: the stat and hash passes use a worker pool (`-workers`, Concurrency: the stat and hash passes use a worker pool (`--workers`,
default `runtime.NumCPU()`). The main goroutine owns stdout writing and default `runtime.NumCPU()`). The main goroutine owns stdout writing and
progress rendering; progress display must never block the workers. progress rendering; progress display must never block the workers.
### Output record format #### Output record format
One record per file on stdout, NUL-terminated (`\x00`), with One record per file on stdout, NUL-terminated (`\x00`), with
tab-separated fields, **path last** so tabs or newlines embedded in tab-separated fields, **path last** so tabs or newlines embedded in
@@ -123,7 +157,7 @@ paths cannot corrupt the record structure:
- Record order is unspecified (workers complete out of order); the - Record order is unspecified (workers complete out of order); the
analysis modes must not depend on ordering. analysis modes must not depend on ordering.
## `report` mode ### `report` mode
`report` reads the scan stream from the file named in its first `report` reads the scan stream from the file named in its first
positional argument, or from stdin if the argument is absent or `-`. positional argument, or from stdin if the argument is absent or `-`.
@@ -147,7 +181,7 @@ Processing:
tie-broken by `first` path ascending. Output must be fully tie-broken by `first` path ascending. Output must be fully
deterministic for a given input. deterministic for a given input.
### Report output format #### Report output format
TSV on stdout: a header line, then one row per duplicate file (N-1 rows TSV on stdout: a header line, then one row per duplicate file (N-1 rows
for a group of N): for a group of N):
@@ -162,7 +196,7 @@ Summary to stderr: records read, malformed count (if any), number of
duplicate groups, number of dupe files, and total reclaimable bytes duplicate groups, number of dupe files, and total reclaimable bytes
(sum of `size` over all dupe rows) in human units. (sum of `size` over all dupe rows) in human units.
## `trees` mode ### `trees` mode
`trees` reads the same scan stream as `report` (same argument handling, `trees` reads the same scan stream as `report` (same argument handling,
same parsing and malformed-record rules) and reports **entire duplicate same parsing and malformed-record rules) and reports **entire duplicate
@@ -209,7 +243,7 @@ Processing:
path ascending. Output must be fully deterministic for a given path ascending. Output must be fully deterministic for a given
input. input.
### Trees output format #### Trees output format
TSV on stdout: a header line, then one row per duplicate tree (N-1 rows TSV on stdout: a header line, then one row per duplicate tree (N-1 rows
for a group of N). `files` is the recursive regular-file count of one for a group of N). `files` is the recursive regular-file count of one
@@ -224,7 +258,7 @@ Summary to stderr: records read, malformed count (if any), number of
duplicate-tree groups, number of dupe trees, and total reclaimable bytes duplicate-tree groups, number of dupe trees, and total reclaimable bytes
(sum of `size` over all dupe rows) in human units. (sum of `size` over all dupe rows) in human units.
## Progress ### Progress
Use the progress-bar library for all scan-pass progress; rendering in the Use the progress-bar library for all scan-pass progress; rendering in the
style of `pv` is the model. All progress goes to stderr. style of `pv` is the model. All progress goes to stderr.
@@ -255,27 +289,36 @@ Additional requirements:
- `report` and `trees` modes need no progress display, only their - `report` and `trees` modes need no progress display, only their
stderr summaries. stderr summaries.
## Error handling and exit codes ### Error handling and exit codes
- `0`: success, even if individual files were skipped with warnings. - `0`: success, even if individual files were skipped with warnings.
- `1`: fatal error (e.g., `-root` does not exist, cannot read the scan - `1`: fatal error (e.g., a `PATH` operand does not exist, cannot
input, stdout write failure). read the scan input, stdout write failure).
- `2`: usage error. - `2`: usage error (including `scan` with no `PATH` operand).
## Build ## Build
`make` (or `make build`) builds the binary with cgo disabled. `make The `Makefile` is the single source of truth for all operations:
check` builds and runs `gofmt` and `go vet`. `make clean` removes the
binary and any local `files.dat`.
## Definition of done - `make build` — build the `sfdupes` binary (cgo disabled).
- `make test` — run the test suite (30-second timeout; reruns with
`-v` on failure).
- `make lint` — run `golangci-lint` with the repo config.
- `make fmt` / `make fmt-check` — format Go sources / verify
formatting without writing.
- `make check``test`, `lint`, and `fmt-check`; modifies nothing.
- `make docker` — build the Docker image, which runs `make check` as
a build stage.
- `make hooks` — install the pre-commit hook.
- `make clean` — remove the binary and any local `files.dat`.
### Definition of done
All of the following, run in this directory, must pass: All of the following, run in this directory, must pass:
1. `gofmt -l .` prints nothing. 1. `make check` passes (tests, lint, `gofmt`).
2. `go vet ./...` is clean. 2. `make docker` succeeds.
3. `go build` succeeds (equivalently, `make check` passes). 3. Smoke test — create a throwaway tree in a temp dir (never test
4. Smoke test — create a throwaway tree in a temp dir (never test
against real data): against real data):
```sh ```sh
@@ -296,7 +339,7 @@ All of the following, run in this directory, must pass:
cp "$d/t1/sub/f2" "$d/t2/sub/f2" cp "$d/t1/sub/f2" "$d/t2/sub/f2"
cp "$d/t1/f1" "$d/t3/f1" cp "$d/t1/f1" "$d/t3/f1"
cp "$d/t1/sub/f2" "$d/t3/sub/f2renamed" cp "$d/t1/sub/f2" "$d/t3/sub/f2renamed"
./sfdupes scan -root "$d" > files.dat ./sfdupes scan "$d" > files.dat
./sfdupes report files.dat ./sfdupes report files.dat
./sfdupes trees files.dat ./sfdupes trees files.dat
``` ```
@@ -307,7 +350,7 @@ All of the following, run in this directory, must pass:
`t2/sub/f2`/`t3/sub/f2renamed` form one group; `tiny1`/`tiny2` pair; `t2/sub/f2`/`t3/sub/f2renamed` form one group; `tiny1`/`tiny2` pair;
`empty1`/`empty2` pair; `unique.bin` and `tiny3` appear nowhere; `empty1`/`empty2` pair; `unique.bin` and `tiny3` appear nowhere;
groups ordered by size descending; piping scan directly into report groups ordered by size descending; piping scan directly into report
(`./sfdupes scan -root "$d" | ./sfdupes report`) gives the same (`./sfdupes scan "$d" | ./sfdupes report`) gives the same
rows. rows.
Expected from `trees`: exactly one row — `first` `$d/t1`, `dupe` Expected from `trees`: exactly one row — `first` `$d/t1`, `dupe`
@@ -315,9 +358,13 @@ All of the following, run in this directory, must pass:
suppressed as non-maximal (implied by the `t1`/`t2` group), and `t3` suppressed as non-maximal (implied by the `t1`/`t2` group), and `t3`
appears nowhere (its file set differs by name). appears nowhere (its file set differs by name).
5. A negative check: run `report` and `trees` after deleting the temp The test suite automates this scenario (see `scan_test.go`), plus a
tree — output must be unchanged (proves the analysis modes never negative check: `report` and `trees` operate on the captured stream
touch the filesystem). alone and never touch the scanned filesystem.
## TODO
Tracked in [TODO.md](TODO.md).
## Non-goals ## Non-goals
@@ -325,6 +372,11 @@ All of the following, run in this directory, must pass:
or linking of duplicates. The reports are advisory; acting on them is or linking of duplicates. The reports are advisory; acting on them is
the user's job. the user's job.
- No persistence formats beyond the scan stream described above. - No persistence formats beyond the scan stream described above.
- No git repository setup and no CI — code, `go.mod`/`go.sum`, the
`Makefile`, and this README only. Do not write anything outside this ## License
directory (module cache aside).
MIT. See [LICENSE](LICENSE).
## Author
[@sneak](https://sneak.berlin)

368
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,368 @@
---
title: Repository Policies
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- **Claude Code repo memory is versioned in the repo**, not left only in
`~/.claude` on one machine. Each memory is one file at
`.claude/memory/<memory>.md`, and every memory file must be `@`-imported
from `.claude/CLAUDE.md` (one `- @memory/<memory>.md` list line per file;
relative import paths resolve against `.claude/`, and Claude Code expands
the imports into context at session launch). When adding a memory, add both
the file and its import line. A root `MEMORY.md` is a violation — Claude
Code never auto-loads it; split it into `.claude/memory/` files. Repos with
no memories yet need no `.claude/` scaffolding.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

41
TODO.md
View File

@@ -14,18 +14,22 @@
# Next Step # Next Step
- bring the repo into full policy compliance (work the Repo Policy - add a remote on git.eeqj.de and push (`main` plus tags)
Compliance checklist below)
# Completed Steps # Completed Steps
- `scan` CLI rework (2026-07-23, branch `scan-required-paths`): required
`PATH...` operands via cobra flags replacing the `/srv` `-root`
default; new `-x`/`--one-file-system` flag (GNU convention) to stop
at filesystem boundaries, which are crossed by default
- bring the repo into full policy compliance (2026-07-23, branch
`repo-policy-compliance`; checklist below)
- `git init` with README-only first commit; code baseline committed on - `git init` with README-only first commit; code baseline committed on
`main` (2026-07-22) `main` (2026-07-22)
- implement `scan`, `report`, and `trees` subcommands (pre-git history) - implement `scan`, `report`, and `trees` subcommands (pre-git history)
# Future Steps # Future Steps
- add a remote on git.eeqj.de and push (`main` plus tags)
- convert Makefile targets to scripts-to-rule-them-all `script/` - convert Makefile targets to scripts-to-rule-them-all `script/`
entrypoints like the other managed repos entrypoints like the other managed repos
- tag `v0.0.1` once the compliance branch is merged - tag `v0.0.1` once the compliance branch is merged
@@ -38,33 +42,34 @@ Audited 2026-07-22 against `REPO_POLICIES.md` (2026-07-06), the existing
repo checklist, and the Go styleguide. Code is already gofmt-clean, so no repo checklist, and the Go styleguide. Code is already gofmt-clean, so no
standalone formatting commit is needed. standalone formatting commit is needed.
- [ ] `.gitignore` missing — the compiled `sfdupes` binary and - [x] `.gitignore` missing — the compiled `sfdupes` binary and
`files.dat` sit untracked in the tree; needs OS/editor/Go `files.dat` sit untracked in the tree; needs OS/editor/Go
artifacts plus secrets patterns artifacts plus secrets patterns
- [ ] `.editorconfig` missing - [x] `.editorconfig` missing
- [ ] `LICENSE` missing and README has no License section (MIT assumed - [x] `LICENSE` missing and README has no License section (MIT assumed
from house convention — user to confirm) from house convention — user to confirm)
- [ ] `REPO_POLICIES.md` missing from repo root - [x] `REPO_POLICIES.md` missing from repo root
- [ ] `.golangci.yml` missing (install canonical copy); code must then - [x] `.golangci.yml` missing (install canonical copy); code must then
pass `make lint` pass `make lint` (150 findings fixed; `make lint` is clean)
- [ ] `Makefile` lacks required targets `test`, `lint`, `fmt`, - [x] `Makefile` lacks required targets `test`, `lint`, `fmt`,
`fmt-check`, `docker`, `hooks`; `check` currently depends on `fmt-check`, `docker`, `hooks`; `check` currently depends on
`build`, which writes the binary (`make check` must not modify `build`, which writes the binary (`make check` must not modify
files) files)
- [ ] no tests — `go test ./...` has nothing to run; policy requires - [x] no tests — `go test ./...` has nothing to run; policy requires
real tests with a 30-second timeout and the conditional `-v` real tests with a 30-second timeout and the conditional `-v`
rerun pattern rerun pattern (suite covers parsing, grouping, digests,
- [ ] `Dockerfile` missing — Go multistage with hash-pinned images: suppression, hashing, and the scan pipeline; 64% coverage)
- [x] `Dockerfile` missing — Go multistage with hash-pinned images:
fail-fast lint stage, build stage running `make check` fail-fast lint stage, build stage running `make check`
- [ ] `.dockerignore` missing - [x] `.dockerignore` missing
- [ ] `.gitea/workflows/check.yml` missing (`docker build .` on push, - [x] `.gitea/workflows/check.yml` missing (`docker build .` on push,
checkout action pinned by commit SHA) checkout action pinned by commit SHA)
- [ ] README lacks required sections: Description first line - [x] README lacks required sections: Description first line
(name/purpose/category/license/author), Getting Started, (name/purpose/category/license/author), Getting Started,
Rationale, TODO, License, Author Rationale, TODO, License, Author
- [ ] README non-goal "no git repository setup and no CI" is stale now - [x] README non-goal "no git repository setup and no CI" is stale now
that the repo is under git with CI that the repo is under git with CI
- [ ] pre-commit hook not installed (`make hooks` once the target - [x] pre-commit hook not installed (`make hooks` once the target
exists) exists)
Accepted divergences (no action): Accepted divergences (no action):

61
main.go
View File

@@ -6,7 +6,7 @@
// //
// Usage: // Usage:
// //
// sfdupes scan [-root /srv] [-workers N] > files.dat // sfdupes scan [--workers N] [-x] PATH... > files.dat
// sfdupes report [files.dat|-] > dupes.tsv // sfdupes report [files.dat|-] > dupes.tsv
// sfdupes trees [files.dat|-] > dupetrees.tsv // sfdupes trees [files.dat|-] > dupetrees.tsv
// //
@@ -16,19 +16,35 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"runtime"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Exit codes: 0 is success (even with per-file warnings), exitFatal is
// a fatal error, exitUsage is a usage error.
const (
exitFatal = 1
exitUsage = 2
)
// Version is the build version, injected at link time via -ldflags
// (see the Makefile); "dev" for a plain go build.
//
//nolint:gochecknoglobals // written only by the linker
var Version = "dev"
func main() { func main() {
root := &cobra.Command{ root := &cobra.Command{
Use: "sfdupes", Use: "sfdupes",
Short: "Find candidate duplicate files by size and head/tail SHA-256", Short: "Find candidate duplicate files by size and head/tail SHA-256",
Args: cobra.NoArgs, Version: Version,
Run: func(cmd *cobra.Command, args []string) { Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, _ []string) {
// A missing subcommand prints usage and exits 2. // A missing subcommand prints usage and exits 2.
_ = cmd.Usage() _ = cmd.Usage()
os.Exit(2)
os.Exit(exitUsage)
}, },
} }
// Everything on stdout is machine-readable data; all human-facing // Everything on stdout is machine-readable data; all human-facing
@@ -37,22 +53,29 @@ func main() {
root.SetErr(os.Stderr) root.SetErr(os.Stderr)
root.CompletionOptions.DisableDefaultCmd = true root.CompletionOptions.DisableDefaultCmd = true
var (
scanWorkers int
scanOneFS bool
)
scanCmd := &cobra.Command{ scanCmd := &cobra.Command{
Use: "scan [-root /srv] [-workers N]", Use: "scan [--workers N] [-x] PATH...",
Short: "Walk a tree and emit one record per regular file on stdout", Short: "Walk trees and emit one record per regular file on stdout",
// The README specifies single-dash flags (-root, -workers); Args: cobra.MinimumNArgs(1),
// parse them with the stdlib flag package inside runScan. Run: func(_ *cobra.Command, args []string) {
DisableFlagParsing: true, runScan(args, scanWorkers, scanOneFS)
Run: func(cmd *cobra.Command, args []string) {
runScan(args)
}, },
} }
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
"concurrent workers for the stat and hash passes")
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
"do not cross filesystem boundaries")
reportCmd := &cobra.Command{ reportCmd := &cobra.Command{
Use: "report [files.dat|-]", Use: "report [files.dat|-]",
Short: "Read a scan stream and print the file-level duplicates report", Short: "Read a scan stream and print the file-level duplicates report",
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(_ *cobra.Command, args []string) {
runReport(args) runReport(args)
}, },
} }
@@ -61,21 +84,23 @@ func main() {
Use: "trees [files.dat|-]", Use: "trees [files.dat|-]",
Short: "Read a scan stream and print the duplicate-tree report", Short: "Read a scan stream and print the duplicate-tree report",
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(_ *cobra.Command, args []string) {
runTrees(args) runTrees(args)
}, },
} }
root.AddCommand(scanCmd, reportCmd, treesCmd) root.AddCommand(scanCmd, reportCmd, treesCmd)
if err := root.Execute(); err != nil {
err := root.Execute()
if err != nil {
// Cobra has already printed the error and usage to stderr; // Cobra has already printed the error and usage to stderr;
// an invalid subcommand or bad arguments is a usage error. // an invalid subcommand or bad arguments is a usage error.
os.Exit(2) os.Exit(exitUsage)
} }
} }
// fatalf reports a fatal error and exits 1. // fatalf reports a fatal error and exits 1.
func fatalf(format string, args ...any) { func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "sfdupes: "+format+"\n", args...) fmt.Fprintf(os.Stderr, "sfdupes: "+format+"\n", args...)
os.Exit(1) os.Exit(exitFatal)
} }

View File

@@ -12,12 +12,23 @@ import (
// is not a TTY. // is not a TTY.
const plainInterval = 5 * time.Second const plainInterval = 5 * time.Second
// barThrottle caps how often the TTY progress bar redraws.
const barThrottle = 100 * time.Millisecond
// walkSpinnerType selects the progressbar library's spinner style used
// for the walk pass, which has no known total.
const walkSpinnerType = 14
// percentScale converts a fraction to a percentage.
const percentScale = 100
// stderrIsTTY reports whether stderr is attached to a terminal. // stderrIsTTY reports whether stderr is attached to a terminal.
func stderrIsTTY() bool { func stderrIsTTY() bool {
fi, err := os.Stderr.Stat() fi, err := os.Stderr.Stat()
if err != nil { if err != nil {
return false return false
} }
return fi.Mode()&os.ModeCharDevice != 0 return fi.Mode()&os.ModeCharDevice != 0
} }
@@ -42,6 +53,7 @@ func newProgress(label string, total int64) *progress {
if !stderrIsTTY() { if !stderrIsTTY() {
return p return p
} }
opts := []progressbar.Option{ opts := []progressbar.Option{
progressbar.OptionSetWriter(os.Stderr), progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSetDescription(label), progressbar.OptionSetDescription(label),
@@ -49,7 +61,7 @@ func newProgress(label string, total int64) *progress {
progressbar.OptionShowIts(), progressbar.OptionShowIts(),
progressbar.OptionSetItsString("files"), progressbar.OptionSetItsString("files"),
progressbar.OptionSetElapsedTime(true), progressbar.OptionSetElapsedTime(true),
progressbar.OptionThrottle(100 * time.Millisecond), progressbar.OptionThrottle(barThrottle),
} }
if total >= 0 { if total >= 0 {
opts = append(opts, opts = append(opts,
@@ -59,10 +71,12 @@ func newProgress(label string, total int64) *progress {
} else { } else {
opts = append(opts, opts = append(opts,
progressbar.OptionSetPredictTime(false), progressbar.OptionSetPredictTime(false),
progressbar.OptionSpinnerType(14), progressbar.OptionSpinnerType(walkSpinnerType),
) )
} }
p.bar = progressbar.NewOptions64(total, opts...) p.bar = progressbar.NewOptions64(total, opts...)
return p return p
} }
@@ -71,8 +85,10 @@ func (p *progress) increment() {
p.count++ p.count++
if p.bar != nil { if p.bar != nil {
_ = p.bar.Add(1) _ = p.bar.Add(1)
return return
} }
if time.Since(p.last) >= plainInterval { if time.Since(p.last) >= plainInterval {
p.last = time.Now() p.last = time.Now()
fmt.Fprintln(os.Stderr, p.plainLine()) fmt.Fprintln(os.Stderr, p.plainLine())
@@ -84,6 +100,7 @@ func (p *progress) warnf(format string, args ...any) {
if p.bar != nil { if p.bar != nil {
_ = p.bar.Clear() _ = p.bar.Clear()
} }
fmt.Fprintf(os.Stderr, format+"\n", args...) fmt.Fprintf(os.Stderr, format+"\n", args...)
} }
@@ -91,9 +108,12 @@ func (p *progress) warnf(format string, args ...any) {
func (p *progress) finish() { func (p *progress) finish() {
if p.bar != nil { if p.bar != nil {
_ = p.bar.Finish() _ = p.bar.Finish()
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
return return
} }
fmt.Fprintln(os.Stderr, p.plainLine()) fmt.Fprintln(os.Stderr, p.plainLine())
} }
@@ -104,19 +124,24 @@ func (p *progress) plainLine() string {
return fmt.Sprintf("%s: %d files, elapsed %s", return fmt.Sprintf("%s: %d files, elapsed %s",
p.label, p.count, elapsed.Round(time.Second)) p.label, p.count, elapsed.Round(time.Second))
} }
pct := 100.0
pct := float64(percentScale)
if p.total > 0 { if p.total > 0 {
pct = 100 * float64(p.count) / float64(p.total) pct = percentScale * float64(p.count) / float64(p.total)
} }
rate := 0.0 rate := 0.0
if s := elapsed.Seconds(); s > 0 { if s := elapsed.Seconds(); s > 0 {
rate = float64(p.count) / s rate = float64(p.count) / s
} }
eta := "?" eta := "?"
if rate > 0 && p.count <= p.total { if rate > 0 && p.count <= p.total {
remaining := time.Duration(float64(p.total-p.count) / rate * float64(time.Second)) remaining := time.Duration(float64(p.total-p.count) / rate * float64(time.Second))
eta = remaining.Round(time.Second).String() eta = remaining.Round(time.Second).String()
} }
return fmt.Sprintf("%s: [%d/%d] %.0f%% %.0f files/s elapsed %s eta %s", return fmt.Sprintf("%s: [%d/%d] %.0f%% %.0f files/s elapsed %s eta %s",
p.label, p.count, p.total, pct, rate, p.label, p.count, p.total, pct, rate,
elapsed.Round(time.Second), eta) elapsed.Round(time.Second), eta)

147
report.go
View File

@@ -11,6 +11,26 @@ import (
"strings" "strings"
) )
// ioBufSize is the buffer size for the buffered scan-stream reader and
// the buffered stdout writers.
const ioBufSize = 1 << 20
// recordFieldCount is the number of tab-separated fields in a scan
// record: size, mtime, head hash, tail hash, path.
const recordFieldCount = 5
// scanInitBufSize and scanMaxRecordSize bound the scanner buffer used
// to read scan records; a record longer than scanMaxRecordSize is a
// fatal read error.
const (
scanInitBufSize = 64 << 10
scanMaxRecordSize = 4 << 20
)
// minGroupSize is the smallest number of members that makes a
// duplicate group.
const minGroupSize = 2
// scanRec is one well-formed record parsed from a scan stream. The // scanRec is one well-formed record parsed from a scan stream. The
// signature (size, head, tail) is the duplicate key; mtime is // signature (size, head, tail) is the duplicate key; mtime is
// informational and not retained. // informational and not retained.
@@ -24,14 +44,16 @@ type scanRec struct {
// openScanInput resolves the analysis-mode input: the file named by the // openScanInput resolves the analysis-mode input: the file named by the
// single optional positional argument, or stdin when it is absent or // single optional positional argument, or stdin when it is absent or
// "-". The returned closer must be called when reading is done. // "-". The returned closer must be called when reading is done.
func openScanInput(args []string) (in io.Reader, name string, closer func()) { func openScanInput(args []string) (io.Reader, string, func()) {
if len(args) == 1 && args[0] != "-" { if len(args) == 1 && args[0] != "-" {
f, err := os.Open(args[0]) f, err := os.Open(args[0])
if err != nil { if err != nil {
fatalf("open %s: %v", args[0], err) fatalf("open %s: %v", args[0], err)
} }
return f, args[0], func() { f.Close() }
return f, args[0], func() { _ = f.Close() }
} }
return os.Stdin, "stdin", func() {} return os.Stdin, "stdin", func() {}
} }
@@ -39,23 +61,33 @@ func openScanInput(args []string) (in io.Reader, name string, closer func()) {
// that does not have exactly 5 fields or whose size is non-numeric is // that does not have exactly 5 fields or whose size is non-numeric is
// counted as malformed and skipped. Total records read is // counted as malformed and skipped. Total records read is
// len(recs) + malformed. // len(recs) + malformed.
func parseScanStream(in io.Reader, name string) (recs []scanRec, malformed int) { func parseScanStream(in io.Reader, name string) ([]scanRec, int) {
sc := bufio.NewScanner(bufio.NewReaderSize(in, 1<<20)) sc := bufio.NewScanner(bufio.NewReaderSize(in, ioBufSize))
sc.Buffer(make([]byte, 0, 64<<10), 4<<20) sc.Buffer(make([]byte, 0, scanInitBufSize), scanMaxRecordSize)
sc.Split(splitNUL) sc.Split(splitNUL)
var (
recs []scanRec
malformed int
)
for sc.Scan() { for sc.Scan() {
// The path is the last field and may itself contain tabs, // The path is the last field and may itself contain tabs,
// so split into at most 5 fields. // so split into at most recordFieldCount fields.
fields := strings.SplitN(sc.Text(), "\t", 5) fields := strings.SplitN(sc.Text(), "\t", recordFieldCount)
if len(fields) != 5 { if len(fields) != recordFieldCount {
malformed++ malformed++
continue continue
} }
size, err := strconv.ParseInt(fields[0], 10, 64) size, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil { if err != nil {
malformed++ malformed++
continue continue
} }
recs = append(recs, scanRec{ recs = append(recs, scanRec{
size: size, size: size,
head: fields[2], head: fields[2],
@@ -63,9 +95,12 @@ func parseScanStream(in io.Reader, name string) (recs []scanRec, malformed int)
path: fields[4], path: fields[4],
}) })
} }
if err := sc.Err(); err != nil {
err := sc.Err()
if err != nil {
fatalf("read %s: %v", name, err) fatalf("read %s: %v", name, err)
} }
return recs, malformed return recs, malformed
} }
@@ -86,56 +121,37 @@ type dupeGroup struct {
func runReport(args []string) { func runReport(args []string) {
in, name, closer := openScanInput(args) in, name, closer := openScanInput(args)
defer closer() defer closer()
recs, malformed := parseScanStream(in, name) recs, malformed := parseScanStream(in, name)
records := len(recs) + malformed records := len(recs) + malformed
dupes := collectDupeGroups(recs)
type dupeKey struct { out := bufio.NewWriterSize(os.Stdout, ioBufSize)
size int64
head string
tail string
}
groups := make(map[dupeKey][]string)
for _, r := range recs {
k := dupeKey{size: r.size, head: r.head, tail: r.tail}
groups[k] = append(groups[k], r.path)
}
var dupes []dupeGroup _, err := fmt.Fprintln(out, "first\tdupe\tsize")
for k, paths := range groups { if err != nil {
if len(paths) < 2 {
continue
}
slices.Sort(paths)
dupes = append(dupes, dupeGroup{size: k.size, paths: paths})
}
// Biggest reclaimable space first; ties broken by first path.
slices.SortFunc(dupes, func(a, b dupeGroup) int {
if a.size != b.size {
if a.size > b.size {
return -1
}
return 1
}
return strings.Compare(a.paths[0], b.paths[0])
})
out := bufio.NewWriterSize(os.Stdout, 1<<20)
if _, err := fmt.Fprintln(out, "first\tdupe\tsize"); err != nil {
fatalf("write stdout: %v", err) fatalf("write stdout: %v", err)
} }
dupeFiles := 0 dupeFiles := 0
var reclaimable int64 var reclaimable int64
for _, g := range dupes { for _, g := range dupes {
for _, p := range g.paths[1:] { for _, p := range g.paths[1:] {
if _, err := fmt.Fprintf(out, "%s\t%s\t%d\n", _, err = fmt.Fprintf(out, "%s\t%s\t%d\n",
g.paths[0], p, g.size); err != nil { g.paths[0], p, g.size)
if err != nil {
fatalf("write stdout: %v", err) fatalf("write stdout: %v", err)
} }
dupeFiles++ dupeFiles++
reclaimable += g.size reclaimable += g.size
} }
} }
if err := out.Flush(); err != nil {
err = out.Flush()
if err != nil {
fatalf("write stdout: %v", err) fatalf("write stdout: %v", err)
} }
@@ -145,24 +161,65 @@ func runReport(args []string) {
humanBytes(reclaimable)) humanBytes(reclaimable))
} }
// collectDupeGroups groups records by signature and returns every group
// with two or more paths, each group's paths sorted lexicographically,
// groups ordered by size descending then by first path ascending.
func collectDupeGroups(recs []scanRec) []dupeGroup {
groups := make(map[fileSig][]string)
for _, r := range recs {
k := fileSig{size: r.size, head: r.head, tail: r.tail}
groups[k] = append(groups[k], r.path)
}
var dupes []dupeGroup
for k, paths := range groups {
if len(paths) < minGroupSize {
continue
}
slices.Sort(paths)
dupes = append(dupes, dupeGroup{size: k.size, paths: paths})
}
// Biggest reclaimable space first; ties broken by first path.
slices.SortFunc(dupes, func(a, b dupeGroup) int {
if a.size != b.size {
if a.size > b.size {
return -1
}
return 1
}
return strings.Compare(a.paths[0], b.paths[0])
})
return dupes
}
// malformedNote formats the optional malformed-record note for the // malformedNote formats the optional malformed-record note for the
// stderr summaries. // stderr summaries.
func malformedNote(malformed int) string { func malformedNote(malformed int) string {
if malformed == 0 { if malformed == 0 {
return "" return ""
} }
return fmt.Sprintf(" (%d malformed, skipped)", malformed) return fmt.Sprintf(" (%d malformed, skipped)", malformed)
} }
// splitNUL is a bufio.SplitFunc for NUL-terminated records. Trailing // splitNUL is a bufio.SplitFunc for NUL-terminated records. Trailing
// data without a terminator at EOF is returned as a final record. // data without a terminator at EOF is returned as a final record.
func splitNUL(data []byte, atEOF bool) (advance int, token []byte, err error) { func splitNUL(data []byte, atEOF bool) (int, []byte, error) {
if i := bytes.IndexByte(data, 0); i >= 0 { if i := bytes.IndexByte(data, 0); i >= 0 {
return i + 1, data[:i], nil return i + 1, data[:i], nil
} }
if atEOF && len(data) > 0 { if atEOF && len(data) > 0 {
return len(data), data, nil return len(data), data, nil
} }
return 0, nil, nil return 0, nil, nil
} }
@@ -172,10 +229,12 @@ func humanBytes(n int64) string {
if n < unit { if n < unit {
return fmt.Sprintf("%d B", n) return fmt.Sprintf("%d B", n)
} }
div, exp := int64(unit), 0 div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit { for m := n / unit; m >= unit; m /= unit {
div *= unit div *= unit
exp++ exp++
} }
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
} }

204
report_test.go Normal file
View File

@@ -0,0 +1,204 @@
package main
import (
"bufio"
"fmt"
"slices"
"strings"
"testing"
)
// mkRecord serializes one scan record in the on-the-wire format.
func mkRecord(size, mtime int64, head, tail, path string) string {
return fmt.Sprintf("%d\t%d\t%s\t%s\t%s\x00",
size, mtime, head, tail, path)
}
func TestSplitNULScanner(t *testing.T) {
t.Parallel()
sc := bufio.NewScanner(strings.NewReader("a\x00bb\x00\x00tail"))
sc.Split(splitNUL)
var got []string
for sc.Scan() {
got = append(got, sc.Text())
}
err := sc.Err()
if err != nil {
t.Fatalf("scanner error: %v", err)
}
want := []string{"a", "bb", "", "tail"}
if !slices.Equal(got, want) {
t.Fatalf("tokens = %q, want %q", got, want)
}
}
func TestParseScanStream(t *testing.T) {
t.Parallel()
stream := mkRecord(10, 1, "h1", "t1", "/a/x") +
"garbage-without-tabs\x00" +
"notanumber\t1\th\tt\t/a/bad\x00" +
mkRecord(20, 2, "h2", "t2", "/a/tab\tin\tname") +
"30\t3\th3\tt3\t/trailing/no-nul"
recs, malformed := parseScanStream(strings.NewReader(stream), "test")
if malformed != 2 {
t.Errorf("malformed = %d, want 2", malformed)
}
want := []scanRec{
{size: 10, head: "h1", tail: "t1", path: "/a/x"},
{size: 20, head: "h2", tail: "t2", path: "/a/tab\tin\tname"},
{size: 30, head: "h3", tail: "t3", path: "/trailing/no-nul"},
}
if !slices.Equal(recs, want) {
t.Fatalf("recs = %+v, want %+v", recs, want)
}
}
func TestParseScanStreamPathWithNewline(t *testing.T) {
t.Parallel()
in := strings.NewReader(mkRecord(5, 9, "h", "t", "/a/new\nline"))
recs, malformed := parseScanStream(in, "test")
if malformed != 0 || len(recs) != 1 {
t.Fatalf("got %d recs, %d malformed, want 1, 0",
len(recs), malformed)
}
if recs[0].path != "/a/new\nline" {
t.Fatalf("path = %q, want %q", recs[0].path, "/a/new\nline")
}
}
func TestParseScanStreamEmpty(t *testing.T) {
t.Parallel()
recs, malformed := parseScanStream(strings.NewReader(""), "test")
if len(recs) != 0 || malformed != 0 {
t.Fatalf("got %d recs, %d malformed, want 0, 0",
len(recs), malformed)
}
}
func TestCollectDupeGroups(t *testing.T) {
t.Parallel()
recs := []scanRec{
{size: 100, head: "h", tail: "t", path: "/z/b"},
{size: 100, head: "h", tail: "t", path: "/z/a"},
{size: 100, head: "h", tail: "t", path: "/z/c"},
{size: 4000, head: "H", tail: "T", path: "/big/2"},
{size: 4000, head: "H", tail: "T", path: "/big/1"},
// Same size as the /z group but a different head hash.
{size: 100, head: "other", tail: "t", path: "/z/d"},
// A singleton signature must not form a group.
{size: 7, head: "u", tail: "u", path: "/lonely"},
}
groups := collectDupeGroups(recs)
if len(groups) != 2 {
t.Fatalf("len(groups) = %d, want 2", len(groups))
}
if groups[0].size != 4000 ||
!slices.Equal(groups[0].paths, []string{"/big/1", "/big/2"}) {
t.Errorf("groups[0] = %+v, want size 4000, paths /big/1 /big/2",
groups[0])
}
if groups[1].size != 100 ||
!slices.Equal(groups[1].paths, []string{"/z/a", "/z/b", "/z/c"}) {
t.Errorf("groups[1] = %+v, want size 100, paths /z/a /z/b /z/c",
groups[1])
}
}
func TestCollectDupeGroupsTieBreak(t *testing.T) {
t.Parallel()
recs := []scanRec{
{size: 50, head: "b", tail: "b", path: "/beta/2"},
{size: 50, head: "b", tail: "b", path: "/beta/1"},
{size: 50, head: "a", tail: "a", path: "/alpha/2"},
{size: 50, head: "a", tail: "a", path: "/alpha/1"},
}
groups := collectDupeGroups(recs)
if len(groups) != 2 {
t.Fatalf("len(groups) = %d, want 2", len(groups))
}
// Equal sizes: ordered by first path ascending.
if groups[0].paths[0] != "/alpha/1" || groups[1].paths[0] != "/beta/1" {
t.Fatalf("tie-break order wrong: %q then %q",
groups[0].paths[0], groups[1].paths[0])
}
}
func TestCollectDupeGroupsDeterministic(t *testing.T) {
t.Parallel()
recs := []scanRec{
{size: 1, head: "a", tail: "a", path: "/p/1"},
{size: 1, head: "a", tail: "a", path: "/p/2"},
{size: 2, head: "b", tail: "b", path: "/q/1"},
{size: 2, head: "b", tail: "b", path: "/q/2"},
}
forward := collectDupeGroups(recs)
reversed := slices.Clone(recs)
slices.Reverse(reversed)
backward := collectDupeGroups(reversed)
if !slices.EqualFunc(forward, backward, func(a, b dupeGroup) bool {
return a.size == b.size && slices.Equal(a.paths, b.paths)
}) {
t.Fatalf("output depends on record order: %+v vs %+v",
forward, backward)
}
}
func TestHumanBytes(t *testing.T) {
t.Parallel()
cases := []struct {
n int64
want string
}{
{0, "0 B"},
{1, "1 B"},
{1023, "1023 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1 << 20, "1.0 MiB"},
{5 << 30, "5.0 GiB"},
{1 << 40, "1.0 TiB"},
{1 << 50, "1.0 PiB"},
{1 << 60, "1.0 EiB"},
}
for _, c := range cases {
if got := humanBytes(c.n); got != c.want {
t.Errorf("humanBytes(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestMalformedNote(t *testing.T) {
t.Parallel()
if got := malformedNote(0); got != "" {
t.Errorf("malformedNote(0) = %q, want empty", got)
}
if got := malformedNote(3); got != " (3 malformed, skipped)" {
t.Errorf("malformedNote(3) = %q", got)
}
}

281
scan.go
View File

@@ -4,17 +4,25 @@ import (
"bufio" "bufio"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"flag" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "syscall"
) )
// chunk is the number of bytes hashed from each end of a file. // chunk is the number of bytes hashed from each end of a file.
const chunk = 1024 const chunk = 1024
// workQueueDepth bounds the job and result channels feeding the stat
// and hash worker pools.
const workQueueDepth = 1024
// errNotRegular reports a path that stopped being a regular file
// between the walk and stat passes.
var errNotRegular = errors.New("no longer a regular file")
// fileRec carries one file between the stat and hash passes. // fileRec carries one file between the stat and hash passes.
type fileRec struct { type fileRec struct {
path string path string
@@ -22,88 +30,156 @@ type fileRec struct {
mtime int64 mtime int64
} }
// runScan implements the scan subcommand: three sequential passes (walk, // runScan implements the scan subcommand: three sequential passes
// stat, hash) over the tree under -root, emitting one NUL-terminated // (walk, stat, hash) over the trees named by the PATH operands,
// record per regular file on stdout. // emitting one NUL-terminated record per regular file on stdout. Flag
func runScan(args []string) { // parsing and the at-least-one-operand check are done by cobra.
fl := flag.NewFlagSet("scan", flag.ExitOnError) func runScan(roots []string, workers int, oneFS bool) {
root := fl.String("root", "/srv", "directory tree to scan") if workers < 1 {
workers := fl.Int("workers", runtime.NumCPU(), workers = 1
"concurrent workers for the stat and hash passes")
fl.Parse(args)
if fl.NArg() > 0 {
fmt.Fprintf(os.Stderr, "scan: unexpected argument %q\n", fl.Arg(0))
os.Exit(2)
}
if *workers < 1 {
*workers = 1
} }
st, err := os.Stat(*root) // A nonexistent operand is a fatal error before any scanning.
if err != nil { for _, root := range roots {
fatalf("root %s: %v", *root, err) _, err := os.Lstat(root)
} if err != nil {
if !st.IsDir() { fatalf("%v", err)
fatalf("root %s: not a directory", *root) }
} }
paths, walkErrs := walkPass(*root) paths, walkErrs := walkPass(roots, oneFS)
recs, statErrs := statPass(paths, *workers) recs, statErrs := statPass(paths, workers)
emitted, hashErrs := hashPass(recs, *workers) emitted, hashErrs := hashPass(recs, workers)
skipped := walkErrs + statErrs + hashErrs skipped := walkErrs + statErrs + hashErrs
fmt.Fprintf(os.Stderr, "scan: %d files emitted, %d skipped\n", fmt.Fprintf(os.Stderr, "scan: %d files emitted, %d skipped\n",
emitted, skipped) emitted, skipped)
} }
// walkPass enumerates every regular file under root. It never follows // treeWalker carries the walk-pass state shared by all PATH operands.
// symlinks, never descends into directories named .zfs, and warns and type treeWalker struct {
// continues on any per-path error. prog *progress
func walkPass(root string) (paths []string, errs int) { oneFS bool
prog := newProgress("walk", -1) paths []string
errs int
}
// walkPass enumerates every regular file under each root operand in
// order. It never follows symlinks, never descends into directories
// named .zfs, and warns and continues on any per-path error. With
// oneFS set it never descends into a directory on a different
// filesystem than its root operand.
func walkPass(roots []string, oneFS bool) ([]string, int) {
w := &treeWalker{prog: newProgress("walk", -1), oneFS: oneFS}
for _, root := range roots {
w.walkRoot(root)
}
w.prog.finish()
return w.paths, w.errs
}
// walkRoot walks a single PATH operand, appending regular-file paths.
func (w *treeWalker) walkRoot(root string) {
rootDev, rootDevOK := deviceOf(root)
walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
errs++ w.errs++
prog.warnf("walk %s: %v", p, err)
w.prog.warnf("walk %s: %v", p, err)
if d != nil && d.IsDir() { if d != nil && d.IsDir() {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
} }
if d.IsDir() { if d.IsDir() {
// ZFS snapshot pseudo-dirs would list every file return w.dirAction(p, d, rootDev, rootDevOK)
// once per snapshot; never descend.
if d.Name() == ".zfs" {
return filepath.SkipDir
}
return nil
} }
// Regular files only: skip symlinks, sockets, FIFOs, and // Regular files only: skip symlinks, sockets, FIFOs, and
// device nodes. // device nodes.
if !d.Type().IsRegular() { if !d.Type().IsRegular() {
return nil return nil
} }
paths = append(paths, p)
prog.increment() w.paths = append(w.paths, p)
w.prog.increment()
return nil return nil
}) })
prog.finish()
if walkErr != nil { if walkErr != nil {
fatalf("walk %s: %v", root, walkErr) fatalf("walk %s: %v", root, walkErr)
} }
return paths, errs }
// dirAction decides whether the walk descends into directory p.
func (w *treeWalker) dirAction(p string, d fs.DirEntry, rootDev uint64, rootDevOK bool) error {
// ZFS snapshot pseudo-dirs would list every file once per
// snapshot; never descend.
if d.Name() == ".zfs" {
return filepath.SkipDir
}
if !w.oneFS || !rootDevOK {
return nil
}
info, err := d.Info()
if err != nil {
w.errs++
w.prog.warnf("walk %s: %v", p, err)
return filepath.SkipDir
}
if dev, ok := deviceOfInfo(info); ok && dev != rootDev {
return filepath.SkipDir
}
return nil
}
// deviceOf returns the filesystem device ID of path without following
// symlinks.
func deviceOf(path string) (uint64, bool) {
fi, err := os.Lstat(path)
if err != nil {
return 0, false
}
return deviceOfInfo(fi)
}
// deviceOfInfo extracts the filesystem device ID from a FileInfo, when
// the platform exposes one.
func deviceOfInfo(fi fs.FileInfo) (uint64, bool) {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return statDev(st), true
} }
// statPass lstats every collected path in a worker pool, recording size // statPass lstats every collected path in a worker pool, recording size
// and mtime. Paths that fail to stat (or are no longer regular files) // and mtime. Paths that fail to stat (or are no longer regular files)
// are warned about and dropped. // are warned about and dropped.
func statPass(paths []string, workers int) (recs []fileRec, errs int) { func statPass(paths []string, workers int) ([]fileRec, int) {
type result struct { type result struct {
rec fileRec rec fileRec
err error err error
} }
jobs := make(chan string, 1024)
results := make(chan result, 1024) jobs := make(chan string, workQueueDepth)
results := make(chan result, workQueueDepth)
for range workers { for range workers {
go func() { go func() {
for p := range jobs { for p := range jobs {
@@ -114,7 +190,7 @@ func statPass(paths []string, workers int) (recs []fileRec, errs int) {
case !fi.Mode().IsRegular(): case !fi.Mode().IsRegular():
results <- result{ results <- result{
rec: fileRec{path: p}, rec: fileRec{path: p},
err: fmt.Errorf("no longer a regular file"), err: errNotRegular,
} }
default: default:
results <- result{rec: fileRec{ results <- result{rec: fileRec{
@@ -126,78 +202,114 @@ func statPass(paths []string, workers int) (recs []fileRec, errs int) {
} }
}() }()
} }
go func() { go func() {
for _, p := range paths { for _, p := range paths {
jobs <- p jobs <- p
} }
close(jobs) close(jobs)
}() }()
prog := newProgress("stat", int64(len(paths))) prog := newProgress("stat", int64(len(paths)))
recs = make([]fileRec, 0, len(paths))
var errs int
recs := make([]fileRec, 0, len(paths))
for range paths { for range paths {
r := <-results r := <-results
if r.err != nil { if r.err != nil {
errs++ errs++
prog.warnf("stat %s: %v", r.rec.path, r.err) prog.warnf("stat %s: %v", r.rec.path, r.err)
} else { } else {
recs = append(recs, r.rec) recs = append(recs, r.rec)
} }
prog.increment() prog.increment()
} }
prog.finish() prog.finish()
return recs, errs return recs, errs
} }
// hashResult carries one file's head/tail hashes (or the error that
// prevented hashing it) from the hash workers to the main goroutine.
type hashResult struct {
rec fileRec
head string
tail string
err error
}
// startHashWorkers starts the hash worker pool over recs and returns
// the channel its results arrive on (one per record, in completion
// order).
func startHashWorkers(recs []fileRec, workers int) <-chan hashResult {
jobs := make(chan fileRec, workQueueDepth)
results := make(chan hashResult, workQueueDepth)
for range workers {
go func() {
for rec := range jobs {
head, tail, err := hashHeadTail(rec.path, rec.size)
results <- hashResult{rec: rec, head: head, tail: tail, err: err}
}
}()
}
go func() {
for _, rec := range recs {
jobs <- rec
}
close(jobs)
}()
return results
}
// hashPass hashes the first and last chunk bytes of every file in a // hashPass hashes the first and last chunk bytes of every file in a
// worker pool and emits the output records on stdout from the main // worker pool and emits the output records on stdout from the main
// goroutine. Files that fail to open or read are warned about and // goroutine. Files that fail to open or read are warned about and
// dropped. // dropped.
func hashPass(recs []fileRec, workers int) (emitted, errs int) { func hashPass(recs []fileRec, workers int) (int, int) {
type result struct { results := startHashWorkers(recs, workers)
rec fileRec
head string
tail string
err error
}
jobs := make(chan fileRec, 1024)
results := make(chan result, 1024)
for range workers {
go func() {
for rec := range jobs {
head, tail, err := hashHeadTail(rec.path, rec.size)
results <- result{rec: rec, head: head, tail: tail, err: err}
}
}()
}
go func() {
for _, rec := range recs {
jobs <- rec
}
close(jobs)
}()
prog := newProgress("hash", int64(len(recs))) prog := newProgress("hash", int64(len(recs)))
out := bufio.NewWriterSize(os.Stdout, 1<<20) out := bufio.NewWriterSize(os.Stdout, ioBufSize)
var emitted, errs int
for range recs { for range recs {
r := <-results r := <-results
if r.err != nil { if r.err != nil {
errs++ errs++
prog.warnf("hash %s: %v", r.rec.path, r.err) prog.warnf("hash %s: %v", r.rec.path, r.err)
prog.increment() prog.increment()
continue continue
} }
if _, err := fmt.Fprintf(out, "%d\t%d\t%s\t%s\t%s\x00",
r.rec.size, r.rec.mtime, r.head, r.tail, r.rec.path); err != nil { _, err := fmt.Fprintf(out, "%d\t%d\t%s\t%s\t%s\x00",
r.rec.size, r.rec.mtime, r.head, r.tail, r.rec.path)
if err != nil {
fatalf("write stdout: %v", err) fatalf("write stdout: %v", err)
} }
emitted++ emitted++
prog.increment() prog.increment()
} }
prog.finish() prog.finish()
if err := out.Flush(); err != nil {
err := out.Flush()
if err != nil {
fatalf("write stdout: %v", err) fatalf("write stdout: %v", err)
} }
return emitted, errs return emitted, errs
} }
@@ -206,26 +318,35 @@ func hashPass(recs []fileRec, workers int) (emitted, errs int) {
// file at path. The two reads overlap when size < 2*chunk; for // file at path. The two reads overlap when size < 2*chunk; for
// size == 0 both hashes are of the empty input. size is the value // size == 0 both hashes are of the empty input. size is the value
// recorded by the stat pass. // recorded by the stat pass.
func hashHeadTail(path string, size int64) (head, tail string, err error) { func hashHeadTail(path string, size int64) (string, string, error) {
//nolint:gosec // hashing operator-supplied paths is the tool's purpose
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
defer f.Close()
defer func() { _ = f.Close() }()
n := min(int64(chunk), size) n := min(int64(chunk), size)
buf := make([]byte, n) buf := make([]byte, n)
if n > 0 { if n > 0 {
if _, err := f.ReadAt(buf, 0); err != nil { _, err = f.ReadAt(buf, 0)
if err != nil {
return "", "", err return "", "", err
} }
} }
h := sha256.Sum256(buf) h := sha256.Sum256(buf)
if n > 0 { if n > 0 {
if _, err := f.ReadAt(buf, size-n); err != nil { _, err = f.ReadAt(buf, size-n)
if err != nil {
return "", "", err return "", "", err
} }
} }
t := sha256.Sum256(buf) t := sha256.Sum256(buf)
return hex.EncodeToString(h[:]), hex.EncodeToString(t[:]), nil return hex.EncodeToString(h[:]), hex.EncodeToString(t[:]), nil
} }

14
scan_dev_darwin.go Normal file
View File

@@ -0,0 +1,14 @@
//go:build darwin
package main
import "syscall"
// statDev returns the filesystem device ID of st as uint64. Device IDs
// are opaque; the conversion only needs to be consistent so equality
// comparisons work, not numerically meaningful.
//
//nolint:gosec // int32 device IDs convert consistently; only equality matters
func statDev(st *syscall.Stat_t) uint64 {
return uint64(st.Dev)
}

10
scan_dev_other.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build !darwin
package main
import "syscall"
// statDev returns the filesystem device ID of st.
func statDev(st *syscall.Stat_t) uint64 {
return st.Dev
}

398
scan_test.go Normal file
View File

@@ -0,0 +1,398 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// writeFile creates a file with the given content and returns its path.
func writeFile(t *testing.T, dir, name string, data []byte) string {
t.Helper()
p := filepath.Join(dir, name)
err := os.MkdirAll(filepath.Dir(p), 0o750)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(p, data, 0o600)
if err != nil {
t.Fatal(err)
}
return p
}
// hexSum returns the lowercase-hex SHA-256 of data.
func hexSum(data []byte) string {
s := sha256.Sum256(data)
return hex.EncodeToString(s[:])
}
// pattern returns n bytes of deterministic content seeded by tag.
func pattern(tag byte, n int) []byte {
data := make([]byte, n)
for i := range data {
data[i] = tag ^ byte(i)
}
return data
}
func TestHashHeadTail(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cases := []struct {
name string
data []byte
}{
{"empty", nil},
{"one-byte", []byte("x")},
{"under-one-chunk", pattern(1, chunk-1)},
{"exactly-one-chunk", pattern(2, chunk)},
{"overlapping-reads", pattern(3, chunk+chunk/2)},
{"exactly-two-chunks", pattern(4, 2*chunk)},
{"beyond-two-chunks", pattern(5, 3*chunk)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
p := writeFile(t, dir, c.name, c.data)
head, tail, err := hashHeadTail(p, int64(len(c.data)))
if err != nil {
t.Fatalf("hashHeadTail: %v", err)
}
n := min(chunk, len(c.data))
if want := hexSum(c.data[:n]); head != want {
t.Errorf("head = %s, want %s", head, want)
}
if want := hexSum(c.data[len(c.data)-n:]); tail != want {
t.Errorf("tail = %s, want %s", tail, want)
}
})
}
}
func TestHashHeadTailErrors(t *testing.T) {
t.Parallel()
dir := t.TempDir()
_, _, err := hashHeadTail(filepath.Join(dir, "missing"), 1)
if err == nil {
t.Error("no error for a missing file")
}
// A file that shrank between the stat and hash passes: reading at
// the stat-reported size must fail rather than emit wrong hashes.
p := writeFile(t, dir, "shrunk", []byte("tiny"))
_, _, err = hashHeadTail(p, int64(2*chunk))
if err == nil {
t.Error("no error when the stat size exceeds the file size")
}
}
func TestWalkPass(t *testing.T) {
t.Parallel()
dir := t.TempDir()
want := []string{
writeFile(t, dir, "a.txt", []byte("a")),
writeFile(t, dir, "sub/b.txt", []byte("b")),
writeFile(t, dir, "sub/deeper/c.txt", []byte("c")),
}
slices.Sort(want)
// Files under a .zfs directory must never be walked.
writeFile(t, dir, ".zfs/snapshot/hourly/a.txt", []byte("a"))
// Symlinks are skipped, not followed.
err := os.Symlink(want[0], filepath.Join(dir, "link"))
if err != nil {
t.Fatal(err)
}
paths, errs := walkPass([]string{dir}, false)
if errs != 0 {
t.Fatalf("errs = %d, want 0", errs)
}
slices.Sort(paths)
if !slices.Equal(paths, want) {
t.Fatalf("paths = %q, want %q", paths, want)
}
}
func TestWalkPassMultipleRoots(t *testing.T) {
t.Parallel()
rootA := t.TempDir()
rootB := t.TempDir()
want := []string{
writeFile(t, rootA, "a1", []byte("1")),
writeFile(t, rootA, "sub/a2", []byte("2")),
writeFile(t, rootB, "b1", []byte("3")),
}
paths, errs := walkPass([]string{rootA, rootB}, false)
if errs != 0 {
t.Fatalf("errs = %d, want 0", errs)
}
// Operands are walked in the order given.
if !slices.Equal(paths, want) {
t.Fatalf("paths = %q, want %q", paths, want)
}
}
func TestWalkPassFileAndSymlinkOperands(t *testing.T) {
t.Parallel()
dir := t.TempDir()
f := writeFile(t, dir, "plain", []byte("data"))
link := filepath.Join(dir, "link")
err := os.Symlink(f, link)
if err != nil {
t.Fatal(err)
}
// A regular-file operand is emitted as itself.
paths, errs := walkPass([]string{f}, false)
if errs != 0 || !slices.Equal(paths, []string{f}) {
t.Fatalf("file operand: paths = %q, errs = %d", paths, errs)
}
// A symlink operand is not followed and yields nothing.
paths, errs = walkPass([]string{link}, false)
if errs != 0 || len(paths) != 0 {
t.Fatalf("symlink operand: paths = %q, errs = %d", paths, errs)
}
}
func TestWalkPassOneFilesystemSameFS(t *testing.T) {
t.Parallel()
// Everything in one filesystem: -x must not skip anything.
dir := t.TempDir()
want := []string{
writeFile(t, dir, "a", []byte("a")),
writeFile(t, dir, "sub/deep/b", []byte("b")),
}
paths, errs := walkPass([]string{dir}, true)
if errs != 0 {
t.Fatalf("errs = %d, want 0", errs)
}
slices.Sort(paths)
if !slices.Equal(paths, want) {
t.Fatalf("paths = %q, want %q", paths, want)
}
}
func TestDeviceOf(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dev1, ok1 := deviceOf(dir)
dev2, ok2 := deviceOf(dir)
if !ok1 || !ok2 || dev1 != dev2 {
t.Fatalf("deviceOf unstable: %d/%v vs %d/%v", dev1, ok1, dev2, ok2)
}
if _, ok := deviceOf(filepath.Join(dir, "missing")); ok {
t.Fatal("deviceOf reported ok for a missing path")
}
}
func TestStatPass(t *testing.T) {
t.Parallel()
dir := t.TempDir()
a := writeFile(t, dir, "a", pattern(1, 10))
b := writeFile(t, dir, "b", pattern(2, 20))
missing := filepath.Join(dir, "vanished")
recs, errs := statPass([]string{a, b, missing}, 2)
if errs != 1 {
t.Fatalf("errs = %d, want 1 for the vanished file", errs)
}
slices.SortFunc(recs, func(x, y fileRec) int {
return strings.Compare(x.path, y.path)
})
if len(recs) != 2 || recs[0].size != 10 || recs[1].size != 20 {
t.Fatalf("recs = %+v, want sizes 10 and 20", recs)
}
if recs[0].mtime <= 0 || recs[1].mtime <= 0 {
t.Fatalf("recs = %+v, want positive mtimes", recs)
}
}
// captureStdout runs fn with os.Stdout redirected to a temp file and
// returns everything fn wrote to it.
func captureStdout(t *testing.T, fn func()) []byte {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
orig := os.Stdout
os.Stdout = f
defer func() { os.Stdout = orig }()
fn()
_, err = f.Seek(0, io.SeekStart)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
_ = f.Close()
return data
}
// buildSmokeTree recreates the README smoke-test filesystem layout
// with deterministic content and returns the tree root.
func buildSmokeTree(t *testing.T) string {
t.Helper()
dir := t.TempDir()
one := pattern(10, 2000)
f1 := pattern(30, 3000)
f2 := pattern(40, 100)
writeFile(t, dir, "a/one.bin", one)
writeFile(t, dir, "b/copy.bin", one)
writeFile(t, dir, "b/copy2.bin", one)
// Same size as one.bin, different content.
writeFile(t, dir, "a/unique.bin", pattern(20, 2000))
writeFile(t, dir, "tiny1", []byte("x"))
writeFile(t, dir, "tiny2", []byte("x"))
writeFile(t, dir, "tiny3", []byte("y"))
writeFile(t, dir, "empty1", nil)
writeFile(t, dir, "empty2", nil)
writeFile(t, dir, "t1/f1", f1)
writeFile(t, dir, "t1/sub/f2", f2)
writeFile(t, dir, "t2/f1", f1)
writeFile(t, dir, "t2/sub/f2", f2)
writeFile(t, dir, "t3/f1", f1)
writeFile(t, dir, "t3/sub/f2renamed", f2)
return dir
}
// scanToRecords runs the walk, stat, and hash passes over dir and
// parses the emitted stream back into records.
func scanToRecords(t *testing.T, dir string) []scanRec {
t.Helper()
paths, walkErrs := walkPass([]string{dir}, false)
if walkErrs != 0 {
t.Fatalf("walk errors: %d", walkErrs)
}
recs, statErrs := statPass(paths, 4)
if statErrs != 0 {
t.Fatalf("stat errors: %d", statErrs)
}
var emitted, hashErrs int
out := captureStdout(t, func() {
emitted, hashErrs = hashPass(recs, 4)
})
if emitted != len(paths) || hashErrs != 0 {
t.Fatalf("emitted %d of %d, %d hash errors",
emitted, len(paths), hashErrs)
}
parsed, malformed := parseScanStream(bytes.NewReader(out), "pipe")
if malformed != 0 || len(parsed) != len(paths) {
t.Fatalf("parsed %d records, %d malformed, want %d, 0",
len(parsed), malformed, len(paths))
}
return parsed
}
//nolint:paralleltest // redirects the process-wide os.Stdout
func TestScanPipeline(t *testing.T) {
dir := buildSmokeTree(t)
parsed := scanToRecords(t, dir)
groups := collectDupeGroups(parsed)
if len(groups) != 5 {
t.Fatalf("len(groups) = %d, want 5", len(groups))
}
wantSizes := []int64{3000, 2000, 100, 1, 0}
for i, g := range groups {
if g.size != wantSizes[i] {
t.Errorf("groups[%d].size = %d, want %d",
i, g.size, wantSizes[i])
}
}
wantF1 := []string{
filepath.Join(dir, "t1/f1"),
filepath.Join(dir, "t2/f1"),
filepath.Join(dir, "t3/f1"),
}
if !slices.Equal(groups[0].paths, wantF1) {
t.Errorf("groups[0].paths = %q, want %q", groups[0].paths, wantF1)
}
super, dirs := buildHierarchy(parsed)
super.compute()
tg := collectTreeGroups(dirs, super)
if len(tg) != 1 {
t.Fatalf("len(tree groups) = %d, want 1", len(tg))
}
wantTrees := []string{filepath.Join(dir, "t1"), filepath.Join(dir, "t2")}
if got := groupPaths(tg)[0]; !slices.Equal(got, wantTrees) {
t.Fatalf("tree group = %q, want %q", got, wantTrees)
}
if tg[0][0].fileCount != 2 || tg[0][0].totalSize != 3100 {
t.Fatalf("tree totals: %d files %d bytes, want 2 3100",
tg[0][0].fileCount, tg[0][0].totalSize)
}
}

113
trees.go
View File

@@ -38,16 +38,64 @@ type treeNode struct {
func runTrees(args []string) { func runTrees(args []string) {
in, name, closer := openScanInput(args) in, name, closer := openScanInput(args)
defer closer() defer closer()
recs, malformed := parseScanStream(in, name) recs, malformed := parseScanStream(in, name)
records := len(recs) + malformed records := len(recs) + malformed
// Build the hierarchy under a synthetic super-root. Paths are super, allDirs := buildHierarchy(recs)
// split on "/"; for absolute paths the first component is empty, super.compute()
// which simply becomes a top-level node representing "/".
dupes := collectTreeGroups(allDirs, super)
out := bufio.NewWriterSize(os.Stdout, ioBufSize)
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize")
if err != nil {
fatalf("write stdout: %v", err)
}
dupeTrees := 0
var reclaimable int64
for _, g := range dupes {
first := g[0]
for _, n := range g[1:] {
_, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\n",
first.path, n.path, first.fileCount, first.totalSize)
if err != nil {
fatalf("write stdout: %v", err)
}
dupeTrees++
reclaimable += first.totalSize
}
}
err = out.Flush()
if err != nil {
fatalf("write stdout: %v", err)
}
fmt.Fprintf(os.Stderr,
"trees: %d records read%s, %d duplicate tree groups, %d dupe trees, %s reclaimable\n",
records, malformedNote(malformed), len(dupes), dupeTrees,
humanBytes(reclaimable))
}
// buildHierarchy reconstructs the directory hierarchy from the record
// paths under a synthetic super-root. Paths are split on "/"; for
// absolute paths the first component is empty, which simply becomes a
// top-level node representing "/". It returns the super-root and every
// directory node created.
func buildHierarchy(recs []scanRec) (*treeNode, []*treeNode) {
super := &treeNode{} super := &treeNode{}
var allDirs []*treeNode var allDirs []*treeNode
for _, r := range recs { for _, r := range recs {
comps := strings.Split(r.path, "/") comps := strings.Split(r.path, "/")
node := super node := super
for _, c := range comps[:len(comps)-1] { for _, c := range comps[:len(comps)-1] {
child := node.dirs[c] child := node.dirs[c]
@@ -56,76 +104,68 @@ func runTrees(args []string) {
if node != super { if node != super {
childPath = node.path + "/" + c childPath = node.path + "/" + c
} }
child = &treeNode{path: childPath, parent: node} child = &treeNode{path: childPath, parent: node}
if node.dirs == nil { if node.dirs == nil {
node.dirs = make(map[string]*treeNode) node.dirs = make(map[string]*treeNode)
} }
node.dirs[c] = child node.dirs[c] = child
allDirs = append(allDirs, child) allDirs = append(allDirs, child)
} }
node = child node = child
} }
if node.files == nil { if node.files == nil {
node.files = make(map[string]fileSig) node.files = make(map[string]fileSig)
} }
node.files[comps[len(comps)-1]] = fileSig{ node.files[comps[len(comps)-1]] = fileSig{
size: r.size, head: r.head, tail: r.tail, size: r.size, head: r.head, tail: r.tail,
} }
} }
super.compute()
// Group directories by digest; two or more dirs sharing a digest return super, allDirs
// are candidate duplicate trees. }
// collectTreeGroups groups directories by digest and returns every
// maximal group with two or more members, each group's members sorted
// by path, groups ordered by tree size descending then by first path
// ascending.
func collectTreeGroups(allDirs []*treeNode, super *treeNode) [][]*treeNode {
groups := make(map[[sha256.Size]byte][]*treeNode) groups := make(map[[sha256.Size]byte][]*treeNode)
for _, d := range allDirs { for _, d := range allDirs {
groups[d.digest] = append(groups[d.digest], d) groups[d.digest] = append(groups[d.digest], d)
} }
var dupes [][]*treeNode var dupes [][]*treeNode
for _, g := range groups { for _, g := range groups {
if len(g) < 2 || suppressed(g, super) { if len(g) < minGroupSize || suppressed(g, super) {
continue continue
} }
slices.SortFunc(g, func(a, b *treeNode) int { slices.SortFunc(g, func(a, b *treeNode) int {
return strings.Compare(a.path, b.path) return strings.Compare(a.path, b.path)
}) })
dupes = append(dupes, g) dupes = append(dupes, g)
} }
// Biggest reclaimable space first; ties broken by first path. // Biggest reclaimable space first; ties broken by first path.
slices.SortFunc(dupes, func(a, b []*treeNode) int { slices.SortFunc(dupes, func(a, b []*treeNode) int {
if a[0].totalSize != b[0].totalSize { if a[0].totalSize != b[0].totalSize {
if a[0].totalSize > b[0].totalSize { if a[0].totalSize > b[0].totalSize {
return -1 return -1
} }
return 1 return 1
} }
return strings.Compare(a[0].path, b[0].path) return strings.Compare(a[0].path, b[0].path)
}) })
out := bufio.NewWriterSize(os.Stdout, 1<<20) return dupes
if _, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize"); err != nil {
fatalf("write stdout: %v", err)
}
dupeTrees := 0
var reclaimable int64
for _, g := range dupes {
first := g[0]
for _, n := range g[1:] {
if _, err := fmt.Fprintf(out, "%s\t%s\t%d\t%d\n",
first.path, n.path, first.fileCount, first.totalSize); err != nil {
fatalf("write stdout: %v", err)
}
dupeTrees++
reclaimable += first.totalSize
}
}
if err := out.Flush(); err != nil {
fatalf("write stdout: %v", err)
}
fmt.Fprintf(os.Stderr,
"trees: %d records read%s, %d duplicate tree groups, %d dupe trees, %s reclaimable\n",
records, malformedNote(malformed), len(dupes), dupeTrees,
humanBytes(reclaimable))
} }
// compute fills in digest, fileCount, and totalSize for n and all of // compute fills in digest, fileCount, and totalSize for n and all of
@@ -143,18 +183,22 @@ func (n *treeNode) compute() {
n.fileCount++ n.fileCount++
n.totalSize += sig.size n.totalSize += sig.size
} }
for name, child := range n.dirs { for name, child := range n.dirs {
child.compute() child.compute()
entries = append(entries, "d\x00"+name+"\x00"+string(child.digest[:])) entries = append(entries, "d\x00"+name+"\x00"+string(child.digest[:]))
n.fileCount += child.fileCount n.fileCount += child.fileCount
n.totalSize += child.totalSize n.totalSize += child.totalSize
} }
slices.Sort(entries) slices.Sort(entries)
h := sha256.New() h := sha256.New()
for _, e := range entries { for _, e := range entries {
h.Write([]byte(e)) h.Write([]byte(e))
h.Write([]byte{0}) h.Write([]byte{0})
} }
copy(n.digest[:], h.Sum(nil)) copy(n.digest[:], h.Sum(nil))
} }
@@ -165,15 +209,19 @@ func (n *treeNode) compute() {
// parent) or members whose parents differ are always reported. // parent) or members whose parents differ are always reported.
func suppressed(g []*treeNode, super *treeNode) bool { func suppressed(g []*treeNode, super *treeNode) bool {
seen := make(map[*treeNode]bool, len(g)) seen := make(map[*treeNode]bool, len(g))
var parentDigest [sha256.Size]byte var parentDigest [sha256.Size]byte
for i, n := range g { for i, n := range g {
p := n.parent p := n.parent
if p == nil || p == super { if p == nil || p == super {
return false return false
} }
if seen[p] { if seen[p] {
return false // siblings: not implied by any parent group return false // siblings: not implied by any parent group
} }
seen[p] = true seen[p] = true
if i == 0 { if i == 0 {
parentDigest = p.digest parentDigest = p.digest
@@ -181,5 +229,6 @@ func suppressed(g []*treeNode, super *treeNode) bool {
return false return false
} }
} }
return true return true
} }

220
trees_test.go Normal file
View File

@@ -0,0 +1,220 @@
package main
import (
"slices"
"testing"
)
// Signature hashes shared by the smoke-test records.
const (
f1Head = "f1h"
f1Tail = "f1t"
f2Head = "f2h"
f2Tail = "f2t"
)
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
// /d/t2 are identical, /d/t3 differs from them only by one filename.
func smokeTreeRecs() []scanRec {
return []scanRec{
{size: 3000, head: f1Head, tail: f1Tail, path: "/d/t1/f1"},
{size: 100, head: f2Head, tail: f2Tail, path: "/d/t1/sub/f2"},
{size: 3000, head: f1Head, tail: f1Tail, path: "/d/t2/f1"},
{size: 100, head: f2Head, tail: f2Tail, path: "/d/t2/sub/f2"},
{size: 3000, head: f1Head, tail: f1Tail, path: "/d/t3/f1"},
{size: 100, head: f2Head, tail: f2Tail, path: "/d/t3/sub/f2renamed"},
}
}
// nodeByPath finds the directory node with the given path.
func nodeByPath(t *testing.T, dirs []*treeNode, path string) *treeNode {
t.Helper()
for _, d := range dirs {
if d.path == path {
return d
}
}
t.Fatalf("no directory node with path %q", path)
return nil
}
// groupPaths flattens tree groups into their member path lists.
func groupPaths(groups [][]*treeNode) [][]string {
out := make([][]string, 0, len(groups))
for _, g := range groups {
paths := make([]string, 0, len(g))
for _, n := range g {
paths = append(paths, n.path)
}
out = append(out, paths)
}
return out
}
func TestBuildHierarchyCounts(t *testing.T) {
t.Parallel()
super, dirs := buildHierarchy(smokeTreeRecs())
super.compute()
d := nodeByPath(t, dirs, "/d")
if d.fileCount != 6 || d.totalSize != 9300 {
t.Errorf("/d: fileCount %d size %d, want 6 9300",
d.fileCount, d.totalSize)
}
t1 := nodeByPath(t, dirs, "/d/t1")
if t1.fileCount != 2 || t1.totalSize != 3100 {
t.Errorf("/d/t1: fileCount %d size %d, want 2 3100",
t1.fileCount, t1.totalSize)
}
sub := nodeByPath(t, dirs, "/d/t1/sub")
if sub.fileCount != 1 || sub.totalSize != 100 {
t.Errorf("/d/t1/sub: fileCount %d size %d, want 1 100",
sub.fileCount, sub.totalSize)
}
}
func TestTreeDigests(t *testing.T) {
t.Parallel()
super, dirs := buildHierarchy(smokeTreeRecs())
super.compute()
t1 := nodeByPath(t, dirs, "/d/t1")
t2 := nodeByPath(t, dirs, "/d/t2")
t3 := nodeByPath(t, dirs, "/d/t3")
if t1.digest != t2.digest {
t.Error("identical trees /d/t1 and /d/t2 have different digests")
}
// t3 differs only in a filename; names are part of the digest.
if t1.digest == t3.digest {
t.Error("/d/t3 digest equals /d/t1 despite a renamed file")
}
sub1 := nodeByPath(t, dirs, "/d/t1/sub")
sub3 := nodeByPath(t, dirs, "/d/t3/sub")
if sub1.digest == sub3.digest {
t.Error("subdirs with differently-named files share a digest")
}
}
func TestTreeDigestContentSensitivity(t *testing.T) {
t.Parallel()
const sharedTail = "same"
recs := []scanRec{
{size: 10, head: sharedTail, tail: sharedTail, path: "/r/a/f"},
{size: 10, head: "DIFF", tail: sharedTail, path: "/r/b/f"},
}
super, dirs := buildHierarchy(recs)
super.compute()
a := nodeByPath(t, dirs, "/r/a")
b := nodeByPath(t, dirs, "/r/b")
if a.digest == b.digest {
t.Error("trees with different file content share a digest")
}
}
func TestCollectTreeGroupsMaximal(t *testing.T) {
t.Parallel()
super, dirs := buildHierarchy(smokeTreeRecs())
super.compute()
groups := collectTreeGroups(dirs, super)
want := [][]string{{"/d/t1", "/d/t2"}}
if got := groupPaths(groups); !slices.EqualFunc(got, want,
slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want)
}
// The /d/t1/sub vs /d/t2/sub group must be suppressed as implied
// by its parents' group; the winning group reports one copy's
// recursive totals.
if groups[0][0].fileCount != 2 || groups[0][0].totalSize != 3100 {
t.Errorf("group totals: %d files %d bytes, want 2 3100",
groups[0][0].fileCount, groups[0][0].totalSize)
}
}
func TestCollectTreeGroupsDeterministic(t *testing.T) {
t.Parallel()
recs := smokeTreeRecs()
super, dirs := buildHierarchy(recs)
super.compute()
forward := groupPaths(collectTreeGroups(dirs, super))
reversed := slices.Clone(recs)
slices.Reverse(reversed)
superR, dirsR := buildHierarchy(reversed)
superR.compute()
backward := groupPaths(collectTreeGroups(dirsR, superR))
if !slices.EqualFunc(forward, backward, slices.Equal) {
t.Fatalf("output depends on record order: %v vs %v",
forward, backward)
}
}
func TestCollectTreeGroupsSiblings(t *testing.T) {
t.Parallel()
// Identical sibling dirs share a parent, so their group cannot be
// implied by a parent group and must be reported.
recs := []scanRec{
{size: 10, head: "h", tail: "t", path: "/p/x1/f"},
{size: 10, head: "h", tail: "t", path: "/p/x2/f"},
}
super, dirs := buildHierarchy(recs)
super.compute()
got := groupPaths(collectTreeGroups(dirs, super))
want := [][]string{{"/p/x1", "/p/x2"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want)
}
}
func TestCollectTreeGroupsDifferingParents(t *testing.T) {
t.Parallel()
// /p/a and /q/b contain identical x subtrees, but /p/a has an
// extra file, so the parents' digests differ and the x group must
// be reported.
recs := []scanRec{
{size: 10, head: "h", tail: "t", path: "/p/a/x/f"},
{size: 99, head: "e", tail: "e", path: "/p/a/extra"},
{size: 10, head: "h", tail: "t", path: "/q/b/x/f"},
}
super, dirs := buildHierarchy(recs)
super.compute()
got := groupPaths(collectTreeGroups(dirs, super))
want := [][]string{{"/p/a/x", "/q/b/x"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want)
}
}