Compare commits
6 Commits
1497300850
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
| 86764abadf | |||
| c856ea25be | |||
| 9ad48fb9b0 | |||
| fffa380270 | |||
| ebbe20dbdf | |||
| 1bb9528548 |
23
Makefile
23
Makefile
@@ -1,7 +1,24 @@
|
||||
TESTDIR := $(HOME)/Documents/_SYSADMIN/cyberdyne
|
||||
|
||||
default: 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
114
README.md
Normal 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 file’s 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/).
|
||||
456
attrsum.go
456
attrsum.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
@@ -9,42 +10,116 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
base58 "github.com/mr-tron/base58/base58"
|
||||
"github.com/multiformats/go-multihash"
|
||||
"github.com/pkg/xattr"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// Extended-attribute keys
|
||||
checksumKey = "berlin.sneak.app.attrsum.checksum"
|
||||
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() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "attrsum",
|
||||
Short: "Compute and verify file checksums via xattrs",
|
||||
}
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
rootCmd.PersistentFlags().BoolVarP(
|
||||
&verbose, "verbose", "v", false, "enable verbose output",
|
||||
)
|
||||
rootCmd.PersistentFlags().BoolVarP(&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(newCheckCmd())
|
||||
rootCmd.AddCommand(newClearCmd())
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// expandPaths expands the given paths, reading from stdin if "-" is present
|
||||
func expandPaths(args []string) ([]string, error) {
|
||||
var paths []string
|
||||
for _, arg := range args {
|
||||
if arg == "-" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Sum operations
|
||||
// Sum commands
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func newSumCmd() *cobra.Command {
|
||||
@@ -53,61 +128,80 @@ func newSumCmd() *cobra.Command {
|
||||
Short: "Checksum maintenance operations",
|
||||
}
|
||||
|
||||
addCmd := &cobra.Command{
|
||||
Use: "add <directory>",
|
||||
add := &cobra.Command{
|
||||
Use: "add <path>... (use - to read paths from stdin)",
|
||||
Short: "Write checksums for files missing them",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
return ProcessSumAdd(args[0])
|
||||
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()}
|
||||
for _, p := range paths {
|
||||
if err := ProcessSumAdd(p, stats); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stats.Print("sum add")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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])
|
||||
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()}
|
||||
for _, p := range paths {
|
||||
if err := ProcessSumUpdate(p, stats); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stats.Print("sum update")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(addCmd, updateCmd)
|
||||
cmd.AddCommand(add, upd)
|
||||
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)
|
||||
}
|
||||
func ProcessSumAdd(dir string, stats *Stats) error {
|
||||
return walkAndProcess(dir, stats, "Adding checksums", func(p string, info os.FileInfo, s *Stats) error {
|
||||
if hasXattr(p, checksumKey) {
|
||||
atomic.AddInt64(&s.FilesSkipped, 1)
|
||||
return nil
|
||||
}
|
||||
return writeChecksumAndTime(path)
|
||||
if err := writeChecksumAndTime(p, info, s); err != nil {
|
||||
atomic.AddInt64(&s.FilesFailed, 1)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
func ProcessSumUpdate(dir string, stats *Stats) error {
|
||||
return walkAndProcess(dir, stats, "Updating checksums", func(p string, info os.FileInfo, s *Stats) error {
|
||||
t, err := readSumTime(p)
|
||||
if err != nil || info.ModTime().After(t) {
|
||||
needUpdate = true
|
||||
if err := writeChecksumAndTime(p, info, s); err != nil {
|
||||
atomic.AddInt64(&s.FilesFailed, 1)
|
||||
return err
|
||||
}
|
||||
if needUpdate {
|
||||
if verbose {
|
||||
log.Printf("update %s", path)
|
||||
}
|
||||
return writeChecksumAndTime(path)
|
||||
} else {
|
||||
atomic.AddInt64(&s.FilesSkipped, 1)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func writeChecksumAndTime(path string) error {
|
||||
func writeChecksumAndTime(path string, info os.FileInfo, stats *Stats) error {
|
||||
hash, err := fileMultihash(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -115,10 +209,20 @@ func writeChecksumAndTime(path string) error {
|
||||
if err := xattr.Set(path, checksumKey, hash); err != nil {
|
||||
return fmt.Errorf("set checksum attr: %w", err)
|
||||
}
|
||||
nowStr := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if err := xattr.Set(path, sumTimeKey, []byte(nowStr)); err != nil {
|
||||
if verbose && !quiet {
|
||||
fmt.Printf("%s %s written\n", path, hash)
|
||||
}
|
||||
|
||||
ts := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if err := xattr.Set(path, sumTimeKey, []byte(ts)); err != nil {
|
||||
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, info.Size())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -131,68 +235,153 @@ 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()}
|
||||
for _, p := range paths {
|
||||
if err := ProcessClear(p, stats); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stats.Print("clear")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ProcessClear(dir string, stats *Stats) error {
|
||||
return walkAndProcess(dir, stats, "Clearing checksums", 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 {
|
||||
var cont bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "check <directory>",
|
||||
Short: "Verify stored checksums against file contents",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
return ProcessCheck(args[0], cont)
|
||||
Use: "check <path>... (use - to read paths from stdin)",
|
||||
Short: "Verify stored checksums",
|
||||
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 finalErr error
|
||||
for _, p := range paths {
|
||||
if err := ProcessCheck(p, cont, stats); err != nil {
|
||||
if cont {
|
||||
finalErr = err
|
||||
} else {
|
||||
stats.Print("check")
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ProcessCheck verifies checksums and exits non-zero on first error unless
|
||||
// --continue is supplied.
|
||||
func ProcessCheck(dir string, cont bool) error {
|
||||
exitErr := errors.New("verification failed")
|
||||
func ProcessCheck(dir string, cont bool, stats *Stats) error {
|
||||
fail := errors.New("verification failed")
|
||||
bad := false
|
||||
|
||||
err := walkAndProcess(dir, func(path string, info os.FileInfo) error {
|
||||
exp, err := xattr.Get(path, checksumKey)
|
||||
err := walkAndProcess(dir, stats, "Verifying checksums", func(p string, info os.FileInfo, s *Stats) error {
|
||||
exp, err := xattr.Get(p, checksumKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, xattr.ENOATTR) {
|
||||
log.Printf("ERROR missing xattr %s", path)
|
||||
bad = true
|
||||
atomic.AddInt64(&s.FilesFailed, 1)
|
||||
if verbose && !quiet {
|
||||
fmt.Printf("%s <none> ERROR\n", p)
|
||||
}
|
||||
if cont {
|
||||
return nil
|
||||
}
|
||||
return exitErr
|
||||
return fail
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
act, err := fileMultihash(path)
|
||||
act, err := fileMultihash(p)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&s.FilesFailed, 1)
|
||||
return err
|
||||
}
|
||||
|
||||
if !bytes.Equal(exp, act) {
|
||||
log.Printf("ERROR checksum mismatch %s", path)
|
||||
if cont {
|
||||
return nil
|
||||
ok := bytes.Equal(exp, act)
|
||||
if !ok {
|
||||
bad = true
|
||||
atomic.AddInt64(&s.FilesFailed, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&s.FilesProcessed, 1)
|
||||
atomic.AddInt64(&s.BytesProcessed, info.Size())
|
||||
}
|
||||
return exitErr
|
||||
if verbose && !quiet {
|
||||
status := "OK"
|
||||
if !ok {
|
||||
status = "ERROR"
|
||||
}
|
||||
if cont {
|
||||
fmt.Printf("OK %s\n", path)
|
||||
fmt.Printf("%s %s %s\n", p, act, status)
|
||||
}
|
||||
if !ok && !cont {
|
||||
return fail
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, exitErr) {
|
||||
return exitErr
|
||||
if errors.Is(err, fail) {
|
||||
return fail
|
||||
}
|
||||
return err
|
||||
}
|
||||
if bad {
|
||||
return fail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -200,16 +389,133 @@ func ProcessCheck(dir string, cont bool) error {
|
||||
// Helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func walkAndProcess(root string, fn func(string, os.FileInfo) error) error {
|
||||
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
// countFiles counts the total number of regular files that will be processed
|
||||
func countFiles(root string) int64 {
|
||||
var count int64
|
||||
root = filepath.Clean(root)
|
||||
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
if shouldExclude(rel, info) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
return fn(path, info)
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
func walkAndProcess(root string, stats *Stats, description string, fn func(string, os.FileInfo, *Stats) error) error {
|
||||
root = filepath.Clean(root)
|
||||
|
||||
// Count files first for progress bar
|
||||
total := countFiles(root)
|
||||
|
||||
// Create progress bar
|
||||
var bar *progressbar.ProgressBar
|
||||
if !quiet {
|
||||
bar = 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: "]",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// skip symlinks entirely
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
if verbose && !quiet {
|
||||
log.Printf("skip symlink %s", p)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
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
|
||||
})
|
||||
|
||||
if bar != nil {
|
||||
bar.Finish()
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -217,7 +523,6 @@ func hasXattr(path, key string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// fileMultihash returns the base58-encoded SHA-2-256 multihash of the file.
|
||||
func fileMultihash(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -229,7 +534,6 @@ func fileMultihash(path string) ([]byte, error) {
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
113
attrsum_test.go
113
attrsum_test.go
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/pkg/xattr"
|
||||
)
|
||||
|
||||
// skipIfNoXattr skips tests when the underlying FS lacks xattr support.
|
||||
func skipIfNoXattr(t *testing.T, path string) {
|
||||
if err := xattr.Set(path, "user.test", []byte("1")); err != nil {
|
||||
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 {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
p := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
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)
|
||||
}
|
||||
return path
|
||||
return p
|
||||
}
|
||||
|
||||
func newTestStats() *Stats {
|
||||
return &Stats{StartTime: time.Now()}
|
||||
}
|
||||
|
||||
func TestSumAddAndUpdate(t *testing.T) {
|
||||
@@ -37,30 +40,20 @@ func TestSumAddAndUpdate(t *testing.T) {
|
||||
|
||||
f := writeFile(t, dir, "a.txt", "hello")
|
||||
|
||||
// Add: write missing checksum.
|
||||
if err := ProcessSumAdd(dir); err != nil {
|
||||
if err := ProcessSumAdd(dir, newTestStats()); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
|
||||
// Attributes should exist.
|
||||
if _, err := xattr.Get(f, checksumKey); err != nil {
|
||||
t.Fatalf("checksum missing: %v", err)
|
||||
}
|
||||
tsb, err := xattr.Get(f, sumTimeKey)
|
||||
if err != nil {
|
||||
t.Fatalf("sumtime missing: %v", err)
|
||||
}
|
||||
tsb, _ := xattr.Get(f, sumTimeKey)
|
||||
origTime, _ := time.Parse(time.RFC3339Nano, string(tsb))
|
||||
|
||||
// Modify file and bump mtime.
|
||||
if err := os.WriteFile(f, []byte(strings.ToUpper("hello")), 0o644); err != nil {
|
||||
t.Fatalf("rewrite: %v", err)
|
||||
}
|
||||
os.WriteFile(f, []byte(strings.ToUpper("hello")), 0o644)
|
||||
now := time.Now().Add(2 * time.Second)
|
||||
os.Chtimes(f, now, now)
|
||||
|
||||
// Update should refresh checksum and time.
|
||||
if err := ProcessSumUpdate(dir); err != nil {
|
||||
if err := ProcessSumUpdate(dir, newTestStats()); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
tsb2, _ := xattr.Get(f, sumTimeKey)
|
||||
@@ -75,23 +68,89 @@ func TestProcessCheckIntegration(t *testing.T) {
|
||||
skipIfNoXattr(t, dir)
|
||||
|
||||
writeFile(t, dir, "b.txt", "world")
|
||||
if err := ProcessSumAdd(dir); err != nil {
|
||||
if err := ProcessSumAdd(dir, newTestStats()); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
|
||||
if err := ProcessCheck(dir, false); err != nil {
|
||||
if err := ProcessCheck(dir, false, newTestStats()); err != nil {
|
||||
t.Fatalf("check ok: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt file contents should produce an error.
|
||||
f := filepath.Join(dir, "b.txt")
|
||||
os.WriteFile(f, []byte("corrupt"), 0o644)
|
||||
|
||||
if err := ProcessCheck(dir, false); err == nil {
|
||||
if err := ProcessCheck(dir, false, newTestStats()); err == 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()); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
|
||||
if err := ProcessClear(dir, newTestStats()); 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()); 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()); 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) {
|
||||
dir := t.TempDir()
|
||||
skipIfNoXattr(t, dir)
|
||||
@@ -100,13 +159,13 @@ func TestPermissionErrors(t *testing.T) {
|
||||
os.Chmod(secret, 0o000)
|
||||
defer os.Chmod(secret, 0o644)
|
||||
|
||||
if err := ProcessSumAdd(dir); err == nil {
|
||||
if err := ProcessSumAdd(dir, newTestStats()); err == nil {
|
||||
t.Fatalf("expected permission error, got nil")
|
||||
}
|
||||
if err := ProcessSumUpdate(dir); err == nil {
|
||||
if err := ProcessSumUpdate(dir, newTestStats()); err == nil {
|
||||
t.Fatalf("expected permission error on update, got nil")
|
||||
}
|
||||
if err := ProcessCheck(dir, false); err == nil {
|
||||
if err := ProcessCheck(dir, false, newTestStats()); err == nil {
|
||||
t.Fatalf("expected permission error on check, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
9
go.mod
9
go.mod
@@ -3,8 +3,11 @@ module git.eeqj.de/sneak/attrsum
|
||||
go 1.24.1
|
||||
|
||||
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/pkg/xattr v0.4.10
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
)
|
||||
|
||||
@@ -12,11 +15,13 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 // 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/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // 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
|
||||
)
|
||||
|
||||
25
go.sum
25
go.sum
@@ -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/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/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.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
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/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/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
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/pkg/xattr v0.4.10 h1:Qe0mtiNFHQZ296vRgUjRCoPHPqH7VdTOrZx3g0T+pGA=
|
||||
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/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/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
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/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/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
|
||||
|
||||
Reference in New Issue
Block a user