Compare commits
15 Commits
1.0.0
...
golangci-v
| Author | SHA1 | Date | |
|---|---|---|---|
| a5d4cd6c13 | |||
| 7f75f2ee72 | |||
| 6230bb1c3a | |||
| 975116f13d | |||
| 1bf1c892f4 | |||
| 629613de1b | |||
| 5c2338d590 | |||
| 9f86bf1dc1 | |||
| 2e44e5bb78 | |||
| b9d65115c2 | |||
| 144d2de243 | |||
| d848c5e51b | |||
| 86764abadf | |||
| c856ea25be | |||
| 9ad48fb9b0 |
9
.gitea/workflows/check.yml
Normal file
9
.gitea/workflows/check.yml
Normal 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
|
||||||
34
.golangci.yml
Normal file
34
.golangci.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||||
|
# linters.settings, not top-level linters-settings) so that the
|
||||||
|
# thresholds below are actually applied by golangci-lint >= v2.
|
||||||
|
|
||||||
|
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
|
||||||
|
settings:
|
||||||
|
lll:
|
||||||
|
line-length: 88
|
||||||
|
funlen:
|
||||||
|
lines: 80
|
||||||
|
statements: 50
|
||||||
|
cyclop:
|
||||||
|
max-complexity: 15
|
||||||
|
dupl:
|
||||||
|
threshold: 100
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 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.12.2, 2026-08-07
|
||||||
|
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
|
# 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 the checks as an unprivileged user. Root bypasses file mode bits, which
|
||||||
|
# would make the permission tests (expecting EACCES on a 0000 file) spuriously
|
||||||
|
# pass with no error. Caches live under /tmp (world-writable) so the user needs
|
||||||
|
# no home directory of its own.
|
||||||
|
ENV GOCACHE=/tmp/gocache
|
||||||
|
ENV XDG_CACHE_HOME=/tmp/xdgcache
|
||||||
|
RUN adduser -D -u 1000 builder && chown -R builder:builder /src /go
|
||||||
|
USER builder
|
||||||
|
|
||||||
|
# Run all checks - build fails if any check fails
|
||||||
|
RUN make check
|
||||||
|
|
||||||
|
# Build the binary (still as the unprivileged user: it owns /src, so git VCS
|
||||||
|
# stamping sees consistent ownership).
|
||||||
|
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"]
|
||||||
34
Makefile
34
Makefile
@@ -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)
|
||||||
|
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -31,47 +31,66 @@ Semantic Versioning 2.0.0 is used for tags.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# add checksum & timestamp xattrs to every regular file under DIR
|
# add checksum & timestamp xattrs to every regular file under one or more paths
|
||||||
attrsum sum add DIR
|
attrsum sum add DIR1 DIR2 file.txt
|
||||||
|
|
||||||
# update checksum only when file mtime is newer than stored sumtime
|
# update checksum only when file mtime is newer than stored sumtime
|
||||||
attrsum sum update DIR
|
attrsum sum update DIR1 DIR2
|
||||||
|
|
||||||
# verify checksums, stop on first error
|
# verify checksums, stop on first error
|
||||||
attrsum check DIR
|
attrsum check DIR1 DIR2
|
||||||
|
|
||||||
# verify every file, reporting each result, keep going after errors
|
# verify every file, reporting each result, keep going after errors
|
||||||
attrsum -v check --continue DIR
|
attrsum -v check --continue DIR1 DIR2
|
||||||
|
|
||||||
# remove checksum & timestamp xattrs
|
# remove checksum & timestamp xattrs
|
||||||
attrsum clear DIR
|
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 |
|
| xattr key | meaning |
|
||||||
|---------------------------------------------|--------------------------------|
|
|---------------------------------------------|--------------------------------|
|
||||||
| `berlin.sneak.app.attrsum.checksum` | base-58 multihash (sha2-256) |
|
| `user.berlin.sneak.app.attrsum.checksum` | base-58 multihash (sha2-256) |
|
||||||
| `berlin.sneak.app.attrsum.sumtime` | RFC 3339 timestamp of checksum |
|
| `user.berlin.sneak.app.attrsum.sumtime` | RFC 3339 timestamp of checksum |
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
|
|
||||||
* `-v, --verbose` — per-file log output
|
* `-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 PATTERN` — skip paths matching rsync/Doublestar glob
|
||||||
* `--exclude-dotfiles` — skip any path component that starts with `.`
|
* `--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, …).
|
`attrsum` **never follows symlinks** and skips non-regular files (sockets, devices, …).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|
||||||
Apple APFS and Linux ext3/ext4 **store no per-file metadata checksums**, so
|
Apple APFS and Linux ext3/ext4 **store no per-file content checksums**, so
|
||||||
silent data corruption can pass unnoticed. `attrsum` keeps a portable,
|
silent data corruption can pass unnoticed. `attrsum` keeps a portable checksum **inside each file’s xattrs**, providing integrity
|
||||||
tamper-evident checksum **inside each file’s xattrs**, providing integrity
|
|
||||||
verification that travels with the file itself—no external database
|
verification that travels with the file itself—no external database
|
||||||
required. Now you can trust a USB stick didn't eat your data.
|
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
|
## Contributing
|
||||||
|
|
||||||
* Author & maintainer: **sneak** – <sneak@sneak.berlin>
|
* Author & maintainer: **sneak** – <sneak@sneak.berlin>
|
||||||
|
|||||||
60
TODO.md
Normal file
60
TODO.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 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-08-07: updated golangci-lint to v2.12.2: canonical
|
||||||
|
`.golangci.yml` (settings moved under `linters.settings` so the
|
||||||
|
configured thresholds actually apply), version pins bumped in
|
||||||
|
`Dockerfile` and `script/bootstrap`, and long lines wrapped to
|
||||||
|
satisfy the now-effective `lll` limit of 88
|
||||||
|
* 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)
|
||||||
728
attrsum.go
728
attrsum.go
@@ -1,6 +1,9 @@
|
|||||||
|
// Command attrsum computes and verifies file checksums stored in
|
||||||
|
// extended attributes.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,27 +13,94 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
base58 "github.com/mr-tron/base58/base58"
|
|
||||||
"github.com/bmatcuk/doublestar/v4"
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
|
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 (
|
||||||
checksumKey = "berlin.sneak.app.attrsum.checksum"
|
// The keys live in the user.* namespace so they are settable on Linux,
|
||||||
sumTimeKey = "berlin.sneak.app.attrsum.sumtime"
|
// where regular-file xattrs outside user.*/trusted.*/security.*/system.*
|
||||||
|
// are rejected with EOPNOTSUPP. macOS treats the whole string as an
|
||||||
|
// opaque attribute name, so the same keys work there too.
|
||||||
|
checksumKey = "user.berlin.sneak.app.attrsum.checksum"
|
||||||
|
sumTimeKey = "user.berlin.sneak.app.attrsum.sumtime"
|
||||||
|
|
||||||
|
// progressThrottle is how often the progress bar repaints.
|
||||||
|
progressThrottle = 250 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sentinel errors returned by the command implementations.
|
||||||
var (
|
var (
|
||||||
|
errNoPaths = errors.New("no paths provided")
|
||||||
|
errNoPathsStdin = errors.New("no paths provided on stdin")
|
||||||
|
errVerification = errors.New("verification failed")
|
||||||
|
errModifiedDuringSum = errors.New("file modified during checksum calculation")
|
||||||
|
)
|
||||||
|
|
||||||
|
// options holds the parsed command-line flag state that is shared across
|
||||||
|
// the sum/check/clear operations. It replaces package-level globals so the
|
||||||
|
// behaviour is explicit and the code is safe to exercise concurrently.
|
||||||
|
type options struct {
|
||||||
verbose bool
|
verbose bool
|
||||||
|
quiet bool
|
||||||
excludePatterns []string
|
excludePatterns []string
|
||||||
excludeDotfiles bool
|
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(opts *options, operation string) {
|
||||||
|
if opts.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() {
|
||||||
|
opts := &options{}
|
||||||
|
|
||||||
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",
|
||||||
@@ -38,80 +108,249 @@ func main() {
|
|||||||
rootCmd.SilenceUsage = true
|
rootCmd.SilenceUsage = true
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output")
|
flags := rootCmd.PersistentFlags()
|
||||||
rootCmd.PersistentFlags().StringArrayVar(&excludePatterns, "exclude", nil, "exclude files/directories matching pattern (rsync-style, repeatable)")
|
flags.BoolVarP(&opts.verbose, "verbose", "v", false, "enable verbose output")
|
||||||
rootCmd.PersistentFlags().BoolVar(&excludeDotfiles, "exclude-dotfiles", false, "exclude any file or directory whose name starts with '.'")
|
flags.BoolVarP(&opts.quiet, "quiet", "q", false, "suppress all output except errors")
|
||||||
|
flags.StringArrayVar(&opts.excludePatterns, "exclude", nil,
|
||||||
|
"exclude files/directories matching pattern (rsync-style, repeatable)")
|
||||||
|
flags.BoolVar(&opts.excludeDotfiles, "exclude-dotfiles", false,
|
||||||
|
"exclude any file or directory whose name starts with '.'")
|
||||||
|
|
||||||
rootCmd.AddCommand(newSumCmd())
|
rootCmd.AddCommand(newSumCmd(opts))
|
||||||
rootCmd.AddCommand(newCheckCmd())
|
rootCmd.AddCommand(newCheckCmd(opts))
|
||||||
rootCmd.AddCommand(newClearCmd())
|
rootCmd.AddCommand(newClearCmd(opts))
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
err := rootCmd.Execute()
|
||||||
|
if 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 != "-" {
|
||||||
|
paths = append(paths, arg)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
readFromStdin = true
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line != "" {
|
||||||
|
paths = append(paths, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := scanner.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading stdin: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(paths) == 0 {
|
||||||
|
if readFromStdin {
|
||||||
|
return nil, errNoPathsStdin
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errNoPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFunc processes a single path within a command's run loop.
|
||||||
|
type processFunc func(
|
||||||
|
opts *options, path string, stats *Stats, bar *progressbar.ProgressBar,
|
||||||
|
) error
|
||||||
|
|
||||||
|
// countAndBar counts the files under paths and returns a progress bar sized
|
||||||
|
// to that total. It always returns either a non-nil bar or a non-nil error.
|
||||||
|
func countAndBar(
|
||||||
|
opts *options, paths []string, desc string,
|
||||||
|
) (*progressbar.ProgressBar, error) {
|
||||||
|
total, err := countFilesMultiple(opts, paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newProgressBar(total, desc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishBar finalizes a progress bar if one is present.
|
||||||
|
func finishBar(bar *progressbar.ProgressBar) {
|
||||||
|
if bar != nil {
|
||||||
|
_ = bar.Finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOverPaths runs process over each path, sharing the progress/stats
|
||||||
|
// bookkeeping common to the sum-add, sum-update and clear commands.
|
||||||
|
func runOverPaths(
|
||||||
|
opts *options, args []string, desc, op string, process processFunc,
|
||||||
|
) error {
|
||||||
|
paths, err := expandPaths(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &Stats{StartTime: time.Now()}
|
||||||
|
|
||||||
|
var bar *progressbar.ProgressBar
|
||||||
|
|
||||||
|
if !opts.quiet {
|
||||||
|
bar, err = countAndBar(opts, paths, desc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
perr := process(opts, p, stats, bar)
|
||||||
|
if perr != nil {
|
||||||
|
finishBar(bar)
|
||||||
|
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishBar(bar)
|
||||||
|
stats.Print(opts, op)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// Sum commands
|
// Sum commands
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func newSumCmd() *cobra.Command {
|
func newSumCmd(opts *options) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "sum",
|
Use: "sum",
|
||||||
Short: "Checksum maintenance operations",
|
Short: "Checksum maintenance operations",
|
||||||
}
|
}
|
||||||
|
|
||||||
add := &cobra.Command{
|
add := &cobra.Command{
|
||||||
Use: "add <dir>",
|
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, a []string) error { return ProcessSumAdd(a[0]) },
|
RunE: func(_ *cobra.Command, a []string) error {
|
||||||
|
return runOverPaths(opts, a, "Adding checksums", "sum add", processSumAdd)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
upd := &cobra.Command{
|
upd := &cobra.Command{
|
||||||
Use: "update <dir>",
|
Use: "update <path>... (use - to read paths from stdin)",
|
||||||
Short: "Recalculate checksum when file newer than stored sumtime",
|
Short: "Recalculate checksum when file newer than stored sumtime",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(_ *cobra.Command, a []string) error { return ProcessSumUpdate(a[0]) },
|
RunE: func(_ *cobra.Command, a []string) error {
|
||||||
|
return runOverPaths(opts, a, "Updating checksums", "sum update", processSumUpdate)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(add, upd)
|
cmd.AddCommand(add, upd)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProcessSumAdd(dir string) error {
|
func processSumAdd(
|
||||||
return walkAndProcess(dir, func(p string, _ os.FileInfo) error { return writeChecksumAndTime(p) })
|
opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar,
|
||||||
|
) error {
|
||||||
|
return walkAndProcess(opts, dir, stats, bar,
|
||||||
|
func(p string, info os.FileInfo, s *Stats) error {
|
||||||
|
if hasXattr(p, checksumKey) {
|
||||||
|
atomic.AddInt64(&s.FilesSkipped, 1)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProcessSumUpdate(dir string) error {
|
err := writeChecksumAndTime(opts, p, info, s)
|
||||||
return walkAndProcess(dir, func(p string, info os.FileInfo) error {
|
if err != nil {
|
||||||
t, err := readSumTime(p)
|
atomic.AddInt64(&s.FilesFailed, 1)
|
||||||
if err != nil || info.ModTime().After(t) {
|
|
||||||
return writeChecksumAndTime(p)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeChecksumAndTime(path string) error {
|
func processSumUpdate(
|
||||||
hash, err := fileMultihash(path)
|
opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar,
|
||||||
|
) error {
|
||||||
|
return walkAndProcess(opts, dir, stats, bar,
|
||||||
|
func(p string, info os.FileInfo, s *Stats) error {
|
||||||
|
t, err := readSumTime(p)
|
||||||
|
if err != nil || info.ModTime().After(t) {
|
||||||
|
werr := writeChecksumAndTime(opts, p, info, s)
|
||||||
|
if werr != nil {
|
||||||
|
atomic.AddInt64(&s.FilesFailed, 1)
|
||||||
|
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddInt64(&s.FilesSkipped, 1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeChecksumAndTime(
|
||||||
|
opts *options, 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := xattr.Set(path, checksumKey, hash); err != nil {
|
|
||||||
return fmt.Errorf("set checksum attr: %w", err)
|
// Check if file was modified during hashing.
|
||||||
}
|
infoAfter, err := os.Lstat(path)
|
||||||
if verbose {
|
if err != nil {
|
||||||
fmt.Printf("%s %s written\n", path, hash)
|
return fmt.Errorf("stat after hash: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := time.Now().UTC().Format(time.RFC3339Nano)
|
if !infoAfter.ModTime().Equal(mtimeBefore) {
|
||||||
if err := xattr.Set(path, sumTimeKey, []byte(ts)); err != nil {
|
return fmt.Errorf("%s: %w", path, errModifiedDuringSum)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = xattr.Set(path, checksumKey, hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set checksum attr: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.verbose && !opts.quiet {
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, "%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)
|
||||||
|
|
||||||
|
err = xattr.Set(path, sumTimeKey, []byte(ts))
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("set sumtime attr: %w", err)
|
return fmt.Errorf("set sumtime attr: %w", err)
|
||||||
}
|
}
|
||||||
if verbose {
|
|
||||||
fmt.Printf("%s %s written\n", path, ts)
|
if opts.verbose && !opts.quiet {
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, "%s %s written\n", path, ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
atomic.AddInt64(&stats.FilesProcessed, 1)
|
||||||
|
atomic.AddInt64(&stats.BytesProcessed, bytesRead)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +359,7 @@ func readSumTime(path string) (time.Time, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, err
|
return time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return time.Parse(time.RFC3339Nano, string(b))
|
return time.Parse(time.RFC3339Nano, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,186 +367,422 @@ func readSumTime(path string) (time.Time, error) {
|
|||||||
// Clear command
|
// Clear command
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func newClearCmd() *cobra.Command {
|
func newClearCmd(opts *options) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "clear <dir>",
|
Use: "clear <path>... (use - to read paths from stdin)",
|
||||||
Short: "Remove checksum xattrs from tree",
|
Short: "Remove checksum xattrs from tree",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(_ *cobra.Command, a []string) error { return ProcessClear(a[0]) },
|
RunE: func(_ *cobra.Command, a []string) error {
|
||||||
|
return runOverPaths(opts, a, "Clearing checksums", "clear", processClear)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProcessClear(dir string) error {
|
func processClear(
|
||||||
return walkAndProcess(dir, func(p string, _ os.FileInfo) error {
|
opts *options, dir string, stats *Stats, bar *progressbar.ProgressBar,
|
||||||
|
) error {
|
||||||
|
return walkAndProcess(opts, dir, stats, bar,
|
||||||
|
func(p string, info os.FileInfo, s *Stats) error {
|
||||||
|
cleared, err := clearOne(opts, p)
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt64(&s.FilesFailed, 1)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleared {
|
||||||
|
atomic.AddInt64(&s.FilesProcessed, 1)
|
||||||
|
atomic.AddInt64(&s.BytesProcessed, info.Size())
|
||||||
|
} else {
|
||||||
|
atomic.AddInt64(&s.FilesSkipped, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearOne removes both checksum xattrs from a single path, reporting
|
||||||
|
// whether any attribute was actually removed.
|
||||||
|
func clearOne(opts *options, p string) (bool, error) {
|
||||||
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, xattr.ENOATTR) {
|
if errors.Is(err, xattr.ENOATTR) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
return cleared, err
|
||||||
}
|
}
|
||||||
if verbose {
|
|
||||||
fmt.Printf("%s %s removed\n", p, string(v))
|
if opts.verbose && !opts.quiet {
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, "%s %s removed\n", p, string(v))
|
||||||
}
|
}
|
||||||
if err := xattr.Remove(p, k); err != nil {
|
|
||||||
return err
|
rerr := xattr.Remove(p, k)
|
||||||
|
if rerr != nil {
|
||||||
|
return cleared, rerr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleared = true
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
})
|
return cleared, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// Check command
|
// Check command
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func newCheckCmd() *cobra.Command {
|
func newCheckCmd(opts *options) *cobra.Command {
|
||||||
var cont bool
|
var cont bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "check <dir>",
|
Use: "check <path>... (use - to read paths from stdin)",
|
||||||
Short: "Verify stored checksums",
|
Short: "Verify stored checksums",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(_ *cobra.Command, a []string) error { return ProcessCheck(a[0], cont) },
|
RunE: func(_ *cobra.Command, a []string) error {
|
||||||
|
return runCheck(opts, a, cont)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProcessCheck(dir string, cont bool) error {
|
func runCheck(opts *options, args []string, cont bool) error {
|
||||||
fail := errors.New("verification failed")
|
paths, err := expandPaths(args)
|
||||||
bad := false
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err := walkAndProcess(dir, func(p string, _ os.FileInfo) error {
|
stats := &Stats{StartTime: time.Now()}
|
||||||
|
|
||||||
|
var bar *progressbar.ProgressBar
|
||||||
|
|
||||||
|
if !opts.quiet {
|
||||||
|
bar, err = countAndBar(opts, paths, "Verifying checksums")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalErr error
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
perr := processCheck(opts, p, cont, stats, bar)
|
||||||
|
if perr != nil {
|
||||||
|
if !cont {
|
||||||
|
finishBar(bar)
|
||||||
|
stats.Print(opts, "check")
|
||||||
|
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
|
||||||
|
finalErr = perr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishBar(bar)
|
||||||
|
stats.Print(opts, "check")
|
||||||
|
|
||||||
|
return finalErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCheck(
|
||||||
|
opts *options, dir string, cont bool, stats *Stats, bar *progressbar.ProgressBar,
|
||||||
|
) error {
|
||||||
|
// Track initial failed count to detect failures during this walk.
|
||||||
|
initialFailed := atomic.LoadInt64(&stats.FilesFailed)
|
||||||
|
|
||||||
|
err := walkAndProcess(opts, dir, stats, bar,
|
||||||
|
func(p string, _ os.FileInfo, s *Stats) error {
|
||||||
|
return checkOne(opts, p, cont, s)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errVerification) {
|
||||||
|
return errVerification
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any failures occurred during this walk.
|
||||||
|
if atomic.LoadInt64(&stats.FilesFailed) > initialFailed {
|
||||||
|
return errVerification
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOne verifies the stored checksum of a single path.
|
||||||
|
func checkOne(opts *options, p string, cont bool, 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
|
return err
|
||||||
if verbose {
|
|
||||||
fmt.Printf("%s <none> ERROR\n", p)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return missingChecksum(opts, p, cont, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
act, bytesRead, err := fileMultihash(p)
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt64(&s.FilesFailed, 1)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := bytes.Equal(exp, act)
|
||||||
|
if ok {
|
||||||
|
atomic.AddInt64(&s.FilesProcessed, 1)
|
||||||
|
atomic.AddInt64(&s.BytesProcessed, bytesRead)
|
||||||
|
} else {
|
||||||
|
atomic.AddInt64(&s.FilesFailed, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportCheck(opts, p, string(act), ok)
|
||||||
|
|
||||||
|
if !ok && !cont {
|
||||||
|
return errVerification
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingChecksum records and reports a file that has no stored checksum,
|
||||||
|
// returning the verification error unless the caller asked to continue.
|
||||||
|
func missingChecksum(opts *options, p string, cont bool, s *Stats) error {
|
||||||
|
atomic.AddInt64(&s.FilesFailed, 1)
|
||||||
|
|
||||||
|
if opts.verbose && !opts.quiet {
|
||||||
|
_, _ = fmt.Fprintf(os.Stdout, "%s <none> ERROR\n", p)
|
||||||
|
}
|
||||||
|
|
||||||
if cont {
|
if cont {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fail
|
|
||||||
}
|
return errVerification
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
act, err := fileMultihash(p)
|
// reportCheck prints a per-file verification result when verbose output is on.
|
||||||
if err != nil {
|
func reportCheck(opts *options, p, actual string, ok bool) {
|
||||||
return err
|
if !opts.verbose || opts.quiet {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ok := bytes.Equal(exp, act)
|
|
||||||
if !ok {
|
|
||||||
bad = true
|
|
||||||
}
|
|
||||||
if verbose {
|
|
||||||
status := "OK"
|
status := "OK"
|
||||||
if !ok {
|
if !ok {
|
||||||
status = "ERROR"
|
status = "ERROR"
|
||||||
}
|
}
|
||||||
fmt.Printf("%s %s %s\n", p, act, status)
|
|
||||||
}
|
|
||||||
if !ok && !cont {
|
|
||||||
return fail
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
_, _ = fmt.Fprintf(os.Stdout, "%s %s %s\n", p, actual, status)
|
||||||
if errors.Is(err, fail) {
|
|
||||||
return fail
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if bad {
|
|
||||||
return fail
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// 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.
|
||||||
|
func countFiles(opts *options, root string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
root = filepath.Clean(root)
|
root = filepath.Clean(root)
|
||||||
|
|
||||||
|
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != 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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(root, p)
|
||||||
|
if shouldExclude(opts, rel) {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// countFilesMultiple counts files across multiple roots.
|
||||||
|
func countFilesMultiple(opts *options, roots []string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
for _, root := range roots {
|
||||||
|
count, err := countFiles(opts, 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(progressThrottle),
|
||||||
|
progressbar.OptionShowElapsedTimeOnFinish(),
|
||||||
|
progressbar.OptionSetPredictTime(true),
|
||||||
|
progressbar.OptionFullWidth(),
|
||||||
|
progressbar.OptionSetTheme(progressbar.Theme{
|
||||||
|
Saucer: "=",
|
||||||
|
SaucerHead: ">",
|
||||||
|
SaucerPadding: " ",
|
||||||
|
BarStart: "[",
|
||||||
|
BarEnd: "]",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkAndProcess(
|
||||||
|
opts *options,
|
||||||
|
root string,
|
||||||
|
stats *Stats,
|
||||||
|
bar *progressbar.ProgressBar,
|
||||||
|
fn func(string, os.FileInfo, *Stats) error,
|
||||||
|
) error {
|
||||||
|
root = filepath.Clean(root)
|
||||||
|
|
||||||
return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
return 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, skipErr := walkSkip(opts, root, p, info)
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if skip {
|
||||||
if verbose {
|
return skipErr
|
||||||
log.Printf("skip symlink %s", p)
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rel, _ := filepath.Rel(root, p)
|
fnErr := fn(p, info, stats)
|
||||||
if shouldExclude(rel, info) {
|
|
||||||
if info.IsDir() {
|
if bar != nil {
|
||||||
return filepath.SkipDir
|
_ = bar.Add(1)
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
return fnErr
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
if verbose {
|
|
||||||
log.Printf("skip non-regular %s", p)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fn(p, info)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldExclude(rel string, info os.FileInfo) bool {
|
// walkSkip reports whether a walked entry should be skipped before the
|
||||||
|
// per-file callback runs. When skip is true, the returned error is what the
|
||||||
|
// walk callback should return (filepath.SkipDir to prune an excluded
|
||||||
|
// directory, otherwise nil). Symlinks, directories and non-regular files are
|
||||||
|
// never checksummed.
|
||||||
|
func walkSkip(opts *options, root, p string, info os.FileInfo) (bool, error) {
|
||||||
|
// 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 opts.verbose && !opts.quiet {
|
||||||
|
log.Printf("skip symlink %s", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(root, p)
|
||||||
|
if shouldExclude(opts, rel) {
|
||||||
|
if info.IsDir() {
|
||||||
|
return true, filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
if opts.verbose && !opts.quiet {
|
||||||
|
log.Printf("skip non-regular %s", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldExclude(opts *options, rel string) bool {
|
||||||
if rel == "." || rel == "" {
|
if rel == "." || rel == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if excludeDotfiles {
|
|
||||||
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
|
if opts.excludeDotfiles {
|
||||||
|
for part := range strings.SplitSeq(rel, string(os.PathSeparator)) {
|
||||||
if strings.HasPrefix(part, ".") {
|
if strings.HasPrefix(part, ".") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, pat := range excludePatterns {
|
|
||||||
|
for _, pat := range opts.excludePatterns {
|
||||||
if ok, _ := doublestar.PathMatch(pat, rel); ok {
|
if ok, _ := doublestar.PathMatch(pat, rel); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasXattr(path, key string) bool {
|
func hasXattr(path, key string) bool {
|
||||||
_, err := xattr.Get(path, key)
|
_, err := xattr.Get(path, key)
|
||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileMultihash(path string) ([]byte, error) {
|
func fileMultihash(path string) ([]byte, int64, error) {
|
||||||
|
// The path is supplied by the operator as the tree to checksum; reading
|
||||||
|
// it is the entire purpose of the tool.
|
||||||
|
//nolint:gosec // G304: operator-specified path is the intended input
|
||||||
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 func() { _ = f.Close() }()
|
||||||
|
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
if _, err := io.Copy(h, f); err != nil {
|
|
||||||
return nil, err
|
bytesRead, err := io.Copy(h, f)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
190
attrsum_test.go
190
attrsum_test.go
@@ -10,49 +10,106 @@ import (
|
|||||||
"github.com/pkg/xattr"
|
"github.com/pkg/xattr"
|
||||||
)
|
)
|
||||||
|
|
||||||
func skipIfNoXattr(t *testing.T, path string) {
|
const (
|
||||||
if err := xattr.Set(path, "user.test", []byte("1")); err != nil {
|
dirPerm = 0o755
|
||||||
t.Skipf("skipping: xattr not supported: %v", err)
|
filePerm = 0o644
|
||||||
} else {
|
noPerm = 0o000
|
||||||
_ = xattr.Remove(path, "user.test")
|
|
||||||
|
// futureSkew advances a file's mtime far enough to be unambiguously
|
||||||
|
// newer than a previously recorded sumtime.
|
||||||
|
futureSkew = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// skipIfNoXattr skips the test unless the filesystem under dir supports the
|
||||||
|
// extended-attribute namespace the program actually uses. Probing with the
|
||||||
|
// real checksumKey (rather than a user.* key) matters on Linux, where regular
|
||||||
|
// files only accept xattrs in the user.* namespace and the program's
|
||||||
|
// berlin.sneak.* keys yield "operation not supported".
|
||||||
|
func skipIfNoXattr(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
probe := filepath.Join(dir, "xattr-probe")
|
||||||
|
|
||||||
|
err := os.WriteFile(probe, []byte("probe"), filePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write probe file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer func() { _ = os.Remove(probe) }()
|
||||||
|
|
||||||
|
err = xattr.Set(probe, checksumKey, []byte("1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping: xattr namespace %q not supported: %v", checksumKey, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = xattr.Remove(probe, checksumKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFile(t *testing.T, root, name, content string) string {
|
func writeFile(t *testing.T, root, name, content string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
p := filepath.Join(root, name)
|
p := filepath.Join(root, name)
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
||||||
|
err := os.MkdirAll(filepath.Dir(p), dirPerm)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("mkdir: %v", err)
|
t.Fatalf("mkdir: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
||||||
|
err = os.WriteFile(p, []byte(content), filePerm)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("write: %v", err)
|
t.Fatalf("write: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestStats() *Stats {
|
||||||
|
return &Stats{StartTime: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSumAddAndUpdate(t *testing.T) {
|
func TestSumAddAndUpdate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := &options{}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
skipIfNoXattr(t, dir)
|
skipIfNoXattr(t, dir)
|
||||||
|
|
||||||
f := writeFile(t, dir, "a.txt", "hello")
|
f := writeFile(t, dir, "a.txt", "hello")
|
||||||
|
|
||||||
if err := ProcessSumAdd(dir); err != nil {
|
err := processSumAdd(opts, dir, newTestStats(), nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("add: %v", err)
|
t.Fatalf("add: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := xattr.Get(f, checksumKey); err != nil {
|
|
||||||
|
_, err = xattr.Get(f, checksumKey)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("checksum missing: %v", err)
|
t.Fatalf("checksum missing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tsb, _ := xattr.Get(f, sumTimeKey)
|
tsb, _ := xattr.Get(f, sumTimeKey)
|
||||||
origTime, _ := time.Parse(time.RFC3339Nano, string(tsb))
|
origTime, _ := time.Parse(time.RFC3339Nano, string(tsb))
|
||||||
|
|
||||||
os.WriteFile(f, []byte(strings.ToUpper("hello")), 0o644)
|
err = os.WriteFile(f, []byte(strings.ToUpper("hello")), filePerm)
|
||||||
now := time.Now().Add(2 * time.Second)
|
if err != nil {
|
||||||
os.Chtimes(f, now, now)
|
t.Fatalf("rewrite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := ProcessSumUpdate(dir); err != nil {
|
now := time.Now().Add(futureSkew)
|
||||||
|
|
||||||
|
err = os.Chtimes(f, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chtimes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = processSumUpdate(opts, dir, newTestStats(), nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("update: %v", err)
|
t.Fatalf("update: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tsb2, _ := xattr.Get(f, sumTimeKey)
|
tsb2, _ := xattr.Get(f, sumTimeKey)
|
||||||
|
|
||||||
newTime, _ := time.Parse(time.RFC3339Nano, string(tsb2))
|
newTime, _ := time.Parse(time.RFC3339Nano, string(tsb2))
|
||||||
if !newTime.After(origTime) {
|
if !newTime.After(origTime) {
|
||||||
t.Fatalf("sumtime not updated")
|
t.Fatalf("sumtime not updated")
|
||||||
@@ -60,46 +117,74 @@ func TestSumAddAndUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessCheckIntegration(t *testing.T) {
|
func TestProcessCheckIntegration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := &options{}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
skipIfNoXattr(t, dir)
|
skipIfNoXattr(t, dir)
|
||||||
|
|
||||||
writeFile(t, dir, "b.txt", "world")
|
writeFile(t, dir, "b.txt", "world")
|
||||||
if err := ProcessSumAdd(dir); err != nil {
|
|
||||||
|
err := processSumAdd(opts, dir, newTestStats(), nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("add: %v", err)
|
t.Fatalf("add: %v", err)
|
||||||
}
|
}
|
||||||
if err := ProcessCheck(dir, false); err != nil {
|
|
||||||
|
err = processCheck(opts, dir, false, newTestStats(), nil)
|
||||||
|
if 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)
|
|
||||||
|
|
||||||
if err := ProcessCheck(dir, false); err == nil {
|
err = os.WriteFile(f, []byte("corrupt"), filePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("corrupt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = processCheck(opts, dir, false, newTestStats(), nil)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf("expected mismatch error, got nil")
|
t.Fatalf("expected mismatch error, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClearRemovesAttrs(t *testing.T) {
|
func TestClearRemovesAttrs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := &options{}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
skipIfNoXattr(t, dir)
|
skipIfNoXattr(t, dir)
|
||||||
|
|
||||||
f := writeFile(t, dir, "c.txt", "data")
|
f := writeFile(t, dir, "c.txt", "data")
|
||||||
if err := ProcessSumAdd(dir); err != nil {
|
|
||||||
|
err := processSumAdd(opts, dir, newTestStats(), nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("add: %v", err)
|
t.Fatalf("add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ProcessClear(dir); err != nil {
|
err = processClear(opts, dir, newTestStats(), nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("clear: %v", err)
|
t.Fatalf("clear: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := xattr.Get(f, checksumKey); err == nil {
|
|
||||||
|
_, err = xattr.Get(f, checksumKey)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf("checksum still present after clear")
|
t.Fatalf("checksum still present after clear")
|
||||||
}
|
}
|
||||||
if _, err := xattr.Get(f, sumTimeKey); err == nil {
|
|
||||||
|
_, err = xattr.Get(f, sumTimeKey)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf("sumtime still present after clear")
|
t.Fatalf("sumtime still present after clear")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcludeDotfilesAndPatterns(t *testing.T) {
|
func TestExcludeDotfilesAndPatterns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := &options{
|
||||||
|
excludeDotfiles: true,
|
||||||
|
excludePatterns: []string{"*.me"},
|
||||||
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
skipIfNoXattr(t, dir)
|
skipIfNoXattr(t, dir)
|
||||||
|
|
||||||
@@ -107,61 +192,82 @@ func TestExcludeDotfilesAndPatterns(t *testing.T) {
|
|||||||
keep := writeFile(t, dir, "keep.txt", "keep")
|
keep := writeFile(t, dir, "keep.txt", "keep")
|
||||||
skip := writeFile(t, dir, "skip.me", "skip")
|
skip := writeFile(t, dir, "skip.me", "skip")
|
||||||
|
|
||||||
oldDot := excludeDotfiles
|
err := processSumAdd(opts, dir, newTestStats(), nil)
|
||||||
oldPat := excludePatterns
|
if err != nil {
|
||||||
excludeDotfiles = true
|
|
||||||
excludePatterns = []string{"*.me"}
|
|
||||||
defer func() { excludeDotfiles, excludePatterns = oldDot, oldPat }()
|
|
||||||
|
|
||||||
if err := ProcessSumAdd(dir); err != nil {
|
|
||||||
t.Fatalf("add with excludes: %v", err)
|
t.Fatalf("add with excludes: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := xattr.Get(keep, checksumKey); err != nil {
|
_, err = xattr.Get(keep, checksumKey)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("expected xattr on keep.txt: %v", err)
|
t.Fatalf("expected xattr on keep.txt: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := xattr.Get(hidden, checksumKey); err == nil {
|
|
||||||
|
_, err = xattr.Get(hidden, checksumKey)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf(".hidden should have been excluded")
|
t.Fatalf(".hidden should have been excluded")
|
||||||
}
|
}
|
||||||
if _, err := xattr.Get(skip, checksumKey); err == nil {
|
|
||||||
|
_, err = xattr.Get(skip, checksumKey)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf("skip.me should have been excluded")
|
t.Fatalf("skip.me should have been excluded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSkipBrokenSymlink(t *testing.T) {
|
func TestSkipBrokenSymlink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := &options{}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
skipIfNoXattr(t, dir)
|
skipIfNoXattr(t, dir)
|
||||||
|
|
||||||
// Create a dangling symlink
|
// Create a dangling symlink.
|
||||||
link := filepath.Join(dir, "dangling.lnk")
|
link := filepath.Join(dir, "dangling.lnk")
|
||||||
if err := os.Symlink(filepath.Join(dir, "nonexistent.txt"), link); err != nil {
|
|
||||||
|
err := os.Symlink(filepath.Join(dir, "nonexistent.txt"), link)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("symlink: %v", err)
|
t.Fatalf("symlink: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not error and should not create xattrs on link
|
// Should not error and should not create xattrs on link.
|
||||||
if err := ProcessSumAdd(dir); err != nil {
|
err = processSumAdd(opts, dir, newTestStats(), nil)
|
||||||
t.Fatalf("ProcessSumAdd with symlink: %v", err)
|
if err != nil {
|
||||||
|
t.Fatalf("processSumAdd with symlink: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := xattr.Get(link, checksumKey); err == nil {
|
|
||||||
|
_, err = xattr.Get(link, checksumKey)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf("symlink should not have xattr")
|
t.Fatalf("symlink should not have xattr")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPermissionErrors(t *testing.T) {
|
func TestPermissionErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opts := &options{}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
skipIfNoXattr(t, dir)
|
skipIfNoXattr(t, dir)
|
||||||
|
|
||||||
secret := writeFile(t, dir, "secret.txt", "data")
|
secret := writeFile(t, dir, "secret.txt", "data")
|
||||||
os.Chmod(secret, 0o000)
|
|
||||||
defer os.Chmod(secret, 0o644)
|
|
||||||
|
|
||||||
if err := ProcessSumAdd(dir); err == nil {
|
err := os.Chmod(secret, noPerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chmod: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = os.Chmod(secret, filePerm) }()
|
||||||
|
|
||||||
|
err = processSumAdd(opts, dir, newTestStats(), nil)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf("expected permission error, got nil")
|
t.Fatalf("expected permission error, got nil")
|
||||||
}
|
}
|
||||||
if err := ProcessSumUpdate(dir); err == nil {
|
|
||||||
|
err = processSumUpdate(opts, dir, newTestStats(), nil)
|
||||||
|
if 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 {
|
|
||||||
|
err = processCheck(opts, dir, false, newTestStats(), nil)
|
||||||
|
if err == nil {
|
||||||
t.Fatalf("expected permission error on check, got nil")
|
t.Fatalf("expected permission error on check, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/mr-tron/base58 v1.2.0
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,10 +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/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
|
||||||
)
|
)
|
||||||
|
|||||||
23
go.sum
23
go.sum
@@ -1,13 +1,21 @@
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
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/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=
|
||||||
@@ -16,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=
|
||||||
|
|||||||
82
script/bootstrap
Executable file
82
script/bootstrap
Executable 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 refs the Dockerfile uses (never "latest").
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Pinned versions, 2026-08-07 (same pins as the Dockerfile)
|
||||||
|
# golangci-lint v2.12.2
|
||||||
|
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2"
|
||||||
|
# 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
14
script/check
Executable 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
13
script/cibuild
Executable 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
14
script/docker
Executable 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
13
script/fmt
Executable 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
18
script/fmt-check
Executable 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
16
script/install-precommit
Executable 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
12
script/lint
Executable 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
21
script/precommit
Executable 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
12
script/projectname
Executable 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
13
script/setup
Executable 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
12
script/test
Executable 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 "$@"
|
||||||
Reference in New Issue
Block a user