Compare commits

..

13 Commits

Author SHA1 Message Date
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
86764abadf Add quiet mode, progress bar, summary report, and stdin support
- Add -q/--quiet flag to suppress all output except errors
- Add progress bar with 250ms refresh, file count, and ETA display
- Print summary report to stderr on completion (files processed, skipped, failed, bytes, duration)
- Support reading paths from stdin with "-" argument (e.g., find | attrsum sum add -)
- Update README with new features and updated TODO section
2026-02-01 04:22:13 -08:00
c856ea25be Support multiple file/directory arguments for all commands
- Change sum add, sum update, check, and clear to accept 1+ paths
- Update README usage examples to show multiple path support
- Add TODO section with planned future improvements
2026-02-01 03:21:36 -08:00
9ad48fb9b0 Update README.md 2025-07-12 08:40:00 +00:00
fffa380270 add README 2025-05-08 14:19:51 -07:00
ebbe20dbdf now skips all but regular files 2025-05-08 14:10:38 -07:00
1bb9528548 seems to work, tests pass. woo! 2025-05-08 13:59:35 -07:00
6 changed files with 724 additions and 122 deletions

View File

@@ -1,7 +1,24 @@
TESTDIR := $(HOME)/Documents/_SYSADMIN/cyberdyne
default: test default: test
test: test:
go test ./... -v @go test ./... -v
build: clean
@go build .
clean:
@rm -f attrsum
try: build
./attrsum sum add -v $(TESTDIR)
./attrsum check -v $(TESTDIR)
./attrsum clear -v $(TESTDIR)
-./attrsum check -v $(TESTDIR)
./attrsum sum add -v $(TESTDIR)
./attrsum check -v $(TESTDIR)
touch $(TESTDIR)/*
./attrsum sum update -v $(TESTDIR)
./attrsum check -v $(TESTDIR)
build:
go build .

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
[**attrsum**](https://git.eeqj.de/sneak/attrsum/) is a **Go
1.22** command-line utility that **adds, updates, verifies, and clears per-file
file content checksums stored in extended attributes (xattrs) on macOS (APFS) and
Linux**, released under the [WTFPL v2](http://www.wtfpl.net/).
Original release 2025-05-08.
Current version 1.0 (2025-05-08).
---
## Getting Started — Quick Build
```bash
# prerequisites: Go 1.22+
git clone https://git.eeqj.de/sneak/attrsum.git
cd attrsum
go build -o attrsum .
```
### Install
```bash
go install git.eeqj.de/sneak/attrsum@latest # into GOPATH/bin or $(go env GOBIN)
```
Semantic Versioning 2.0.0 is used for tags.
---
## Usage
```bash
# add checksum & timestamp xattrs to every regular file under one or more paths
attrsum sum add DIR1 DIR2 file.txt
# update checksum only when file mtime is newer than stored sumtime
attrsum sum update DIR1 DIR2
# verify checksums, stop on first error
attrsum check DIR1 DIR2
# verify every file, reporting each result, keep going after errors
attrsum -v check --continue DIR1 DIR2
# remove checksum & timestamp xattrs
attrsum clear DIR1 DIR2
# read paths from stdin (use - as argument)
find /data -name "*.jpg" | attrsum sum add -
# quiet mode (suppress progress bar and summary)
attrsum -q sum add DIR
```
| xattr key | meaning |
|---------------------------------------------|--------------------------------|
| `berlin.sneak.app.attrsum.checksum` | base-58 multihash (sha2-256) |
| `berlin.sneak.app.attrsum.sumtime` | RFC 3339 timestamp of checksum |
Flags:
* `-v, --verbose` — per-file log output
* `-q, --quiet` — suppress all output except errors (no progress bar or summary)
* `--exclude PATTERN` — skip paths matching rsync/Doublestar glob
* `--exclude-dotfiles` — skip any path component that starts with `.`
All commands display a progress bar with ETA and print a summary report to stderr on completion (unless `--quiet` is specified).
`attrsum` **never follows symlinks** and skips non-regular files (sockets, devices, …).
---
## Why?
Apple APFS and Linux ext3/ext4 **store no per-file content checksums**, so
silent data corruption can pass unnoticed. `attrsum` keeps a portable checksum **inside each files xattrs**, providing integrity
verification that travels with the file itself—no external database
required. Now you can trust a USB stick didn't eat your data.
---
## TODO
Future improvements under consideration:
- **Dry-run mode (`--dry-run`, `-n`)** — show what would be done without making changes
- **JSON output (`--json`)** — machine-readable output for scripting and integration
- **Parallel processing (`-j N`)** — use multiple goroutines for faster checksumming on large trees
- **Exit code documentation** — formalize and document exit codes for scripting
---
## Contributing
* Author & maintainer: **sneak** <sneak@sneak.berlin>
* Issues / PRs: <https://git.eeqj.de/sneak/attrsum/>
* Code must pass `go vet`, `go test ./...`, and `go fmt`.
* No CLA; contributions are under WTFPL v2.
---
## Community & Support
Bug tracker and wiki are in the Gitea repo linked above.
No formal Code of Conduct; be excellent to each other.
---
## License
*Everything is permitted.*
See [WTFPL v2](http://www.wtfpl.net/txt/copying/).

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"bufio"
"bytes" "bytes"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
@@ -9,42 +10,124 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic"
"time" "time"
"github.com/bmatcuk/doublestar/v4"
base58 "github.com/mr-tron/base58/base58" base58 "github.com/mr-tron/base58/base58"
"github.com/multiformats/go-multihash" "github.com/multiformats/go-multihash"
"github.com/pkg/xattr" "github.com/pkg/xattr"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const ( const (
// Extended-attribute keys
checksumKey = "berlin.sneak.app.attrsum.checksum" checksumKey = "berlin.sneak.app.attrsum.checksum"
sumTimeKey = "berlin.sneak.app.attrsum.sumtime" sumTimeKey = "berlin.sneak.app.attrsum.sumtime"
) )
var verbose bool var (
verbose bool
quiet bool
excludePatterns []string
excludeDotfiles bool
)
// Stats tracks operation statistics for summary reporting
type Stats struct {
FilesProcessed int64
FilesSkipped int64
FilesFailed int64
BytesProcessed int64
StartTime time.Time
}
func (s *Stats) Duration() time.Duration {
return time.Since(s.StartTime)
}
func (s *Stats) Print(operation string) {
if quiet {
return
}
fmt.Fprintf(os.Stderr, "\n%s complete: %d files processed, %d skipped, %d failed, %s bytes in %s\n",
operation,
s.FilesProcessed,
s.FilesSkipped,
s.FilesFailed,
formatBytes(s.BytesProcessed),
s.Duration().Round(time.Millisecond),
)
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
func main() { func main() {
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "attrsum", Use: "attrsum",
Short: "Compute and verify file checksums via xattrs", Short: "Compute and verify file checksums via xattrs",
} }
rootCmd.SilenceUsage = true
rootCmd.SilenceErrors = true
rootCmd.PersistentFlags().BoolVarP( rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output")
&verbose, "verbose", "v", false, "enable verbose output", rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "suppress all output except errors")
) rootCmd.PersistentFlags().StringArrayVar(&excludePatterns, "exclude", nil, "exclude files/directories matching pattern (rsync-style, repeatable)")
rootCmd.PersistentFlags().BoolVar(&excludeDotfiles, "exclude-dotfiles", false, "exclude any file or directory whose name starts with '.'")
rootCmd.AddCommand(newSumCmd()) rootCmd.AddCommand(newSumCmd())
rootCmd.AddCommand(newCheckCmd()) rootCmd.AddCommand(newCheckCmd())
rootCmd.AddCommand(newClearCmd())
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
// expandPaths expands the given paths, reading from stdin if "-" is present
func expandPaths(args []string) ([]string, error) {
var paths []string
readFromStdin := false
for _, arg := range args {
if arg == "-" {
readFromStdin = true
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
paths = append(paths, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading stdin: %w", err)
}
} else {
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
}
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// Sum operations // Sum commands
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
func newSumCmd() *cobra.Command { func newSumCmd() *cobra.Command {
@@ -53,72 +136,144 @@ func newSumCmd() *cobra.Command {
Short: "Checksum maintenance operations", Short: "Checksum maintenance operations",
} }
addCmd := &cobra.Command{ add := &cobra.Command{
Use: "add <directory>", Use: "add <path>... (use - to read paths from stdin)",
Short: "Write checksums for files missing them", Short: "Write checksums for files missing them",
Args: cobra.ExactArgs(1), Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, a []string) error {
return ProcessSumAdd(args[0]) paths, err := expandPaths(a)
},
}
updateCmd := &cobra.Command{
Use: "update <directory>",
Short: "Refresh checksums when file modified",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
return ProcessSumUpdate(args[0])
},
}
cmd.AddCommand(addCmd, updateCmd)
return cmd
}
// ProcessSumAdd writes checksum & sumtime only when checksum is absent.
func ProcessSumAdd(dir string) error {
return walkAndProcess(dir, func(path string, info os.FileInfo) error {
if hasXattr(path, checksumKey) {
if verbose {
log.Printf("skip existing %s", path)
}
return nil
}
return writeChecksumAndTime(path)
})
}
// ProcessSumUpdate recalculates checksum when file mtime exceeds stored
// sumtime (or when attributes are missing / malformed).
func ProcessSumUpdate(dir string) error {
return walkAndProcess(dir, func(path string, info os.FileInfo) error {
needUpdate := false
t, err := readSumTime(path)
if err != nil || info.ModTime().After(t) {
needUpdate = true
}
if needUpdate {
if verbose {
log.Printf("update %s", path)
}
return writeChecksumAndTime(path)
}
return nil
})
}
func writeChecksumAndTime(path string) error {
hash, err := fileMultihash(path)
if err != nil { if err != nil {
return err return err
} }
stats := &Stats{StartTime: time.Now()}
var bar *progressbar.ProgressBar
if !quiet {
total, err := countFilesMultiple(paths)
if err != nil {
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")
return nil
},
}
upd := &cobra.Command{
Use: "update <path>... (use - to read paths from stdin)",
Short: "Recalculate checksum when file newer than stored sumtime",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, a []string) error {
paths, err := expandPaths(a)
if err != nil {
return err
}
stats := &Stats{StartTime: time.Now()}
var bar *progressbar.ProgressBar
if !quiet {
total, err := countFilesMultiple(paths)
if err != nil {
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")
return nil
},
}
cmd.AddCommand(add, upd)
return cmd
}
func ProcessSumAdd(dir string, stats *Stats, bar *progressbar.ProgressBar) error {
return walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
if hasXattr(p, checksumKey) {
atomic.AddInt64(&s.FilesSkipped, 1)
return nil
}
if err := writeChecksumAndTime(p, info, s); err != nil {
atomic.AddInt64(&s.FilesFailed, 1)
return err
}
return nil
})
}
func ProcessSumUpdate(dir string, stats *Stats, bar *progressbar.ProgressBar) error {
return walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
t, err := readSumTime(p)
if err != nil || info.ModTime().After(t) {
if err := writeChecksumAndTime(p, info, s); err != nil {
atomic.AddInt64(&s.FilesFailed, 1)
return err
}
} else {
atomic.AddInt64(&s.FilesSkipped, 1)
}
return nil
})
}
func writeChecksumAndTime(path string, info os.FileInfo, stats *Stats) error {
// Record mtime before hashing to detect modifications during hash
mtimeBefore := info.ModTime()
hash, bytesRead, err := fileMultihash(path)
if err != nil {
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)
} }
nowStr := time.Now().UTC().Format(time.RFC3339Nano) if verbose && !quiet {
if err := xattr.Set(path, sumTimeKey, []byte(nowStr)); err != nil { fmt.Printf("%s %s written\n", path, hash)
}
// 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 {
return fmt.Errorf("set sumtime attr: %w", err) return fmt.Errorf("set sumtime attr: %w", err)
} }
if verbose && !quiet {
fmt.Printf("%s %s written\n", path, ts)
}
atomic.AddInt64(&stats.FilesProcessed, 1)
atomic.AddInt64(&stats.BytesProcessed, bytesRead)
return nil return nil
} }
@@ -131,68 +286,181 @@ func readSumTime(path string) (time.Time, error) {
} }
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// Check operation // Clear command
///////////////////////////////////////////////////////////////////////////////
func newClearCmd() *cobra.Command {
return &cobra.Command{
Use: "clear <path>... (use - to read paths from stdin)",
Short: "Remove checksum xattrs from tree",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, a []string) error {
paths, err := expandPaths(a)
if err != nil {
return err
}
stats := &Stats{StartTime: time.Now()}
var bar *progressbar.ProgressBar
if !quiet {
total, err := countFilesMultiple(paths)
if err != nil {
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")
return nil
},
}
}
func ProcessClear(dir string, stats *Stats, bar *progressbar.ProgressBar) error {
return walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
cleared := false
for _, k := range []string{checksumKey, sumTimeKey} {
v, err := xattr.Get(p, k)
if err != nil {
if errors.Is(err, xattr.ENOATTR) {
continue
}
atomic.AddInt64(&s.FilesFailed, 1)
return err
}
if verbose && !quiet {
fmt.Printf("%s %s removed\n", p, string(v))
}
if err := xattr.Remove(p, k); err != nil {
atomic.AddInt64(&s.FilesFailed, 1)
return err
}
cleared = true
}
if cleared {
atomic.AddInt64(&s.FilesProcessed, 1)
atomic.AddInt64(&s.BytesProcessed, info.Size())
} else {
atomic.AddInt64(&s.FilesSkipped, 1)
}
return nil
})
}
///////////////////////////////////////////////////////////////////////////////
// Check command
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
func newCheckCmd() *cobra.Command { func newCheckCmd() *cobra.Command {
var cont bool var cont bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "check <directory>", Use: "check <path>... (use - to read paths from stdin)",
Short: "Verify stored checksums against file contents", Short: "Verify stored checksums",
Args: cobra.ExactArgs(1), Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, a []string) error {
return ProcessCheck(args[0], cont) paths, err := expandPaths(a)
if err != nil {
return err
}
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
for _, p := range paths {
if err := ProcessCheck(p, cont, stats, bar); err != nil {
if cont {
finalErr = err
} else {
if bar != nil {
bar.Finish()
}
stats.Print("check")
return err
}
}
}
if bar != nil {
bar.Finish()
}
stats.Print("check")
return finalErr
}, },
} }
cmd.Flags().BoolVar(&cont, "continue", false, "continue after errors and report each file")
cmd.Flags().BoolVar(&cont, "continue", false,
"continue after errors and report each file")
return cmd return cmd
} }
// ProcessCheck verifies checksums and exits non-zero on first error unless func ProcessCheck(dir string, cont bool, stats *Stats, bar *progressbar.ProgressBar) error {
// --continue is supplied. fail := errors.New("verification failed")
func ProcessCheck(dir string, cont bool) error { // Track initial failed count to detect failures during this walk
exitErr := errors.New("verification failed") initialFailed := atomic.LoadInt64(&stats.FilesFailed)
err := walkAndProcess(dir, func(path string, info os.FileInfo) error { err := walkAndProcess(dir, stats, bar, func(p string, info os.FileInfo, s *Stats) error {
exp, err := xattr.Get(path, 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) {
log.Printf("ERROR missing xattr %s", path) atomic.AddInt64(&s.FilesFailed, 1)
if verbose && !quiet {
fmt.Printf("%s <none> ERROR\n", p)
}
if cont { if cont {
return nil return nil
} }
return exitErr return fail
} }
return err return err
} }
act, err := fileMultihash(path) act, bytesRead, err := fileMultihash(p)
if err != nil { if err != nil {
atomic.AddInt64(&s.FilesFailed, 1)
return err return err
} }
ok := bytes.Equal(exp, act)
if !bytes.Equal(exp, act) { if !ok {
log.Printf("ERROR checksum mismatch %s", path) atomic.AddInt64(&s.FilesFailed, 1)
if cont { } else {
return nil atomic.AddInt64(&s.FilesProcessed, 1)
atomic.AddInt64(&s.BytesProcessed, bytesRead)
} }
return exitErr if verbose && !quiet {
status := "OK"
if !ok {
status = "ERROR"
} }
if cont { fmt.Printf("%s %s %s\n", p, act, status)
fmt.Printf("OK %s\n", path) }
if !ok && !cont {
return fail
} }
return nil return nil
}) })
if err != nil { if err != nil {
if errors.Is(err, exitErr) { if errors.Is(err, fail) {
return exitErr return fail
} }
return err return err
} }
// Check if any failures occurred during this walk
if atomic.LoadInt64(&stats.FilesFailed) > initialFailed {
return fail
}
return nil return nil
} }
@@ -200,16 +468,135 @@ func ProcessCheck(dir string, cont bool) error {
// Helpers // Helpers
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
func walkAndProcess(root string, fn func(string, os.FileInfo) error) error { // countFiles counts the total number of regular files that will be processed
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { func countFiles(root string) (int64, error) {
var count int64
root = filepath.Clean(root)
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 - 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 {
return nil
}
rel, _ := filepath.Rel(root, p)
if shouldExclude(rel, info) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if info.IsDir() { if info.IsDir() {
return nil return nil
} }
return fn(path, info) if !info.Mode().IsRegular() {
return nil
}
count++
return nil
}) })
return count, err
}
// countFilesMultiple counts files across multiple roots
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
}
// newProgressBar creates a new progress bar with standard options
func newProgressBar(total int64, description string) *progressbar.ProgressBar {
return progressbar.NewOptions64(total,
progressbar.OptionSetDescription(description),
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionShowCount(),
progressbar.OptionShowIts(),
progressbar.OptionSetItsString("files"),
progressbar.OptionThrottle(250*time.Millisecond),
progressbar.OptionShowElapsedTimeOnFinish(),
progressbar.OptionSetPredictTime(true),
progressbar.OptionFullWidth(),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "=",
SaucerHead: ">",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
)
}
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 {
if err != nil {
return err
}
// 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 verbose && !quiet {
log.Printf("skip symlink %s", p)
}
return nil
}
rel, _ := filepath.Rel(root, p)
if shouldExclude(rel, info) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if info.IsDir() {
return nil
}
if !info.Mode().IsRegular() {
if verbose && !quiet {
log.Printf("skip non-regular %s", p)
}
return nil
}
fnErr := fn(p, info, stats)
if bar != nil {
bar.Add(1)
}
return fnErr
})
return err
}
func shouldExclude(rel string, info os.FileInfo) bool {
if rel == "." || rel == "" {
return false
}
if excludeDotfiles {
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
if strings.HasPrefix(part, ".") {
return true
}
}
}
for _, pat := range excludePatterns {
if ok, _ := doublestar.PathMatch(pat, rel); ok {
return true
}
}
return false
} }
func hasXattr(path, key string) bool { func hasXattr(path, key string) bool {
@@ -217,22 +604,21 @@ func hasXattr(path, key string) bool {
return err == nil return err == nil
} }
// fileMultihash returns the base58-encoded SHA-2-256 multihash of the file. func fileMultihash(path string) (hash []byte, bytesRead int64, err error) {
func fileMultihash(path string) ([]byte, 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

@@ -10,7 +10,6 @@ import (
"github.com/pkg/xattr" "github.com/pkg/xattr"
) )
// skipIfNoXattr skips tests when the underlying FS lacks xattr support.
func skipIfNoXattr(t *testing.T, path string) { func skipIfNoXattr(t *testing.T, path string) {
if err := xattr.Set(path, "user.test", []byte("1")); err != nil { if err := xattr.Set(path, "user.test", []byte("1")); err != nil {
t.Skipf("skipping: xattr not supported: %v", err) t.Skipf("skipping: xattr not supported: %v", err)
@@ -21,14 +20,18 @@ func skipIfNoXattr(t *testing.T, path string) {
func writeFile(t *testing.T, root, name, content string) string { func writeFile(t *testing.T, root, name, content string) string {
t.Helper() t.Helper()
path := filepath.Join(root, name) p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
if err := os.WriteFile(path, []byte(content), 0o644); err != nil { if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
} }
return path return p
}
func newTestStats() *Stats {
return &Stats{StartTime: time.Now()}
} }
func TestSumAddAndUpdate(t *testing.T) { func TestSumAddAndUpdate(t *testing.T) {
@@ -37,30 +40,20 @@ func TestSumAddAndUpdate(t *testing.T) {
f := writeFile(t, dir, "a.txt", "hello") f := writeFile(t, dir, "a.txt", "hello")
// Add: write missing checksum. if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
if err := ProcessSumAdd(dir); err != nil {
t.Fatalf("add: %v", err) t.Fatalf("add: %v", err)
} }
// Attributes should exist.
if _, err := xattr.Get(f, checksumKey); err != nil { if _, err := xattr.Get(f, checksumKey); err != nil {
t.Fatalf("checksum missing: %v", err) t.Fatalf("checksum missing: %v", err)
} }
tsb, err := xattr.Get(f, sumTimeKey) tsb, _ := xattr.Get(f, sumTimeKey)
if err != nil {
t.Fatalf("sumtime missing: %v", err)
}
origTime, _ := time.Parse(time.RFC3339Nano, string(tsb)) origTime, _ := time.Parse(time.RFC3339Nano, string(tsb))
// Modify file and bump mtime. os.WriteFile(f, []byte(strings.ToUpper("hello")), 0o644)
if err := os.WriteFile(f, []byte(strings.ToUpper("hello")), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
now := time.Now().Add(2 * time.Second) now := time.Now().Add(2 * time.Second)
os.Chtimes(f, now, now) os.Chtimes(f, now, now)
// Update should refresh checksum and time. if err := ProcessSumUpdate(dir, newTestStats(), nil); err != nil {
if err := ProcessSumUpdate(dir); err != nil {
t.Fatalf("update: %v", err) t.Fatalf("update: %v", err)
} }
tsb2, _ := xattr.Get(f, sumTimeKey) tsb2, _ := xattr.Get(f, sumTimeKey)
@@ -75,23 +68,89 @@ 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); 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(), nil); err != nil {
if err := ProcessCheck(dir, false); err != nil {
t.Fatalf("check ok: %v", err) t.Fatalf("check ok: %v", err)
} }
// Corrupt file contents should produce an error.
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); 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")
} }
} }
func TestClearRemovesAttrs(t *testing.T) {
dir := t.TempDir()
skipIfNoXattr(t, dir)
f := writeFile(t, dir, "c.txt", "data")
if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("add: %v", err)
}
if err := ProcessClear(dir, newTestStats(), nil); err != nil {
t.Fatalf("clear: %v", err)
}
if _, err := xattr.Get(f, checksumKey); err == nil {
t.Fatalf("checksum still present after clear")
}
if _, err := xattr.Get(f, sumTimeKey); err == nil {
t.Fatalf("sumtime still present after clear")
}
}
func TestExcludeDotfilesAndPatterns(t *testing.T) {
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")
oldDot := excludeDotfiles
oldPat := excludePatterns
excludeDotfiles = true
excludePatterns = []string{"*.me"}
defer func() { excludeDotfiles, excludePatterns = oldDot, oldPat }()
if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("add with excludes: %v", err)
}
if _, err := xattr.Get(keep, checksumKey); err != nil {
t.Fatalf("expected xattr on keep.txt: %v", err)
}
if _, err := xattr.Get(hidden, checksumKey); err == nil {
t.Fatalf(".hidden should have been excluded")
}
if _, err := xattr.Get(skip, checksumKey); err == nil {
t.Fatalf("skip.me should have been excluded")
}
}
func TestSkipBrokenSymlink(t *testing.T) {
dir := t.TempDir()
skipIfNoXattr(t, dir)
// Create a dangling symlink
link := filepath.Join(dir, "dangling.lnk")
if err := os.Symlink(filepath.Join(dir, "nonexistent.txt"), link); err != nil {
t.Fatalf("symlink: %v", err)
}
// Should not error and should not create xattrs on link
if err := ProcessSumAdd(dir, newTestStats(), nil); err != nil {
t.Fatalf("ProcessSumAdd with symlink: %v", err)
}
if _, err := xattr.Get(link, checksumKey); err == nil {
t.Fatalf("symlink should not have xattr")
}
}
func TestPermissionErrors(t *testing.T) { func TestPermissionErrors(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
skipIfNoXattr(t, dir) skipIfNoXattr(t, dir)
@@ -100,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); 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); 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); 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")
} }
} }

9
go.mod
View File

@@ -3,8 +3,11 @@ module git.eeqj.de/sneak/attrsum
go 1.24.1 go 1.24.1
require ( require (
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/mr-tron/base58 v1.2.0
github.com/multiformats/go-multihash v0.2.3 github.com/multiformats/go-multihash v0.2.3
github.com/pkg/xattr v0.4.10 github.com/pkg/xattr v0.4.10
github.com/schollz/progressbar/v3 v3.19.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
) )
@@ -12,11 +15,13 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/multiformats/go-varint v0.0.6 // indirect github.com/multiformats/go-varint v0.0.6 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
golang.org/x/sys v0.1.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
lukechampine.com/blake3 v1.1.6 // indirect lukechampine.com/blake3 v1.1.6 // indirect
) )

25
go.sum
View File

@@ -1,11 +1,21 @@
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
@@ -14,19 +24,30 @@ github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA= github.com/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA=
github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=