Files
attrsum/attrsum_test.go
T
clawbot c4b50e2b55
check / check (push) Successful in 4m10s
Skip excluded paths before looking at their errors (closes #16)
filepath.Walk hands its callback the error for a path it could not
read, such as a directory it cannot list. Both callbacks, in
walkAndProcess and countFiles, returned that error before they checked
the exclusions, so a directory that --exclude or --exclude-dotfiles
excluded still failed the run when it could not be listed. Given an
error, each callback now checks the exclusions first and skips an
excluded path. The new test excludes a directory that cannot be listed
and runs sum add and check over its parent.

Model: opus-5-5
2026-10-06 07:44:00 +02:00

379 lines
8.2 KiB
Go

package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/pkg/xattr"
)
const (
dirPerm = 0o755
filePerm = 0o644
noPerm = 0o000
// futureSkew advances a file's mtime far enough to be unambiguously
// newer than a previously recorded sumtime.
futureSkew = 2 * time.Second
)
// skipIfNoXattr skips the test unless the filesystem under dir supports the
// extended-attribute namespace the program actually uses. Probing with the
// real checksumKey (rather than a user.* key) matters on Linux, where regular
// files only accept xattrs in the user.* namespace and the program's
// berlin.sneak.* keys yield "operation not supported".
func skipIfNoXattr(t *testing.T, dir string) {
t.Helper()
probe := filepath.Join(dir, "xattr-probe")
err := os.WriteFile(probe, []byte("probe"), filePerm)
if err != nil {
t.Fatalf("write probe file: %v", err)
}
defer func() { _ = os.Remove(probe) }()
err = xattr.Set(probe, checksumKey, []byte("1"))
if err != nil {
t.Skipf("skipping: xattr namespace %q not supported: %v", checksumKey, err)
return
}
_ = xattr.Remove(probe, checksumKey)
}
func writeFile(t *testing.T, root, name, content string) string {
t.Helper()
p := filepath.Join(root, name)
err := os.MkdirAll(filepath.Dir(p), dirPerm)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
err = os.WriteFile(p, []byte(content), filePerm)
if err != nil {
t.Fatalf("write: %v", err)
}
return p
}
func newTestStats() *Stats {
return &Stats{StartTime: time.Now()}
}
func TestSumAddAndUpdate(t *testing.T) {
t.Parallel()
opts := &options{}
dir := t.TempDir()
skipIfNoXattr(t, dir)
f := writeFile(t, dir, "a.txt", "hello")
err := processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("add: %v", err)
}
_, err = xattr.Get(f, checksumKey)
if err != nil {
t.Fatalf("checksum missing: %v", err)
}
tsb, _ := xattr.Get(f, sumTimeKey)
origTime, _ := time.Parse(time.RFC3339Nano, string(tsb))
err = os.WriteFile(f, []byte(strings.ToUpper("hello")), filePerm)
if err != nil {
t.Fatalf("rewrite: %v", err)
}
now := time.Now().Add(futureSkew)
err = os.Chtimes(f, now, now)
if err != nil {
t.Fatalf("chtimes: %v", err)
}
err = processSumUpdate(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("update: %v", err)
}
tsb2, _ := xattr.Get(f, sumTimeKey)
newTime, _ := time.Parse(time.RFC3339Nano, string(tsb2))
if !newTime.After(origTime) {
t.Fatalf("sumtime not updated")
}
}
func TestProcessCheckIntegration(t *testing.T) {
t.Parallel()
opts := &options{}
dir := t.TempDir()
skipIfNoXattr(t, dir)
writeFile(t, dir, "b.txt", "world")
err := processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("add: %v", err)
}
err = processCheck(opts, dir, false, newTestStats(), nil)
if err != nil {
t.Fatalf("check ok: %v", err)
}
f := filepath.Join(dir, "b.txt")
err = os.WriteFile(f, []byte("corrupt"), filePerm)
if err != nil {
t.Fatalf("corrupt: %v", err)
}
err = processCheck(opts, dir, false, newTestStats(), nil)
if err == nil {
t.Fatalf("expected mismatch error, got nil")
}
}
func TestClearRemovesAttrs(t *testing.T) {
t.Parallel()
opts := &options{}
dir := t.TempDir()
skipIfNoXattr(t, dir)
f := writeFile(t, dir, "c.txt", "data")
err := processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("add: %v", err)
}
err = processClear(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("clear: %v", err)
}
_, err = xattr.Get(f, checksumKey)
if err == nil {
t.Fatalf("checksum still present after clear")
}
_, err = xattr.Get(f, sumTimeKey)
if err == nil {
t.Fatalf("sumtime still present after clear")
}
}
func TestExcludeDotfilesAndPatterns(t *testing.T) {
t.Parallel()
opts := &options{
excludeDotfiles: true,
excludePatterns: []string{"*.me"},
}
dir := t.TempDir()
skipIfNoXattr(t, dir)
hidden := writeFile(t, dir, ".hidden", "dot")
keep := writeFile(t, dir, "keep.txt", "keep")
skip := writeFile(t, dir, "skip.me", "skip")
err := processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("add with excludes: %v", err)
}
_, err = xattr.Get(keep, checksumKey)
if err != nil {
t.Fatalf("expected xattr on keep.txt: %v", err)
}
_, err = xattr.Get(hidden, checksumKey)
if err == nil {
t.Fatalf(".hidden should have been excluded")
}
_, err = xattr.Get(skip, checksumKey)
if err == nil {
t.Fatalf("skip.me should have been excluded")
}
}
func TestExcludeUnreadableDir(t *testing.T) {
t.Parallel()
opts := &options{excludePatterns: []string{"locked"}}
dir := t.TempDir()
skipIfNoXattr(t, dir)
keep := writeFile(t, dir, "keep.txt", "keep")
hidden := writeFile(t, dir, "locked/a.txt", "hidden")
// The excluded directory cannot be listed.
sub := filepath.Join(dir, "locked")
err := os.Chmod(sub, noPerm)
if err != nil {
t.Fatalf("chmod dir: %v", err)
}
defer func() { _ = os.Chmod(sub, dirPerm) }()
err = processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("add: %v", err)
}
_, err = xattr.Get(keep, checksumKey)
if err != nil {
t.Fatalf("expected xattr on keep.txt: %v", err)
}
// Without --quiet, runCheck counts the files for the progress bar
// before it checks any, so this also covers the count.
err = runCheck(opts, []string{dir}, false)
if err != nil {
t.Fatalf("check: %v", err)
}
err = os.Chmod(sub, dirPerm)
if err != nil {
t.Fatalf("chmod dir back: %v", err)
}
_, err = xattr.Get(hidden, checksumKey)
if err == nil {
t.Fatalf("locked/a.txt should have been excluded")
}
}
func TestSkipBrokenSymlink(t *testing.T) {
t.Parallel()
opts := &options{}
dir := t.TempDir()
skipIfNoXattr(t, dir)
// Create a dangling symlink.
link := filepath.Join(dir, "dangling.lnk")
err := os.Symlink(filepath.Join(dir, "nonexistent.txt"), link)
if err != nil {
t.Fatalf("symlink: %v", err)
}
// Should not error and should not create xattrs on link.
err = processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("processSumAdd with symlink: %v", err)
}
_, err = xattr.Get(link, checksumKey)
if err == nil {
t.Fatalf("symlink should not have xattr")
}
}
func TestPermissionErrors(t *testing.T) {
t.Parallel()
opts := &options{}
dir := t.TempDir()
skipIfNoXattr(t, dir)
secret := writeFile(t, dir, "secret.txt", "data")
err := os.Chmod(secret, noPerm)
if err != nil {
t.Fatalf("chmod: %v", err)
}
defer func() { _ = os.Chmod(secret, filePerm) }()
err = processSumAdd(opts, dir, newTestStats(), nil)
if err == nil {
t.Fatalf("expected permission error, got nil")
}
err = processSumUpdate(opts, dir, newTestStats(), nil)
if err == nil {
t.Fatalf("expected permission error on update, got nil")
}
err = processCheck(opts, dir, false, newTestStats(), nil)
if err == nil {
t.Fatalf("expected permission error on check, got nil")
}
}
func TestCheckContinuePastUnreadable(t *testing.T) {
t.Parallel()
opts := &options{}
dir := t.TempDir()
skipIfNoXattr(t, dir)
writeFile(t, dir, "a.txt", "one")
secret := writeFile(t, dir, "b.txt", "two")
writeFile(t, dir, "c/d.txt", "three")
writeFile(t, dir, "e.txt", "four")
err := processSumAdd(opts, dir, newTestStats(), nil)
if err != nil {
t.Fatalf("add: %v", err)
}
// An unreadable file and an unlistable directory sit between the
// readable files a.txt and e.txt.
sub := filepath.Join(dir, "c")
err = os.Chmod(secret, noPerm)
if err != nil {
t.Fatalf("chmod file: %v", err)
}
defer func() { _ = os.Chmod(secret, filePerm) }()
err = os.Chmod(sub, noPerm)
if err != nil {
t.Fatalf("chmod dir: %v", err)
}
defer func() { _ = os.Chmod(sub, dirPerm) }()
stats := newTestStats()
err = processCheck(opts, dir, true, stats, nil)
if !errors.Is(err, errVerification) {
t.Fatalf("expected verification error, got %v", err)
}
if stats.FilesProcessed != 2 || stats.FilesFailed != 2 {
t.Fatalf("expected 2 verified and 2 failed, got %d and %d",
stats.FilesProcessed, stats.FilesFailed)
}
// Without --quiet, runCheck counts the files for the progress bar
// before it checks any, so the count reaches the unlistable directory
// first.
err = runCheck(opts, []string{dir}, true)
if !errors.Is(err, errVerification) {
t.Fatalf("expected verification error from runCheck, got %v", err)
}
}