Compare commits

..
Author SHA1 Message Date
sneak 9e6e3b9287 Say in --help where the database is and what each report prints (closes #177)
check / check (push) Successful in 3m4s
The help of sfdupes and of scan names the database, its default path
and SFDUPES_DATABASE. The help of report and trees lists the columns,
the order of the rows and the four escapes. The help of info lists its
lines. The help of dwim says that it scans /, should run as root and
keeps the scan in the database.

The README's opening drops its sentence on copies deleted after the
scan, an edge case that its report and trees sections already cover.

Model: opus-5-5
2026-11-11 05:57:25 +00:00
9 changed files with 196 additions and 281 deletions

No files matched your search

+19 -35
View File
@@ -22,14 +22,14 @@ records the files it finds in an SQLite database. SQLite keeps a whole database
in one file. The database is kept between runs. A rescan reads only new and in one file. The database is kept between runs. A rescan reads only new and
changed files. That makes `scan` cheap enough to run every day from `cron`, the changed files. That makes `scan` cheap enough to run every day from `cron`, the
system's job scheduler. `report`, `trees` and `info` work from the database and system's job scheduler. `report`, `trees` and `info` work from the database and
can run at any time. `report` and `trees` leave out a copy that was deleted can run at any time. `dwim` scans `/` and then prints the `trees` report, for a
after the scan. `dwim` scans `/` and then prints the `trees` report, for a first first look at a new machine with one command.
look at a new machine with one command.
## Getting Started ## Getting Started
```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
@@ -45,10 +45,8 @@ 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. `info` prints the database's path, size and record count.
Root keeps the database in `/var/lib/sfdupes/db.sqlite`. Any other user keeps it The database is `/var/lib/sfdupes/db.sqlite` unless `SFDUPES_DATABASE` names
in `sfdupes/db.sqlite` in the XDG state directory, `~/.local/state` by default another file. The first `scan` creates it.
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
@@ -150,9 +148,8 @@ 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. Another user can then run those three commands against root's by every user. An unprivileged user can then run those three commands against
database, with `SFDUPES_DATABASE=/var/lib/sfdupes/db.sqlite` before the root's database.
command.
### Reading the reports ### Reading the reports
@@ -292,9 +289,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 at its default path (see "Location"). There is no command-line database is `/var/lib/sfdupes/db.sqlite`. There is no command-line flag for
flag for it. The path names the file exactly, whatever characters it holds. A it. The path names the file exactly, whatever characters it holds. A relative
relative path is relative to the working directory. path is relative to the working directory.
- `--workers N` (`scan`) sets how many directories `scan` reads at once while it - `--workers N` (`scan`) 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 least 1. A hashes them. The default is the number of CPUs. `N` must be at least 1. A
@@ -319,9 +316,11 @@ The reference below is also the program's specification.
- `--version`, `-v` prints `sfdupes VERSION` (see "Install") to stdout and - `--version`, `-v` prints `sfdupes VERSION` (see "Install") to stdout and
exits 0. It writes nothing to stderr. exits 0. It writes nothing to stderr.
- `--help`, `-h`, alone or after a subcommand, prints the help text to stderr - `--help`, `-h`, alone or after a subcommand, prints the help text to stderr
and exits 0. It writes nothing to stdout. `sfdupes scan --help` also lists the and exits 0. It writes nothing to stdout. The help of `sfdupes` and of `scan`
default excludes of the system it runs on, and the filesystem types the walk names the database. The help of `report`, `trees`, `info` and `dwim` says what
never records anything on. each prints. `sfdupes scan --help` also lists the default excludes of the
system it runs on, and the filesystem types the walk never records anything
on.
### Duplicate detection ### Duplicate detection
@@ -928,22 +927,7 @@ 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. Otherwise it is at its default path, which depends on the user: empty, and otherwise `/var/lib/sfdupes/db.sqlite`.
- 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
@@ -983,9 +967,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`. For root, by default, database's path, with symlinks resolved, plus `.lock`. By default that is
that is `/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the `/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the same
same lock file. 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
+5 -5
View File
@@ -29,11 +29,11 @@
Newest first. An entry ends with its date and its issue or branch, where those Newest first. An entry ends with its date and its issue or branch, where those
are known. are known.
- Without `SFDUPES_DATABASE`, a user other than root keeps the database in - The help of `sfdupes` and of `scan` names the database, its default path and
`sfdupes/db.sqlite` under the XDG state directory, `~/.local/state` by `SFDUPES_DATABASE`. The help of `report` and `trees` lists the columns, the
default. Root keeps `/var/lib/sfdupes/db.sqlite`. `sfdupes --help` says where order of the rows and the escapes. The help of `info` lists its lines. The
the database is (2026-11-11, https://git.eeqj.de/sneak/sfdupes/issues/171) help of `dwim` says that it scans `/`, should run as root and keeps the scan
in the database (2026-11-11, https://git.eeqj.de/sneak/sfdupes/issues/177)
- New `scan` flag `--exclude PATH`, which may be repeated. The walk neither - New `scan` flag `--exclude PATH`, which may be repeated. The walk neither
enters nor records an excluded path, and keeps the records below it. An enters nor records an excluded path, and keeps the records below it. An
operand at or below one is a fatal error (2026-11-11, operand at or below one is a fatal error (2026-11-11,
+2 -4
View File
@@ -5,10 +5,8 @@
// 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 that the other subcommands read. The // database of file signatures (SFDUPES_DATABASE, default
// database is SFDUPES_DATABASE, or by default /var/lib/sfdupes/db.sqlite // /var/lib/sfdupes/db.sqlite) that the other subcommands read.
// for root and sfdupes/db.sqlite in the XDG state directory for any
// other user.
// //
// Usage: // Usage:
// //
-1
View File
@@ -3,7 +3,6 @@ module sneak.berlin/go/sfdupes
go 1.25.7 go 1.25.7
require ( require (
github.com/adrg/xdg v0.5.3
github.com/schollz/progressbar/v3 v3.19.1 github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.46.0 golang.org/x/sys v0.46.0
-2
View File
@@ -1,5 +1,3 @@
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=
+76 -17
View File
@@ -32,6 +32,69 @@ const (
cmdDwim = "dwim" cmdDwim = "dwim"
) )
// databaseHelp is the paragraph of the root and scan help that names
// the database (README "Location").
const databaseHelp = "scan and dwim store what they find in a database " +
"file. report, trees\nand info read it. The file is $" + databaseEnv +
" when that is set and\nnot empty, and otherwise " +
defaultDatabasePath + ", where creating it\nneeds root.\n"
// escapesHelp is the paragraph of the report and trees help on how
// paths are escaped (README "Report output format").
const escapesHelp = `Four characters in a path are escaped, so each row stays one line:
\\ a backslash
\t a tab
\n a newline
\r a carriage return
`
// reportHelp, treesHelp, infoHelp and dwimHelp follow the one-line
// summary in the help of their subcommands.
const (
reportHelp = `The report is tab-separated text on stdout: a header line, then one
row per duplicate file. The columns are:
first the path that sorts first, byte by byte, in its group of copies
dupe another copy of that file
size the file's size in bytes
Rows are ordered by size, largest first, then by path.
` + escapesHelp
treesHelp = `The report is tab-separated text on stdout: a header line, then one
row per duplicate directory tree. The columns are:
first the path that sorts first, byte by byte, in its group of copies
dupe another copy of that tree
files the number of regular files in one copy
size their total size in bytes
disk_size the bytes the dupe copy takes on disk
Rows are ordered by size, largest first, then by path.
` + escapesHelp
infoHelp = `The details go to stdout, one per line. Each line holds a name and its
value, separated by a tab:
database the path, and where it came from
database file size in bytes, then rounded, as 2147483648 (2.0 GiB)
-wal file size in bytes, then rounded; only if a -wal file exists
modified when the database file last changed, in UTC
schema version the version of the database's layout
records the number of records
size of recorded files their total size, in bytes, then rounded
`
dwimHelp = `dwim runs what "sfdupes scan /" runs, then what "sfdupes trees" runs.
Run it as root, so that the scan can read every file. The scan is stored
in the database, as scan stores it, so "sfdupes trees" prints the report
again later.
`
)
// errNoSubcommand is returned by the root command when it is invoked // errNoSubcommand is returned by the root command when it is invoked
// without a subcommand. That is a usage error, and the usage text // without a subcommand. That is a usage error, and the usage text
// cobra prints for it is the whole message. // cobra prints for it is the whole message.
@@ -122,14 +185,6 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
} }
// databaseHelp says in sfdupes --help where the database is.
const databaseHelp = `The database is the file SFDUPES_DATABASE names.
When that is unset or empty, root uses /var/lib/sfdupes/db.sqlite. Any
other 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.`
// newRootCommand builds the command tree. Everything on stdout is // newRootCommand builds the command tree. Everything on stdout is
// machine-readable data, the version line included; all human-facing // machine-readable data, the version line included; all human-facing
// output (help, usage, errors) goes to stderr. // output (help, usage, errors) goes to stderr.
@@ -148,12 +203,9 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
return nil return nil
}) })
short := "Find candidate duplicate files by size and head/tail/content SHA-256"
root := &cobra.Command{ root := &cobra.Command{
Use: "sfdupes", Use: "sfdupes",
Short: short, Short: "Find candidate duplicate files by size and head/tail/content SHA-256",
Long: short + ".\n\n" + databaseHelp,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if showVersion { if showVersion {
@@ -213,15 +265,21 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
}), }),
} }
root.Long = root.Short + ".\n\n" + databaseHelp
reportCmd.Long = reportCmd.Short + ".\n\n" + reportHelp
treesCmd.Long = treesCmd.Short + ".\n\n" + treesHelp
infoCmd.Long = infoCmd.Short + ".\n\n" + infoHelp
dwimCmd.Long = dwimCmd.Short + ".\n\n" + dwimHelp
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd, dwimCmd) root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd, dwimCmd)
return root return root
} }
// newScanCommand builds the scan subcommand, with its --workers, -x, // newScanCommand builds the scan subcommand, with its --workers, -x,
// --no-default-excludes and --exclude flags. Its help lists the default // --no-default-excludes and --exclude flags. Its help names the
// excludes of the system it runs on, and the filesystem types it never // database, and lists the default excludes of the system it runs on and
// records anything on. // the filesystem types it never records anything on.
func newScanCommand() *cobra.Command { func newScanCommand() *cobra.Command {
var ( var (
scanWorkers int scanWorkers int
@@ -239,8 +297,9 @@ func newScanCommand() *cobra.Command {
// append its generic "[flags]" to it. // append its generic "[flags]" to it.
DisableFlagsInUseLine: true, DisableFlagsInUseLine: true,
Short: short, Short: short,
Long: short + ".\n\n" + defaultExcludesHelp(runtime.GOOS), Long: short + ".\n\n" + databaseHelp + "\n" +
Args: cobra.MinimumNArgs(1), defaultExcludesHelp(runtime.GOOS),
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, _ []string) error { PreRunE: func(cmd *cobra.Command, _ []string) error {
err := checkScanWorkers(cmd, scanWorkers) err := checkScanWorkers(cmd, scanWorkers)
if err != nil { if err != nil {
+78 -98
View File
@@ -15,7 +15,6 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/adrg/xdg"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
@@ -23,6 +22,9 @@ import (
// error prints and a runtime failure must not. // error prints and a runtime failure must not.
const usageMarker = "Usage:" const usageMarker = "Usage:"
// helpFlag asks for the help text.
const helpFlag = "--help"
// TestMain stops the tests at once, saying why, when their temporary // TestMain stops the tests at once, saying why, when their temporary
// directory is not on a persistent filesystem: scan records nothing // directory is not on a persistent filesystem: scan records nothing
// there (README "Rules for the walk"), so most scan tests would fail // there (README "Rules for the walk"), so most scan tests would fail
@@ -554,17 +556,13 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
// line. // line.
t.Setenv(databaseEnv, "") t.Setenv(databaseEnv, "")
dbPath, err := defaultDatabasePath(os.Geteuid())
if err != nil {
t.Fatal(err)
}
// Whether scan calls the database new depends on that machine too. // Whether scan calls the database new depends on that machine too.
scanWant := "scan: database " + dbPath + " (default)\n" scanWant := "scan: database " + defaultDatabasePath + " (default)\n"
_, err = os.Stat(dbPath) _, err := os.Stat(defaultDatabasePath)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
scanWant = "scan: new database " + dbPath + " (default)\n" scanWant = "scan: new database " + defaultDatabasePath +
" (default)\n"
} }
missing := filepath.Join(t.TempDir(), "nope") missing := filepath.Join(t.TempDir(), "nope")
@@ -584,21 +582,21 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
run: func() error { run: func() error {
return runReport(cancelledContext(t), io.Discard) return runReport(cancelledContext(t), io.Discard)
}, },
want: "report: database " + dbPath + " (default)\n" + want: "report: database " + defaultDatabasePath + " (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), io.Discard)
}, },
want: "trees: database " + dbPath + " (default)\n" + want: "trees: database " + defaultDatabasePath + " (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 " + dbPath + " (default)\n" + want: "info: database " + defaultDatabasePath + " (default)\n" +
infoOpenLine + "\n" + infoOpenLine + "\n", infoOpenLine + "\n" + infoOpenLine + "\n",
}, },
} }
@@ -698,7 +696,7 @@ func TestRunHelp(t *testing.T) {
// README §Settings: help goes to stderr, exits 0, and leaves stdout // README §Settings: help goes to stderr, exits 0, and leaves stdout
// empty. // empty.
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}} cases := [][]string{{helpFlag}, {"-h"}, {cmdScan, helpFlag}}
for _, args := range cases { for _, args := range cases {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -717,22 +715,6 @@ 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)
} }
if args[0] == cmdScan {
continue
}
// README §Location: sfdupes --help says where the database is,
// and that XDG_STATE_HOME must be an absolute path.
for _, want := range []string{
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.
@@ -775,6 +757,73 @@ func TestRunScanUsageLine(t *testing.T) {
} }
} }
func TestRunHelpDescribesDatabaseAndOutput(t *testing.T) {
t.Parallel()
// README §Settings: the help names the database, and says what
// report, trees, info and dwim print.
database := []string{
"scan and dwim store", databaseEnv, defaultDatabasePath, "needs root",
}
escapes := []string{`\\`, `\t`, `\n`, `\r`}
// The subcommand whose help is printed; none for the root.
cases := []struct {
subcommand []string
want []string
}{
{nil, database},
{[]string{cmdScan}, database},
{
[]string{cmdReport},
append([]string{
"tab-separated", "\n first ", "\n dupe ", "\n size ",
"largest first",
}, escapes...),
},
{
[]string{cmdTrees},
append([]string{
"tab-separated", "\n first ", "\n dupe ", "\n files ",
"\n size ", "\n disk_size ", "largest first",
}, escapes...),
},
{
[]string{cmdInfo},
[]string{
"\n database ", "\n database file size ",
"\n -wal file size ", "\n modified ",
"\n schema version ", "\n records ",
"\n size of recorded files ",
},
},
{
[]string{cmdDwim},
[]string{
"sfdupes scan /", "as root", "sfdupes trees", "database",
},
},
}
for _, tc := range cases {
var stdout, stderr bytes.Buffer
args := slices.Concat(tc.subcommand, []string{helpFlag})
code := run(args, &stdout, &stderr)
if code != exitOK {
t.Fatalf("run(%v) = %d, want %d", args, code, exitOK)
}
for _, want := range tc.want {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) help lacks %q:\n%s",
args, want, stderr.String())
}
}
}
}
func TestRunVersion(t *testing.T) { func TestRunVersion(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1557,75 +1606,6 @@ 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 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, 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.
+15 -68
View File
@@ -15,40 +15,28 @@ 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"
) )
// rootDatabasePath is the database root uses when SFDUPES_DATABASE is // defaultDatabasePath is where the persistent scan database lives when
// not set. // SFDUPES_DATABASE is not set.
const rootDatabasePath = "/var/lib/sfdupes/db.sqlite" const defaultDatabasePath = "/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 directory scan creates for root's // dbDirPerm is the mode for a database parent directory created by
// default database or for SFDUPES_DATABASE. // scan.
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
@@ -156,48 +144,15 @@ 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, defaultDatabasePath otherwise. It returns the // set and non-empty, the compiled-in default 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 := os.Getenv(databaseEnv), "from "+databaseEnv path, source := defaultDatabasePath, "default"
if path == "" { if p := os.Getenv(databaseEnv); p != "" {
def, err := defaultDatabasePath(os.Geteuid()) path, source = p, "from "+databaseEnv
if err != nil {
return "", "", err
}
path, source = def, "default"
} }
abs, err := absolutePath(path) abs, err := absolutePath(path)
@@ -233,23 +188,15 @@ 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. Each directory gets mode dbDirPerm, or // databasePath returns it. The directory is path with its last element
// stateDirPerm when databasePath uses a user's default path in the XDG // cut off as text, by filepath.Split. filepath.Dir would clean it,
// state directory: the user is not root and SFDUPES_DATABASE is unset // dropping each ".." together with the directory name before it; when
// or empty. The directory is path with its last element cut off as // that name is a symlink, the cleaned path names another directory than
// text, by filepath.Split. filepath.Dir would clean it, dropping each // the one the system reaches when it opens path.
// ".." 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)
perm := os.FileMode(dbDirPerm) err := os.MkdirAll(dir, 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)
} }
+1 -51
View File
@@ -9,13 +9,10 @@ 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
@@ -59,16 +56,11 @@ 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
}{ }{
{"", defaultPath, "default"}, {"", defaultDatabasePath, "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},
@@ -97,48 +89,6 @@ 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()