Compare commits
7
Commits
be47255598
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e16c46804d | ||
|
|
317abb834c | ||
|
|
652df1c382 | ||
|
|
37893d2d5a | ||
|
|
95aac3d804 | ||
|
|
d4f90f43ff | ||
|
|
58d27a6df3 |
No files matched your search
@@ -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 or temporary directory the walk leaves out (see
|
- a line for each system, temporary or cache directory the walk leaves out
|
||||||
"Rules for the walk");
|
(see "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,8 +309,9 @@ 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 and temporary directories the walk otherwise
|
excludes. Those are the system, temporary and cache directories the walk
|
||||||
leaves out, such as `/var/tmp` on Linux (see "Rules for the walk").
|
otherwise leaves out, such as `/var/tmp` and `/var/cache` on Linux (see "Rules
|
||||||
|
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
|
||||||
@@ -553,7 +563,7 @@ walk practical on large or busy storage pools.
|
|||||||
`scan` prints a line on stderr naming the path and its filesystem type:
|
`scan` prints a line on stderr naming the path and its filesystem type:
|
||||||
|
|
||||||
```
|
```
|
||||||
scan: skipped /dev/shm (tmpfs, not a persistent filesystem)
|
scan: left out /dev/shm (tmpfs, not a persistent filesystem)
|
||||||
```
|
```
|
||||||
|
|
||||||
The path is not counted as skipped. The records below it are kept, as
|
The path is not counted as skipped. The records below it are kept, as
|
||||||
@@ -563,7 +573,7 @@ walk practical on large or busy storage pools.
|
|||||||
For each one it reaches, `scan` prints a line on stderr naming it:
|
For each one it reaches, `scan` prints a line on stderr naming it:
|
||||||
|
|
||||||
```
|
```
|
||||||
scan: skipped /srv/cache (--exclude)
|
scan: left out /srv/cache (--exclude)
|
||||||
```
|
```
|
||||||
|
|
||||||
The path is not counted as skipped. The scan keeps the records at and below
|
The path is not counted as skipped. The scan keeps the records at and below
|
||||||
@@ -571,12 +581,17 @@ 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 temporary files and the kernel's view of the running
|
default excludes hold the kernel's view of the running system, temporary
|
||||||
system. They depend on the system, and `sfdupes scan --help` lists those of
|
files, and caches. A cache here is data that programs and package managers
|
||||||
the system it runs on. On Linux they are:
|
download or build again on their own. The default excludes depend on the
|
||||||
|
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
|
/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
|
||||||
```
|
```
|
||||||
|
|
||||||
On macOS they are:
|
On macOS they are:
|
||||||
@@ -584,13 +599,21 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
A walk of `/` on macOS reaches everything in `/System/Volumes/Data` already,
|
`/private/var/folders` holds each user's temporary files and caches on
|
||||||
through firmlinks. Firmlinks are the links macOS uses to show the data
|
macOS. Root's home on macOS is `/var/root`, which the walk reaches as
|
||||||
volume inside the system volume. Walking `/System/Volumes/Data` as well
|
`/private/var/root`. A walk of `/` on macOS reaches everything in
|
||||||
would record each of those files twice. Any other system has no default
|
`/System/Volumes/Data` already, through firmlinks. Firmlinks are the links
|
||||||
excludes.
|
macOS uses to show the data volume inside the system volume. Walking
|
||||||
|
`/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.
|
||||||
@@ -599,7 +622,7 @@ walk practical on large or busy storage pools.
|
|||||||
not enter, `scan` prints a line on stderr naming it:
|
not enter, `scan` prints a line on stderr naming it:
|
||||||
|
|
||||||
```
|
```
|
||||||
scan: skipped /var/tmp (default exclude)
|
scan: left out /var/tmp (default exclude)
|
||||||
```
|
```
|
||||||
|
|
||||||
The directory is not counted as skipped. `scan` prints the filesystem line
|
The directory is not counted as skipped. `scan` prints the filesystem line
|
||||||
@@ -659,6 +682,18 @@ scan: 123400 files seen (1200 added, 34 updated, 56 removed, 122166 unchanged),
|
|||||||
|
|
||||||
`removed` counts deleted records, which are not part of the files seen.
|
`removed` counts deleted records, which are not part of the files seen.
|
||||||
|
|
||||||
|
The warning for a skipped path names the path once and gives the reason:
|
||||||
|
|
||||||
|
```
|
||||||
|
scan: skipped /srv/a/closed (permission denied)
|
||||||
|
scan: skipped /srv/a/pipe (a FIFO)
|
||||||
|
```
|
||||||
|
|
||||||
|
The summary counts each path named in such a warning. A line for a path left out
|
||||||
|
on purpose starts with `scan: left out` instead, and is not counted. A search of
|
||||||
|
the log for `scan: skipped` therefore finds the paths the summary counts. When a
|
||||||
|
file with several hard links cannot be read, each of its paths gets a warning.
|
||||||
|
|
||||||
#### Stopping a scan
|
#### Stopping a scan
|
||||||
|
|
||||||
`scan` stops cleanly on `SIGINT`, the signal Ctrl-C sends, or `SIGTERM`, the
|
`scan` stops cleanly on `SIGINT`, the signal Ctrl-C sends, or `SIGTERM`, the
|
||||||
@@ -686,11 +721,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.
|
||||||
@@ -722,6 +758,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
|
||||||
@@ -749,7 +806,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.
|
||||||
@@ -760,11 +818,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:
|
||||||
@@ -792,6 +851,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
|
||||||
@@ -845,10 +909,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
|
||||||
@@ -857,8 +923,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"):
|
||||||
|
|
||||||
@@ -937,7 +1005,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
|
||||||
@@ -977,9 +1060,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
|
||||||
@@ -1268,7 +1351,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
@@ -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:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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/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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+147
-33
@@ -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},
|
||||||
@@ -996,8 +1013,7 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
|
|||||||
|
|
||||||
stderr := scanOK(t, dir)
|
stderr := scanOK(t, dir)
|
||||||
|
|
||||||
warning := "hash " + unreadable + ": open " + unreadable +
|
warning := "scan: skipped " + unreadable + " (permission denied)\n"
|
||||||
": permission denied\n"
|
|
||||||
if !strings.Contains(stderr, warning) {
|
if !strings.Contains(stderr, warning) {
|
||||||
t.Errorf("stderr = %q, want %q", stderr, warning)
|
t.Errorf("stderr = %q, want %q", stderr, warning)
|
||||||
}
|
}
|
||||||
@@ -1269,7 +1285,7 @@ func TestRunScanSkipsSymlinkToFIFOOperand(t *testing.T) {
|
|||||||
|
|
||||||
stderr := scanOK(t, link)
|
stderr := scanOK(t, link)
|
||||||
|
|
||||||
warning := "walk " + fifo + ": skipping FIFO operand\n"
|
warning := "scan: skipped " + fifo + " (a FIFO)\n"
|
||||||
if !strings.Contains(stderr, warning) {
|
if !strings.Contains(stderr, warning) {
|
||||||
t.Errorf("stderr = %q, want %q", stderr, warning)
|
t.Errorf("stderr = %q, want %q", stderr, warning)
|
||||||
}
|
}
|
||||||
@@ -1304,7 +1320,7 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
|||||||
|
|
||||||
stderr := scanOK(t, operand)
|
stderr := scanOK(t, operand)
|
||||||
|
|
||||||
warning := "walk " + operand + ": skipping " + kind + " operand\n"
|
warning := "scan: skipped " + operand + " (a " + kind + ")\n"
|
||||||
if !strings.Contains(stderr, warning) {
|
if !strings.Contains(stderr, warning) {
|
||||||
t.Errorf("stderr = %q, want %q", stderr, warning)
|
t.Errorf("stderr = %q, want %q", stderr, warning)
|
||||||
}
|
}
|
||||||
@@ -1338,7 +1354,7 @@ func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
|
|||||||
writeFile(t, hourly, "f", pattern(1, 10))
|
writeFile(t, hourly, "f", pattern(1, 10))
|
||||||
f := writeFile(t, dir, "f", pattern(2, 10))
|
f := writeFile(t, dir, "f", pattern(2, 10))
|
||||||
|
|
||||||
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
|
warning := "scan: skipped " + hourly + " (inside a .zfs directory)\n"
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
operands []string
|
operands []string
|
||||||
@@ -1390,7 +1406,7 @@ func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
|
|||||||
stderr := scanOK(t, filepath.Join(snaps, "daily"))
|
stderr := scanOK(t, filepath.Join(snaps, "daily"))
|
||||||
|
|
||||||
daily := filepath.Join(dir, "pool", ".zfs", "snapshot", "daily")
|
daily := filepath.Join(dir, "pool", ".zfs", "snapshot", "daily")
|
||||||
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
|
warning := "scan: skipped " + daily + " (inside a .zfs directory)\n"
|
||||||
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||||
"0 unchanged), 1 skipped\n"
|
"0 unchanged), 1 skipped\n"
|
||||||
|
|
||||||
@@ -1609,6 +1625,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 +1949,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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ func TestRunDwimExcludeLeavesTreeOut(t *testing.T) {
|
|||||||
t.Errorf("group = %q, want it to start %q", lines[1], want)
|
t.Errorf("group = %q, want it to start %q", lines[1], want)
|
||||||
}
|
}
|
||||||
|
|
||||||
skipped := "scan: skipped " + excluded + " (--exclude)\n"
|
leftOut := "scan: left out " + excluded + " (--exclude)\n"
|
||||||
if got := stderr(); !strings.Contains(got, skipped) {
|
if got := stderr(); !strings.Contains(got, leftOut) {
|
||||||
t.Errorf("stderr = %q, want it to contain %q", got, skipped)
|
t.Errorf("stderr = %q, want it to contain %q", got, leftOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,14 +72,31 @@ type walkRules struct {
|
|||||||
func defaultExcludes(goos string) []string {
|
func defaultExcludes(goos string) []string {
|
||||||
switch goos {
|
switch goos {
|
||||||
case goosLinux:
|
case goosLinux:
|
||||||
return []string{"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"}
|
// The kernel's view of the running system, temporary files, and
|
||||||
|
// 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.
|
// those files twice. /private/var/folders holds each user's
|
||||||
|
// 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
|
||||||
@@ -186,7 +203,7 @@ func (rules walkRules) excludePathLine(path string, fi fs.FileInfo) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("scan: skipped %s (--exclude)", path)
|
return fmt.Sprintf("scan: left out %s (--exclude)", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// nonPersistentFilesystemTypes returns the filesystem types that are
|
// nonPersistentFilesystemTypes returns the filesystem types that are
|
||||||
@@ -216,7 +233,7 @@ func (rules walkRules) nonPersistentLine(path string, fi fs.FileInfo) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("scan: skipped %s (%s, not a persistent filesystem)",
|
return fmt.Sprintf("scan: left out %s (%s, not a persistent filesystem)",
|
||||||
path, fsType)
|
path, fsType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,22 @@ 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: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
|
goosLinux: {
|
||||||
|
"/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",
|
||||||
@@ -63,6 +75,29 @@ 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) {
|
||||||
@@ -207,7 +242,7 @@ func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
|
|||||||
t.Errorf("%s recorded: the walk entered %s", unseen, excluded)
|
t.Errorf("%s recorded: the walk entered %s", unseen, excluded)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := "\nscan: skipped " + excluded + " (default exclude)\n"
|
line := "\nscan: left out " + excluded + " (default exclude)\n"
|
||||||
if !strings.Contains(stderr(), line) {
|
if !strings.Contains(stderr(), line) {
|
||||||
t.Errorf("stderr = %q, want %q", stderr(), line)
|
t.Errorf("stderr = %q, want %q", stderr(), line)
|
||||||
}
|
}
|
||||||
@@ -276,7 +311,7 @@ func TestScanNamesNoDefaultExcludeThatIsAnOperand(t *testing.T) {
|
|||||||
|
|
||||||
recordByPath(t, dbRecords(t, db), f)
|
recordByPath(t, dbRecords(t, db), f)
|
||||||
|
|
||||||
if strings.Contains(stderr(), "scan: skipped") {
|
if strings.Contains(stderr(), "scan: left out") {
|
||||||
t.Errorf("stderr = %q, want no line for %s", stderr(), excluded)
|
t.Errorf("stderr = %q, want no line for %s", stderr(), excluded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,15 +325,15 @@ func TestRunScanTempDirOperand(t *testing.T) {
|
|||||||
t.Setenv(databaseEnv, path)
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
dir := realTempDir(t)
|
dir := realTempDir(t)
|
||||||
if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) {
|
if !insideDefaultExclude(dir, defaultExcludes(runtime.GOOS)) {
|
||||||
t.Skip(dir + " is not inside a default exclude")
|
t.Skip(dir + " is not inside a default exclude")
|
||||||
}
|
}
|
||||||
|
|
||||||
f := writeFile(t, dir, "sub/f", pattern(1, 10))
|
f := writeFile(t, dir, "sub/f", pattern(1, 10))
|
||||||
|
|
||||||
stderr := scanOK(t, dir)
|
stderr := scanOK(t, dir)
|
||||||
if strings.Contains(stderr, "scan: skipped") {
|
if strings.Contains(stderr, "scan: left out") {
|
||||||
t.Errorf("stderr = %q, want no line saying a path was skipped",
|
t.Errorf("stderr = %q, want no line saying a path was left out",
|
||||||
stderr)
|
stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +377,7 @@ func TestRunScanNoDefaultExcludes(t *testing.T) {
|
|||||||
path := testDBPath(t)
|
path := testDBPath(t)
|
||||||
t.Setenv(databaseEnv, path)
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
line := "scan: skipped /var/tmp (default exclude)\n"
|
line := "scan: left out /var/tmp (default exclude)\n"
|
||||||
|
|
||||||
if stderr := scanOK(t, "/var"); !strings.Contains(stderr, line) {
|
if stderr := scanOK(t, "/var"); !strings.Contains(stderr, line) {
|
||||||
t.Errorf("scan /var: stderr = %q, want %q", stderr, line)
|
t.Errorf("scan /var: stderr = %q, want %q", stderr, line)
|
||||||
@@ -383,7 +418,7 @@ func TestRunScanExcludeNested(t *testing.T) {
|
|||||||
|
|
||||||
stderr := scanOK(t, excludeFlag, excluded, dir)
|
stderr := scanOK(t, excludeFlag, excluded, dir)
|
||||||
|
|
||||||
line := "scan: skipped " + excluded + " (--exclude)\n"
|
line := "scan: left out " + excluded + " (--exclude)\n"
|
||||||
if !strings.Contains(stderr, line) {
|
if !strings.Contains(stderr, line) {
|
||||||
t.Errorf("stderr = %q, want %q", stderr, line)
|
t.Errorf("stderr = %q, want %q", stderr, line)
|
||||||
}
|
}
|
||||||
@@ -445,8 +480,8 @@ func TestRunScanExcludeReachedByAnotherPath(t *testing.T) {
|
|||||||
stderr := scanOK(t, excludeFlag, filepath.Join(link, "cache"),
|
stderr := scanOK(t, excludeFlag, filepath.Join(link, "cache"),
|
||||||
excludeFlag, dotDot, dir)
|
excludeFlag, dotDot, dir)
|
||||||
|
|
||||||
for _, skipped := range []string{filepath.Dir(inDir), file, hardLink} {
|
for _, leftOut := range []string{filepath.Dir(inDir), file, hardLink} {
|
||||||
line := "scan: skipped " + skipped + " (--exclude)\n"
|
line := "scan: left out " + leftOut + " (--exclude)\n"
|
||||||
if !strings.Contains(stderr, line) {
|
if !strings.Contains(stderr, line) {
|
||||||
t.Errorf("stderr = %q, want %q", stderr, line)
|
t.Errorf("stderr = %q, want %q", stderr, line)
|
||||||
}
|
}
|
||||||
@@ -580,7 +615,7 @@ func TestRunScanSkipsNonPersistentOperand(t *testing.T) {
|
|||||||
|
|
||||||
t.Setenv(databaseEnv, testDBPath(t))
|
t.Setenv(databaseEnv, testDBPath(t))
|
||||||
|
|
||||||
line := "scan: skipped " + shm + " (" + fsType +
|
line := "scan: left out " + shm + " (" + fsType +
|
||||||
", not a persistent filesystem)\n"
|
", not a persistent filesystem)\n"
|
||||||
summary := "scan: 0 files seen"
|
summary := "scan: 0 files seen"
|
||||||
|
|
||||||
@@ -634,7 +669,7 @@ func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
|
|||||||
t.Errorf("%s recorded", unseen)
|
t.Errorf("%s recorded", unseen)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := "scan: skipped " + shm + " (" + fsType +
|
line := "scan: left out " + shm + " (" + fsType +
|
||||||
", not a persistent filesystem)\n"
|
", not a persistent filesystem)\n"
|
||||||
if !strings.Contains(stderr(), line) {
|
if !strings.Contains(stderr(), line) {
|
||||||
t.Errorf("stderr = %q, want %q", stderr(), line)
|
t.Errorf("stderr = %q, want %q", stderr(), line)
|
||||||
@@ -667,7 +702,7 @@ func TestWalkLeavesOutNonPersistent(t *testing.T) {
|
|||||||
|
|
||||||
leftOut = append(leftOut, ev.leftOut)
|
leftOut = append(leftOut, ev.leftOut)
|
||||||
|
|
||||||
want := "scan: skipped " + ev.leftOut + " (" + fsType +
|
want := "scan: left out " + ev.leftOut + " (" + fsType +
|
||||||
", not a persistent filesystem)"
|
", not a persistent filesystem)"
|
||||||
if ev.warn != want {
|
if ev.warn != want {
|
||||||
t.Errorf("line = %q, want %q", ev.warn, want)
|
t.Errorf("line = %q, want %q", ev.warn, want)
|
||||||
@@ -711,6 +746,20 @@ 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) {
|
||||||
|
|||||||
+70
-14
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-12
@@ -664,8 +664,8 @@ func isDatabaseFile(f fileRec, rules walkRules) bool {
|
|||||||
// inode run, committing completed records to the database in batches
|
// inode run, committing completed records to the database in batches
|
||||||
// as results arrive, so a long scan persists its progress as it goes
|
// as results arrive, so a long scan persists its progress as it goes
|
||||||
// (an interrupted scan resumes cheaply: the next run skips everything
|
// (an interrupted scan resumes cheaply: the next run skips everything
|
||||||
// already recorded). A run that fails to hash is warned about and
|
// already recorded). Each path of a run that fails to hash is warned
|
||||||
// counted as skipped; stale records for its paths, if any, are deleted
|
// about and counted as skipped; stale records for them, if any, are deleted
|
||||||
// by the update phase. A run whose file no longer exists gets no record
|
// by the update phase. A run whose file no longer exists gets no record
|
||||||
// and no warning, and is not counted as skipped. The total counts actual
|
// and no warning, and is not counted as skipped. The total counts actual
|
||||||
// reads, so the display's time remaining is meaningful.
|
// reads, so the display's time remaining is meaningful.
|
||||||
@@ -706,9 +706,12 @@ func (s *scanState) hashPhase(ctx context.Context, workers int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
s.st.skipped += len(r.run)
|
// Every path in the run is counted, so every path gets a line.
|
||||||
|
for _, rec := range r.run {
|
||||||
|
s.st.skipped++
|
||||||
|
|
||||||
prog.warnf("hash %s: %v", r.run[0].path, r.err)
|
prog.warnf("%s", skippedLine(rec.path, r.err))
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -915,8 +918,7 @@ func operandWarning(root string, fi fs.FileInfo) string {
|
|||||||
// The walk never enters a .zfs directory, so it starts from nothing
|
// The walk never enters a .zfs directory, so it starts from nothing
|
||||||
// inside one either.
|
// inside one either.
|
||||||
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") {
|
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") {
|
||||||
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
|
return fmt.Sprintf("scan: skipped %s (inside a .zfs directory)", root)
|
||||||
root)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var kind string
|
var kind string
|
||||||
@@ -942,7 +944,21 @@ func operandWarning(root string, fi fs.FileInfo) string {
|
|||||||
kind = "non-regular file"
|
kind = "non-regular file"
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("walk %s: skipping %s operand", root, kind)
|
return fmt.Sprintf("scan: skipped %s (a %s)", root, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// skippedLine returns the warning for a path the scan skips because of
|
||||||
|
// err. The reason is the error without its path, which the line already
|
||||||
|
// names.
|
||||||
|
func skippedLine(path string, err error) string {
|
||||||
|
reason := err.Error()
|
||||||
|
|
||||||
|
var pathErr *fs.PathError
|
||||||
|
if errors.As(err, &pathErr) {
|
||||||
|
reason = pathErr.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("scan: skipped %s (%s)", path, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedRoot turns one PATH operand into the walk's starting state: a
|
// seedRoot turns one PATH operand into the walk's starting state: a
|
||||||
@@ -965,7 +981,7 @@ func seedRoot(ctx context.Context, root string, rules walkRules,
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(ctx, events, walkEvent{
|
sendEvent(ctx, events, walkEvent{
|
||||||
warn: fmt.Sprintf("walk %s: %v", root, err),
|
warn: skippedLine(root, err),
|
||||||
fail: true,
|
fail: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1094,7 +1110,7 @@ func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(ctx, events, walkEvent{
|
sendEvent(ctx, events, walkEvent{
|
||||||
warn: fmt.Sprintf("walk %s: %v", job.path, err),
|
warn: skippedLine(job.path, err),
|
||||||
fail: true,
|
fail: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1153,7 +1169,7 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(ctx, events, walkEvent{
|
sendEvent(ctx, events, walkEvent{
|
||||||
warn: fmt.Sprintf("stat %s: %v", p, err),
|
warn: skippedLine(p, err),
|
||||||
fail: true,
|
fail: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1214,7 +1230,7 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(ctx, events, walkEvent{
|
sendEvent(ctx, events, walkEvent{
|
||||||
warn: fmt.Sprintf("walk %s: %v", p, err),
|
warn: skippedLine(p, err),
|
||||||
fail: true,
|
fail: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1235,7 +1251,7 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
if isDefaultExclude(p, rules.defaultExcludes) {
|
if isDefaultExclude(p, rules.defaultExcludes) {
|
||||||
sendEvent(ctx, events, walkEvent{
|
sendEvent(ctx, events, walkEvent{
|
||||||
leftOut: p,
|
leftOut: p,
|
||||||
warn: fmt.Sprintf("scan: skipped %s (default exclude)", p),
|
warn: fmt.Sprintf("scan: left out %s (default exclude)", p),
|
||||||
})
|
})
|
||||||
|
|
||||||
return dirJob{}, false
|
return dirJob{}, false
|
||||||
|
|||||||
@@ -752,8 +752,9 @@ func TestWalkPassesOverRemovedPaths(t *testing.T) {
|
|||||||
|
|
||||||
// TestScanWarnsAboutUnreadablePaths checks that a permission error is
|
// TestScanWarnsAboutUnreadablePaths checks that a permission error is
|
||||||
// still warned about and counted as skipped, unlike a removed path. The
|
// still warned about and counted as skipped, unlike a removed path. The
|
||||||
// walk reaches an unreadable directory, and a file in a directory that
|
// walk reaches an unreadable directory. It also reaches a file and a
|
||||||
// can be read but not searched. Another such file is an operand.
|
// subdirectory in a directory that can be read but not searched. Another
|
||||||
|
// such file is an operand.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
func TestScanWarnsAboutUnreadablePaths(t *testing.T) {
|
func TestScanWarnsAboutUnreadablePaths(t *testing.T) {
|
||||||
@@ -765,6 +766,7 @@ func TestScanWarnsAboutUnreadablePaths(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
tree := filepath.Join(dir, "tree")
|
tree := filepath.Join(dir, "tree")
|
||||||
walked := writeFile(t, tree, "unsearchable/f", []byte("x"))
|
walked := writeFile(t, tree, "unsearchable/f", []byte("x"))
|
||||||
|
subdir := filepath.Join(filepath.Dir(walked), "d")
|
||||||
operand := writeFile(t, dir, "unsearchable/f", []byte("y"))
|
operand := writeFile(t, dir, "unsearchable/f", []byte("y"))
|
||||||
unreadable := filepath.Join(tree, "unreadable")
|
unreadable := filepath.Join(tree, "unreadable")
|
||||||
|
|
||||||
@@ -773,6 +775,11 @@ func TestScanWarnsAboutUnreadablePaths(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = os.Mkdir(subdir, 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
modes := map[string]fs.FileMode{
|
modes := map[string]fs.FileMode{
|
||||||
unreadable: 0,
|
unreadable: 0,
|
||||||
filepath.Dir(walked): 0o400,
|
filepath.Dir(walked): 0o400,
|
||||||
@@ -793,15 +800,16 @@ func TestScanWarnsAboutUnreadablePaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
st := syncTree(t, openTestDB(t), tree, operand)
|
st := syncTree(t, openTestDB(t), tree, operand)
|
||||||
if st != (scanStats{skipped: 3}) {
|
if st != (scanStats{skipped: 4}) {
|
||||||
t.Errorf("stats = %+v, want 3 skipped and nothing else", st)
|
t.Errorf("stats = %+v, want 4 skipped and nothing else", st)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := stderr()
|
got := stderr()
|
||||||
for _, warning := range []string{
|
for _, warning := range []string{
|
||||||
"walk " + unreadable + ": open " + unreadable + ": permission denied\n",
|
"scan: skipped " + unreadable + " (permission denied)\n",
|
||||||
"stat " + walked + ": lstat " + walked + ": permission denied\n",
|
"scan: skipped " + walked + " (permission denied)\n",
|
||||||
"walk " + operand + ": lstat " + operand + ": permission denied\n",
|
"scan: skipped " + subdir + " (permission denied)\n",
|
||||||
|
"scan: skipped " + operand + " (permission denied)\n",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(got, warning) {
|
if !strings.Contains(got, warning) {
|
||||||
t.Errorf("stderr = %q, want %q", got, warning)
|
t.Errorf("stderr = %q, want %q", got, warning)
|
||||||
@@ -1941,14 +1949,19 @@ func TestScanHardlinksReadOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScanHardlinkRunFailsTogether makes a file with two hard links
|
||||||
|
// unreadable. Both paths are counted as skipped, and each gets its own
|
||||||
|
// warning.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
func TestScanHardlinkRunFailsTogether(t *testing.T) {
|
func TestScanHardlinkRunFailsTogether(t *testing.T) {
|
||||||
t.Parallel()
|
stderr := captureStderr(t)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
a := writeFile(t, dir, "a.bin", pattern(1, 300))
|
a := writeFile(t, dir, "a.bin", pattern(1, 300))
|
||||||
|
b := filepath.Join(dir, "b.bin")
|
||||||
|
|
||||||
err := os.Link(a, filepath.Join(dir, "b.bin"))
|
err := os.Link(a, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1964,6 +1977,15 @@ func TestScanHardlinkRunFailsTogether(t *testing.T) {
|
|||||||
if st.skipped != 2 || st.added != 0 {
|
if st.skipped != 2 || st.added != 0 {
|
||||||
t.Fatalf("stats = %+v, want both hardlink paths skipped", st)
|
t.Fatalf("stats = %+v, want both hardlink paths skipped", st)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
got := stderr()
|
||||||
|
|
||||||
|
for _, p := range []string{a, b} {
|
||||||
|
warning := "scan: skipped " + p + " (permission denied)\n"
|
||||||
|
if !strings.Contains(got, warning) {
|
||||||
|
t.Errorf("stderr = %q, want %q", got, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHashPhaseSkipsFileReplacedByFIFO replaces a walked regular file
|
// TestHashPhaseSkipsFileReplacedByFIFO replaces a walked regular file
|
||||||
@@ -2001,7 +2023,7 @@ func TestHashPhaseSkipsFileReplacedByFIFO(t *testing.T) {
|
|||||||
s.st.skipped, len(s.batch))
|
s.st.skipped, len(s.batch))
|
||||||
}
|
}
|
||||||
|
|
||||||
warning := "hash " + p + ": not a regular file\n"
|
warning := "scan: skipped " + p + " (not a regular file)\n"
|
||||||
if got := stderr(); strings.Count(got, warning) != 1 {
|
if got := stderr(); strings.Count(got, warning) != 1 {
|
||||||
t.Errorf("stderr = %q, want %q once", got, warning)
|
t.Errorf("stderr = %q, want %q once", got, warning)
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-25
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user