package main import ( "bytes" "context" "errors" "io" "io/fs" "os" "path/filepath" "strconv" "strings" "testing" ) // usageMarker is the first line of cobra's usage text, which a usage // error prints and a runtime failure must not. const usageMarker = "Usage:" // walSuffixes are the SQLite sidecar files a WAL-mode database keeps // while it is open. A clean close checkpoints the WAL and removes // both; finding either afterwards means the database was never closed. // //nolint:gochecknoglobals // a constant list, immutable by convention var walSuffixes = []string{"-wal", "-shm"} // assertNoSidecars fails when a WAL sidecar is still present beside the // database at path. func assertNoSidecars(t *testing.T, path string) { t.Helper() for _, suffix := range walSuffixes { _, err := os.Stat(path + suffix) if err == nil { t.Errorf("%s%s still present: the database was not closed", path, suffix) continue } if !errors.Is(err, fs.ErrNotExist) { t.Fatal(err) } } } // makeReadOnly takes write permission away from the database at path, // from any WAL sidecar beside it, and from their directory, as for a // user reading a database that a root cron scan keeps. Root ignores // file permissions, so it skips the test when run as root. func makeReadOnly(t *testing.T, path string) { t.Helper() if os.Geteuid() == 0 { t.Skip("root ignores file permissions") } err := os.Chmod(path, 0o400) if err != nil { t.Fatal(err) } for _, suffix := range walSuffixes { err = os.Chmod(path+suffix, 0o400) if err != nil && !errors.Is(err, fs.ErrNotExist) { t.Fatal(err) } } dir := filepath.Dir(path) //nolint:gosec // reaching the database needs the search bit err = os.Chmod(dir, 0o500) if err != nil { t.Fatal(err) } // Runs before t.TempDir's own cleanup, which must delete the files. t.Cleanup(func() { //nolint:gosec // removing the directory needs its search bit back _ = os.Chmod(dir, 0o700) }) } // brokenDatabase writes a database that opens cleanly and passes the // schema-version check but has no files table, so the first query // fails with the database already open: a fatal error on a path that // owns an open database. It closes the database the way scan does. func brokenDatabase(t *testing.T) string { t.Helper() path := testDBPath(t) db, err := openDB(path, scanParams) if err != nil { t.Fatal(err) } _, err = db.ExecContext(context.Background(), "PRAGMA user_version = "+strconv.Itoa(schemaVersion)) if err != nil { t.Fatal(err) } closeScanDatabase(t.Context(), db, path) return path } func TestOpenDatabaseKeepsWALWhileOpen(t *testing.T) { t.Parallel() // The premise of the fatal-path tests below: an open database has // a -wal sidecar, so its absence afterwards is evidence that the // database was closed and its WAL checkpointed. path := testDBPath(t) db, err := openScanDatabase(t.Context(), path) if err != nil { t.Fatal(err) } _, err = os.Stat(path + "-wal") if err != nil { t.Fatalf("no -wal beside an open database: %v", err) } err = db.Close() if err != nil { t.Fatal(err) } assertNoSidecars(t, path) } func TestRunFatalAfterOpenClosesDatabase(t *testing.T) { // Every subcommand that owns an open database must close it when // it fails: no os.Exit between the open and the return. The // sidecar check is evidence of the close only for scan: report and // trees only read a database that is out of WAL mode, which leaves // nothing on disk whether they close it or not. cases := map[string][]string{ cmdScan: {cmdScan}, cmdReport: {cmdReport}, cmdTrees: {cmdTrees}, } for name, args := range cases { t.Run(name, func(t *testing.T) { path := brokenDatabase(t) t.Setenv(databaseEnv, path) if name == cmdScan { args = append(args, t.TempDir()) } var stdout, stderr bytes.Buffer code := run(args, &stdout, &stderr) if code != exitFatal { t.Errorf("run(%v) = %d, want %d", args, code, exitFatal) } assertNoSidecars(t, path) assertFatalOutput(t, stderr.String(), stdout.String()) // Proof that the failure happened after the open: only a // query against the opened database can report this. if !strings.Contains(stderr.String(), "no such table: files") { t.Errorf("stderr = %q, want the failure to come from a "+ "query on the open database", stderr.String()) } }) } } func TestRunMissingOperandIsFatalNotUsage(t *testing.T) { // README §Error handling: a PATH operand that does not exist is a // fatal error (1), not a usage error (2) — and a runtime failure // must not dump the usage text. t.Setenv(databaseEnv, testDBPath(t)) var stdout, stderr bytes.Buffer missing := filepath.Join(t.TempDir(), "nope") code := run([]string{cmdScan, missing}, &stdout, &stderr) if code != exitFatal { t.Errorf("run(scan %s) = %d, want %d", missing, code, exitFatal) } assertFatalOutput(t, stderr.String(), stdout.String()) } // assertFatalOutput checks that a fatal error was reported the way // README §Error handling and design goal 4 require: the message on // stderr, prefixed with the program name, no usage text, and nothing // at all on stdout. func assertFatalOutput(t *testing.T, stderr, stdout string) { t.Helper() if !strings.Contains(stderr, "sfdupes: ") { t.Errorf("stderr = %q, want a \"sfdupes: \" error report", stderr) } if strings.Contains(stderr, usageMarker) { t.Errorf("stderr = %q, want no usage text for a runtime failure", stderr) } if stdout != "" { t.Errorf("stdout = %q, want nothing (data only)", stdout) } } func TestRunUsageErrors(t *testing.T) { // Usage errors keep exiting 2 with cobra's own report on stderr. cases := map[string]struct { args []string want string }{ "no subcommand": {[]string{}, usageMarker}, "scan without paths": {[]string{cmdScan}, usageMarker}, "report with args": {[]string{cmdReport, "x"}, usageMarker}, "trees with args": {[]string{cmdTrees, "x"}, usageMarker}, "unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker}, "unknown subcommand": {[]string{"nope"}, "unknown command"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { // No usage error may reach the database, so point it at a // path that does not exist. t.Setenv(databaseEnv, testDBPath(t)) var stdout, stderr bytes.Buffer code := run(tc.args, &stdout, &stderr) if code != exitUsage { t.Errorf("run(%v) = %d, want %d", tc.args, code, exitUsage) } if !strings.Contains(stderr.String(), tc.want) { t.Errorf("stderr = %q, want %q", stderr.String(), tc.want) } if got := stdout.String(); got != "" { t.Errorf("stdout = %q, want nothing (data only)", got) } }) } } // TestRunHelpAndVersionSucceed checks that the two informational flags // exit 0 and keep their human-facing output on stderr. func TestRunHelpAndVersionSucceed(t *testing.T) { t.Parallel() assertHumanOutput(t, "--help") assertHumanOutput(t, "--version") } // assertHumanOutput runs sfdupes with one informational flag and checks // that it succeeds with its output on stderr and stdout untouched // (README design goal 4). func assertHumanOutput(t *testing.T, arg string) { t.Helper() var stdout, stderr bytes.Buffer code := run([]string{arg}, &stdout, &stderr) if code != exitOK { t.Errorf("run(%s) = %d, want %d", arg, code, exitOK) } if stderr.Len() == 0 { t.Errorf("run(%s) wrote nothing to stderr", arg) } if got := stdout.String(); got != "" { t.Errorf("stdout = %q, want nothing (data only)", got) } } // scanFixture builds a small tree holding one duplicate pair and one // unreadable file and scans it into the database the caller has // pointed SFDUPES_DATABASE at. The unreadable file makes the scan warn // and skip, which README §Error handling still calls a successful run. // It returns the duplicate pair's paths. func scanFixture(t *testing.T) []string { t.Helper() dir := t.TempDir() dupes := []string{ writeFile(t, dir, "one/a.bin", pattern(1, 300)), writeFile(t, dir, "two/a.bin", pattern(1, 300)), } // Same size as the pair, so the scan queues it for hashing and the // read fails. unreadable := writeFile(t, dir, "unreadable.bin", pattern(2, 300)) err := os.Chmod(unreadable, 0) if err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer code := run([]string{cmdScan, dir}, &stdout, &stderr) if code != exitOK { t.Fatalf("run(scan) = %d, want %d; stderr: %s", code, exitOK, stderr.String()) } if got := stdout.String(); got != "" { t.Errorf("scan stdout = %q, want nothing (data only)", got) } return dupes } func TestRunScanSucceedsDespiteWarnings(t *testing.T) { path := testDBPath(t) t.Setenv(databaseEnv, path) scanFixture(t) assertNoSidecars(t, path) } func TestRunReportSucceeds(t *testing.T) { path := testDBPath(t) t.Setenv(databaseEnv, path) dupes := scanFixture(t) var stdout, stderr bytes.Buffer code := run([]string{cmdReport}, &stdout, &stderr) if code != exitOK { t.Fatalf("run(report) = %d, want %d; stderr: %s", code, exitOK, stderr.String()) } want := "first\tdupe\tsize\n" + dupes[0] + "\t" + dupes[1] + "\t300\n" if got := stdout.String(); got != want { t.Errorf("stdout = %q, want %q", got, want) } assertNoSidecars(t, path) } func TestRunTreesSucceeds(t *testing.T) { path := testDBPath(t) t.Setenv(databaseEnv, path) dupes := scanFixture(t) var stdout, stderr bytes.Buffer code := run([]string{cmdTrees}, &stdout, &stderr) if code != exitOK { t.Fatalf("run(trees) = %d, want %d; stderr: %s", code, exitOK, stderr.String()) } // The two directories holding the duplicate pair are duplicate // trees of each other. want := "first\tdupe\tfiles\tsize\n" + filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n" if got := stdout.String(); got != want { t.Errorf("stdout = %q, want %q", got, want) } assertNoSidecars(t, path) } func TestRunReportsNeedOnlyReadAccess(t *testing.T) { // README §Database: report and trees need only read access to the // database file. With its directory read-only as well, SQLite // cannot create any file beside it. path := testDBPath(t) t.Setenv(databaseEnv, path) dupes := scanFixture(t) assertNoSidecars(t, path) makeReadOnly(t, path) cases := map[string]string{ cmdReport: "first\tdupe\tsize\n" + dupes[0] + "\t" + dupes[1] + "\t300\n", cmdTrees: "first\tdupe\tfiles\tsize\n" + filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n", } for name, want := range cases { var stdout, stderr bytes.Buffer code := run([]string{name}, &stdout, &stderr) if code != exitOK { t.Errorf("run(%s) = %d, want %d; stderr: %s", name, code, exitOK, stderr.String()) continue } if got := stdout.String(); got != want { t.Errorf("%s stdout = %q, want %q", name, got, want) } } } func TestRunStdoutClosedIsFatal(t *testing.T) { // README §Error handling: a stdout write failure exits 1, reported // in one line on stderr. for _, name := range []string{cmdReport, cmdTrees} { t.Run(name, func(t *testing.T) { t.Setenv(databaseEnv, testDBPath(t)) scanFixture(t) stdout, err := os.Create(filepath.Join(t.TempDir(), "stdout")) if err != nil { t.Fatal(err) } err = stdout.Close() if err != nil { t.Fatal(err) } var stderr bytes.Buffer code := run([]string{name}, stdout, &stderr) if code != exitFatal { t.Errorf("run(%s) = %d, want %d", name, code, exitFatal) } got := stderr.String() if !strings.HasPrefix(got, "sfdupes: write stdout: ") || !strings.Contains(got, os.ErrClosed.Error()) || strings.Count(got, "\n") != 1 { t.Errorf("stderr = %q, want one line reporting the "+ "failed stdout write", got) } }) } } // errWriteFailed is the error failingWriter returns. var errWriteFailed = errors.New("write failed") // failingWriter is a stdout that fails every write. type failingWriter struct{} func (failingWriter) Write([]byte) (int, error) { return 0, errWriteFailed } func TestStdoutWriteErrorPropagates(t *testing.T) { t.Setenv(databaseEnv, testDBPath(t)) scanFixture(t) cases := map[string]func(context.Context, io.Writer) error{ cmdReport: runReport, cmdTrees: runTrees, } for name, fn := range cases { err := fn(t.Context(), failingWriter{}) if !errors.Is(err, errWriteFailed) { t.Errorf("%s: error = %v, want %v", name, err, errWriteFailed) } } }