check / check (push) Successful in 2m50s
A symlink, socket, FIFO or device-node operand, or a directory operand named .zfs, was silently ignored yet stayed in the scanned operands, so the update phase deleted every record stored beneath it. Such an operand now gets a one-line warning, counts as skipped, and is dropped before overlapping operands are pruned and the database index is loaded: another operand beneath it is still scanned, and the records beneath it count as outside the scanned operands and are not deleted, unless it lies under another operand. The exit status stays 0. An operand that turns into one of these after that check is warned about and skipped by the walk instead. README "scan mode" and "Rules for the walk" say so. Model: opus-5-5
719 lines
18 KiB
Go
719 lines
18 KiB
Go
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)
|
|
})
|
|
}
|
|
|
|
// captureStderr redirects os.Stderr to a file for the rest of the test
|
|
// and returns a function reading back everything written to it. scan
|
|
// writes its warnings and summary straight to os.Stderr, not to the
|
|
// stderr writer run is given.
|
|
func captureStderr(t *testing.T) func() string {
|
|
t.Helper()
|
|
|
|
f, err := os.Create(filepath.Join(t.TempDir(), "stderr"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
saved := os.Stderr
|
|
os.Stderr = f
|
|
|
|
t.Cleanup(func() {
|
|
os.Stderr = saved
|
|
|
|
_ = f.Close()
|
|
})
|
|
|
|
return func() string {
|
|
// Read what has been written without disturbing the write
|
|
// offset, so the capture can be inspected more than once.
|
|
size, err := f.Seek(0, io.SeekCurrent)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if size == 0 {
|
|
return ""
|
|
}
|
|
|
|
b := make([]byte, size)
|
|
|
|
_, err = f.ReadAt(b, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return string(b)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
scanOK(t, dir)
|
|
|
|
return dupes
|
|
}
|
|
|
|
// scanOK runs scan over operands, fails the test unless it exits 0 with
|
|
// nothing on stdout, and returns everything it printed to stderr.
|
|
func scanOK(t *testing.T, operands ...string) string {
|
|
t.Helper()
|
|
|
|
var stdout bytes.Buffer
|
|
|
|
stderr := captureStderr(t)
|
|
|
|
code := run(append([]string{cmdScan}, operands...), &stdout, os.Stderr)
|
|
if code != exitOK {
|
|
t.Fatalf("run(scan %q) = %d, want %d; stderr: %s",
|
|
operands, code, exitOK, stderr())
|
|
}
|
|
|
|
if got := stdout.String(); got != "" {
|
|
t.Errorf("scan stdout = %q, want nothing (data only)", got)
|
|
}
|
|
|
|
return stderr()
|
|
}
|
|
|
|
func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
scanFixture(t)
|
|
assertNoSidecars(t, path)
|
|
}
|
|
|
|
func TestRunScanSkipsSymlinkOperand(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "target/sub/f", pattern(1, 10))
|
|
|
|
link := filepath.Join(dir, "link")
|
|
|
|
err := os.Symlink(filepath.Join(dir, "target"), link)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Scanning a directory through the symlink stores a record beneath
|
|
// the symlink's own path for a file beneath its target.
|
|
scanOK(t, filepath.Join(link, "sub"))
|
|
|
|
assertOperandSkipped(t, path, link, "symlink",
|
|
filepath.Join(link, "sub", "f"))
|
|
}
|
|
|
|
func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "target/sub/f", pattern(1, 10))
|
|
|
|
link := filepath.Join(dir, "link")
|
|
|
|
err := os.Symlink(filepath.Join(dir, "target"), link)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// link is dropped as a symlink, but link/sub must still be scanned,
|
|
// not dropped as lying under link.
|
|
scanOK(t, link, filepath.Join(link, "sub"))
|
|
|
|
db, err := openDB(path, reportParams)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
recordByPath(t, dbRecords(t, db), filepath.Join(link, "sub", "f"))
|
|
}
|
|
|
|
func TestRunScanSkipsZFSOperand(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
zfs := filepath.Join(t.TempDir(), ".zfs")
|
|
snapshot := filepath.Join(zfs, "snapshot", "hourly")
|
|
f := writeFile(t, snapshot, "f", pattern(1, 10))
|
|
|
|
// An operand beneath a .zfs directory is walked, because it is not
|
|
// itself named .zfs.
|
|
scanOK(t, snapshot)
|
|
|
|
assertOperandSkipped(t, path, zfs, ".zfs directory", f)
|
|
}
|
|
|
|
// assertOperandSkipped scans operand alone and checks that it is skipped
|
|
// as kind: a warning naming it, one skip in the summary, exit 0, and the
|
|
// record for kept, which an earlier scan stored beneath operand, still
|
|
// in the database at dbPath.
|
|
func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
|
kept string,
|
|
) {
|
|
t.Helper()
|
|
|
|
stderr := scanOK(t, operand)
|
|
|
|
warning := "walk " + operand + ": skipping " + kind + " operand\n"
|
|
if !strings.Contains(stderr, warning) {
|
|
t.Errorf("stderr = %q, want %q", stderr, warning)
|
|
}
|
|
|
|
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
|
"0 unchanged), 1 skipped\n"
|
|
if !strings.Contains(stderr, summary) {
|
|
t.Errorf("stderr = %q, want %q", stderr, summary)
|
|
}
|
|
|
|
db, err := openDB(dbPath, reportParams)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
recordByPath(t, dbRecords(t, db), kept)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func holdScanLock(t *testing.T, path string) {
|
|
t.Helper()
|
|
|
|
lock, err := lockScanDatabase(path)
|
|
if err != nil {
|
|
t.Fatalf("lock %s: %v", path, err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = lock.Close() })
|
|
}
|
|
|
|
func TestRunSecondScanFails(t *testing.T) {
|
|
// README §Database: while one scan holds the lock, a second scan
|
|
// fails at once, naming the lock file, without creating the
|
|
// database.
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
holdScanLock(t, path)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := run([]string{cmdScan, t.TempDir()}, &stdout, &stderr)
|
|
if code != exitFatal {
|
|
t.Errorf("run(scan) = %d, want %d", code, exitFatal)
|
|
}
|
|
|
|
want := "sfdupes: another scan is running (lock held on " +
|
|
path + ".lock)\n"
|
|
if got := stderr.String(); got != want {
|
|
t.Errorf("stderr = %q, want %q", got, want)
|
|
}
|
|
|
|
if got := stdout.String(); got != "" {
|
|
t.Errorf("stdout = %q, want nothing (data only)", got)
|
|
}
|
|
|
|
_, err := os.Stat(path)
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("stat %s = %v, want the database not created", path, err)
|
|
}
|
|
}
|
|
|
|
func TestRunScanReleasesLock(t *testing.T) {
|
|
// README §Database: a scan releases the lock however it ends.
|
|
t.Run("success", func(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
scanFixture(t)
|
|
holdScanLock(t, path)
|
|
})
|
|
|
|
t.Run("fatal error", func(t *testing.T) {
|
|
path := brokenDatabase(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
code := run([]string{cmdScan, t.TempDir()}, io.Discard, io.Discard)
|
|
if code != exitFatal {
|
|
t.Fatalf("run(scan) = %d, want %d", code, exitFatal)
|
|
}
|
|
|
|
holdScanLock(t, path)
|
|
})
|
|
}
|
|
|
|
func TestRunReportsDuringScan(t *testing.T) {
|
|
// README §Database: report and trees never take the lock, so they
|
|
// run while a scan holds it.
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
scanFixture(t)
|
|
holdScanLock(t, path)
|
|
|
|
for _, name := range []string{cmdReport, cmdTrees} {
|
|
var stderr bytes.Buffer
|
|
|
|
code := run([]string{name}, io.Discard, &stderr)
|
|
if code != exitOK {
|
|
t.Errorf("run(%s) = %d, want %d; stderr: %s",
|
|
name, code, exitOK, stderr.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|