Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1a3385788 |
+2
-66
@@ -10,20 +10,14 @@ run:
|
||||
|
||||
linters:
|
||||
default: all
|
||||
enable:
|
||||
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||
# left to `default: all`, because it carries the module policy below.
|
||||
- gomodguard_v2
|
||||
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
|
||||
# Deprecated: the warning is attached to the old name, so it is
|
||||
# silenced by disabling that name, not by enabling the successor.
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
@@ -34,64 +28,6 @@ linters:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
depguard:
|
||||
# Test-support code must not be compiled into the shipped binary. A
|
||||
# test-support package exists to hand a test privileges the program
|
||||
# itself must never have, so a file that is not a test must not import
|
||||
# one. Test files, and the files inside a package whose directory name
|
||||
# ends in `test`, are where that code belongs, and are exempt.
|
||||
#
|
||||
# The deny list below is the one part of this file a repository is
|
||||
# expected to extend, and the only part it may. depguard matches an
|
||||
# import path against a list of prefixes, so it cannot be told "any path
|
||||
# whose last segment ends in test"; a repository's own test-support
|
||||
# packages have to be named here one at a time, by full import path,
|
||||
# under a module path that differs from repository to repository. Add
|
||||
# them; change nothing else.
|
||||
rules:
|
||||
test-support:
|
||||
list-mode: lax
|
||||
files:
|
||||
- "$all"
|
||||
- "!$test"
|
||||
- "!**/*test/**"
|
||||
deny:
|
||||
- pkg: net/http/httptest
|
||||
desc: >-
|
||||
Test-support code belongs in test files and in packages whose
|
||||
directory name ends in test, not in the shipped binary.
|
||||
# Only decisions already recorded in the Go package defaults are
|
||||
# listed here. Every entry matches the module path exactly.
|
||||
gomodguard_v2:
|
||||
blocked:
|
||||
- module: github.com/rs/zerolog
|
||||
recommendations:
|
||||
- log/slog
|
||||
reason: "Structured logging is stdlib log/slog."
|
||||
# One entry per pre-fork module path, because the later releases
|
||||
# are separate paths. A prefix match would be shorter but would
|
||||
# also reach github.com/go-redis/redismock, the test double for
|
||||
# the successor these entries recommend.
|
||||
- module: github.com/go-redis/redis
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v7
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v8
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/sergi/go-diff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "No unified diff output; use go-udiff."
|
||||
- module: github.com/hexops/gotextdiff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "Unmaintained fork; use go-udiff."
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
|
||||
+64
-56
@@ -6,36 +6,37 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Cache-buster for the gate layers, and only for them: on an unchanged
|
||||
# tree Docker would serve the gates below from cache and the build would
|
||||
# exit 0 having run nothing. script/cibuild and script/docker pass a
|
||||
# fresh CHECK_EPOCH; a build without one, such as a bare
|
||||
# `docker build .`, fails at the check right after the ARG.
|
||||
# Cache-buster for the gate layers, and only for them. Docker
|
||||
# invalidates COPY only when the copied content changes, so on an
|
||||
# unchanged tree the gates below would be served from cache and the
|
||||
# build would exit 0 having run nothing. script/cibuild and
|
||||
# script/docker pass a fresh CHECK_EPOCH on every invocation.
|
||||
#
|
||||
# ARG is per-stage, so the markdown and build stages declare it again.
|
||||
# Each gate RUN must reference the value: BuildKit hashes the expanded
|
||||
# command, so a declared but unreferenced ARG invalidates nothing. Keep
|
||||
# it below the dependency layers so they stay cached.
|
||||
# Two properties this depends on. ARG is per-stage, so the markdown and
|
||||
# build stages below declare it again; one declaration here would leave
|
||||
# their gates cacheable. And each gate RUN must reference the value,
|
||||
# because BuildKit hashes the expanded command: a declared but
|
||||
# unreferenced ARG invalidates nothing.
|
||||
#
|
||||
# It sits below the dependency layers deliberately. Everything above it
|
||||
# (the pinned base image, go mod download) keeps its cache; only the
|
||||
# gates go cold.
|
||||
ARG CHECK_EPOCH
|
||||
RUN if [ -z "${CHECK_EPOCH}" ]; then \
|
||||
echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# These gates call the tools directly, not through `make lint` or
|
||||
# `make fmt-check`: both run docker, which cannot run inside a docker
|
||||
# build. This step is the gofmt half of `make fmt-check`; the markdown
|
||||
# stage is its prettier half. gofmt's output is assigned to a variable
|
||||
# first so that its own exit status, as when it cannot parse a file,
|
||||
# still fails the step.
|
||||
# The linter is invoked directly here, not through `make lint`. That
|
||||
# target now runs `docker build -f Dockerfile.lint`, and a docker build
|
||||
# cannot run a docker build: routing the gate through make would mean
|
||||
# nesting docker inside this image. Same reason `make check` is gone
|
||||
# from the build stage below, and `make fmt-check` from both stages: it
|
||||
# runs prettier through docker too. Its gofmt half is the step below,
|
||||
# its Markdown half the markdown stage further down.
|
||||
RUN echo "gate gofmt, epoch ${CHECK_EPOCH}" && \
|
||||
files="$(gofmt -s -l .)" && \
|
||||
if [ -n "$files" ]; then \
|
||||
echo "gofmt: files not formatted:" >&2; echo "$files" >&2; exit 1; \
|
||||
fi
|
||||
test -z "$(gofmt -s -l .)" || \
|
||||
{ echo "gofmt: files not formatted:" >&2; gofmt -s -l . >&2; exit 1; }
|
||||
|
||||
# Fails the build when the FROM above and the one in Dockerfile.lint pin
|
||||
# different linter images.
|
||||
# The FROM above and the one in Dockerfile.lint pin the same linter
|
||||
# twice, and nothing else keeps them in sync; this fails the build when
|
||||
# they disagree. See the script for why it restates neither pin.
|
||||
RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \
|
||||
script/verify-lint-image-pin
|
||||
|
||||
@@ -66,13 +67,9 @@ WORKDIR /src
|
||||
# Markdown stage: the Markdown half of `make fmt-check`, as a gate.
|
||||
FROM prettier AS markdown
|
||||
COPY . .
|
||||
# Second per-stage declaration of the gate cache-buster and its check;
|
||||
# see the lint stage above.
|
||||
# Second per-stage declaration of the gate cache-buster; see the lint
|
||||
# stage above.
|
||||
ARG CHECK_EPOCH
|
||||
RUN if [ -z "${CHECK_EPOCH}" ]; then \
|
||||
echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
RUN echo "gate prettier, epoch ${CHECK_EPOCH}" && \
|
||||
prettier --check '**/*.md' --tab-width 4 --prose-wrap always
|
||||
|
||||
@@ -82,13 +79,16 @@ FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c
|
||||
|
||||
# We never build or run as root. Create an unprivileged user and point
|
||||
# HOME and the build cache at its home so go build and go test can write
|
||||
# it when we drop to it below.
|
||||
# it when we drop to it below. $GOPATH/bin is deliberately not on PATH:
|
||||
# script/bootstrap no longer `go install`s anything (the linter runs
|
||||
# from a pinned image, never from a host install), so nothing lands
|
||||
# there and adding it would only widen what this image resolves.
|
||||
#
|
||||
# The module cache stays at the base image's default /go/pkg/mod and
|
||||
# belongs to root: script/bootstrap fills it as root. Do not move it
|
||||
# into the home and hand it over with `chown -R`: that walks every file
|
||||
# in it, which took from about 80 s to over ten minutes on a shared
|
||||
# host, depending on load.
|
||||
# The module cache is kept outside that home, at the base image's
|
||||
# default /go/pkg/mod, and belongs to root: script/bootstrap fills it as
|
||||
# root. Do not move it into the home and hand it over with `chown -R`:
|
||||
# that walks every file in it, which took from about 80 s to over ten
|
||||
# minutes on a shared host, depending on load.
|
||||
RUN adduser -D -u 1000 builder
|
||||
ENV HOME=/home/builder
|
||||
ENV GOPATH=/home/builder/go
|
||||
@@ -97,16 +97,24 @@ ENV GOCACHE=/home/builder/.cache/go-build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# No-op file copies whose only purpose is the build-graph edge: they
|
||||
# make this stage depend on the lint and markdown stages, so BuildKit
|
||||
# finishes those gates before compilation and tests start. Remove one
|
||||
# and the build silently stops gating on that stage and still exits 0.
|
||||
# No-op file copies whose only purpose is the build-graph edge: they are
|
||||
# what make this stage depend on the lint and markdown stages, and so
|
||||
# what forces BuildKit to finish gofmt, the pin guard, lint and prettier
|
||||
# before compilation and tests start. Remove one and the fail-fast
|
||||
# design dies silently — the build stops gating on that stage and still
|
||||
# exits 0. The first replaces a copy of the linter binary itself, which
|
||||
# is no longer wanted here: nothing in this stage runs the linter,
|
||||
# because `make lint` is now a docker build and a docker build cannot
|
||||
# run inside one.
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=markdown /src/go.sum /dev/null
|
||||
|
||||
# Install development prerequisites the same way a developer does. Only
|
||||
# script/ and the dependency manifests are copied first, so this layer
|
||||
# stays cached until they change. Bootstrap ends in `go mod download`.
|
||||
# Install development prerequisites the same way a developer does,
|
||||
# rather than duplicating the installs inline. Only script/ and the
|
||||
# dependency manifests are copied first, nothing else, so this layer
|
||||
# stays cached until the scripts or the dependencies change — bootstrap
|
||||
# ends in `go mod download`, which is why there is no separate
|
||||
# invocation of it here.
|
||||
COPY script/ script/
|
||||
COPY go.mod go.sum ./
|
||||
RUN script/bootstrap
|
||||
@@ -129,22 +137,22 @@ RUN chown builder:builder /src /go/pkg/mod/cache/download && \
|
||||
COPY --chown=builder:builder . .
|
||||
USER builder
|
||||
|
||||
# Fail the build unless the branch is green. Runs as non-root: root
|
||||
# would bypass the chmod(0) the permission-denied tests rely on.
|
||||
# Fail the build unless the branch is green. Runs as non-root so the
|
||||
# permission-denied test paths are exercised legitimately (root would
|
||||
# bypass the chmod(0) the tests rely on).
|
||||
#
|
||||
# The gate is `make test`, not `make check`, which runs docker; lint and
|
||||
# the format checks ran in the lint and markdown stages above. `make`,
|
||||
# not the script directly, because the Makefile's
|
||||
# The gate is `make test`, not `make check`: that aggregate runs
|
||||
# `script/lint` and `script/fmt-check`, which both run docker, and
|
||||
# nothing inside an image build may shell out to docker. Lint and the
|
||||
# format checks are not skipped by this — they ran in the lint and
|
||||
# markdown stages above, which this stage's COPY --from lines make
|
||||
# prerequisites. `make`, not the script directly, because the Makefile's
|
||||
# `export CGO_ENABLED = 0` applies only to what it invokes.
|
||||
#
|
||||
# Third per-stage declaration of the gate cache-buster and its check;
|
||||
# see the lint stage above. It is placed after USER so the drop to the
|
||||
# unprivileged user still happens before the checks run.
|
||||
# Third per-stage declaration of the gate cache-buster; see the lint
|
||||
# stage above for why one is not enough. It is placed after USER so the
|
||||
# drop to the unprivileged user still happens before the checks run.
|
||||
ARG CHECK_EPOCH
|
||||
RUN if [ -z "${CHECK_EPOCH}" ]; then \
|
||||
echo "CHECK_EPOCH is unset; build via script/cibuild or script/docker" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
RUN echo "gate test, epoch ${CHECK_EPOCH}" && make test
|
||||
|
||||
# The version stamped into the binary: the VERSION build argument when
|
||||
|
||||
+35
-21
@@ -1,12 +1,14 @@
|
||||
# Lint-only image, built by script/lint: the repo is copied into the
|
||||
# pinned golangci-lint image and the linter runs as a build step, so a
|
||||
# successful build is a clean lint. No bind mount, so it works when the
|
||||
# docker daemon is remote.
|
||||
# Lint-only image: this is how the linter runs, everywhere. The repo is
|
||||
# COPYed into the pinned golangci-lint image and the linter runs as a
|
||||
# build step, so a successful build IS a clean lint. golangci-lint is
|
||||
# never installed on a host — one toolchain, pinned by digest, identical
|
||||
# on a laptop and in CI — and this works even when the docker daemon is
|
||||
# remote and bind mounts are impossible.
|
||||
#
|
||||
# It is separate from the main Dockerfile's lint stage because
|
||||
# script/lint must not depend on the rest of that build; the two FROM
|
||||
# lines are kept identical by script/verify-lint-image-pin, run as a
|
||||
# gate below.
|
||||
# script/lint builds this file. It is a separate image from the lint
|
||||
# stage of the main Dockerfile because script/lint must not depend on
|
||||
# the rest of that build; the two FROM lines are kept identical by
|
||||
# script/verify-lint-image-pin, run as a gate below.
|
||||
# golangci/golangci-lint:v2.12.2, 2026-08-07
|
||||
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
|
||||
|
||||
@@ -18,26 +20,38 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Cache-buster for the gate layers, and only for them; caching of the
|
||||
# lint run is waived by ruling. On an unchanged tree the gates below
|
||||
# would be served from cache and this build would exit 0 in under a
|
||||
# second having run no linter. script/lint passes a fresh value on every
|
||||
# Cache-buster for the gate layers, and only for them. Caching of the
|
||||
# lint run is waived by ruling: COPY is invalidated only by changed
|
||||
# content, so on an unchanged tree the gates below would be served from
|
||||
# cache and this build would exit 0 in under a second having run no
|
||||
# linter at all. That exact false green has bitten this repo twice
|
||||
# already (#32, #39). script/lint passes a fresh value on every
|
||||
# invocation.
|
||||
#
|
||||
# Each gate RUN must reference the value: BuildKit hashes the expanded
|
||||
# command, so a declared but unreferenced ARG invalidates nothing. Keep
|
||||
# it below the dependency layers so they stay cached.
|
||||
# Each gate RUN must reference the value, because BuildKit hashes the
|
||||
# expanded command and not the ARG declaration: a declared but
|
||||
# unreferenced ARG invalidates nothing. The ARG sits below the
|
||||
# dependency layers deliberately — everything above it keeps its cache,
|
||||
# only the gates go cold.
|
||||
ARG CHECK_EPOCH
|
||||
|
||||
# Fails the build when the FROM above and the main Dockerfile's lint
|
||||
# stage pin different linter images.
|
||||
# The linter version is pinned in two places, here and in the main
|
||||
# Dockerfile's lint stage. Nothing else keeps them in sync, so a
|
||||
# half-applied bump is a build failure; see the script.
|
||||
RUN echo "gate lint-image-pin, epoch ${CHECK_EPOCH}" && \
|
||||
script/verify-lint-image-pin
|
||||
|
||||
# Validates .golangci.yml against golangci-lint's JSON schema, which the
|
||||
# pinned binary embeds: measured under `--network none`, it passes a
|
||||
# valid config and rejects an invalid one. No gate step makes a network
|
||||
# call, but `go mod download` above needs the network on a cold cache.
|
||||
# Validates .golangci.yml against golangci-lint's JSON schema. The
|
||||
# concern about this step was that it fetches that schema over a live,
|
||||
# unpinned HTTPS call; measured on the pinned image, it does not. The
|
||||
# binary carries the schema for its own version, so under
|
||||
# `--network none` this both passes on a valid config and still rejects
|
||||
# an invalid one with the jsonschema error. That holds for the gate
|
||||
# steps generally — none of them makes a network call — but not for
|
||||
# this build as a whole: `go mod download` above needs the network on a
|
||||
# cold cache, and under `--network none` a first build fails there
|
||||
# before reaching any gate. That layer stays cached, so only a warm
|
||||
# cache lints offline, until go.mod or go.sum changes.
|
||||
RUN echo "gate config verify, epoch ${CHECK_EPOCH}" && \
|
||||
golangci-lint config verify --config .golangci.yml
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ BINARY := sfdupes
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -X main.Version=$(VERSION)
|
||||
|
||||
.PHONY: sfdupes build bootstrap setup test test-race lint fmt fmt-check check docker hooks clean
|
||||
.PHONY: sfdupes build bootstrap setup test lint fmt fmt-check check docker hooks clean
|
||||
|
||||
# Standard targets are thin shims; the implementations live in script/
|
||||
# per the scripts-to-rule-them-all pattern.
|
||||
@@ -27,9 +27,6 @@ setup:
|
||||
test:
|
||||
@script/test
|
||||
|
||||
test-race:
|
||||
@script/test-race
|
||||
|
||||
lint:
|
||||
@script/lint
|
||||
|
||||
|
||||
@@ -263,29 +263,22 @@ All three subcommands operate on a single SQLite database file:
|
||||
|
||||
- Location: the value of the `SFDUPES_DATABASE` environment variable when set
|
||||
and non-empty, otherwise `/var/lib/sfdupes/db.sqlite`. There is no
|
||||
command-line flag. The path names the file exactly, whatever characters it
|
||||
holds (`?`, `#` and `%` included); a relative path is relative to the working
|
||||
directory.
|
||||
command-line flag.
|
||||
- `scan` creates the database (and its parent directory) on first use. `report`
|
||||
and `trees` require an existing database; a missing database file is a fatal
|
||||
error (exit 1) telling the user to run `scan` first.
|
||||
- Only one `scan` runs against a database at a time. For its whole run, `scan`
|
||||
holds an exclusive `flock(2)` lock on a lock file beside the database, named
|
||||
by appending `.lock` to the database's path with symlinks resolved
|
||||
(`/var/lib/sfdupes/db.sqlite.lock` by default), taken before it walks the
|
||||
filesystem or opens the database. Every path to one database, through a
|
||||
symlink to the file or to a directory above it, names the same lock file. Just
|
||||
before it takes the lock, `scan` creates the database file, empty, if it is
|
||||
missing (through a symlink at the database path, it creates the symlink's
|
||||
target), so the lock file is named the same way before the first scan sets up
|
||||
the database as after. A second `scan` against the same database does not
|
||||
wait: it fails at once with a one-line error naming the lock file and exits 1,
|
||||
without walking anything or opening the database, and the running scan carries
|
||||
on. The lock file is created on first use, open to its owner only, and left in
|
||||
place: a leftover file blocks nothing, because the lock ends with the process
|
||||
holding it however it ends, a fatal error or an interrupt included, and
|
||||
deleting the file while a scan runs would let a second scan start. `report`
|
||||
and `trees` never take the lock, so they run during a scan.
|
||||
by appending `.lock` to the database path (`/var/lib/sfdupes/db.sqlite.lock`
|
||||
by default), taken before it walks the filesystem or opens the database. A
|
||||
second `scan` against the same database does not wait: it fails at once with a
|
||||
one-line error naming the lock file and exits 1, without walking anything or
|
||||
opening the database, and the running scan carries on. The lock file is
|
||||
created on first use, open to its owner only, and left in place: a leftover
|
||||
file blocks nothing, because the lock ends with the process holding it however
|
||||
it ends, a fatal error or an interrupt included, and deleting the file while a
|
||||
scan runs would let a second scan start. `report` and `trees` never take the
|
||||
lock, so they run during a scan.
|
||||
- While `scan` runs, the database is in WAL journal mode with a busy timeout, so
|
||||
running a report while a cron `scan` is in progress is safe. The filesystem is
|
||||
authoritative; the database is an eventually-consistent reflection of it.
|
||||
@@ -317,34 +310,29 @@ All three subcommands operate on a single SQLite database file:
|
||||
|
||||
```sql
|
||||
CREATE TABLE files (
|
||||
path BLOB PRIMARY KEY, -- absolute path, raw bytes
|
||||
size INTEGER NOT NULL, -- bytes, from lstat
|
||||
mtime INTEGER NOT NULL, -- whole Unix seconds of the mtime, from lstat
|
||||
mtime_nsec INTEGER NOT NULL, -- nanoseconds within that second, 0 to 999999999
|
||||
head TEXT NOT NULL, -- lowercase-hex SHA-256; first 64 KiB, or whole file under 10 MiB
|
||||
tail TEXT NOT NULL, -- lowercase-hex SHA-256; last 64 KiB, or whole file under 10 MiB
|
||||
content TEXT NOT NULL -- lowercase-hex SHA-256, whole file or samples
|
||||
path BLOB PRIMARY KEY, -- absolute path, raw bytes
|
||||
size INTEGER NOT NULL, -- bytes, from lstat
|
||||
mtime INTEGER NOT NULL, -- Unix seconds, from lstat
|
||||
head TEXT NOT NULL, -- lowercase-hex SHA-256; first 64 KiB, or whole file under 10 MiB
|
||||
tail TEXT NOT NULL, -- lowercase-hex SHA-256; last 64 KiB, or whole file under 10 MiB
|
||||
content TEXT NOT NULL -- lowercase-hex SHA-256, whole file or samples
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX files_signature ON files (size, head, tail, content);
|
||||
```
|
||||
|
||||
Paths are stored as BLOBs because Unix paths are raw bytes, not guaranteed
|
||||
UTF-8. `mtime` and `mtime_nsec` hold the file's mtime to the nanosecond:
|
||||
`mtime` the whole Unix seconds, rounded down, and `mtime_nsec` the
|
||||
nanoseconds past that second. Split this way they hold any mtime a
|
||||
filesystem can record, one before 1678 or after 2262 included, which a
|
||||
single 64-bit count of nanoseconds cannot. They are used only for change
|
||||
detection and are not part of the duplicate key. For a file under 10 MiB
|
||||
`head`, `tail`, and `content` all hold the whole-file hash (that range is
|
||||
hashed in full, with no end windows); for a larger file `head` and `tail`
|
||||
hold the first- and last-64 KiB hashes and `content` the whole-file or
|
||||
sampled hash. All three are empty strings when the file has never been
|
||||
hashed because its size was unique as of the last scan that covered it. For
|
||||
a file of 10 MiB or more, `content` stays empty until the content phase of a
|
||||
scan (see "`scan` mode" below) has read the file. A record with an empty
|
||||
`content` is never part of a duplicate group, though it still defines the
|
||||
file for tree reconstruction. The `files_signature` index lets SQLite group
|
||||
the records by signature for `report` without sorting the whole table.
|
||||
UTF-8. `mtime` is used only for change detection; it is not part of the
|
||||
duplicate key. For a file under 10 MiB `head`, `tail`, and `content` all
|
||||
hold the whole-file hash (that range is hashed in full, with no end
|
||||
windows); for a larger file `head` and `tail` hold the first- and last-64
|
||||
KiB hashes and `content` the whole-file or sampled hash. All three are empty
|
||||
strings when the file has never been hashed because its size was unique as
|
||||
of the last scan that covered it. For a file of 10 MiB or more, `content`
|
||||
stays empty until the content phase of a scan (see "`scan` mode" below) has
|
||||
read the file. A record with an empty `content` is never part of a duplicate
|
||||
group, though it still defines the file for tree reconstruction. The
|
||||
`files_signature` index lets SQLite group the records by signature for
|
||||
`report` without sorting the whole table.
|
||||
|
||||
### Duplicate detection
|
||||
|
||||
@@ -398,11 +386,7 @@ results do not depend on the working directory. All operands belong to a single
|
||||
scan and are enumerated concurrently: every operand seeds the shared walk worker
|
||||
pool. Overlapping operands are harmless — an operand that duplicates another or
|
||||
lies under another is dropped before walking, so every file is reached exactly
|
||||
once and produces one database record. With `-x`, though, an operand under
|
||||
another one is kept and walked on its own when any directory from it (from its
|
||||
directory, for a regular file) up to but not including the operand covering it
|
||||
is on a different filesystem than that operand, because that operand's walk does
|
||||
not enter such a directory and so does not reach it.
|
||||
once and produces one database record.
|
||||
|
||||
An operand that is a symlink (never followed, not even as an operand), socket,
|
||||
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
|
||||
@@ -411,8 +395,9 @@ drops it from the scanned operands before reading the database. Another operand
|
||||
beneath it is still scanned. The records stored beneath it are not deleted: they
|
||||
are treated like any other record outside the scanned operands, including the
|
||||
content-phase exception below. If it lies under another operand, they are under
|
||||
that operand instead, and are treated like any other record there. This is not
|
||||
an error: a scan whose every operand is dropped walks nothing and exits 0.
|
||||
that operand instead, and are deleted like any other record there that this scan
|
||||
did not verify. This is not an error: a scan whose every operand is dropped
|
||||
walks nothing and exits 0.
|
||||
|
||||
`scan` synchronizes the database with the filesystem state under the scanned
|
||||
operands:
|
||||
@@ -434,9 +419,6 @@ operands:
|
||||
once its size, `head`, and `tail` match another record's.
|
||||
- A file whose mtime is newer than recorded, or whose size differs, is processed
|
||||
as if new: re-hashed, or recorded without hashes, per the shared-size rule.
|
||||
Change detection compares the mtime to the nanosecond, as finely as the
|
||||
filesystem records it, so a same-size rewrite counts as a change whenever the
|
||||
filesystem gives it a later mtime than recorded, even within the same second.
|
||||
- A database record whose path lies under one of the scanned operands but was
|
||||
not successfully processed this run is deleted. This removes records for
|
||||
deleted files. It also removes records for paths that failed to stat or hash
|
||||
@@ -450,12 +432,6 @@ operands:
|
||||
a `content` hash is read for one, wherever it lies, once its size, `head`, and
|
||||
`tail` match another record's. If that file is gone or has changed since its
|
||||
record was written, the record is left as it is.
|
||||
- With `-x`, a record below a directory the walk did not enter because that
|
||||
directory is on another filesystem than its operand counts as outside the
|
||||
scanned operands: it is not deleted, and its size joins the size census. A
|
||||
filesystem mounted under an operand can therefore be scanned on its own
|
||||
schedule into the same database. Records below an operand at or inside such a
|
||||
directory are not kept this way, since that operand's own walk verifies them.
|
||||
|
||||
`scan` runs **four sequential phases over the whole scan**. Parallelism lives
|
||||
inside each phase; batched database writes begin during the hash phase:
|
||||
@@ -522,29 +498,23 @@ Rules for the walk:
|
||||
- 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. The records below such a directory,
|
||||
unless they lie below an operand at or inside that directory, count as outside
|
||||
the scanned operands, as described in "`scan` mode" above: they are not
|
||||
deleted, and their sizes join the size census.
|
||||
operand is bounded by its own filesystem.
|
||||
- On any per-path error (permission denied, file vanished between passes,
|
||||
unreadable): print a one-line warning to stderr, skip the path, and continue.
|
||||
Per-file errors never abort the run; the final summary reports how many were
|
||||
skipped. As specified above, a skipped path that has a database record from an
|
||||
earlier scan loses that record, unless it failed only in the content phase, or
|
||||
is an operand dropped before the database was read whose record counts as
|
||||
outside the scanned operands; an unreadable directory subtree likewise loses
|
||||
its records (accepted: the database mirrors what the latest scan could
|
||||
actually verify).
|
||||
is an operand dropped before the database was read that lies under no other
|
||||
operand; an unreadable directory subtree likewise loses its records (accepted:
|
||||
the database mirrors what the latest scan could actually verify).
|
||||
|
||||
Concurrency: the walk phase (which also stats files), the hash phase, and the
|
||||
content phase each use a worker pool of `--workers` workers (default
|
||||
`runtime.NumCPU()`); the walk parallelizes across directories, hashing across
|
||||
files. `--workers` must be at least 1: a smaller value is a usage error,
|
||||
reported in one line on stderr with exit 2 before anything is scanned. All three
|
||||
phases are seek-bound on spinning disks, so raising `--workers` well past the
|
||||
core count can help on pools with many spindles. The main goroutine owns
|
||||
partitioning, database writes, and progress rendering; progress display must
|
||||
never block the workers.
|
||||
files. All three phases are seek-bound on spinning disks, so raising `--workers`
|
||||
well past the core count can help on pools with many spindles. The main
|
||||
goroutine owns partitioning, database writes, and progress rendering; progress
|
||||
display must never block the workers.
|
||||
|
||||
`scan` writes nothing to stdout. The summary line on stderr reports the files
|
||||
seen this run broken down by disposition, plus skips:
|
||||
@@ -720,8 +690,8 @@ Additional requirements:
|
||||
already running against the same database, the database cannot be
|
||||
created/opened/read/written, a missing database for `report`/`trees`, stdout
|
||||
write failure), or a `scan` stopped by `SIGINT` or `SIGTERM` (see below).
|
||||
- `2`: usage error (including `scan` with no `PATH` operand, `scan` with
|
||||
`--workers` below 1, and `report`/`trees` with any positional argument).
|
||||
- `2`: usage error (including `scan` with no `PATH` operand and `report`/`trees`
|
||||
with any positional argument).
|
||||
|
||||
A stdout write failure, such as a full disk, is reported in one line on stderr
|
||||
and exits 1. Two cases never reach sfdupes as a failed write:
|
||||
@@ -768,33 +738,22 @@ entrypoints are:
|
||||
installed: they run in Docker (see `script/lint` and `script/fmt`) and never
|
||||
from a host install, so there is no host copy to drift from the pin. A missing
|
||||
`docker` is warned about rather than installed or treated as fatal —
|
||||
everything except linting, formatting and `make test-race` works without it.
|
||||
Ends with `go mod download`.
|
||||
everything except linting and formatting works without it. Ends with
|
||||
`go mod download`.
|
||||
- `script/setup` — make a fresh clone ready for development: runs
|
||||
`script/bootstrap`, then `script/install-precommit`.
|
||||
- `script/projectname` — print this project's name (`sfdupes`). Scripts that
|
||||
need the name call it, so they stay identical across repositories.
|
||||
- `script/test` — run the test suite with a 30-second timeout and coverage
|
||||
enabled, rerunning verbosely on failure so the logs show which test failed.
|
||||
- `script/test-race` — run the test suite under the race detector with a
|
||||
60-second timeout. The detector needs cgo and a C compiler, which the build
|
||||
never uses, so the tests run in a digest-pinned Debian `golang` image that has
|
||||
`gcc`, with the checkout mounted read-only; the container is removed when it
|
||||
exits. They run as the calling user, or as `nobody` when that is root, because
|
||||
several tests make a file unreadable and root reads it anyway; only then must
|
||||
the checkout be readable by other users. Not part of `script/check`. Every run
|
||||
starts with empty caches, so it needs the network and takes minutes, and the
|
||||
mount needs a local docker daemon.
|
||||
- `script/lint` — run the linter. It builds `Dockerfile.lint`, which copies the
|
||||
repository into the digest-pinned `golangci/golangci-lint` image and runs
|
||||
`golangci-lint config verify` and `golangci-lint run` as build steps, so a
|
||||
successful build is a clean lint. That exit status is all it produces, so it
|
||||
runs with `--output=type=cacheonly` and writes no image; a run leaves only
|
||||
build cache. The linter is never run on the host, which makes a working
|
||||
`docker` the one prerequisite for linting — and therefore for `make check` and
|
||||
the pre-commit hook. Offline machines: the gate steps themselves make no
|
||||
network calls. `golangci-lint run` does not, and neither does
|
||||
`golangci-lint config verify` — it validates against a schema the pinned
|
||||
successful build is a clean lint. The linter is never run on the host, which
|
||||
makes a working `docker` the one prerequisite for linting — and therefore for
|
||||
`make check` and the pre-commit hook. Offline machines: the gate steps
|
||||
themselves make no network calls. `golangci-lint run` does not, and neither
|
||||
does `golangci-lint config verify` — it validates against a schema the pinned
|
||||
binary embeds, measured under `--network none` to both pass a valid config and
|
||||
reject an invalid one. The build around them does. `Dockerfile.lint` runs
|
||||
`go mod download` before the gates and this module has external dependencies,
|
||||
@@ -856,12 +815,9 @@ from binary-versus-pin to pin-versus-pin, which is what
|
||||
gate layers from cache and the build exits 0 having executed no tests and no
|
||||
lint — a green it never earned, and one this repository has produced twice.
|
||||
`CHECK_EPOCH` invalidates the gate layers on every run while leaving the pinned
|
||||
base images and the dependency layers cached. Each script's value carries the
|
||||
process id as well as the epoch, because two runs land inside the same second
|
||||
easily and a bare epoch would cache the second one. Each `Dockerfile` stage with
|
||||
gates fails when the value is empty, so a bare `docker build .` stops with
|
||||
`CHECK_EPOCH is unset; build via script/cibuild or script/docker` instead of
|
||||
serving the gates from cache.
|
||||
base images and the dependency layers cached. `script/lint`'s value carries the
|
||||
process id as well as the epoch, because two lint runs land inside the same
|
||||
second easily and a bare epoch would cache the second one.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -875,8 +831,6 @@ compile recipe:
|
||||
- `make setup` — prepare a fresh clone: `bootstrap` plus the pre-commit hook.
|
||||
- `make test` — run the test suite (30-second timeout; reruns with `-v` on
|
||||
failure).
|
||||
- `make test-race` — run the test suite under the race detector, in Docker (see
|
||||
`script/test-race`); requires `docker`. Not part of `make check`.
|
||||
- `make lint` — run `golangci-lint` with the repo config, in Docker (see
|
||||
`script/lint`); requires `docker`.
|
||||
- `make fmt` / `make fmt-check` — format the Go sources and the Markdown /
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
- take an issue from the `1.0.0` milestone on the tracker; work not yet on the
|
||||
tracker gets filed as an issue first
|
||||
- branch from `next`
|
||||
- branch (from `main`)
|
||||
- do the work, with tests, in small focused commits
|
||||
- record it at the top of Completed Steps (`TODO.md` changes in the same commit
|
||||
as the work)
|
||||
- push the branch and open a PR against `next` whose title ends with
|
||||
` (closes #N)`
|
||||
- an independent review gates each merge to `next`; every finding is addressed
|
||||
or explicitly rebutted on the PR
|
||||
- only the owner merges `next` to `main`
|
||||
- push the branch and open a PR whose title ends with ` (closes #N)`
|
||||
- an independent review gates the merge; every finding is addressed or
|
||||
explicitly rebutted on the PR
|
||||
- merge to `main` once the review passes
|
||||
|
||||
# Status
|
||||
|
||||
@@ -29,82 +28,10 @@
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- with `-x`, an operand under another operand is walked on its own instead of
|
||||
being dropped when a directory from it (from its directory, for a regular
|
||||
file) up to but not including that operand is on a different filesystem than
|
||||
that operand, whose walk then cannot reach it, and the records of files gone
|
||||
from below it are deleted (2026-10-14,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/106)
|
||||
|
||||
- with `-x`, `scan` keeps the records below a directory on another filesystem
|
||||
under its operand, and counts their sizes in the size census, instead of
|
||||
deleting them (2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/99)
|
||||
|
||||
- the hash and content phases open each file without blocking and check that it
|
||||
is still a regular file, so one replaced by a FIFO or device node after it was
|
||||
statted is warned about and skipped instead of hanging the scan (2026-10-14,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/100)
|
||||
|
||||
- the scan lock file is named from the database's path with symlinks resolved,
|
||||
after `scan` creates the database file if it is missing, so a scan through a
|
||||
symlink to the database fails while another scan holds the lock (2026-10-14,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/101)
|
||||
|
||||
- on a terminal, a warning for the last file of a phase no longer erases the
|
||||
phase's finished bar: the bar is drawn again below the warning (2026-10-14,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/102)
|
||||
|
||||
- `scan` records mtime to the nanosecond, as whole seconds in `mtime` plus
|
||||
`mtime_nsec`, and compares it at that resolution, so a same-size rewrite
|
||||
within the same second is re-hashed (2026-10-07,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/12)
|
||||
|
||||
- cut the narration from `TODO.md` Completed Steps and from the comments in
|
||||
`script/` and both Dockerfiles; §Workflow now branches from and merges to
|
||||
`next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49)
|
||||
|
||||
- `make test-race` runs the test suite under the race detector in a cgo-enabled
|
||||
container, outside `make check` (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/18)
|
||||
|
||||
- a bare `docker build .` fails with a message naming `script/cibuild` and
|
||||
`script/docker` instead of serving the gates from cache (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/39)
|
||||
|
||||
- `make fmt` and `make fmt-check` run prettier over all Markdown, in Docker, and
|
||||
CI checks it; all Markdown reformatted (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/19)
|
||||
|
||||
- `script/lint` writes no image, so a run no longer leaves an untagged one
|
||||
behind (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/48)
|
||||
|
||||
- tests cover a missing database, `scan` keeping stdout empty, its skip warning,
|
||||
the `report` and `trees` summary lines, and every subcommand going through
|
||||
`runE` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/16)
|
||||
|
||||
- `.golangci.yml` replaced with the current canonical copy, which uses
|
||||
`gomodguard_v2`, so lint no longer prints a deprecation warning (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/26)
|
||||
|
||||
- a test fails when either `hashWorker` cancellation check in `scan.go` is
|
||||
removed (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/83)
|
||||
|
||||
- a database path holding `?`, `#` or `%` opens exactly the file it names
|
||||
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/55)
|
||||
|
||||
- `scan` rejects `--workers` below 1 as a usage error instead of running
|
||||
single-threaded (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/10)
|
||||
|
||||
- a test fails when either walk cancellation check in `scan.go` is removed
|
||||
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/81)
|
||||
|
||||
- test that `scan` refuses a database with another schema version (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/64)
|
||||
|
||||
- correct four inaccurate comments in `cancel_test.go` and rename
|
||||
`walkCancelInFlightDirs` to `walkCancelInFlightFiles` (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/33)
|
||||
|
||||
- test the `-x` filesystem-boundary rules in `subdirJob` (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/17)
|
||||
|
||||
@@ -156,24 +83,29 @@
|
||||
|
||||
- stamp the git tag or short commit in a plain `docker build .` instead of `dev`
|
||||
(2026-10-02, branch `next`, closes
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/67): `.dockerignore` sends `.git`
|
||||
without `.git/config`; the build stage stamps the `VERSION` build argument,
|
||||
else `git describe --tags --always`, and fails if the context carries `.git`
|
||||
and the version is still empty, `dev` or `unknown`. CI checks out the full
|
||||
history (`fetch-depth: 0`) so it stamps the same value as `make build`.
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/67): `.dockerignore` now sends
|
||||
`.git`, without `.git/config`, and the `Dockerfile` build stage takes the
|
||||
`VERSION` build argument when one is given, otherwise
|
||||
`git describe --tags --always` of that `.git`. The build fails if the context
|
||||
carries `.git` and the version still comes out empty, `dev` or `unknown`. The
|
||||
CI checkout step fetches the full history (`fetch-depth: 0`) so CI sees the
|
||||
tag and stamps the same value as `make build`.
|
||||
|
||||
- replace the 1 KiB end-window sampling with the head/tail plus content-hash
|
||||
ladder (2026-09-22, branch `next`, closes
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/61); README "Duplicate detection"
|
||||
documents every rung. A file under 10 MiB is hashed in full, and its `head`,
|
||||
`tail` and `content` all hold that hash. A larger file gets only its 64 KiB
|
||||
`head` and `tail` in the hash phase; the content phase, after the update
|
||||
phase, reads it for `content` (the whole file below 50 MiB, gigabyte-spaced 1
|
||||
MiB samples at or above) only when its size, `head` and `tail` match another
|
||||
record's from this scan or an earlier one, and never reads a file gone or
|
||||
changed since its record was written. `report` and `trees` leave out any
|
||||
record without a `content` hash. The `content` column is part of the version 1
|
||||
schema.
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/61): a file under 10 MiB is hashed in
|
||||
full and compared directly, with no end-window step — its `head`, `tail`, and
|
||||
`content` all hold the whole-file hash. A file at 10 MiB or above gets only
|
||||
the 64 KiB `head` and `tail` in the hash phase; a new content phase, after the
|
||||
update phase, reads it for its `content` hash — the whole file below 50 MiB,
|
||||
gigabyte-spaced 1 MiB samples at or above — only when its size, `head`, and
|
||||
`tail` match another record's, from the same scan or stored by an earlier one,
|
||||
so a stored file gains its content hash when it gains a match. A file that is
|
||||
gone or has changed since its record was written is not read. The `content`
|
||||
column is part of the version 1 schema. `report` and `trees` group by the
|
||||
extended signature and leave out any record without a `content` hash, so the
|
||||
ladder is applied across the whole database. README "Duplicate detection"
|
||||
documents every rung including the probabilistic large-file path.
|
||||
|
||||
- remove the dead `files.dat` references from `Makefile`, `.gitignore` and
|
||||
`.dockerignore` (2026-09-21, branch `next`, closes
|
||||
@@ -181,162 +113,264 @@
|
||||
|
||||
- fix the lint-image pin comments and `FROM` form in `Dockerfile` and
|
||||
`Dockerfile.lint` (2026-08-10, branch `next`, closes
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/25): both pins are now the policy
|
||||
`# image:vX.Y.Z, YYYY-MM-DD` comment over a bare `FROM image@sha256:...`,
|
||||
without the false `(Debian-based)` note or the tag; digest unchanged.
|
||||
`script/verify-lint-image-pin` still matches the tagless form, and a tag on
|
||||
one side only is caught as a plain mismatch.
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/25): dropped the false
|
||||
`(Debian-based)` parenthetical (v2.12.1 was Debian too) and the redundant tag,
|
||||
so both pins are the policy `# image:vX.Y.Z, YYYY-MM-DD` comment over a bare
|
||||
`FROM image@sha256:...`. Digest unchanged. `script/verify-lint-image-pin`
|
||||
parses those `FROM` lines and still matches the tagless form; its advice line
|
||||
lost the now meaningless "tag and digest". With no tag in either reference, a
|
||||
tag-only disagreement no longer exists — a one-sided tag is caught as a plain
|
||||
mismatch.
|
||||
|
||||
- run all linting in Docker via `Dockerfile.lint` and `script/lint` (2026-08-10,
|
||||
branch `next`, closes https://git.eeqj.de/sneak/sfdupes/issues/46): per the
|
||||
owner ruling the linter is never installed on a host. `Dockerfile.lint` copies
|
||||
owner ruling, the linter runs inside a container invoked through the `script/`
|
||||
entrypoint and is never installed on a host. New root `Dockerfile.lint` COPYs
|
||||
the repo into the digest-pinned `golangci/golangci-lint:v2.12.2` image and
|
||||
runs `golangci-lint config verify` and `golangci-lint run` as build steps;
|
||||
`script/lint` builds it. `script/bootstrap` no longer installs or pins the
|
||||
linter, and warns rather than fails when `docker` is absent;
|
||||
`ENV PATH=/home/builder/go/bin:$PATH` went with its `go install`.
|
||||
`script/verify-linter-pin` is retired; `script/verify-lint-image-pin`, a gate
|
||||
in both files, compares their two `FROM` lines and restates neither pin.
|
||||
Traps: an unchanged tree lets a lint build pass in under a second having run
|
||||
no linter, so every gate `RUN` references `ARG CHECK_EPOCH` (BuildKit hashes
|
||||
the expanded command) and `script/lint` passes `"$(date +%s)-$$"`, the PID
|
||||
because two runs land in the same second easily. Nothing inside an image build
|
||||
may shell out to docker, so the `Dockerfile` lint stage calls `golangci-lint`
|
||||
directly and the build stage runs `make test` and `make fmt-check` instead of
|
||||
`make check`, through `make` because the Makefile's `export CGO_ENABLED = 0`
|
||||
only reaches what it invokes. `COPY --from=lint /src/go.sum /dev/null`
|
||||
replaces the copied linter binary as the only edge making the build stage wait
|
||||
for lint; dropping it would end fail-fast linting under a still-green build.
|
||||
`golangci-lint config verify`, included per the ruling, validates from an
|
||||
embedded schema with no network call, but `go mod download` above the gates
|
||||
still needs the network on a cold cache. Verified: `make lint` green with no
|
||||
`golangci-lint` on `PATH`; two back-to-back `script/lint` runs on an untouched
|
||||
tree both ran the linter (27.7s and 28.7s in the lint step, `COPY . .`
|
||||
`CACHED` above); a planted unused variable failed `script/lint`, and failed
|
||||
`make docker` at `[lint 9/9]` with the build stage stopped at
|
||||
`[builder 3/12]`; the drift guard fails on a tag-only, a digest-only and an
|
||||
unreadable reference, naming both sides; under `--network none` config verify
|
||||
passes a valid config and rejects an invalid one; `make docker` green in 5m35s
|
||||
with all six gates run under one epoch (lint 37.6s, test 25.2s reporting
|
||||
`ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5%`, not `(cached)`); in the
|
||||
builder image with the Go test cache off, `--user 0:0` still fails
|
||||
`TestScanHardlinkRunFailsTogether` where the unprivileged user passes. Noted
|
||||
for follow-up, not fixed here: `golangci-lint` warns that `gomodguard` is
|
||||
deprecated since v2.12.0 in favour of `gomodguard_v2`.
|
||||
runs `golangci-lint config verify` and `golangci-lint run` as build steps, so
|
||||
a successful build IS a clean lint; `script/lint` is reduced to building it.
|
||||
`script/bootstrap` loses the `go install`, the pin constants, the version
|
||||
parser and `verify_golangci_lint` outright rather than hardening them — with
|
||||
nothing linting on the host, the `$GOPATH/bin` versus `PATH` problem that
|
||||
motivated them has no subject — and now warns rather than fails when `docker`
|
||||
is absent. Two traps handled. A lint build on an unchanged tree returns
|
||||
success in well under a second having run no linter, which is
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/32 and
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/39 again, so `Dockerfile.lint`
|
||||
carries `ARG CHECK_EPOCH` referenced inside every gate `RUN` (BuildKit hashes
|
||||
the expanded command, not the declaration) and `script/lint` passes
|
||||
`"$(date +%s)-$$"` — the PID matters because two lint runs land inside the
|
||||
same second easily. And nothing inside an image build may shell out to docker,
|
||||
so the main `Dockerfile`'s lint stage now invokes `golangci-lint` directly
|
||||
instead of `make lint`, and its build stage runs `make test` and
|
||||
`make fmt-check` instead of the `make check` aggregate (`make`, not the
|
||||
scripts bare, because the Makefile's `export CGO_ENABLED = 0` only reaches
|
||||
what it invokes). `COPY --from=lint` `/usr/bin/golangci-lint` is replaced by
|
||||
`COPY --from=lint /src/go.sum /dev/null`: the copied binary was the only edge
|
||||
forcing BuildKit to finish linting before the build stage starts, and dropping
|
||||
it without replacing the edge would have ended fail-fast linting silently
|
||||
under a still-green build. That is canonical `REPO_POLICIES.md:107`'s ordering
|
||||
edge, restored. `ENV PATH=/home/builder/go/bin:$PATH` is gone with the
|
||||
`go install` that justified it. `script/verify-linter-pin` is retired, deleted
|
||||
along with its README entry, because both of its subjects ceased to exist in
|
||||
the same change: it compared a linter binary against `GOLANGCI_LINT_VERSION`
|
||||
in `script/bootstrap`, and there is now neither a binary crossing between
|
||||
stages nor a version pin in bootstrap. The drift it guarded has not gone away,
|
||||
it has moved — the linter is still pinned twice, now as the `FROM` line of
|
||||
`Dockerfile.lint` and the `FROM` line of the `Dockerfile` lint stage, with
|
||||
nothing syncing them, which is exactly what
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/42 made a build failure. Its
|
||||
replacement is one new `script/verify-lint-image-pin`, run as a gate in both
|
||||
files, which compares the two references to each other and deliberately
|
||||
restates neither: a hardcoded expected digest would be a third copy and the
|
||||
same drift one file further out. `golangci-lint config verify` is included per
|
||||
the ruling, and the concern about its unpinned live HTTPS schema fetch was
|
||||
measured rather than assumed — under `--network none` the pinned binary both
|
||||
passes a valid config and rejects an invalid one with the jsonschema error, so
|
||||
it validates from an embedded schema and makes no network call of its own. The
|
||||
README scopes that to the gate steps rather than to linting as a whole:
|
||||
`Dockerfile.lint` runs `go mod download` above them, so a cold cache still
|
||||
needs the network and only a warm one lints offline. Verified: `make lint`
|
||||
green with every `PATH` directory containing a `golangci-lint` removed
|
||||
(`/home/user/go/bin`, `/home/user/.local/bin`, `/usr/local/bin`;
|
||||
`command -v golangci-lint` empty); two consecutive `script/lint` runs on an
|
||||
untouched tree both executed the linter, 27.7s and 28.7s in the lint step
|
||||
under distinct epochs with the `COPY . .` layer `CACHED` above them, at 42.2s
|
||||
and 41.8s wall clock — the no-cache rule was not weakened to shorten that.
|
||||
Negative control: a planted `var unusedIssue46Sentinel = 1` failed
|
||||
`script/lint` with
|
||||
`report.go:173:5: var unusedIssue46Sentinel is unused (unused)`, and failed
|
||||
`make docker` at `[lint 9/9]` with the build stage stopped at `[builder 3/12]`
|
||||
— `COPY --from=lint`, `script/bootstrap`, the test gate and `make build` all
|
||||
zero occurrences — then reverted clean. The drift guard fails on a tag-only
|
||||
disagreement, on a digest-only disagreement, and on an unreadable reference,
|
||||
naming both sides. `make docker` green in 5m35s with all six gates executing
|
||||
under one epoch (lint 37.6s, test 25.2s reporting
|
||||
`ok sneak.berlin/go/sfdupes 1.938s coverage: 88.5%`, not `(cached)`). The
|
||||
non-root quirk still holds: in the builder image with the Go test cache off,
|
||||
`--user 0:0` fails `TestScanHardlinkRunFailsTogether` (exit 1) where the
|
||||
unprivileged user passes (exit 0). Noted for follow-up, not fixed here:
|
||||
`golangci-lint` warns that the `gomodguard` linter is deprecated since v2.12.0
|
||||
in favour of `gomodguard_v2`.
|
||||
|
||||
- install the Docker build stage's prerequisites by running `script/bootstrap`
|
||||
instead of `apk add --no-cache make` inline (2026-08-09, branch
|
||||
`dockerfile-bootstrap`, closes https://git.eeqj.de/sneak/sfdupes/issues/42):
|
||||
the stage copies `script/` plus `go.mod`/`go.sum` and runs `script/bootstrap`,
|
||||
which ends in `go mod download`, so the separate call to it is gone.
|
||||
`COPY --from=lint /usr/bin/golangci-lint` stays and moves above the bootstrap
|
||||
layer: it is the only edge making this stage depend on the lint stage, so
|
||||
deleting it would end fail-fast linting silently. A new
|
||||
`script/verify-linter-pin`, run in the build stage before bootstrap, fails the
|
||||
build naming both versions unless that copied binary is the version
|
||||
`script/bootstrap` pins; a pin it cannot read is a hard failure, not a skip.
|
||||
`$GOPATH/bin` joins `PATH`, where bootstrap's `go install` lands. Everything
|
||||
added sits above `ARG CHECK_EPOCH`, and the `chown` and `USER builder` still
|
||||
precede `make check`. Verified: the guard fails the build with both versions
|
||||
named when the lint stage's linter is faked to another version, and passes an
|
||||
unmodified build; bootstrap runs clean under Alpine's `sh` and `apk`, finding
|
||||
the copied linter already at the pin; a second build served the bootstrap and
|
||||
dependency layers `CACHED` while both gates ran with a fresh epoch; a planted
|
||||
`unused` finding failed the build at the lint gate in 48.9s with the build
|
||||
stage's `make check` never starting; and the suite run in the image as
|
||||
`--user 0:0` fails `TestScanHardlinkRunFailsTogether`, so the drop to the
|
||||
unprivileged user is still needed. That last check needs the Go test cache
|
||||
off: as root it first reported `ok ... (cached)`, reusing the build-time
|
||||
result. Build times on a noisy shared host: 2m13s on an unchanged tree, 2m17s
|
||||
and 4m29s after a source change, 5m14s cold, which breaches the policy
|
||||
ceiling; `chown -R builder:builder /src /home/builder` walks the module cache
|
||||
and alone varied from 77s to 210s across those builds, and `main` measured
|
||||
5m03s cold with a 209s `chown`. Filed as
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/43
|
||||
`dockerfile-bootstrap`, closes #42): canonical `REPO_POLICIES.md:97` requires
|
||||
it, and the inline install left the build stage maintaining its own notion of
|
||||
the toolchain — exactly the divergence #24 exists to close, one layer down.
|
||||
The stage now copies `script/` plus `go.mod`/`go.sum` and runs
|
||||
`script/bootstrap`, which ends in `go mod download`, so the separate
|
||||
invocation of that is gone. `COPY --from=lint /usr/bin/golangci-lint` stays,
|
||||
and moves above the bootstrap layer. It is the only edge making this stage
|
||||
depend on the lint stage, so deleting it as redundant would end fail-fast
|
||||
linting silently. Letting bootstrap install its own linter here would have
|
||||
reintroduced the second toolchain and paid for a from-source build of it. What
|
||||
makes the two stages provably one toolchain rather than two that happen to
|
||||
agree is a new `script/verify-linter-pin`, run in the build stage on the
|
||||
binary that arrives from the lint stage, before bootstrap: it fails the build
|
||||
naming both versions unless that binary is the version `script/bootstrap`
|
||||
pins. Bootstrap's own check could not serve that purpose — it reinstalls its
|
||||
pin from source and then verifies whatever `PATH` resolves, so drift
|
||||
self-heals silently and a lint stage image bumped on its own would lint at the
|
||||
new version while `make check` ran at the old one, green. The linter version
|
||||
is pinned in two independent places (the lint stage image digest and
|
||||
`GOLANGCI_LINT_VERSION`) and nothing else keeps them in sync, so a
|
||||
half-applied bump is now a build failure. The pin is read out of
|
||||
`script/bootstrap`, which stays the single source of truth; a pin that cannot
|
||||
be read is a hard failure, not a skip. The check needs no `CHECK_EPOCH`: its
|
||||
only inputs are the copied binary and `script/`, so Docker invalidates the
|
||||
layer exactly when a cached result would stop being true, and it is documented
|
||||
with the other entrypoints in the README. `$GOPATH/bin` joins `PATH` because
|
||||
that is where bootstrap's `go install` lands and bootstrap verifies its
|
||||
installs against what `PATH` resolves — nothing in the image is shadowed by
|
||||
it, the directory does not exist until bootstrap runs. Everything added sits
|
||||
above `ARG CHECK_EPOCH`, and the `chown` and `USER builder` still precede
|
||||
`make check`. Verified: the guard fails the build with both versions named
|
||||
when the lint stage's linter is faked to a different version, and an
|
||||
unmodified build still passes it; bootstrap runs clean under Alpine's `sh` and
|
||||
its `apk` branch, installing `git` and `make` and finding the copied linter
|
||||
already at the pin; a second build served the bootstrap and dependency layers
|
||||
`CACHED` while both gates ran with a fresh epoch; a planted `unused` finding
|
||||
failed the build at the lint gate in 48.9s with the build stage's `make check`
|
||||
never starting; and the suite run in the image as `--user 0:0` fails
|
||||
`TestScanHardlinkRunFailsTogether`, so the drop to the unprivileged user is
|
||||
still load-bearing. That last check needs the Go test cache disabled — the
|
||||
first attempt reported `ok ... (cached)` as root, reusing the result the
|
||||
build-time run had left in the shared cache, which would have read as a pass.
|
||||
Build wall time, on a shared host running many concurrent builds and so noisy:
|
||||
2m13s on an unchanged tree, 2m17s and 4m29s for two builds after a source
|
||||
change, 5m14s cold. Only the cold one breaches the policy ceiling, and not
|
||||
because of this change — `chown -R builder:builder /src /home/builder` walks
|
||||
the module cache and re-runs on every source change, and it alone varied
|
||||
between 77s and 210s across those four builds, which is also the whole spread
|
||||
in the totals. The same cold measurement against `main` is 5m03s with a 209s
|
||||
`chown`. Filed as #43
|
||||
- bust the Docker layer cache for the gate steps, so `script/cibuild` and
|
||||
`script/docker` cannot report a green they did not earn (2026-08-09, branch
|
||||
`cibuild-cache-bust`, closes https://git.eeqj.de/sneak/sfdupes/issues/32): the
|
||||
`Dockerfile` copies the tree before its gates, so on an unchanged tree Docker
|
||||
served them from cache and the build exited 0 having run nothing. Both scripts
|
||||
now pass `--build-arg CHECK_EPOCH="$(date +%s)"`. `ARG` is per stage and the
|
||||
gates span two stages, so it is declared in both; BuildKit hashes the expanded
|
||||
command, so each gate `RUN` echoes the epoch, which also logs it as evidence
|
||||
the layer ran. It sits below the dependency layers so they stay cached.
|
||||
Verified under `BUILDKIT_PROGRESS=plain`, each script run twice back to back
|
||||
on an unchanged tree: all three gates ran on all four runs with a fresh epoch
|
||||
(`script/cibuild` 78.8s then 61.1s; `script/docker` 61.1s then 53.4s), and
|
||||
thirteen steps were still served `CACHED`. With a planted `unused` finding the
|
||||
build failed at `make lint` in 36.1s and the build-stage `make check` never
|
||||
started. Run as root, the same image fails `TestScanHardlinkRunFailsTogether`,
|
||||
because root reads through the `chmod(0)` the test relies on, so the build
|
||||
stage must drop to the unprivileged `builder` user. Local fix only;
|
||||
propagating it to the canonical templates is
|
||||
https://git.eeqj.de/sneak/prompts/issues/26
|
||||
`cibuild-cache-bust`, closes #32): both scripts were bare `docker build`
|
||||
invocations with no cache control, and the `Dockerfile` copies the tree before
|
||||
running its gates, so on an unchanged tree Docker served those layers from
|
||||
cache and the build exited 0 having executed nothing. That is not hypothetical
|
||||
here — every merge this repo has done is a non-fast-forward merge of an
|
||||
undiverged branch, so each merge commit's tree is byte-identical to the branch
|
||||
head's and each merge CI run was almost certainly a full cache hit; and PR
|
||||
#31's reviewer found `make docker` returning success as a 17-layer cache hit,
|
||||
catching it only by being suspicious. The fix is `ARG CHECK_EPOCH` with the
|
||||
scripts passing `--build-arg CHECK_EPOCH="$(date +%s)"`. Two details make or
|
||||
break it. `ARG` is scoped per stage and this `Dockerfile` has three gates
|
||||
across two — `make fmt-check` and `make lint` in the lint stage, `make check`
|
||||
in the build stage — so a single declaration would have left one stage
|
||||
silently cacheable; it is declared in both. And BuildKit hashes the expanded
|
||||
command, not the declaration, so a declared-but-unreferenced `ARG` invalidates
|
||||
nothing: each gate `RUN` echoes the epoch, which also puts the value in the
|
||||
build log as evidence the layer really ran. Placement is below the dependency
|
||||
layers on purpose — a build that goes cold every time would be a different
|
||||
bug, not a fix. Verified by running each script twice back to back on an
|
||||
unchanged tree under `BUILDKIT_PROGRESS=plain`: all three gates executed on
|
||||
all four runs, each with a fresh epoch in the log (`script/cibuild` 78.8s then
|
||||
61.1s; `script/docker` 61.1s then 53.4s), and twelve steps were still served
|
||||
`CACHED` in the steady state — both `go mod download`s, `apk add`, `adduser`,
|
||||
the `chown`, every `go.mod`/`go.sum` and source copy, the linter copy out of
|
||||
the lint stage, and the binary copy into the runtime stage. The lint stage
|
||||
still gates the build stage: with a deliberate `unused` finding planted in the
|
||||
tree, the build failed at `make lint` in 36.1s and the build-stage
|
||||
`make check` never started. The build stage also still drops to the
|
||||
unprivileged `builder` user before `make check`, which the suite depends on
|
||||
rather than merely prefers: forcing the same image to run the tests as root
|
||||
fails `TestScanHardlinkRunFailsTogether`, because root reads straight through
|
||||
the `chmod(0)` the test uses to prove hard links are read once. This is the
|
||||
local fix only; propagating it to the canonical templates is `prompts` #26
|
||||
- check the installed golangci-lint version in `script/bootstrap` instead of
|
||||
only its presence (2026-08-09, branch `bootstrap-version-check`, closes
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
|
||||
`GOLANGCI_LINT_VERSION`, with the `go install` module ref derived from it, and
|
||||
any installed version that is not the pin — older, newer, absent or
|
||||
unparseable — is reinstalled. `go install` writes into `GOBIN` (or
|
||||
`GOPATH/bin`) while `make lint` runs the first `golangci-lint` on `PATH`, so
|
||||
bootstrap re-reads the effective version after installing and, on a mismatch,
|
||||
prints both paths and both versions and exits non-zero; it does not reorder
|
||||
`PATH` or delete anyone's binary. The `--version` call keeps its stderr and is
|
||||
bounded by `timeout(1)` where that exists. `git`, `make` and `go` keep
|
||||
presence-only checks. Verified by bootstrapping this host from v2.10.1 to
|
||||
v2.12.2 and again to a no-op, and by stub runs of the script under `dash`
|
||||
covering a thirteen-input version-parse matrix, a shadowed install that must
|
||||
exit non-zero, an install destination not on `PATH`, `GOBIN` set, and a wedged
|
||||
binary that must hit the timeout; `make check` and `make lint` are clean at
|
||||
v2.12.2, so v2.10.1 was not hiding any findings on `main`
|
||||
only its presence (2026-08-09, branch `bootstrap-version-check`, closes #24):
|
||||
`missing golangci-lint` meant any linter already on `PATH` satisfied the
|
||||
check, so the pin was never consulted and the v2.12.2 bump from #3 was inert
|
||||
on every host that already had one — this host ran v2.10.1 against a v2.12.2
|
||||
pin, `make check` went green, and `make docker` then rejected the same commit
|
||||
with findings the local gate never saw. The version now lives in one place,
|
||||
`GOLANGCI_LINT_VERSION`, with the `go install` module ref derived from it so a
|
||||
bump cannot half-apply; a `golangci_lint_version` helper parses
|
||||
`golangci-lint --version` (taking the field after the word `version` and
|
||||
tolerating an optional leading `v`, which the module ref carries and the
|
||||
binary's output does not), and any version that is not the pin — older, newer,
|
||||
absent or unparseable — is reinstalled. The install is then verified against
|
||||
the binary `PATH` actually resolves: `go install` writes into `GOBIN` (or
|
||||
`GOPATH/bin`) while `make lint` runs whichever `golangci-lint` comes first on
|
||||
`PATH`, so a wrong-version one sitting ahead of it — nix, apt, brew, apk, or
|
||||
the `/usr/local/bin` copy the `Dockerfile` builder stage makes — would swallow
|
||||
the install and leave the local gate disagreeing with CI under an affirmative
|
||||
`bootstrap complete`. Bootstrap now re-reads the effective version after
|
||||
installing and, on a mismatch, prints both paths and both versions to stderr
|
||||
and exits non-zero instead of claiming success; it does not reorder anyone's
|
||||
`PATH` or delete their binary. The `--version` call keeps its stderr
|
||||
connected, so a present-but-broken binary says why rather than reinstalling
|
||||
forever in silence, and is bounded by `timeout(1)` where that exists, so a
|
||||
wedged binary cannot hang bootstrap. `git`, `make` and `go` keep their
|
||||
presence-only checks and now say why in a comment: they are host
|
||||
package-manager tools the repo deliberately does not pin, with `go.mod`
|
||||
governing the language version and the digest-pinned images covering
|
||||
reproducible builds. Verified on this host by bootstrapping from v2.10.1 to
|
||||
v2.12.2 and running it again to a no-op, plus stub runs of the real script
|
||||
under `dash` covering a thirteen-input parse matrix (absent, older, newer,
|
||||
host-style, image-style, leading-`v`, stderr-only, empty, non-zero exit,
|
||||
impostor binary, `(devel)`, trailing `version`), a shadowed install that must
|
||||
exit non-zero, an install destination not on `PATH` at all, `GOBIN` set, and a
|
||||
wedged binary that must hit the timeout; `make check` and `make lint` are
|
||||
clean at v2.12.2, so v2.10.1 was not hiding any findings on `main`
|
||||
- unwind the hash worker pool on the error path (2026-08-09, branch
|
||||
`hash-pool-cleanup`, closes https://git.eeqj.de/sneak/sfdupes/issues/6): the
|
||||
pool is now an owned, context-aware `hashPool`: every blocking send in the
|
||||
feeder and the workers selects on `ctx.Done()`, `jobs` is closed on every path
|
||||
out, and `hashPhase` defers `pool.stop()`, which cancels and then drains
|
||||
`results` until the last goroutine has exited — draining is what frees a
|
||||
worker already parked on a send. `ctx` is threaded from `cmd.Context()`
|
||||
through `runScan`, `syncScan`, both worker pools and the whole database layer,
|
||||
as the first parameter everywhere. The walk pool gets the same treatment plus
|
||||
a `ctx.Err()` guard after the walk: a cancelled walk yields a partial size
|
||||
census, and every file it never reached looks vanished to the update phase.
|
||||
That phase's own `BeginTx` also fails on the cancelled context before deleting
|
||||
anything, but the guard is the barrier that still holds once an interrupted
|
||||
scan may commit what it has. Tests drive `run(scan)` against a database whose
|
||||
insert trigger aborts and assert that the scan fails instead of hanging and
|
||||
that `runtime.NumGoroutine()` polls back to its pre-scan baseline; others
|
||||
cancel a scan part-way through the walk, deterministically, by counting its
|
||||
own consultations of `ctx.Done()`, and assert that it stops at the guard
|
||||
holding a partial census and a still-populated record index, with every record
|
||||
intact. Direct tests of `sendEvent`, the walk workers, `dispatchDirs`,
|
||||
`feedHashJobs`, `hashWorker` and `hashPhase` cover the remaining cancellation
|
||||
branches of both pools
|
||||
`hash-pool-cleanup`, closes #6): `hashPhase` used to return the moment
|
||||
`recordRun` failed and abandon the pool — the feeder parked forever on a full
|
||||
`jobs` channel and every worker on a full `results` channel. That only stopped
|
||||
being invisible when #4 landed and `runScan` began unwinding instead of
|
||||
calling `os.Exit`. The pool is now an owned, context-aware `hashPool`: every
|
||||
blocking send in the feeder and the workers selects on `ctx.Done()`, `jobs` is
|
||||
closed on every path out, and `hashPhase` defers `pool.stop()`, which cancels
|
||||
and then drains `results` until the last goroutine has exited — draining is
|
||||
what frees a worker already parked on a send. `ctx` is threaded from
|
||||
`cmd.Context()` through `runScan`, `syncScan`, both worker pools and the whole
|
||||
database layer (it is the first parameter everywhere), so #5 can hand this
|
||||
path a signal and needs to add nothing else. The walk pool never leaked,
|
||||
because `walkPhase` always drains its events to close, but it has the same
|
||||
unbounded-send shape and #5 will give it an early return, so it gets the same
|
||||
treatment plus a `ctx.Err()` guard after the walk: a cancelled walk yields a
|
||||
partial size census, and every file it never reached looks vanished to the
|
||||
update phase. That phase's own `BeginTx` fails on the same cancelled context
|
||||
before deleting anything, so the guard is defence in depth rather than the
|
||||
only barrier — but it is the one that survives #5 deciding an interrupted scan
|
||||
may commit what it has. Tests drive `run(scan)` against a database whose
|
||||
insert trigger aborts, and assert both that the scan fails instead of hanging
|
||||
and that `runtime.NumGoroutine()` polls back to its pre-scan baseline; a
|
||||
second set cancels a scan part-way through the walk — deterministically, by
|
||||
counting the scan's own consultations of `ctx.Done()` rather than racing a
|
||||
timer — and asserts that it stops at the guard holding a partial census and a
|
||||
still-populated record index, with every record intact. The remaining
|
||||
cancellation branches of both pools are covered by direct tests of
|
||||
`sendEvent`, the walk workers, `dispatchDirs`, `feedHashJobs`, `hashWorker`
|
||||
and `hashPhase`
|
||||
- guarantee the database is closed on every fatal exit path (2026-08-09, branch
|
||||
`db-close-on-fatal`, closes https://git.eeqj.de/sneak/sfdupes/issues/4):
|
||||
`fatalf` and its `os.Exit(1)` are gone, so the deferred `db.Close()` — and
|
||||
with it the SQLite WAL checkpoint — now actually runs when a subcommand fails;
|
||||
`runScan`, `runReport`, `runTrees`, `loadRecords` and `resolveRoots` return
|
||||
errors instead. The single exit point is `run` in `main.go`: it maps a
|
||||
`fatalError` (anything a subcommand returned) to exit 1 and cobra's own
|
||||
argument and flag errors to exit 2, which keeps a runtime failure from being
|
||||
reported as a usage error or printing the usage text. New `main_test.go`
|
||||
drives the CLI in-process and asserts the exit codes from README §Error
|
||||
handling plus the stdout/stderr split, including that a fatal error raised
|
||||
after the database is open leaves no `-wal`/`-shm` sidecar behind for `scan`,
|
||||
`report` or `trees`
|
||||
`db-close-on-fatal`, closes #4): `fatalf` and its `os.Exit(1)` are gone, so
|
||||
the deferred `db.Close()` — and with it the SQLite WAL checkpoint — now
|
||||
actually runs when a subcommand fails; `runScan`, `runReport`, `runTrees`,
|
||||
`loadRecords` and `resolveRoots` return errors instead. The single exit point
|
||||
is `run` in `main.go`: it maps a `fatalError` (anything a subcommand returned)
|
||||
to exit 1 and cobra's own argument and flag errors to exit 2, which keeps a
|
||||
runtime failure from being reported as a usage error or printing the usage
|
||||
text. New `main_test.go` drives the CLI in-process and asserts the exit codes
|
||||
from README §Error handling plus the stdout/stderr split, including that a
|
||||
fatal error raised after the database is open leaves no `-wal`/`-shm` sidecar
|
||||
behind for `scan`, `report` or `trees`
|
||||
- update golangci-lint to v2.12.2 with the canonical config (2026-08-09, branch
|
||||
`golangci-v2.12.2`, merged as `38a01bd`, closes
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/3): bumped the pinned linter in the
|
||||
`Dockerfile` lint stage and `script/bootstrap` from v2.12.1 to v2.12.2, and
|
||||
replaced `.golangci.yml` with the canonical file — the linter settings (`lll`,
|
||||
`funlen`, `cyclop`, `dupl` thresholds) now live under `linters.settings` per
|
||||
the v2 schema, so they are actually applied; no new lint findings surfaced
|
||||
`golangci-v2.12.2`, merged as `38a01bd`, closes #3): bumped the pinned linter
|
||||
in the `Dockerfile` lint stage and `script/bootstrap` from v2.12.1 to v2.12.2,
|
||||
and replaced `.golangci.yml` with the canonical file — the linter settings
|
||||
(`lll`, `funlen`, `cyclop`, `dupl` thresholds) now live under
|
||||
`linters.settings` per the v2 schema, so they are actually applied; no new
|
||||
lint findings surfaced
|
||||
- convert Makefile targets to scripts-to-rule-them-all `script/` entrypoints
|
||||
like the other managed repos (2026-07-26, commit `3abeacf`, closes
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/1): all 12 `script/` entrypoints
|
||||
exist (`bootstrap`, `setup`, `projectname`, `test`, `lint`, `fmt`,
|
||||
`fmt-check`, `check`, `docker`, `cibuild`, `precommit`, `install-precommit`)
|
||||
and every Makefile target is now a thin shim over them
|
||||
like the other managed repos (2026-07-26, commit `3abeacf`, closes #1): all 12
|
||||
`script/` entrypoints exist (`bootstrap`, `setup`, `projectname`, `test`,
|
||||
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||
`install-precommit`) and every Makefile target is now a thin shim over them,
|
||||
matching the other managed repos
|
||||
- make the binary the default Make target (2026-07-24, branch
|
||||
`make-default-target`): plain `make` now builds `sfdupes` (previously it ran
|
||||
`check` plus `build`); `make build` remains as an alias
|
||||
@@ -355,7 +389,8 @@
|
||||
- announce each operand on stderr before its passes (2026-07-24, branch
|
||||
`scan-operand-progress`): with per-operand walk/hash/update cycles, a
|
||||
multi-operand run (e.g. `scan /srv/*`) showed pass totals that looked like the
|
||||
whole run's
|
||||
whole run's — an operator watching operand 3 of 14 hash 300k files concluded
|
||||
20M files were being skipped
|
||||
- parallel walk (2026-07-24, branch `parallel-walk`): the walk pass was a single
|
||||
goroutine and took hours at ~20M files on a busy pool (observed: 22M files in
|
||||
4h on a ZFS server); it is now a per-directory worker-pool traversal that
|
||||
@@ -427,6 +462,5 @@ Accepted divergences (no action):
|
||||
|
||||
- flat single-package layout with `.go` files in the repo root — fine for a
|
||||
small single-binary tool per the Go styleguide; the tracker audit agrees
|
||||
- `make test` runs without `-race` — the repo mandates `CGO_ENABLED=0` (pure-Go
|
||||
builds) and the race detector requires cgo, so the detector runs in a separate
|
||||
cgo-enabled container, `make test-race`, which is not part of `make check`
|
||||
- `go test` runs without `-race` — the repo mandates `CGO_ENABLED=0` (pure-Go
|
||||
builds) and the race detector requires cgo
|
||||
|
||||
+41
-151
@@ -18,21 +18,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file gathers the tests for scan cancellation and worker-pool
|
||||
// unwinding. Everything it exercises lives in scan.go, so by the repo's
|
||||
// convention of one test file per source file it would belong in
|
||||
// scan_test.go. It is kept separate on purpose: cancellation behaviour
|
||||
// cuts across both the walk pool and the hash pool as a single concern,
|
||||
// and scan_test.go is already over 1,600 lines. That is the deliberate
|
||||
// exception the convention otherwise expects to be stated.
|
||||
|
||||
// poolUnwind bounds how long a test waits for a cancellation to take
|
||||
// effect: for a goroutine to return or a channel to close once its
|
||||
// context is cancelled, or for a signal to cancel the scan's context.
|
||||
// Only a failing run waits this long, and the bound is what makes that
|
||||
// failure an assertion instead of a hang. A call made without it, as
|
||||
// most of this file's scans are, has no bound: a regression that parks
|
||||
// it is caught only as the test binary's own timeout.
|
||||
// poolUnwind bounds how long a goroutine is given to leave a pool
|
||||
// after its context is cancelled. Only a failing run ever waits this
|
||||
// long: a pool that ignored its cancellation parks forever, and this
|
||||
// is what turns that into a failed assertion instead of a suite that
|
||||
// hangs until the test binary's own timeout.
|
||||
const poolUnwind = 2 * time.Second
|
||||
|
||||
// walkClock is a context whose cancellation is driven by the scan's
|
||||
@@ -43,14 +33,9 @@ const poolUnwind = 2 * time.Second
|
||||
//
|
||||
// The accounting behind the n chosen by each test: every blocking
|
||||
// channel operation in the walk selects on Done, so the walk spends
|
||||
// one consultation per file event plus a couple per directory. The
|
||||
// index load that runs ahead of it also consults Done, but a bounded
|
||||
// number of times that does not grow with the record count. The tests
|
||||
// depend on that property, not on the bound's exact value: each test
|
||||
// sets n from the consultations of the walk, plus those of the hash
|
||||
// phase when it cancels mid-hash, far from both ends of the phase it
|
||||
// interrupts, so the cancellation lands inside that phase whatever the
|
||||
// record count.
|
||||
// one consultation per file event plus a couple per directory, while
|
||||
// the index load that runs ahead of it spends a small fixed number
|
||||
// (three) whatever the record count.
|
||||
type walkClock struct {
|
||||
n int64
|
||||
seen atomic.Int64
|
||||
@@ -106,14 +91,11 @@ func (c *walkClock) Value(_ any) any {
|
||||
// directory still queued and only the handful already in flight can
|
||||
// emit anything more.
|
||||
const (
|
||||
walkCancelDirs = 100
|
||||
walkCancelFilesPerDir = 20
|
||||
walkCancelFiles = walkCancelDirs * walkCancelFilesPerDir
|
||||
walkCancelWorkers = 4
|
||||
// The most files the walkCancelWorkers directories already in
|
||||
// flight when the scan is cancelled can still emit, at
|
||||
// walkCancelFilesPerDir each. A file count, not a directory count.
|
||||
walkCancelInFlightFiles = walkCancelWorkers * walkCancelFilesPerDir
|
||||
walkCancelDirs = 100
|
||||
walkCancelFilesPerDir = 20
|
||||
walkCancelFiles = walkCancelDirs * walkCancelFilesPerDir
|
||||
walkCancelWorkers = 4
|
||||
walkCancelInFlightDirs = walkCancelWorkers * walkCancelFilesPerDir
|
||||
)
|
||||
|
||||
// walkCancelAtDone is the consultation on which the fixture's context
|
||||
@@ -176,11 +158,6 @@ func assertRecordsIntact(t *testing.T, db *sql.DB, before []string) {
|
||||
// unreachable, makes the scan carry its truncated view into the update
|
||||
// phase, which counts every record the walk never reached for removal.
|
||||
//
|
||||
// The syncScan call here is not bounded by poolUnwind: a regression
|
||||
// that left a worker pool parked would hang it, and that regression is
|
||||
// caught only by the test binary's own timeout, not by a quick
|
||||
// assertion.
|
||||
//
|
||||
//nolint:paralleltest // counts goroutines: must not run beside others
|
||||
func TestSyncScanCancelledMidWalkKeepsRecords(t *testing.T) {
|
||||
dir := buildWalkCancelTree(t)
|
||||
@@ -232,11 +209,10 @@ func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
|
||||
}
|
||||
|
||||
// The workers drop every directory still queued once the scan is
|
||||
// cancelled, so only the files in the directories already in flight
|
||||
// can add to the census after the fact. A census beyond that bound
|
||||
// would mean the cancellation was not observed where it should have
|
||||
// been.
|
||||
limit := walkCancelAtDone + walkCancelInFlightFiles
|
||||
// cancelled, so only the directories already in flight can add to
|
||||
// the census after the fact. A census beyond that bound would mean
|
||||
// the cancellation was not observed where it should have been.
|
||||
limit := walkCancelAtDone + walkCancelInFlightDirs
|
||||
if st.unchanged > limit {
|
||||
t.Errorf("census covers %d files, want at most %d: the walk kept "+
|
||||
"taking directories off the queue after cancellation",
|
||||
@@ -344,14 +320,13 @@ func storedPaths(t *testing.T, path string) []string {
|
||||
|
||||
// TestRunScanInterrupted calls the scan entrypoint with a context that
|
||||
// is already cancelled, as when a signal arrives at once. It must return
|
||||
// errInterrupted promptly with its one line on stderr and nothing on
|
||||
// stdout, leave the database valid and as it was, and leave nothing in
|
||||
// the way of the next scan, which must bring the database up to date.
|
||||
// errInterrupted promptly with its one line on stderr, leave the
|
||||
// database valid and as it was, and leave nothing in the way of the
|
||||
// next scan, which must bring the database up to date.
|
||||
func TestRunScanInterrupted(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
stdout := captureStdout(t)
|
||||
stderr := captureStderr(t)
|
||||
dir := buildSmokeTree(t)
|
||||
|
||||
@@ -394,10 +369,6 @@ func TestRunScanInterrupted(t *testing.T) {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := stdout(); got != "" {
|
||||
t.Errorf("stdout = %q, want nothing (data only)", got)
|
||||
}
|
||||
|
||||
assertNoSidecars(t, path)
|
||||
|
||||
if got := storedPaths(t, path); !slices.Equal(got, before) {
|
||||
@@ -420,13 +391,12 @@ func TestRunScanInterrupted(t *testing.T) {
|
||||
// TestRunScanInterruptedMidHash interrupts the scan entrypoint part-way
|
||||
// through its hash phase, after the database is open. It must return
|
||||
// errInterrupted, release the lock, end stderr with its line counting
|
||||
// every file the walk reached, write nothing to stdout, close the
|
||||
// database out of WAL mode, and keep the records it hashed.
|
||||
// every file the walk reached, close the database out of WAL mode, and
|
||||
// keep the records it hashed.
|
||||
func TestRunScanInterruptedMidHash(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
stdout := captureStdout(t)
|
||||
stderr := captureStderr(t)
|
||||
dir := buildWalkCancelTree(t)
|
||||
|
||||
@@ -444,10 +414,6 @@ func TestRunScanInterruptedMidHash(t *testing.T) {
|
||||
t.Errorf("stderr = %q, want it to end with %q", got, want)
|
||||
}
|
||||
|
||||
if got := stdout(); got != "" {
|
||||
t.Errorf("stdout = %q, want nothing (data only)", got)
|
||||
}
|
||||
|
||||
assertNoSidecars(t, path)
|
||||
|
||||
db, err := openReportDatabase(t.Context(), path)
|
||||
@@ -596,49 +562,20 @@ func TestSendEventAbandonsBlockedSend(t *testing.T) {
|
||||
awaitReturn(t, done, "sendEvent")
|
||||
}
|
||||
|
||||
// TestWalkOneDirStopsWhenCancelled checks that a cancelled scan stops
|
||||
// reading a directory instead of going through the rest of its
|
||||
// entries. A walk that kept going would return the subdirectory below
|
||||
// to descend into. Unlike a file event, that return is not a send the
|
||||
// cancellation can abandon, so the test catches the regression every
|
||||
// time.
|
||||
func TestWalkOneDirStopsWhenCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.Mkdir(filepath.Join(dir, "sub"), 0o750)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Unbuffered and unread: on a cancelled scan every send gives up.
|
||||
events := make(chan walkEvent)
|
||||
|
||||
subs := walkOneDir(cancelledContext(t), dirJob{path: dir}, false, events)
|
||||
if len(subs) != 0 {
|
||||
t.Errorf("cancelled walkOneDir returned %+v to descend into, "+
|
||||
"want none", subs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalkWorkersDropQueuedDirs checks that cancelled walk workers keep
|
||||
// reading jobs and drop the directories rather than stopping their
|
||||
// read: the range over jobs has to run out for the pool to tear down
|
||||
// and close its event stream. The queued directory does not exist, so
|
||||
// a worker that walked it anyway would send a warning before
|
||||
// walkOneDir's own cancellation check could stop it. On a cancelled
|
||||
// scan that send delivers or gives up at random, so with 64 jobs
|
||||
// queued the regression has a one in 2^64 chance of passing.
|
||||
// and close its event stream.
|
||||
func TestWalkWorkersDropQueuedDirs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing")
|
||||
dir := t.TempDir()
|
||||
writeEmptyFiles(t, dir, walkCancelFilesPerDir)
|
||||
|
||||
jobs, _, events := startWalkWorkers(cancelledContext(t), 2, false)
|
||||
|
||||
for range 64 {
|
||||
jobs <- dirJob{path: missing}
|
||||
for range 4 {
|
||||
jobs <- dirJob{path: dir}
|
||||
}
|
||||
|
||||
close(jobs)
|
||||
@@ -709,11 +646,7 @@ func TestDispatchDirsClosesJobsWhenCancelled(t *testing.T) {
|
||||
|
||||
// TestFeedHashJobsClosesJobsWhenCancelled checks that the hash feeder
|
||||
// abandons the runs it has not queued yet and still closes the job
|
||||
// channel, which is what lets the workers' range terminate. The
|
||||
// receive on jobs below is not bounded: a feeder that returned without
|
||||
// closing jobs would leave that receive with no sender and no close, so
|
||||
// this regression is caught by the test binary's timeout rather than by
|
||||
// a bounded assertion.
|
||||
// channel, which is what lets the workers' range terminate.
|
||||
func TestFeedHashJobsClosesJobsWhenCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -739,84 +672,41 @@ func TestFeedHashJobsClosesJobsWhenCancelled(t *testing.T) {
|
||||
// TestHashWorkerDropsQueuedRuns checks that a cancelled hash worker
|
||||
// keeps reading jobs and drops the runs rather than reading files
|
||||
// nobody wants the hashes of — while still letting the range run out
|
||||
// so the pool tears down. The hash function records that it was
|
||||
// called, so a worker that hashed the queued run anyway is caught
|
||||
// every time.
|
||||
// so the pool tears down. The queued run names a file that does not
|
||||
// exist, so a worker that hashed it anyway would produce a result.
|
||||
func TestHashWorkerDropsQueuedRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
done := make(chan struct{})
|
||||
jobs := make(chan []fileRec, 1)
|
||||
results := make(chan hashResult)
|
||||
results := make(chan hashResult, 1)
|
||||
|
||||
jobs <- []fileRec{{path: filepath.Join(t.TempDir(), "missing"), size: 1}}
|
||||
run := []fileRec{{path: filepath.Join(t.TempDir(), "missing"), size: 1}}
|
||||
|
||||
jobs <- run
|
||||
|
||||
close(jobs)
|
||||
|
||||
var hashed atomic.Bool
|
||||
|
||||
hash := func(path string, size int64) (string, string, string, error) {
|
||||
hashed.Store(true)
|
||||
|
||||
return hashSignature(path, size)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
hashWorker(cancelledContext(t), jobs, results, hash)
|
||||
hashWorker(cancelledContext(t), jobs, results, hashSignature)
|
||||
}()
|
||||
|
||||
awaitReturn(t, done, "hashWorker")
|
||||
|
||||
if hashed.Load() {
|
||||
t.Error("cancelled hash worker hashed the queued run, want it dropped")
|
||||
select {
|
||||
case r := <-results:
|
||||
t.Errorf("cancelled hash worker produced %+v, want the run dropped",
|
||||
r)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashWorkerAbandonsBlockedSend checks that a hash worker with a
|
||||
// result to deliver and nobody to deliver it to leaves once the scan
|
||||
// is cancelled, instead of holding the pool open. The scan tests do
|
||||
// not catch this: stop drains results, which frees a parked worker
|
||||
// anyway.
|
||||
func TestHashWorkerAbandonsBlockedSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
jobs := make(chan []fileRec, 1)
|
||||
// Unbuffered and unread, with jobs left open: the worker's only way
|
||||
// out is the cancellation case beside its send.
|
||||
results := make(chan hashResult)
|
||||
|
||||
jobs <- []fileRec{{path: filepath.Join(t.TempDir(), "missing"), size: 1}}
|
||||
|
||||
// The scan is cancelled while the worker hashes, so the worker has
|
||||
// already passed the check that drops queued runs.
|
||||
hash := func(path string, size int64) (string, string, string, error) {
|
||||
cancel()
|
||||
|
||||
return hashSignature(path, size)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
hashWorker(ctx, jobs, results, hash)
|
||||
}()
|
||||
|
||||
awaitReturn(t, done, "hashWorker")
|
||||
}
|
||||
|
||||
// TestHashPhaseCancelledReturnsContextError checks the result loop's
|
||||
// own exit: with the pool cancelled, no result will ever arrive, and
|
||||
// the loop must leave through the cancellation rather than wait for a
|
||||
// receive that cannot happen. This call is not bounded by poolUnwind: a
|
||||
// loop that dropped its cancellation case would block on that receive,
|
||||
// so the regression surfaces as the test binary's timeout rather than
|
||||
// as a bounded assertion.
|
||||
// receive that cannot happen.
|
||||
func TestHashPhaseCancelledReturnsContextError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
// The pure-Go SQLite driver, registered as "sqlite"; keeps cgo
|
||||
@@ -35,28 +33,21 @@ const schemaVersion = 1
|
||||
// scan.
|
||||
const dbDirPerm = 0o755
|
||||
|
||||
// dbFilePerm is the mode for a database file created by scan, the mode
|
||||
// SQLite gives a database file it creates.
|
||||
const dbFilePerm = 0o644
|
||||
|
||||
// lockFilePerm is the mode for the scan lock file. Anyone who can open
|
||||
// the file can hold the lock and keep every scan from running, so it
|
||||
// is open to its owner only.
|
||||
const lockFilePerm = 0o600
|
||||
|
||||
// createTableSQL is the schema applied to a fresh database. Paths are
|
||||
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime
|
||||
// holds whole Unix seconds and mtime_nsec the nanoseconds within that
|
||||
// second.
|
||||
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8.
|
||||
const createTableSQL = `
|
||||
CREATE TABLE files (
|
||||
path BLOB PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
mtime INTEGER NOT NULL,
|
||||
mtime_nsec INTEGER NOT NULL,
|
||||
head TEXT NOT NULL,
|
||||
tail TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
path BLOB PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
mtime INTEGER NOT NULL,
|
||||
head TEXT NOT NULL,
|
||||
tail TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
) WITHOUT ROWID
|
||||
`
|
||||
|
||||
@@ -69,11 +60,10 @@ CREATE INDEX files_signature ON files (size, head, tail, content)
|
||||
// upsertSQL inserts one file record, replacing any existing record for
|
||||
// the same path.
|
||||
const upsertSQL = `
|
||||
INSERT INTO files (path, size, mtime, mtime_nsec, head, tail, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO files (path, size, mtime, head, tail, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
size = excluded.size, mtime = excluded.mtime,
|
||||
mtime_nsec = excluded.mtime_nsec,
|
||||
head = excluded.head, tail = excluded.tail,
|
||||
content = excluded.content
|
||||
`
|
||||
@@ -117,19 +107,7 @@ const reportParams = "mode=ro" +
|
||||
// openDB opens the SQLite database at path with the connection
|
||||
// parameters params. It does not create or verify the schema.
|
||||
func openDB(path, params string) (*sql.DB, error) {
|
||||
// The path is escaped into a file: URI, so ?, # and % in it stay
|
||||
// part of the file name. SQLite reads what follows file:// up to
|
||||
// the next / as a host name, so an absolute path goes after an
|
||||
// empty host (file:///abs) and a relative path goes without one
|
||||
// (file:rel).
|
||||
uri := url.URL{
|
||||
Scheme: "file",
|
||||
OmitHost: !filepath.IsAbs(path),
|
||||
Path: path,
|
||||
RawQuery: params,
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", uri.String())
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?"+params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database %s: %w", path, err)
|
||||
}
|
||||
@@ -144,37 +122,19 @@ func openDB(path, params string) (*sql.DB, error) {
|
||||
|
||||
// lockScanDatabase takes the lock that keeps a second scan off the
|
||||
// database at path: an exclusive flock(2) on the file beside it named
|
||||
// by the database's path with symlinks resolved and ".lock" appended,
|
||||
// so every path to one database names the same lock file. If missing,
|
||||
// the database's parent directory and an empty database file are
|
||||
// created first, so the lock file is named the same way before the
|
||||
// database is set up as after. A lock held by another scan fails at
|
||||
// once instead of waiting. The lock lasts until the returned file is
|
||||
// closed or the process ends. The lock file is never deleted: a scan
|
||||
// that deleted it would let the next scan lock a new file while
|
||||
// another still holds the old one.
|
||||
// path with ".lock" appended, created along with the database's parent
|
||||
// directory if missing. A lock held by another scan fails at once
|
||||
// instead of waiting. The lock lasts until the returned file is closed
|
||||
// or the process ends. The file is never deleted: a scan that deleted
|
||||
// it would let the next scan lock a new file while another still holds
|
||||
// the old one.
|
||||
func lockScanDatabase(path string) (*os.File, error) {
|
||||
err := os.MkdirAll(filepath.Dir(path), dbDirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Like SQLite, this follows a symlink at path and creates its
|
||||
// target. SQLite opens an empty file as a new database.
|
||||
//nolint:gosec // the operator chooses the database path
|
||||
dbFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, dbFilePerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
|
||||
_ = dbFile.Close()
|
||||
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve database path: %w", err)
|
||||
}
|
||||
|
||||
lockPath := resolved + ".lock"
|
||||
lockPath := path + ".lock"
|
||||
|
||||
//nolint:gosec // the operator chooses the database path
|
||||
f, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, lockFilePerm)
|
||||
@@ -383,8 +343,8 @@ func userVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||
// which is the order of the primary key, so SQLite does not sort.
|
||||
func loadFileRows(ctx context.Context, db *sql.DB, fn func(r scanRec)) error {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT path, size, mtime, mtime_nsec, head, tail, content "+
|
||||
"FROM files ORDER BY path")
|
||||
"SELECT path, size, mtime, head, tail, content FROM files "+
|
||||
"ORDER BY path")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read records: %w", err)
|
||||
}
|
||||
@@ -393,19 +353,17 @@ func loadFileRows(ctx context.Context, db *sql.DB, fn func(r scanRec)) error {
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
path []byte
|
||||
sec, nsec int64
|
||||
r scanRec
|
||||
path []byte
|
||||
r scanRec
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
|
||||
err = rows.Scan(&path, &r.size, &r.mtime, &r.head, &r.tail,
|
||||
&r.content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
r.path = string(path)
|
||||
r.mtime = time.Unix(sec, nsec)
|
||||
fn(r)
|
||||
}
|
||||
|
||||
@@ -495,10 +453,10 @@ func loadDupeRows(ctx context.Context, db *sql.DB,
|
||||
// values, and skipping the hash columns keeps the scan's in-memory
|
||||
// index small on multi-million-file databases.
|
||||
func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
fn func(path string, size int64, mtime time.Time, hashed bool),
|
||||
fn func(path string, size, mtime int64, hashed bool),
|
||||
) error {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT path, size, mtime, mtime_nsec, head <> '' FROM files")
|
||||
"SELECT path, size, mtime, head <> '' FROM files")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read records: %w", err)
|
||||
}
|
||||
@@ -507,17 +465,17 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
path []byte
|
||||
size, sec, nsec int64
|
||||
hashed int64
|
||||
path []byte
|
||||
size, mtime int64
|
||||
hashed int64
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &size, &sec, &nsec, &hashed)
|
||||
err = rows.Scan(&path, &size, &mtime, &hashed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
fn(string(path), size, time.Unix(sec, nsec), hashed != 0)
|
||||
fn(string(path), size, mtime, hashed != 0)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
@@ -536,7 +494,7 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
// memory; the rows come ordered by size, head, and tail, so each
|
||||
// group's rows arrive together.
|
||||
const contentCandidatesSQL = `
|
||||
SELECT f.path, f.size, f.mtime, f.mtime_nsec, f.head, f.tail, f.content <> ''
|
||||
SELECT f.path, f.size, f.mtime, f.head, f.tail, f.content <> ''
|
||||
FROM files AS f
|
||||
JOIN (
|
||||
SELECT size, head, tail
|
||||
@@ -562,20 +520,17 @@ func loadContentCandidates(ctx context.Context, db *sql.DB,
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
path []byte
|
||||
sec, nsec int64
|
||||
r scanRec
|
||||
hashed int64
|
||||
path []byte
|
||||
r scanRec
|
||||
hashed int64
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
|
||||
&hashed)
|
||||
err = rows.Scan(&path, &r.size, &r.mtime, &r.head, &r.tail, &hashed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
r.path = string(path)
|
||||
r.mtime = time.Unix(sec, nsec)
|
||||
fn(r, hashed != 0)
|
||||
}
|
||||
|
||||
@@ -659,8 +614,8 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
for _, r := range upserts {
|
||||
_, err = st.ExecContext(ctx, []byte(r.path), r.size,
|
||||
r.mtime.Unix(), r.mtime.Nanosecond(), r.head, r.tail, r.content)
|
||||
_, err = st.ExecContext(ctx,
|
||||
[]byte(r.path), r.size, r.mtime, r.head, r.tail, r.content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert %s: %w", r.path, err)
|
||||
}
|
||||
|
||||
+9
-29
@@ -10,22 +10,13 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testDBPath returns a database path inside a fresh temp dir, with
|
||||
// symlinks resolved as the scan lock resolves them, so the lock file
|
||||
// is this path plus ".lock" even where the temp dir is reached through
|
||||
// a symlink.
|
||||
// testDBPath returns a database path inside a fresh temp dir.
|
||||
func testDBPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return filepath.Join(dir, "db.sqlite")
|
||||
return filepath.Join(t.TempDir(), "db.sqlite")
|
||||
}
|
||||
|
||||
// openTestDB creates a fresh scan database in a temp dir.
|
||||
@@ -166,11 +157,9 @@ func TestOpenReportDatabaseMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDatabaseVersionMismatch(t *testing.T) {
|
||||
func TestOpenReportDatabaseVersionMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A database stamped with a schema version other than 0 and
|
||||
// schemaVersion. report, trees and scan must all refuse it.
|
||||
path := testDBPath(t)
|
||||
|
||||
db, err := openScanDatabase(t.Context(), path)
|
||||
@@ -187,12 +176,7 @@ func TestOpenDatabaseVersionMismatch(t *testing.T) {
|
||||
|
||||
_, err = openReportDatabase(t.Context(), path)
|
||||
if !errors.Is(err, errSchemaVersion) {
|
||||
t.Fatalf("report: err = %v, want errSchemaVersion", err)
|
||||
}
|
||||
|
||||
_, err = openScanDatabase(t.Context(), path)
|
||||
if !errors.Is(err, errSchemaVersion) {
|
||||
t.Fatalf("scan: err = %v, want errSchemaVersion", err)
|
||||
t.Fatalf("err = %v, want errSchemaVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,13 +253,10 @@ func TestApplyChangesRoundTrip(t *testing.T) {
|
||||
// written.
|
||||
recs := []scanRec{
|
||||
{
|
||||
size: 2, mtime: time.Unix(20, 999_999_999), head: "h2", tail: "t2",
|
||||
content: "c2", path: "/a/tab\tnew\nline",
|
||||
},
|
||||
{
|
||||
size: 1, mtime: time.Unix(10, 0), head: "h1", tail: "t1",
|
||||
content: "c1", path: "/a/x",
|
||||
size: 2, mtime: 20, head: "h2", tail: "t2", content: "c2",
|
||||
path: "/a/tab\tnew\nline",
|
||||
},
|
||||
{size: 1, mtime: 10, head: "h1", tail: "t1", content: "c1", path: "/a/x"},
|
||||
}
|
||||
|
||||
err := applyChanges(t.Context(), db, recs, nil,
|
||||
@@ -293,8 +274,7 @@ func TestApplyChangesRoundTrip(t *testing.T) {
|
||||
// An upsert for an existing path updates in place; a delete
|
||||
// removes exactly its path.
|
||||
upd := scanRec{
|
||||
size: 3, mtime: time.Unix(30, 0), head: "h3", tail: "t3", content: "c3",
|
||||
path: "/a/x",
|
||||
size: 3, mtime: 30, head: "h3", tail: "t3", content: "c3", path: "/a/x",
|
||||
}
|
||||
|
||||
err = applyChanges(t.Context(), db, []scanRec{upd},
|
||||
@@ -321,7 +301,7 @@ func TestApplyChangesBatching(t *testing.T) {
|
||||
recs := make([]scanRec, 0, n)
|
||||
for i := range n {
|
||||
recs = append(recs, scanRec{
|
||||
size: int64(i), mtime: time.Unix(1, 0), head: "h", tail: "t",
|
||||
size: int64(i), mtime: 1, head: "h", tail: "t",
|
||||
path: fmt.Sprintf("/batch/%07d", i),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,10 +51,6 @@ const (
|
||||
// cobra prints for it is the whole message.
|
||||
var errNoSubcommand = errors.New("no subcommand")
|
||||
|
||||
// errWorkersBelowOne is the usage error for a scan --workers value
|
||||
// below 1.
|
||||
var errWorkersBelowOne = errors.New("--workers must be at least 1")
|
||||
|
||||
// Version is the build version, injected at link time via -ldflags
|
||||
// (see the Makefile); "dev" for a plain go build.
|
||||
//
|
||||
@@ -102,7 +98,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
|
||||
return exitFatal
|
||||
default:
|
||||
// A usage error, which cobra has already reported on stderr.
|
||||
// A usage error: cobra has already printed the message and
|
||||
// the usage text.
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
@@ -157,9 +154,6 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
||||
Use: cmdScan + " [--workers N] [-x] PATH...",
|
||||
Short: "Walk trees and synchronize the scan database",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return checkScanWorkers(cmd, scanWorkers)
|
||||
},
|
||||
RunE: runE(func(ctx context.Context, args []string) error {
|
||||
ctx, stop := interruptContext(ctx)
|
||||
defer stop()
|
||||
@@ -195,19 +189,6 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
// checkScanWorkers rejects a scan --workers value below 1. That is a
|
||||
// usage error reported in one line: cobra prints the returned message
|
||||
// without the usage text, and run exits 2.
|
||||
func checkScanWorkers(cmd *cobra.Command, workers int) error {
|
||||
if workers >= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
return fmt.Errorf("%w, got %d", errWorkersBelowOne, workers)
|
||||
}
|
||||
|
||||
// runE adapts a subcommand implementation, or the version print, to
|
||||
// cobra's RunE. Cobra prints the error and the command's usage text for
|
||||
// every error RunE returns, but a subcommand that ran and failed has no
|
||||
|
||||
+37
-331
@@ -8,7 +8,6 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -85,41 +84,21 @@ func makeReadOnly(t *testing.T, path string) {
|
||||
|
||||
// captureStderr redirects os.Stderr to a file for the rest of the test
|
||||
// and returns a function reading back everything written to it. scan
|
||||
// writes its warnings and summary, and report and trees their
|
||||
// summaries, straight to os.Stderr, not to the stderr writer run is
|
||||
// given.
|
||||
// writes its warnings and summary straight to os.Stderr, not to the
|
||||
// stderr writer run is given.
|
||||
func captureStderr(t *testing.T) func() string {
|
||||
t.Helper()
|
||||
|
||||
return capture(t, &os.Stderr)
|
||||
}
|
||||
|
||||
// captureStdout does for os.Stdout what captureStderr does for
|
||||
// os.Stderr. scan is never given run's stdout writer, so anything it
|
||||
// printed would go straight to os.Stdout. The scan tests pass os.Stdout
|
||||
// as run's stdout too, so the one capture sees both.
|
||||
func captureStdout(t *testing.T) func() string {
|
||||
t.Helper()
|
||||
|
||||
return capture(t, &os.Stdout)
|
||||
}
|
||||
|
||||
// capture redirects *std, which is os.Stdout or os.Stderr, to a file
|
||||
// for the rest of the test and returns a function reading back
|
||||
// everything written to it.
|
||||
func capture(t *testing.T, std **os.File) func() string {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Create(filepath.Join(t.TempDir(), "output"))
|
||||
f, err := os.Create(filepath.Join(t.TempDir(), "stderr"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
saved := *std
|
||||
*std = f
|
||||
saved := os.Stderr
|
||||
os.Stderr = f
|
||||
|
||||
t.Cleanup(func() {
|
||||
*std = saved
|
||||
os.Stderr = saved
|
||||
|
||||
_ = f.Close()
|
||||
})
|
||||
@@ -204,33 +183,30 @@ func TestRunFatalAfterOpenClosesDatabase(t *testing.T) {
|
||||
// sidecar check is evidence of the close only for scan: report and
|
||||
// trees only read a database that is out of WAL mode, which leaves
|
||||
// nothing on disk whether they close it or not.
|
||||
//
|
||||
// The subcommands come from the command tree, so a new one is
|
||||
// checked too: one wired with a bare RunE instead of runE reports
|
||||
// its failure as a usage error, exit 2 with the usage text.
|
||||
for _, cmd := range newRootCommand(io.Discard, io.Discard).Commands() {
|
||||
name := cmd.Name()
|
||||
cases := map[string][]string{
|
||||
cmdScan: {cmdScan},
|
||||
cmdReport: {cmdReport},
|
||||
cmdTrees: {cmdTrees},
|
||||
}
|
||||
|
||||
for name, args := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := brokenDatabase(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
args := []string{name}
|
||||
if name == cmdScan {
|
||||
args = append(args, t.TempDir())
|
||||
}
|
||||
|
||||
stdout := captureStdout(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run(args, os.Stdout, &stderr)
|
||||
code := run(args, &stdout, &stderr)
|
||||
if code != exitFatal {
|
||||
t.Errorf("run(%v) = %d, want %d", args, code, exitFatal)
|
||||
}
|
||||
|
||||
assertNoSidecars(t, path)
|
||||
assertFatalOutput(t, stderr.String(), stdout())
|
||||
assertFatalOutput(t, stderr.String(), stdout.String())
|
||||
|
||||
// Proof that the failure happened after the open: only a
|
||||
// query against the opened database can report this.
|
||||
@@ -242,24 +218,22 @@ func TestRunFatalAfterOpenClosesDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNonexistentPathIsFatalNotUsage(t *testing.T) {
|
||||
func TestRunMissingOperandIsFatalNotUsage(t *testing.T) {
|
||||
// README §Error handling: a PATH operand that does not exist is a
|
||||
// fatal error (1), not a usage error (2) — and a runtime failure
|
||||
// must not dump the usage text.
|
||||
t.Setenv(databaseEnv, testDBPath(t))
|
||||
|
||||
stdout := captureStdout(t)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "nope")
|
||||
|
||||
code := run([]string{cmdScan, missing}, os.Stdout, &stderr)
|
||||
code := run([]string{cmdScan, missing}, &stdout, &stderr)
|
||||
if code != exitFatal {
|
||||
t.Errorf("run(scan %s) = %d, want %d", missing, code, exitFatal)
|
||||
}
|
||||
|
||||
assertFatalOutput(t, stderr.String(), stdout())
|
||||
assertFatalOutput(t, stderr.String(), stdout.String())
|
||||
}
|
||||
|
||||
// assertFatalOutput checks that a fatal error was reported the way
|
||||
@@ -283,34 +257,6 @@ func assertFatalOutput(t *testing.T, stderr, stdout string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMissingDatabaseIsFatal(t *testing.T) {
|
||||
// README §Database: report and trees need an existing database; a
|
||||
// missing one exits 1 with a message telling the user to run scan.
|
||||
for _, name := range []string{cmdReport, cmdTrees} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := run([]string{name}, &stdout, &stderr)
|
||||
if code != exitFatal {
|
||||
t.Errorf("run(%s) = %d, want %d", name, code, exitFatal)
|
||||
}
|
||||
|
||||
want := "sfdupes: " + path + ": no database (run \"sfdupes " +
|
||||
"scan\" first, or set " + databaseEnv + ")\n"
|
||||
if got := stderr.String(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Errorf("stdout = %q, want nothing (data only)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsageErrors(t *testing.T) {
|
||||
// Usage errors keep exiting 2 with cobra's own report on stderr.
|
||||
cases := map[string]struct {
|
||||
@@ -349,42 +295,6 @@ func TestRunUsageErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
||||
// README §scan mode: --workers below 1 is a usage error reported in
|
||||
// one line on stderr, before the scan opens the database.
|
||||
for _, workers := range []string{"0", "-1"} {
|
||||
t.Run(workers, func(t *testing.T) {
|
||||
dbPath := testDBPath(t)
|
||||
t.Setenv(databaseEnv, dbPath)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
args := []string{cmdScan, "--workers", workers, t.TempDir()}
|
||||
|
||||
code := run(args, &stdout, &stderr)
|
||||
if code != exitUsage {
|
||||
t.Errorf("run(%v) = %d, want %d", args, code, exitUsage)
|
||||
}
|
||||
|
||||
want := "Error: --workers must be at least 1, got " + workers +
|
||||
"\n"
|
||||
if got := stderr.String(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Errorf("stdout = %q, want nothing (data only)", got)
|
||||
}
|
||||
|
||||
_, err := os.Stat(dbPath)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("stat %s: %v, want the database never created",
|
||||
dbPath, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -488,16 +398,17 @@ func scanFixture(t *testing.T) []string {
|
||||
func scanOK(t *testing.T, operands ...string) string {
|
||||
t.Helper()
|
||||
|
||||
stdout := captureStdout(t)
|
||||
var stdout bytes.Buffer
|
||||
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run(append([]string{cmdScan}, operands...), os.Stdout, os.Stderr)
|
||||
code := run(append([]string{cmdScan}, operands...), &stdout, os.Stderr)
|
||||
if code != exitOK {
|
||||
t.Fatalf("run(scan %q) = %d, want %d; stderr: %s",
|
||||
operands, code, exitOK, stderr())
|
||||
}
|
||||
|
||||
if got := stdout(); got != "" {
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Errorf("scan stdout = %q, want nothing (data only)", got)
|
||||
}
|
||||
|
||||
@@ -505,41 +416,10 @@ func scanOK(t *testing.T, operands ...string) string {
|
||||
}
|
||||
|
||||
func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
|
||||
// README §Error handling: a scan that skips a file it cannot read
|
||||
// warns, counts the skip in its summary, and still exits 0, which
|
||||
// scanOK checks along with the empty stdout.
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root ignores file permissions")
|
||||
}
|
||||
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "a.bin", pattern(1, 300))
|
||||
|
||||
// Same size as a.bin, so the scan reads it, and the read fails.
|
||||
unreadable := writeFile(t, dir, "unreadable.bin", pattern(2, 300))
|
||||
|
||||
err := os.Chmod(unreadable, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stderr := scanOK(t, dir)
|
||||
|
||||
warning := "hash " + unreadable + ": open " + unreadable +
|
||||
": permission denied\n"
|
||||
if !strings.Contains(stderr, warning) {
|
||||
t.Errorf("stderr = %q, want %q", stderr, warning)
|
||||
}
|
||||
|
||||
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 1 skipped\n"
|
||||
if !strings.Contains(stderr, summary) {
|
||||
t.Errorf("stderr = %q, want %q", stderr, summary)
|
||||
}
|
||||
|
||||
scanFixture(t)
|
||||
assertNoSidecars(t, path)
|
||||
}
|
||||
|
||||
@@ -646,14 +526,12 @@ func TestRunReportSucceeds(t *testing.T) {
|
||||
|
||||
dupes := scanFixture(t)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run([]string{cmdReport}, &stdout, os.Stderr)
|
||||
code := run([]string{cmdReport}, &stdout, &stderr)
|
||||
if code != exitOK {
|
||||
t.Fatalf("run(report) = %d, want %d; stderr: %s",
|
||||
code, exitOK, stderr())
|
||||
code, exitOK, stderr.String())
|
||||
}
|
||||
|
||||
want := "first\tdupe\tsize\n" + dupes[0] + "\t" + dupes[1] + "\t300\n"
|
||||
@@ -661,12 +539,6 @@ func TestRunReportSucceeds(t *testing.T) {
|
||||
t.Errorf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "report: 2 records read, 1 duplicate groups, 1 dupe files, " +
|
||||
"300 B reclaimable\n"
|
||||
if got := stderr(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
assertNoSidecars(t, path)
|
||||
}
|
||||
|
||||
@@ -676,14 +548,12 @@ func TestRunTreesSucceeds(t *testing.T) {
|
||||
|
||||
dupes := scanFixture(t)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run([]string{cmdTrees}, &stdout, os.Stderr)
|
||||
code := run([]string{cmdTrees}, &stdout, &stderr)
|
||||
if code != exitOK {
|
||||
t.Fatalf("run(trees) = %d, want %d; stderr: %s",
|
||||
code, exitOK, stderr())
|
||||
code, exitOK, stderr.String())
|
||||
}
|
||||
|
||||
// The two directories holding the duplicate pair are duplicate
|
||||
@@ -694,12 +564,6 @@ func TestRunTreesSucceeds(t *testing.T) {
|
||||
t.Errorf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "trees: 2 records read, 1 duplicate tree groups, 1 dupe trees, " +
|
||||
"300 B reclaimable\n"
|
||||
if got := stderr(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
assertNoSidecars(t, path)
|
||||
}
|
||||
|
||||
@@ -739,73 +603,6 @@ func TestRunReportsNeedOnlyReadAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// assertRunsUseDatabase runs scan, then report and trees, against the
|
||||
// database that SFDUPES_DATABASE names, the file name in dir. It fails
|
||||
// unless the reports find the duplicate pair the scan recorded and dir
|
||||
// then holds only that file and its lock file: nothing was created
|
||||
// under a shortened name.
|
||||
func assertRunsUseDatabase(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
|
||||
dupes := scanFixture(t)
|
||||
|
||||
want := "first\tdupe\tsize\n" + dupes[0] + "\t" + dupes[1] + "\t300\n"
|
||||
if got := runStdout(t, cmdReport); got != want {
|
||||
t.Errorf("report stdout = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "first\tdupe\tfiles\tsize\n" +
|
||||
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n"
|
||||
if got := runStdout(t, cmdTrees); got != want {
|
||||
t.Errorf("trees stdout = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
got = append(got, e.Name())
|
||||
}
|
||||
|
||||
if wantFiles := []string{name, name + ".lock"}; !slices.Equal(got, wantFiles) {
|
||||
t.Errorf("%s holds %q, want %q", dir, got, wantFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
|
||||
// README §Database: the path names the database file exactly. In
|
||||
// SQLite's connection string an unescaped ? or # would end the file
|
||||
// name and % would start an escape, and a path starting with //
|
||||
// could be read as a host name. %25 is a valid escape, so unescaped
|
||||
// this name opens a file named a without any error.
|
||||
const name = "a?b#c%25d e.sqlite"
|
||||
|
||||
t.Run("absolute", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(databaseEnv, filepath.Join(dir, name))
|
||||
|
||||
assertRunsUseDatabase(t, dir, name)
|
||||
})
|
||||
|
||||
t.Run("leading double slash", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(databaseEnv, "/"+filepath.Join(dir, name))
|
||||
|
||||
assertRunsUseDatabase(t, dir, name)
|
||||
})
|
||||
|
||||
t.Run("relative", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
t.Setenv(databaseEnv, name)
|
||||
|
||||
assertRunsUseDatabase(t, dir, name)
|
||||
})
|
||||
}
|
||||
|
||||
// holdScanLock takes the lock on the database at path, as a running
|
||||
// scan does, and holds it until the test ends. It fails the test when
|
||||
// the lock is already held.
|
||||
@@ -822,18 +619,16 @@ func holdScanLock(t *testing.T, path string) {
|
||||
|
||||
func TestRunSecondScanFails(t *testing.T) {
|
||||
// README §Database: while one scan holds the lock, a second scan
|
||||
// fails at once, naming the lock file, without opening the
|
||||
// fails at once, naming the lock file, without creating the
|
||||
// database.
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
holdScanLock(t, path)
|
||||
|
||||
stdout := captureStdout(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run([]string{cmdScan, t.TempDir()}, os.Stdout, &stderr)
|
||||
code := run([]string{cmdScan, t.TempDir()}, &stdout, &stderr)
|
||||
if code != exitFatal {
|
||||
t.Errorf("run(scan) = %d, want %d", code, exitFatal)
|
||||
}
|
||||
@@ -844,103 +639,14 @@ func TestRunSecondScanFails(t *testing.T) {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := stdout(); got != "" {
|
||||
if got := stdout.String(); got != "" {
|
||||
t.Errorf("stdout = %q, want nothing (data only)", got)
|
||||
}
|
||||
|
||||
// Taking the lock created the database file, empty; opening it as
|
||||
// a database would have written to it.
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
_, err := os.Stat(path)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("stat %s = %v, want the database not created", path, err)
|
||||
}
|
||||
|
||||
if info.Size() != 0 {
|
||||
t.Errorf("database size = %d, want 0 (not opened)", info.Size())
|
||||
}
|
||||
}
|
||||
|
||||
// assertSecondScanFails runs a scan against the database
|
||||
// SFDUPES_DATABASE names while the test holds the lock on it. It fails
|
||||
// the test unless that scan fails at once, naming the lock file beside
|
||||
// path, the database's real path.
|
||||
func assertSecondScanFails(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run([]string{cmdScan, t.TempDir()}, io.Discard, &stderr)
|
||||
if code != exitFatal {
|
||||
t.Errorf("run(scan) = %d, want %d", code, exitFatal)
|
||||
}
|
||||
|
||||
want := "sfdupes: another scan is running (lock held on " +
|
||||
path + ".lock)\n"
|
||||
if got := stderr.String(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSecondScanThroughSymlinkFails(t *testing.T) {
|
||||
// README §Database: every path to one database names the same lock
|
||||
// file, so while a scan holds it through one path, a scan through
|
||||
// another fails, naming the lock file beside the database's real
|
||||
// path.
|
||||
t.Run("symlink to the database", func(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
scanFixture(t)
|
||||
|
||||
link := filepath.Join(t.TempDir(), "link.sqlite")
|
||||
|
||||
err := os.Symlink(path, link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holdScanLock(t, path)
|
||||
t.Setenv(databaseEnv, link)
|
||||
assertSecondScanFails(t, path)
|
||||
})
|
||||
|
||||
t.Run("symlink to its directory, no database yet", func(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
|
||||
err := os.Symlink(filepath.Dir(path), link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holdScanLock(t, path)
|
||||
t.Setenv(databaseEnv, filepath.Join(link, filepath.Base(path)))
|
||||
assertSecondScanFails(t, path)
|
||||
})
|
||||
|
||||
t.Run("symlink whose target does not exist yet", func(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
link := filepath.Join(t.TempDir(), "link.sqlite")
|
||||
|
||||
err := os.Symlink(path, link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The first scan takes the lock through the link before the
|
||||
// database exists, then creates the database through it.
|
||||
holdScanLock(t, link)
|
||||
|
||||
db, err := openScanDatabase(t.Context(), link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
closeScanDatabase(t.Context(), db, link)
|
||||
|
||||
t.Setenv(databaseEnv, link)
|
||||
assertSecondScanFails(t, path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunScanReleasesLock(t *testing.T) {
|
||||
|
||||
@@ -139,13 +139,6 @@ func (p *progress) warnf(format string, args ...any) {
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
|
||||
// The library never draws a finished bar again, so the warning for
|
||||
// a pass's last item would erase it for good: draw its last frame
|
||||
// again below the warning.
|
||||
if p.bar != nil && p.bar.IsFinished() {
|
||||
fmt.Fprint(os.Stderr, p.bar.String())
|
||||
}
|
||||
}
|
||||
|
||||
// finish terminates the pass's display. A bar whose pass stopped short
|
||||
|
||||
@@ -187,25 +187,3 @@ func TestBarStoppedShortKeepsCount(t *testing.T) {
|
||||
t.Errorf("terminal shows %q, want a count of 2 of 10", shown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarningAfterLastItemKeepsBar checks that a warning printed after
|
||||
// a pass's last item, as for a failed read of the last file, leaves the
|
||||
// pass's finished bar on the terminal below the warning.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestWarningAfterLastItemKeepsBar(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := &progress{
|
||||
label: "hash", total: 2, start: time.Now(),
|
||||
bar: newBar("hash", 2),
|
||||
}
|
||||
|
||||
p.increment()
|
||||
p.increment()
|
||||
p.warnf("warning")
|
||||
p.finish()
|
||||
|
||||
if shown := lastFrame(stderr()); !strings.Contains(shown, "(2/2,") {
|
||||
t.Errorf("terminal shows %q, want a count of 2 of 2", shown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ioBufSize is the buffer size for the buffered stdout writers.
|
||||
@@ -22,7 +21,7 @@ const minGroupSize = 2
|
||||
// only and used by scan for change detection.
|
||||
type scanRec struct {
|
||||
size int64
|
||||
mtime time.Time
|
||||
mtime int64
|
||||
head string
|
||||
tail string
|
||||
content string
|
||||
|
||||
+2
-9
@@ -11,7 +11,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// awkwardDir is a directory name holding every byte the reports escape.
|
||||
@@ -314,14 +313,8 @@ func TestDupeGroupsMtimeExcluded(t *testing.T) {
|
||||
// mtime is informational only; records differing only in mtime
|
||||
// still group together.
|
||||
recs := []scanRec{
|
||||
{
|
||||
size: 9, mtime: time.Unix(100, 0), head: "h", tail: "t",
|
||||
content: "c", path: "/m/1",
|
||||
},
|
||||
{
|
||||
size: 9, mtime: time.Unix(200, 0), head: "h", tail: "t",
|
||||
content: "c", path: "/m/2",
|
||||
},
|
||||
{size: 9, mtime: 100, head: "h", tail: "t", content: "c", path: "/m/1"},
|
||||
{size: 9, mtime: 200, head: "h", tail: "t", content: "c", path: "/m/2"},
|
||||
}
|
||||
|
||||
groups := dupeGroupsOf(t, recs)
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The duplicate ladder (see hashSignature and README "Duplicate
|
||||
@@ -69,7 +68,7 @@ var errInterrupted = errors.New("scan interrupted")
|
||||
type fileRec struct {
|
||||
path string
|
||||
size int64
|
||||
mtime time.Time
|
||||
mtime int64
|
||||
dev uint64
|
||||
ino uint64
|
||||
}
|
||||
@@ -80,7 +79,7 @@ type fileRec struct {
|
||||
// they would dominate the scan's memory.
|
||||
type fileMeta struct {
|
||||
size int64
|
||||
mtime time.Time
|
||||
mtime int64
|
||||
hashed bool
|
||||
}
|
||||
|
||||
@@ -99,11 +98,14 @@ type fileMeta struct {
|
||||
// is released after it. When ctx is cancelled, as by the SIGINT or
|
||||
// SIGTERM that interruptContext catches, the scan keeps what it has
|
||||
// hashed (see syncScan), prints how many files its walk reached, and
|
||||
// returns errInterrupted. workers must be at least 1; the scan command
|
||||
// rejects anything less.
|
||||
// returns errInterrupted.
|
||||
func runScan(ctx context.Context, roots []string, workers int,
|
||||
oneFS bool,
|
||||
) error {
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
roots, err := resolveRoots(roots)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -203,16 +205,10 @@ func resolveRoots(roots []string) ([]string, error) {
|
||||
}
|
||||
|
||||
// pruneRoots drops operands already covered by another operand:
|
||||
// duplicates and any operand lying under another one. With -x, an
|
||||
// operand under another one is kept when the walk of the deepest kept
|
||||
// operand above it cannot reach it: when device puts any directory from
|
||||
// it (from its directory, for a regular file) up to but not including
|
||||
// that operand on a different filesystem than that operand. Every
|
||||
// remaining file is then reachable through exactly one root, so walked
|
||||
// paths are unique without keeping a scan-wide set of every path seen.
|
||||
func pruneRoots(roots []string, oneFS bool,
|
||||
device func(path string) (uint64, bool),
|
||||
) []string {
|
||||
// duplicates and any operand lying under another one. Every remaining
|
||||
// file is then reachable through exactly one root, so walked paths are
|
||||
// unique without keeping a scan-wide set of every path seen.
|
||||
func pruneRoots(roots []string) []string {
|
||||
// Shorter-first ordering guarantees an ancestor is kept before any
|
||||
// operand under it is considered.
|
||||
sorted := slices.Clone(roots)
|
||||
@@ -227,58 +223,14 @@ func pruneRoots(roots []string, oneFS bool,
|
||||
kept := make([]string, 0, len(sorted))
|
||||
|
||||
for _, r := range sorted {
|
||||
above := deepestRoot(r, kept)
|
||||
if above == "" {
|
||||
if !underAnyRoot(r, kept) {
|
||||
kept = append(kept, r)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Without -x, and always for a duplicate, r is dropped.
|
||||
if !oneFS || above == r {
|
||||
continue
|
||||
}
|
||||
|
||||
aboveDev, aboveOK := device(above)
|
||||
if !aboveOK {
|
||||
continue
|
||||
}
|
||||
|
||||
// The walk of above enters a directory only when it is on
|
||||
// above's filesystem, and then reaches every regular file in it
|
||||
// whatever device the file reports, so for a regular-file
|
||||
// operand the directories checked start at its directory.
|
||||
dir := r
|
||||
|
||||
fi, err := os.Lstat(r)
|
||||
if err == nil && fi.Mode().IsRegular() {
|
||||
dir = filepath.Dir(r)
|
||||
}
|
||||
|
||||
for ; dir != above; dir = filepath.Dir(dir) {
|
||||
dev, ok := device(dir)
|
||||
if ok && dev != aboveDev {
|
||||
kept = append(kept, r)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
// deviceOfPath returns the filesystem device ID of the file at path,
|
||||
// when lstat succeeds and the platform exposes one.
|
||||
func deviceOfPath(path string) (uint64, bool) {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return deviceOfInfo(fi)
|
||||
}
|
||||
|
||||
// scanStats summarizes one scan's database synchronization for the
|
||||
// final stderr summary, or for the line an interrupted scan prints.
|
||||
type scanStats struct {
|
||||
@@ -338,22 +290,21 @@ func syncScan(ctx context.Context, db *sql.DB, roots []string,
|
||||
// size, head, and tail match another record's). Records outside the
|
||||
// roots are never touched, except that the content phase fills in
|
||||
// their content hash. Operands the walk cannot start from are dropped
|
||||
// first, so the records beneath them are treated as if those operands
|
||||
// had not been given.
|
||||
// first, so the records beneath them count as outside the roots unless
|
||||
// they lie under another root.
|
||||
func (s *scanState) runPhases(ctx context.Context, roots []string,
|
||||
workers int, oneFS bool,
|
||||
) error {
|
||||
// Types are checked before pruning so that an operand under a
|
||||
// dropped one is still scanned, not dropped as lying under it.
|
||||
roots = pruneRoots(s.walkableRoots(roots), oneFS, deviceOfPath)
|
||||
roots = pruneRoots(s.walkableRoots(roots))
|
||||
|
||||
err := s.loadIndex(ctx, roots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed, unhashed := s.walkPhase(
|
||||
startWalk(ctx, roots, oneFS, workers), roots)
|
||||
changed, unhashed := s.walkPhase(startWalk(ctx, roots, oneFS, workers))
|
||||
|
||||
// A cancelled walk stops early, so its size census covers only part
|
||||
// of the roots, and every file it never reached would look vanished
|
||||
@@ -381,10 +332,10 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
||||
// walkableRoots returns the operands the walk can start from: regular
|
||||
// files, and directories not named .zfs. Every other operand is warned
|
||||
// about, counted as skipped, and dropped. A dropped operand is no
|
||||
// longer a root, so the records stored beneath it are treated as if it
|
||||
// had not been given: those under no other root count as outside the
|
||||
// roots and are not deleted as unverified. An operand that fails lstat
|
||||
// here is kept, and the walk warns about it.
|
||||
// longer a root, so the records stored beneath it count as outside the
|
||||
// roots and are not deleted as unverified, unless it lies under another
|
||||
// root. An operand that fails lstat here is kept, and the walk warns
|
||||
// about it.
|
||||
func (s *scanState) walkableRoots(roots []string) []string {
|
||||
kept := make([]string, 0, len(roots))
|
||||
|
||||
@@ -421,7 +372,7 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
||||
s.existing = make(map[string]fileMeta)
|
||||
|
||||
return loadFileMeta(ctx, s.db,
|
||||
func(path string, size int64, mtime time.Time, hashed bool) {
|
||||
func(path string, size, mtime int64, hashed bool) {
|
||||
prog.increment()
|
||||
|
||||
if underAnyRoot(path, roots) {
|
||||
@@ -442,27 +393,15 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
||||
// (the content phase may still fill in its content hash). It returns
|
||||
// the new-or-changed files and the unchanged files whose records lack
|
||||
// hashes; both remain candidates until the census decides whether
|
||||
// their sizes are shared. A record below a directory -x did not enter
|
||||
// counts as outside the roots: it is not deleted, and its size joins
|
||||
// the census. That does not hold below a root at or inside that
|
||||
// directory, since that root's own walk verifies the records below it.
|
||||
// their sizes are shared.
|
||||
func (s *scanState) walkPhase(
|
||||
events <-chan walkEvent, roots []string,
|
||||
events <-chan walkEvent,
|
||||
) ([]fileRec, []fileRec) {
|
||||
prog := newProgress("walk", -1)
|
||||
|
||||
var (
|
||||
changed, unhashed []fileRec
|
||||
otherFSDirs []string
|
||||
)
|
||||
var changed, unhashed []fileRec
|
||||
|
||||
for ev := range events {
|
||||
if ev.otherFSDir != "" {
|
||||
otherFSDirs = append(otherFSDirs, ev.otherFSDir)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if ev.fail {
|
||||
s.st.skipped++
|
||||
|
||||
@@ -479,7 +418,7 @@ func (s *scanState) walkPhase(
|
||||
old, ok := s.existing[ev.rec.path]
|
||||
|
||||
switch {
|
||||
case !ok || old.size != ev.rec.size || mtimeAfter(ev.rec.mtime, old.mtime):
|
||||
case !ok || old.size != ev.rec.size || old.mtime < ev.rec.mtime:
|
||||
changed = append(changed, ev.rec)
|
||||
case old.hashed:
|
||||
delete(s.existing, ev.rec.path)
|
||||
@@ -492,19 +431,6 @@ func (s *scanState) walkPhase(
|
||||
|
||||
prog.finish()
|
||||
|
||||
for path, old := range s.existing {
|
||||
// Kept only when a directory -x did not enter lies below the
|
||||
// deepest root above the record. A root at or below that
|
||||
// directory was walked on its own and did not find the file.
|
||||
// Both contain the record, so the longer path is the deeper.
|
||||
otherFSDir := deepestRoot(path, otherFSDirs)
|
||||
if len(otherFSDir) > len(deepestRoot(path, roots)) {
|
||||
delete(s.existing, path)
|
||||
|
||||
s.sizes = append(s.sizes, old.size)
|
||||
}
|
||||
}
|
||||
|
||||
return changed, unhashed
|
||||
}
|
||||
|
||||
@@ -884,7 +810,7 @@ func unchangedFile(r scanRec) (fileRec, bool, error) {
|
||||
}
|
||||
|
||||
if !fi.Mode().IsRegular() || fi.Size() != r.size ||
|
||||
mtimeAfter(fi.ModTime(), r.mtime) {
|
||||
fi.ModTime().Unix() > r.mtime {
|
||||
return fileRec{}, false, nil
|
||||
}
|
||||
|
||||
@@ -895,16 +821,6 @@ func unchangedFile(r scanRec) (fileRec, bool, error) {
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// mtimeAfter reports whether mtime a is later than mtime b.
|
||||
// Not a.After(b): time.Time wraps an mtime past year 292 billion; Unix() undoes it.
|
||||
func mtimeAfter(a, b time.Time) bool {
|
||||
if a.Unix() != b.Unix() {
|
||||
return a.Unix() > b.Unix()
|
||||
}
|
||||
|
||||
return a.Nanosecond() > b.Nanosecond()
|
||||
}
|
||||
|
||||
// underAnyRoot reports whether path is any of the roots or lies under
|
||||
// one of them.
|
||||
func underAnyRoot(path string, roots []string) bool {
|
||||
@@ -932,20 +848,6 @@ func underRoot(path, root string) bool {
|
||||
return strings.HasPrefix(path, prefix)
|
||||
}
|
||||
|
||||
// deepestRoot returns the longest of the roots that path is or lies
|
||||
// under, or "" when it lies under none.
|
||||
func deepestRoot(path string, roots []string) string {
|
||||
deepest := ""
|
||||
|
||||
for _, root := range roots {
|
||||
if underRoot(path, root) && len(root) > len(deepest) {
|
||||
deepest = root
|
||||
}
|
||||
}
|
||||
|
||||
return deepest
|
||||
}
|
||||
|
||||
// dirJob is one directory awaiting traversal by the walk workers. It
|
||||
// carries its operand's filesystem device so -x can stop at
|
||||
// filesystem boundaries.
|
||||
@@ -956,23 +858,19 @@ type dirJob struct {
|
||||
}
|
||||
|
||||
// walkEvent is one walk result delivered to the walk phase: a regular
|
||||
// file's statted record, a warning when fail is set, or, when
|
||||
// otherFSDir is set, a directory -x did not enter because it is on
|
||||
// another filesystem than its operand.
|
||||
// file's statted record, or a warning when fail is set.
|
||||
type walkEvent struct {
|
||||
rec fileRec
|
||||
warn string
|
||||
fail bool
|
||||
otherFSDir string
|
||||
rec fileRec
|
||||
warn string
|
||||
fail bool
|
||||
}
|
||||
|
||||
// startWalk seeds every root into the shared walk worker pool and
|
||||
// returns the event stream: one record per regular file, one warning
|
||||
// event per per-path error, and one event per directory -x did not
|
||||
// enter. The channel is closed when the walk completes, and also when
|
||||
// ctx is cancelled — every goroutine in the pool abandons its blocking
|
||||
// send in that case, so the consumer sees a truncated but properly
|
||||
// terminated stream instead of a stalled one.
|
||||
// event per per-path error. The channel is closed when the walk
|
||||
// completes, and also when ctx is cancelled — every goroutine in the
|
||||
// pool abandons its blocking send in that case, so the consumer sees a
|
||||
// truncated but properly terminated stream instead of a stalled one.
|
||||
func startWalk(ctx context.Context, roots []string, oneFS bool,
|
||||
workers int,
|
||||
) <-chan walkEvent {
|
||||
@@ -1039,8 +937,7 @@ func operandWarning(root string, fi fs.FileInfo) string {
|
||||
// operand becomes an initial job. walkableRoots has already dropped
|
||||
// every other operand. One that has changed into something else since
|
||||
// is warned about and skipped here; it is still a root, so the records
|
||||
// stored beneath it, other than those under another root, are deleted
|
||||
// as unverified.
|
||||
// stored beneath it are deleted as unverified.
|
||||
func seedRoot(ctx context.Context, root string,
|
||||
events chan<- walkEvent,
|
||||
) []dirJob {
|
||||
@@ -1072,7 +969,7 @@ func seedRoot(ctx context.Context, root string,
|
||||
sendEvent(ctx, events, walkEvent{rec: fileRec{
|
||||
path: root,
|
||||
size: fi.Size(),
|
||||
mtime: fi.ModTime(),
|
||||
mtime: fi.ModTime().Unix(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
}})
|
||||
@@ -1230,7 +1127,7 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
||||
sendEvent(ctx, events, walkEvent{rec: fileRec{
|
||||
path: p,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
mtime: info.ModTime().Unix(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
}})
|
||||
@@ -1239,9 +1136,7 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
||||
// subdirJob applies the descent rules to directory p: never enter
|
||||
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
|
||||
// snapshot), and with -x never enter a directory on a different
|
||||
// filesystem than its operand, sending an event that names it so the
|
||||
// scan keeps the records below it, except those below an operand at or
|
||||
// inside it.
|
||||
// filesystem than its operand.
|
||||
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
||||
parent dirJob, oneFS bool, events chan<- walkEvent,
|
||||
) (dirJob, bool) {
|
||||
@@ -1265,8 +1160,6 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
||||
}
|
||||
|
||||
if dev, ok := deviceOfInfo(info); ok && dev != parent.rootDev {
|
||||
sendEvent(ctx, events, walkEvent{otherFSDir: p})
|
||||
|
||||
return dirJob{}, false
|
||||
}
|
||||
|
||||
@@ -1408,38 +1301,6 @@ func hashWorker(ctx context.Context, jobs <-chan []fileRec,
|
||||
}
|
||||
}
|
||||
|
||||
// errNotRegular reports a path that is no longer a regular file when it
|
||||
// is opened to be read.
|
||||
var errNotRegular = errors.New("not a regular file")
|
||||
|
||||
// openRegularFile opens path for reading and returns it only if it is
|
||||
// still a regular file, since the path may have been replaced after it
|
||||
// was statted. O_NONBLOCK keeps the open of a FIFO or a device node
|
||||
// from waiting forever, and fstat then rejects it. Reads of a regular
|
||||
// file ignore O_NONBLOCK.
|
||||
func openRegularFile(path string) (*os.File, error) {
|
||||
//nolint:gosec // hashing operator-supplied paths is the tool's purpose
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !fi.Mode().IsRegular() {
|
||||
_ = f.Close()
|
||||
|
||||
return nil, errNotRegular
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// emptyHash is the lowercase-hex SHA-256 of the empty input: the head,
|
||||
// tail, and content hash of every zero-length file.
|
||||
const emptyHash = "e3b0c44298fc1c149afbf4c8996fb924" +
|
||||
@@ -1462,7 +1323,8 @@ func hashSignature(path string, size int64) (string, string, string, error) {
|
||||
return emptyHash, emptyHash, emptyHash, nil
|
||||
}
|
||||
|
||||
f, err := openRegularFile(path)
|
||||
//nolint:gosec // hashing operator-supplied paths is the tool's purpose
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
@@ -1493,7 +1355,8 @@ func hashSignature(path string, size int64) (string, string, string, error) {
|
||||
// tail are returned empty, because the content phase keeps the ones its
|
||||
// records already hold.
|
||||
func hashContentOnly(path string, size int64) (string, string, string, error) {
|
||||
f, err := openRegularFile(path)
|
||||
//nolint:gosec // hashing operator-supplied paths is the tool's purpose
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
+11
-605
@@ -20,8 +20,6 @@ import (
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// writeFile creates a file with the given content and returns its path.
|
||||
@@ -583,65 +581,6 @@ func TestScanContentHashedStalePartners(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanContentSameSecondRewrite is TestScanContentStalePartners for
|
||||
// a stored file rewritten in place at the same size with an mtime later
|
||||
// in the same second than recorded: the file counts as changed, so
|
||||
// neither it nor its match inside the operand is read.
|
||||
func TestScanContentSameSecondRewrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openTestDB(t)
|
||||
dirA := t.TempDir()
|
||||
changed := sparseFileWithoutMatch(t, dirA, "changed", headTailMin)
|
||||
|
||||
first := time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC)
|
||||
|
||||
err := os.Chtimes(changed, first, first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
syncTree(t, db, dirA)
|
||||
|
||||
before := dbRecords(t, db)
|
||||
|
||||
// Rewrite one byte in place, keeping the size.
|
||||
pokeAt(t, changed, headTailMin/2, []byte{1})
|
||||
|
||||
later := first.Add(500 * time.Millisecond)
|
||||
|
||||
err = os.Chtimes(changed, later, later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dirB := t.TempDir()
|
||||
sparseFile(t, dirB, "changed-copy", headTailMin)
|
||||
|
||||
st := syncTree(t, db, dirB)
|
||||
if st != (scanStats{walked: 1, added: 1}) {
|
||||
t.Errorf("stats = %+v, want 1 added and nothing skipped", st)
|
||||
}
|
||||
|
||||
recs := dbRecords(t, db)
|
||||
for _, r := range recs {
|
||||
if r.content != "" {
|
||||
t.Errorf("%s: content = %q, want none: its only match is stale",
|
||||
r.path, r.content)
|
||||
}
|
||||
}
|
||||
|
||||
for _, old := range before {
|
||||
if r := recordByPath(t, recs, old.path); r != old {
|
||||
t.Errorf("record = %+v, want it left as %+v", r, old)
|
||||
}
|
||||
}
|
||||
|
||||
if groups := dupeGroups(t, db); len(groups) != 0 {
|
||||
t.Errorf("groups = %+v, want none", groups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanContentReadFailure checks that a failed content read is
|
||||
// counted as skipped and leaves the record without a content hash, and
|
||||
// that a later scan tries the read again.
|
||||
@@ -844,8 +783,8 @@ func TestWalk(t *testing.T) {
|
||||
t.Errorf("%s: size = %d, want 1..3", r.path, r.size)
|
||||
}
|
||||
|
||||
if r.mtime.Unix() <= 0 {
|
||||
t.Errorf("%s: mtime = %v, want after 1970", r.path, r.mtime)
|
||||
if r.mtime <= 0 {
|
||||
t.Errorf("%s: mtime = %d, want positive", r.path, r.mtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -997,7 +936,7 @@ func dirEntryFor(t *testing.T, dir, name string) fs.DirEntry {
|
||||
}
|
||||
|
||||
// callSubdirJob runs subdirJob against e under parent, collecting any
|
||||
// events it emits (subdirJob emits at most one).
|
||||
// warning events it emits (subdirJob emits at most one).
|
||||
func callSubdirJob(t *testing.T, p string, e fs.DirEntry,
|
||||
parent dirJob, oneFS bool,
|
||||
) (dirJob, bool, []walkEvent) {
|
||||
@@ -1075,15 +1014,8 @@ func TestSubdirJobOneFilesystem(t *testing.T) {
|
||||
t.Fatalf("accepted = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
|
||||
// A refused descent names the directory, so the scan keeps
|
||||
// the records below it.
|
||||
var wantEvs []walkEvent
|
||||
if !ok {
|
||||
wantEvs = []walkEvent{{otherFSDir: sub}}
|
||||
}
|
||||
|
||||
if !slices.Equal(evs, wantEvs) {
|
||||
t.Fatalf("events = %+v, want %+v", evs, wantEvs)
|
||||
if len(evs) != 0 {
|
||||
t.Fatalf("unexpected events: %+v", evs)
|
||||
}
|
||||
|
||||
// The accepted job must carry the operand's device down, or -x
|
||||
@@ -1135,296 +1067,6 @@ func TestSubdirJobStatError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// syncOneFilesystem scans roots into db with -x and returns the scan
|
||||
// stats. Every device pruneRoots looks up, and the device each
|
||||
// operand's walk compares its subdirectories with, comes from devices
|
||||
// instead of from lstat, so a test can put a path on a filesystem that
|
||||
// no directory here is on. Unlike runPhases, it skips walkableRoots,
|
||||
// the check for a cancelled walk, and the content phase.
|
||||
func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
||||
devices map[string]uint64,
|
||||
) scanStats {
|
||||
t.Helper()
|
||||
|
||||
device := func(path string) (uint64, bool) {
|
||||
dev, ok := devices[path]
|
||||
|
||||
return dev, ok
|
||||
}
|
||||
|
||||
roots = pruneRoots(roots, true, device)
|
||||
|
||||
ctx := t.Context()
|
||||
s := &scanState{db: db}
|
||||
|
||||
err := s.loadIndex(ctx, roots)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jobs, subdirs, events := startWalkWorkers(ctx, 2, true)
|
||||
|
||||
// seedRoot emits a regular-file operand itself and returns a job
|
||||
// only for a directory operand.
|
||||
var initial []dirJob
|
||||
|
||||
for _, root := range roots {
|
||||
for _, job := range seedRoot(ctx, root, events) {
|
||||
job.rootDev = devices[root]
|
||||
initial = append(initial, job)
|
||||
}
|
||||
}
|
||||
|
||||
dispatchDirs(ctx, initial, jobs, subdirs)
|
||||
|
||||
changed, unhashed := s.walkPhase(events, roots)
|
||||
s.partition(changed, unhashed)
|
||||
|
||||
err = s.hashPhase(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = s.updatePhase(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return s.st
|
||||
}
|
||||
|
||||
// TestScanOneFilesystemKeepsOtherFilesystemRecords checks that with -x a
|
||||
// record below a directory the walk does not enter, because it is on
|
||||
// another filesystem than its operand, counts as outside the operands:
|
||||
// it is not deleted, and its size joins the size census. The record of
|
||||
// a gone file that is not below that directory is still deleted, even
|
||||
// when its path begins with the directory's name. The operand is given
|
||||
// a device that no directory here is on, so no second real filesystem
|
||||
// is needed.
|
||||
func TestScanOneFilesystemKeepsOtherFilesystemRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
|
||||
// A scan without -x records the file below mnt, and mnt2, which is
|
||||
// beside mnt, not below it.
|
||||
mounted := writeFile(t, dir, "mnt/f", pattern(1, 100))
|
||||
gone := writeFile(t, dir, "mnt2", pattern(3, 50))
|
||||
syncTree(t, db, dir)
|
||||
|
||||
err := os.Remove(gone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The same size as mnt/f, so it is hashed only if mnt/f's size is
|
||||
// in the census.
|
||||
added := writeFile(t, dir, "added", pattern(2, 100))
|
||||
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dev, ok := deviceOfInfo(info)
|
||||
if !ok {
|
||||
t.Skip("platform exposes no device id")
|
||||
}
|
||||
|
||||
syncOneFilesystem(t, db, []string{dir}, map[string]uint64{dir: dev + 1})
|
||||
|
||||
recs := dbRecords(t, db)
|
||||
recordByPath(t, recs, mounted)
|
||||
|
||||
if recordByPath(t, recs, added).head == "" {
|
||||
t.Errorf("%s not hashed: the census lacks the size of %s",
|
||||
added, mounted)
|
||||
}
|
||||
|
||||
if slices.Contains(recordPaths(recs), gone) {
|
||||
t.Errorf("record for %s kept: the file is gone and not below mnt",
|
||||
gone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanOneFilesystemWalksNestedOperand checks that with -x an
|
||||
// operand under another one, on a different filesystem than it, is
|
||||
// walked on its own: a file added below it is recorded, and the record
|
||||
// of a file gone from below it is deleted. A record below a directory
|
||||
// that its own walk does not enter is still kept. Each operand is given
|
||||
// a device that no directory here is on, so neither walk enters a
|
||||
// subdirectory.
|
||||
func TestScanOneFilesystemWalksNestedOperand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
|
||||
// A scan without -x records both files below mnt.
|
||||
gone := writeFile(t, dir, "mnt/gone", pattern(1, 10))
|
||||
kept := writeFile(t, dir, "mnt/sub/kept", pattern(2, 20))
|
||||
syncTree(t, db, dir)
|
||||
|
||||
err := os.Remove(gone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
added := writeFile(t, dir, "mnt/added", pattern(3, 30))
|
||||
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dev, ok := deviceOfInfo(info)
|
||||
if !ok {
|
||||
t.Skip("platform exposes no device id")
|
||||
}
|
||||
|
||||
mnt := filepath.Join(dir, "mnt")
|
||||
syncOneFilesystem(t, db, []string{dir, mnt},
|
||||
map[string]uint64{dir: dev + 1, mnt: dev + 2})
|
||||
|
||||
paths := recordPaths(dbRecords(t, db))
|
||||
|
||||
if !slices.Contains(paths, added) {
|
||||
t.Errorf("no record for %s: the walk did not reach %s", added, mnt)
|
||||
}
|
||||
|
||||
if slices.Contains(paths, gone) {
|
||||
t.Errorf("record for %s kept: the file is gone", gone)
|
||||
}
|
||||
|
||||
if !slices.Contains(paths, kept) {
|
||||
t.Errorf("record for %s deleted: the walk of %s did not enter %s",
|
||||
kept, mnt, filepath.Dir(kept))
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanOneFilesystemWalksOperandBelowOtherFilesystem checks that
|
||||
// with -x an operand on the same filesystem as the operand covering it
|
||||
// is walked on its own when a directory between them is on another
|
||||
// filesystem, since the walk of the covering operand does not enter
|
||||
// that directory: a file added below the nested operand is recorded,
|
||||
// and the record of a file gone from below it is deleted. A record
|
||||
// below that directory but outside the nested operand is still kept.
|
||||
// Both operands are given one device that no directory here is on,
|
||||
// while mnt keeps its own.
|
||||
func TestScanOneFilesystemWalksOperandBelowOtherFilesystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
|
||||
// A scan without -x records both files below mnt.
|
||||
gone := writeFile(t, dir, "mnt/back/gone", pattern(1, 10))
|
||||
kept := writeFile(t, dir, "mnt/kept", pattern(2, 20))
|
||||
syncTree(t, db, dir)
|
||||
|
||||
err := os.Remove(gone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
added := writeFile(t, dir, "mnt/back/added", pattern(3, 30))
|
||||
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dev, ok := deviceOfInfo(info)
|
||||
if !ok {
|
||||
t.Skip("platform exposes no device id")
|
||||
}
|
||||
|
||||
mnt := filepath.Join(dir, "mnt")
|
||||
back := filepath.Join(mnt, "back")
|
||||
syncOneFilesystem(t, db, []string{dir, back},
|
||||
map[string]uint64{dir: dev + 1, mnt: dev, back: dev + 1})
|
||||
|
||||
paths := recordPaths(dbRecords(t, db))
|
||||
|
||||
if !slices.Contains(paths, added) {
|
||||
t.Errorf("no record for %s: the walk did not reach %s", added, back)
|
||||
}
|
||||
|
||||
if slices.Contains(paths, gone) {
|
||||
t.Errorf("record for %s kept: the file is gone", gone)
|
||||
}
|
||||
|
||||
if !slices.Contains(paths, kept) {
|
||||
t.Errorf("record for %s deleted: the walk of %s did not enter %s",
|
||||
kept, dir, mnt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanOneFilesystemRecordsNestedFileOnce checks that with -x a
|
||||
// regular-file operand under another operand is dropped when the file
|
||||
// reports a different device than that operand but its directory does
|
||||
// not, as a file bind-mounted into the tree does: the walk of the other
|
||||
// operand enters the directory and reaches the file, so the file is
|
||||
// recorded once.
|
||||
func TestScanOneFilesystemRecordsNestedFileOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
file := writeFile(t, dir, "sub/f", pattern(1, 10))
|
||||
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dev, ok := deviceOfInfo(info)
|
||||
if !ok {
|
||||
t.Skip("platform exposes no device id")
|
||||
}
|
||||
|
||||
st := syncOneFilesystem(t, db, []string{dir, file}, map[string]uint64{
|
||||
dir: dev, filepath.Dir(file): dev, file: dev + 1,
|
||||
})
|
||||
|
||||
if st.added != 1 {
|
||||
t.Errorf("%s recorded %d times, want once", file, st.added)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanOneFilesystemWalksNestedFileOperand checks that with -x a
|
||||
// regular-file operand under another operand is kept when its directory
|
||||
// is on a different filesystem than that operand: the walk of the other
|
||||
// operand does not enter the directory, so the file is recorded once,
|
||||
// as its own operand. The other operand is given a device that no
|
||||
// directory here is on.
|
||||
func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
file := writeFile(t, dir, "sub/f", pattern(1, 10))
|
||||
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dev, ok := deviceOfInfo(info)
|
||||
if !ok {
|
||||
t.Skip("platform exposes no device id")
|
||||
}
|
||||
|
||||
st := syncOneFilesystem(t, db, []string{dir, file}, map[string]uint64{
|
||||
dir: dev + 1, filepath.Dir(file): dev,
|
||||
})
|
||||
|
||||
if st.added != 1 {
|
||||
t.Errorf("%s recorded %d times, want once", file, st.added)
|
||||
}
|
||||
}
|
||||
|
||||
// buildSmokeTree recreates the README smoke-test filesystem layout
|
||||
// with deterministic content and returns the tree root.
|
||||
func buildSmokeTree(t *testing.T) string {
|
||||
@@ -1643,172 +1285,11 @@ func TestSyncScanMtimeBump(t *testing.T) {
|
||||
t.Fatalf("mtime-bump stats = %+v, want 1 updated", st)
|
||||
}
|
||||
|
||||
if r := recordByPath(t, dbRecords(t, db), a); !r.mtime.Equal(future) {
|
||||
t.Fatalf("mtime = %v, want %v", r.mtime, future)
|
||||
if r := recordByPath(t, dbRecords(t, db), a); r.mtime != future.Unix() {
|
||||
t.Fatalf("mtime = %d, want %d", r.mtime, future.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
// assertWholeFileHashed fails unless the record for path holds the
|
||||
// whole-file hash of data as its head, tail, and content.
|
||||
func assertWholeFileHashed(t *testing.T, db *sql.DB, path string,
|
||||
data []byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
r := recordByPath(t, dbRecords(t, db), path)
|
||||
if want := hexSum(data); r.head != want || r.tail != want ||
|
||||
r.content != want {
|
||||
t.Fatalf("head, tail, content = %q, %q, %q, want %q for each",
|
||||
r.head, r.tail, r.content, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncScanSameSecondRewrite rewrites a file in place at the same
|
||||
// size with an mtime later in the same second as the recorded one: the
|
||||
// next scan must notice the change and re-hash the file.
|
||||
func TestSyncScanSameSecondRewrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
||||
|
||||
// b.bin shares the size of a.bin, so a.bin is hashed.
|
||||
writeFile(t, dir, "b.bin", pattern(2, 500))
|
||||
|
||||
first := time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC)
|
||||
|
||||
err := os.Chtimes(a, first, first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
syncTree(t, db, dir)
|
||||
|
||||
rewritten := pattern(3, 500)
|
||||
writeFile(t, dir, "a.bin", rewritten)
|
||||
|
||||
later := first.Add(500 * time.Millisecond)
|
||||
|
||||
err = os.Chtimes(a, later, later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{walked: 2, updated: 1, unchanged: 1}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 updated 1 unchanged", st)
|
||||
}
|
||||
|
||||
assertWholeFileHashed(t, db, a, rewritten)
|
||||
}
|
||||
|
||||
// TestSyncScanOperandSameSecondRewrite is TestSyncScanSameSecondRewrite
|
||||
// for files given to scan as operands, which scan stats without reading
|
||||
// their directory.
|
||||
func TestSyncScanOperandSameSecondRewrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
||||
|
||||
// b.bin shares the size of a.bin, so a.bin is hashed.
|
||||
b := writeFile(t, dir, "b.bin", pattern(2, 500))
|
||||
|
||||
first := time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC)
|
||||
|
||||
err := os.Chtimes(a, first, first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
syncTree(t, db, a, b)
|
||||
|
||||
rewritten := pattern(3, 500)
|
||||
writeFile(t, dir, "a.bin", rewritten)
|
||||
|
||||
later := first.Add(500 * time.Millisecond)
|
||||
|
||||
err = os.Chtimes(a, later, later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := syncTree(t, db, a, b)
|
||||
if st != (scanStats{walked: 2, updated: 1, unchanged: 1}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 updated 1 unchanged", st)
|
||||
}
|
||||
|
||||
assertWholeFileHashed(t, db, a, rewritten)
|
||||
}
|
||||
|
||||
// TestSyncScanRewriteAfter2262 runs assertLateRewriteRehashed with an
|
||||
// mtime after 2262, a time too late to count in nanoseconds in an int64.
|
||||
func TestSyncScanRewriteAfter2262(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertLateRewriteRehashed(t, time.Date(2300, 1, 2, 3, 4, 5, 0, time.UTC))
|
||||
}
|
||||
|
||||
// TestSyncScanRewritePastTimeLimit runs assertLateRewriteRehashed with an
|
||||
// mtime one second past the latest a time.Time holds without wrapping it
|
||||
// to a time far in the past.
|
||||
func TestSyncScanRewritePastTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertLateRewriteRehashed(t, time.Unix(9223371974719179008, 0))
|
||||
}
|
||||
|
||||
// assertLateRewriteRehashed scans a directory, rewrites a file in it in
|
||||
// place at the same size, sets its mtime to late, and fails unless the
|
||||
// next scan re-hashes the file. It skips where late does not fit the
|
||||
// platform's timespec or the filesystem does not store it.
|
||||
func assertLateRewriteRehashed(t *testing.T, late time.Time) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
||||
|
||||
// b.bin shares the size of a.bin, so a.bin is hashed.
|
||||
writeFile(t, dir, "b.bin", pattern(2, 500))
|
||||
|
||||
syncTree(t, db, dir)
|
||||
|
||||
rewritten := pattern(3, 500)
|
||||
writeFile(t, dir, "a.bin", rewritten)
|
||||
|
||||
// os.Chtimes cannot set such a time: it converts through UnixNano.
|
||||
ts, err := unix.TimeToTimespec(late)
|
||||
if err != nil {
|
||||
t.Skipf("an mtime %d seconds after 1970 does not fit this platform's "+
|
||||
"timespec: %v", late.Unix(), err)
|
||||
}
|
||||
|
||||
err = unix.UtimesNano(a, []unix.Timespec{ts, ts})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fi, err := os.Lstat(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if fi.ModTime().Unix() != late.Unix() {
|
||||
t.Skipf("the filesystem stored the mtime as %d seconds after 1970, "+
|
||||
"not %d", fi.ModTime().Unix(), late.Unix())
|
||||
}
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{walked: 2, updated: 1, unchanged: 1}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 updated 1 unchanged", st)
|
||||
}
|
||||
|
||||
assertWholeFileHashed(t, db, a, rewritten)
|
||||
}
|
||||
|
||||
func TestSyncScanAddRemove(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1854,7 +1335,9 @@ func TestSyncScanSizeChange(t *testing.T) {
|
||||
|
||||
writeFile(t, dir, "f", pattern(1, 200))
|
||||
|
||||
err := os.Chtimes(p, old.mtime, old.mtime)
|
||||
mt := time.Unix(old.mtime, 0)
|
||||
|
||||
err := os.Chtimes(p, mt, mt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2094,54 +1577,6 @@ func TestScanHardlinkRunFailsTogether(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPhaseSkipsFileReplacedByFIFO replaces a walked regular file
|
||||
// with a FIFO before the hash phase reads it. Opening a FIFO that has
|
||||
// no writer waits for one, so a blocking open would hang the phase. The
|
||||
// path must be warned about once and skipped, with no record written.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestHashPhaseSkipsFileReplacedByFIFO(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
dir := t.TempDir()
|
||||
p := writeFile(t, dir, "a.bin", pattern(1, 300))
|
||||
|
||||
walked, _ := collectWalk(t, []string{dir}, false, 1)
|
||||
|
||||
err := os.Remove(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = unix.Mkfifo(p, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := &scanState{db: openTestDB(t), toHash: walked}
|
||||
|
||||
err = s.hashPhase(t.Context(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("hashPhase = %v, want nil", err)
|
||||
}
|
||||
|
||||
if s.st.skipped != 1 || len(s.batch) != 0 {
|
||||
t.Errorf("skipped = %d, records = %d, want 1 skipped and none",
|
||||
s.st.skipped, len(s.batch))
|
||||
}
|
||||
|
||||
warning := "hash " + p + ": not a regular file\n"
|
||||
if got := stderr(); strings.Count(got, warning) != 1 {
|
||||
t.Errorf("stderr = %q, want %q once", got, warning)
|
||||
}
|
||||
|
||||
// The content phase opens files the same way.
|
||||
_, _, content, err := hashContentOnly(p, headTailMin)
|
||||
if content != "" || !errors.Is(err, errNotRegular) {
|
||||
t.Errorf("hashContentOnly on a FIFO = %q, %v; want no hash and %v",
|
||||
content, err, errNotRegular)
|
||||
}
|
||||
}
|
||||
|
||||
// injectedWriteFailure is the message the injected database trigger
|
||||
// aborts with, so the test can recognize its own failure in the error
|
||||
// the scan reports.
|
||||
@@ -2335,43 +1770,14 @@ func TestHashRuns(t *testing.T) {
|
||||
func TestPruneRoots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const pqRoot = "/p/q"
|
||||
|
||||
// /p/q is on another filesystem than /p; /p/q/r is on the same one as
|
||||
// /p/q, /p/s as /p, and /p/t has no known device.
|
||||
devices := map[string]uint64{"/p": 1, pqRoot: 2, "/p/q/r": 2, "/p/s": 1}
|
||||
device := func(path string) (uint64, bool) {
|
||||
dev, ok := devices[path]
|
||||
|
||||
return dev, ok
|
||||
}
|
||||
|
||||
// Duplicates and operands under other operands are dropped; /cc is
|
||||
// not under /c (sibling with a shared prefix).
|
||||
got := pruneRoots([]string{"/a/b", "/a", "/c", "/a", "/a/b/c", "/cc"},
|
||||
false, device)
|
||||
got := pruneRoots([]string{"/a/b", "/a", "/c", "/a", "/a/b/c", "/cc"})
|
||||
|
||||
want := []string{"/a", "/c", "/cc"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("pruneRoots = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
nested := []string{"/p/q/r", pqRoot, "/p", "/p", "/p/s", "/p/t"}
|
||||
|
||||
got = pruneRoots(nested, false, device)
|
||||
|
||||
want = []string{"/p"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("pruneRoots without -x = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// With -x, /p/q, on another filesystem than /p, is kept as well.
|
||||
got = pruneRoots(nested, true, device)
|
||||
|
||||
want = []string{"/p", pqRoot}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("pruneRoots with -x = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
||||
|
||||
+22
-11
@@ -1,9 +1,14 @@
|
||||
#!/bin/sh
|
||||
# script/bootstrap: install all dependencies needed to build and develop
|
||||
# this repo. Idempotent; assumes nothing is present (not git, make, or
|
||||
# go). Base tooling comes from nix, apt, brew, or apk (detected in that
|
||||
# order). golangci-lint and prettier are never installed: they run via
|
||||
# docker only (script/lint, script/fmt, script/fmt-check).
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes nothing is present (not git,
|
||||
# make, or go). Neither the linter nor the Markdown formatter is
|
||||
# installed: golangci-lint (script/lint) and prettier (script/fmt,
|
||||
# script/fmt-check) run via docker only, pinned by hash, so their only
|
||||
# prerequisite is a working docker — which is warned about, not
|
||||
# installed, because everything except linting and formatting works
|
||||
# without it.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -58,19 +63,25 @@ missing() {
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# Deliberately unpinned, so presence is the whole check: go.mod
|
||||
# governs the Go version, and reproducible builds run in the
|
||||
# digest-pinned Docker images.
|
||||
# System tooling, deliberately unpinned: these come from the host
|
||||
# package manager and whatever version it ships is what the host
|
||||
# gets, so a presence check is the right check. The repo pins no
|
||||
# system toolchain versions — the Go language version is governed by
|
||||
# go.mod, and builds that must be reproducible run in the Docker
|
||||
# image, whose base images are pinned by digest.
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
|
||||
# Warn, do not fail: only the targets named below, and the
|
||||
# pre-commit hook, need docker.
|
||||
# Linting and Markdown formatting run via docker only, so docker is
|
||||
# their prerequisite rather than something bootstrap installs. Warn,
|
||||
# do not fail: everything except `make lint`, `make fmt` and
|
||||
# `make fmt-check` — and, through them, `make check`, `make docker`
|
||||
# and the pre-commit hook — works without it.
|
||||
if missing docker; then
|
||||
echo "bootstrap: WARNING: docker not found; make lint, make fmt," >&2
|
||||
echo "bootstrap: make fmt-check, make check, make docker and" >&2
|
||||
echo "bootstrap: make test-race require it." >&2
|
||||
echo "bootstrap: make fmt-check, make check and make docker" >&2
|
||||
echo "bootstrap: require it." >&2
|
||||
fi
|
||||
|
||||
go mod download
|
||||
|
||||
+22
-7
@@ -1,19 +1,34 @@
|
||||
#!/bin/sh
|
||||
# script/cibuild: run the CI build. The Gitea workflow runs this on
|
||||
# push. The Dockerfile runs every gate make check runs, as build steps,
|
||||
# so a successful build means the repo is green.
|
||||
# push.
|
||||
#
|
||||
# Without a fresh CHECK_EPOCH, a rebuild of an unchanged checkout serves
|
||||
# the gate layers from cache and passes having run none of them. The
|
||||
# process id goes in with the epoch so two runs started in the same
|
||||
# second still differ.
|
||||
# The Dockerfile runs the gates individually as build steps, not the
|
||||
# make check aggregate: the lint stage runs the gofmt check,
|
||||
# script/verify-lint-image-pin, golangci-lint config verify and
|
||||
# golangci-lint run; the markdown stage runs the prettier check; the
|
||||
# build stage, dropped to an unprivileged user, runs make test. None of
|
||||
# make lint, make fmt-check or make check appears, because each runs
|
||||
# docker, and docker cannot run inside a docker build. Nothing is
|
||||
# skipped by that — the linter, gofmt and prettier are invoked directly
|
||||
# in their stages, and the build stage's COPY --from lines make those
|
||||
# stages prerequisites, so BuildKit must finish them first. Between the
|
||||
# three stages everything make check would run has run, which is why a
|
||||
# successful build here implies the repo is green.
|
||||
#
|
||||
# That implication holds only because of CHECK_EPOCH. A COPY layer is
|
||||
# invalidated only by changed content, and a rebuild of an unchanged
|
||||
# checkout sends the same content, so without a fresh value here Docker
|
||||
# serves the gate layers from cache and the build reports a green it
|
||||
# never earned. Passing the current epoch invalidates the gate
|
||||
# layers on every run while leaving the pinned base images and
|
||||
# go mod download cached; see the Dockerfile for the placement.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --build-arg CHECK_EPOCH="$(date +%s)-$$" .
|
||||
docker build --build-arg CHECK_EPOCH="$(date +%s)" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+10
-5
@@ -1,9 +1,14 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# The tag comes from script/projectname. CHECK_EPOCH is passed for the
|
||||
# same reason script/cibuild passes it: without a fresh value an
|
||||
# unchanged tree is served from cache and this exits 0 having run no
|
||||
# gate.
|
||||
# The tag comes from script/projectname.
|
||||
#
|
||||
# CHECK_EPOCH is passed for the same reason script/cibuild passes it:
|
||||
# without it Docker serves the Dockerfile's gate layers from cache on an
|
||||
# unchanged tree and this exits 0 having run none of the lint stage's
|
||||
# gates, the markdown stage's prettier gate or the builder stage's test
|
||||
# gate. This is the set of gates a developer or reviewer runs by hand,
|
||||
# so a cached pass here is the most misleading result the repo can
|
||||
# produce. Dependency layers sit above the ARG and stay cached.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -12,7 +17,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build \
|
||||
--build-arg CHECK_EPOCH="$(date +%s)-$$" \
|
||||
--build-arg CHECK_EPOCH="$(date +%s)" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" \
|
||||
.
|
||||
}
|
||||
|
||||
+1
-6
@@ -12,12 +12,7 @@ main() {
|
||||
cd "$ROOT"
|
||||
status=0
|
||||
|
||||
# Under set -e a bare assignment would end the script when gofmt
|
||||
# fails (a Go file it cannot parse), and prettier would never run.
|
||||
if ! files="$(gofmt -s -l .)"; then
|
||||
echo "gofmt: failed; see its errors above" >&2
|
||||
status=1
|
||||
fi
|
||||
files="$(gofmt -s -l .)"
|
||||
if [ -n "$files" ]; then
|
||||
echo "gofmt: files not formatted:" >&2
|
||||
echo "$files" >&2
|
||||
|
||||
+13
-12
@@ -1,17 +1,19 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter. golangci-lint is never installed on a
|
||||
# host: this builds Dockerfile.lint, which copies the repo into the
|
||||
# digest-pinned golangci-lint image and lints as a build step, so a
|
||||
# successful build is a clean lint. A cold cache needs the network to
|
||||
# pull the image and for `go mod download`; once warm this runs offline
|
||||
# until go.mod or go.sum changes.
|
||||
# host: it runs via docker only, one way, everywhere — this builds
|
||||
# Dockerfile.lint, which COPYs the repo into the digest-pinned
|
||||
# golangci-lint image and lints as a build step, so a successful build
|
||||
# is a clean lint. The only prerequisite is a working docker. The gate
|
||||
# steps make no network calls of their own, but Dockerfile.lint runs
|
||||
# `go mod download` above them, so a cold cache does reach the network
|
||||
# (as does pulling the pinned image); that layer stays cached, and once
|
||||
# it is warm this runs offline until go.mod or go.sum changes.
|
||||
#
|
||||
# Without a fresh CHECK_EPOCH docker serves the gate layers from cache
|
||||
# on an unchanged tree and this exits 0 having run no linter. The PID is
|
||||
# in the value because two lint runs land inside the same second easily.
|
||||
#
|
||||
# The image is never used, so --output=type=cacheonly writes none;
|
||||
# without it every run leaves an untagged image behind.
|
||||
# CHECK_EPOCH is what makes the result mean anything. Without it docker
|
||||
# serves the gate layers from cache on an unchanged tree and this exits
|
||||
# 0 in well under a second having run no linter. The PID is in the value
|
||||
# as well as the epoch because two lint runs land inside the same second
|
||||
# easily, and `date +%s` alone would cache the second one.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -20,7 +22,6 @@ main() {
|
||||
cd "$ROOT"
|
||||
docker build \
|
||||
--build-arg CHECK_EPOCH="$(date +%s)-$$" \
|
||||
--output=type=cacheonly \
|
||||
-f Dockerfile.lint \
|
||||
.
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/test-race: run the test suite under the race detector. Not part
|
||||
# of script/check.
|
||||
#
|
||||
# The race detector needs cgo and a C compiler, which the host build
|
||||
# never uses, so the tests run in a golang image that has gcc. The
|
||||
# checkout is mounted read-only, so the docker daemon must be local. The
|
||||
# container starts with empty caches every time: each run downloads the
|
||||
# dependencies and compiles them with the detector, which needs the
|
||||
# network and takes minutes.
|
||||
#
|
||||
# The tests run as the calling user, never as root: several of them make
|
||||
# a file unreadable and expect reading it to fail, and root reads it
|
||||
# anyway. When the caller is root they run as nobody, and then the
|
||||
# checkout must be readable by other users. Neither user has a home
|
||||
# directory in the image, so HOME is /tmp, where Go puts its build cache.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# golang:1.25-trixie, 2026-10-04. Debian rather than the Alpine image the
|
||||
# Dockerfile builds with, because this one includes gcc.
|
||||
IMAGE="golang@sha256:2c4c60ef415fbfa5e90300722293bef36c5e63fae17570ce18f580af933dbd73"
|
||||
|
||||
main() {
|
||||
user="$(id -u):$(id -g)"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
user=65534:65534
|
||||
fi
|
||||
docker run --rm \
|
||||
--user "$user" \
|
||||
--env HOME=/tmp \
|
||||
--env CGO_ENABLED=1 \
|
||||
--volume "$ROOT:/src:ro" \
|
||||
--workdir /src \
|
||||
"$IMAGE" \
|
||||
go test -race -timeout 60s ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -2,16 +2,23 @@
|
||||
# script/verify-lint-image-pin: fail unless the golangci-lint image
|
||||
# referenced by Dockerfile.lint and the one referenced by the main
|
||||
# Dockerfile's lint stage are the same image at the same digest. Our own
|
||||
# extension to scripts-to-rule-them-all, not one of its entrypoints; run
|
||||
# as a gate in both files. Nothing else keeps the two pins in sync, and
|
||||
# a bump applied to one alone would lint the same tree against different
|
||||
# rulesets, both green.
|
||||
# extension to scripts-to-rule-them-all, not one of its entrypoints.
|
||||
#
|
||||
# Do not hardcode the expected digest here: that is a third copy to keep
|
||||
# in sync.
|
||||
# The linter version is pinned in two independent files. That is the
|
||||
# shape #42 turned into a build failure rather than tolerate: nothing
|
||||
# else keeps the two in sync, and a bump applied to one file alone would
|
||||
# leave `make lint` and the fail-fast lint stage of `make docker`
|
||||
# linting the same tree against different rulesets, both green. This is
|
||||
# the single guard that stops it, run as a gate in both files.
|
||||
#
|
||||
# A reference that cannot be read is a hard failure, not a skip: two
|
||||
# empty strings compare equal.
|
||||
# It deliberately restates neither pin. A hardcoded expected digest here
|
||||
# would be a third copy — one more thing to bump, and the same drift one
|
||||
# file further out. It compares the two files to each other and knows
|
||||
# nothing about which version is correct.
|
||||
#
|
||||
# A reference that cannot be read is a hard failure, not a skip: a
|
||||
# comparison of two empty strings succeeds, which would turn this guard
|
||||
# into exactly the unearned green it exists to prevent.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
Reference in New Issue
Block a user