Update golangci-lint to v2.12.2 with canonical config (closes #60)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
Adopts golangci-lint v2.12.2 and the canonical .golangci.yml (default: all), and fixes all resulting findings across the tree. Two intended behavior changes: absent MFFilePath.Mtime is handled explicitly in freshen, list and export rather than dereferenced (main panicked); gpg positional key IDs now follow an explicit -- end-of-options marker. All twelve reworded user-visible error messages restored to byte-identical parity with main and pinned by tests.
This commit was merged in pull request #59.
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
// Package cli implements the mfer command-line interface.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,21 +19,254 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
// fingerprintHexLen is the length of a full GPG key fingerprint in hex
|
||||
// characters.
|
||||
const fingerprintHexLen = 40
|
||||
|
||||
var (
|
||||
// errNoManifestFound indicates no manifest file was found in the
|
||||
// searched directory.
|
||||
errNoManifestFound = errors.New("no manifest found")
|
||||
// errInvalidFingerprint indicates a malformed --require-signature
|
||||
// fingerprint argument. The length is spliced in from
|
||||
// fingerprintHexLen so the two cannot drift apart.
|
||||
errInvalidFingerprint = errors.New(
|
||||
"invalid fingerprint: must be exactly " +
|
||||
strconv.Itoa(fingerprintHexLen) + " hex characters")
|
||||
// errManifestNotSigned indicates a signature was required but the
|
||||
// manifest is unsigned. It is wrapped mid-sentence so that the
|
||||
// rendered message stays exactly as mfer has always printed it.
|
||||
errManifestNotSigned = errors.New("manifest is not signed")
|
||||
// errSignerMismatch indicates the embedded signing key fingerprint
|
||||
// does not match the required signer. Its text is the mid-sentence
|
||||
// fragment of the rendered message, which users grep for in CI and
|
||||
// which must therefore not change; match it with errors.Is rather
|
||||
// than by reading it.
|
||||
errSignerMismatch = errors.New("does not match required")
|
||||
)
|
||||
|
||||
// safeUint64 converts a non-negative int64 to uint64, clamping negative
|
||||
// values to zero.
|
||||
func safeUint64(n int64) uint64 {
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return uint64(n)
|
||||
}
|
||||
|
||||
// safeRateUint64 converts a bytes-per-second rate to uint64 for display.
|
||||
//
|
||||
// A rate is computed as bytes/elapsed, so it is +Inf when the elapsed
|
||||
// time rounds to zero and NaN when zero bytes were processed in zero
|
||||
// time. Neither has a defined conversion to uint64, and on amd64 +Inf
|
||||
// converts to a number that renders as "8.0 EiB/s"; both display as zero
|
||||
// instead.
|
||||
func safeRateUint64(rate float64) uint64 {
|
||||
if math.IsNaN(rate) || math.IsInf(rate, 0) || rate <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
if rate >= math.MaxUint64 {
|
||||
return math.MaxUint64
|
||||
}
|
||||
|
||||
return uint64(rate)
|
||||
}
|
||||
|
||||
// findManifest looks for a manifest file in the given directory.
|
||||
// It checks for index.mf and .index.mf, returning the first one found.
|
||||
func findManifest(fs afero.Fs, dir string) (string, error) {
|
||||
candidates := []string{"index.mf", ".index.mf"}
|
||||
for _, name := range candidates {
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
exists, err := afero.Exists(fs, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no manifest found in %s (looked for index.mf and .index.mf)", dir)
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"%w in %s (looked for index.mf and .index.mf)", errNoManifestFound, dir)
|
||||
}
|
||||
|
||||
// fetchManifestToTemp downloads a manifest URL to a temporary file and
|
||||
// returns the temp file path. The caller is responsible for removing it.
|
||||
func (mfa *CLIApp) fetchManifestToTemp(url string) (string, error) {
|
||||
rc, fetchErr := mfa.openManifestReader(url)
|
||||
if fetchErr != nil {
|
||||
return "", fetchErr
|
||||
}
|
||||
|
||||
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
|
||||
if tmpErr != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
return "", fmt.Errorf("failed to create temp file: %w", tmpErr)
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
_, cpErr := io.Copy(tmpFile, rc)
|
||||
_ = rc.Close()
|
||||
_ = tmpFile.Close()
|
||||
|
||||
if cpErr != nil {
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
|
||||
return "", fmt.Errorf("failed to download manifest: %w", cpErr)
|
||||
}
|
||||
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
||||
// against the manifest's embedded signing key.
|
||||
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||
// Validate fingerprint format: must be exactly 40 hex characters
|
||||
if len(requiredSigner) != fingerprintHexLen {
|
||||
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
||||
}
|
||||
|
||||
_, err := hex.DecodeString(requiredSigner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
|
||||
}
|
||||
|
||||
if !chk.IsSigned() {
|
||||
return fmt.Errorf("%w, but signature from %s is required",
|
||||
errManifestNotSigned, requiredSigner)
|
||||
}
|
||||
|
||||
// Extract fingerprint from the embedded public key (not from the
|
||||
// signer field). This validates the key is importable and gets its
|
||||
// actual fingerprint.
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to extract fingerprint from embedded signing key: %w", err)
|
||||
}
|
||||
|
||||
// Compare fingerprints - must be exact match (case-insensitive)
|
||||
if !strings.EqualFold(embeddedFP, requiredSigner) {
|
||||
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
embeddedFP, errSignerMismatch, requiredSigner)
|
||||
}
|
||||
|
||||
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reportCheckProgress renders progress updates until the channel closes.
|
||||
func reportCheckProgress(progress <-chan mfer.CheckStatus) {
|
||||
for status := range progress {
|
||||
if status.ETA > 0 {
|
||||
log.Progressf("Checking: %d/%d files, %s/s, ETA %s, %d failures",
|
||||
status.CheckedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
||||
status.ETA.Round(time.Second),
|
||||
status.Failures)
|
||||
} else {
|
||||
log.Progressf("Checking: %d/%d files, %s/s, %d failures",
|
||||
status.CheckedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
||||
status.Failures)
|
||||
}
|
||||
}
|
||||
|
||||
log.ProgressDone()
|
||||
}
|
||||
|
||||
// countCheckFailures consumes check results, counting and logging
|
||||
// failures, then closes done.
|
||||
func countCheckFailures(
|
||||
results <-chan mfer.Result, failures *int64, done chan<- struct{},
|
||||
) {
|
||||
for result := range results {
|
||||
if result.Status != mfer.StatusOK {
|
||||
*failures++
|
||||
|
||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||
} else {
|
||||
log.Verbosef("%s: %s", result.Status, result.Path)
|
||||
}
|
||||
}
|
||||
|
||||
close(done)
|
||||
}
|
||||
|
||||
// findExtraFiles reports files present on disk but absent from the
|
||||
// manifest, counting each as a failure.
|
||||
func findExtraFiles(ctx *cli.Context, chk *mfer.Checker, failures *int64) error {
|
||||
extraResults := make(chan mfer.Result, 1)
|
||||
extraDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for result := range extraResults {
|
||||
*failures++
|
||||
|
||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||
}
|
||||
|
||||
close(extraDone)
|
||||
}()
|
||||
|
||||
err := chk.FindExtraFiles(ctx.Context, extraResults)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for extra files: %w", err)
|
||||
}
|
||||
|
||||
<-extraDone
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCheck runs the manifest check with progress and result reporting
|
||||
// and returns the number of failures.
|
||||
func runCheck(ctx *cli.Context, chk *mfer.Checker, showProgress bool) (int64, error) {
|
||||
// Set up results channel
|
||||
results := make(chan mfer.Result, 1)
|
||||
|
||||
// Set up progress channel
|
||||
var progress chan mfer.CheckStatus
|
||||
if showProgress {
|
||||
progress = make(chan mfer.CheckStatus, 1)
|
||||
|
||||
go reportCheckProgress(progress)
|
||||
}
|
||||
|
||||
// Process results in a goroutine
|
||||
var failures int64
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go countCheckFailures(results, &failures, done)
|
||||
|
||||
// Run check
|
||||
err := chk.Check(ctx.Context, results, progress)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("check failed: %w", err)
|
||||
}
|
||||
|
||||
// Wait for results processing to complete
|
||||
<-done
|
||||
|
||||
// Check for extra files if requested
|
||||
if ctx.Bool("no-extra-files") {
|
||||
err = findExtraFiles(ctx, chk, &failures)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return failures, nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||
@@ -42,24 +279,13 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||
|
||||
// URL manifests need to be downloaded to a temp file for the checker
|
||||
if isHTTPURL(manifestPath) {
|
||||
rc, fetchErr := mfa.openManifestReader(manifestPath)
|
||||
if fetchErr != nil {
|
||||
return fmt.Errorf("check: %w", fetchErr)
|
||||
}
|
||||
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
|
||||
tmpPath, tmpErr := mfa.fetchManifestToTemp(manifestPath)
|
||||
if tmpErr != nil {
|
||||
_ = rc.Close()
|
||||
return fmt.Errorf("check: failed to create temp file: %w", tmpErr)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
_, cpErr := io.Copy(tmpFile, rc)
|
||||
_ = rc.Close()
|
||||
_ = tmpFile.Close()
|
||||
if cpErr != nil {
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
return fmt.Errorf("check: failed to download manifest: %w", cpErr)
|
||||
return fmt.Errorf("check: %w", tmpErr)
|
||||
}
|
||||
|
||||
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
||||
|
||||
manifestPath = tmpPath
|
||||
}
|
||||
|
||||
@@ -77,111 +303,31 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||
// Check signature requirement
|
||||
requiredSigner := ctx.String("require-signature")
|
||||
if requiredSigner != "" {
|
||||
// Validate fingerprint format: must be exactly 40 hex characters
|
||||
if len(requiredSigner) != 40 {
|
||||
return fmt.Errorf("invalid fingerprint: must be exactly 40 hex characters, got %d", len(requiredSigner))
|
||||
}
|
||||
if _, err := hex.DecodeString(requiredSigner); err != nil {
|
||||
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
|
||||
}
|
||||
|
||||
if !chk.IsSigned() {
|
||||
return fmt.Errorf("manifest is not signed, but signature from %s is required", requiredSigner)
|
||||
}
|
||||
|
||||
// Extract fingerprint from the embedded public key (not from the signer field)
|
||||
// This validates the key is importable and gets its actual fingerprint
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||
err = verifyRequiredSigner(chk, requiredSigner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract fingerprint from embedded signing key: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Compare fingerprints - must be exact match (case-insensitive)
|
||||
if !strings.EqualFold(embeddedFP, requiredSigner) {
|
||||
return fmt.Errorf("embedded signing key fingerprint %s does not match required %s", embeddedFP, requiredSigner)
|
||||
}
|
||||
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
|
||||
}
|
||||
|
||||
log.Infof("manifest contains %d files, %s", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())))
|
||||
log.Infof("manifest contains %d files, %s", chk.FileCount(),
|
||||
humanize.IBytes(safeUint64(int64(chk.TotalBytes()))))
|
||||
|
||||
// Set up results channel
|
||||
results := make(chan mfer.Result, 1)
|
||||
|
||||
// Set up progress channel
|
||||
var progress chan mfer.CheckStatus
|
||||
if showProgress {
|
||||
progress = make(chan mfer.CheckStatus, 1)
|
||||
go func() {
|
||||
for status := range progress {
|
||||
if status.ETA > 0 {
|
||||
log.Progressf("Checking: %d/%d files, %s/s, ETA %s, %d failures",
|
||||
status.CheckedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||
status.ETA.Round(time.Second),
|
||||
status.Failures)
|
||||
} else {
|
||||
log.Progressf("Checking: %d/%d files, %s/s, %d failures",
|
||||
status.CheckedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||
status.Failures)
|
||||
}
|
||||
}
|
||||
log.ProgressDone()
|
||||
}()
|
||||
}
|
||||
|
||||
// Process results in a goroutine
|
||||
var failures int64
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for result := range results {
|
||||
if result.Status != mfer.StatusOK {
|
||||
failures++
|
||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||
} else {
|
||||
log.Verbosef("%s: %s", result.Status, result.Path)
|
||||
}
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Run check
|
||||
err = chk.Check(ctx.Context, results, progress)
|
||||
failures, err := runCheck(ctx, chk, showProgress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check failed: %w", err)
|
||||
}
|
||||
|
||||
// Wait for results processing to complete
|
||||
<-done
|
||||
|
||||
// Check for extra files if requested
|
||||
if ctx.Bool("no-extra-files") {
|
||||
extraResults := make(chan mfer.Result, 1)
|
||||
extraDone := make(chan struct{})
|
||||
go func() {
|
||||
for result := range extraResults {
|
||||
failures++
|
||||
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||
}
|
||||
close(extraDone)
|
||||
}()
|
||||
|
||||
err = chk.FindExtraFiles(ctx.Context, extraResults)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for extra files: %w", err)
|
||||
}
|
||||
<-extraDone
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(mfa.startupTime).Seconds()
|
||||
|
||||
rate := float64(chk.TotalBytes()) / elapsed
|
||||
if failures == 0 {
|
||||
log.Infof("checked %d files (%s) in %.1fs (%s/s): all OK", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)))
|
||||
log.Infof("checked %d files (%s) in %.1fs (%s/s): all OK",
|
||||
chk.FileCount(), humanize.IBytes(safeUint64(int64(chk.TotalBytes()))),
|
||||
elapsed, humanize.IBytes(safeRateUint64(rate)))
|
||||
} else {
|
||||
log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)), failures)
|
||||
log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed",
|
||||
chk.FileCount(), humanize.IBytes(safeUint64(int64(chk.TotalBytes()))),
|
||||
elapsed, humanize.IBytes(safeRateUint64(rate)), failures)
|
||||
}
|
||||
|
||||
if failures > 0 {
|
||||
|
||||
@@ -7,15 +7,18 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// NO_COLOR disables colored output when set. Automatically true if the
|
||||
// NoColor disables colored output when set. Automatically true if the
|
||||
// NO_COLOR environment variable is present (per https://no-color.org/).
|
||||
var NO_COLOR bool
|
||||
//
|
||||
//nolint:gochecknoglobals // process-wide setting derived from the environment
|
||||
var NoColor = noColorEnvSet()
|
||||
|
||||
func init() {
|
||||
NO_COLOR = false
|
||||
if _, exists := os.LookupEnv("NO_COLOR"); exists {
|
||||
NO_COLOR = true
|
||||
}
|
||||
// noColorEnvSet reports whether the NO_COLOR environment variable is
|
||||
// present.
|
||||
func noColorEnvSet() bool {
|
||||
_, exists := os.LookupEnv("NO_COLOR")
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
// RunOptions contains all configuration for running the CLI application.
|
||||
@@ -64,5 +67,6 @@ func RunWithOptions(opts *RunOptions) int {
|
||||
}
|
||||
|
||||
m.run(opts.Args)
|
||||
|
||||
return m.exitCode
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//nolint:testpackage // white-box tests exercise unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
@@ -13,19 +17,53 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const (
|
||||
testApp = "mfer"
|
||||
testDir = "/testdir"
|
||||
testFile1 = "/testdir/file1.txt"
|
||||
testMF = "/testdir/test.mf"
|
||||
testOutput = "/output.mf"
|
||||
testOutputTmp = "/output.mf.tmp"
|
||||
testManifest = "/manifest.mf"
|
||||
testFlagBase = "--base"
|
||||
testFlagNoExtra = "--no-extra-files"
|
||||
)
|
||||
|
||||
var errSimulatedWrite = errors.New("simulated write failure")
|
||||
|
||||
// runMu serializes CLI runs: RunWithOptions wires the process-global
|
||||
// logger to the run's I/O streams, so parallel runs would cross-wire
|
||||
// captured output between tests.
|
||||
//
|
||||
//nolint:gochecknoglobals // guards process-global logger state in tests
|
||||
var runMu sync.Mutex
|
||||
|
||||
// runCLI invokes RunWithOptions while holding runMu so parallel tests
|
||||
// capture their own output.
|
||||
func runCLI(opts *RunOptions) int {
|
||||
runMu.Lock()
|
||||
defer runMu.Unlock()
|
||||
|
||||
return RunWithOptions(opts)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Prevent urfave/cli from calling os.Exit during tests
|
||||
urfcli.OsExiter = func(code int) {}
|
||||
urfcli.OsExiter = func(_ int) {}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := &CLIApp{}
|
||||
assert.NotNil(t, m)
|
||||
}
|
||||
|
||||
func testOpts(args []string, fs afero.Fs) *RunOptions {
|
||||
return &RunOptions{
|
||||
Appname: "mfer",
|
||||
Appname: testApp,
|
||||
Version: "1.0.0",
|
||||
Gitrev: "abc123",
|
||||
Args: args,
|
||||
@@ -36,374 +74,451 @@ func testOpts(args []string, fs afero.Fs) *RunOptions {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{"mfer", "version"}, fs)
|
||||
func testStdout(t *testing.T, opts *RunOptions) string {
|
||||
t.Helper()
|
||||
|
||||
exitCode := RunWithOptions(opts)
|
||||
buf, ok := opts.Stdout.(*bytes.Buffer)
|
||||
require.True(t, ok)
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func testStderr(t *testing.T, opts *RunOptions) string {
|
||||
t.Helper()
|
||||
|
||||
buf, ok := opts.Stderr.(*bytes.Buffer)
|
||||
require.True(t, ok)
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, path, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func TestVersionCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{testApp, "version"}, fs)
|
||||
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 0, exitCode)
|
||||
stdout := opts.Stdout.(*bytes.Buffer).String()
|
||||
|
||||
stdout := testStdout(t, opts)
|
||||
assert.Contains(t, stdout, mfer.Version)
|
||||
assert.Contains(t, stdout, "abc123")
|
||||
}
|
||||
|
||||
func TestHelpCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{"mfer", "--help"}, fs)
|
||||
t.Parallel()
|
||||
|
||||
exitCode := RunWithOptions(opts)
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{testApp, "--help"}, fs)
|
||||
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 0, exitCode)
|
||||
stdout := opts.Stdout.(*bytes.Buffer).String()
|
||||
assert.Contains(t, stdout, "generate")
|
||||
assert.Contains(t, stdout, "check")
|
||||
|
||||
stdout := testStdout(t, opts)
|
||||
assert.Contains(t, stdout, cmdGenerate)
|
||||
assert.Contains(t, stdout, cmdCheck)
|
||||
assert.Contains(t, stdout, "fetch")
|
||||
}
|
||||
|
||||
func TestGenerateCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files in memory filesystem
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("test content"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello world")
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "test content")
|
||||
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
||||
|
||||
exitCode := RunWithOptions(opts)
|
||||
exitCode := runCLI(opts)
|
||||
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
||||
|
||||
// Verify manifest was created
|
||||
exists, err := afero.Exists(fs, "/testdir/test.mf")
|
||||
exists, err := afero.Exists(fs, testMF)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestGenerateAndCheckCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files with subdirectory
|
||||
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file2.txt", []byte("test content"), 0o644))
|
||||
writeTestFile(t, fs, testFile1, "hello world")
|
||||
writeTestFile(t, fs, "/testdir/subdir/file2.txt", "test content")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts))
|
||||
|
||||
// Check manifest
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
assert.Equal(t, 0, exitCode, "check failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||
opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 0, exitCode, "check failed: %s", testStderr(t, opts))
|
||||
}
|
||||
|
||||
func TestCheckCommandWithMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello world")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts))
|
||||
|
||||
// Delete the file
|
||||
require.NoError(t, fs.Remove("/testdir/file1.txt"))
|
||||
require.NoError(t, fs.Remove(testFile1))
|
||||
|
||||
// Check manifest - should fail
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode, "check should have failed for missing file")
|
||||
}
|
||||
|
||||
func TestCheckCommandWithCorruptedFile(t *testing.T) {
|
||||
func runCheckAfterRewrite(t *testing.T, rewritten, msg string) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello world")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts))
|
||||
|
||||
// Rewrite the file, then check the manifest - it must fail
|
||||
writeTestFile(t, fs, testFile1, rewritten)
|
||||
|
||||
opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode, msg)
|
||||
}
|
||||
|
||||
func TestCheckCommandWithCorruptedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Corrupt the file (change content but keep same size)
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("HELLO WORLD"), 0o644))
|
||||
|
||||
// Check manifest - should fail with hash mismatch
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
assert.Equal(t, 1, exitCode, "check should have failed for corrupted file")
|
||||
runCheckAfterRewrite(t, "HELLO WORLD",
|
||||
"check should have failed for corrupted file")
|
||||
}
|
||||
|
||||
func TestCheckCommandWithSizeMismatch(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||
t.Parallel()
|
||||
|
||||
// Change file size
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("different size content here"), 0o644))
|
||||
|
||||
// Check manifest - should fail with size mismatch
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
assert.Equal(t, 1, exitCode, "check should have failed for size mismatch")
|
||||
runCheckAfterRewrite(t, "different size content here",
|
||||
"check should have failed for size mismatch")
|
||||
}
|
||||
|
||||
func TestBannerOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Run without -q to see banner
|
||||
opts := testOpts([]string{"mfer", "generate", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-o", testMF, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 0, exitCode)
|
||||
|
||||
// Banner ASCII art should be in stdout
|
||||
stdout := opts.Stdout.(*bytes.Buffer).String()
|
||||
stdout := testStdout(t, opts)
|
||||
assert.Contains(t, stdout, "___")
|
||||
assert.Contains(t, stdout, "\\")
|
||||
}
|
||||
|
||||
func TestUnknownCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{"mfer", "unknown"}, fs)
|
||||
t.Parallel()
|
||||
|
||||
exitCode := RunWithOptions(opts)
|
||||
fs := afero.NewMemMapFs()
|
||||
opts := testOpts([]string{testApp, "unknown"}, fs)
|
||||
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode)
|
||||
}
|
||||
|
||||
func TestGenerateExcludesDotfilesByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files including dotfiles
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden", []byte("secret"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
writeTestFile(t, fs, "/testdir/.hidden", "secret")
|
||||
|
||||
// Generate manifest without --include-dotfiles (default excludes dotfiles)
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Check that manifest exists
|
||||
exists, _ := afero.Exists(fs, "/testdir/test.mf")
|
||||
exists, _ := afero.Exists(fs, testMF)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Verify manifest only has 1 file (the non-dotfile)
|
||||
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/test.mf")
|
||||
manifest, err := mfer.NewManifestFromFile(fs, testMF)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 1)
|
||||
assert.Equal(t, "file1.txt", manifest.Files()[0].Path)
|
||||
assert.Equal(t, "file1.txt", manifest.Files()[0].GetPath())
|
||||
}
|
||||
|
||||
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files including dotfiles
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden", []byte("secret"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
writeTestFile(t, fs, "/testdir/.hidden", "secret")
|
||||
|
||||
// Generate manifest with --include-dotfiles
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "--include-dotfiles", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "--include-dotfiles", "-o", testMF, testDir,
|
||||
}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Verify manifest has 2 files (including dotfile)
|
||||
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/test.mf")
|
||||
manifest, err := mfer.NewManifestFromFile(fs, testMF)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
}
|
||||
|
||||
func TestMultipleInputPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files in multiple directories
|
||||
require.NoError(t, fs.MkdirAll("/dir1", 0o755))
|
||||
require.NoError(t, fs.MkdirAll("/dir2", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/dir1/file1.txt", []byte("content1"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/dir2/file2.txt", []byte("content2"), 0o644))
|
||||
writeTestFile(t, fs, "/dir1/file1.txt", "content1")
|
||||
writeTestFile(t, fs, "/dir2/file2.txt", "content2")
|
||||
|
||||
// Generate manifest from multiple paths
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/dir1", "/dir2"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", testOutput, "/dir1", "/dir2",
|
||||
}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
||||
|
||||
exists, _ := afero.Exists(fs, "/output.mf")
|
||||
exists, _ := afero.Exists(fs, testOutput)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestNoExtraFilesPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("world"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "world")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Check with --no-extra-files (should pass - no extra files)
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 0, exitCode)
|
||||
}
|
||||
|
||||
func TestNoExtraFilesFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Add an extra file after manifest generation
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/extra.txt", []byte("extra"), 0o644))
|
||||
writeTestFile(t, fs, "/testdir/extra.txt", "extra")
|
||||
|
||||
// Check with --no-extra-files (should fail - extra file exists)
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode, "check should fail when extra files exist")
|
||||
}
|
||||
|
||||
func TestNoExtraFilesWithSubdirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files with subdirectory
|
||||
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file2.txt", []byte("world"), 0o644))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
writeTestFile(t, fs, "/testdir/subdir/file2.txt", "world")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Add extra file in subdirectory
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/extra.txt", []byte("extra"), 0o644))
|
||||
writeTestFile(t, fs, "/testdir/subdir/extra.txt", "extra")
|
||||
|
||||
// Check with --no-extra-files (should fail)
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
assert.Equal(t, 1, exitCode, "check should fail when extra files exist in subdirectory")
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode,
|
||||
"check should fail when extra files exist in subdirectory")
|
||||
}
|
||||
|
||||
func TestCheckWithoutNoExtraFilesIgnoresExtra(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Add extra file
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/extra.txt", []byte("extra"), 0o644))
|
||||
writeTestFile(t, fs, "/testdir/extra.txt", "extra")
|
||||
|
||||
// Check WITHOUT --no-extra-files (should pass - extra files ignored)
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
assert.Equal(t, 0, exitCode, "check without --no-extra-files should ignore extra files")
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 0, exitCode,
|
||||
"check without --no-extra-files should ignore extra files")
|
||||
}
|
||||
|
||||
func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Verify output file exists
|
||||
exists, err := afero.Exists(fs, "/output.mf")
|
||||
exists, err := afero.Exists(fs, testOutput)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "output file should exist")
|
||||
|
||||
// Verify temp file does NOT exist
|
||||
tmpExists, err := afero.Exists(fs, "/output.mf.tmp")
|
||||
tmpExists, err := afero.Exists(fs, testOutputTmp)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, tmpExists, "temp file should not exist after successful generation")
|
||||
assert.False(t, tmpExists,
|
||||
"temp file should not exist after successful generation")
|
||||
}
|
||||
|
||||
func TestGenerateAtomicWriteOverwriteWithForce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Create existing manifest with different content
|
||||
require.NoError(t, afero.WriteFile(fs, "/output.mf", []byte("old content"), 0o644))
|
||||
writeTestFile(t, fs, testOutput, "old content")
|
||||
|
||||
// Generate manifest with --force
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-f", "-o", "/output.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-f", "-o", testOutput, testDir,
|
||||
}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Verify output file exists and was overwritten
|
||||
content, err := afero.ReadFile(fs, "/output.mf")
|
||||
content, err := afero.ReadFile(fs, testOutput)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, "old content", string(content), "manifest should be overwritten")
|
||||
assert.NotEqual(t, "old content", string(content),
|
||||
"manifest should be overwritten")
|
||||
|
||||
// Verify temp file does NOT exist
|
||||
tmpExists, err := afero.Exists(fs, "/output.mf.tmp")
|
||||
tmpExists, err := afero.Exists(fs, testOutputTmp)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, tmpExists, "temp file should not exist after successful generation")
|
||||
assert.False(t, tmpExists,
|
||||
"temp file should not exist after successful generation")
|
||||
}
|
||||
|
||||
func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Create existing manifest
|
||||
require.NoError(t, afero.WriteFile(fs, "/output.mf", []byte("existing"), 0o644))
|
||||
writeTestFile(t, fs, testOutput, "existing")
|
||||
|
||||
// Generate manifest WITHOUT --force (should fail)
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode, "should fail when output exists without --force")
|
||||
|
||||
// Verify original content is preserved
|
||||
content, err := afero.ReadFile(fs, "/output.mf")
|
||||
content, err := afero.ReadFile(fs, testOutput)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "existing", string(content), "original file should be preserved")
|
||||
}
|
||||
|
||||
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test verifies that generate uses a temp file by checking
|
||||
// that the output file doesn't exist until generation completes.
|
||||
// We do this by generating to a path and verifying the temp file
|
||||
@@ -411,183 +526,239 @@ func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create test file
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "hello")
|
||||
|
||||
// Generate manifest
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
// Both output file should exist and temp should not
|
||||
exists, _ := afero.Exists(fs, "/output.mf")
|
||||
exists, _ := afero.Exists(fs, testOutput)
|
||||
assert.True(t, exists, "output file should exist")
|
||||
|
||||
tmpExists, _ := afero.Exists(fs, "/output.mf.tmp")
|
||||
tmpExists, _ := afero.Exists(fs, testOutputTmp)
|
||||
assert.False(t, tmpExists, "temp file should be cleaned up")
|
||||
|
||||
// Verify manifest is valid (not empty)
|
||||
content, err := afero.ReadFile(fs, "/output.mf")
|
||||
content, err := afero.ReadFile(fs, testOutput)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, len(content) > 0, "manifest should not be empty")
|
||||
assert.NotEmpty(t, content, "manifest should not be empty")
|
||||
}
|
||||
|
||||
// failingWriterFs wraps a filesystem and makes writes fail after N bytes
|
||||
type failingWriterFs struct {
|
||||
afero.Fs
|
||||
|
||||
failAfter int64
|
||||
written int64
|
||||
}
|
||||
|
||||
type failingFile struct {
|
||||
afero.File
|
||||
|
||||
fs *failingWriterFs
|
||||
}
|
||||
|
||||
func (f *failingFile) Write(p []byte) (int, error) {
|
||||
f.fs.written += int64(len(p))
|
||||
if f.fs.written > f.fs.failAfter {
|
||||
return 0, fmt.Errorf("simulated write failure")
|
||||
return 0, errSimulatedWrite
|
||||
}
|
||||
|
||||
return f.File.Write(p)
|
||||
}
|
||||
|
||||
//nolint:ireturn // Create must return afero.File to satisfy afero.Fs.
|
||||
func (fs *failingWriterFs) Create(name string) (afero.File, error) {
|
||||
f, err := fs.Fs.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &failingFile{File: f, fs: fs}, nil
|
||||
}
|
||||
|
||||
func TestGenerateAtomicWriteCleansUpOnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseFs := afero.NewMemMapFs()
|
||||
|
||||
// Create test files - need enough content to trigger the write failure
|
||||
require.NoError(t, baseFs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(baseFs, "/testdir/file1.txt", []byte("hello world this is a test file"), 0o644))
|
||||
require.NoError(t, baseFs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, baseFs, testFile1, "hello world this is a test file")
|
||||
|
||||
// Wrap with failing writer that fails after writing some bytes
|
||||
fs := &failingWriterFs{Fs: baseFs, failAfter: 10}
|
||||
|
||||
// Generate manifest - should fail during write
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode, "should fail due to write error")
|
||||
|
||||
// With atomic writes: output.mf should NOT exist (temp was cleaned up)
|
||||
// With non-atomic writes: output.mf WOULD exist (partial/empty)
|
||||
exists, _ := afero.Exists(baseFs, "/output.mf")
|
||||
assert.False(t, exists, "output file should not exist after failed generation (atomic write)")
|
||||
exists, _ := afero.Exists(baseFs, testOutput)
|
||||
assert.False(t, exists,
|
||||
"output file should not exist after failed generation (atomic write)")
|
||||
|
||||
// Temp file should also not exist
|
||||
tmpExists, _ := afero.Exists(baseFs, "/output.mf.tmp")
|
||||
assert.False(t, tmpExists, "temp file should be cleaned up after failed generation")
|
||||
tmpExists, _ := afero.Exists(baseFs, testOutputTmp)
|
||||
assert.False(t, tmpExists,
|
||||
"temp file should be cleaned up after failed generation")
|
||||
}
|
||||
|
||||
func TestGenerateValidatesInputPaths(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
t.Parallel()
|
||||
|
||||
// Create one valid directory
|
||||
require.NoError(t, fs.MkdirAll("/validdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/validdir/file.txt", []byte("content"), 0o644))
|
||||
seedValidDir := func(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, fs.MkdirAll("/validdir", 0o755))
|
||||
writeTestFile(t, fs, "/validdir/file.txt", "content")
|
||||
}
|
||||
|
||||
t.Run("nonexistent path fails fast", func(t *testing.T) {
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/nonexistent"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
seedValidDir(t, fs)
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", testOutput, "/nonexistent",
|
||||
}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode)
|
||||
stderr := opts.Stderr.(*bytes.Buffer).String()
|
||||
|
||||
stderr := testStderr(t, opts)
|
||||
assert.Contains(t, stderr, "path does not exist")
|
||||
assert.Contains(t, stderr, "/nonexistent")
|
||||
})
|
||||
|
||||
t.Run("mix of valid and invalid paths fails fast", func(t *testing.T) {
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir", "/alsononexistent"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
seedValidDir(t, fs)
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", testOutput,
|
||||
"/validdir", "/alsononexistent",
|
||||
}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode)
|
||||
stderr := opts.Stderr.(*bytes.Buffer).String()
|
||||
|
||||
stderr := testStderr(t, opts)
|
||||
assert.Contains(t, stderr, "path does not exist")
|
||||
assert.Contains(t, stderr, "/alsononexistent")
|
||||
|
||||
// Output file should not have been created
|
||||
exists, _ := afero.Exists(fs, "/output.mf")
|
||||
assert.False(t, exists, "output file should not exist when path validation fails")
|
||||
exists, _ := afero.Exists(fs, testOutput)
|
||||
assert.False(t, exists,
|
||||
"output file should not exist when path validation fails")
|
||||
})
|
||||
|
||||
t.Run("valid paths succeed", func(t *testing.T) {
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
seedValidDir(t, fs)
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", testOutput, "/validdir",
|
||||
}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
assert.Equal(t, 0, exitCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data
|
||||
|
||||
// Create many small files with random names to generate a ~1MB manifest
|
||||
// Each manifest entry is roughly 50-60 bytes, so we need ~20000 files
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
|
||||
numFiles := 20000
|
||||
for i := 0; i < numFiles; i++ {
|
||||
for range numFiles {
|
||||
// Generate random filename
|
||||
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat", rng.Uint32(), rng.Uint32(), rng.Uint32())
|
||||
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat",
|
||||
rng.Uint32(), rng.Uint32(), rng.Uint32())
|
||||
// Small random content
|
||||
content := make([]byte, 16+rng.Intn(48))
|
||||
rng.Read(content)
|
||||
_, _ = rng.Read(content)
|
||||
require.NoError(t, afero.WriteFile(fs, filename, content, 0o644))
|
||||
}
|
||||
|
||||
// Generate manifest outside of testdir
|
||||
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||
exitCode := RunWithOptions(opts)
|
||||
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
||||
exitCode := runCLI(opts)
|
||||
require.Equal(t, 0, exitCode, "generate should succeed")
|
||||
|
||||
// Read the valid manifest and verify it's approximately 1MB
|
||||
validManifest, err := afero.ReadFile(fs, "/manifest.mf")
|
||||
validManifest, err := afero.ReadFile(fs, testManifest)
|
||||
require.NoError(t, err)
|
||||
require.True(t, len(validManifest) >= 1024*1024, "manifest should be at least 1MB, got %d bytes", len(validManifest))
|
||||
require.GreaterOrEqual(t, len(validManifest), 1024*1024,
|
||||
"manifest should be at least 1MB, got %d bytes", len(validManifest))
|
||||
t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles)
|
||||
|
||||
// First corruption: truncate the manifest
|
||||
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", validManifest[:len(validManifest)/2], 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, testManifest,
|
||||
validManifest[:len(validManifest)/2], 0o644))
|
||||
|
||||
// Check should fail with truncated manifest
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode, "check should fail with truncated manifest")
|
||||
|
||||
// Verify check passes with valid manifest
|
||||
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", validManifest, 0o644))
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
require.NoError(t, afero.WriteFile(fs, testManifest, validManifest, 0o644))
|
||||
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
require.Equal(t, 0, exitCode, "check should pass with valid manifest")
|
||||
|
||||
// Now do 500 random corruption iterations
|
||||
for i := 0; i < 500; i++ {
|
||||
for i := range 500 {
|
||||
// Corrupt: write a random byte at a random offset
|
||||
corrupted := make([]byte, len(validManifest))
|
||||
copy(corrupted, validManifest)
|
||||
|
||||
offset := rng.Intn(len(corrupted))
|
||||
originalByte := corrupted[offset]
|
||||
|
||||
// Make sure we actually change the byte
|
||||
newByte := byte(rng.Intn(256))
|
||||
buf := make([]byte, 1)
|
||||
|
||||
newByte := originalByte
|
||||
for newByte == originalByte {
|
||||
newByte = byte(rng.Intn(256))
|
||||
_, _ = rng.Read(buf)
|
||||
newByte = buf[0]
|
||||
}
|
||||
|
||||
corrupted[offset] = newByte
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", corrupted, 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, testManifest, corrupted, 0o644))
|
||||
|
||||
// Check should fail with corrupted manifest
|
||||
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||
exitCode = RunWithOptions(opts)
|
||||
assert.Equal(t, 1, exitCode, "iteration %d: check should fail with corrupted manifest (offset %d, 0x%02x -> 0x%02x)",
|
||||
opts = testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
||||
}, fs)
|
||||
exitCode = runCLI(opts)
|
||||
assert.Equal(t, 1, exitCode,
|
||||
"iteration %d: check should fail with corrupted manifest "+
|
||||
"(offset %d, 0x%02x -> 0x%02x)",
|
||||
i, offset, originalByte, newByte)
|
||||
|
||||
// Restore valid manifest for next iteration
|
||||
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", validManifest, 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, testManifest, validManifest, 0o644))
|
||||
}
|
||||
}
|
||||
|
||||
168
internal/cli/errmsg_test.go
Normal file
168
internal/cli/errmsg_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
//nolint:testpackage // white-box tests exercise unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// errMsgCase is one pinned user-visible error message.
|
||||
type errMsgCase struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}
|
||||
|
||||
const (
|
||||
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
)
|
||||
|
||||
func checkErrMsgCases(t *testing.T, cases []errMsgCase) {
|
||||
t.Helper()
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, tc.err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorMessagesVerbatim pins the exact rendered text of the CLI's
|
||||
// user-visible error messages.
|
||||
//
|
||||
// These strings are an interface: they are grepped for in CI pipelines
|
||||
// and quoted in bug reports. The messages are assembled by wrapping
|
||||
// static sentinels, and it is easy to change what a user sees while
|
||||
// only meaning to make an error matchable with errors.Is - which is
|
||||
// precisely what happened once already. Any change to a string below is
|
||||
// therefore a deliberate, separately stated change, never a side effect
|
||||
// of a refactor.
|
||||
func TestErrorMessagesVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checkErrMsgCases(t, []errMsgCase{
|
||||
{
|
||||
name: "check: no manifest found",
|
||||
err: fmt.Errorf("%w in %s (looked for index.mf and .index.mf)",
|
||||
errNoManifestFound, "/tmp/x"),
|
||||
want: "no manifest found in /tmp/x " +
|
||||
"(looked for index.mf and .index.mf)",
|
||||
},
|
||||
{
|
||||
name: "check: invalid fingerprint length",
|
||||
err: fmt.Errorf("%w, got %d", errInvalidFingerprint, 8),
|
||||
want: "invalid fingerprint: must be exactly 40 hex characters, got 8",
|
||||
},
|
||||
{
|
||||
name: "check: manifest not signed",
|
||||
err: fmt.Errorf("%w, but signature from %s is required",
|
||||
errManifestNotSigned, msgFpA),
|
||||
want: "manifest is not signed, but signature from " + msgFpA +
|
||||
" is required",
|
||||
},
|
||||
{
|
||||
name: "check: signer mismatch",
|
||||
err: fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
msgFpA, errSignerMismatch, msgFpB),
|
||||
want: "embedded signing key fingerprint " + msgFpA +
|
||||
" does not match required " + msgFpB,
|
||||
},
|
||||
{
|
||||
name: "gen: path does not exist",
|
||||
err: fmt.Errorf("%w: %s", errPathNotExist, "nope"),
|
||||
want: "path does not exist: nope",
|
||||
},
|
||||
{
|
||||
name: "gen: output file exists",
|
||||
err: fmt.Errorf("output file %s %w", "index.mf", errOutputExists),
|
||||
want: "output file index.mf already exists " +
|
||||
"(use --force to overwrite)",
|
||||
},
|
||||
{
|
||||
name: "mfer: unknown command",
|
||||
err: fmt.Errorf("%w %q", errUnknownCommand, "bogus"),
|
||||
want: `unknown command "bogus"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestFetchErrorMessagesVerbatim pins the fetch and manifest-loader
|
||||
// messages; see TestErrorMessagesVerbatim for why.
|
||||
func TestFetchErrorMessagesVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checkErrMsgCases(t, []errMsgCase{
|
||||
{
|
||||
name: "manifest_loader: http status",
|
||||
err: fmt.Errorf("failed to fetch %s: %w %d",
|
||||
"https://example.com/index.mf", errHTTPStatus, 404),
|
||||
want: "failed to fetch https://example.com/index.mf: HTTP 404",
|
||||
},
|
||||
{
|
||||
name: "fetch: manifest http status",
|
||||
err: fmt.Errorf("failed to fetch manifest: %w %d",
|
||||
errHTTPStatus, 404),
|
||||
want: "failed to fetch manifest: HTTP 404",
|
||||
},
|
||||
{
|
||||
name: "fetch: file http status",
|
||||
err: fmt.Errorf("%w %d", errHTTPStatus, 500),
|
||||
want: "HTTP 500",
|
||||
},
|
||||
{
|
||||
name: "fetch: empty path",
|
||||
err: errEmptyPath,
|
||||
want: "empty path",
|
||||
},
|
||||
{
|
||||
name: "fetch: absolute path",
|
||||
err: fmt.Errorf("%w: %s", errAbsolutePath, "/etc/passwd"),
|
||||
want: "absolute path not allowed: /etc/passwd",
|
||||
},
|
||||
{
|
||||
name: "fetch: path traversal",
|
||||
err: fmt.Errorf("%w: %s", errPathTraversal, "../x"),
|
||||
want: "path traversal not allowed: ../x",
|
||||
},
|
||||
{
|
||||
name: "fetch: size mismatch",
|
||||
err: fmt.Errorf("%w: expected %d bytes, got %d",
|
||||
errSizeMismatch, 10, 9),
|
||||
want: "size mismatch: expected 10 bytes, got 9",
|
||||
},
|
||||
{
|
||||
name: "fetch: url required",
|
||||
err: errURLRequired,
|
||||
want: "URL argument required",
|
||||
},
|
||||
{
|
||||
name: "fetch: hash mismatch",
|
||||
err: errHashMismatch,
|
||||
want: "hash mismatch",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestSentinelsAreMatchable checks that the wrapped forms of the
|
||||
// messages above remain matchable with errors.Is, which is the reason
|
||||
// the sentinels exist at all.
|
||||
func TestSentinelsAreMatchable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrapped := fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
"a", errSignerMismatch, "b")
|
||||
require.ErrorIs(t, wrapped, errSignerMismatch)
|
||||
|
||||
wrapped = fmt.Errorf("output file %s %w", "index.mf", errOutputExists)
|
||||
require.ErrorIs(t, wrapped, errOutputExists)
|
||||
|
||||
wrapped = fmt.Errorf("failed to fetch manifest: %w %d", errHTTPStatus, 404)
|
||||
require.ErrorIs(t, wrapped, errHTTPStatus)
|
||||
|
||||
assert.NotErrorIs(t, errHashMismatch, errSizeMismatch)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func (mfa *CLIApp) exportManifestOperation(ctx *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
manifest, err := mfer.NewManifestFromReader(rc)
|
||||
@@ -41,21 +42,23 @@ func (mfa *CLIApp) exportManifestOperation(ctx *cli.Context) error {
|
||||
|
||||
for _, f := range files {
|
||||
entry := ExportEntry{
|
||||
Path: f.Path,
|
||||
Size: f.Size,
|
||||
Hashes: make([]string, 0, len(f.Hashes)),
|
||||
Path: f.GetPath(),
|
||||
Size: f.GetSize(),
|
||||
Hashes: make([]string, 0, len(f.GetHashes())),
|
||||
}
|
||||
|
||||
for _, h := range f.Hashes {
|
||||
entry.Hashes = append(entry.Hashes, hex.EncodeToString(h.MultiHash))
|
||||
for _, h := range f.GetHashes() {
|
||||
entry.Hashes = append(entry.Hashes, hex.EncodeToString(h.GetMultiHash()))
|
||||
}
|
||||
|
||||
if f.Mtime != nil {
|
||||
t := time.Unix(f.Mtime.Seconds, int64(f.Mtime.Nanos)).UTC().Format(time.RFC3339Nano)
|
||||
if mtime, ok := entryMtime(f); ok {
|
||||
t := mtime.UTC().Format(time.RFC3339Nano)
|
||||
entry.Mtime = &t
|
||||
}
|
||||
if f.Ctime != nil {
|
||||
t := time.Unix(f.Ctime.Seconds, int64(f.Ctime.Nanos)).UTC().Format(time.RFC3339Nano)
|
||||
|
||||
if f.GetCtime() != nil {
|
||||
t := time.Unix(f.GetCtime().GetSeconds(), int64(f.GetCtime().GetNanos())).
|
||||
UTC().Format(time.RFC3339Nano)
|
||||
entry.Ctime = &t
|
||||
}
|
||||
|
||||
@@ -64,7 +67,9 @@ func (mfa *CLIApp) exportManifestOperation(ctx *cli.Context) error {
|
||||
|
||||
enc := json.NewEncoder(mfa.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(entries); err != nil {
|
||||
|
||||
err = enc.Encode(entries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: failed to encode JSON: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
const testCmdExport = "export"
|
||||
|
||||
// buildTestManifest creates a manifest from in-memory files and returns its bytes.
|
||||
func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
for path, content := range files {
|
||||
require.NoError(t, sourceFs.MkdirAll("/", 0o755))
|
||||
@@ -28,11 +31,15 @@ func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExportManifestOperation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testFiles := map[string][]byte{
|
||||
"hello.txt": []byte("Hello, World!"),
|
||||
"sub/file.txt": []byte("nested content"),
|
||||
@@ -44,9 +51,10 @@ func TestExportManifestOperation(t *testing.T) {
|
||||
require.NoError(t, afero.WriteFile(fs, "/test.mf", manifestData, 0o644))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := RunWithOptions(&RunOptions{
|
||||
Appname: "mfer",
|
||||
Args: []string{"mfer", "export", "/test.mf"},
|
||||
|
||||
exitCode := runCLI(&RunOptions{
|
||||
Appname: testApp,
|
||||
Args: []string{testApp, testCmdExport, "/test.mf"},
|
||||
Stdin: &bytes.Buffer{},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
@@ -64,28 +72,33 @@ func TestExportManifestOperation(t *testing.T) {
|
||||
for _, e := range entries {
|
||||
pathSet[e.Path] = true
|
||||
assert.NotEmpty(t, e.Hashes, "entry %s should have hashes", e.Path)
|
||||
assert.Greater(t, e.Size, int64(0), "entry %s should have positive size", e.Path)
|
||||
assert.Positive(t, e.Size, "entry %s should have positive size", e.Path)
|
||||
}
|
||||
|
||||
assert.True(t, pathSet["hello.txt"])
|
||||
assert.True(t, pathSet["sub/file.txt"])
|
||||
}
|
||||
|
||||
func TestExportFromHTTPURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testFiles := map[string][]byte{
|
||||
"a.txt": []byte("aaa"),
|
||||
}
|
||||
manifestData := buildTestManifest(t, testFiles)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(manifestData)
|
||||
}))
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(manifestData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := RunWithOptions(&RunOptions{
|
||||
Appname: "mfer",
|
||||
Args: []string{"mfer", "export", server.URL + "/index.mf"},
|
||||
|
||||
exitCode := runCLI(&RunOptions{
|
||||
Appname: testApp,
|
||||
Args: []string{testApp, testCmdExport, server.URL + "/index.mf"},
|
||||
Stdin: &bytes.Buffer{},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
@@ -101,21 +114,25 @@ func TestExportFromHTTPURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListFromHTTPURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testFiles := map[string][]byte{
|
||||
"one.txt": []byte("1"),
|
||||
"two.txt": []byte("22"),
|
||||
}
|
||||
manifestData := buildTestManifest(t, testFiles)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(manifestData)
|
||||
}))
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(manifestData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := RunWithOptions(&RunOptions{
|
||||
Appname: "mfer",
|
||||
Args: []string{"mfer", "list", server.URL + "/index.mf"},
|
||||
|
||||
exitCode := runCLI(&RunOptions{
|
||||
Appname: testApp,
|
||||
Args: []string{testApp, "list", server.URL + "/index.mf"},
|
||||
Stdin: &bytes.Buffer{},
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
@@ -129,6 +146,8 @@ func TestListFromHTTPURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsHTTPURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, isHTTPURL("http://example.com/manifest.mf"))
|
||||
assert.True(t, isHTTPURL("https://example.com/manifest.mf"))
|
||||
assert.False(t, isHTTPURL("/local/path.mf"))
|
||||
|
||||
@@ -2,7 +2,9 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -20,6 +22,45 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
const (
|
||||
// progressChanBuffer is the buffer size of the download progress
|
||||
// channel.
|
||||
progressChanBuffer = 10
|
||||
|
||||
// bitsPerByte converts a bytes-per-second rate to bits per second.
|
||||
bitsPerByte = 8
|
||||
|
||||
// dirPerms is the permission mode for directories created for
|
||||
// downloaded files. Fetched trees are content that is normally
|
||||
// published (served by a web server, read by another uid), so the
|
||||
// traversal bit for group and other must stay set.
|
||||
dirPerms os.FileMode = 0o755
|
||||
|
||||
// Bitrate unit thresholds in bits per second.
|
||||
bpsPerGbps = 1e9
|
||||
bpsPerMbps = 1e6
|
||||
bpsPerKbps = 1e3
|
||||
)
|
||||
|
||||
var (
|
||||
// errURLRequired indicates the fetch command was run without a URL
|
||||
// argument.
|
||||
errURLRequired = errors.New("URL argument required")
|
||||
// errEmptyPath indicates an empty file path in the manifest.
|
||||
errEmptyPath = errors.New("empty path")
|
||||
// errAbsolutePath indicates an absolute file path in the manifest.
|
||||
errAbsolutePath = errors.New("absolute path not allowed")
|
||||
// errPathTraversal indicates a manifest path escaping the target
|
||||
// directory.
|
||||
errPathTraversal = errors.New("path traversal not allowed")
|
||||
// errSizeMismatch indicates a downloaded file with an unexpected
|
||||
// size.
|
||||
errSizeMismatch = errors.New("size mismatch")
|
||||
// errHashMismatch indicates a downloaded file whose hash matches no
|
||||
// manifest hash.
|
||||
errHashMismatch = errors.New("hash mismatch")
|
||||
)
|
||||
|
||||
// DownloadProgress reports the progress of a single file download.
|
||||
type DownloadProgress struct {
|
||||
Path string // File path being downloaded
|
||||
@@ -29,14 +70,98 @@ type DownloadProgress struct {
|
||||
ETA time.Duration // Estimated time to completion
|
||||
}
|
||||
|
||||
// httpGet issues a GET request for the given URL using the provided
|
||||
// context and returns the response. The caller must close the body.
|
||||
//
|
||||
// Errors are returned unwrapped: this helper replaced direct http.Get
|
||||
// calls, and each caller already supplies its own context string, so
|
||||
// adding one here would change user-visible messages.
|
||||
func httpGet(ctx context.Context, fileURL string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// reportDownloadProgress renders download progress until the channel
|
||||
// closes, then closes done.
|
||||
func reportDownloadProgress(progress <-chan DownloadProgress, done chan<- struct{}) {
|
||||
defer close(done)
|
||||
|
||||
for p := range progress {
|
||||
rate := formatBitrate(p.BytesPerSec * bitsPerByte)
|
||||
if p.ETA > 0 {
|
||||
log.Infof("%s: %s/%s, %s, ETA %s",
|
||||
p.Path, humanize.IBytes(safeUint64(p.BytesRead)),
|
||||
humanize.IBytes(safeUint64(p.TotalBytes)),
|
||||
rate, p.ETA.Round(time.Second))
|
||||
} else {
|
||||
log.Infof("%s: %s/%s, %s",
|
||||
p.Path, humanize.IBytes(safeUint64(p.BytesRead)),
|
||||
humanize.IBytes(safeUint64(p.TotalBytes)), rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// manifestBaseURL returns the URL of the directory containing the
|
||||
// manifest, with a trailing slash.
|
||||
func manifestBaseURL(manifestURL string) (*url.URL, error) {
|
||||
baseURL, err := url.Parse(manifestURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch: invalid manifest URL: %w", err)
|
||||
}
|
||||
|
||||
baseURL.Path = path.Dir(baseURL.Path)
|
||||
if !strings.HasSuffix(baseURL.Path, "/") {
|
||||
baseURL.Path += "/"
|
||||
}
|
||||
|
||||
return baseURL, nil
|
||||
}
|
||||
|
||||
// downloadManifestFiles downloads every file in the manifest, reporting
|
||||
// progress on the progress channel.
|
||||
func downloadManifestFiles(
|
||||
ctx context.Context,
|
||||
baseURL *url.URL,
|
||||
files []*mfer.MFFilePath,
|
||||
progress chan<- DownloadProgress,
|
||||
) error {
|
||||
for _, f := range files {
|
||||
// Sanitize the path to prevent path traversal attacks
|
||||
localPath, err := sanitizePath(f.GetPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path in manifest: %w", err)
|
||||
}
|
||||
|
||||
fileURL := baseURL.String() + encodeFilePath(f.GetPath())
|
||||
log.Infof("fetching %s", f.GetPath())
|
||||
|
||||
err = downloadFile(ctx, fileURL, localPath, f, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download %s: %w", f.GetPath(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
|
||||
log.Debug("fetchManifestOperation()")
|
||||
|
||||
if ctx.Args().Len() == 0 {
|
||||
return fmt.Errorf("URL argument required")
|
||||
return errURLRequired
|
||||
}
|
||||
|
||||
inputURL := ctx.Args().Get(0)
|
||||
|
||||
manifestURL, err := resolveManifestURL(inputURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
@@ -45,14 +170,16 @@ func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
|
||||
log.Infof("fetching manifest from %s", manifestURL)
|
||||
|
||||
// Fetch manifest
|
||||
resp, err := http.Get(manifestURL)
|
||||
resp, err := httpGet(ctx.Context, manifestURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch manifest: HTTP %d", resp.StatusCode)
|
||||
return fmt.Errorf("failed to fetch manifest: %w %d",
|
||||
errHTTPStatus, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse manifest
|
||||
@@ -65,74 +192,43 @@ func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
|
||||
log.Infof("manifest contains %d files", len(files))
|
||||
|
||||
// Compute base URL (directory containing manifest)
|
||||
baseURL, err := url.Parse(manifestURL)
|
||||
baseURL, err := manifestBaseURL(manifestURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch: invalid manifest URL: %w", err)
|
||||
}
|
||||
baseURL.Path = path.Dir(baseURL.Path)
|
||||
if !strings.HasSuffix(baseURL.Path, "/") {
|
||||
baseURL.Path += "/"
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate total bytes to download
|
||||
var totalBytes int64
|
||||
for _, f := range files {
|
||||
totalBytes += f.Size
|
||||
totalBytes += f.GetSize()
|
||||
}
|
||||
|
||||
// Create progress channel
|
||||
progress := make(chan DownloadProgress, 10)
|
||||
|
||||
// Start progress reporter goroutine
|
||||
// Create progress channel and start progress reporter goroutine
|
||||
progress := make(chan DownloadProgress, progressChanBuffer)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for p := range progress {
|
||||
rate := formatBitrate(p.BytesPerSec * 8)
|
||||
if p.ETA > 0 {
|
||||
log.Infof("%s: %s/%s, %s, ETA %s",
|
||||
p.Path, humanize.IBytes(uint64(p.BytesRead)), humanize.IBytes(uint64(p.TotalBytes)),
|
||||
rate, p.ETA.Round(time.Second))
|
||||
} else {
|
||||
log.Infof("%s: %s/%s, %s",
|
||||
p.Path, humanize.IBytes(uint64(p.BytesRead)), humanize.IBytes(uint64(p.TotalBytes)), rate)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go reportDownloadProgress(progress, done)
|
||||
|
||||
// Track download start time
|
||||
startTime := time.Now()
|
||||
|
||||
// Download each file
|
||||
for _, f := range files {
|
||||
// Sanitize the path to prevent path traversal attacks
|
||||
localPath, err := sanitizePath(f.Path)
|
||||
if err != nil {
|
||||
close(progress)
|
||||
<-done
|
||||
return fmt.Errorf("invalid path in manifest: %w", err)
|
||||
}
|
||||
|
||||
fileURL := baseURL.String() + encodeFilePath(f.Path)
|
||||
log.Infof("fetching %s", f.Path)
|
||||
|
||||
if err := downloadFile(fileURL, localPath, f, progress); err != nil {
|
||||
close(progress)
|
||||
<-done
|
||||
return fmt.Errorf("failed to download %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
dlErr := downloadManifestFiles(ctx.Context, baseURL, files, progress)
|
||||
|
||||
close(progress)
|
||||
<-done
|
||||
|
||||
if dlErr != nil {
|
||||
return dlErr
|
||||
}
|
||||
|
||||
// Print summary
|
||||
elapsed := time.Since(startTime)
|
||||
avgBytesPerSec := float64(totalBytes) / elapsed.Seconds()
|
||||
avgRate := formatBitrate(avgBytesPerSec * 8)
|
||||
avgRate := formatBitrate(avgBytesPerSec * bitsPerByte)
|
||||
log.Infof("downloaded %d files (%s) in %.1fs (%s avg)",
|
||||
len(files),
|
||||
humanize.IBytes(uint64(totalBytes)),
|
||||
humanize.IBytes(safeUint64(totalBytes)),
|
||||
elapsed.Seconds(),
|
||||
avgRate)
|
||||
|
||||
@@ -145,6 +241,7 @@ func encodeFilePath(p string) string {
|
||||
for i, seg := range segments {
|
||||
segments[i] = url.PathEscape(seg)
|
||||
}
|
||||
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
@@ -153,12 +250,12 @@ func encodeFilePath(p string) string {
|
||||
func sanitizePath(p string) (string, error) {
|
||||
// Reject empty paths
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
return "", errEmptyPath
|
||||
}
|
||||
|
||||
// Reject absolute paths
|
||||
if filepath.IsAbs(p) {
|
||||
return "", fmt.Errorf("absolute path not allowed: %s", p)
|
||||
return "", fmt.Errorf("%w: %s", errAbsolutePath, p)
|
||||
}
|
||||
|
||||
// Clean the path to resolve . and ..
|
||||
@@ -166,12 +263,12 @@ func sanitizePath(p string) (string, error) {
|
||||
|
||||
// Reject paths that escape the current directory
|
||||
if strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || cleaned == ".." {
|
||||
return "", fmt.Errorf("path traversal not allowed: %s", p)
|
||||
return "", fmt.Errorf("%w: %s", errPathTraversal, p)
|
||||
}
|
||||
|
||||
// Also check for absolute paths after cleaning (handles edge cases)
|
||||
if filepath.IsAbs(cleaned) {
|
||||
return "", fmt.Errorf("absolute path not allowed: %s", p)
|
||||
return "", fmt.Errorf("%w: %s", errAbsolutePath, p)
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
@@ -214,10 +311,14 @@ type progressWriter struct {
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.w.Write(p)
|
||||
|
||||
pw.written += int64(n)
|
||||
if pw.progress != nil {
|
||||
var bytesPerSec float64
|
||||
var eta time.Duration
|
||||
var (
|
||||
bytesPerSec float64
|
||||
eta time.Duration
|
||||
)
|
||||
|
||||
elapsed := time.Since(pw.startTime)
|
||||
if elapsed > 0 && pw.written > 0 {
|
||||
bytesPerSec = float64(pw.written) / elapsed.Seconds()
|
||||
@@ -226,6 +327,7 @@ func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
eta = time.Duration(float64(remainingBytes)/bytesPerSec) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(pw.progress, DownloadProgress{
|
||||
Path: pw.path,
|
||||
BytesRead: pw.written,
|
||||
@@ -234,18 +336,19 @@ func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
ETA: eta,
|
||||
})
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// formatBitrate formats a bits-per-second value with appropriate unit prefix.
|
||||
func formatBitrate(bps float64) string {
|
||||
switch {
|
||||
case bps >= 1e9:
|
||||
return fmt.Sprintf("%.1f Gbps", bps/1e9)
|
||||
case bps >= 1e6:
|
||||
return fmt.Sprintf("%.1f Mbps", bps/1e6)
|
||||
case bps >= 1e3:
|
||||
return fmt.Sprintf("%.1f Kbps", bps/1e3)
|
||||
case bps >= bpsPerGbps:
|
||||
return fmt.Sprintf("%.1f Gbps", bps/bpsPerGbps)
|
||||
case bps >= bpsPerMbps:
|
||||
return fmt.Sprintf("%.1f Mbps", bps/bpsPerMbps)
|
||||
case bps >= bpsPerKbps:
|
||||
return fmt.Sprintf("%.1f Kbps", bps/bpsPerKbps)
|
||||
default:
|
||||
return fmt.Sprintf("%.0f bps", bps)
|
||||
}
|
||||
@@ -259,53 +362,100 @@ func sendProgress(ch chan<- DownloadProgress, p DownloadProgress) {
|
||||
}
|
||||
}
|
||||
|
||||
// downloadFile downloads a URL to a local file path with hash verification.
|
||||
// It downloads to a temporary file, verifies the hash, then renames to the final path.
|
||||
// Progress is reported via the progress channel.
|
||||
func downloadFile(fileURL, localPath string, entry *mfer.MFFilePath, progress chan<- DownloadProgress) error {
|
||||
// Create parent directories if needed
|
||||
// tempPathFor computes the temporary download path for a local file.
|
||||
// For dotfiles, just append .tmp (they're already hidden); for regular
|
||||
// files, prefix with . and append .tmp.
|
||||
func tempPathFor(localPath string) string {
|
||||
dir := filepath.Dir(localPath)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute temp file path in the same directory
|
||||
// For dotfiles, just append .tmp (they're already hidden)
|
||||
// For regular files, prefix with . and append .tmp
|
||||
base := filepath.Base(localPath)
|
||||
|
||||
var tmpName string
|
||||
if strings.HasPrefix(base, ".") {
|
||||
tmpName = base + ".tmp"
|
||||
} else {
|
||||
tmpName = "." + base + ".tmp"
|
||||
}
|
||||
tmpPath := filepath.Join(dir, tmpName)
|
||||
|
||||
if dir == "" || dir == "." {
|
||||
tmpPath = tmpName
|
||||
return tmpName
|
||||
}
|
||||
|
||||
return filepath.Join(dir, tmpName)
|
||||
}
|
||||
|
||||
// verifyDownloadedHash checks the computed sha256 digest against the
|
||||
// manifest entry's hashes; at least one must match.
|
||||
func verifyDownloadedHash(digest []byte, entry *mfer.MFFilePath) error {
|
||||
computed, err := multihash.Encode(digest, multihash.SHA2_256)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode hash: %w", err)
|
||||
}
|
||||
|
||||
for _, hash := range entry.GetHashes() {
|
||||
if bytes.Equal(computed, hash.GetMultiHash()) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errHashMismatch
|
||||
}
|
||||
|
||||
// downloadFile downloads a URL to a local file path with hash verification.
|
||||
// It downloads to a temporary file, verifies the hash, then renames to the final path.
|
||||
// Progress is reported via the progress channel.
|
||||
func downloadFile(
|
||||
ctx context.Context,
|
||||
fileURL, localPath string,
|
||||
entry *mfer.MFFilePath,
|
||||
progress chan<- DownloadProgress,
|
||||
) error {
|
||||
// Enforce the path invariant here rather than relying on the caller,
|
||||
// so every entry point to downloadFile gets the same treatment.
|
||||
localPath, err := sanitizePath(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
dir := filepath.Dir(localPath)
|
||||
if dir != "" && dir != "." {
|
||||
err := os.MkdirAll(dir, dirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
tmpPath := tempPathFor(localPath)
|
||||
|
||||
// Fetch file
|
||||
resp, err := http.Get(fileURL) //nolint:gosec // URL constructed from manifest base
|
||||
resp, err := httpGet(ctx, fileURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
return fmt.Errorf("%w %d", errHTTPStatus, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Determine expected size
|
||||
expectedSize := entry.Size
|
||||
expectedSize := entry.GetSize()
|
||||
|
||||
totalBytes := resp.ContentLength
|
||||
if totalBytes < 0 {
|
||||
totalBytes = expectedSize
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
out, err := os.Create(tmpPath)
|
||||
// Create temp file.
|
||||
//
|
||||
// G304: tmpPath is derived from localPath, which sanitizePath above
|
||||
// constrains lexically to a relative path that does not escape the
|
||||
// destination directory. That is a purely lexical guarantee: it does
|
||||
// not resolve symlinks, so a pre-existing symlink inside the
|
||||
// destination tree can still redirect this write outside of it
|
||||
// (tracked in issue #86).
|
||||
out, err := os.Create(tmpPath) //nolint:gosec // G304: see comment above
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
@@ -328,45 +478,50 @@ func downloadFile(fileURL, localPath string, entry *mfer.MFFilePath, progress ch
|
||||
// Close file before checking errors (to flush writes)
|
||||
closeErr := out.Close()
|
||||
|
||||
// If copy failed, clean up temp file and return error
|
||||
if copyErr != nil {
|
||||
err = finishDownload(
|
||||
tmpPath, localPath, written, expectedSize, h.Sum(nil), entry,
|
||||
copyErr, closeErr)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishDownload validates the copy result, verifies size and hash, and
|
||||
// moves the temp file into place. On error the caller removes tmpPath.
|
||||
func finishDownload(
|
||||
tmpPath, localPath string,
|
||||
written, expectedSize int64,
|
||||
digest []byte,
|
||||
entry *mfer.MFFilePath,
|
||||
copyErr, closeErr error,
|
||||
) error {
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// Verify size
|
||||
if written != expectedSize {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("size mismatch: expected %d bytes, got %d", expectedSize, written)
|
||||
}
|
||||
|
||||
// Encode computed hash as multihash
|
||||
computed, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to encode hash: %w", err)
|
||||
return fmt.Errorf("%w: expected %d bytes, got %d",
|
||||
errSizeMismatch, expectedSize, written)
|
||||
}
|
||||
|
||||
// Verify hash against manifest (at least one must match)
|
||||
hashMatch := false
|
||||
for _, hash := range entry.Hashes {
|
||||
if bytes.Equal(computed, hash.MultiHash) {
|
||||
hashMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hashMatch {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("hash mismatch")
|
||||
err := verifyDownloadedHash(digest, entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename temp file to final path
|
||||
if err := os.Rename(tmpPath, localPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
err = os.Rename(tmpPath, localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests exercise unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -16,13 +17,25 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
const (
|
||||
testFileTxt = "file.txt"
|
||||
testDirFile = "dir/file.txt"
|
||||
testIndexMF = "https://example.com/path/index.mf"
|
||||
|
||||
// Exactly what url.Parse renders, with no wrapper of our own.
|
||||
urlParseControlCharErr = `parse "http://example.com/\x7f": ` +
|
||||
`net/url: invalid control character in URL`
|
||||
)
|
||||
|
||||
func TestEncodeFilePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"file.txt", "file.txt"},
|
||||
{"dir/file.txt", "dir/file.txt"},
|
||||
{testFileTxt, testFileTxt},
|
||||
{testDirFile, testDirFile},
|
||||
{"my file.txt", "my%20file.txt"},
|
||||
{"dir/my file.txt", "dir/my%20file.txt"},
|
||||
{"file#1.txt", "file%231.txt"},
|
||||
@@ -33,6 +46,8 @@ func TestEncodeFilePath(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := encodeFilePath(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
@@ -40,23 +55,27 @@ func TestEncodeFilePath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Valid paths that should be accepted
|
||||
validTests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"file.txt", "file.txt"},
|
||||
{"dir/file.txt", "dir/file.txt"},
|
||||
{testFileTxt, testFileTxt},
|
||||
{testDirFile, testDirFile},
|
||||
{"dir/subdir/file.txt", "dir/subdir/file.txt"},
|
||||
{"./file.txt", "file.txt"},
|
||||
{"./dir/file.txt", "dir/file.txt"},
|
||||
{"dir/./file.txt", "dir/file.txt"},
|
||||
{"./file.txt", testFileTxt},
|
||||
{"./dir/file.txt", testDirFile},
|
||||
{"dir/./file.txt", testDirFile},
|
||||
}
|
||||
|
||||
for _, tt := range validTests {
|
||||
t.Run("valid:"+tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := sanitizePath(tt.input)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
@@ -78,6 +97,8 @@ func TestSanitizePath(t *testing.T) {
|
||||
|
||||
for _, tt := range invalidTests {
|
||||
t.Run("invalid:"+tt.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := sanitizePath(tt.input)
|
||||
assert.Error(t, err, "expected error for path: %s", tt.input)
|
||||
})
|
||||
@@ -85,36 +106,115 @@ func TestSanitizePath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveManifestURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// Already ends with .mf - use as-is
|
||||
{"https://example.com/path/index.mf", "https://example.com/path/index.mf"},
|
||||
{testIndexMF, testIndexMF},
|
||||
{"https://example.com/path/custom.mf", "https://example.com/path/custom.mf"},
|
||||
{"https://example.com/foo.mf", "https://example.com/foo.mf"},
|
||||
|
||||
// Directory with trailing slash - append index.mf
|
||||
{"https://example.com/path/", "https://example.com/path/index.mf"},
|
||||
{"https://example.com/path/", testIndexMF},
|
||||
{"https://example.com/", "https://example.com/index.mf"},
|
||||
|
||||
// Directory without trailing slash - add slash and index.mf
|
||||
{"https://example.com/path", "https://example.com/path/index.mf"},
|
||||
{"https://example.com/path", testIndexMF},
|
||||
{"https://example.com", "https://example.com/index.mf"},
|
||||
|
||||
// With query strings
|
||||
{"https://example.com/path?foo=bar", "https://example.com/path/index.mf?foo=bar"},
|
||||
{
|
||||
"https://example.com/path?foo=bar",
|
||||
"https://example.com/path/index.mf?foo=bar",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := resolveManifestURL(tt.input)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
|
||||
// The sole caller wraps this error as "invalid URL: %w", so
|
||||
// resolveManifestURL must return url.Parse's error unadorned.
|
||||
t.Run("invalid:control character", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := resolveManifestURL("http://example.com/\x7f")
|
||||
require.ErrorContains(t, err, urlParseControlCharErr)
|
||||
assert.NotContains(t, err.Error(), "failed to parse URL")
|
||||
})
|
||||
}
|
||||
|
||||
// scanToManifest scans sourceFs and returns the serialized manifest bytes.
|
||||
func scanToManifest(t *testing.T, sourceFs afero.Fs) []byte {
|
||||
t.Helper()
|
||||
|
||||
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: sourceFs})
|
||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
return manifestBuf.Bytes()
|
||||
}
|
||||
|
||||
// chdirTemp switches the working directory to a fresh temp dir for the
|
||||
// duration of the test and returns its path.
|
||||
func chdirTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
destDir := t.TempDir()
|
||||
|
||||
origDir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.Chdir(destDir))
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
return destDir
|
||||
}
|
||||
|
||||
// fetchTestHandler serves the manifest at /index.mf and the given files
|
||||
// at their paths.
|
||||
func fetchTestHandler(
|
||||
manifestData []byte, testFiles map[string][]byte,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/index.mf" {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(manifestData)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Strip leading slash
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
|
||||
content, exists := testFiles[path]
|
||||
if !exists {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchFromHTTP(t *testing.T) {
|
||||
// Create source filesystem with test files
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
@@ -134,51 +234,14 @@ func TestFetchFromHTTP(t *testing.T) {
|
||||
}
|
||||
|
||||
// Generate manifest using scanner
|
||||
opts := &mfer.ScannerOptions{
|
||||
Fs: sourceFs,
|
||||
}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
manifestData := manifestBuf.Bytes()
|
||||
manifestData := scanToManifest(t, sourceFs)
|
||||
|
||||
// Create HTTP server that serves the source filesystem
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/index.mf" {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(manifestData)
|
||||
return
|
||||
}
|
||||
|
||||
// Strip leading slash
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
|
||||
content, exists := testFiles[path]
|
||||
if !exists {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
server := httptest.NewServer(fetchTestHandler(manifestData, testFiles))
|
||||
defer server.Close()
|
||||
|
||||
// Create destination directory
|
||||
destDir, err := os.MkdirTemp("", "mfer-fetch-test-*")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = os.RemoveAll(destDir) }()
|
||||
|
||||
// Change to dest directory for the test
|
||||
origDir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(destDir))
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
// Change to a fresh destination directory for the test
|
||||
destDir := chdirTemp(t)
|
||||
|
||||
// Parse the manifest to get file entries
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
@@ -189,132 +252,125 @@ func TestFetchFromHTTP(t *testing.T) {
|
||||
|
||||
// Download each file using downloadFile
|
||||
progress := make(chan DownloadProgress, 10)
|
||||
|
||||
go func() {
|
||||
for range progress {
|
||||
// Drain progress channel
|
||||
for p := range progress {
|
||||
_ = p // drain progress channel
|
||||
}
|
||||
}()
|
||||
|
||||
baseURL := server.URL + "/"
|
||||
|
||||
for _, f := range files {
|
||||
localPath, err := sanitizePath(f.Path)
|
||||
localPath, err := sanitizePath(f.GetPath())
|
||||
require.NoError(t, err)
|
||||
|
||||
fileURL := baseURL + f.Path
|
||||
err = downloadFile(fileURL, localPath, f, progress)
|
||||
require.NoError(t, err, "failed to download %s", f.Path)
|
||||
fileURL := baseURL + f.GetPath()
|
||||
err = downloadFile(context.Background(), fileURL, localPath, f, progress)
|
||||
require.NoError(t, err, "failed to download %s", f.GetPath())
|
||||
}
|
||||
|
||||
close(progress)
|
||||
|
||||
// Verify downloaded files match originals
|
||||
for path, expectedContent := range testFiles {
|
||||
downloadedPath := filepath.Join(destDir, path)
|
||||
//nolint:gosec // test-controlled path
|
||||
downloadedContent, err := os.ReadFile(downloadedPath)
|
||||
require.NoError(t, err, "failed to read downloaded file %s", path)
|
||||
assert.Equal(t, expectedContent, downloadedContent, "content mismatch for %s", path)
|
||||
assert.Equal(t, expectedContent, downloadedContent,
|
||||
"content mismatch for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchHashMismatch(t *testing.T) {
|
||||
// Create source filesystem with a test file
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
originalContent := []byte("Original content")
|
||||
require.NoError(t, afero.WriteFile(sourceFs, "/file.txt", originalContent, 0o644))
|
||||
|
||||
// Generate manifest
|
||||
opts := &mfer.ScannerOptions{Fs: sourceFs}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||
// Generate and parse manifest
|
||||
manifestData := scanToManifest(t, sourceFs)
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
// Parse manifest
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
require.NoError(t, err)
|
||||
|
||||
files := manifest.Files()
|
||||
require.Len(t, files, 1)
|
||||
|
||||
// Create server that serves DIFFERENT content (to trigger hash mismatch)
|
||||
tamperedContent := []byte("Tampered content!")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(tamperedContent)
|
||||
}))
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(tamperedContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create temp directory
|
||||
destDir, err := os.MkdirTemp("", "mfer-fetch-hash-test-*")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = os.RemoveAll(destDir) }()
|
||||
|
||||
origDir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(destDir))
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
// Work in a fresh temp directory
|
||||
chdirTemp(t)
|
||||
|
||||
// Try to download - should fail with hash mismatch
|
||||
err = downloadFile(server.URL+"/file.txt", "file.txt", files[0], nil)
|
||||
assert.Error(t, err)
|
||||
err = downloadFile(context.Background(),
|
||||
server.URL+"/file.txt", testFileTxt, files[0], nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mismatch")
|
||||
|
||||
// Verify temp file was cleaned up
|
||||
_, err = os.Stat(".file.txt.tmp")
|
||||
assert.True(t, os.IsNotExist(err), "temp file should be cleaned up on hash mismatch")
|
||||
assert.True(t, os.IsNotExist(err),
|
||||
"temp file should be cleaned up on hash mismatch")
|
||||
|
||||
// Verify final file was not created
|
||||
_, err = os.Stat("file.txt")
|
||||
assert.True(t, os.IsNotExist(err), "final file should not exist on hash mismatch")
|
||||
_, err = os.Stat(testFileTxt)
|
||||
assert.True(t, os.IsNotExist(err),
|
||||
"final file should not exist on hash mismatch")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchSizeMismatch(t *testing.T) {
|
||||
// Create source filesystem with a test file
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
originalContent := []byte("Original content with specific size")
|
||||
require.NoError(t, afero.WriteFile(sourceFs, "/file.txt", originalContent, 0o644))
|
||||
|
||||
// Generate manifest
|
||||
opts := &mfer.ScannerOptions{Fs: sourceFs}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||
// Generate and parse manifest
|
||||
manifestData := scanToManifest(t, sourceFs)
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
// Parse manifest
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
require.NoError(t, err)
|
||||
|
||||
files := manifest.Files()
|
||||
require.Len(t, files, 1)
|
||||
|
||||
// Create server that serves content with wrong size
|
||||
wrongSizeContent := []byte("Short")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(wrongSizeContent)
|
||||
}))
|
||||
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(wrongSizeContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create temp directory
|
||||
destDir, err := os.MkdirTemp("", "mfer-fetch-size-test-*")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = os.RemoveAll(destDir) }()
|
||||
|
||||
origDir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(destDir))
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
// Work in a fresh temp directory
|
||||
chdirTemp(t)
|
||||
|
||||
// Try to download - should fail with size mismatch
|
||||
err = downloadFile(server.URL+"/file.txt", "file.txt", files[0], nil)
|
||||
assert.Error(t, err)
|
||||
err = downloadFile(context.Background(),
|
||||
server.URL+"/file.txt", testFileTxt, files[0], nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "size mismatch")
|
||||
|
||||
// Verify temp file was cleaned up
|
||||
_, err = os.Stat(".file.txt.tmp")
|
||||
assert.True(t, os.IsNotExist(err), "temp file should be cleaned up on size mismatch")
|
||||
assert.True(t, os.IsNotExist(err),
|
||||
"temp file should be cleaned up on size mismatch")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchProgress(t *testing.T) {
|
||||
// Create source filesystem with a larger test file
|
||||
sourceFs := afero.NewMemMapFs()
|
||||
@@ -322,53 +378,47 @@ func TestFetchProgress(t *testing.T) {
|
||||
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
|
||||
require.NoError(t, afero.WriteFile(sourceFs, "/large.txt", content, 0o644))
|
||||
|
||||
// Generate manifest
|
||||
opts := &mfer.ScannerOptions{Fs: sourceFs}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||
// Generate and parse manifest
|
||||
manifestData := scanToManifest(t, sourceFs)
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
// Parse manifest
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
require.NoError(t, err)
|
||||
|
||||
files := manifest.Files()
|
||||
require.Len(t, files, 1)
|
||||
|
||||
// Create server that serves the content
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", "102400")
|
||||
// Write in chunks to allow progress reporting
|
||||
reader := bytes.NewReader(content)
|
||||
_, _ = io.Copy(w, reader)
|
||||
}))
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", "102400")
|
||||
// Write in chunks to allow progress reporting
|
||||
reader := bytes.NewReader(content)
|
||||
_, _ = io.Copy(w, reader)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create temp directory
|
||||
destDir, err := os.MkdirTemp("", "mfer-fetch-progress-test-*")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = os.RemoveAll(destDir) }()
|
||||
|
||||
origDir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(destDir))
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
// Work in a fresh temp directory
|
||||
chdirTemp(t)
|
||||
|
||||
// Set up progress channel and collect updates
|
||||
progress := make(chan DownloadProgress, 100)
|
||||
|
||||
var progressUpdates []DownloadProgress
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for p := range progress {
|
||||
progressUpdates = append(progressUpdates, p)
|
||||
}
|
||||
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Download
|
||||
err = downloadFile(server.URL+"/large.txt", "large.txt", files[0], progress)
|
||||
err = downloadFile(context.Background(),
|
||||
server.URL+"/large.txt", "large.txt", files[0], progress)
|
||||
close(progress)
|
||||
<-done
|
||||
|
||||
@@ -380,7 +430,8 @@ func TestFetchProgress(t *testing.T) {
|
||||
// Verify final progress shows complete
|
||||
if len(progressUpdates) > 0 {
|
||||
last := progressUpdates[len(progressUpdates)-1]
|
||||
assert.Equal(t, int64(len(content)), last.BytesRead, "final progress should show all bytes read")
|
||||
assert.Equal(t, int64(len(content)), last.BytesRead,
|
||||
"final progress should show all bytes read")
|
||||
assert.Equal(t, "large.txt", last.Path)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -16,6 +17,19 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
const (
|
||||
// hashBufSize is the read buffer size used when hashing files.
|
||||
hashBufSize = 64 * 1024
|
||||
|
||||
// scanProgressInterval is how many scanned files pass between
|
||||
// progress updates.
|
||||
scanProgressInterval = 100
|
||||
)
|
||||
|
||||
// errEntryMissingMtime indicates a manifest entry that carries no
|
||||
// modification time where one is required to carry it forward unchanged.
|
||||
var errEntryMissingMtime = errors.New("manifest entry has no mtime")
|
||||
|
||||
// FreshenStatus contains progress information for the freshen operation.
|
||||
type FreshenStatus struct {
|
||||
Phase string // "scan" or "hash"
|
||||
@@ -36,195 +50,292 @@ type freshenEntry struct {
|
||||
existing *mfer.MFFilePath // existing manifest entry if unchanged
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
log.Debug("freshenManifestOperation()")
|
||||
// freshenScanner walks the filesystem and compares it against the
|
||||
// entries of an existing manifest.
|
||||
type freshenScanner struct {
|
||||
fs afero.Fs
|
||||
absBase string
|
||||
manifestBase string
|
||||
includeDotfiles bool
|
||||
followSymlinks bool
|
||||
showProgress bool
|
||||
existingByPath map[string]*mfer.MFFilePath
|
||||
|
||||
basePath := ctx.String("base")
|
||||
showProgress := ctx.Bool("progress")
|
||||
includeDotfiles := ctx.Bool("include-dotfiles")
|
||||
followSymlinks := ctx.Bool("follow-symlinks")
|
||||
entries []*freshenEntry
|
||||
scanCount int64
|
||||
changed int64
|
||||
added int64
|
||||
unchanged int64
|
||||
}
|
||||
|
||||
// Find manifest file
|
||||
var manifestPath string
|
||||
var err error
|
||||
// resolveSymlink resolves a symlink to its target's FileInfo. The
|
||||
// second return value is false when the entry should be skipped.
|
||||
func (s *freshenScanner) resolveSymlink(path string) (fs.FileInfo, bool) {
|
||||
if !s.followSymlinks {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if ctx.Args().Len() > 0 {
|
||||
arg := ctx.Args().Get(0)
|
||||
info, statErr := mfa.Fs.Stat(arg)
|
||||
if statErr == nil && info.IsDir() {
|
||||
manifestPath, err = findManifest(mfa.Fs, arg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("freshen: %w", err)
|
||||
}
|
||||
} else {
|
||||
manifestPath = arg
|
||||
}
|
||||
realPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return nil, false // Skip broken symlinks
|
||||
}
|
||||
|
||||
realInfo, err := s.fs.Stat(realPath)
|
||||
if err != nil || realInfo.IsDir() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return realInfo, true
|
||||
}
|
||||
|
||||
// recordEntry classifies a scanned file as changed, unchanged, or added
|
||||
// relative to the existing manifest.
|
||||
func (s *freshenScanner) recordEntry(relPath string, info fs.FileInfo) {
|
||||
existing, inManifest := s.existingByPath[relPath]
|
||||
if !inManifest {
|
||||
s.added++
|
||||
|
||||
log.Verbosef("A %s", relPath)
|
||||
s.entries = append(s.entries, &freshenEntry{
|
||||
path: relPath,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
needsHash: true,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Check if changed (size or mtime). An entry with no recorded mtime
|
||||
// cannot be compared, so it counts as changed and gets re-hashed;
|
||||
// silently treating the absent mtime as the Unix epoch would classify
|
||||
// every such entry as changed without saying why.
|
||||
existingMtime, haveMtime := entryMtime(existing)
|
||||
if !haveMtime {
|
||||
log.Debugf("%s: manifest entry has no mtime, treating as changed",
|
||||
relPath)
|
||||
}
|
||||
|
||||
if !haveMtime || existing.GetSize() != info.Size() ||
|
||||
!existingMtime.Equal(info.ModTime()) {
|
||||
s.changed++
|
||||
|
||||
log.Verbosef("M %s", relPath)
|
||||
s.entries = append(s.entries, &freshenEntry{
|
||||
path: relPath,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
needsHash: true,
|
||||
})
|
||||
} else {
|
||||
manifestPath, err = findManifest(mfa.Fs, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("freshen: %w", err)
|
||||
}
|
||||
s.unchanged++
|
||||
|
||||
s.entries = append(s.entries, &freshenEntry{
|
||||
path: relPath,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
needsHash: false,
|
||||
existing: existing,
|
||||
})
|
||||
}
|
||||
// Mark as seen
|
||||
delete(s.existingByPath, relPath)
|
||||
}
|
||||
|
||||
// walk is the afero.Walk callback for the scan phase.
|
||||
func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
log.Infof("loading manifest from %s", manifestPath)
|
||||
|
||||
// Load existing manifest
|
||||
manifest, err := mfer.NewManifestFromFile(mfa.Fs, manifestPath)
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(s.absBase, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load manifest: %w", err)
|
||||
return fmt.Errorf(
|
||||
"freshen: failed to compute relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
existingFiles := manifest.Files()
|
||||
log.Infof("manifest contains %d files", len(existingFiles))
|
||||
|
||||
// Build map of existing entries by path
|
||||
existingByPath := make(map[string]*mfer.MFFilePath, len(existingFiles))
|
||||
for _, f := range existingFiles {
|
||||
existingByPath[f.Path] = f
|
||||
// Skip the manifest file itself
|
||||
if relPath == s.manifestBase || relPath == "."+s.manifestBase {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Phase 1: Scan filesystem
|
||||
log.Infof("scanning filesystem...")
|
||||
startScan := time.Now()
|
||||
|
||||
var entries []*freshenEntry
|
||||
var scanCount int64
|
||||
var removed, changed, added, unchanged int64
|
||||
|
||||
absBase, err := filepath.Abs(basePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("freshen: invalid base path: %w", err)
|
||||
}
|
||||
|
||||
err = afero.Walk(mfa.Fs, absBase, func(path string, info fs.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(absBase, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("freshen: failed to compute relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Skip the manifest file itself
|
||||
if relPath == filepath.Base(manifestPath) || relPath == "."+filepath.Base(manifestPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle dotfiles
|
||||
if !includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
// Handle dotfiles
|
||||
if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle symlinks
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
if !followSymlinks {
|
||||
return nil
|
||||
}
|
||||
realPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return nil // Skip broken symlinks
|
||||
}
|
||||
realInfo, err := mfa.Fs.Stat(realPath)
|
||||
if err != nil || realInfo.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info = realInfo
|
||||
}
|
||||
|
||||
scanCount++
|
||||
|
||||
// Check against existing manifest
|
||||
existing, inManifest := existingByPath[relPath]
|
||||
if inManifest {
|
||||
// Check if changed (size or mtime)
|
||||
existingMtime := time.Unix(existing.Mtime.Seconds, int64(existing.Mtime.Nanos))
|
||||
if existing.Size != info.Size() || !existingMtime.Equal(info.ModTime()) {
|
||||
changed++
|
||||
log.Verbosef("M %s", relPath)
|
||||
entries = append(entries, &freshenEntry{
|
||||
path: relPath,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
needsHash: true,
|
||||
})
|
||||
} else {
|
||||
unchanged++
|
||||
entries = append(entries, &freshenEntry{
|
||||
path: relPath,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
needsHash: false,
|
||||
existing: existing,
|
||||
})
|
||||
}
|
||||
// Mark as seen
|
||||
delete(existingByPath, relPath)
|
||||
} else {
|
||||
added++
|
||||
log.Verbosef("A %s", relPath)
|
||||
entries = append(entries, &freshenEntry{
|
||||
path: relPath,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
needsHash: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Report scan progress
|
||||
if showProgress && scanCount%100 == 0 {
|
||||
log.Progressf("Scanning: %d files found", scanCount)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if showProgress {
|
||||
log.ProgressDone()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan filesystem: %w", err)
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remaining entries in existingByPath are removed files
|
||||
removed = int64(len(existingByPath))
|
||||
for path := range existingByPath {
|
||||
log.Verbosef("D %s", path)
|
||||
// Handle symlinks
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
realInfo, keep := s.resolveSymlink(path)
|
||||
if !keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
info = realInfo
|
||||
}
|
||||
|
||||
scanDuration := time.Since(startScan)
|
||||
log.Infof("scan complete in %s: %d unchanged, %d changed, %d added, %d removed",
|
||||
scanDuration.Round(time.Millisecond), unchanged, changed, added, removed)
|
||||
s.scanCount++
|
||||
|
||||
// Calculate total bytes to hash
|
||||
var totalHashBytes int64
|
||||
var filesToHash int64
|
||||
for _, e := range entries {
|
||||
if e.needsHash {
|
||||
totalHashBytes += e.size
|
||||
filesToHash++
|
||||
// Check against existing manifest
|
||||
s.recordEntry(relPath, info)
|
||||
|
||||
// Report scan progress
|
||||
if s.showProgress && s.scanCount%scanProgressInterval == 0 {
|
||||
log.Progressf("Scanning: %d files found", s.scanCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFreshenManifestPath determines the manifest path from the CLI
|
||||
// arguments, searching directories for a manifest where needed.
|
||||
func (mfa *CLIApp) resolveFreshenManifestPath(ctx *cli.Context) (string, error) {
|
||||
if ctx.Args().Len() == 0 {
|
||||
return findManifest(mfa.Fs, ".")
|
||||
}
|
||||
|
||||
arg := ctx.Args().Get(0)
|
||||
|
||||
info, statErr := mfa.Fs.Stat(arg)
|
||||
if statErr == nil && info.IsDir() {
|
||||
return findManifest(mfa.Fs, arg)
|
||||
}
|
||||
|
||||
return arg, nil
|
||||
}
|
||||
|
||||
// freshenHasher hashes changed and added files and feeds all entries to
|
||||
// a manifest builder.
|
||||
type freshenHasher struct {
|
||||
fs afero.Fs
|
||||
absBase string
|
||||
showProgress bool
|
||||
totalHashBytes int64
|
||||
filesToHash int64
|
||||
startHash time.Time
|
||||
builder *mfer.Builder
|
||||
|
||||
hashedFiles int64
|
||||
hashedBytes int64
|
||||
}
|
||||
|
||||
// reportProgress renders hashing progress for the current byte count.
|
||||
func (h *freshenHasher) reportProgress(n int64) {
|
||||
if !h.showProgress {
|
||||
return
|
||||
}
|
||||
|
||||
currentBytes := h.hashedBytes + n
|
||||
elapsed := time.Since(h.startHash)
|
||||
|
||||
var (
|
||||
rate float64
|
||||
eta time.Duration
|
||||
)
|
||||
|
||||
if elapsed > 0 && currentBytes > 0 {
|
||||
rate = float64(currentBytes) / elapsed.Seconds()
|
||||
|
||||
remaining := h.totalHashBytes - currentBytes
|
||||
if rate > 0 {
|
||||
eta = time.Duration(float64(remaining)/rate) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Hash changed and new files
|
||||
if filesToHash > 0 {
|
||||
log.Infof("hashing %d files (%s)...", filesToHash, humanize.IBytes(uint64(totalHashBytes)))
|
||||
if eta > 0 {
|
||||
log.Progressf("Hashing: %d/%d files, %s/s, ETA %s",
|
||||
h.hashedFiles, h.filesToHash, humanize.IBytes(safeRateUint64(rate)),
|
||||
eta.Round(time.Second))
|
||||
} else {
|
||||
log.Progressf("Hashing: %d/%d files, %s/s",
|
||||
h.hashedFiles, h.filesToHash, humanize.IBytes(safeRateUint64(rate)))
|
||||
}
|
||||
}
|
||||
|
||||
// processEntry hashes the entry if needed and adds it to the builder.
|
||||
func (h *freshenHasher) processEntry(e *freshenEntry) error {
|
||||
if !e.needsHash {
|
||||
// Use existing entry
|
||||
err := addExistingToBuilder(h.builder, e.existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
startHash := time.Now()
|
||||
var hashedFiles int64
|
||||
var hashedBytes int64
|
||||
// Need to read and hash the file
|
||||
absPath := filepath.Join(h.absBase, e.path)
|
||||
|
||||
f, err := h.fs.Open(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %s: %w", e.path, err)
|
||||
}
|
||||
|
||||
hash, bytesRead, err := hashFile(f, h.reportProgress)
|
||||
_ = f.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash %s: %w", e.path, err)
|
||||
}
|
||||
|
||||
h.hashedBytes += bytesRead
|
||||
h.hashedFiles++
|
||||
|
||||
// Add to builder with computed hash
|
||||
err = addFileToBuilder(h.builder, e.path, e.size, e.mtime, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFreshenedManifest writes the manifest atomically (write to a
|
||||
// temp file, then rename over the target).
|
||||
func writeFreshenedManifest(
|
||||
afs afero.Fs, builder *mfer.Builder, manifestPath string,
|
||||
) error {
|
||||
tmpPath := manifestPath + ".tmp"
|
||||
|
||||
outFile, err := afs.Create(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
err = builder.Build(outFile)
|
||||
_ = outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
_ = afs.Remove(tmpPath)
|
||||
|
||||
return fmt.Errorf("failed to write manifest: %w", err)
|
||||
}
|
||||
|
||||
// Rename temp to final
|
||||
err = afs.Rename(tmpPath, manifestPath)
|
||||
if err != nil {
|
||||
_ = afs.Remove(tmpPath)
|
||||
|
||||
return fmt.Errorf("failed to rename manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newFreshenBuilder constructs the manifest builder configured from CLI
|
||||
// flags.
|
||||
func newFreshenBuilder(ctx *cli.Context) *mfer.Builder {
|
||||
builder := mfer.NewBuilder()
|
||||
if ctx.Bool("include-timestamps") {
|
||||
builder.SetIncludeTimestamps(true)
|
||||
@@ -238,6 +349,77 @@ func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
log.Infof("signing manifest with GPG key: %s", signKey)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
// freshenScan runs the scan phase against the loaded manifest entries
|
||||
// and returns the populated scanner and the count of removed files.
|
||||
func (mfa *CLIApp) freshenScan(
|
||||
ctx *cli.Context, manifestPath, absBase string,
|
||||
existingByPath map[string]*mfer.MFFilePath,
|
||||
) (*freshenScanner, int64, error) {
|
||||
log.Infof("scanning filesystem...")
|
||||
|
||||
startScan := time.Now()
|
||||
showProgress := ctx.Bool("progress")
|
||||
|
||||
scanner := &freshenScanner{
|
||||
fs: mfa.Fs,
|
||||
absBase: absBase,
|
||||
manifestBase: filepath.Base(manifestPath),
|
||||
includeDotfiles: ctx.Bool("include-dotfiles"),
|
||||
followSymlinks: ctx.Bool("follow-symlinks"),
|
||||
showProgress: showProgress,
|
||||
existingByPath: existingByPath,
|
||||
}
|
||||
|
||||
err := afero.Walk(mfa.Fs, absBase, scanner.walk)
|
||||
|
||||
if showProgress {
|
||||
log.ProgressDone()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan filesystem: %w", err)
|
||||
}
|
||||
|
||||
// Remaining entries in existingByPath are removed files
|
||||
removed := int64(len(existingByPath))
|
||||
for path := range existingByPath {
|
||||
log.Verbosef("D %s", path)
|
||||
}
|
||||
|
||||
scanDuration := time.Since(startScan)
|
||||
log.Infof("scan complete in %s: %d unchanged, %d changed, %d added, %d removed",
|
||||
scanDuration.Round(time.Millisecond), scanner.unchanged, scanner.changed,
|
||||
scanner.added, removed)
|
||||
|
||||
return scanner, removed, nil
|
||||
}
|
||||
|
||||
// hashTotals returns the total byte count and file count of entries
|
||||
// that need hashing.
|
||||
func hashTotals(entries []*freshenEntry) (int64, int64) {
|
||||
var (
|
||||
totalHashBytes int64
|
||||
filesToHash int64
|
||||
)
|
||||
|
||||
for _, e := range entries {
|
||||
if e.needsHash {
|
||||
totalHashBytes += e.size
|
||||
filesToHash++
|
||||
}
|
||||
}
|
||||
|
||||
return totalHashBytes, filesToHash
|
||||
}
|
||||
|
||||
// runFreshenHash processes every entry through the hasher, aborting if
|
||||
// the context is canceled.
|
||||
func runFreshenHash(
|
||||
ctx *cli.Context, hasher *freshenHasher, entries []*freshenEntry,
|
||||
) error {
|
||||
for _, e := range entries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -245,122 +427,154 @@ func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
default:
|
||||
}
|
||||
|
||||
if e.needsHash {
|
||||
// Need to read and hash the file
|
||||
absPath := filepath.Join(absBase, e.path)
|
||||
f, err := mfa.Fs.Open(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %s: %w", e.path, err)
|
||||
}
|
||||
|
||||
hash, bytesRead, err := hashFile(f, e.size, func(n int64) {
|
||||
if showProgress {
|
||||
currentBytes := hashedBytes + n
|
||||
elapsed := time.Since(startHash)
|
||||
var rate float64
|
||||
var eta time.Duration
|
||||
if elapsed > 0 && currentBytes > 0 {
|
||||
rate = float64(currentBytes) / elapsed.Seconds()
|
||||
remaining := totalHashBytes - currentBytes
|
||||
if rate > 0 {
|
||||
eta = time.Duration(float64(remaining)/rate) * time.Second
|
||||
}
|
||||
}
|
||||
if eta > 0 {
|
||||
log.Progressf("Hashing: %d/%d files, %s/s, ETA %s",
|
||||
hashedFiles, filesToHash, humanize.IBytes(uint64(rate)), eta.Round(time.Second))
|
||||
} else {
|
||||
log.Progressf("Hashing: %d/%d files, %s/s",
|
||||
hashedFiles, filesToHash, humanize.IBytes(uint64(rate)))
|
||||
}
|
||||
}
|
||||
})
|
||||
_ = f.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash %s: %w", e.path, err)
|
||||
}
|
||||
|
||||
hashedBytes += bytesRead
|
||||
hashedFiles++
|
||||
|
||||
// Add to builder with computed hash
|
||||
if err := addFileToBuilder(builder, e.path, e.size, e.mtime, hash); err != nil {
|
||||
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||
}
|
||||
} else {
|
||||
// Use existing entry
|
||||
if err := addExistingToBuilder(builder, e.existing); err != nil {
|
||||
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||
}
|
||||
err := hasher.processEntry(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadExistingEntries loads the manifest and indexes its file entries
|
||||
// by path.
|
||||
func (mfa *CLIApp) loadExistingEntries(
|
||||
manifestPath string,
|
||||
) (map[string]*mfer.MFFilePath, error) {
|
||||
log.Infof("loading manifest from %s", manifestPath)
|
||||
|
||||
// Load existing manifest
|
||||
manifest, err := mfer.NewManifestFromFile(mfa.Fs, manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load manifest: %w", err)
|
||||
}
|
||||
|
||||
existingFiles := manifest.Files()
|
||||
log.Infof("manifest contains %d files", len(existingFiles))
|
||||
|
||||
// Build map of existing entries by path
|
||||
existingByPath := make(map[string]*mfer.MFFilePath, len(existingFiles))
|
||||
for _, f := range existingFiles {
|
||||
existingByPath[f.GetPath()] = f
|
||||
}
|
||||
|
||||
return existingByPath, nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||
log.Debug("freshenManifestOperation()")
|
||||
|
||||
basePath := ctx.String("base")
|
||||
showProgress := ctx.Bool("progress")
|
||||
|
||||
// Find manifest file
|
||||
manifestPath, err := mfa.resolveFreshenManifestPath(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("freshen: %w", err)
|
||||
}
|
||||
|
||||
existingByPath, err := mfa.loadExistingEntries(manifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
absBase, err := filepath.Abs(basePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("freshen: invalid base path: %w", err)
|
||||
}
|
||||
|
||||
// Phase 1: Scan filesystem
|
||||
scanner, removed, err := mfa.freshenScan(ctx, manifestPath, absBase,
|
||||
existingByPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate total bytes to hash
|
||||
totalHashBytes, filesToHash := hashTotals(scanner.entries)
|
||||
|
||||
// Phase 2: Hash changed and new files
|
||||
if filesToHash > 0 {
|
||||
log.Infof("hashing %d files (%s)...", filesToHash,
|
||||
humanize.IBytes(safeUint64(totalHashBytes)))
|
||||
}
|
||||
|
||||
hasher := &freshenHasher{
|
||||
fs: mfa.Fs,
|
||||
absBase: absBase,
|
||||
showProgress: showProgress,
|
||||
totalHashBytes: totalHashBytes,
|
||||
filesToHash: filesToHash,
|
||||
startHash: time.Now(),
|
||||
builder: newFreshenBuilder(ctx),
|
||||
}
|
||||
|
||||
err = runFreshenHash(ctx, hasher, scanner.entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if showProgress && filesToHash > 0 {
|
||||
log.ProgressDone()
|
||||
}
|
||||
|
||||
// Print summary
|
||||
log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed",
|
||||
unchanged, changed, added, removed)
|
||||
scanner.unchanged, scanner.changed, scanner.added, removed)
|
||||
|
||||
// Skip writing if nothing changed
|
||||
if changed == 0 && added == 0 && removed == 0 {
|
||||
if scanner.changed == 0 && scanner.added == 0 && removed == 0 {
|
||||
log.Infof("manifest unchanged, skipping write")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write updated manifest atomically (write to temp, then rename)
|
||||
tmpPath := manifestPath + ".tmp"
|
||||
outFile, err := mfa.Fs.Create(tmpPath)
|
||||
err = writeFreshenedManifest(mfa.Fs, hasher.builder, manifestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
err = builder.Build(outFile)
|
||||
_ = outFile.Close()
|
||||
if err != nil {
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to write manifest: %w", err)
|
||||
}
|
||||
|
||||
// Rename temp to final
|
||||
if err := mfa.Fs.Rename(tmpPath, manifestPath); err != nil {
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to rename manifest: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
totalDuration := time.Since(mfa.startupTime)
|
||||
if hashedBytes > 0 {
|
||||
hashDuration := time.Since(startHash)
|
||||
hashRate := float64(hashedBytes) / hashDuration.Seconds()
|
||||
if hasher.hashedBytes > 0 {
|
||||
hashDuration := time.Since(hasher.startHash)
|
||||
hashRate := float64(hasher.hashedBytes) / hashDuration.Seconds()
|
||||
log.Infof("hashed %s in %.1fs (%s/s)",
|
||||
humanize.IBytes(uint64(hashedBytes)), totalDuration.Seconds(), humanize.IBytes(uint64(hashRate)))
|
||||
humanize.IBytes(safeUint64(hasher.hashedBytes)),
|
||||
totalDuration.Seconds(), humanize.IBytes(safeRateUint64(hashRate)))
|
||||
}
|
||||
log.Infof("wrote %d files to %s", len(entries), manifestPath)
|
||||
|
||||
log.Infof("wrote %d files to %s", len(scanner.entries), manifestPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashFile reads a file and computes its SHA256 multihash.
|
||||
// Progress callback is called with bytes read so far.
|
||||
func hashFile(r io.Reader, size int64, progress func(int64)) ([]byte, int64, error) {
|
||||
func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
|
||||
h := sha256.New()
|
||||
buf := make([]byte, 64*1024)
|
||||
buf := make([]byte, hashBufSize)
|
||||
|
||||
var total int64
|
||||
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
h.Write(buf[:n])
|
||||
|
||||
total += int64(n)
|
||||
if progress != nil {
|
||||
progress(total)
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
// Returned unwrapped: the caller renders this as
|
||||
// "failed to hash <path>: <err>" and adding a second layer here
|
||||
// would change that message.
|
||||
if err != nil {
|
||||
return nil, total, err
|
||||
}
|
||||
@@ -375,15 +589,29 @@ func hashFile(r io.Reader, size int64, progress func(int64)) ([]byte, int64, err
|
||||
}
|
||||
|
||||
// addFileToBuilder adds a new file entry to the builder
|
||||
func addFileToBuilder(b *mfer.Builder, path string, size int64, mtime time.Time, hash []byte) error {
|
||||
return b.AddFileWithHash(mfer.RelFilePath(path), mfer.FileSize(size), mfer.ModTime(mtime), hash)
|
||||
func addFileToBuilder(
|
||||
b *mfer.Builder, path string, size int64, mtime time.Time, hash []byte,
|
||||
) error {
|
||||
return b.AddFileWithHash(
|
||||
mfer.RelFilePath(path), mfer.FileSize(size), mfer.ModTime(mtime), hash)
|
||||
}
|
||||
|
||||
// addExistingToBuilder adds an existing manifest entry to the builder
|
||||
// addExistingToBuilder adds an existing manifest entry to the builder.
|
||||
//
|
||||
// Entries reach this path only when recordEntry classified them as
|
||||
// unchanged, which requires a recorded mtime, so an absent mtime here is
|
||||
// an error rather than something to paper over with the Unix epoch.
|
||||
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
||||
mtime := time.Unix(entry.Mtime.Seconds, int64(entry.Mtime.Nanos))
|
||||
if len(entry.Hashes) == 0 {
|
||||
mtime, ok := entryMtime(entry)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", errEntryMissingMtime, entry.GetPath())
|
||||
}
|
||||
|
||||
if len(entry.GetHashes()) == 0 {
|
||||
return nil
|
||||
}
|
||||
return b.AddFileWithHash(mfer.RelFilePath(entry.Path), mfer.FileSize(entry.Size), mfer.ModTime(mtime), entry.Hashes[0].MultiHash)
|
||||
|
||||
return b.AddFileWithHash(mfer.RelFilePath(entry.GetPath()),
|
||||
mfer.FileSize(entry.GetSize()), mfer.ModTime(mtime),
|
||||
entry.GetHashes()[0].GetMultiHash())
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//nolint:testpackage // white-box tests exercise unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -11,24 +14,48 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
func TestFreshenUnchanged(t *testing.T) {
|
||||
// Create filesystem with test files
|
||||
fs := afero.NewMemMapFs()
|
||||
// stubFileInfo is a minimal fs.FileInfo for exercising recordEntry
|
||||
// without touching a filesystem.
|
||||
type stubFileInfo struct {
|
||||
size int64
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||
func (s stubFileInfo) Name() string { return "stub" }
|
||||
func (s stubFileInfo) Size() int64 { return s.size }
|
||||
func (s stubFileInfo) Mode() os.FileMode { return 0 }
|
||||
func (s stubFileInfo) ModTime() time.Time { return s.mtime }
|
||||
func (s stubFileInfo) IsDir() bool { return false }
|
||||
func (s stubFileInfo) Sys() any { return nil }
|
||||
|
||||
// setupFreshenDir populates /testdir with two files, scans it, and
|
||||
// writes the resulting manifest to /testdir/.index.mf.
|
||||
func setupFreshenDir(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
writeTestFile(t, fs, testFile1, "content1")
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "content2")
|
||||
|
||||
// Generate initial manifest
|
||||
opts := &mfer.ScannerOptions{Fs: fs}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
||||
require.NoError(t, s.EnumeratePath(testDir, nil))
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
// Write manifest to filesystem
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||
require.NoError(t,
|
||||
afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||
}
|
||||
|
||||
func TestFreshenUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
setupFreshenDir(t, fs)
|
||||
|
||||
// Parse manifest to verify
|
||||
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||
@@ -37,23 +64,10 @@ func TestFreshenUnchanged(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFreshenWithChanges(t *testing.T) {
|
||||
// Create filesystem with test files
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||
|
||||
// Generate initial manifest
|
||||
opts := &mfer.ScannerOptions{Fs: fs}
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||
|
||||
// Write manifest to filesystem
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||
setupFreshenDir(t, fs)
|
||||
|
||||
// Verify initial manifest has 2 files
|
||||
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||
@@ -61,17 +75,17 @@ func TestFreshenWithChanges(t *testing.T) {
|
||||
assert.Len(t, manifest.Files(), 2)
|
||||
|
||||
// Add a new file
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file3.txt", []byte("content3"), 0o644))
|
||||
writeTestFile(t, fs, "/testdir/file3.txt", "content3")
|
||||
|
||||
// Modify file2 (change content and size)
|
||||
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("modified content2"), 0o644))
|
||||
writeTestFile(t, fs, "/testdir/file2.txt", "modified content2")
|
||||
|
||||
// Remove file1
|
||||
require.NoError(t, fs.Remove("/testdir/file1.txt"))
|
||||
require.NoError(t, fs.Remove(testFile1))
|
||||
|
||||
// Note: The freshen operation would need to be run here
|
||||
// For now, we just verify the test setup is correct
|
||||
exists, _ := afero.Exists(fs, "/testdir/file1.txt")
|
||||
exists, _ := afero.Exists(fs, testFile1)
|
||||
assert.False(t, exists)
|
||||
|
||||
exists, _ = afero.Exists(fs, "/testdir/file3.txt")
|
||||
@@ -80,3 +94,104 @@ func TestFreshenWithChanges(t *testing.T) {
|
||||
content, _ := afero.ReadFile(fs, "/testdir/file2.txt")
|
||||
assert.Equal(t, "modified content2", string(content))
|
||||
}
|
||||
|
||||
// TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry
|
||||
// with respect to MFFilePath.Mtime, which is a message pointer with
|
||||
// proto3 field presence and may legitimately be absent.
|
||||
//
|
||||
// An absent mtime must never be read as time.Unix(0, 0): that value
|
||||
// never equals a real modification time, so every entry would be
|
||||
// classified as changed, re-hashed, and the manifest rewritten
|
||||
// unconditionally - the exact inverse of what freshen is for, and
|
||||
// silent. An entry with no mtime is therefore "changed" because it
|
||||
// cannot be compared, not because it looks like it dates from 1970.
|
||||
func TestFreshenRecordEntryMtimePresence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const relPath = "file1.txt"
|
||||
|
||||
mtime := time.Unix(1_700_000_000, 0)
|
||||
info := stubFileInfo{size: 8, mtime: mtime}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
entry *mfer.MFFilePath
|
||||
needsHash bool
|
||||
changed int64
|
||||
unchanged int64
|
||||
}{
|
||||
{
|
||||
name: "matching mtime and size is unchanged",
|
||||
entry: &mfer.MFFilePath{
|
||||
Path: relPath,
|
||||
Size: 8,
|
||||
Mtime: &mfer.Timestamp{Seconds: mtime.Unix()},
|
||||
},
|
||||
needsHash: false,
|
||||
changed: 0,
|
||||
unchanged: 1,
|
||||
},
|
||||
{
|
||||
name: "absent mtime is changed, not epoch",
|
||||
entry: &mfer.MFFilePath{
|
||||
Path: relPath,
|
||||
Size: 8,
|
||||
Mtime: nil,
|
||||
},
|
||||
needsHash: true,
|
||||
changed: 1,
|
||||
unchanged: 0,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &freshenScanner{
|
||||
existingByPath: map[string]*mfer.MFFilePath{relPath: tc.entry},
|
||||
}
|
||||
s.recordEntry(relPath, info)
|
||||
|
||||
require.Len(t, s.entries, 1)
|
||||
assert.Equal(t, tc.needsHash, s.entries[0].needsHash)
|
||||
assert.Equal(t, tc.changed, s.changed)
|
||||
assert.Equal(t, tc.unchanged, s.unchanged)
|
||||
assert.Zero(t, s.added)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreshenAddExistingRejectsMissingMtime pins that an entry with no
|
||||
// mtime is never carried forward into a rebuilt manifest with a
|
||||
// fabricated epoch timestamp.
|
||||
func TestFreshenAddExistingRejectsMissingMtime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := mfer.NewBuilder()
|
||||
entry := &mfer.MFFilePath{
|
||||
Path: "file1.txt",
|
||||
Size: 8,
|
||||
Mtime: nil,
|
||||
Hashes: []*mfer.MFFileChecksum{
|
||||
{MultiHash: []byte{0x12, 0x20}},
|
||||
},
|
||||
}
|
||||
|
||||
err := addExistingToBuilder(b, entry)
|
||||
require.ErrorIs(t, err, errEntryMissingMtime)
|
||||
assert.Contains(t, err.Error(), "file1.txt")
|
||||
}
|
||||
|
||||
// TestEntryMtime pins the presence semantics the callers depend on.
|
||||
func TestEntryMtime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := entryMtime(&mfer.MFFilePath{Mtime: nil})
|
||||
assert.False(t, ok)
|
||||
assert.True(t, got.IsZero())
|
||||
|
||||
got, ok = entryMtime(&mfer.MFFilePath{
|
||||
Mtime: &mfer.Timestamp{Seconds: 1_700_000_000, Nanos: 500},
|
||||
})
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, time.Unix(1_700_000_000, 500), got)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -16,9 +17,78 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||
log.Debug("generateManifestOperation()")
|
||||
var (
|
||||
// errPathNotExist indicates an input path that does not exist.
|
||||
errPathNotExist = errors.New("path does not exist")
|
||||
// errOutputExists indicates the output file already exists and
|
||||
// --force was not given. It is wrapped mid-sentence so that the
|
||||
// rendered message stays exactly as mfer has always printed it.
|
||||
errOutputExists = errors.New(
|
||||
"already exists (use --force to overwrite)")
|
||||
)
|
||||
|
||||
// reportEnumProgress renders enumeration progress until the channel
|
||||
// closes.
|
||||
func reportEnumProgress(progress <-chan mfer.EnumerateStatus, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
for status := range progress {
|
||||
log.Progressf("Enumerating: %d files, %s",
|
||||
status.FilesFound,
|
||||
humanize.IBytes(safeUint64(int64(status.BytesFound))))
|
||||
}
|
||||
|
||||
log.ProgressDone()
|
||||
}
|
||||
|
||||
// reportScanProgress renders scan progress until the channel closes.
|
||||
func reportScanProgress(progress <-chan mfer.ScanStatus, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
for status := range progress {
|
||||
if status.ETA > 0 {
|
||||
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
|
||||
status.ScannedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
||||
status.ETA.Round(time.Second))
|
||||
} else {
|
||||
log.Progressf("Scanning: %d/%d files, %s/s",
|
||||
status.ScannedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(safeRateUint64(status.BytesPerSec)))
|
||||
}
|
||||
}
|
||||
|
||||
log.ProgressDone()
|
||||
}
|
||||
|
||||
// collectInputPaths validates the input path arguments and returns them
|
||||
// as absolute paths.
|
||||
func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
|
||||
paths := make([]string, 0, args.Len())
|
||||
|
||||
for i := range args.Len() {
|
||||
inputPath := args.Get(i)
|
||||
|
||||
ap, err := filepath.Abs(inputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
|
||||
}
|
||||
// Validate path exists before adding to list
|
||||
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
|
||||
return nil, fmt.Errorf("%w: %s", errPathNotExist, inputPath)
|
||||
}
|
||||
|
||||
log.Debugf("enumerating path: %s", ap)
|
||||
paths = append(paths, ap)
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// buildScannerOptions constructs scanner options from the CLI flags.
|
||||
func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions {
|
||||
opts := &mfer.ScannerOptions{
|
||||
IncludeDotfiles: ctx.Bool("include-dotfiles"),
|
||||
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
||||
@@ -29,6 +99,7 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||
// Set seed for deterministic UUID if provided
|
||||
if seed := ctx.String("seed"); seed != "" {
|
||||
opts.Seed = seed
|
||||
|
||||
log.Infof("using deterministic seed for manifest UUID")
|
||||
}
|
||||
|
||||
@@ -40,136 +111,167 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||
log.Infof("signing manifest with GPG key: %s", signKey)
|
||||
}
|
||||
|
||||
s := mfer.NewScannerWithOptions(opts)
|
||||
|
||||
// Phase 1: Enumeration - collect paths and stat files
|
||||
args := ctx.Args()
|
||||
showProgress := ctx.Bool("progress")
|
||||
|
||||
// Set up enumeration progress reporting
|
||||
var enumProgress chan mfer.EnumerateStatus
|
||||
var enumWg sync.WaitGroup
|
||||
if showProgress {
|
||||
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
||||
enumWg.Add(1)
|
||||
go func() {
|
||||
defer enumWg.Done()
|
||||
for status := range enumProgress {
|
||||
log.Progressf("Enumerating: %d files, %s",
|
||||
status.FilesFound,
|
||||
humanize.IBytes(uint64(status.BytesFound)))
|
||||
}
|
||||
log.ProgressDone()
|
||||
}()
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// enumerateInputs runs the enumeration phase over the argument paths,
|
||||
// or the current directory when no arguments are given.
|
||||
func (mfa *CLIApp) enumerateInputs(
|
||||
s *mfer.Scanner, args cli.Args, enumProgress chan mfer.EnumerateStatus,
|
||||
) error {
|
||||
if args.Len() == 0 {
|
||||
// Default to current directory
|
||||
if err := s.EnumeratePath(".", enumProgress); err != nil {
|
||||
return fmt.Errorf("generate: failed to enumerate current directory: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Collect and validate all paths first
|
||||
paths := make([]string, 0, args.Len())
|
||||
for i := 0; i < args.Len(); i++ {
|
||||
inputPath := args.Get(i)
|
||||
ap, err := filepath.Abs(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
|
||||
}
|
||||
// Validate path exists before adding to list
|
||||
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
|
||||
return fmt.Errorf("path does not exist: %s", inputPath)
|
||||
}
|
||||
log.Debugf("enumerating path: %s", ap)
|
||||
paths = append(paths, ap)
|
||||
}
|
||||
if err := s.EnumeratePaths(enumProgress, paths...); err != nil {
|
||||
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
||||
err := s.EnumeratePath(".", enumProgress)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"generate: failed to enumerate current directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect and validate all paths first
|
||||
paths, err := mfa.collectInputPaths(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.EnumeratePaths(enumProgress, paths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOnSignal installs a handler that removes the temp output file
|
||||
// and exits when the process is interrupted. It returns the signal
|
||||
// channel so the caller can stop and close it when done.
|
||||
func (mfa *CLIApp) cleanupOnSignal(outFile afero.File, tmpPath string) chan os.Signal {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
sig, ok := <-sigChan
|
||||
if !ok || sig == nil {
|
||||
return // Channel closed normally, not a signal
|
||||
}
|
||||
|
||||
_ = outFile.Close()
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
return sigChan
|
||||
}
|
||||
|
||||
// runEnumeratePhase enumerates all input paths with optional progress
|
||||
// reporting and logs the totals.
|
||||
func (mfa *CLIApp) runEnumeratePhase(ctx *cli.Context, s *mfer.Scanner) error {
|
||||
// Set up enumeration progress reporting
|
||||
var (
|
||||
enumProgress chan mfer.EnumerateStatus
|
||||
enumWg sync.WaitGroup
|
||||
)
|
||||
|
||||
if ctx.Bool("progress") {
|
||||
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
||||
|
||||
enumWg.Add(1)
|
||||
|
||||
go reportEnumProgress(enumProgress, &enumWg)
|
||||
}
|
||||
|
||||
err := mfa.enumerateInputs(s, ctx.Args(), enumProgress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enumWg.Wait()
|
||||
|
||||
log.Infof("enumerated %d files, %s total", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())))
|
||||
log.Infof("enumerated %d files, %s total", s.FileCount(),
|
||||
humanize.IBytes(safeUint64(int64(s.TotalBytes()))))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||
log.Debug("generateManifestOperation()")
|
||||
|
||||
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(ctx))
|
||||
|
||||
// Phase 1: Enumeration - collect paths and stat files
|
||||
err := mfa.runEnumeratePhase(ctx, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
showProgress := ctx.Bool("progress")
|
||||
|
||||
// Check if output file exists
|
||||
outputPath := ctx.String("output")
|
||||
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists {
|
||||
if !ctx.Bool("force") {
|
||||
return fmt.Errorf("output file %s already exists (use --force to overwrite)", outputPath)
|
||||
}
|
||||
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !ctx.Bool("force") {
|
||||
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
|
||||
}
|
||||
|
||||
// Create temp file for atomic write
|
||||
tmpPath := outputPath + ".tmp"
|
||||
|
||||
outFile, err := mfa.Fs.Create(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
// Set up signal handler to clean up temp file on Ctrl-C
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig, ok := <-sigChan
|
||||
if !ok || sig == nil {
|
||||
return // Channel closed normally, not a signal
|
||||
}
|
||||
_ = outFile.Close()
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
os.Exit(1)
|
||||
}()
|
||||
sigChan := mfa.cleanupOnSignal(outFile, tmpPath)
|
||||
|
||||
// Clean up temp file on any error or interruption
|
||||
success := false
|
||||
|
||||
defer func() {
|
||||
signal.Stop(sigChan)
|
||||
close(sigChan)
|
||||
|
||||
_ = outFile.Close()
|
||||
|
||||
if !success {
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Phase 2: Scan - read file contents and generate manifest
|
||||
var scanProgress chan mfer.ScanStatus
|
||||
var scanWg sync.WaitGroup
|
||||
var (
|
||||
scanProgress chan mfer.ScanStatus
|
||||
scanWg sync.WaitGroup
|
||||
)
|
||||
|
||||
if showProgress {
|
||||
scanProgress = make(chan mfer.ScanStatus, 1)
|
||||
|
||||
scanWg.Add(1)
|
||||
go func() {
|
||||
defer scanWg.Done()
|
||||
for status := range scanProgress {
|
||||
if status.ETA > 0 {
|
||||
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
|
||||
status.ScannedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||
status.ETA.Round(time.Second))
|
||||
} else {
|
||||
log.Progressf("Scanning: %d/%d files, %s/s",
|
||||
status.ScannedFiles,
|
||||
status.TotalFiles,
|
||||
humanize.IBytes(uint64(status.BytesPerSec)))
|
||||
}
|
||||
}
|
||||
log.ProgressDone()
|
||||
}()
|
||||
|
||||
go reportScanProgress(scanProgress, &scanWg)
|
||||
}
|
||||
|
||||
err = s.ToManifest(ctx.Context, outFile, scanProgress)
|
||||
|
||||
scanWg.Wait()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate manifest: %w", err)
|
||||
}
|
||||
|
||||
// Close file before rename to ensure all data is flushed
|
||||
if err := outFile.Close(); err != nil {
|
||||
err = outFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := mfa.Fs.Rename(tmpPath, outputPath); err != nil {
|
||||
err = mfa.Fs.Rename(tmpPath, outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
@@ -177,7 +279,9 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||
|
||||
elapsed := time.Since(mfa.startupTime).Seconds()
|
||||
rate := float64(s.TotalBytes()) / elapsed
|
||||
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())), outputPath, elapsed, humanize.IBytes(uint64(rate)))
|
||||
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(),
|
||||
humanize.IBytes(safeUint64(int64(s.TotalBytes()))), outputPath, elapsed,
|
||||
humanize.IBytes(safeRateUint64(rate)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func (mfa *CLIApp) listManifestOperation(ctx *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("list: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
manifest, err := mfer.NewManifestFromReader(rc)
|
||||
@@ -42,10 +43,17 @@ func (mfa *CLIApp) listManifestOperation(ctx *cli.Context) error {
|
||||
|
||||
for _, f := range files {
|
||||
if longFormat {
|
||||
mtime := time.Unix(f.Mtime.Seconds, int64(f.Mtime.Nanos))
|
||||
_, _ = fmt.Fprintf(mfa.Stdout, "%d\t%s\t%s%s", f.Size, mtime.Format(time.RFC3339), f.Path, lineEnd)
|
||||
// An entry may legitimately carry no mtime; render that as
|
||||
// mtimeAbsent rather than as the Unix epoch.
|
||||
mtimeStr := mtimeAbsent
|
||||
if mtime, ok := entryMtime(f); ok {
|
||||
mtimeStr = mtime.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(mfa.Stdout, "%d\t%s\t%s%s",
|
||||
f.GetSize(), mtimeStr, f.GetPath(), lineEnd)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(mfa.Stdout, "%s%s", f.Path, lineEnd)
|
||||
_, _ = fmt.Fprintf(mfa.Stdout, "%s%s", f.GetPath(), lineEnd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,6 +12,17 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// manifestFetchTimeout bounds HTTP requests made to fetch a manifest.
|
||||
const manifestFetchTimeout = 30 * time.Second
|
||||
|
||||
// errHTTPStatus indicates an HTTP response with a non-OK status code.
|
||||
//
|
||||
// Its text is the literal "HTTP" prefix of the rendered "HTTP <code>"
|
||||
// message that mfer has always printed, so that wrapping it does not
|
||||
// change any user-visible output. Match it with errors.Is; do not read
|
||||
// its message.
|
||||
var errHTTPStatus = errors.New("HTTP")
|
||||
|
||||
// isHTTPURL returns true if the string starts with http:// or https://.
|
||||
func isHTTPURL(s string) bool {
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
@@ -19,21 +32,35 @@ func isHTTPURL(s string) bool {
|
||||
// The caller must close the returned reader.
|
||||
func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) {
|
||||
if isHTTPURL(pathOrURL) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(pathOrURL) //nolint:gosec // user-provided URL is intentional
|
||||
client := &http.Client{Timeout: manifestFetchTimeout}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, pathOrURL, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("failed to fetch %s: HTTP %d", pathOrURL, resp.StatusCode)
|
||||
|
||||
return nil, fmt.Errorf("failed to fetch %s: %w %d",
|
||||
pathOrURL, errHTTPStatus, resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
f, err := mfa.Fs.Open(pathOrURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -46,11 +73,14 @@ func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) {
|
||||
if isHTTPURL(arg) {
|
||||
return arg, nil
|
||||
}
|
||||
|
||||
info, statErr := mfa.Fs.Stat(arg)
|
||||
if statErr == nil && info.IsDir() {
|
||||
return findManifest(mfa.Fs, arg)
|
||||
}
|
||||
|
||||
return arg, nil
|
||||
}
|
||||
|
||||
return findManifest(mfa.Fs, ".")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,8 +13,24 @@ import (
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
// Command and flag names shared across command definitions and tests.
|
||||
const (
|
||||
cmdGenerate = "generate"
|
||||
cmdCheck = "check"
|
||||
cmdExport = "export"
|
||||
|
||||
flagProgress = "progress"
|
||||
|
||||
manifestArgsUsage = "[manifest file]"
|
||||
)
|
||||
|
||||
// errUnknownCommand indicates an unrecognized command argument.
|
||||
var errUnknownCommand = errors.New("unknown command")
|
||||
|
||||
// CLIApp is the main CLI application container. It holds configuration,
|
||||
// I/O streams, and filesystem abstraction to enable testing and flexibility.
|
||||
//
|
||||
//nolint:revive // established name used throughout the codebase and tests
|
||||
type CLIApp struct {
|
||||
appname string
|
||||
version string
|
||||
@@ -41,29 +58,34 @@ const banner = `
|
||||
\ \:\ \ \:\ \ \::/ \ \:\
|
||||
\__\/ \__\/ \__\/ \__\/`
|
||||
|
||||
func (mfa *CLIApp) printBanner() {
|
||||
if log.GetLevel() <= log.InfoLevel {
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
||||
_, _ = fmt.Fprintf(mfa.Stdout, " mfer by @sneak: v%s released %s\n", mfer.Version, mfer.ReleaseDate)
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
||||
}
|
||||
}
|
||||
|
||||
// VersionString returns the version and git revision formatted for display.
|
||||
func (mfa *CLIApp) VersionString() string {
|
||||
if mfa.gitrev != "" {
|
||||
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
||||
}
|
||||
|
||||
return mfer.Version
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) printBanner() {
|
||||
if log.GetLevel() <= log.InfoLevel {
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
||||
_, _ = fmt.Fprintf(mfa.Stdout,
|
||||
" mfer by @sneak: v%s released %s\n",
|
||||
mfer.Version, mfer.ReleaseDate)
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) setVerbosity(c *cli.Context) {
|
||||
_, present := os.LookupEnv("MFER_DEBUG")
|
||||
if present {
|
||||
|
||||
switch {
|
||||
case present:
|
||||
log.EnableDebugLogging()
|
||||
} else if c.Bool("quiet") {
|
||||
case c.Bool("quiet"):
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
} else {
|
||||
default:
|
||||
log.SetLevelFromVerbosity(c.Count("verbose"))
|
||||
}
|
||||
}
|
||||
@@ -85,10 +107,215 @@ func commonFlags() []cli.Flag {
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) generateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdGenerate,
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate manifest file",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
|
||||
return mfa.generateManifestOperation(c)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.BoolFlag{
|
||||
Name: "follow-symlinks",
|
||||
Aliases: []string{"L"},
|
||||
Usage: "Resolve encountered symlinks",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-dotfiles",
|
||||
Aliases: []string{"IncludeDotfiles"},
|
||||
|
||||
Usage: "Include dot (hidden) files (excluded by default)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Value: "./.index.mf",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Specify output filename",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Overwrite output file if it exists",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: flagProgress,
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Show progress during enumeration and scanning",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sign-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "GPG key ID to sign the manifest with",
|
||||
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "seed",
|
||||
Usage: "Seed value for deterministic manifest UUID",
|
||||
EnvVars: []string{"MFER_SEED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-timestamps",
|
||||
Usage: "Include createdAt timestamp in manifest " +
|
||||
"(omitted by default for determinism)",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) checkCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdCheck,
|
||||
Usage: "Validate files using manifest file",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
|
||||
return mfa.checkManifestOperation(c)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.StringFlag{
|
||||
Name: "base",
|
||||
Aliases: []string{"b"},
|
||||
Value: ".",
|
||||
Usage: "Base directory for resolving relative paths from manifest",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: flagProgress,
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Show progress during checking",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-extra-files",
|
||||
Usage: "Fail if files exist in base directory that are not in manifest",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "require-signature",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "Require manifest to be signed by the specified GPG key ID",
|
||||
EnvVars: []string{"MFER_REQUIRE_SIGNATURE"},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) freshenCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "freshen",
|
||||
Usage: "Update manifest with changed, new, and removed files",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
|
||||
return mfa.freshenManifestOperation(c)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.StringFlag{
|
||||
Name: "base",
|
||||
Aliases: []string{"b"},
|
||||
Value: ".",
|
||||
Usage: "Base directory for resolving relative paths",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "follow-symlinks",
|
||||
Aliases: []string{"L"},
|
||||
Usage: "Resolve encountered symlinks",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-dotfiles",
|
||||
Aliases: []string{"IncludeDotfiles"},
|
||||
|
||||
Usage: "Include dot (hidden) files (excluded by default)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: flagProgress,
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Show progress during scanning and hashing",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sign-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "GPG key ID to sign the manifest with",
|
||||
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-timestamps",
|
||||
Usage: "Include createdAt timestamp in manifest " +
|
||||
"(omitted by default for determinism)",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) exportCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: cmdExport,
|
||||
Usage: "Export manifest contents as JSON",
|
||||
ArgsUsage: "[manifest file or URL]",
|
||||
Action: func(c *cli.Context) error {
|
||||
return mfa.exportManifestOperation(c)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) versionCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "version",
|
||||
Usage: "Show version",
|
||||
Action: func(_ *cli.Context) error {
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, mfa.VersionString())
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) listCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List files in manifest",
|
||||
ArgsUsage: manifestArgsUsage,
|
||||
Action: func(c *cli.Context) error {
|
||||
return mfa.listManifestOperation(c)
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "long",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Show size and mtime",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "print0",
|
||||
Usage: "Separate entries with NUL character (for xargs -0)",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) fetchCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "fetch",
|
||||
Usage: "fetch manifest and referenced files",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
|
||||
return mfa.fetchManifestOperation(c)
|
||||
},
|
||||
Flags: commonFlags(),
|
||||
}
|
||||
}
|
||||
|
||||
func (mfa *CLIApp) run(args []string) {
|
||||
mfa.startupTime = time.Now()
|
||||
|
||||
if NO_COLOR {
|
||||
if NoColor {
|
||||
// shoutout to rob pike who thinks it's juvenile
|
||||
log.DisableStyling()
|
||||
}
|
||||
@@ -106,196 +333,30 @@ func (mfa *CLIApp) run(args []string) {
|
||||
ErrWriter: mfa.Stderr,
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.Args().Len() > 0 {
|
||||
return fmt.Errorf("unknown command %q", c.Args().First())
|
||||
return fmt.Errorf("%w %q", errUnknownCommand, c.Args().First())
|
||||
}
|
||||
|
||||
mfa.printBanner()
|
||||
|
||||
return cli.ShowAppHelp(c)
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "generate",
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate manifest file",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
return mfa.generateManifestOperation(c)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.BoolFlag{
|
||||
Name: "follow-symlinks",
|
||||
Aliases: []string{"L"},
|
||||
Usage: "Resolve encountered symlinks",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-dotfiles",
|
||||
Aliases: []string{"IncludeDotfiles"},
|
||||
|
||||
Usage: "Include dot (hidden) files (excluded by default)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Value: "./.index.mf",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Specify output filename",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "force",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Overwrite output file if it exists",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "progress",
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Show progress during enumeration and scanning",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sign-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "GPG key ID to sign the manifest with",
|
||||
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "seed",
|
||||
Usage: "Seed value for deterministic manifest UUID",
|
||||
EnvVars: []string{"MFER_SEED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-timestamps",
|
||||
Usage: "Include createdAt timestamp in manifest (omitted by default for determinism)",
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "check",
|
||||
Usage: "Validate files using manifest file",
|
||||
ArgsUsage: "[manifest file]",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
return mfa.checkManifestOperation(c)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.StringFlag{
|
||||
Name: "base",
|
||||
Aliases: []string{"b"},
|
||||
Value: ".",
|
||||
Usage: "Base directory for resolving relative paths from manifest",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "progress",
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Show progress during checking",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-extra-files",
|
||||
Usage: "Fail if files exist in base directory that are not in manifest",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "require-signature",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "Require manifest to be signed by the specified GPG key ID",
|
||||
EnvVars: []string{"MFER_REQUIRE_SIGNATURE"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "freshen",
|
||||
Usage: "Update manifest with changed, new, and removed files",
|
||||
ArgsUsage: "[manifest file]",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
return mfa.freshenManifestOperation(c)
|
||||
},
|
||||
Flags: append(commonFlags(),
|
||||
&cli.StringFlag{
|
||||
Name: "base",
|
||||
Aliases: []string{"b"},
|
||||
Value: ".",
|
||||
Usage: "Base directory for resolving relative paths",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "follow-symlinks",
|
||||
Aliases: []string{"L"},
|
||||
Usage: "Resolve encountered symlinks",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-dotfiles",
|
||||
Aliases: []string{"IncludeDotfiles"},
|
||||
|
||||
Usage: "Include dot (hidden) files (excluded by default)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "progress",
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Show progress during scanning and hashing",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sign-key",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "GPG key ID to sign the manifest with",
|
||||
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include-timestamps",
|
||||
Usage: "Include createdAt timestamp in manifest (omitted by default for determinism)",
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "export",
|
||||
Usage: "Export manifest contents as JSON",
|
||||
ArgsUsage: "[manifest file or URL]",
|
||||
Action: func(c *cli.Context) error {
|
||||
return mfa.exportManifestOperation(c)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "version",
|
||||
Usage: "Show version",
|
||||
Action: func(c *cli.Context) error {
|
||||
_, _ = fmt.Fprintln(mfa.Stdout, mfa.VersionString())
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List files in manifest",
|
||||
ArgsUsage: "[manifest file]",
|
||||
Action: func(c *cli.Context) error {
|
||||
return mfa.listManifestOperation(c)
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "long",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Show size and mtime",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "print0",
|
||||
Usage: "Separate entries with NUL character (for xargs -0)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "fetch",
|
||||
Usage: "fetch manifest and referenced files",
|
||||
Action: func(c *cli.Context) error {
|
||||
mfa.setVerbosity(c)
|
||||
mfa.printBanner()
|
||||
return mfa.fetchManifestOperation(c)
|
||||
},
|
||||
Flags: commonFlags(),
|
||||
},
|
||||
mfa.generateCommand(),
|
||||
mfa.checkCommand(),
|
||||
mfa.freshenCommand(),
|
||||
mfa.exportCommand(),
|
||||
mfa.versionCommand(),
|
||||
mfa.listCommand(),
|
||||
mfa.fetchCommand(),
|
||||
},
|
||||
}
|
||||
|
||||
mfa.app.HideVersion = false
|
||||
|
||||
err := mfa.app.Run(args)
|
||||
if err != nil {
|
||||
mfa.exitCode = 1
|
||||
|
||||
log.WithError(err).Debugf("exiting")
|
||||
}
|
||||
}
|
||||
|
||||
28
internal/cli/mtime.go
Normal file
28
internal/cli/mtime.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
// mtimeAbsent is printed in place of a modification time when a manifest
|
||||
// entry does not carry one.
|
||||
const mtimeAbsent = "-"
|
||||
|
||||
// entryMtime returns the modification time recorded for a manifest entry.
|
||||
//
|
||||
// MFFilePath.Mtime is a message pointer with proto3 field presence, so an
|
||||
// absent mtime is a representable, on-the-wire-valid state. It must never
|
||||
// be conflated with a recorded mtime of the Unix epoch: callers that
|
||||
// compare mtimes have to treat "absent" as "unknown", not as
|
||||
// 1970-01-01T00:00:00Z, or every entry compares as modified. ok reports
|
||||
// whether an mtime was actually recorded.
|
||||
func entryMtime(entry *mfer.MFFilePath) (time.Time, bool) {
|
||||
ts := entry.GetMtime()
|
||||
if ts == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return time.Unix(ts.GetSeconds(), int64(ts.GetNanos())), true
|
||||
}
|
||||
Reference in New Issue
Block a user