check / check (push) Canceled after 0s
The walk neither enters nor records an --exclude path or anything below it, and prints "scan: skipped PATH (--exclude)" for each one it reaches. The flag repeats. Each value is resolved to its real path, as an operand is. The records below an excluded path are kept, as for a default exclude. An empty value, which would name the working directory, is a usage error. An --exclude path whose real path scan cannot find, or an operand at or below one, is a fatal error before the scan starts. walkRules names its two lists excludePaths and defaultExcludes. The line naming the database moved out of runScan into printDatabasePath, which keeps runScan under the lint length limit. Model: opus-5-5
675 lines
19 KiB
Go
675 lines
19 KiB
Go
package sfdupes //nolint:testpackage // checks the unexported defaultExcludes
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// excludeFlag is the scan flag that names a path the walk never enters.
|
|
const excludeFlag = "--exclude"
|
|
|
|
func TestDefaultExcludes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// README "Rules for the walk": the Linux and macOS lists, and none
|
|
// on any other system. The Windows list exists in the code only: the
|
|
// binary does not build for Windows, so the README leaves it out.
|
|
cases := map[string][]string{
|
|
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
|
|
goosDarwin: {
|
|
"/dev", "/private/tmp", "/private/var/tmp", "/private/var/vm",
|
|
"/System/Volumes/VM", "/System/Volumes/Data",
|
|
},
|
|
goosWindows: {
|
|
"pagefile.sys", "swapfile.sys", "hiberfil.sys",
|
|
"System Volume Information", "$Recycle.Bin", `Windows\Temp`,
|
|
`Users\*\AppData\Local\Temp`,
|
|
},
|
|
"freebsd": nil,
|
|
}
|
|
|
|
for goos, want := range cases {
|
|
if got := defaultExcludes(goos); !slices.Equal(got, want) {
|
|
t.Errorf("defaultExcludes(%q) = %q, want %q", goos, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsDefaultExclude(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A * matches one path component, no more and no fewer.
|
|
excludes := []string{"/scratch", "/home/*/cache"}
|
|
|
|
cases := map[string]bool{
|
|
"/scratch": true,
|
|
"/scratch/x": false,
|
|
"/home/u/cache": true,
|
|
"/home/u/v/cache": false,
|
|
"/home/cache": false,
|
|
}
|
|
|
|
for path, want := range cases {
|
|
if got := isDefaultExclude(path, excludes); got != want {
|
|
t.Errorf("isDefaultExclude(%q) = %v, want %v", path, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadMountInfo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The second-to-last line has no optional fields. The tmpfs on
|
|
// /data/a/b is hidden by the root filesystem's /srv, mounted over
|
|
// /data/a after it: the tmpfs still gives its own device, and the
|
|
// root filesystem's device is not listed.
|
|
mountinfo := strings.Join([]string{
|
|
"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw",
|
|
"23 22 0:21 / /proc rw,nosuid shared:12 - proc none rw",
|
|
"24 22 0:22 / /sys rw,nosuid shared:7 - sysfs none rw",
|
|
"25 22 0:23 / /data/a/b rw shared:2 - tmpfs none rw",
|
|
"26 22 8:1 /srv /data/a rw - ext4 /dev/sda1 rw",
|
|
"27 22 0:25 / /srv/auto rw shared:4 - autofs systemd-1 rw",
|
|
}, "\n")
|
|
|
|
got, err := readMountInfo(strings.NewReader(mountinfo))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := map[uint64]string{
|
|
unix.Mkdev(0, 21): "proc", unix.Mkdev(0, 22): "sysfs",
|
|
unix.Mkdev(0, 23): "tmpfs", unix.Mkdev(0, 25): "autofs",
|
|
}
|
|
if !maps.Equal(got, want) {
|
|
t.Errorf("readMountInfo = %v, want %v", got, want)
|
|
}
|
|
|
|
for _, line := range []string{
|
|
"22 1 8:1 / / rw,relatime\n",
|
|
"22 1 sda / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n",
|
|
} {
|
|
_, err = readMountInfo(strings.NewReader(line))
|
|
if !errors.Is(err, errMountInfoLine) {
|
|
t.Errorf("readMountInfo(%q) = %v, want %v", line, err,
|
|
errMountInfoLine)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNonPersistentFilesystems(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS != goosLinux {
|
|
t.Skip("the test knows a mount only on Linux: /proc")
|
|
}
|
|
|
|
nonPersistent, err := nonPersistentFilesystems()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfPath("/proc")
|
|
if !ok {
|
|
t.Fatal("no device for /proc")
|
|
}
|
|
|
|
if got := nonPersistent[dev]; got != "proc" {
|
|
t.Errorf("filesystem of /proc = %q, want %q", got, "proc")
|
|
}
|
|
}
|
|
|
|
func TestDefaultExcludesHelp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, goos := range []string{goosLinux, goosDarwin, goosWindows} {
|
|
help := defaultExcludesHelp(goos)
|
|
|
|
for _, dir := range defaultExcludes(goos) {
|
|
if !strings.Contains(help, "\n "+dir+"\n") {
|
|
t.Errorf("help for %s does not list %s:\n%s", goos, dir, help)
|
|
}
|
|
}
|
|
|
|
// Only Linux and macOS list their mounted filesystems.
|
|
for _, fsType := range nonPersistentFilesystemTypes() {
|
|
listed := strings.Contains(help, " "+fsType+",") ||
|
|
strings.Contains(help, " "+fsType+"\n")
|
|
if listed != (goos != goosWindows) {
|
|
t.Errorf("help for %s lists %s: %v", goos, fsType, listed)
|
|
}
|
|
}
|
|
}
|
|
|
|
want := "This system has no default excludes.\n"
|
|
if got := defaultExcludesHelp("freebsd"); got != want {
|
|
t.Errorf("help for freebsd = %q, want %q", got, want)
|
|
}
|
|
|
|
// scan's help shows those of the system it runs on.
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
run([]string{cmdScan, "-h"}, &stdout, &stderr)
|
|
|
|
help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS))
|
|
if !strings.Contains(stderr.String(), help) {
|
|
t.Errorf("scan --help = %q, want it to hold %q", stderr.String(), help)
|
|
}
|
|
}
|
|
|
|
// TestScanDoesNotEnterDefaultExclude checks that the walk does not
|
|
// enter a default exclude below an operand. It says so on a line of its
|
|
// own and does not count it as skipped. The records below it count as
|
|
// outside the operands: the record of a file gone from it is kept,
|
|
// while a file added to it is not recorded.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
excluded := filepath.Join(dir, "tmp")
|
|
|
|
// A scan with no default excludes records the file in excluded.
|
|
gone := writeFile(t, excluded, "gone", pattern(1, 100))
|
|
syncTree(t, db, dir)
|
|
|
|
err := os.Remove(gone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
unseen := writeFile(t, excluded, "new", pattern(2, 10))
|
|
|
|
st, err := syncScan(t.Context(), db, []string{dir}, 1, false,
|
|
walkRules{defaultExcludes: []string{excluded}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if st.skipped != 0 {
|
|
t.Errorf("skipped = %d, want 0", st.skipped)
|
|
}
|
|
|
|
recs := dbRecords(t, db)
|
|
recordByPath(t, recs, gone)
|
|
|
|
if slices.Contains(recordPaths(recs), unseen) {
|
|
t.Errorf("%s recorded: the walk entered %s", unseen, excluded)
|
|
}
|
|
|
|
line := "\nscan: skipped " + excluded + " (default exclude)\n"
|
|
if !strings.Contains(stderr(), line) {
|
|
t.Errorf("stderr = %q, want %q", stderr(), line)
|
|
}
|
|
}
|
|
|
|
// TestScanWalksOperandInDefaultExclude checks that an operand at or
|
|
// inside a default exclude is walked, also when it lies under another
|
|
// operand, whose walk does not enter the default exclude.
|
|
func TestScanWalksOperandInDefaultExclude(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
excluded := filepath.Join(dir, "tmp")
|
|
f := writeFile(t, excluded, "sub/f", pattern(1, 10))
|
|
sub := filepath.Dir(f)
|
|
|
|
cases := map[string][]string{
|
|
"at it": {excluded},
|
|
"inside it": {sub},
|
|
"inside it under an operand": {dir, sub},
|
|
}
|
|
|
|
for name, roots := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openTestDB(t)
|
|
|
|
st, err := syncScan(t.Context(), db, roots, 1, false,
|
|
walkRules{defaultExcludes: []string{excluded}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if st.added != 1 {
|
|
t.Errorf("added = %d, want %s recorded once", st.added, f)
|
|
}
|
|
|
|
recordByPath(t, dbRecords(t, db), f)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestScanNamesNoDefaultExcludeThatIsAnOperand checks that a default
|
|
// exclude given as an operand under another operand, which the walk of
|
|
// that other operand does not enter, gets no line saying it was
|
|
// skipped: it is walked as an operand of its own.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestScanNamesNoDefaultExcludeThatIsAnOperand(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
excluded := filepath.Join(dir, "tmp")
|
|
f := writeFile(t, excluded, "f", pattern(1, 10))
|
|
|
|
st, err := syncScan(t.Context(), db, []string{dir, excluded}, 1, false,
|
|
walkRules{defaultExcludes: []string{excluded}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if st.added != 1 {
|
|
t.Errorf("added = %d, want %s recorded once", st.added, f)
|
|
}
|
|
|
|
recordByPath(t, dbRecords(t, db), f)
|
|
|
|
if strings.Contains(stderr(), "scan: skipped") {
|
|
t.Errorf("stderr = %q, want no line for %s", stderr(), excluded)
|
|
}
|
|
}
|
|
|
|
// TestRunScanTempDirOperand scans a tree in the tests' temporary
|
|
// directory, which on Linux lies inside the default exclude /tmp unless
|
|
// TMPDIR names another place, with the system's own default excludes:
|
|
// the scan walks the whole tree and reports no default exclude.
|
|
func TestRunScanTempDirOperand(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
dir := realTempDir(t)
|
|
if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) {
|
|
t.Skip(dir + " is not inside a default exclude")
|
|
}
|
|
|
|
f := writeFile(t, dir, "sub/f", pattern(1, 10))
|
|
|
|
stderr := scanOK(t, dir)
|
|
if strings.Contains(stderr, "scan: skipped") {
|
|
t.Errorf("stderr = %q, want no line saying a path was skipped",
|
|
stderr)
|
|
}
|
|
|
|
assertRecorded(t, path, f, true)
|
|
}
|
|
|
|
// TestRunScanNoDefaultExcludes scans /var through the command. Its
|
|
// /var/tmp, on a persistent filesystem, is a default exclude on Linux:
|
|
// without --no-default-excludes the walk does not enter it and says so,
|
|
// and with that flag the walk records a file the test puts there.
|
|
func TestRunScanNoDefaultExcludes(t *testing.T) {
|
|
if runtime.GOOS != goosLinux {
|
|
t.Skip("only Linux has /var/tmp among its default excludes")
|
|
}
|
|
|
|
nonPersistent, err := nonPersistentFilesystems()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rules := walkRules{nonPersistent: nonPersistent}
|
|
|
|
for _, dir := range []string{"/var", "/var/tmp"} {
|
|
fi, lstatErr := os.Lstat(dir)
|
|
if lstatErr != nil || !fi.IsDir() ||
|
|
rules.nonPersistentLine(dir, fi) != "" {
|
|
t.Skip(dir + " is not a directory on a persistent filesystem here")
|
|
}
|
|
}
|
|
|
|
//nolint:usetesting // the directory must be inside /var/tmp
|
|
tmp, err := os.MkdirTemp("/var/tmp", "sfdupes-test-")
|
|
if err != nil {
|
|
t.Skip("cannot write to /var/tmp here: " + err.Error())
|
|
}
|
|
|
|
t.Cleanup(func() { _ = os.RemoveAll(tmp) })
|
|
|
|
f := writeFile(t, tmp, "f", pattern(1, 10))
|
|
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
line := "scan: skipped /var/tmp (default exclude)\n"
|
|
|
|
if stderr := scanOK(t, "/var"); !strings.Contains(stderr, line) {
|
|
t.Errorf("scan /var: stderr = %q, want %q", stderr, line)
|
|
}
|
|
|
|
assertRecorded(t, path, f, false)
|
|
|
|
stderr := scanOK(t, "--no-default-excludes", "/var")
|
|
if strings.Contains(stderr, line) {
|
|
t.Errorf("scan --no-default-excludes /var: stderr = %q, want no %q",
|
|
stderr, line)
|
|
}
|
|
|
|
assertRecorded(t, path, f, true)
|
|
}
|
|
|
|
// TestRunScanExcludeNested scans a tree through the command with an
|
|
// --exclude path two levels below the operand. The walk records nothing
|
|
// at or below it and says so. The record an earlier scan left there is
|
|
// kept although its file is gone.
|
|
func TestRunScanExcludeNested(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
dir := realTempDir(t)
|
|
excluded := filepath.Join(dir, "a", "b")
|
|
kept := writeFile(t, dir, "a/f", pattern(1, 10))
|
|
gone := writeFile(t, excluded, "gone", pattern(2, 10))
|
|
|
|
scanOK(t, dir)
|
|
|
|
err := os.Remove(gone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
unseen := writeFile(t, excluded, "c/new", pattern(3, 10))
|
|
|
|
stderr := scanOK(t, excludeFlag, excluded, dir)
|
|
|
|
line := "scan: skipped " + excluded + " (--exclude)\n"
|
|
if !strings.Contains(stderr, line) {
|
|
t.Errorf("stderr = %q, want %q", stderr, line)
|
|
}
|
|
|
|
assertRecorded(t, path, kept, true)
|
|
assertRecorded(t, path, gone, true)
|
|
assertRecorded(t, path, unseen, false)
|
|
}
|
|
|
|
// TestRunScanExcludeRepeated gives --exclude twice, once for a
|
|
// directory and once for a file, the file through a symlink to it. The
|
|
// walk records neither, and records the file beside them.
|
|
func TestRunScanExcludeRepeated(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
dir := realTempDir(t)
|
|
inDir := writeFile(t, dir, "skip/f", pattern(1, 10))
|
|
file := writeFile(t, dir, "skip-file", pattern(2, 10))
|
|
kept := writeFile(t, dir, "keep", pattern(3, 10))
|
|
|
|
link := filepath.Join(t.TempDir(), "link")
|
|
symlink(t, file, link)
|
|
|
|
scanOK(t, excludeFlag, filepath.Dir(inDir), excludeFlag, link, dir)
|
|
|
|
assertRecorded(t, path, inDir, false)
|
|
assertRecorded(t, path, file, false)
|
|
assertRecorded(t, path, kept, true)
|
|
}
|
|
|
|
// TestRunScanExcludedOperand checks that an operand at or below an
|
|
// --exclude path is a fatal error that names both, and so is an
|
|
// --exclude path that does not exist. Neither scan writes a database.
|
|
func TestRunScanExcludedOperand(t *testing.T) {
|
|
dir := realTempDir(t)
|
|
sub := filepath.Join(dir, "sub")
|
|
writeFile(t, sub, "f", pattern(1, 10))
|
|
|
|
cases := map[string]struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
"at it": {
|
|
[]string{excludeFlag, sub, sub},
|
|
"scan " + sub + ": operand excluded by --exclude " + sub + "\n",
|
|
},
|
|
"below it": {
|
|
[]string{excludeFlag, dir, sub},
|
|
"scan " + sub + ": operand excluded by --exclude " + dir + "\n",
|
|
},
|
|
"after an operand it does not exclude": {
|
|
[]string{dir, excludeFlag, sub, sub},
|
|
"scan " + sub + ": operand excluded by --exclude " + sub + "\n",
|
|
},
|
|
"exclude missing": {
|
|
[]string{excludeFlag, filepath.Join(dir, "nope"), dir},
|
|
"--exclude: resolve " + filepath.Join(dir, "nope") + ": ",
|
|
},
|
|
}
|
|
|
|
for name, tc := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
stdout := captureStdout(t)
|
|
stderr := captureStderr(t)
|
|
|
|
code := run(append([]string{cmdScan}, tc.args...), os.Stdout,
|
|
os.Stderr)
|
|
if code != exitFatal {
|
|
t.Errorf("run(scan %q) = %d, want %d", tc.args, code,
|
|
exitFatal)
|
|
}
|
|
|
|
assertFatalOutput(t, stderr(), stdout())
|
|
|
|
if !strings.Contains(stderr(), "sfdupes: "+tc.want) {
|
|
t.Errorf("stderr = %q, want %q", stderr(), tc.want)
|
|
}
|
|
|
|
_, err := os.Lstat(path)
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("lstat %s: %v, want no database", path, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunScanRejectsEmptyExclude checks that an empty --exclude value,
|
|
// given as its own argument, after "=" or after another --exclude, is
|
|
// a usage error reported in one line before anything is scanned
|
|
// (README "Settings").
|
|
func TestRunScanRejectsEmptyExclude(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
cases := map[string][]string{
|
|
"separate": {excludeFlag, "", dir},
|
|
"joined by =": {excludeFlag + "=", dir},
|
|
"after a path": {excludeFlag, t.TempDir(), excludeFlag, "", dir},
|
|
}
|
|
|
|
for name, args := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := run(append([]string{cmdScan}, args...), &stdout,
|
|
&stderr)
|
|
if code != exitUsage {
|
|
t.Errorf("run(scan %q) = %d, want %d", args, code,
|
|
exitUsage)
|
|
}
|
|
|
|
want := "Error: --exclude must not be empty\n"
|
|
if stdout.String() != "" || stderr.String() != want {
|
|
t.Errorf("stdout, stderr = %q, %q, want \"\", %q",
|
|
stdout.String(), stderr.String(), want)
|
|
}
|
|
|
|
_, err := os.Lstat(path)
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("lstat %s: %v, want no database", path, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunScanSkipsNonPersistentOperand scans, through the command, a
|
|
// directory on a filesystem that is not persistent, with and without
|
|
// --no-default-excludes: either way it is skipped, with a line saying
|
|
// so, and nothing in it is recorded.
|
|
func TestRunScanSkipsNonPersistentOperand(t *testing.T) {
|
|
shm, fsType, _ := shmTempDir(t)
|
|
writeFile(t, shm, "f", pattern(1, 10))
|
|
|
|
t.Setenv(databaseEnv, testDBPath(t))
|
|
|
|
line := "scan: skipped " + shm + " (" + fsType +
|
|
", not a persistent filesystem)\n"
|
|
summary := "scan: 0 files seen"
|
|
|
|
for _, flags := range [][]string{nil, {"--no-default-excludes"}} {
|
|
stderr := scanOK(t, append(flags, shm)...)
|
|
if !strings.Contains(stderr, line) ||
|
|
!strings.Contains(stderr, summary) {
|
|
t.Errorf("scan %q: stderr = %q, want %q and %q", flags, stderr,
|
|
line, summary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScanKeepsRecordsOnNonPersistent scans a directory on a filesystem
|
|
// that is not persistent beside one on the tests' temporary directory.
|
|
// The records an earlier scan left in it count as outside the operands:
|
|
// the record of a file gone from it is kept, while a file added to it
|
|
// is not recorded.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
|
|
shm, fsType, rules := shmTempDir(t)
|
|
stderr := captureStderr(t)
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
|
|
// A scan that, without the list of filesystems, records gone.
|
|
gone := writeFile(t, shm, "gone", pattern(1, 100))
|
|
syncTree(t, db, shm)
|
|
|
|
err := os.Remove(gone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
unseen := writeFile(t, shm, "new", pattern(2, 10))
|
|
|
|
st, err := syncScan(t.Context(), db, []string{shm, dir}, 1, false, rules)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if st.skipped != 0 {
|
|
t.Errorf("skipped = %d, want 0", st.skipped)
|
|
}
|
|
|
|
recs := dbRecords(t, db)
|
|
recordByPath(t, recs, gone)
|
|
|
|
if slices.Contains(recordPaths(recs), unseen) {
|
|
t.Errorf("%s recorded", unseen)
|
|
}
|
|
|
|
line := "scan: skipped " + shm + " (" + fsType +
|
|
", not a persistent filesystem)\n"
|
|
if !strings.Contains(stderr(), line) {
|
|
t.Errorf("stderr = %q, want %q", stderr(), line)
|
|
}
|
|
}
|
|
|
|
// TestWalkLeavesOutNonPersistent starts the walk itself in a directory
|
|
// on a filesystem that is not persistent, which a scan never does: it
|
|
// drops such an operand first. Below a directory on a persistent
|
|
// filesystem, one on another takes a mount, which a test cannot make.
|
|
// The walk enters no directory and records no file there, and names
|
|
// each.
|
|
func TestWalkLeavesOutNonPersistent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
shm, fsType, rules := shmTempDir(t)
|
|
f := writeFile(t, shm, "f", pattern(1, 10))
|
|
sub := filepath.Dir(writeFile(t, shm, "sub/g", pattern(1, 10)))
|
|
|
|
var leftOut []string
|
|
|
|
for ev := range startWalk(t.Context(), []string{shm}, false, rules, 1) {
|
|
if ev.rec.path != "" {
|
|
t.Errorf("walk recorded %s", ev.rec.path)
|
|
}
|
|
|
|
if ev.leftOut == "" {
|
|
continue
|
|
}
|
|
|
|
leftOut = append(leftOut, ev.leftOut)
|
|
|
|
want := "scan: skipped " + ev.leftOut + " (" + fsType +
|
|
", not a persistent filesystem)"
|
|
if ev.warn != want {
|
|
t.Errorf("line = %q, want %q", ev.warn, want)
|
|
}
|
|
}
|
|
|
|
slices.Sort(leftOut)
|
|
|
|
if want := []string{f, sub}; !slices.Equal(leftOut, want) {
|
|
t.Errorf("walk left out %q, want %q", leftOut, want)
|
|
}
|
|
}
|
|
|
|
// shmTempDir returns a new directory in /dev/shm, removed when the test
|
|
// ends, the type of its filesystem, and the rules holding the real
|
|
// filesystems that are not persistent. It skips the test where /dev/shm
|
|
// is on a persistent filesystem, or cannot be written to.
|
|
func shmTempDir(t *testing.T) (string, string, walkRules) {
|
|
t.Helper()
|
|
|
|
nonPersistent, err := nonPersistentFilesystems()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfPath("/dev/shm")
|
|
|
|
fsType, found := nonPersistent[dev]
|
|
if !ok || !found {
|
|
t.Skip("/dev/shm is not a memory-backed filesystem here")
|
|
}
|
|
|
|
//nolint:usetesting // the directory must be inside /dev/shm
|
|
dir, err := os.MkdirTemp("/dev/shm", "sfdupes-test-")
|
|
if err != nil {
|
|
t.Skip("cannot write to /dev/shm here: " + err.Error())
|
|
}
|
|
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
|
|
return dir, fsType, walkRules{nonPersistent: nonPersistent}
|
|
}
|
|
|
|
// assertRecorded checks that the database at dbPath holds a record for
|
|
// path when want is set, and none when it is not.
|
|
func assertRecorded(t *testing.T, dbPath, path string, want bool) {
|
|
t.Helper()
|
|
|
|
db, err := openDB(dbPath, reportParams)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
if got := slices.Contains(recordPaths(dbRecords(t, db)), path); got != want {
|
|
t.Errorf("%s recorded: %v, want %v", path, got, want)
|
|
}
|
|
}
|