diff --git a/TODO.md b/TODO.md index bc56565..e0fef95 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,9 @@ today) with lint, fmt, fmt-check, check, and hooks targets. # Completed Steps +* 2026-10-06: `check --continue` keeps going past a file or directory + it cannot read: it counts it as failed, prints the error and the + path on stderr, and checks the rest of the tree * 2026-10-05: golangci-lint settings take effect: canonical `.golangci.yml` (v2 layout, settings under `linters.settings`), golangci-lint pinned at v2.12.2 in `Dockerfile` and diff --git a/attrsum.go b/attrsum.go index 54f25be..a4bdca8 100644 --- a/attrsum.go +++ b/attrsum.go @@ -268,7 +268,7 @@ func newSumCmd(opts *options) *cobra.Command { func processSumAdd( opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar, ) error { - return walkAndProcess(opts, dir, stats, bar, + return walkAndProcess(opts, dir, false, stats, bar, func(p string, info os.FileInfo, s *Stats) error { if hasXattr(p, checksumKey) { atomic.AddInt64(&s.FilesSkipped, 1) @@ -290,7 +290,7 @@ func processSumAdd( func processSumUpdate( opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar, ) error { - return walkAndProcess(opts, dir, stats, bar, + return walkAndProcess(opts, dir, false, stats, bar, func(p string, info os.FileInfo, s *Stats) error { t, err := readSumTime(p) if err != nil || info.ModTime().After(t) { @@ -386,7 +386,7 @@ func newClearCmd(opts *options) *cobra.Command { func processClear( opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar, ) error { - return walkAndProcess(opts, dir, stats, bar, + return walkAndProcess(opts, dir, false, stats, bar, func(p string, info os.FileInfo, s *Stats) error { cleared, err := clearOne(opts, p) if err != nil { @@ -468,8 +468,10 @@ func runCheck(opts *options, args []string, cont bool) error { var bar *progressbar.ProgressBar if !opts.quiet { + // With --continue, a path the count cannot read leaves the run + // without a progress bar; the walk below reports it as failed. bar, err = countAndBar(opts, paths, "Verifying checksums") - if err != nil { + if err != nil && !cont { return err } } @@ -502,7 +504,7 @@ func processCheck( // Track initial failed count to detect failures during this walk. initialFailed := atomic.LoadInt64(&stats.FilesFailed) - err := walkAndProcess(opts, dir, stats, bar, + err := walkAndProcess(opts, dir, cont, stats, bar, func(p string, _ os.FileInfo, s *Stats) error { return checkOne(opts, p, cont, s) }) @@ -527,7 +529,7 @@ func checkOne(opts *options, p string, cont bool, s *Stats) error { exp, err := xattr.Get(p, checksumKey) if err != nil { if !errors.Is(err, xattr.ENOATTR) { - return err + return unreadable(cont, s, err) } return missingChecksum(opts, p, cont, s) @@ -535,9 +537,7 @@ func checkOne(opts *options, p string, cont bool, s *Stats) error { act, bytesRead, err := fileMultihash(p) if err != nil { - atomic.AddInt64(&s.FilesFailed, 1) - - return err + return unreadable(cont, s, err) } ok := bytes.Equal(exp, act) @@ -573,6 +573,21 @@ func missingChecksum(opts *options, p string, cont bool, s *Stats) error { return errVerification } +// unreadable counts a file or directory that could not be read as failed. +// With --continue it prints err, which names the path, to stderr and +// returns nil so the walk goes on; otherwise it returns err. +func unreadable(cont bool, s *Stats, err error) error { + atomic.AddInt64(&s.FilesFailed, 1) + + if !cont { + return err + } + + log.Print(err) + + return nil +} + // reportCheck prints a per-file verification result when verbose output is on. func reportCheck(opts *options, p, actual string, ok bool) { if !opts.verbose || opts.quiet { @@ -672,6 +687,7 @@ func newProgressBar(total int64, description string) *progressbar.ProgressBar { func walkAndProcess( opts *options, root string, + cont bool, stats *Stats, bar *progressbar.ProgressBar, fn func(string, os.FileInfo, *Stats) error, @@ -680,7 +696,7 @@ func walkAndProcess( return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil { - return err + return unreadable(cont, stats, err) } skip, skipErr := walkSkip(opts, root, p, info) diff --git a/attrsum_test.go b/attrsum_test.go index 26d37a2..a3c7ce6 100644 --- a/attrsum_test.go +++ b/attrsum_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "path/filepath" "strings" @@ -271,3 +272,51 @@ func TestPermissionErrors(t *testing.T) { 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) + } +}