Files
attrsum/attrsum_test.go
T
clawbot 9e55fd7113
check / check (push) Successful in 2m13s
Print the byte unit once in the summary line (closes #17)
formatBytes already ends in a unit, so the summary line read "9 B
bytes" or "1.0 KiB bytes". The format string in Stats.Print no longer
adds the word "bytes".

Print now takes the io.Writer to print to; its callers pass os.Stderr,
and the new test passes a buffer to read the line for one small and
one large byte count.

Model: opus-5-5
2026-10-06 08:27:08 +02:00

404 lines
8.7 KiB
Go

package main
import (
"bytes"
"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)
}
}
func TestSummaryPrintsByteUnitOnce(t *testing.T) {
t.Parallel()
tests := []struct {
bytesProcessed int64
want string
}{
{9, ", 9 B in "},
{1536, ", 1.5 KiB in "},
}
for _, tt := range tests {
var out bytes.Buffer
stats := newTestStats()
stats.BytesProcessed = tt.bytesProcessed
stats.Print(&out, &options{}, "check")
if !strings.Contains(out.String(), tt.want) {
t.Errorf("summary %q does not contain %q", out.String(), tt.want)
}
}
}