From 6ded88234a66eb2a20c6220464ca0e23e9ceb5fa Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 04:00:23 +0000 Subject: [PATCH] Keep a non-root user's database in the XDG state directory (closes #171) Without SFDUPES_DATABASE, a user other than root now uses sfdupes/db.sqlite under $XDG_STATE_HOME (~/.local/state by default), which scan creates, instead of failing on /var/lib/sfdupes. Root keeps /var/lib/sfdupes/db.sqlite. sfdupes --help and README say where the database is. Adds github.com/adrg/xdg, the decided XDG library. Model: opus-5-5 --- README.md | 33 +++++++++++++++--------- TODO.md | 5 ++++ cmd/sfdupes/main.go | 6 +++-- go.mod | 1 + go.sum | 2 ++ internal/sfdupes/cli.go | 12 ++++++++- internal/sfdupes/cli_test.go | 50 ++++++++++++++++++++++++++++++------ internal/sfdupes/db.go | 25 ++++++++++++++---- internal/sfdupes/db_test.go | 40 ++++++++++++++++++++++++++++- 9 files changed, 145 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 93e057a..9a5f9ef 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ first look at a new machine with one command. ```sh make build -export SFDUPES_DATABASE="$HOME/.local/share/sfdupes/db.sqlite" ./sfdupes scan /srv ./sfdupes report > dupes.tsv ./sfdupes trees > dupetrees.tsv @@ -45,8 +44,9 @@ 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. `info` prints the database's path, size and record count. -The database is `/var/lib/sfdupes/db.sqlite` unless `SFDUPES_DATABASE` names -another file. The first `scan` creates it. +Root keeps the database in `/var/lib/sfdupes/db.sqlite`. On Linux, any other +user keeps it in `~/.local/state/sfdupes/db.sqlite`. `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 errors go to stderr. stdout and stderr are a program's two output streams. The @@ -146,8 +146,9 @@ walk"). As the file `/etc/cron.d/sfdupes`: - `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. 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 - root's database. + by every user. Another user can then run those three commands against root's + database, with `SFDUPES_DATABASE=/var/lib/sfdupes/db.sqlite` before the + command. ### Reading the reports @@ -283,9 +284,9 @@ The reference below is also the program's specification. ### Settings - `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 - it. The path names the file exactly, whatever characters it holds. A relative - path is relative to the working directory. + database is at its default path (see "Location"). There is no command-line + flag for it. The path names the file exactly, whatever characters it holds. A + relative path is relative to the working directory. - `--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 hashes them. The default is the number of CPUs. `N` must be at least 1. A @@ -880,7 +881,15 @@ All five subcommands work on one SQLite database file. #### Location 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 to an absolute path. Otherwise + it is `~/.local/state` on Linux and `~/Library/Application Support` on macOS. + +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 anything else and whether or not stderr is a terminal. The line gives the path @@ -920,9 +929,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 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 - database's path, with symlinks resolved, plus `.lock`. By default that is - `/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the same - lock file. + database's path, with symlinks resolved, plus `.lock`. For root, by default, + that is `/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the + same lock file. - `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 the lock file is named the same way before the first scan as after. A symlink diff --git a/TODO.md b/TODO.md index e0cee09..4d8543a 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,11 @@ Newest first. An entry ends with its date and its issue or branch, where those are known. +- Without `SFDUPES_DATABASE`, a user other than root keeps the database in + `sfdupes/db.sqlite` under the XDG state directory, `~/.local/state` by + default. Root keeps `/var/lib/sfdupes/db.sqlite`. `sfdupes --help` says where + the database is (2026-11-11, https://git.eeqj.de/sneak/sfdupes/issues/171) + - `report` and `trees` leave out empty files. `report` writes no group of size 0, and `trees` writes no group whose trees hold 0 bytes. An empty file still counts toward a tree's digest (2026-11-11, diff --git a/cmd/sfdupes/main.go b/cmd/sfdupes/main.go index 39a82dc..f894468 100644 --- a/cmd/sfdupes/main.go +++ b/cmd/sfdupes/main.go @@ -5,8 +5,10 @@ // 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 // file is never read in full. scan maintains a persistent SQLite -// database of file signatures (SFDUPES_DATABASE, default -// /var/lib/sfdupes/db.sqlite) that the other subcommands read. +// database of file signatures that the other subcommands read. The +// 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: // diff --git a/go.mod b/go.mod index 5d7e17c..3a9b590 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module sneak.berlin/go/sfdupes go 1.25.7 require ( + github.com/adrg/xdg v0.5.3 github.com/schollz/progressbar/v3 v3.19.1 github.com/spf13/cobra v1.10.2 golang.org/x/sys v0.46.0 diff --git a/go.sum b/go.sum index a247cbd..f979fc4 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/sfdupes/cli.go b/internal/sfdupes/cli.go index bc0539f..7bc6559 100644 --- a/internal/sfdupes/cli.go +++ b/internal/sfdupes/cli.go @@ -116,6 +116,13 @@ 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 +(~/Library/Application Support on macOS).` + // newRootCommand builds the command tree. Everything on stdout is // machine-readable data, the version line included; all human-facing // output (help, usage, errors) goes to stderr. @@ -134,9 +141,12 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command { return nil }) + short := "Find candidate duplicate files by size and head/tail/content SHA-256" + root := &cobra.Command{ Use: "sfdupes", - Short: "Find candidate duplicate files by size and head/tail/content SHA-256", + Short: short, + Long: short + ".\n\n" + databaseHelp, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if showVersion { diff --git a/internal/sfdupes/cli_test.go b/internal/sfdupes/cli_test.go index 6e65e7f..af447d4 100644 --- a/internal/sfdupes/cli_test.go +++ b/internal/sfdupes/cli_test.go @@ -15,6 +15,7 @@ import ( "strings" "testing" + "github.com/adrg/xdg" "golang.org/x/sys/unix" ) @@ -553,13 +554,14 @@ func TestRunNamesDefaultDatabase(t *testing.T) { // line. t.Setenv(databaseEnv, "") - // Whether scan calls the database new depends on that machine too. - scanWant := "scan: database " + defaultDatabasePath + " (default)\n" + dbPath := defaultDatabasePath(os.Geteuid()) - _, 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) { - scanWant = "scan: new database " + defaultDatabasePath + - " (default)\n" + scanWant = "scan: new database " + dbPath + " (default)\n" } missing := filepath.Join(t.TempDir(), "nope") @@ -578,21 +580,21 @@ func TestRunNamesDefaultDatabase(t *testing.T) { run: func() error { return runReport(cancelledContext(t), io.Discard) }, - want: "report: database " + defaultDatabasePath + " (default)\n" + + want: "report: database " + dbPath + " (default)\n" + reportOpenLine + "\n" + reportOpenLine + "\n", }, cmdTrees: { run: func() error { return runTrees(cancelledContext(t), io.Discard) }, - want: "trees: database " + defaultDatabasePath + " (default)\n" + + want: "trees: database " + dbPath + " (default)\n" + treesOpenLine + "\n" + treesOpenLine + "\n", }, cmdInfo: { run: func() error { return runInfo(cancelledContext(t), io.Discard) }, - want: "info: database " + defaultDatabasePath + " (default)\n" + + want: "info: database " + dbPath + " (default)\n" + infoOpenLine + "\n" + infoOpenLine + "\n", }, } @@ -711,6 +713,20 @@ func TestRunHelp(t *testing.T) { t.Errorf("run(%v) stdout = %q, want nothing (data only)", args, got) } + + if args[0] == cmdScan { + continue + } + + // README §Location: sfdupes --help says where the database is. + for _, want := range []string{ + databaseEnv, rootDatabasePath, "XDG_STATE_HOME", "~/.local/state", + } { + if !strings.Contains(stderr.String(), want) { + t.Errorf("run(%v) stderr = %q, want it to name %q", + args, stderr.String(), want) + } + } } } @@ -1496,6 +1512,24 @@ 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, and report and trees read the same file. + if os.Geteuid() == 0 { + t.Skip("root uses " + rootDatabasePath) + } + + t.Cleanup(xdg.Reload) + + state := t.TempDir() + t.Setenv("XDG_STATE_HOME", state) + t.Setenv(databaseEnv, "") + xdg.Reload() + + assertRunsUseDatabase(t, filepath.Join(state, "sfdupes"), "db.sqlite") +} + // holdScanLock takes the lock on the database at path, as a running // scan does, and holds it until the test ends. It fails the test when // the lock is already held. diff --git a/internal/sfdupes/db.go b/internal/sfdupes/db.go index a6fa2a0..2ac79c5 100644 --- a/internal/sfdupes/db.go +++ b/internal/sfdupes/db.go @@ -15,15 +15,16 @@ import ( "strings" "time" + "github.com/adrg/xdg" "golang.org/x/sys/unix" // The pure-Go SQLite driver, registered as "sqlite"; keeps cgo // disabled. _ "modernc.org/sqlite" ) -// defaultDatabasePath is where the persistent scan database lives when -// SFDUPES_DATABASE is not set. -const defaultDatabasePath = "/var/lib/sfdupes/db.sqlite" +// rootDatabasePath is the database root uses when SFDUPES_DATABASE is +// not set. +const rootDatabasePath = "/var/lib/sfdupes/db.sqlite" // databaseEnv is the environment variable that overrides the database // path. @@ -144,13 +145,27 @@ var errScanRunning = errors.New("another scan is running") // 32 bytes of a SHA-256. var errHashSize = errors.New("hash is not 32 bytes") +// 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. The +// path is joined as text, not cleaned, for the reason absolutePath +// gives. +func defaultDatabasePath(euid int) string { + if euid == 0 { + return rootDatabasePath + } + + return strings.TrimSuffix(xdg.StateHome, "/") + "/sfdupes/db.sqlite" +} + // 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, // "default" or "from SFDUPES_DATABASE", for the line each subcommand // prints first on stderr. func databasePath() (string, string, error) { - path, source := defaultDatabasePath, "default" + path, source := defaultDatabasePath(os.Geteuid()), "default" if p := os.Getenv(databaseEnv); p != "" { path, source = p, "from "+databaseEnv } diff --git a/internal/sfdupes/db_test.go b/internal/sfdupes/db_test.go index 5d801ea..c60e7d8 100644 --- a/internal/sfdupes/db_test.go +++ b/internal/sfdupes/db_test.go @@ -9,10 +9,13 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "slices" "strings" "testing" "time" + + "github.com/adrg/xdg" ) // testDBPath returns a database path inside a fresh temp dir, with @@ -60,7 +63,7 @@ func TestDatabasePath(t *testing.T) { cases := []struct { env, path, source string }{ - {"", defaultDatabasePath, "default"}, + {"", defaultDatabasePath(os.Geteuid()), "default"}, {"/custom/place.sqlite", "/custom/place.sqlite", fromEnv}, {"/custom/link/../place.sqlite", "/custom/link/../place.sqlite", fromEnv}, @@ -89,6 +92,41 @@ 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 not absolute. + 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 + }{ + {0, "", rootDatabasePath}, + {0, "/elsewhere/state", rootDatabasePath}, + {1000, "", "/home/someone/.local/state/sfdupes/db.sqlite"}, + {1000, "relative/state", "/home/someone/.local/state/sfdupes/db.sqlite"}, + {1000, "/elsewhere/state", "/elsewhere/state/sfdupes/db.sqlite"}, + {1000, "/elsewhere/state/", "/elsewhere/state/sfdupes/db.sqlite"}, + } + + for _, tc := range cases { + t.Setenv("XDG_STATE_HOME", tc.stateHome) + xdg.Reload() + + if got := defaultDatabasePath(tc.euid); got != tc.want { + t.Errorf("defaultDatabasePath(%d) with XDG_STATE_HOME=%q = %q, want %q", + tc.euid, tc.stateHome, got, tc.want) + } + } +} + func TestOpenScanDatabaseCreates(t *testing.T) { t.Parallel()