From 049a74a10b4e12a41dcf92fae6c1789f602aaa6c 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 in the XDG state directory (~/.local/state by default) instead of failing on /var/lib/sfdupes. scan creates the missing directories there with mode 0700, as the XDG Base Directory specification asks. An XDG_STATE_HOME that is not an absolute path is a fatal error for every subcommand. Root keeps /var/lib/sfdupes/db.sqlite. The help of sfdupes and of scan, and README, say where the database is. Adds github.com/adrg/xdg, the decided XDG library. Model: opus-5-5 --- README.md | 41 +++++++--- cmd/sfdupes/main.go | 6 +- go.mod | 1 + go.sum | 2 + internal/sfdupes/cli.go | 12 ++- internal/sfdupes/cli_test.go | 144 ++++++++++++++++++++++++++++++----- internal/sfdupes/db.go | 83 ++++++++++++++++---- internal/sfdupes/db_test.go | 52 ++++++++++++- 8 files changed, 289 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index d326640..1d78177 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 @@ -47,8 +46,10 @@ 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 -another file. The first `scan` creates it. +Root keeps the database in `/var/lib/sfdupes/db.sqlite`. Any other user keeps 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 errors go to stderr. stdout and stderr are a program's two output streams. The @@ -155,8 +156,9 @@ point of each disk at or below `/srv` with its own `mountpoint -q`, joined with - `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 @@ -295,9 +297,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`, `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 hashes them. The default is the number of CPUs. `N` must be at @@ -977,7 +979,22 @@ 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 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 anything else and whether or not stderr is a terminal. The line gives the path @@ -1017,9 +1034,9 @@ Only one `scan` at a time can hold the lock. - For its whole run, `scan` holds a lock on a lock file beside the database. The 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/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 3cf9824..5571c73 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/rivo/uniseg v0.4.7 github.com/schollz/progressbar/v3 v3.19.1 github.com/spf13/cobra v1.10.2 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 ed4f566..d994c21 100644 --- a/internal/sfdupes/cli.go +++ b/internal/sfdupes/cli.go @@ -34,10 +34,14 @@ const ( // 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" +const databaseHelp = `scan and dwim store what they find in a database file. report, +trees and info read it. The file is $SFDUPES_DATABASE when that is set +and not empty. Otherwise 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. +` // escapesHelp is the paragraph of the report and trees help on how // paths are escaped (README "Report output format"). diff --git a/internal/sfdupes/cli_test.go b/internal/sfdupes/cli_test.go index 5c10bd8..7df15c7 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" ) @@ -559,13 +560,17 @@ 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, err := defaultDatabasePath(os.Geteuid()) + 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) { - scanWant = "scan: new database " + defaultDatabasePath + - " (default)\n" + scanWant = "scan: new database " + dbPath + " (default)\n" } missing := filepath.Join(t.TempDir(), "nope") @@ -585,21 +590,21 @@ func TestRunNamesDefaultDatabase(t *testing.T) { run: func() error { return runReport(cancelledContext(t), nil, 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), nil, 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", }, } @@ -716,6 +721,19 @@ func TestRunHelp(t *testing.T) { t.Errorf("run(%v) stdout = %q, want nothing (data only)", 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. @@ -758,28 +776,24 @@ func TestRunScanUsageLine(t *testing.T) { } } -func TestRunHelpDescribesDatabaseAndOutput(t *testing.T) { +func TestRunHelpDescribesOutput(t *testing.T) { t.Parallel() - // README §Settings: the help names the database, and says what - // report, trees, info and dwim print. The help of report and trees - // also explains their PATH operands. - database := []string{ - "scan and dwim store", databaseEnv, defaultDatabasePath, "needs root", - } + // README §Settings: the help of report, trees, info and dwim says + // what each prints. The help of report and trees also explains + // their PATH operands. TestRunHelp checks the database paragraph + // of the root and scan help. 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 { subcommand []string want []string }{ - {nil, database}, - {[]string{cmdScan}, database}, { []string{cmdReport}, slices.Concat([]string{ @@ -1612,6 +1626,100 @@ func TestRunDatabasePathUsedAsGiven(t *testing.T) { }) } +func TestRunNonRootUsesStateDirectory(t *testing.T) { + // README §Location: without SFDUPES_DATABASE, a user other than + // root keeps the database in the XDG state directory. scan creates + // the missing directories with mode 0700, and report and trees read + // the same file. + if os.Geteuid() == 0 { + t.Skip("root uses " + rootDatabasePath) + } + + t.Cleanup(xdg.Reload) + + state := filepath.Join(t.TempDir(), "state") + t.Setenv(stateHomeEnv, state) + t.Setenv(databaseEnv, "") + xdg.Reload() + + assertRunsUseDatabase(t, filepath.Join(state, "sfdupes"), "db.sqlite") + + for _, dir := range []string{state, filepath.Join(state, "sfdupes")} { + fi, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + + if got := fi.Mode().Perm(); got != stateDirPerm { + t.Errorf("%s mode = %#o, want %#o", dir, got, stateDirPerm) + } + } +} + +func TestRunNonRootDatabaseEnvDirectoryMode(t *testing.T) { + // README §Location: for SFDUPES_DATABASE, scan creates each missing + // directory with mode 755, for a user other than root too. + if os.Geteuid() == 0 { + t.Skip("needs a user other than root") + } + + top := filepath.Join(t.TempDir(), "missing") + sub := filepath.Join(top, "sub") + t.Setenv(databaseEnv, filepath.Join(sub, "db.sqlite")) + + scanOK(t, t.TempDir()) + + for _, dir := range []string{top, sub} { + fi, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + + if got := fi.Mode().Perm(); got != dbDirPerm { + t.Errorf("%s mode = %#o, want %#o", dir, got, dbDirPerm) + } + } +} + +func TestRunRelativeStateHomeFails(t *testing.T) { + // README §Location: without SFDUPES_DATABASE, a user other than + // root whose XDG_STATE_HOME is not an absolute path gets an error + // naming it from every subcommand, which exits 1 before it does + // anything else. + if os.Geteuid() == 0 { + t.Skip("root does not use " + stateHomeEnv) + } + + t.Setenv(stateHomeEnv, "relative/state") + t.Setenv(databaseEnv, "") + + want := "sfdupes: " + stateHomeEnv + + " must be an absolute path, got \"relative/state\"\n" + + // A scan that got past the check would stop at this missing + // operand, before it creates anything. + missing := filepath.Join(t.TempDir(), "missing") + + for _, args := range [][]string{ + {cmdScan, missing}, {cmdReport}, {cmdTrees}, {cmdInfo}, + } { + var stdout, stderr bytes.Buffer + + code := run(args, &stdout, &stderr) + if code != exitFatal || stderr.String() != want { + t.Errorf("run(%v) = %d with stderr %q, want %d and %q", + args, code, stderr.String(), exitFatal, want) + } + } + + // dwim scans /, so runDwim gets the missing operand instead. + err := runDwim(t.Context(), missing, scanOptions{workers: 1}, io.Discard) + if !errors.Is(err, errStateHomeNotAbsolute) { + t.Errorf("runDwim: error = %v, want %v", err, + errStateHomeNotAbsolute) + } +} + // holdScanLock takes the lock on the database at path, as a running // 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..f7dd25c 100644 --- a/internal/sfdupes/db.go +++ b/internal/sfdupes/db.go @@ -15,28 +15,40 @@ 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. 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 // writes, stored in PRAGMA user_version. const schemaVersion = 1 -// dbDirPerm is the mode for a database parent directory created by -// scan. +// dbDirPerm is the mode for a directory scan creates for root's +// default database or for SFDUPES_DATABASE. 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 // SQLite gives a database file it creates. const dbFilePerm = 0o644 @@ -144,15 +156,48 @@ var errScanRunning = errors.New("another scan is running") // 32 bytes of a SHA-256. 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 -// 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" - if p := os.Getenv(databaseEnv); p != "" { - path, source = p, "from "+databaseEnv + path, source := os.Getenv(databaseEnv), "from "+databaseEnv + if path == "" { + def, err := defaultDatabasePath(os.Geteuid()) + if err != nil { + return "", "", err + } + + path, source = def, "default" } abs, err := absolutePath(path) @@ -188,15 +233,23 @@ func absolutePath(path string) (string, error) { // createDatabaseDirectory creates the directory the database at path // goes in, and any directory above it, if missing. path is absolute, as -// databasePath returns it. The directory is path with its last element -// cut off as 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. +// databasePath returns it. Each directory gets mode dbDirPerm, or +// stateDirPerm when databasePath uses a user's default path in the XDG +// state directory: the user is not root and SFDUPES_DATABASE is unset +// or empty. The directory is path with its last element cut off as +// 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 { 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 { return fmt.Errorf("create database directory: %w", err) } diff --git a/internal/sfdupes/db_test.go b/internal/sfdupes/db_test.go index 5d801ea..4c5af1e 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 @@ -56,11 +59,16 @@ func TestDatabasePath(t *testing.T) { t.Chdir(dir) + defaultPath, err := defaultDatabasePath(os.Geteuid()) + if err != nil { + t.Fatal(err) + } + fromEnv := "from " + databaseEnv cases := []struct { env, path, source string }{ - {"", defaultDatabasePath, "default"}, + {"", defaultPath, "default"}, {"/custom/place.sqlite", "/custom/place.sqlite", fromEnv}, {"/custom/link/../place.sqlite", "/custom/link/../place.sqlite", 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) { t.Parallel()