10 Commits

Author SHA1 Message Date
6230bb1c3a Add scripts-to-rule-them-all scaffold (refs #1)
Some checks failed
check / check (push) Failing after 28s
Add the standard STRTA scaffold, mirroring the conformant Go repos:

- script/ POSIX-sh entrypoints (bootstrap, setup, projectname, test,
  lint, fmt, fmt-check, check, docker, cibuild, precommit,
  install-precommit).
- Makefile rewritten as thin shims: .PHONY plus the nine standard
  targets each delegating to script/NAME; repo-specific build, clean,
  and try targets retained.
- .golangci.yml matching the org-standard Go lint config.
- Dockerfile whose build runs make check then make build, so the image
  fails on any check failure.
- .gitea/workflows/check.yml running script/cibuild.

make fmt-check and make test are green. make check is not yet green
because of pre-existing lint findings in the application code, which
are out of scope for this scaffold change; hence refs (not closes).
2026-07-25 18:36:57 +07:00
975116f13d Add standard Workflow section to TODO.md 2026-07-06 21:06:44 +02:00
1bf1c892f4 Add TODO.md 2026-07-06 20:35:51 +02:00
629613de1b Track actual bytes read instead of stale file size
fileMultihash now returns the number of bytes actually read during
hashing. This ensures BytesProcessed reflects the true amount of
data processed, not a potentially stale size from the initial walk.
2026-02-02 13:50:42 -08:00
5c2338d590 Use atomic operations for failure tracking in ProcessCheck
Replace the non-atomic 'bad' bool with atomic comparison of FilesFailed
count before and after the walk. This ensures consistent use of atomic
operations for all shared state and eliminates a potential race if
parallelism is added in the future.
2026-02-02 13:49:12 -08:00
9f86bf1dc1 Detect file modifications during checksum calculation (TOCTOU fix)
- Check file mtime before and after hashing; error if they differ
- Store file's mtime as sumtime instead of wall-clock time
- Use fresh stat for BytesProcessed to get accurate count

This fixes a TOCTOU race where a file could be modified between
hashing and writing the xattr, resulting in a stale checksum.
It also makes sum update comparisons semantically correct by
comparing file mtime against stored mtime rather than wall-clock time.
2026-02-02 13:48:24 -08:00
2e44e5bb78 Return errors from countFiles instead of swallowing them
countFiles and countFilesMultiple now return errors instead of silently
ignoring them. This ensures that issues like non-existent paths or
permission errors are reported early rather than showing a misleading
progress bar with 0 total.
2026-02-02 13:47:40 -08:00
b9d65115c2 Use single progress bar when processing multiple paths
Instead of creating a new progress bar for each path, count total files
across all paths upfront and use a single unified progress bar. This
provides clearer UX when processing multiple directories.
2026-02-02 13:46:18 -08:00
144d2de243 Return error when stdin provides no paths
When using "-" to read paths from stdin, if stdin is empty or contains
only blank lines, return an explicit error instead of silently succeeding
with no work done.
2026-02-02 13:43:18 -08:00
d848c5e51b Remove dead code in symlink handling
filepath.Walk uses Lstat, so symlinks are reported with ModeSymlink set,
never ModeDir. The info.IsDir() check was always false, making the
filepath.SkipDir branch unreachable dead code.
2026-02-02 13:15:39 -08:00
19 changed files with 567 additions and 87 deletions

View File

@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild

32
.golangci.yml Normal file
View File

@@ -0,0 +1,32 @@
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Build stage
# golang 1.25-alpine, 2026-02-28
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.10.1
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee
# goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run all checks - build fails if any check fails
RUN make check
# Build the binary
RUN make build
# Runtime stage
# alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /src/attrsum /app/attrsum
ENTRYPOINT ["/app/attrsum"]

View File

@@ -1,9 +1,38 @@
.PHONY: default bootstrap setup test lint fmt fmt-check check docker hooks build clean try
TESTDIR := $(HOME)/Documents/_SYSADMIN/cyberdyne TESTDIR := $(HOME)/Documents/_SYSADMIN/cyberdyne
default: test # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern.
default: check build
bootstrap:
@script/bootstrap
setup:
@script/setup
test: test:
@go test ./... -v @script/test
lint:
@script/lint
fmt:
@script/fmt
fmt-check:
@script/fmt-check
check:
@script/check
docker:
@script/docker
hooks:
@script/install-precommit
build: clean build: clean
@go build . @go build .
@@ -21,4 +50,3 @@ try: build
touch $(TESTDIR)/* touch $(TESTDIR)/*
./attrsum sum update -v $(TESTDIR) ./attrsum sum update -v $(TESTDIR)
./attrsum check -v $(TESTDIR) ./attrsum check -v $(TESTDIR)

55
TODO.md Normal file
View File

@@ -0,0 +1,55 @@
# Workflow
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status
1.0+
Tagged 1.0.0 (2025-05-08). Substantial correctness fixes and features
have landed since the tag.
# Next Step
Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig,
.golangci.yml, and a comprehensive .gitignore (currently only the
attrsum binary), and extend the Makefile (only test/build/clean/try
today) with lint, fmt, fmt-check, check, and hooks targets. Fix the try
target to use a temp fixture instead of the hardcoded
$(HOME)/Documents/_SYSADMIN/cyberdyne path.
# Completed Steps
* 2026-02-02: correctness pass: track actual bytes read instead of
stale file size, atomic failure tracking in ProcessCheck, detect
file modification during checksum (TOCTOU), propagate countFiles
errors, single progress bar across paths, error on empty stdin,
dead code removal
* 2026-02-01: added quiet mode, progress bar, summary report, and stdin
path input; multiple file/directory arguments for all commands
* 2025-07-12: README update
* 2025-05-08: initial working tool with passing tests, skips
non-regular files, Makefile, README; tagged 1.0.0
# Future Steps
* Add Dockerfile and .dockerignore that run make check, images pinned
by sha256, plus a Makefile docker target
* Add .gitea/workflows/check.yml
* Restructure README.md into the standard sections: Description,
Getting Started, Rationale, Design, TODO, License, Author (Getting
Started, Why?, TODO, License exist; Description, Design, Author are
missing)
* Tag a patch release to ship the 2026-02-02 correctness fixes
* Dry-run mode (--dry-run, -n): show what would be done without making
changes (from README TODO)
* JSON output (--json) for scripting and integration (from README TODO)
* Parallel processing (-j N) with multiple goroutines for faster
checksumming on large trees (from README TODO)
* Formalize and document exit codes for scripting (from README TODO)

View File

@@ -99,8 +99,10 @@ func main() {
// expandPaths expands the given paths, reading from stdin if "-" is present // expandPaths expands the given paths, reading from stdin if "-" is present
func expandPaths(args []string) ([]string, error) { func expandPaths(args []string) ([]string, error) {
var paths []string var paths []string
readFromStdin := false
for _, arg := range args { for _, arg := range args {
if arg == "-" { if arg == "-" {
readFromStdin = true
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
@@ -115,6 +117,12 @@ func expandPaths(args []string) ([]string, error) {
paths = append(paths, arg) paths = append(paths, arg)
} }
} }
if len(paths) == 0 {
if readFromStdin {
return nil, errors.New("no paths provided on stdin")
}
return nil, errors.New("no paths provided")
}
return paths, nil return paths, nil
} }
@@ -138,10 +146,24 @@ func newSumCmd() *cobra.Command {
return err return err
} }
stats := &Stats{StartTime: time.Now()} stats := &Stats{StartTime: time.Now()}
for _, p := range paths { var bar *progressbar.ProgressBar
if err := ProcessSumAdd(p, stats); err != nil { if !quiet {
total, err := countFilesMultiple(paths)
if err != nil {
return err return err
} }
bar = newProgressBar(total, "Adding checksums")
}
for _, p := range paths {
if err := ProcessSumAdd(p, stats, bar); err != nil {
if bar != nil {
bar.Finish()
}
return err
}
}
if bar != nil {
bar.Finish()
} }
stats.Print("sum add") stats.Print("sum add")
return nil return nil
@@ -158,10 +180,24 @@ func newSumCmd() *cobra.Command {
return err return err
} }
stats := &Stats{StartTime: time.Now()} stats := &Stats{StartTime: time.Now()}
for _, p := range paths { var bar *progressbar.ProgressBar
if err := ProcessSumUpdate(p, stats); err != nil { if !quiet {
total, err := countFilesMultiple(paths)
if err != nil {
return err return err
} }
bar = newProgressBar(total, "Updating checksums")
}
for _, p := range paths {
if err := ProcessSumUpdate(p, stats, bar); err != nil {
if bar != nil {
bar.Finish()
}
return err
}
}
if bar != nil {
bar.Finish()
} }
stats.Print("sum update") stats.Print("sum update")
return nil return nil
@@ -172,8 +208,8 @@ func newSumCmd() *cobra.Command {
return cmd return cmd
} }
func ProcessSumAdd(dir string, stats *Stats) error { func ProcessSumAdd(dir string, stats *Stats, bar *progressbar.ProgressBar) error {
return walkAndProcess(dir, stats, "Adding checksums", func(p string, info os.FileInfo, s *Stats) error { return walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
if hasXattr(p, checksumKey) { if hasXattr(p, checksumKey) {
atomic.AddInt64(&s.FilesSkipped, 1) atomic.AddInt64(&s.FilesSkipped, 1)
return nil return nil
@@ -186,8 +222,8 @@ func ProcessSumAdd(dir string, stats *Stats) error {
}) })
} }
func ProcessSumUpdate(dir string, stats *Stats) error { func ProcessSumUpdate(dir string, stats *Stats, bar *progressbar.ProgressBar) error {
return walkAndProcess(dir, stats, "Updating checksums", func(p string, info os.FileInfo, s *Stats) error { return walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
t, err := readSumTime(p) t, err := readSumTime(p)
if err != nil || info.ModTime().After(t) { if err != nil || info.ModTime().After(t) {
if err := writeChecksumAndTime(p, info, s); err != nil { if err := writeChecksumAndTime(p, info, s); err != nil {
@@ -202,10 +238,23 @@ func ProcessSumUpdate(dir string, stats *Stats) error {
} }
func writeChecksumAndTime(path string, info os.FileInfo, stats *Stats) error { func writeChecksumAndTime(path string, info os.FileInfo, stats *Stats) error {
hash, err := fileMultihash(path) // Record mtime before hashing to detect modifications during hash
mtimeBefore := info.ModTime()
hash, bytesRead, err := fileMultihash(path)
if err != nil { if err != nil {
return err return err
} }
// Check if file was modified during hashing
infoAfter, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("stat after hash: %w", err)
}
if !infoAfter.ModTime().Equal(mtimeBefore) {
return fmt.Errorf("%s: file modified during checksum calculation", path)
}
if err := xattr.Set(path, checksumKey, hash); err != nil { if err := xattr.Set(path, checksumKey, hash); err != nil {
return fmt.Errorf("set checksum attr: %w", err) return fmt.Errorf("set checksum attr: %w", err)
} }
@@ -213,7 +262,9 @@ func writeChecksumAndTime(path string, info os.FileInfo, stats *Stats) error {
fmt.Printf("%s %s written\n", path, hash) fmt.Printf("%s %s written\n", path, hash)
} }
ts := time.Now().UTC().Format(time.RFC3339Nano) // Store the file's mtime as sumtime (not wall-clock time)
// This makes update comparisons semantically correct
ts := mtimeBefore.UTC().Format(time.RFC3339Nano)
if err := xattr.Set(path, sumTimeKey, []byte(ts)); err != nil { if err := xattr.Set(path, sumTimeKey, []byte(ts)); err != nil {
return fmt.Errorf("set sumtime attr: %w", err) return fmt.Errorf("set sumtime attr: %w", err)
} }
@@ -222,7 +273,7 @@ func writeChecksumAndTime(path string, info os.FileInfo, stats *Stats) error {
} }
atomic.AddInt64(&stats.FilesProcessed, 1) atomic.AddInt64(&stats.FilesProcessed, 1)
atomic.AddInt64(&stats.BytesProcessed, info.Size()) atomic.AddInt64(&stats.BytesProcessed, bytesRead)
return nil return nil
} }
@@ -249,10 +300,24 @@ func newClearCmd() *cobra.Command {
return err return err
} }
stats := &Stats{StartTime: time.Now()} stats := &Stats{StartTime: time.Now()}
for _, p := range paths { var bar *progressbar.ProgressBar
if err := ProcessClear(p, stats); err != nil { if !quiet {
total, err := countFilesMultiple(paths)
if err != nil {
return err return err
} }
bar = newProgressBar(total, "Clearing checksums")
}
for _, p := range paths {
if err := ProcessClear(p, stats, bar); err != nil {
if bar != nil {
bar.Finish()
}
return err
}
}
if bar != nil {
bar.Finish()
} }
stats.Print("clear") stats.Print("clear")
return nil return nil
@@ -260,8 +325,8 @@ func newClearCmd() *cobra.Command {
} }
} }
func ProcessClear(dir string, stats *Stats) error { func ProcessClear(dir string, stats *Stats, bar *progressbar.ProgressBar) error {
return walkAndProcess(dir, stats, "Clearing checksums", func(p string, info os.FileInfo, s *Stats) error { return walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
cleared := false cleared := false
for _, k := range []string{checksumKey, sumTimeKey} { for _, k := range []string{checksumKey, sumTimeKey} {
v, err := xattr.Get(p, k) v, err := xattr.Get(p, k)
@@ -307,17 +372,31 @@ func newCheckCmd() *cobra.Command {
return err return err
} }
stats := &Stats{StartTime: time.Now()} stats := &Stats{StartTime: time.Now()}
var bar *progressbar.ProgressBar
if !quiet {
total, err := countFilesMultiple(paths)
if err != nil {
return err
}
bar = newProgressBar(total, "Verifying checksums")
}
var finalErr error var finalErr error
for _, p := range paths { for _, p := range paths {
if err := ProcessCheck(p, cont, stats); err != nil { if err := ProcessCheck(p, cont, stats, bar); err != nil {
if cont { if cont {
finalErr = err finalErr = err
} else { } else {
if bar != nil {
bar.Finish()
}
stats.Print("check") stats.Print("check")
return err return err
} }
} }
} }
if bar != nil {
bar.Finish()
}
stats.Print("check") stats.Print("check")
return finalErr return finalErr
}, },
@@ -326,15 +405,15 @@ func newCheckCmd() *cobra.Command {
return cmd return cmd
} }
func ProcessCheck(dir string, cont bool, stats *Stats) error { func ProcessCheck(dir string, cont bool, stats *Stats, bar *progressbar.ProgressBar) error {
fail := errors.New("verification failed") fail := errors.New("verification failed")
bad := false // Track initial failed count to detect failures during this walk
initialFailed := atomic.LoadInt64(&stats.FilesFailed)
err := walkAndProcess(dir, stats, "Verifying checksums", func(p string, info os.FileInfo, s *Stats) error { err := walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
exp, err := xattr.Get(p, checksumKey) exp, err := xattr.Get(p, checksumKey)
if err != nil { if err != nil {
if errors.Is(err, xattr.ENOATTR) { if errors.Is(err, xattr.ENOATTR) {
bad = true
atomic.AddInt64(&s.FilesFailed, 1) atomic.AddInt64(&s.FilesFailed, 1)
if verbose && !quiet { if verbose && !quiet {
fmt.Printf("%s <none> ERROR\n", p) fmt.Printf("%s <none> ERROR\n", p)
@@ -347,18 +426,17 @@ func ProcessCheck(dir string, cont bool, stats *Stats) error {
return err return err
} }
act, err := fileMultihash(p) act, bytesRead, err := fileMultihash(p)
if err != nil { if err != nil {
atomic.AddInt64(&s.FilesFailed, 1) atomic.AddInt64(&s.FilesFailed, 1)
return err return err
} }
ok := bytes.Equal(exp, act) ok := bytes.Equal(exp, act)
if !ok { if !ok {
bad = true
atomic.AddInt64(&s.FilesFailed, 1) atomic.AddInt64(&s.FilesFailed, 1)
} else { } else {
atomic.AddInt64(&s.FilesProcessed, 1) atomic.AddInt64(&s.FilesProcessed, 1)
atomic.AddInt64(&s.BytesProcessed, info.Size()) atomic.AddInt64(&s.BytesProcessed, bytesRead)
} }
if verbose && !quiet { if verbose && !quiet {
status := "OK" status := "OK"
@@ -379,7 +457,8 @@ func ProcessCheck(dir string, cont bool, stats *Stats) error {
} }
return err return err
} }
if bad { // Check if any failures occurred during this walk
if atomic.LoadInt64(&stats.FilesFailed) > initialFailed {
return fail return fail
} }
return nil return nil
@@ -390,17 +469,16 @@ func ProcessCheck(dir string, cont bool, stats *Stats) error {
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// countFiles counts the total number of regular files that will be processed // countFiles counts the total number of regular files that will be processed
func countFiles(root string) int64 { func countFiles(root string) (int64, error) {
var count int64 var count int64
root = filepath.Clean(root) root = filepath.Clean(root)
filepath.Walk(root, func(p string, info os.FileInfo, err error) error { err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return nil return err
} }
// Skip symlinks - note: filepath.Walk uses Lstat, so symlinks are
// reported as ModeSymlink, never as directories. Walk doesn't follow them.
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
if info.IsDir() {
return filepath.SkipDir
}
return nil return nil
} }
rel, _ := filepath.Rel(root, p) rel, _ := filepath.Rel(root, p)
@@ -419,19 +497,25 @@ func countFiles(root string) int64 {
count++ count++
return nil return nil
}) })
return count return count, err
} }
func walkAndProcess(root string, stats *Stats, description string, fn func(string, os.FileInfo, *Stats) error) error { // countFilesMultiple counts files across multiple roots
root = filepath.Clean(root) func countFilesMultiple(roots []string) (int64, error) {
var total int64
for _, root := range roots {
count, err := countFiles(root)
if err != nil {
return total, err
}
total += count
}
return total, nil
}
// Count files first for progress bar // newProgressBar creates a new progress bar with standard options
total := countFiles(root) func newProgressBar(total int64, description string) *progressbar.ProgressBar {
return progressbar.NewOptions64(total,
// Create progress bar
var bar *progressbar.ProgressBar
if !quiet {
bar = progressbar.NewOptions64(total,
progressbar.OptionSetDescription(description), progressbar.OptionSetDescription(description),
progressbar.OptionSetWriter(os.Stderr), progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionShowCount(), progressbar.OptionShowCount(),
@@ -451,19 +535,20 @@ func walkAndProcess(root string, stats *Stats, description string, fn func(strin
) )
} }
func walkAndProcess(root string, stats *Stats, bar *progressbar.ProgressBar, fn func(string, os.FileInfo, *Stats) error) error {
root = filepath.Clean(root)
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
// skip symlinks entirely // Skip symlinks - filepath.Walk uses Lstat, so symlinks are reported
// as ModeSymlink, never as directories. Walk doesn't follow them.
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
if verbose && !quiet { if verbose && !quiet {
log.Printf("skip symlink %s", p) log.Printf("skip symlink %s", p)
} }
if info.IsDir() {
return filepath.SkipDir
}
return nil return nil
} }
@@ -492,10 +577,6 @@ func walkAndProcess(root string, stats *Stats, description string, fn func(strin
return fnErr return fnErr
}) })
if bar != nil {
bar.Finish()
}
return err return err
} }
@@ -523,20 +604,21 @@ func hasXattr(path, key string) bool {
return err == nil return err == nil
} }
func fileMultihash(path string) ([]byte, error) { func fileMultihash(path string) (hash []byte, bytesRead int64, err error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
defer f.Close() defer f.Close()
h := sha256.New() h := sha256.New()
if _, err := io.Copy(h, f); err != nil { bytesRead, err = io.Copy(h, f)
return nil, err if err != nil {
return nil, bytesRead, err
} }
mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256) mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
if err != nil { if err != nil {
return nil, err return nil, bytesRead, err
} }
return []byte(base58.Encode(mh)), nil return []byte(base58.Encode(mh)), bytesRead, nil
} }

View File

@@ -40,7 +40,7 @@ func TestSumAddAndUpdate(t *testing.T) {
f := writeFile(t, dir, "a.txt", "hello") f := writeFile(t, dir, "a.txt", "hello")
if err := ProcessSumAdd(dir, newTestStats()); err != nil { if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("add: %v", err) t.Fatalf("add: %v", err)
} }
if _, err := xattr.Get(f, checksumKey); err != nil { if _, err := xattr.Get(f, checksumKey); err != nil {
@@ -53,7 +53,7 @@ func TestSumAddAndUpdate(t *testing.T) {
now := time.Now().Add(2 * time.Second) now := time.Now().Add(2 * time.Second)
os.Chtimes(f, now, now) os.Chtimes(f, now, now)
if err := ProcessSumUpdate(dir, newTestStats()); err != nil { if err := ProcessSumUpdate(dir, newTestStats(), nil); err != nil {
t.Fatalf("update: %v", err) t.Fatalf("update: %v", err)
} }
tsb2, _ := xattr.Get(f, sumTimeKey) tsb2, _ := xattr.Get(f, sumTimeKey)
@@ -68,17 +68,17 @@ func TestProcessCheckIntegration(t *testing.T) {
skipIfNoXattr(t, dir) skipIfNoXattr(t, dir)
writeFile(t, dir, "b.txt", "world") writeFile(t, dir, "b.txt", "world")
if err := ProcessSumAdd(dir, newTestStats()); err != nil { if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("add: %v", err) t.Fatalf("add: %v", err)
} }
if err := ProcessCheck(dir, false, newTestStats()); err != nil { if err := ProcessCheck(dir, false, newTestStats(), nil); err != nil {
t.Fatalf("check ok: %v", err) t.Fatalf("check ok: %v", err)
} }
f := filepath.Join(dir, "b.txt") f := filepath.Join(dir, "b.txt")
os.WriteFile(f, []byte("corrupt"), 0o644) os.WriteFile(f, []byte("corrupt"), 0o644)
if err := ProcessCheck(dir, false, newTestStats()); err == nil { if err := ProcessCheck(dir, false, newTestStats(), nil); err == nil {
t.Fatalf("expected mismatch error, got nil") t.Fatalf("expected mismatch error, got nil")
} }
} }
@@ -88,11 +88,11 @@ func TestClearRemovesAttrs(t *testing.T) {
skipIfNoXattr(t, dir) skipIfNoXattr(t, dir)
f := writeFile(t, dir, "c.txt", "data") f := writeFile(t, dir, "c.txt", "data")
if err := ProcessSumAdd(dir, newTestStats()); err != nil { if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("add: %v", err) t.Fatalf("add: %v", err)
} }
if err := ProcessClear(dir, newTestStats()); err != nil { if err := ProcessClear(dir, newTestStats(), nil); err != nil {
t.Fatalf("clear: %v", err) t.Fatalf("clear: %v", err)
} }
if _, err := xattr.Get(f, checksumKey); err == nil { if _, err := xattr.Get(f, checksumKey); err == nil {
@@ -117,7 +117,7 @@ func TestExcludeDotfilesAndPatterns(t *testing.T) {
excludePatterns = []string{"*.me"} excludePatterns = []string{"*.me"}
defer func() { excludeDotfiles, excludePatterns = oldDot, oldPat }() defer func() { excludeDotfiles, excludePatterns = oldDot, oldPat }()
if err := ProcessSumAdd(dir, newTestStats()); err != nil { if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("add with excludes: %v", err) t.Fatalf("add with excludes: %v", err)
} }
@@ -143,7 +143,7 @@ func TestSkipBrokenSymlink(t *testing.T) {
} }
// Should not error and should not create xattrs on link // Should not error and should not create xattrs on link
if err := ProcessSumAdd(dir, newTestStats()); err != nil { if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("ProcessSumAdd with symlink: %v", err) t.Fatalf("ProcessSumAdd with symlink: %v", err)
} }
if _, err := xattr.Get(link, checksumKey); err == nil { if _, err := xattr.Get(link, checksumKey); err == nil {
@@ -159,13 +159,13 @@ func TestPermissionErrors(t *testing.T) {
os.Chmod(secret, 0o000) os.Chmod(secret, 0o000)
defer os.Chmod(secret, 0o644) defer os.Chmod(secret, 0o644)
if err := ProcessSumAdd(dir, newTestStats()); err == nil { if err := ProcessSumAdd(dir, newTestStats(), nil); err == nil {
t.Fatalf("expected permission error, got nil") t.Fatalf("expected permission error, got nil")
} }
if err := ProcessSumUpdate(dir, newTestStats()); err == nil { if err := ProcessSumUpdate(dir, newTestStats(), nil); err == nil {
t.Fatalf("expected permission error on update, got nil") t.Fatalf("expected permission error on update, got nil")
} }
if err := ProcessCheck(dir, false, newTestStats()); err == nil { if err := ProcessCheck(dir, false, newTestStats(), nil); err == nil {
t.Fatalf("expected permission error on check, got nil") t.Fatalf("expected permission error on check, got nil")
} }
} }

82
script/bootstrap Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present.
# golangci-lint and goimports are installed via `go install` at the same
# pinned commits the Dockerfile uses (never "latest").
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07 (same pins as the Dockerfile)
# golangci-lint v2.10.1
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee"
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
main() {
cd "$ROOT"
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi
# Lint/format tools, pinned via go install (installs into
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi
if missing goimports; then go install "$GOIMPORTS_REF"; fi
go mod download
echo "bootstrap complete"
}
main "$@"

14
script/check Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

13
script/cibuild Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

14
script/docker Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

13
script/fmt Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
gofmt -s -w .
goimports -w .
}
main "$@"

18
script/fmt-check Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "gofmt: files not formatted:" >&2
echo "$files" >&2
exit 1
fi
}
main "$@"

16
script/install-precommit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

12
script/lint Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run --config .golangci.yml ./...
}
main "$@"

21
script/precommit Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go extra:
# go mod tidy must be a no-op before the checks run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
if ! git diff --exit-code -- go.mod go.sum; then
echo "precommit: go mod tidy changed go.mod/go.sum;" \
"stage the changes and retry" >&2
exit 1
fi
"$SCRIPT_DIR/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "attrsum"
}
main "$@"

13
script/setup Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

12
script/test Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v -race -timeout 30s -cover ./...
}
main "$@"