Compare commits

..
Author SHA1 Message Date
sneak 049a74a10b Keep a non-root user's database in the XDG state directory (closes #171)
check / check (push) Successful in 5m53s
Without SFDUPES_DATABASE, a user other than root now uses
sfdupes/db.sqlite in the XDG state directory (~/.local/state by
default) instead of failing on /var/lib/sfdupes. scan creates the
missing directories there with mode 0700, as the XDG Base Directory
specification asks. An XDG_STATE_HOME that is not an absolute path is
a fatal error for every subcommand. Root keeps
/var/lib/sfdupes/db.sqlite. The help of sfdupes and of scan, and
README, say where the database is. Adds github.com/adrg/xdg, the
decided XDG library.

Model: opus-5-5
2026-11-11 08:38:23 +00:00
clawbot 37893d2d5a Show the walk path on the line below its display (closes #160) (#166)
check / check (push) Canceled after 0s
2026-11-11 09:37:21 +01:00
clawbot 95aac3d804 trees: count a nested dupe tree once in the reclaimable total (closes #168) (#188)
check / check (push) Canceled after 0s
2026-11-11 09:37:17 +01:00
clawbot d4f90f43ff Limit report and trees to given paths (closes #174) (#191)
check / check (push) Canceled after 0s
2026-11-11 09:37:14 +01:00
clawbot 58d27a6df3 README: guard the daily cron scan against an unmounted disk (closes #175) (#186)
check / check (push) Canceled after 0s
2026-11-11 09:37:10 +01:00
18 changed files with 1108 additions and 248 deletions

No files matched your search

+114 -62
View File
@@ -29,7 +29,6 @@ first look at a new machine with one command.
```sh ```sh
make build make build
export SFDUPES_DATABASE="$HOME/.local/share/sfdupes/db.sqlite"
./sfdupes scan /srv ./sfdupes scan /srv
./sfdupes report > dupes.tsv ./sfdupes report > dupes.tsv
./sfdupes trees > dupetrees.tsv ./sfdupes trees > dupetrees.tsv
@@ -43,10 +42,14 @@ as opposed to a directory, a symlink or a device node. A symlink is a file that
holds the path of another file and stands in for it. A device node is a file holds the path of another file and stands in for it. A device node is a file
that stands for a piece of hardware, such as a disk or a terminal. `report` that stands for a piece of hardware, such as a disk or a terminal. `report`
prints the file-level duplicates. `trees` prints the duplicate directory trees. prints the file-level duplicates. `trees` prints the duplicate directory trees.
`info` prints the database's path, size and record count. Both cover the whole database, or only the paths given after them, as in
`sfdupes report /srv/photos`. `info` prints the database's path, size and record
count.
The database is `/var/lib/sfdupes/db.sqlite` unless `SFDUPES_DATABASE` names Root keeps the database in `/var/lib/sfdupes/db.sqlite`. Any other user keeps it
another file. The first `scan` creates it. in `sfdupes/db.sqlite` in the XDG state directory, `~/.local/state` by default
on Linux. `SFDUPES_DATABASE` names another file (see "Location"). The first
`scan` creates the database.
`report`, `trees` and `info` write their data to stdout. Progress, warnings and `report`, `trees` and `info` write their data to stdout. Progress, warnings and
errors go to stderr. stdout and stderr are a program's two output streams. The errors go to stderr. stdout and stderr are a program's two output streams. The
@@ -114,22 +117,27 @@ docker run --rm -v /srv:/srv:ro -v /var/lib/sfdupes:/var/lib/sfdupes \
Run `scan` as root, so that it can read every file. `scan` warns about a Run `scan` as root, so that it can read every file. `scan` warns about a
directory it cannot read, and about a new or changed file it cannot read. It directory it cannot read, and about a new or changed file it cannot read. It
skips the path and deletes the records at or below it (see "Rules for the skips the path and deletes the records at or below it (see "Rules for the
walk"). As the file `/etc/cron.d/sfdupes`: walk").
An unmounted disk leaves its mount point empty, so `scan` deletes every record
under it, and the next scan reads every file on the disk again. Test the mount
point of each disk at or below `/srv` with its own `mountpoint -q`, joined with
`&&`, and test `/srv` only when it is one. As the file `/etc/cron.d/sfdupes`:
``` ```
30 3 * * * root /usr/local/bin/sfdupes scan /srv 2>>/var/log/sfdupes.log || tail -n 3 /var/log/sfdupes.log 30 3 * * * root if mountpoint -q /srv; then /usr/local/bin/sfdupes scan /srv 2>>/var/log/sfdupes.log || tail -n 3 /var/log/sfdupes.log; else echo "a disk is not mounted; scan of /srv skipped"; fi
``` ```
- The first scan creates `/var/lib/sfdupes/db.sqlite` and its directory. To keep - The first scan creates `/var/lib/sfdupes/db.sqlite` and its directory. To keep
the database elsewhere, put `SFDUPES_DATABASE=/path/to/db.sqlite` before the the database elsewhere, put `SFDUPES_DATABASE=/path/to/db.sqlite` directly
command on the same line. before `/usr/local/bin/sfdupes`.
- `scan` writes nothing to stdout. Its stderr, appended here to - `scan` writes nothing to stdout. Its stderr, appended here to
`/var/log/sfdupes.log`, holds: `/var/log/sfdupes.log`, holds:
- one line naming the database; - one line naming the database;
- progress lines, as described under "Progress"; - progress lines, as described under "Progress";
- a warning for each path it skips; - a warning for each path it skips;
- a line for each system, temporary or cache directory the walk leaves out - a line for each system or temporary directory the walk leaves out (see
(see "Rules for the walk"); "Rules for the walk");
- a line for each path left out because its filesystem is one the kernel, - a line for each path left out because its filesystem is one the kernel,
the core of the operating system, makes up or keeps in memory, such as the core of the operating system, makes up or keeps in memory, such as
`tmpfs`; `tmpfs`;
@@ -148,8 +156,9 @@ walk"). As the file `/etc/cron.d/sfdupes`:
- `report`, `trees` and `info` need only read access to the database. The umask - `report`, `trees` and `info` need only read access to the database. The umask
is the setting that removes permissions from the files a process creates. is the setting that removes permissions from the files a process creates.
Under the usual umask of `022`, the first scan creates the database readable Under the usual umask of `022`, the first scan creates the database readable
by every user. An unprivileged user can then run those three commands against by every user. Another user can then run those three commands against root's
root's database. database, with `SFDUPES_DATABASE=/var/lib/sfdupes/db.sqlite` before the
command.
### Reading the reports ### Reading the reports
@@ -258,8 +267,8 @@ In order:
``` ```
sfdupes scan [--workers N] [-x] [--no-default-excludes] [--exclude PATH]... PATH... sfdupes scan [--workers N] [-x] [--no-default-excludes] [--exclude PATH]... PATH...
sfdupes report > dupes.tsv sfdupes report [PATH...] > dupes.tsv
sfdupes trees > dupetrees.tsv sfdupes trees [PATH...] > dupetrees.tsv
sfdupes info sfdupes info
sfdupes dwim [--workers N] [-x] [--no-default-excludes] [--exclude PATH]... sfdupes dwim [--workers N] [-x] [--no-default-excludes] [--exclude PATH]...
sfdupes --version sfdupes --version
@@ -288,9 +297,9 @@ The reference below is also the program's specification.
### Settings ### Settings
- `SFDUPES_DATABASE` names the database file. When it is unset or empty, the - `SFDUPES_DATABASE` names the database file. When it is unset or empty, the
database is `/var/lib/sfdupes/db.sqlite`. There is no command-line flag for database is at its default path (see "Location"). There is no command-line
it. The path names the file exactly, whatever characters it holds. A relative flag for it. The path names the file exactly, whatever characters it holds. A
path is relative to the working directory. relative path is relative to the working directory.
- `--workers N` (`scan`, `dwim`) sets how many directories `scan` reads at once - `--workers N` (`scan`, `dwim`) sets how many directories `scan` reads at once
while it lists the trees. It also sets how many files `scan` reads at once while it lists the trees. It also sets how many files `scan` reads at once
while it hashes them. The default is the number of CPUs. `N` must be at while it hashes them. The default is the number of CPUs. `N` must be at
@@ -300,9 +309,8 @@ The reference below is also the program's specification.
of directories on other filesystems, as the same flag does for `du` and of directories on other filesystems, as the same flag does for `du` and
`rsync`. By default the walk also enters directories on other filesystems. `rsync`. By default the walk also enters directories on other filesystems.
- `--no-default-excludes` (`scan`, `dwim`) lets the walk enter the default - `--no-default-excludes` (`scan`, `dwim`) lets the walk enter the default
excludes. Those are the system, temporary and cache directories the walk excludes. Those are the system and temporary directories the walk otherwise
otherwise leaves out, such as `/var/tmp` and `/var/cache` on Linux (see "Rules leaves out, such as `/var/tmp` on Linux (see "Rules for the walk").
for the walk").
- `--exclude PATH` (`scan`, `dwim`) keeps the walk out of `PATH` and everything - `--exclude PATH` (`scan`, `dwim`) keeps the walk out of `PATH` and everything
below it. `PATH` may be a directory or a file. The flag may be given more than below it. `PATH` may be a directory or a file. The flag may be given more than
once. The walk knows `PATH` by its device ID and inode number, as it knows the once. The walk knows `PATH` by its device ID and inode number, as it knows the
@@ -572,17 +580,12 @@ walk practical on large or busy storage pools.
`--no-default-excludes` does not change this rule. `--no-default-excludes` does not change this rule.
- Below an operand, the walk does not enter a default exclude. On Linux, the - Below an operand, the walk does not enter a default exclude. On Linux, the
default excludes hold the kernel's view of the running system, temporary default excludes hold temporary files and the kernel's view of the running
files, and caches. A cache here is data that programs and package managers system. They depend on the system, and `sfdupes scan --help` lists those of
download or build again on their own. The default excludes depend on the the system it runs on. On Linux they are:
system, and `sfdupes scan --help` lists those of the system it runs on. In the
lists, `*` stands for any one directory name, so `/home/*/.cache` matches
`/home/alice/.cache`. On Linux they are:
``` ```
/proc, /sys, /dev, /run, /tmp, /var/tmp, /var/cache /proc, /sys, /dev, /run, /tmp, /var/tmp
/root/.cache, /root/.npm, /root/.cargo/registry, /root/go/pkg/mod
/home/*/.cache, /home/*/.npm, /home/*/.cargo/registry, /home/*/go/pkg/mod
``` ```
On macOS they are: On macOS they are:
@@ -590,21 +593,13 @@ walk practical on large or busy storage pools.
``` ```
/dev, /private/tmp, /private/var/tmp, /private/var/vm /dev, /private/tmp, /private/var/tmp, /private/var/vm
/System/Volumes/VM, /System/Volumes/Data /System/Volumes/VM, /System/Volumes/Data
/private/var/folders, /Library/Caches
/private/var/root/Library/Caches, /private/var/root/.cache
/private/var/root/.npm, /private/var/root/.cargo/registry
/private/var/root/go/pkg/mod
/Users/*/Library/Caches, /Users/*/.cache, /Users/*/.npm
/Users/*/.cargo/registry, /Users/*/go/pkg/mod
``` ```
`/private/var/folders` holds each user's temporary files and caches on A walk of `/` on macOS reaches everything in `/System/Volumes/Data` already,
macOS. Root's home on macOS is `/var/root`, which the walk reaches as through firmlinks. Firmlinks are the links macOS uses to show the data
`/private/var/root`. A walk of `/` on macOS reaches everything in volume inside the system volume. Walking `/System/Volumes/Data` as well
`/System/Volumes/Data` already, through firmlinks. Firmlinks are the links would record each of those files twice. Any other system has no default
macOS uses to show the data volume inside the system volume. Walking excludes.
`/System/Volumes/Data` as well would record each of those files twice. Any
other system has no default excludes.
An operand at or inside a default exclude is walked, so An operand at or inside a default exclude is walked, so
`sfdupes scan /tmp/x` works when `/tmp/x` is on a persistent filesystem. `sfdupes scan /tmp/x` works when `/tmp/x` is on a persistent filesystem.
@@ -700,11 +695,12 @@ and `scan` leaves it ignored.
### `report` mode ### `report` mode
`report` reads every record from the database. It takes no positional arguments. `report` reads every record from the database. It takes optional `PATH`
A positional argument is anything on the command line that is not a flag, such operands. With none, it uses every record. With one or more, it uses only the
as the paths `scan` takes. `report` never opens a scanned file. It checks that records at or below them (see "Operands of `report` and `trees`"). `report`
each path in a group still exists, and looks at no other path. The check is never opens a scanned file. It checks that each path in a group still exists,
`lstat`, the system call that looks up a path without following a symlink: and looks at no other path. It never looks up an operand. The check is `lstat`,
the system call that looks up a path without following a symlink:
- A path is gone when `lstat` finds that it, or a directory above it, does not - A path is gone when `lstat` finds that it, or a directory above it, does not
exist. exist.
@@ -736,6 +732,27 @@ with one signature come together, and so do the records of one size. So `report`
holds only the duplicate groups of the size it is reading. Once it reaches a holds only the duplicate groups of the size it is reading. Once it reaches a
smaller size, it writes them, ordered by `first` path. smaller size, it writes them, ordered by `first` path.
#### Operands of `report` and `trees`
`report` and `trees` take the same `PATH` operands:
- A record is used when its path is an operand or lies below one. `/srv/photos`
covers `/srv/photos/x` but not `/srv/photos2/x`.
- The records used are grouped, ordered and totalled as they are without
operands. A copy outside the operands is not reported. With copies `/a/f`,
`/srv/photos/x` and `/srv/photos/y`, `sfdupes report /srv/photos` prints one
row, `/srv/photos/x` and `/srv/photos/y`.
- A relative operand is joined to the working directory as `pwd -P` prints it.
- Each operand is then cleaned as text, as `realpath -ms` prints it. Its `.`
elements and repeated or trailing slashes are dropped. Each `..` drops the
element before it.
- No operand is looked up on the filesystem. A symlink in an operand is not
followed, and an operand need not exist. The records hold real paths (see
"Operands" under "`scan` mode"), so an operand that passes through a symlink
matches no record.
- An operand that matches no record is not an error; it adds nothing to the
report.
#### Report output format #### Report output format
The report is TSV (tab-separated values) on stdout. It holds a header line, then The report is TSV (tab-separated values) on stdout. It holds a header line, then
@@ -763,7 +780,8 @@ Grouping and ordering use the stored path. The warnings about skipped paths that
The summary line on stderr gives: The summary line on stderr gives:
- the records read; - the records read, which are all the records in the database, with or without
operands;
- the duplicate groups; - the duplicate groups;
- the dupe files, which is the number of rows; - the dupe files, which is the number of rows;
- the reclaimable bytes, the sum of `size` over all rows. - the reclaimable bytes, the sum of `size` over all rows.
@@ -774,11 +792,12 @@ report: 14 records read, 4 duplicate groups, 7 dupe files, 10.0 KiB reclaimable
### `trees` mode ### `trees` mode
`trees` reads the same database as `report` and takes no positional arguments. `trees` reads the same database as `report` and takes the same `PATH` operands
It reports whole duplicate directory trees. Two directories are duplicate trees (see "Operands of `report` and `trees`"). It reports whole duplicate directory
when they hold the same relative paths, and each path holds a file with the same trees. Two directories are duplicate trees when they hold the same relative
signature in both. It rebuilds the directory tree from the paths in the records, paths, and each path holds a file with the same signature in both. It rebuilds
split on `/`. Like `report`, it never opens a scanned file. It checks only that the directory tree from the paths in the records, split on `/`. Like `report`,
it never opens a scanned file and never looks up an operand. It checks only that
the directory at the top of each copy still exists, as `report` checks a path. the directory at the top of each copy still exists, as `report` checks a path.
Definitions: Definitions:
@@ -806,6 +825,11 @@ Processing:
directories that all have the same digest. The parents' group, or an directories that all have the same digest. The parents' group, or an
ancestor's, already implies it. A group is always reported when it holds ancestor's, already implies it. A group is always reported when it holds
sibling directories, or when its members' parents have different digests. sibling directories, or when its members' parents have different digests.
- With operands, `trees` builds the tree from the records it uses. It reports
only directories at or below an operand. Any other directory lies above an
operand and can hold files that are not used, so its digest does not describe
it. Such a directory is never reported and never implies a group of its
children.
- A directory that is gone is dropped from its group. A group left with fewer - A directory that is gone is dropped from its group. A group left with fewer
than two directories is not reported. than two directories is not reported.
- Within a group, paths are sorted in byte order. The first is `first`, and - Within a group, paths are sorted in byte order. The first is `first`, and
@@ -859,10 +883,12 @@ directory's path is `/`.
The summary line on stderr gives: The summary line on stderr gives:
- the records read; - the records read, which are all the records in the database, with or without
operands;
- the duplicate-tree groups; - the duplicate-tree groups;
- the dupe trees, which is the number of rows; - the dupe trees, which is the number of rows;
- the reclaimable bytes, the sum of `size` over all rows. - the reclaimable bytes, the sum of `size` over all rows, leaving out a row
whose `dupe` lies inside another row's `dupe`.
``` ```
trees: 14 records read, 1 duplicate tree groups, 1 dupe trees, 3.0 KiB reclaimable trees: 14 records read, 1 duplicate tree groups, 1 dupe trees, 3.0 KiB reclaimable
@@ -871,8 +897,10 @@ trees: 14 records read, 1 duplicate tree groups, 1 dupe trees, 3.0 KiB reclaimab
### `dwim` mode ### `dwim` mode
`dwim`, short for "do what I mean", scans a new machine and reports its `dwim`, short for "do what I mean", scans a new machine and reports its
duplicate trees in one command. It takes no positional arguments. Like `scan`, duplicate trees in one command. It takes no positional arguments. A positional
it should run as root, so that it can read every file. argument is anything on the command line that is not a flag, such as the paths
`scan` takes. Like `scan`, it should run as root, so that it can read every
file.
`dwim` takes these flags of `scan`, with the same meaning (see "Settings"): `dwim` takes these flags of `scan`, with the same meaning (see "Settings"):
@@ -951,7 +979,22 @@ All five subcommands work on one SQLite database file.
#### Location #### Location
The database is the file named by `SFDUPES_DATABASE`, when that is set and not The database is the file named by `SFDUPES_DATABASE`, when that is set and not
empty, and otherwise `/var/lib/sfdupes/db.sqlite`. empty. Otherwise it is at its default path, which depends on the user:
- For root, it is `/var/lib/sfdupes/db.sqlite`.
- For any other user, it is `sfdupes/db.sqlite` in the XDG state directory. That
directory is `$XDG_STATE_HOME` when that is set and not empty. Otherwise it is
`~/.local/state` on Linux and `~/Library/Application Support` on macOS. An
`XDG_STATE_HOME` that is not an absolute path is a fatal error, exit 1, for
every subcommand. The error names `XDG_STATE_HOME`.
`scan` creates each missing directory on the way to the database with mode 755.
At the default path of a user other than root, it uses mode 700 instead, as the
XDG Base Directory specification asks. Other users cannot enter a directory with
mode 700, so they cannot read a database in it.
All five subcommands use the same default, so a user's `report` reads the
database that user's `scan` wrote.
Each subcommand prints one line on stderr naming the database it uses, before Each subcommand prints one line on stderr naming the database it uses, before
anything else and whether or not stderr is a terminal. The line gives the path anything else and whether or not stderr is a terminal. The line gives the path
@@ -991,9 +1034,9 @@ Only one `scan` at a time can hold the lock.
- For its whole run, `scan` holds a lock on a lock file beside the database. The - For its whole run, `scan` holds a lock on a lock file beside the database. The
lock is taken with the `flock(2)` system call, a request a program makes to lock is taken with the `flock(2)` system call, a request a program makes to
the kernel, and only one process can hold it. The lock file's name is the the kernel, and only one process can hold it. The lock file's name is the
database's path, with symlinks resolved, plus `.lock`. By default that is database's path, with symlinks resolved, plus `.lock`. For root, by default,
`/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the same that is `/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the
lock file. same lock file.
- `scan` takes the lock before it walks the filesystem or opens the database. - `scan` takes the lock before it walks the filesystem or opens the database.
Just before that, it creates the database file, empty, if it is missing. So Just before that, it creates the database file, empty, if it is missing. So
the lock file is named the same way before the first scan as after. A symlink the lock file is named the same way before the first scan as after. A symlink
@@ -1215,6 +1258,14 @@ On a terminal:
⠹ [3/4] hash: [12345/98765], 92 files/s, elapsed 2m32s, remain 17m44s 12% |█ | ⠹ [3/4] hash: [12345/98765], 92 files/s, elapsed 2m32s, remain 17m44s 12% |█ |
``` ```
- During `scan`'s `walk`, the line below the display shows the path of the last
file the walk found. Each draw shows the path of that moment, cut short before
the terminal's last column. The path is escaped as in the reports (see "Report
output format"). Each control character left after that is written as `\x` and
its code in two hexadecimal digits, such as `\x1b` for the escape character. A
control character is one that a terminal acts on instead of showing. The line
is erased when the walk ends, and the next phase is drawn on it.
- No line reaches the terminal's last column. A line too wide for the terminal - No line reaches the terminal's last column. A line too wide for the terminal
first leaves out its rate, the words of a stalled phase, or what the phase is first leaves out its rate, the words of a stalled phase, or what the phase is
waiting for. It leaves out its bar next. It is cut short at the edge only when waiting for. It leaves out its bar next. It is cut short at the edge only when
@@ -1274,7 +1325,8 @@ showing the count it stopped at.
- `scan` with no operand; - `scan` with no operand;
- `--workers` below 1; - `--workers` below 1;
- an empty `--exclude` value, as in `--exclude ""`; - an empty `--exclude` value, as in `--exclude ""`;
- `report`, `trees`, `info` or `dwim` with a positional argument. - an empty `PATH` for `report` or `trees`, as in `sfdupes report ""`;
- `info` or `dwim` with a positional argument.
A fatal error is reported on stderr, starting with `sfdupes:`. A failed write to A fatal error is reported on stderr, starting with `sfdupes:`. A failed write to
stdout, such as a full disk, is reported the same way. Two cases never reach stdout, such as a full disk, is reported the same way. Two cases never reach
+4 -2
View File
@@ -5,8 +5,10 @@
// KiB, and of the content. The content hash covers the whole file under // KiB, and of the content. The content hash covers the whole file under
// 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large // 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large
// file is never read in full. scan maintains a persistent SQLite // file is never read in full. scan maintains a persistent SQLite
// database of file signatures (SFDUPES_DATABASE, default // database of file signatures that the other subcommands read. The
// /var/lib/sfdupes/db.sqlite) that the other subcommands read. // database is SFDUPES_DATABASE, or by default /var/lib/sfdupes/db.sqlite
// for root and sfdupes/db.sqlite in the XDG state directory for any
// other user.
// //
// Usage: // Usage:
// //
+2 -1
View File
@@ -3,6 +3,8 @@ module sneak.berlin/go/sfdupes
go 1.25.7 go 1.25.7
require ( require (
github.com/adrg/xdg v0.5.3
github.com/rivo/uniseg v0.4.7
github.com/schollz/progressbar/v3 v3.19.1 github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.46.0 golang.org/x/sys v0.46.0
@@ -18,7 +20,6 @@ require (
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+42 -14
View File
@@ -34,10 +34,14 @@ const (
// databaseHelp is the paragraph of the root and scan help that names // databaseHelp is the paragraph of the root and scan help that names
// the database (README "Location"). // the database (README "Location").
const databaseHelp = "scan and dwim store what they find in a database " + const databaseHelp = `scan and dwim store what they find in a database file. report,
"file. report, trees\nand info read it. The file is $" + databaseEnv + trees and info read it. The file is $SFDUPES_DATABASE when that is set
" when that is set and\nnot empty, and otherwise " + and not empty. Otherwise root uses /var/lib/sfdupes/db.sqlite. Any other
defaultDatabasePath + ", where creating it\nneeds root.\n" user uses sfdupes/db.sqlite in the XDG state directory: $XDG_STATE_HOME,
or ~/.local/state when that is unset or empty
(~/Library/Application Support on macOS). An XDG_STATE_HOME that is
not an absolute path is an error.
`
// escapesHelp is the paragraph of the report and trees help on how // escapesHelp is the paragraph of the report and trees help on how
// paths are escaped (README "Report output format"). // paths are escaped (README "Report output format").
@@ -49,6 +53,13 @@ const escapesHelp = `Four characters in a path are escaped, so each row stays on
\r a carriage return \r a carriage return
` `
// operandsHelp is the paragraph of the report and trees help on their
// PATH operands (README "Operands of `report` and `trees`").
const operandsHelp = `With PATH operands, only the records at or below them are used. A
relative PATH is joined to the working directory. No PATH is looked up
on the filesystem, so a symlink in one is not followed.
`
// reportHelp, treesHelp, infoHelp and dwimHelp follow the one-line // reportHelp, treesHelp, infoHelp and dwimHelp follow the one-line
// summary in the help of their subcommands. // summary in the help of their subcommands.
const ( const (
@@ -61,7 +72,7 @@ row per duplicate file. The columns are:
Rows are ordered by size, largest first, then by path. Rows are ordered by size, largest first, then by path.
` + escapesHelp ` + escapesHelp + "\n" + operandsHelp
treesHelp = `The report is tab-separated text on stdout: a header line, then one treesHelp = `The report is tab-separated text on stdout: a header line, then one
row per duplicate directory tree. The columns are: row per duplicate directory tree. The columns are:
@@ -74,7 +85,7 @@ row per duplicate directory tree. The columns are:
Rows are ordered by size, largest first, then by path. Rows are ordered by size, largest first, then by path.
` + escapesHelp ` + escapesHelp + "\n" + operandsHelp
infoHelp = `The details go to stdout, one per line. Each line holds a name and its infoHelp = `The details go to stdout, one per line. Each line holds a name and its
value, separated by a tab: value, separated by a tab:
@@ -108,6 +119,11 @@ var errWorkersBelowOne = errors.New("--workers must be at least 1")
// would name the working directory. // would name the working directory.
var errEmptyExcludeValue = errors.New("--exclude must not be empty") var errEmptyExcludeValue = errors.New("--exclude must not be empty")
// errEmptyOperand is the usage error for an empty PATH operand of
// report or trees, as from "$DIR" with DIR unset. Taken as a path, it
// would name the working directory.
var errEmptyOperand = errors.New("PATH must not be empty")
// Version is the build version, injected at link time via -ldflags // Version is the build version, injected at link time via -ldflags
// (see the Makefile); empty when the linker set none, as for a binary // (see the Makefile); empty when the linker set none, as for a binary
// built by go install. buildVersion turns it into what --version prints. // built by go install. buildVersion turns it into what --version prints.
@@ -229,20 +245,20 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
"print the version to stdout") "print the version to stdout")
reportCmd := &cobra.Command{ reportCmd := &cobra.Command{
Use: cmdReport, Use: cmdReport + " [PATH...]",
Short: "Read the scan database and print the file-level duplicates report", Short: "Read the scan database and print the file-level duplicates report",
Args: cobra.NoArgs, Args: rejectEmptyOperand,
RunE: runE(func(ctx context.Context, _ []string) error { RunE: runE(func(ctx context.Context, args []string) error {
return runReport(ctx, stdout) return runReport(ctx, args, stdout)
}), }),
} }
treesCmd := &cobra.Command{ treesCmd := &cobra.Command{
Use: cmdTrees, Use: cmdTrees + " [PATH...]",
Short: "Read the scan database and print the duplicate-tree report", Short: "Read the scan database and print the duplicate-tree report",
Args: cobra.NoArgs, Args: rejectEmptyOperand,
RunE: runE(func(ctx context.Context, _ []string) error { RunE: runE(func(ctx context.Context, args []string) error {
return runTrees(ctx, stdout) return runTrees(ctx, args, stdout)
}), }),
} }
@@ -387,6 +403,18 @@ func rejectEmptyExcludeValue(cmd *cobra.Command, excludePaths []string) error {
return errEmptyExcludeValue return errEmptyExcludeValue
} }
// rejectEmptyOperand rejects an empty PATH operand of report or trees.
// That is a usage error reported in one line, as for --workers below 1.
func rejectEmptyOperand(cmd *cobra.Command, args []string) error {
if !slices.Contains(args, "") {
return nil
}
cmd.SilenceUsage = true
return errEmptyOperand
}
// runE adapts a subcommand implementation, or the version print, to // runE adapts a subcommand implementation, or the version print, to
// cobra's RunE. Cobra prints the error and the command's usage text for // 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 // every error RunE returns, but a subcommand that ran and failed has no
+142 -27
View File
@@ -15,6 +15,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/adrg/xdg"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
@@ -559,13 +560,17 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
// line. // line.
t.Setenv(databaseEnv, "") t.Setenv(databaseEnv, "")
// Whether scan calls the database new depends on that machine too. dbPath, err := defaultDatabasePath(os.Geteuid())
scanWant := "scan: database " + defaultDatabasePath + " (default)\n" if err != nil {
t.Fatal(err)
}
_, err := os.Stat(defaultDatabasePath) // Whether scan calls the database new depends on that machine too.
scanWant := "scan: database " + dbPath + " (default)\n"
_, err = os.Stat(dbPath)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
scanWant = "scan: new database " + defaultDatabasePath + scanWant = "scan: new database " + dbPath + " (default)\n"
" (default)\n"
} }
missing := filepath.Join(t.TempDir(), "nope") missing := filepath.Join(t.TempDir(), "nope")
@@ -583,23 +588,23 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
}, },
cmdReport: { cmdReport: {
run: func() error { run: func() error {
return runReport(cancelledContext(t), io.Discard) return runReport(cancelledContext(t), nil, io.Discard)
}, },
want: "report: database " + defaultDatabasePath + " (default)\n" + want: "report: database " + dbPath + " (default)\n" +
reportOpenLine + "\n" + reportOpenLine + "\n", reportOpenLine + "\n" + reportOpenLine + "\n",
}, },
cmdTrees: { cmdTrees: {
run: func() error { run: func() error {
return runTrees(cancelledContext(t), io.Discard) return runTrees(cancelledContext(t), nil, io.Discard)
}, },
want: "trees: database " + defaultDatabasePath + " (default)\n" + want: "trees: database " + dbPath + " (default)\n" +
treesOpenLine + "\n" + treesOpenLine + "\n", treesOpenLine + "\n" + treesOpenLine + "\n",
}, },
cmdInfo: { cmdInfo: {
run: func() error { run: func() error {
return runInfo(cancelledContext(t), io.Discard) return runInfo(cancelledContext(t), io.Discard)
}, },
want: "info: database " + defaultDatabasePath + " (default)\n" + want: "info: database " + dbPath + " (default)\n" +
infoOpenLine + "\n" + infoOpenLine + "\n", infoOpenLine + "\n" + infoOpenLine + "\n",
}, },
} }
@@ -626,8 +631,6 @@ func TestRunUsageErrors(t *testing.T) {
}{ }{
"no subcommand": {[]string{}, usageMarker}, "no subcommand": {[]string{}, usageMarker},
"scan without paths": {[]string{cmdScan}, usageMarker}, "scan without paths": {[]string{cmdScan}, usageMarker},
"report with args": {[]string{cmdReport, "x"}, usageMarker},
"trees with args": {[]string{cmdTrees, "x"}, usageMarker},
"info with args": {[]string{cmdInfo, "x"}, usageMarker}, "info with args": {[]string{cmdInfo, "x"}, usageMarker},
"dwim with args": {[]string{cmdDwim, "x"}, usageMarker}, "dwim with args": {[]string{cmdDwim, "x"}, usageMarker},
"unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker}, "unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker},
@@ -718,6 +721,19 @@ func TestRunHelp(t *testing.T) {
t.Errorf("run(%v) stdout = %q, want nothing (data only)", t.Errorf("run(%v) stdout = %q, want nothing (data only)",
args, got) args, got)
} }
// README §Location: the help of sfdupes and of scan says where
// the database is, and that XDG_STATE_HOME must be an absolute
// path.
for _, want := range []string{
"scan and dwim store", databaseEnv, rootDatabasePath,
stateHomeEnv, "~/.local/state", "not an absolute path is an error",
} {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr = %q, want it to name %q",
args, stderr.String(), want)
}
}
} }
// The scan help lists --exclude with its argument. // The scan help lists --exclude with its argument.
@@ -760,36 +776,37 @@ func TestRunScanUsageLine(t *testing.T) {
} }
} }
func TestRunHelpDescribesDatabaseAndOutput(t *testing.T) { func TestRunHelpDescribesOutput(t *testing.T) {
t.Parallel() t.Parallel()
// README §Settings: the help names the database, and says what // README §Settings: the help of report, trees, info and dwim says
// report, trees, info and dwim print. // what each prints. The help of report and trees also explains
database := []string{ // their PATH operands. TestRunHelp checks the database paragraph
"scan and dwim store", databaseEnv, defaultDatabasePath, "needs root", // of the root and scan help.
}
escapes := []string{`\\`, `\t`, `\n`, `\r`} escapes := []string{`\\`, `\t`, `\n`, `\r`}
operands := []string{
"With PATH operands", "at or below them", "working directory",
"No PATH is looked up", "not followed",
}
// The subcommand whose help is printed; none for the root. // The subcommand whose help is printed.
cases := []struct { cases := []struct {
subcommand []string subcommand []string
want []string want []string
}{ }{
{nil, database},
{[]string{cmdScan}, database},
{ {
[]string{cmdReport}, []string{cmdReport},
append([]string{ slices.Concat([]string{
"tab-separated", "\n first ", "\n dupe ", "\n size ", "tab-separated", "\n first ", "\n dupe ", "\n size ",
"largest first", "largest first",
}, escapes...), }, escapes, operands),
}, },
{ {
[]string{cmdTrees}, []string{cmdTrees},
append([]string{ slices.Concat([]string{
"tab-separated", "\n first ", "\n dupe ", "\n files ", "tab-separated", "\n first ", "\n dupe ", "\n files ",
"\n size ", "\n disk_size ", "largest first", "\n size ", "\n disk_size ", "largest first",
}, escapes...), }, escapes, operands),
}, },
{ {
[]string{cmdInfo}, []string{cmdInfo},
@@ -1609,6 +1626,100 @@ func TestRunDatabasePathUsedAsGiven(t *testing.T) {
}) })
} }
func TestRunNonRootUsesStateDirectory(t *testing.T) {
// README §Location: without SFDUPES_DATABASE, a user other than
// root keeps the database in the XDG state directory. scan creates
// the missing directories with mode 0700, and report and trees read
// the same file.
if os.Geteuid() == 0 {
t.Skip("root uses " + rootDatabasePath)
}
t.Cleanup(xdg.Reload)
state := filepath.Join(t.TempDir(), "state")
t.Setenv(stateHomeEnv, state)
t.Setenv(databaseEnv, "")
xdg.Reload()
assertRunsUseDatabase(t, filepath.Join(state, "sfdupes"), "db.sqlite")
for _, dir := range []string{state, filepath.Join(state, "sfdupes")} {
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != stateDirPerm {
t.Errorf("%s mode = %#o, want %#o", dir, got, stateDirPerm)
}
}
}
func TestRunNonRootDatabaseEnvDirectoryMode(t *testing.T) {
// README §Location: for SFDUPES_DATABASE, scan creates each missing
// directory with mode 755, for a user other than root too.
if os.Geteuid() == 0 {
t.Skip("needs a user other than root")
}
top := filepath.Join(t.TempDir(), "missing")
sub := filepath.Join(top, "sub")
t.Setenv(databaseEnv, filepath.Join(sub, "db.sqlite"))
scanOK(t, t.TempDir())
for _, dir := range []string{top, sub} {
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != dbDirPerm {
t.Errorf("%s mode = %#o, want %#o", dir, got, dbDirPerm)
}
}
}
func TestRunRelativeStateHomeFails(t *testing.T) {
// README §Location: without SFDUPES_DATABASE, a user other than
// root whose XDG_STATE_HOME is not an absolute path gets an error
// naming it from every subcommand, which exits 1 before it does
// anything else.
if os.Geteuid() == 0 {
t.Skip("root does not use " + stateHomeEnv)
}
t.Setenv(stateHomeEnv, "relative/state")
t.Setenv(databaseEnv, "")
want := "sfdupes: " + stateHomeEnv +
" must be an absolute path, got \"relative/state\"\n"
// A scan that got past the check would stop at this missing
// operand, before it creates anything.
missing := filepath.Join(t.TempDir(), "missing")
for _, args := range [][]string{
{cmdScan, missing}, {cmdReport}, {cmdTrees}, {cmdInfo},
} {
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code != exitFatal || stderr.String() != want {
t.Errorf("run(%v) = %d with stderr %q, want %d and %q",
args, code, stderr.String(), exitFatal, want)
}
}
// dwim scans /, so runDwim gets the missing operand instead.
err := runDwim(t.Context(), missing, scanOptions{workers: 1}, io.Discard)
if !errors.Is(err, errStateHomeNotAbsolute) {
t.Errorf("runDwim: error = %v, want %v", err,
errStateHomeNotAbsolute)
}
}
// holdScanLock takes the lock on the database at path, as a running // 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 // scan does, and holds it until the test ends. It fails the test when
// the lock is already held. // the lock is already held.
@@ -1839,8 +1950,12 @@ func TestStdoutWriteErrorPropagates(t *testing.T) {
scanFixture(t) scanFixture(t)
cases := map[string]func(context.Context, io.Writer) error{ cases := map[string]func(context.Context, io.Writer) error{
cmdReport: runReport, cmdReport: func(ctx context.Context, w io.Writer) error {
cmdTrees: runTrees, return runReport(ctx, nil, w)
},
cmdTrees: func(ctx context.Context, w io.Writer) error {
return runTrees(ctx, nil, w)
},
cmdInfo: runInfo, cmdInfo: runInfo,
} }
+68 -15
View File
@@ -15,28 +15,40 @@ import (
"strings" "strings"
"time" "time"
"github.com/adrg/xdg"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
// The pure-Go SQLite driver, registered as "sqlite"; keeps cgo // The pure-Go SQLite driver, registered as "sqlite"; keeps cgo
// disabled. // disabled.
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
// defaultDatabasePath is where the persistent scan database lives when // rootDatabasePath is the database root uses when SFDUPES_DATABASE is
// SFDUPES_DATABASE is not set. // not set.
const defaultDatabasePath = "/var/lib/sfdupes/db.sqlite" const rootDatabasePath = "/var/lib/sfdupes/db.sqlite"
// databaseEnv is the environment variable that overrides the database // databaseEnv is the environment variable that overrides the database
// path. // path.
const databaseEnv = "SFDUPES_DATABASE" const databaseEnv = "SFDUPES_DATABASE"
// stateHomeEnv is the environment variable that names the XDG state
// directory.
const stateHomeEnv = "XDG_STATE_HOME"
// schemaVersion is the database schema version this build reads and // schemaVersion is the database schema version this build reads and
// writes, stored in PRAGMA user_version. // writes, stored in PRAGMA user_version.
const schemaVersion = 1 const schemaVersion = 1
// dbDirPerm is the mode for a database parent directory created by // dbDirPerm is the mode for a directory scan creates for root's
// scan. // default database or for SFDUPES_DATABASE.
const dbDirPerm = 0o755 const dbDirPerm = 0o755
// stateDirPerm is the mode for a directory scan creates for the
// database at a user's default path in the XDG state directory. It is
// the mode the XDG Base Directory specification asks for, and it keeps
// other users from reading the database, which lists every scanned
// path.
const stateDirPerm = 0o700
// dbFilePerm is the mode for a database file created by scan, the mode // dbFilePerm is the mode for a database file created by scan, the mode
// SQLite gives a database file it creates. // SQLite gives a database file it creates.
const dbFilePerm = 0o644 const dbFilePerm = 0o644
@@ -144,15 +156,48 @@ var errScanRunning = errors.New("another scan is running")
// 32 bytes of a SHA-256. // 32 bytes of a SHA-256.
var errHashSize = errors.New("hash is not 32 bytes") var errHashSize = errors.New("hash is not 32 bytes")
// errStateHomeNotAbsolute reports an XDG_STATE_HOME that is set to a
// path that is not absolute.
var errStateHomeNotAbsolute = errors.New(
stateHomeEnv + " must be an absolute path")
// defaultDatabasePath is the database used when SFDUPES_DATABASE is not
// set: rootDatabasePath for root (euid 0), and db.sqlite in the
// directory sfdupes under the XDG state directory for any other user.
// xdg reads XDG_STATE_HOME and HOME once, when the program starts. For
// any other user, an XDG_STATE_HOME that is set and not an absolute
// path is an error, where xdg would use its default instead. The path
// is joined as text, not cleaned, for the reason absolutePath gives.
func defaultDatabasePath(euid int) (string, error) {
if euid == 0 {
return rootDatabasePath, nil
}
stateHome := os.Getenv(stateHomeEnv)
if stateHome != "" && !filepath.IsAbs(stateHome) {
return "", fmt.Errorf("%w, got %q", errStateHomeNotAbsolute,
stateHome)
}
stateDir := strings.TrimSuffix(xdg.StateHome, "/")
return stateDir + "/sfdupes/db.sqlite", nil
}
// databasePath resolves the database location: SFDUPES_DATABASE when // databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the // set and non-empty, defaultDatabasePath otherwise. It returns the
// path, made absolute by absolutePath, and where the path came from, // path, made absolute by absolutePath, and where the path came from,
// "default" or "from SFDUPES_DATABASE", for the line each subcommand // "default" or "from SFDUPES_DATABASE", for the line each subcommand
// prints first on stderr. // prints first on stderr.
func databasePath() (string, string, error) { func databasePath() (string, string, error) {
path, source := defaultDatabasePath, "default" path, source := os.Getenv(databaseEnv), "from "+databaseEnv
if p := os.Getenv(databaseEnv); p != "" { if path == "" {
path, source = p, "from "+databaseEnv def, err := defaultDatabasePath(os.Geteuid())
if err != nil {
return "", "", err
}
path, source = def, "default"
} }
abs, err := absolutePath(path) abs, err := absolutePath(path)
@@ -188,15 +233,23 @@ func absolutePath(path string) (string, error) {
// createDatabaseDirectory creates the directory the database at path // createDatabaseDirectory creates the directory the database at path
// goes in, and any directory above it, if missing. path is absolute, as // goes in, and any directory above it, if missing. path is absolute, as
// databasePath returns it. The directory is path with its last element // databasePath returns it. Each directory gets mode dbDirPerm, or
// cut off as text, by filepath.Split. filepath.Dir would clean it, // stateDirPerm when databasePath uses a user's default path in the XDG
// dropping each ".." together with the directory name before it; when // state directory: the user is not root and SFDUPES_DATABASE is unset
// that name is a symlink, the cleaned path names another directory than // or empty. The directory is path with its last element cut off as
// the one the system reaches when it opens path. // text, by filepath.Split. filepath.Dir would clean it, dropping each
// ".." together with the directory name before it; when that name is a
// symlink, the cleaned path names another directory than the one the
// system reaches when it opens path.
func createDatabaseDirectory(path string) error { func createDatabaseDirectory(path string) error {
dir, _ := filepath.Split(path) dir, _ := filepath.Split(path)
err := os.MkdirAll(dir, dbDirPerm) perm := os.FileMode(dbDirPerm)
if os.Geteuid() != 0 && os.Getenv(databaseEnv) == "" {
perm = stateDirPerm
}
err := os.MkdirAll(dir, perm)
if err != nil { if err != nil {
return fmt.Errorf("create database directory: %w", err) return fmt.Errorf("create database directory: %w", err)
} }
+51 -1
View File
@@ -9,10 +9,13 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/adrg/xdg"
) )
// testDBPath returns a database path inside a fresh temp dir, with // testDBPath returns a database path inside a fresh temp dir, with
@@ -56,11 +59,16 @@ func TestDatabasePath(t *testing.T) {
t.Chdir(dir) t.Chdir(dir)
defaultPath, err := defaultDatabasePath(os.Geteuid())
if err != nil {
t.Fatal(err)
}
fromEnv := "from " + databaseEnv fromEnv := "from " + databaseEnv
cases := []struct { cases := []struct {
env, path, source string env, path, source string
}{ }{
{"", defaultDatabasePath, "default"}, {"", defaultPath, "default"},
{"/custom/place.sqlite", "/custom/place.sqlite", fromEnv}, {"/custom/place.sqlite", "/custom/place.sqlite", fromEnv},
{"/custom/link/../place.sqlite", "/custom/link/../place.sqlite", {"/custom/link/../place.sqlite", "/custom/link/../place.sqlite",
fromEnv}, fromEnv},
@@ -89,6 +97,48 @@ func TestDatabasePath(t *testing.T) {
} }
} }
func TestDefaultDatabasePath(t *testing.T) {
// README §Location: root's database is /var/lib/sfdupes/db.sqlite.
// Any other user's is db.sqlite in sfdupes under XDG_STATE_HOME, or
// under ~/.local/state when XDG_STATE_HOME is unset or empty. For
// that user, an XDG_STATE_HOME that is not an absolute path is an
// error.
if runtime.GOOS != goosLinux {
t.Skip("macOS has another default state directory")
}
t.Cleanup(xdg.Reload)
t.Setenv("HOME", "/home/someone")
cases := []struct {
euid int
stateHome string
want string
wantErr error
}{
{0, "", rootDatabasePath, nil},
{0, "/elsewhere/state", rootDatabasePath, nil},
{0, "relative/state", rootDatabasePath, nil},
{1000, "", "/home/someone/.local/state/sfdupes/db.sqlite", nil},
{1000, "relative/state", "", errStateHomeNotAbsolute},
{1000, "~/state", "", errStateHomeNotAbsolute},
{1000, "/elsewhere/state", "/elsewhere/state/sfdupes/db.sqlite", nil},
{1000, "/elsewhere/state/", "/elsewhere/state/sfdupes/db.sqlite", nil},
}
for _, tc := range cases {
t.Setenv(stateHomeEnv, tc.stateHome)
xdg.Reload()
got, err := defaultDatabasePath(tc.euid)
if got != tc.want || !errors.Is(err, tc.wantErr) {
t.Errorf("defaultDatabasePath(%d) with %s=%q = %q, %v; want %q, %v",
tc.euid, stateHomeEnv, tc.stateHome, got, err, tc.want,
tc.wantErr)
}
}
}
func TestOpenScanDatabaseCreates(t *testing.T) { func TestOpenScanDatabaseCreates(t *testing.T) {
t.Parallel() t.Parallel()
+1 -1
View File
@@ -26,7 +26,7 @@ func runDwim(
return err return err
} }
err = runTrees(ctx, stdout) err = runTrees(ctx, nil, stdout)
if err != nil { if err != nil {
return err return err
} }
+2 -19
View File
@@ -72,31 +72,14 @@ type walkRules struct {
func defaultExcludes(goos string) []string { func defaultExcludes(goos string) []string {
switch goos { switch goos {
case goosLinux: case goosLinux:
// The kernel's view of the running system, temporary files, and return []string{"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"}
// caches that programs and package managers fill again on their
// own: the system's, root's and those in each home in /home.
return []string{
"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp", "/var/cache",
"/root/.cache", "/root/.npm", "/root/.cargo/registry",
"/root/go/pkg/mod",
"/home/*/.cache", "/home/*/.npm", "/home/*/.cargo/registry",
"/home/*/go/pkg/mod",
}
case goosDarwin: case goosDarwin:
// A walk of / reaches everything in /System/Volumes/Data through // A walk of / reaches everything in /System/Volumes/Data through
// the firmlinks, so walking it as well would record each of // the firmlinks, so walking it as well would record each of
// those files twice. /private/var/folders holds each user's // those files twice.
// temporary files and caches. The walk reaches root's home,
// /var/root, as /private/var/root.
return []string{ return []string{
"/dev", "/private/tmp", "/private/var/tmp", "/private/var/vm", "/dev", "/private/tmp", "/private/var/tmp", "/private/var/vm",
"/System/Volumes/VM", "/System/Volumes/Data", "/System/Volumes/VM", "/System/Volumes/Data",
"/private/var/folders", "/Library/Caches",
"/private/var/root/Library/Caches", "/private/var/root/.cache",
"/private/var/root/.npm", "/private/var/root/.cargo/registry",
"/private/var/root/go/pkg/mod",
"/Users/*/Library/Caches", "/Users/*/.cache", "/Users/*/.npm",
"/Users/*/.cargo/registry", "/Users/*/go/pkg/mod",
} }
case goosWindows: case goosWindows:
// Relative to the root of the drive an operand lies on. The // Relative to the root of the drive an operand lies on. The
+2 -51
View File
@@ -24,22 +24,10 @@ func TestDefaultExcludes(t *testing.T) {
// on any other system. The Windows list exists in the code only: the // on any other system. The Windows list exists in the code only: the
// binary does not build for Windows, so the README leaves it out. // binary does not build for Windows, so the README leaves it out.
cases := map[string][]string{ cases := map[string][]string{
goosLinux: { goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp", "/var/cache",
"/root/.cache", "/root/.npm", "/root/.cargo/registry",
"/root/go/pkg/mod",
"/home/*/.cache", "/home/*/.npm", "/home/*/.cargo/registry",
"/home/*/go/pkg/mod",
},
goosDarwin: { goosDarwin: {
"/dev", "/private/tmp", "/private/var/tmp", "/private/var/vm", "/dev", "/private/tmp", "/private/var/tmp", "/private/var/vm",
"/System/Volumes/VM", "/System/Volumes/Data", "/System/Volumes/VM", "/System/Volumes/Data",
"/private/var/folders", "/Library/Caches",
"/private/var/root/Library/Caches", "/private/var/root/.cache",
"/private/var/root/.npm", "/private/var/root/.cargo/registry",
"/private/var/root/go/pkg/mod",
"/Users/*/Library/Caches", "/Users/*/.cache", "/Users/*/.npm",
"/Users/*/.cargo/registry", "/Users/*/go/pkg/mod",
}, },
goosWindows: { goosWindows: {
"pagefile.sys", "swapfile.sys", "hiberfil.sys", "pagefile.sys", "swapfile.sys", "hiberfil.sys",
@@ -75,29 +63,6 @@ func TestIsDefaultExclude(t *testing.T) {
t.Errorf("isDefaultExclude(%q) = %v, want %v", path, got, want) t.Errorf("isDefaultExclude(%q) = %v, want %v", path, got, want)
} }
} }
// The Linux list matches the cache directories in /root and in each
// home in /home, but nothing below them and no .cache directory
// elsewhere.
linux := map[string]bool{
"/home/u/.cache": true,
"/home/u/go/pkg/mod": true,
"/root/.cache": true,
"/var/cache": true,
"/home/u/.cache/pip": false,
"/home/u/src/.cache": false,
"/srv/home/u/.cache": false,
"/home/u/.cargo/bin": false,
"/home/u/go/pkg/mod/cache": false,
}
for path, want := range linux {
got := isDefaultExclude(path, defaultExcludes(goosLinux))
if got != want {
t.Errorf("isDefaultExclude(%q, Linux list) = %v, want %v", path,
got, want)
}
}
} }
func TestReadMountInfo(t *testing.T) { func TestReadMountInfo(t *testing.T) {
@@ -325,7 +290,7 @@ func TestRunScanTempDirOperand(t *testing.T) {
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := realTempDir(t) dir := realTempDir(t)
if !insideDefaultExclude(dir, defaultExcludes(runtime.GOOS)) { if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) {
t.Skip(dir + " is not inside a default exclude") t.Skip(dir + " is not inside a default exclude")
} }
@@ -746,20 +711,6 @@ func shmTempDir(t *testing.T) (string, string, walkRules) {
return dir, fsType, walkRules{nonPersistent: nonPersistent} return dir, fsType, walkRules{nonPersistent: nonPersistent}
} }
// insideDefaultExclude reports whether path, which is absolute and
// clean, or a directory above it is one of excludes.
func insideDefaultExclude(path string, excludes []string) bool {
for dir := path; ; dir = filepath.Dir(dir) {
if isDefaultExclude(dir, excludes) {
return true
}
if dir == "/" {
return false
}
}
}
// assertRecorded checks that the database at dbPath holds a record for // assertRecorded checks that the database at dbPath holds a record for
// path when want is set, and none when it is not. // path when want is set, and none when it is not.
func assertRecorded(t *testing.T, dbPath, path string, want bool) { func assertRecorded(t *testing.T, dbPath, path string, want bool) {
+107 -9
View File
@@ -9,8 +9,10 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"unicode/utf8" "unicode/utf8"
"github.com/rivo/uniseg"
"github.com/schollz/progressbar/v3" "github.com/schollz/progressbar/v3"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -116,7 +118,8 @@ type drawnCount struct {
// terminal's width. A phase with an exact total ends the line with its // terminal's width. A phase with an exact total ends the line with its
// percent and bar, at the terminal's right edge, when the terminal has // percent and bar, at the terminal's right edge, when the terminal has
// room for them. It is drawn again every redrawInterval, so its elapsed // room for them. It is drawn again every redrawInterval, so its elapsed
// time keeps moving while the phase waits for its next item. When // time keeps moving while the phase waits for its next item. Each draw
// of the walk shows the path it reached last on the line below. When
// stderr is not a TTY it emits no ANSI redraws: it prints line as the // stderr is not a TTY it emits no ANSI redraws: it prints line as the
// phase starts, every plainInterval while it runs, and as it ends. // phase starts, every plainInterval while it runs, and as it ends.
// //
@@ -141,6 +144,7 @@ type progress struct {
estimate bool estimate bool
waitingFor string waitingFor string
stalledOn string stalledOn string
path string // the path the walk reached last; "" for none
count int64 count int64
start time.Time // when the phase started, for the elapsed time start time.Time // when the phase started, for the elapsed time
rateStart time.Time // when the count started, for the time remaining rateStart time.Time // when the count started, for the time remaining
@@ -239,6 +243,82 @@ func (p *progress) draw() {
// less the terminal's last column, which spinnerMargin keeps clear. A // less the terminal's last column, which spinnerMargin keeps clear. A
// bar's cells take one column each but more than one byte. // bar's cells take one column each but more than one byte.
p.widest = max(p.widest, utf8.RuneCountInString(text)+spinnerMargin-1) p.widest = max(p.widest, utf8.RuneCountInString(text)+spinnerMargin-1)
if p.path != "" {
// The library draws the path on the line below and returns to
// the phase's line. The path is escaped as warnings are, and its
// other control characters are written out, so that the terminal
// shows them instead of acting on them. It stays clear of the
// last column, so that no terminal wraps it.
shown := escapeControlChars(escapePath(p.path))
_ = p.bar.AddDetail(cutToColumns(shown, width-1))
}
}
// escapeControlChars returns s with each control character written as
// \x and its code in two hex digits, as \x1b for the escape character.
// The control characters are U+0000 to U+001F, U+007F and U+0080 to
// U+009F, as unicode.IsControl reports them. A byte from 0x80 to 0x9F
// that is not part of a valid UTF-8 character is written the same way,
// as \x9b, since a terminal not in UTF-8 mode acts on it. Every other
// byte is kept as it is.
func escapeControlChars(s string) string {
var b strings.Builder
for rest := s; rest != ""; {
char, size := utf8.DecodeRuneInString(rest)
if char == utf8.RuneError && size == 1 {
// Not part of a valid UTF-8 character. A terminal not in
// UTF-8 mode reads the byte as the character with its code,
// so that 0x9b is a control character there and 0xe9 is not.
char = rune(rest[0])
}
if unicode.IsControl(char) {
fmt.Fprintf(&b, `\x%02x`, char)
} else {
b.WriteString(rest[:size])
}
rest = rest[size:]
}
return b.String()
}
// cutToColumns returns s cut to at most columns terminal columns, at a
// character boundary. A wide character, as in Chinese, takes two
// columns.
func cutToColumns(s string, columns int) string {
used, end, state := 0, 0, -1
for rest := s; rest != ""; {
char, next, width, nextState := uniseg.FirstGraphemeClusterInString(
rest, state)
if used+width > columns {
break
}
used += width
end += len(char)
rest, state = next, nextState
}
return s[:end]
}
// clear erases the display from the terminal: its line, and the path on
// the line below when one is shown. p.mu must be held.
func (p *progress) clear() {
if p.bar == nil {
return
}
if p.path != "" {
_ = p.bar.AddDetail("")
}
_ = p.bar.Clear()
} }
// newBar has the library make what draw draws: its spinner, followed by // newBar has the library make what draw draws: its spinner, followed by
@@ -263,6 +343,8 @@ func (p *progress) newBar(width int) {
// The text carries the elapsed time and the time remaining. // The text carries the elapsed time and the time remaining.
progressbar.OptionSetElapsedTime(false), progressbar.OptionSetElapsedTime(false),
progressbar.OptionSetPredictTime(false), progressbar.OptionSetPredictTime(false),
// The line below the phase's line, for the walk's path.
progressbar.OptionSetMaxDetailRow(1),
) )
p.widest = 0 p.widest = 0
} }
@@ -314,6 +396,19 @@ func (p *progress) increment() {
p.lastItem = now p.lastItem = now
} }
// setPath records the path the walk reached last, which a display on a
// terminal shows at its next draw.
func (p *progress) setPath(path string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
p.path = path
}
// warnf prints a one-line warning to stderr without corrupting the // warnf prints a one-line warning to stderr without corrupting the
// display. The whole message is escaped like a report's path columns, // display. The whole message is escaped like a report's path columns,
// so a path holding a newline cannot split the warning. // so a path holding a newline cannot split the warning.
@@ -327,11 +422,9 @@ func (p *progress) warnf(format string, args ...any) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.bar != nil { // The warning takes the display's line; the next draw shows the
// The warning takes the display's line; the next draw shows // display again, on the line below.
// the display again, on the line below. p.clear()
_ = p.bar.Clear()
}
fmt.Fprintln(os.Stderr, msg) fmt.Fprintln(os.Stderr, msg)
} }
@@ -392,9 +485,7 @@ func (w *progressWriter) Flush() error {
w.p.mu.Lock() w.p.mu.Lock()
defer w.p.mu.Unlock() defer w.p.mu.Unlock()
if w.p.bar != nil { w.p.clear()
_ = w.p.bar.Clear()
}
} }
_, err := w.out.Write(w.buf) _, err := w.out.Write(w.buf)
@@ -414,6 +505,13 @@ func (p *progress) finish() {
close(p.stop) close(p.stop)
<-p.done <-p.done
// The walk's path is only for someone watching the walk. Its line is
// erased, and the next phase is drawn on it.
p.mu.Lock()
p.clear()
p.path = ""
p.mu.Unlock()
p.draw() p.draw()
if p.width != nil { if p.width != nil {
+162
View File
@@ -6,6 +6,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -1138,3 +1139,164 @@ func TestWarningAfterLastItemKeepsBar(t *testing.T) {
t.Errorf("terminal shows %q, want a count of 2 of 2", shown) t.Errorf("terminal shows %q, want a count of 2 of 2", shown)
} }
} }
func TestCutToColumns(t *testing.T) {
t.Parallel()
cases := []struct {
s string
columns int
want string
}{
{"/srv/a", 10, "/srv/a"},
{"/srv/photos", 8, "/srv/pho"},
// Each of these two fullwidth letters takes two columns.
{"/AB/a", 4, "/A"},
{"/AB/a", 5, "/AB"},
// An accent that combines with the letter before stays with it.
{"/café/a", 5, "/café"},
{"/srv", 0, ""},
}
for _, tc := range cases {
if got := cutToColumns(tc.s, tc.columns); got != tc.want {
t.Errorf("cutToColumns(%q, %d) = %q, want %q",
tc.s, tc.columns, got, tc.want)
}
}
}
func TestEscapeControlChars(t *testing.T) {
t.Parallel()
cases := map[string]string{
"/srv/music": "/srv/music",
"/a\x1b[2Jb": `/a\x1b[2Jb`,
"/a\x00\a\x7fb": `/a\x00\x07\x7fb`,
// U+009B takes two bytes in UTF-8.
"/a\u009b2Jb": `/a\x9b2Jb`,
// Byte 0x9b alone is not valid UTF-8. A terminal not in UTF-8
// mode acts on it, as on every byte from 0x80 to 0x9f.
"/a\x9b2Jb": `/a\x9b2Jb`,
"/a\x80\x9f\xa0b": `/a\x80\x9f` + "\xa0b",
// In ě, which is c4 9b in UTF-8, byte 0x9b is part of a valid
// character.
"/aě2Jb": "/aě2Jb",
"/café/AB": "/café/AB",
"/latin1-caf\xe9": "/latin1-caf\xe9",
}
for in, want := range cases {
if got := escapeControlChars(in); got != want {
t.Errorf("escapeControlChars(%q) = %q, want %q", in, got, want)
}
}
}
// pathLines returns the text of each line the library wrote below a
// display's line. It writes one as an erase to the end of the line, a
// carriage return, the text and a newline.
func pathLines(out string) []string {
matches := regexp.MustCompile("\x1b\\[K\r([^\n]*)\n").
FindAllStringSubmatch(out, -1)
lines := make([]string, 0, len(matches))
for _, m := range matches {
lines = append(lines, m[1])
}
return lines
}
// TestTerminalWalkShowsPathBelow checks that the walk's display on a
// terminal 30 columns wide shows the path it reached last on the line
// below, cut to 29 columns. That line is erased before a warning is
// printed, and again when the walk ends, so the next phase is drawn on
// an empty line.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalWalkShowsPathBelow(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, func() int { return 30 }, testRedraw, time.Now)
const shown = "/srv/photos/2026/holiday/beac"
p.setPath("/srv/photos/2026/holiday/beach.jpg")
p.increment()
awaitOutput(t, stderr, shown, 1)
p.warnf("warning")
before, _, _ := strings.Cut(stderr(), "warning")
if lines := pathLines(before); lines[len(lines)-1] != "" {
t.Errorf("line below shows %q as the warning is printed, want it "+
"empty", lines[len(lines)-1])
}
// Drawn again below the warning.
awaitOutput(t, stderr, shown, strings.Count(before, shown)+1)
p.finish()
lines := pathLines(stderr())
if !slices.Contains(lines, shown) {
t.Errorf("lines below the display = %q, want %q among them",
lines, shown)
}
if last := lines[len(lines)-1]; last != "" {
t.Errorf("line below the display shows %q after the walk, want it "+
"empty", last)
}
}
// TestTerminalWalkEscapesControlChars checks that a walked path holding
// escape characters, which would clear the screen and set the window
// title, puts none on the line below the walk's display. The line shows
// each control character written out, as \x1b.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalWalkEscapesControlChars(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, func() int { return 80 }, testRedraw, time.Now)
p.setPath("/srv/\x1b[2J\x1b]0;title\a/beach.jpg")
p.increment()
awaitOutput(t, stderr, "beach.jpg", 1)
p.finish()
lines := pathLines(stderr())
for _, line := range lines {
if strings.Contains(line, "\x1b") {
t.Errorf("line below the display = %q, want no escape "+
"character", line)
}
}
const shown = `/srv/\x1b[2J\x1b]0;title\x07/beach.jpg`
if !slices.Contains(lines, shown) {
t.Errorf("lines below the display = %q, want %q among them",
lines, shown)
}
}
// TestPlainWalkShowsNoPath checks that the walk's display prints no path
// when stderr is not a terminal.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestPlainWalkShowsNoPath(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, nil, testRedraw, time.Now)
p.setPath("/srv/a")
p.increment()
p.finish()
if strings.Contains(stderr(), "/srv/a") {
t.Errorf("stderr = %q, want no path", stderr())
}
}
+70 -14
View File
@@ -9,6 +9,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"os" "os"
"path/filepath"
"slices" "slices"
"strings" "strings"
"syscall" "syscall"
@@ -44,10 +45,12 @@ func reportPhase(name string) string {
} }
// runReport implements the report subcommand: it prints the file-level // runReport implements the report subcommand: it prints the file-level
// duplicates report as TSV on stdout. Of the scanned filesystem it // duplicates report as TSV on stdout, from the records at or below the
// only checks, with pathExists, that each path in a duplicate group is // operands in args, or from every record when args is empty. Of the
// still there. It names the database on stderr before it opens it. Any // scanned filesystem it only checks, with pathExists, that each path in
// database problem, including a missing database, is fatal. // a duplicate group is still there; it never looks up an operand. It
// names the database on stderr before it opens it. Any database
// problem, including a missing database, is fatal.
// //
// The work runs in two phases, each under a progress display of its // The work runs in two phases, each under a progress display of its
// own on stderr: open (open the database) and read (read the number of // own on stderr: open (open the database) and read (read the number of
@@ -55,7 +58,12 @@ func reportPhase(name string) string {
// order, writing each size's duplicate groups to stdout once that size // order, writing each size's duplicate groups to stdout once that size
// is read; see readReport). Each display ends before the next phase // is read; see readReport). Each display ends before the next phase
// starts, and before an error is returned to be printed. // starts, and before an error is returned to be printed.
func runReport(ctx context.Context, stdout io.Writer) error { func runReport(ctx context.Context, args []string, stdout io.Writer) error {
operands, err := cleanOperands(args)
if err != nil {
return err
}
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
return err return err
@@ -87,7 +95,8 @@ func runReport(ctx context.Context, stdout io.Writer) error {
label: reportPhase("read"), unit: "records", total: -1, label: reportPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, stalledOn: "db/disk/out", waitingFor: countingRecords, stalledOn: "db/disk/out",
}) })
report, err := readReport(ctx, db, dbPath, stdout, pathExists, prog) report, err := readReport(ctx, db, dbPath, operands, stdout, pathExists,
prog)
prog.finish() prog.finish()
@@ -108,12 +117,14 @@ func runReport(ctx context.Context, stdout io.Writer) error {
// dbPath, as its total, then reads every record in signature order, // dbPath, as its total, then reads every record in signature order,
// counting each on prog, and writes the report to stdout: the header // counting each on prog, and writes the report to stdout: the header
// line, then the rows of each size's duplicate groups once that size is // line, then the rows of each size's duplicate groups once that size is
// read. A path for which exists returns false is left out of its group. // read. Only the records that underOperand finds under operands join a
// stdout may be the terminal prog is drawn on, so the lines go through // group, and a path for which exists returns false is left out of its
// prog's writer. It returns what it wrote, with the totals for the // group. stdout may be the terminal prog is drawn on, so the lines go
// summary line. // through prog's writer. It returns what it wrote, with the totals for
// the summary line.
func readReport(ctx context.Context, db *sql.DB, dbPath string, func readReport(ctx context.Context, db *sql.DB, dbPath string,
stdout io.Writer, exists func(path string) bool, prog *progress, operands []string, stdout io.Writer, exists func(path string) bool,
prog *progress,
) (*reportWriter, error) { ) (*reportWriter, error) {
out := prog.writer(stdout) out := prog.writer(stdout)
report := &reportWriter{out: out, exists: exists} report := &reportWriter{out: out, exists: exists}
@@ -128,6 +139,12 @@ func readReport(ctx context.Context, db *sql.DB, dbPath string,
err = loadSignatureRows(ctx, db, prog.setTotal, func(r scanRec) error { err = loadSignatureRows(ctx, db, prog.setTotal, func(r scanRec) error {
prog.increment() prog.increment()
report.records++
if !underOperand(r.path, operands) {
return nil
}
writeErr = report.add(r) writeErr = report.add(r)
return writeErr return writeErr
@@ -159,7 +176,7 @@ func readReport(ctx context.Context, db *sql.DB, dbPath string,
// records of one size, so it holds only the duplicate groups of the // records of one size, so it holds only the duplicate groups of the
// size being read, and writes them, ordered by first path, once a // size being read, and writes them, ordered by first path, once a
// record of a smaller size comes. It counts what the summary line // record of a smaller size comes. It counts what the summary line
// reports. // reports; readReport counts the records read.
type reportWriter struct { type reportWriter struct {
out io.Writer out io.Writer
@@ -179,8 +196,6 @@ type reportWriter struct {
// its paths; any other ends them, and one of a smaller size first // its paths; any other ends them, and one of a smaller size first
// writes the groups of the size before. // writes the groups of the size before.
func (w *reportWriter) add(r scanRec) error { func (w *reportWriter) add(r scanRec) error {
w.records++
if r.size == w.sig.size && r.head == w.sig.head && if r.size == w.sig.size && r.head == w.sig.head &&
r.tail == w.sig.tail && r.content == w.sig.content { r.tail == w.sig.tail && r.content == w.sig.content {
w.paths = append(w.paths, r.path) w.paths = append(w.paths, r.path)
@@ -273,6 +288,47 @@ func pathExists(path string) bool {
return !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, syscall.ENOTDIR) return !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, syscall.ENOTDIR)
} }
// cleanOperands returns the operands of report or trees, each made
// absolute by absolutePath and then cleaned. The records hold real
// paths, with no "." or ".." element and no repeated or trailing
// slash, so an operand is cleaned to match them. No operand is looked
// up on the filesystem. None is empty: rejectEmptyOperand refuses an
// empty operand on the command line.
func cleanOperands(args []string) ([]string, error) {
operands := make([]string, 0, len(args))
for _, a := range args {
abs, err := absolutePath(a)
if err != nil {
return nil, fmt.Errorf("resolve path %s: %w", escapePath(a), err)
}
operands = append(operands, filepath.Clean(abs))
}
return operands, nil
}
// underOperand reports whether path is one of operands or lies below
// one of them. Each operand is clean and absolute, as cleanOperands
// returns it. With no operands, a report covers the whole database, so
// underOperand is true for every path.
func underOperand(path string, operands []string) bool {
if len(operands) == 0 {
return true
}
for _, o := range operands {
// Only the root directory's clean path ends in "/".
dir := strings.TrimSuffix(o, "/") + "/"
if path == o || strings.HasPrefix(path, dir) {
return true
}
}
return false
}
// escapePath returns a path as it is written in a report column (README // escapePath returns a path as it is written in a report column (README
// "Report output format"): a backslash, tab, newline or carriage return // "Report output format"): a backslash, tab, newline or carriage return
// becomes \\, \t, \n or \r, and every other byte is kept as it is. // becomes \\, \t, \n or \r, and every other byte is kept as it is.
+139 -6
View File
@@ -102,7 +102,8 @@ func dupeGroups(t *testing.T, db *sql.DB) []dupeGroup {
var out bytes.Buffer var out bytes.Buffer
_, err := readReport(t.Context(), db, "", &out, everyPathExists, nil) _, err := readReport(t.Context(), db, "", nil, &out, everyPathExists,
nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -148,6 +149,136 @@ func dupeGroupsOf(t *testing.T, recs []scanRec) []dupeGroup {
return dupeGroups(t, db) return dupeGroups(t, db)
} }
// TestRunReportOperands runs report with operands on three copies of
// one file, two of them under srv/photos, and a fourth copy in
// srv/photos2, all in a new directory. Only the records at or below the
// operands form the groups and the totals, however each operand is
// written.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestRunReportOperands(t *testing.T) {
h := testHash("h")
recs := make([]scanRec, 0, 4)
for _, p := range []string{
"/a/copy", "/srv/photos/x", "/srv/photos/y", "/srv/photos2/z",
} {
recs = append(recs,
scanRec{size: 7, head: h, tail: h, content: h, path: p})
}
dir, recs := recsOnDisk(t, recs)
a, x, y, z := recs[0].path, recs[1].path, recs[2].path, recs[3].path
t.Setenv(databaseEnv, seedDatabase(t, recs))
header := "first\tdupe\tsize\n"
row := func(first, dupe string) string {
return first + "\t" + dupe + "\t7\n"
}
photos := header + row(x, y)
cases := map[string]struct {
operands []string
want string
summary string
}{
"directory": {[]string{dir + "/srv/photos"}, photos,
"1 duplicate groups, 1 dupe files, 7 B reclaimable"},
"trailing slash": {[]string{dir + "/srv/photos/"}, photos, ""},
"dot and dot-dot": {[]string{dir + "/srv/./a/../photos"}, photos, ""},
"files": {[]string{a, z}, header + row(a, z), ""},
"overlapping": {[]string{dir + "/srv", dir + "/srv/photos"},
header + row(x, y) + row(x, z), ""},
"root": {[]string{"/"},
header + row(a, x) + row(a, y) + row(a, z), ""},
"no record": {[]string{dir + "/srv/photo"}, header,
"0 duplicate groups, 0 dupe files, 0 B reclaimable"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
var stdout bytes.Buffer
stderr := captureStderr(t)
code := run(append([]string{cmdReport}, tc.operands...),
&stdout, os.Stderr)
if code != exitOK {
t.Fatalf("run(report %q) = %d, want %d; stderr: %s",
tc.operands, code, exitOK, stderr())
}
if got := stdout.String(); got != tc.want {
t.Errorf("stdout = %q, want %q", got, tc.want)
}
// The records read are all four, whatever the operands.
summary := "report: 4 records read, " + tc.summary + "\n"
if got := stderr(); tc.summary != "" &&
!strings.HasSuffix(got, summary) {
t.Errorf("stderr = %q, want it to end %q", got, summary)
}
})
}
}
//nolint:paralleltest // t.Chdir changes the process-wide working directory
func TestCleanOperandsMakesRelativePathsAbsolute(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
wd, err := absolutePath(".")
if err != nil {
t.Fatal(err)
}
got, err := cleanOperands([]string{"photos/", "a/../b", "/c//d"})
if err != nil {
t.Fatal(err)
}
want := []string{
filepath.Join(wd, "photos"), filepath.Join(wd, "b"), "/c/d",
}
if !slices.Equal(got, want) {
t.Errorf("cleanOperands = %q, want %q", got, want)
}
}
// TestRunReportsRejectEmptyOperand checks that an empty PATH operand of
// report or trees, alone or after another operand, is a usage error
// reported in one line before the database is opened (README "Error
// handling and exit codes").
func TestRunReportsRejectEmptyOperand(t *testing.T) {
cases := map[string][]string{
"report": {cmdReport, ""},
"trees": {cmdTrees, ""},
"after an operand": {cmdTrees, t.TempDir(), ""},
}
for name, args := range cases {
t.Run(name, func(t *testing.T) {
// No database exists there, so opening one would be a fatal
// error, exit 1.
t.Setenv(databaseEnv, testDBPath(t))
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code != exitUsage {
t.Errorf("run(%q) = %d, want %d", args, code, exitUsage)
}
want := "Error: PATH must not be empty\n"
if stdout.String() != "" || stderr.String() != want {
t.Errorf("stdout, stderr = %q, %q, want \"\", %q",
stdout.String(), stderr.String(), want)
}
})
}
}
func TestRunReportEscapesPaths(t *testing.T) { func TestRunReportEscapesPaths(t *testing.T) {
dir, recs := recsOnDisk(t, awkwardPairRecs()) dir, recs := recsOnDisk(t, awkwardPairRecs())
t.Setenv(databaseEnv, seedDatabase(t, recs)) t.Setenv(databaseEnv, seedDatabase(t, recs))
@@ -195,7 +326,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
// Counts the records read, and draws nothing. // Counts the records read, and draws nothing.
prog := &progress{now: time.Now} prog := &progress{now: time.Now}
_, err = readReport(t.Context(), db, "", failingWriter{}, _, err = readReport(t.Context(), db, "", nil, failingWriter{},
everyPathExists, prog) everyPathExists, prog)
if !errors.Is(err, errWriteFailed) || if !errors.Is(err, errWriteFailed) ||
!strings.HasPrefix(err.Error(), "write stdout: ") { !strings.HasPrefix(err.Error(), "write stdout: ") {
@@ -292,7 +423,8 @@ func TestReadReportOnTerminal(t *testing.T) {
waitingFor: countingRecords, waitingFor: countingRecords,
}, terminalWidth, time.Hour, time.Now) }, terminalWidth, time.Hour, time.Now)
_, err = readReport(t.Context(), db, "", os.Stderr, everyPathExists, p) _, err = readReport(t.Context(), db, "", nil, os.Stderr,
everyPathExists, p)
p.finish() p.finish()
@@ -514,7 +646,7 @@ func TestReadReportChecksOnlyPossibleDuplicates(t *testing.T) {
return true return true
} }
_, err = readReport(t.Context(), db, "", io.Discard, exists, nil) _, err = readReport(t.Context(), db, "", nil, io.Discard, exists, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -548,7 +680,8 @@ func TestReadReportDropsGroupLeftWithOnePath(t *testing.T) {
exists := func(path string) bool { return path != "/a/gone" } exists := func(path string) bool { return path != "/a/gone" }
report, err := readReport(t.Context(), db, "", io.Discard, exists, nil) report, err := readReport(t.Context(), db, "", nil, io.Discard, exists,
nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -581,7 +714,7 @@ func TestReadReportKeepsGroupLeftWithTwoPaths(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
report, err := readReport(t.Context(), db, "", &out, exists, nil) report, err := readReport(t.Context(), db, "", nil, &out, exists, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1
View File
@@ -562,6 +562,7 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
s.st.walked++ s.st.walked++
prog.setPath(ev.rec.path)
prog.increment() prog.increment()
old, ok := s.existing[ev.rec.path] old, ok := s.existing[ev.rec.path]
+86 -25
View File
@@ -37,12 +37,14 @@ func treesPhase(name string) string {
// runTrees implements the trees subcommand: it reads every record from // runTrees implements the trees subcommand: it reads every record from
// the database in path order, reconstructs the directory hierarchy from // the database in path order, reconstructs the directory hierarchy from
// the record paths, computes a Merkle-style digest per directory, and // the paths of the records at or below the operands in args (of every
// prints maximal duplicate-tree groups as TSV on stdout. Of the scanned // record when args is empty), computes a Merkle-style digest per
// filesystem it only checks, with pathExists, that the root of each // directory, and prints maximal duplicate-tree groups as TSV on stdout.
// duplicate tree is still there, never the files below it. It names the // Of the scanned filesystem it only checks, with pathExists, that the
// database on stderr before it opens it. Any database problem, // root of each duplicate tree is still there, never the files below it;
// including a missing database, is fatal. // it never looks up an operand. It names the database on stderr before
// it opens it. Any database problem, including a missing database, is
// fatal.
// //
// The work runs in four phases, each under a progress display of its // The work runs in four phases, each under a progress display of its
// own on stderr: open (open the database), read (read the number of // own on stderr: open (open the database), read (read the number of
@@ -50,7 +52,12 @@ func treesPhase(name string) string {
// hierarchy), group (group the directories by digest) and write (write // hierarchy), group (group the directories by digest) and write (write
// the groups to stdout). Each display ends before the next phase // the groups to stdout). Each display ends before the next phase
// starts, and before an error is returned to be printed. // starts, and before an error is returned to be printed.
func runTrees(ctx context.Context, stdout io.Writer) error { func runTrees(ctx context.Context, args []string, stdout io.Writer) error {
operands, err := cleanOperands(args)
if err != nil {
return err
}
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
return err return err
@@ -79,7 +86,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
label: treesPhase("read"), unit: "records", total: -1, label: treesPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, stalledOn: "db", waitingFor: countingRecords, stalledOn: "db",
}) })
records, super, allDirs, err := readTree(ctx, db, prog) records, super, allDirs, err := readTree(ctx, db, operands, prog)
prog.finish() prog.finish()
@@ -101,7 +108,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)), label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
stalledOn: "out", stalledOn: "out",
}) })
dupeTrees, reclaimable, err := writeTreeGroups(stdout, dupes, prog) dupeTrees, err := writeTreeGroups(stdout, dupes, prog)
prog.finish() prog.finish()
@@ -112,16 +119,23 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
fmt.Fprintf(os.Stderr, fmt.Fprintf(os.Stderr,
"trees: %d records read, %d duplicate tree groups, %d dupe trees, "+ "trees: %d records read, %d duplicate tree groups, %d dupe trees, "+
"%s reclaimable\n", "%s reclaimable\n",
records, len(dupes), dupeTrees, humanBytes(reclaimable)) records, len(dupes), dupeTrees, humanBytes(reclaimableBytes(dupes)))
return nil return nil
} }
// readTree gives prog the number of records in db as its total, then // readTree gives prog the number of records in db as its total, then
// reads every record in path order into the directory hierarchy, // reads every record in path order, counting each on prog. It adds the
// counting each on prog. It returns the number of records read, the // records that underOperand finds under operands to the directory
// super-root and every directory. // hierarchy. It returns the number of records read, the super-root and
func readTree(ctx context.Context, db *sql.DB, // every directory at or below one of operands.
//
// Any other directory lies above an operand and can hold files that are
// not used, so its digest does not describe it. It is left out of the
// directories returned. A directory whose parent is left out gets the
// super-root as its parent, so suppressed never judges its group by
// that parent's digest.
func readTree(ctx context.Context, db *sql.DB, operands []string,
prog *progress, prog *progress,
) (int, *treeNode, []*treeNode, error) { ) (int, *treeNode, []*treeNode, error) {
records := 0 records := 0
@@ -131,7 +145,10 @@ func readTree(ctx context.Context, db *sql.DB,
records++ records++
prog.increment() prog.increment()
if underOperand(r.path, operands) {
tree.add(r) tree.add(r)
}
}) })
if err != nil { if err != nil {
return 0, nil, nil, err return 0, nil, nil, err
@@ -139,29 +156,40 @@ func readTree(ctx context.Context, db *sql.DB,
super, allDirs := tree.finish() super, allDirs := tree.finish()
return records, super, allDirs, nil dirs := allDirs[:0]
for _, d := range allDirs {
if !underOperand(d.path, operands) {
continue
}
if !underOperand(d.parent.path, operands) {
d.parent = super
}
dirs = append(dirs, d)
}
return records, super, dirs, nil
} }
// writeTreeGroups writes dupes to stdout as TSV, a header line and then // writeTreeGroups writes dupes to stdout as TSV, a header line and then
// a row for every member of each group but its first, counting each // a row for every member of each group but its first, counting each
// group on prog once its rows are written. The disk_size column is the // group on prog once its rows are written. The disk_size column is the
// dupe's own. stdout may be the terminal prog is drawn on, so the lines // dupe's own. stdout may be the terminal prog is drawn on, so the lines
// go through prog's writer. It returns the number of rows (dupe trees) // go through prog's writer. It returns the number of rows (dupe trees).
// and the bytes they could reclaim.
func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode, func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
prog *progress, prog *progress,
) (int, int64, error) { ) (int, error) {
out := prog.writer(stdout) out := prog.writer(stdout)
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize\tdisk_size") _, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize\tdisk_size")
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("write stdout: %w", err) return 0, fmt.Errorf("write stdout: %w", err)
} }
dupeTrees := 0 dupeTrees := 0
var reclaimable int64
for _, g := range dupes { for _, g := range dupes {
first := g[0] first := g[0]
for _, n := range g[1:] { for _, n := range g[1:] {
@@ -169,11 +197,10 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
escapePath(first.path), escapePath(n.path), escapePath(first.path), escapePath(n.path),
first.fileCount, first.totalSize, n.diskSize) first.fileCount, first.totalSize, n.diskSize)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("write stdout: %w", err) return 0, fmt.Errorf("write stdout: %w", err)
} }
dupeTrees++ dupeTrees++
reclaimable += first.totalSize
} }
prog.increment() prog.increment()
@@ -181,10 +208,44 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
err = out.Flush() err = out.Flush()
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("write stdout: %w", err) return 0, fmt.Errorf("write stdout: %w", err)
} }
return dupeTrees, reclaimable, nil return dupeTrees, nil
}
// reclaimableBytes returns the sum of the size column over the rows
// that writeTreeGroups writes for dupes, leaving out a row whose dupe
// lies inside another row's dupe.
func reclaimableBytes(dupes [][]*treeNode) int64 {
isDupe := make(map[*treeNode]bool)
for _, g := range dupes {
for _, n := range g[1:] {
isDupe[n] = true
}
}
var total int64
for n := range isDupe {
if !insideDupe(n, isDupe) {
total += n.totalSize
}
}
return total
}
// insideDupe reports whether a directory above n is in isDupe.
func insideDupe(n *treeNode, isDupe map[*treeNode]bool) bool {
for p := n.parent; p != nil; p = p.parent {
if isDupe[p] {
return true
}
}
return false
} }
// treeBuilder reconstructs the directory hierarchy from records added // treeBuilder reconstructs the directory hierarchy from records added
+113 -1
View File
@@ -214,6 +214,57 @@ func TestRunTreesEscapesPaths(t *testing.T) {
} }
} }
// TestRunTreesOperands runs trees with operands on records in a new
// directory. x/a and y/a are copies, and so are y/a and srv/b. x and y
// also hold files that differ. Given x/a and y/a, trees reads x and y
// as holding one copy each of a, yet neither is reported: only the
// directories at or below an operand are.
func TestRunTreesOperands(t *testing.T) {
f, g, k := testHash("f"), testHash("g"), testHash("k")
dir, recs := recsOnDisk(t, []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/srv/b/f"},
{size: 10, head: f, tail: f, content: f, path: "/x/a/f"},
{size: 20, head: g, tail: g, content: g, path: "/x/g"},
{size: 10, head: f, tail: f, content: f, path: "/y/a/f"},
{size: 30, head: k, tail: k, content: k, path: "/y/k"},
})
b, xa, ya := dir+"/srv/b", dir+"/x/a", dir+"/y/a"
header := "first\tdupe\tfiles\tsize\tdisk_size\n"
row := func(first, dupe string) string {
return first + "\t" + dupe + "\t1\t10\t0\n"
}
cases := map[string]struct {
operands []string
want string
}{
"none": {nil, header + row(b, xa) + row(b, ya)},
"two copies": {[]string{xa, ya}, header + row(xa, ya)},
"parents": {[]string{dir + "/x", dir + "/y"}, header + row(xa, ya)},
"one copy": {[]string{b}, header},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Setenv(databaseEnv, seedDatabase(t, recs))
var stdout, stderr bytes.Buffer
code := run(append([]string{cmdTrees}, tc.operands...),
&stdout, &stderr)
if code != exitOK {
t.Fatalf("run(trees %q) = %d, want %d; stderr: %s",
tc.operands, code, exitOK, stderr.String())
}
if got := stdout.String(); got != tc.want {
t.Errorf("stdout = %q, want %q", got, tc.want)
}
})
}
}
// TestRunTreesDiskSize runs trees on two copies of a tree whose files // TestRunTreesDiskSize runs trees on two copies of a tree whose files
// take different space on disk. The row's disk_size is the sum over the // take different space on disk. The row's disk_size is the sum over the
// dupe copy's files, those in its subdirectory included. // dupe copy's files, those in its subdirectory included.
@@ -313,7 +364,7 @@ func TestWriteTreeGroupsOnTerminal(t *testing.T) {
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)), label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
}, terminalWidth, time.Hour, time.Now) }, terminalWidth, time.Hour, time.Now)
_, _, err := writeTreeGroups(os.Stderr, dupes, p) _, err := writeTreeGroups(os.Stderr, dupes, p)
p.finish() p.finish()
@@ -587,3 +638,64 @@ func TestCollectTreeGroupsKeepsGroupLeftWithTwoCopies(t *testing.T) {
t.Errorf("groups = %v, want %v", got, want) t.Errorf("groups = %v, want %v", got, want)
} }
} }
// TestReclaimableBytesCountsNestedDupeOnce checks that a dupe tree
// inside another dupe tree adds nothing to the reclaimable bytes. /B/x
// lies in /B, so the total adds the sizes of /B and /C/x, 1500 + 1000.
func TestReclaimableBytesCountsNestedDupeOnce(t *testing.T) {
t.Parallel()
fh, ft, fc := testHash("fh"), testHash("ft"), testHash("fc")
gh, gt, gc := testHash("gh"), testHash("gt"), testHash("gc")
recs := []scanRec{
{size: 1000, head: fh, tail: ft, content: fc, path: "/A/x/f"},
{size: 500, head: gh, tail: gt, content: gc, path: "/A/y/g"},
{size: 1000, head: fh, tail: ft, content: fc, path: "/B/x/f"},
{size: 500, head: gh, tail: gt, content: gc, path: "/B/y/g"},
{size: 1000, head: fh, tail: ft, content: fc, path: "/C/x/f"},
}
super, dirs := treeOf(t, recs)
dupes := collectTreeGroups(dirs, super, everyPathExists, nil)
wantGroups := [][]string{{"/A", "/B"}, {"/A/x", "/B/x", "/C/x"}}
if got := groupPaths(dupes); !slices.EqualFunc(got, wantGroups,
slices.Equal) {
t.Fatalf("groups = %v, want %v", got, wantGroups)
}
if got := reclaimableBytes(dupes); got != 2500 {
t.Errorf("reclaimableBytes = %d, want 2500", got)
}
}
// TestReclaimableBytesCountsDupeInsideGrandparentDupeOnce checks that a
// dupe tree adds nothing when a dupe tree above its parent holds it.
// /B/m/x lies in /B, and /B/m is no dupe, since the group of /A and /B
// implies that of /A/m and /B/m. The total adds the sizes of /B and
// /C/x, 1300 + 1000.
func TestReclaimableBytesCountsDupeInsideGrandparentDupeOnce(t *testing.T) {
t.Parallel()
f, w := testHash("f"), testHash("w")
recs := []scanRec{
{size: 1000, head: f, tail: f, content: f, path: "/A/m/x/f"},
{size: 300, head: w, tail: w, content: w, path: "/A/m/w"},
{size: 1000, head: f, tail: f, content: f, path: "/B/m/x/f"},
{size: 300, head: w, tail: w, content: w, path: "/B/m/w"},
{size: 1000, head: f, tail: f, content: f, path: "/C/x/f"},
}
super, dirs := treeOf(t, recs)
dupes := collectTreeGroups(dirs, super, everyPathExists, nil)
wantGroups := [][]string{{"/A", "/B"}, {"/A/m/x", "/B/m/x", "/C/x"}}
if got := groupPaths(dupes); !slices.EqualFunc(got, wantGroups,
slices.Equal) {
t.Fatalf("groups = %v, want %v", got, wantGroups)
}
if got := reclaimableBytes(dupes); got != 2300 {
t.Errorf("reclaimableBytes = %d, want 2300", got)
}
}