Author SHA1 Message Date
sneak 519f433534 Make error message wording consistent (closes #165)
check / check (push) Waiting to run
Error messages in mfer/ and internal/cli/ are lowercase except names and
acronyms, carry no "failed to" or command-name prefix, and each wrap names
only the operation and thing the wrapped error does not already name, so a
stacked message names what failed once. Wraps around errors that already
name their operation and path (os and afero path errors, url.Error, the gpg
helpers' own errors) are dropped. runGPG appends gpg's stderr only when gpg
wrote some, so no message ends in a colon. errHTTPStatus reads "unexpected
HTTP status"; both inner-not-set sentinels read "inner message not set".
No sentinel, errors.Is result or exit status changes. Message tests pin the
new text through the real call sites.

Model: opus-5-5
2026-10-07 13:51:41 +00:00
13 changed files with 78 additions and 580 deletions
+2 -23
View File
@@ -9,9 +9,8 @@ downloading, streaming, and mirroring. It was first published in 2022. The
manifest files' data is serialized with Google's
[protobuf serialization format](https://developers.google.com/protocol-buffers).
The structure of these files can be found
[in the format specification](docs/FORMAT.md), which refers to the protobuf
schema `mfer/mf.proto` for exact field numbers and types. Both are included in
the [project repository](https://git.eeqj.de/sneak/mfer).
[in the format specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
which is included in the [project repository](https://git.eeqj.de/sneak/mfer).
The current version is pre-1.0 and while the repo was published in 2022, there
has not yet been any versioned release. [SemVer](https://semver.org) will be
@@ -269,33 +268,13 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
- recurses under current directory and writes out an `index.mf`
- records every file's mode as `0000` unless given `--include-permissions`,
which records each file's permission bits (`0777` at most)
- `mfer gen /media/drive`
- writes `/media/drive/index.mf`, listing each file by its path under
`/media/drive`, so `mfer check /media/drive` verifies it. Given a file,
gen writes `index.mf` beside it and lists the file by its name; given
several paths, it writes `index.mf` in the current directory
- `--output` names another file to write instead. What gen lists depends
only on the paths it is given and the files under them, so with the same
`--seed` and an unchanged tree it writes the same bytes wherever the
manifest goes. The file it writes to is never listed
- `mfer check` / `mfer check .`
- verifies checksums of all files in manifest, displaying error and exiting
nonzero if any files are missing or corrupted, or have permission bits
other than the mode the manifest records, unless that is `0000`
- looks for those files under the base directory: the one `--base` names, or
else the directory holding the manifest, or the current directory for a
manifest given by URL. So `mfer check /media/drive` checks a drive against
the `index.mf` at its root, from any directory
- warns about each file under the base directory that the manifest does not
list, hidden files included; with `--no-extra-files` each one is a failure
instead
- `mfer freshen` / `mfer freshen .`
- rewrites `index.mf` to list the files now under the directory holding it,
or under the one `--base` names, hashing only the files that are new or
changed
- leaves out hidden files unless given `--include-dotfiles`, and symlinks
unless given `--follow-symlinks`, which lists each symlink to a file under
its own name with the contents of the file it points to
- `mfer fetch https://example.com/stuff/`
- fetches `/stuff/index.mf` and downloads all files listed in manifest into
the current directory, or the one given with `--dest`, and assures
+1 -4
View File
@@ -299,10 +299,6 @@ func (mfa *CLIApp) checkManifestOperation(
return err
}
// Done before a URL is swapped for the temp file it is downloaded to,
// whose directory is not the base.
basePath := resolveBasePath(cmd, manifestPath)
// URL manifests need to be downloaded to a temp file for the checker
if isHTTPURL(manifestPath) {
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
@@ -315,6 +311,7 @@ func (mfa *CLIApp) checkManifestOperation(
manifestPath = tmpPath
}
basePath := cmd.String("base")
showProgress := cmd.Bool("progress")
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
-259
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"math/rand"
"net/http/httptest"
"os"
"path/filepath"
"slices"
@@ -980,90 +979,6 @@ func TestCheckNeverReportsManifest(t *testing.T) {
}
}
// The directory setupManifestInSubdir makes and the manifest it writes there,
// relative to the working directory it sets.
const (
testSubdir = "sub"
testSubdirManifest = testSubdir + "/" + defaultManifestName
)
// setupManifestInSubdir makes a temp dir holding file.txt and sub/b.txt,
// where sub/index.mf is the manifest gen writes for sub, and makes it the
// working directory, so a test calling it cannot run in parallel. It returns
// the temp dir.
func setupManifestInSubdir(t *testing.T) string {
t.Helper()
root := t.TempDir()
sub := filepath.Join(root, testSubdir)
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(sub, 0o750))
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "not in the manifest")
writeTestFile(t, fs, filepath.Join(sub, "b.txt"), "in the manifest")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", filepath.Join(sub, defaultManifestName), sub,
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
t.Chdir(root)
return root
}
// TestCheckResolvesEntriesAgainstManifestDirectory runs check from the
// directory above sub, on the manifest in sub. Without --base, the
// manifest's entries are looked for in sub, whether check is given the
// manifest or sub, and the files above sub are not reported. --base names
// the directory to look in instead, the current one included.
//
//nolint:paralleltest // changes the process-global working directory
func TestCheckResolvesEntriesAgainstManifestDirectory(t *testing.T) {
root := setupManifestInSubdir(t)
for _, tc := range []struct {
args []string
exitCode int
failure string // a line check must print, if any
}{
{[]string{testSubdir}, 0, ""},
{[]string{testSubdirManifest}, 0, ""},
{[]string{filepath.Join(root, testSubdir)}, 0, ""},
{[]string{testFlagBase, testSubdir, testSubdirManifest}, 0, ""},
{[]string{testFlagBase, ".", testSubdirManifest}, 1, "MISSING: b.txt"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
opts := testOpts(slices.Concat(
[]string{testApp, cmdCheck, testFlagNoExtra}, tc.args,
), afero.NewOsFs())
assert.Equal(t, tc.exitCode, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStderr(t, opts), tc.failure)
})
}
}
// TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory runs check on
// a manifest given by URL, from a directory holding the file it lists: the
// file is looked for there.
//
//nolint:paralleltest // changes the process-global working directory
func TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory(t *testing.T) {
files := map[string][]byte{testFileTxt: []byte("hello")}
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
defer server.Close()
cwd := chdirTemp(t)
require.NoError(t,
os.WriteFile(filepath.Join(cwd, testFileTxt), files[testFileTxt], 0o600))
opts := testOpts([]string{
testApp, cmdCheck, testFlagNoExtra, server.URL + "/" + defaultManifestName,
}, afero.NewOsFs())
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
}
// unlistableDirFs is a filesystem on which one directory cannot be listed.
type unlistableDirFs struct {
afero.Fs
@@ -1289,180 +1204,6 @@ func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output))
}
// writeTestTree writes file.txt and sub/nested.txt under dir.
func writeTestTree(t *testing.T, fs afero.Fs, dir string) {
t.Helper()
require.NoError(t, fs.MkdirAll(filepath.Join(dir, testSubdir), 0o750))
writeTestFile(t, fs, filepath.Join(dir, testFileTxt), "hello")
writeTestFile(t, fs, filepath.Join(dir, testSubdir, "nested.txt"), "in sub")
}
// TestGenerateDefaultOutput runs gen without --output on one directory or
// one file: it writes index.mf in that directory, or beside that file,
// listing each file by its path under the directory index.mf is in, and
// check given that directory passes.
func TestGenerateDefaultOutput(t *testing.T) {
t.Parallel()
// Paths are relative to a temp dir holding file.txt and sub/nested.txt.
for name, tc := range map[string]struct {
input, output string
listed []string
}{
"directory": {
".", defaultManifestName, []string{testFileTxt, "sub/nested.txt"},
},
"subdirectory": {testSubdir, testSubdirManifest, []string{"nested.txt"}},
"file": {testFileTxt, defaultManifestName, []string{testFileTxt}},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
fs := afero.NewOsFs()
writeTestTree(t, fs, root)
opts := testOpts([]string{
testApp, cmdGenerate, "-q", filepath.Join(root, tc.input),
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
output := filepath.Join(root, tc.output)
assert.ElementsMatch(t, tc.listed, manifestPaths(t, fs, output))
opts = testOpts([]string{
testApp, cmdCheck, "-q", filepath.Dir(output),
}, fs)
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
})
}
}
// TestGenerateSeveralPathsDefaultOutput runs gen without --output on two
// directories: it writes index.mf in the current directory, listing both
// directories' files, and writes no index.mf in either directory.
//
//nolint:paralleltest // changes the process-global working directory
func TestGenerateSeveralPathsDefaultOutput(t *testing.T) {
root := t.TempDir()
fs := afero.NewOsFs()
dirs := []string{"first", "second"}
for _, dir := range dirs {
require.NoError(t, fs.MkdirAll(filepath.Join(root, dir), 0o750))
writeTestFile(t, fs, filepath.Join(root, dir, dir+".txt"), dir)
}
t.Chdir(root)
opts := testOpts(append([]string{testApp, cmdGenerate, "-q"}, dirs...), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, []string{"first.txt", "second.txt"},
manifestPaths(t, fs, filepath.Join(root, defaultManifestName)))
for _, dir := range dirs {
exists, err := afero.Exists(fs, filepath.Join(root, dir, defaultManifestName))
require.NoError(t, err)
assert.False(t, exists, "index.mf written in %s", dir)
}
}
// TestGenerateBytesDoNotDependOnOutput runs gen --seed on one tree, each
// time writing to another file, over a file already there and beside an
// earlier run's temp file: neither is listed, and what is listed depends
// only on the tree, so every manifest has the same bytes.
func TestGenerateBytesDoNotDependOnOutput(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
defaultOutput := filepath.Join(tree, defaultManifestName)
fs := afero.NewOsFs()
writeTestTree(t, fs, tree)
var first []byte
for _, output := range []string{
defaultOutput,
filepath.Join(tree, "listing.mf"),
filepath.Join(tree, testSubdir, "listing.mf"),
filepath.Join(root, "outside.mf"),
} {
writeTestFile(t, fs, output, "previous manifest")
writeTestFile(t, fs, manifestTempPath(output), "part of a manifest")
args := []string{testApp, cmdGenerate, "-q", "-f", "--seed", "mfer"}
if output != defaultOutput {
args = append(args, "-o", output)
}
args = append(args, tree)
opts := testOpts(args, fs)
require.Equal(t, 0, runCLI(opts),
"output %s, stderr: %s", output, testStderr(t, opts))
got, err := afero.ReadFile(fs, output)
require.NoError(t, err)
require.NoError(t, fs.Remove(output))
if first == nil {
first = got
}
assert.Equal(t, first, got, "output %s", output)
}
}
// TestGenerateRefusesExistingDefaultOutput runs gen without --output or
// --force on a directory already holding index.mf: gen fails, naming that
// file, and leaves it as it was.
func TestGenerateRefusesExistingDefaultOutput(t *testing.T) {
t.Parallel()
output := filepath.Join(testDir, defaultManifestName)
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
writeTestFile(t, fs, output, "previous manifest")
opts := testOpts([]string{testApp, cmdGenerate, "-q", testDir}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts),
"output file "+output+" already exists (use --force to overwrite)")
content, err := afero.ReadFile(fs, output)
require.NoError(t, err)
assert.Equal(t, "previous manifest", string(content))
}
// TestGenerateRefusesEmptyOutput runs gen with --force and an --output
// given an empty value, as an unset shell variable gives it, on a
// directory already holding index.mf: gen fails and leaves that file as it
// was.
func TestGenerateRefusesEmptyOutput(t *testing.T) {
t.Parallel()
output := filepath.Join(testDir, defaultManifestName)
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
writeTestFile(t, fs, output, "previous manifest")
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-f", "-o", "", testDir}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), errEmptyOutput.Error())
content, err := afero.ReadFile(fs, output)
require.NoError(t, err)
assert.Equal(t, "previous manifest", string(content))
}
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
t.Parallel()
-42
View File
@@ -405,48 +405,6 @@ func TestFetchHashMismatchMessage(t *testing.T) {
assert.EqualError(t, err, "download "+testFileTxt+": hash mismatch")
}
// TestFreshenBackslashPathMessage runs freshen on a tree that has gained a
// file whose name holds a backslash, which a manifest path may not contain.
func TestFreshenBackslashPathMessage(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
root, manifestPath := setupFreshenDir(t, fs,
map[string]string{testFileTxt: "content"})
writeTestFile(t, fs, filepath.Join(root, `a\b.txt`), "new")
mfa := &CLIApp{Fs: fs}
cmd := mfa.freshenCommand()
cmd.Action = mfa.freshenManifestOperation
// freshenManifestOperation logs to the process-global logger.
err := runLocked(func() error {
return cmd.Run(context.Background(),
[]string{cmdFreshen, testFlagBase, root, manifestPath})
})
assert.EqualError(t, err,
`path "a\\b.txt" contains backslash; use forward slashes only`)
}
// TestFreshenReadErrorMessage has freshen hash a directory as though it
// were a file, so reading it fails.
func TestFreshenReadErrorMessage(t *testing.T) {
t.Parallel()
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, "sub"), 0o750))
hasher := &freshenHasher{
fs: afero.NewOsFs(),
absBase: root,
builder: mfer.NewBuilder(),
}
err := hasher.processEntry(&freshenEntry{path: "sub", needsHash: true})
assert.EqualError(t, err,
"read "+filepath.Join(root, "sub")+": is a directory")
}
func TestURLRequiredMessage(t *testing.T) {
t.Parallel()
+11 -5
View File
@@ -295,14 +295,19 @@ func (h *freshenHasher) processEntry(e *freshenEntry) error {
_ = f.Close()
if err != nil {
return err
return fmt.Errorf("hash %s: %w", e.path, err)
}
h.hashedBytes += bytesRead
h.hashedFiles++
// Add to builder with computed hash; a refused path is named in the error
return addFileToBuilder(h.builder, e.path, e.size, e.mtime, e.mode, hash)
// Add to builder with computed hash
err = addFileToBuilder(h.builder, e.path, e.size, e.mtime, e.mode, hash)
if err != nil {
return fmt.Errorf("add %s: %w", e.path, err)
}
return nil
}
// writeFreshenedManifest writes the manifest atomically (write to a
@@ -486,6 +491,7 @@ func (mfa *CLIApp) freshenManifestOperation(
) error {
log.Debug("freshenManifestOperation()")
basePath := cmd.String("base")
showProgress := cmd.Bool("progress")
// Find manifest file
@@ -500,7 +506,7 @@ func (mfa *CLIApp) freshenManifestOperation(
return err
}
absBase, err := filepath.Abs(resolveBasePath(cmd, manifestPath))
absBase, err := filepath.Abs(basePath)
if err != nil {
return fmt.Errorf("invalid base path: %w", err)
}
@@ -594,7 +600,7 @@ func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
break
}
// Returned unwrapped: a read error already names the file.
// Returned unwrapped: the caller adds the path.
if err != nil {
return nil, total, err
}
-36
View File
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -301,41 +300,6 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
manifestPaths(t, fs, manifestPath))
}
// TestFreshenResolvesEntriesAgainstManifestDirectory adds sub/c.txt, then
// runs freshen from the directory above sub, on the manifest in sub.
// Without --base, the manifest then lists the files in sub, whether freshen
// is given the manifest or sub. --base names the directory to list instead,
// the current one included.
//
//nolint:paralleltest // changes the process-global working directory
func TestFreshenResolvesEntriesAgainstManifestDirectory(t *testing.T) {
for _, tc := range []struct {
args []string
want []string // the paths the manifest lists afterwards
}{
{[]string{testSubdir}, []string{"b.txt", "c.txt"}},
{[]string{testSubdirManifest}, []string{"b.txt", "c.txt"}},
{
[]string{testFlagBase, ".", testSubdirManifest},
[]string{testFileTxt, "sub/b.txt", "sub/c.txt"},
},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
fs := afero.NewOsFs()
root := setupManifestInSubdir(t)
writeTestFile(t, fs, filepath.Join(root, testSubdir, "c.txt"), "added")
opts := testOpts(slices.Concat(
[]string{testApp, cmdFreshen, "-q"}, tc.args,
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, tc.want, manifestPaths(t, fs,
filepath.Join(root, testSubdirManifest)))
})
}
}
// 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.
+26 -75
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
@@ -27,8 +26,6 @@ var (
// rendered message stays exactly as mfer has always printed it.
errOutputExists = errors.New(
"already exists (use --force to overwrite)")
// errEmptyOutput indicates --output given with an empty value.
errEmptyOutput = errors.New("--output must not be empty")
)
// reportEnumProgress renders enumeration progress until the channel
@@ -91,40 +88,9 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
return paths, nil
}
// outputPath returns the file gen writes the manifest to: the one --output
// names, or else index.mf in the directory the only argument names, or
// beside the file it names, or else in the current directory. An --output
// given with an empty value is refused.
func (mfa *CLIApp) outputPath(cmd *cli.Command) (string, error) {
if cmd.IsSet("output") {
output := cmd.String("output")
if output == "" {
return "", errEmptyOutput
}
return output, nil
}
if cmd.Args().Len() != 1 {
return defaultManifestName, nil
}
arg := cmd.Args().First()
// A path that does not exist is refused when it is enumerated.
info, err := mfa.Fs.Stat(arg)
if err == nil && !info.IsDir() {
return filepath.Join(filepath.Dir(arg), defaultManifestName), nil
}
return filepath.Join(arg, defaultManifestName), nil
}
// buildScannerOptions constructs scanner options from the CLI flags and
// the path the manifest is written to.
func (mfa *CLIApp) buildScannerOptions(
cmd *cli.Command, output string,
) *mfer.ScannerOptions {
// buildScannerOptions constructs scanner options from the CLI flags.
func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
output := cmd.String("output")
opts := &mfer.ScannerOptions{
IncludeDotfiles: cmd.Bool("include-dotfiles"),
FollowSymLinks: cmd.Bool("follow-symlinks"),
@@ -236,54 +202,23 @@ func (mfa *CLIApp) runEnumeratePhase(cmd *cli.Command, s *mfer.Scanner) error {
return nil
}
// runScanPhase reads the enumerated files and writes the manifest to out,
// with optional progress reporting.
func (mfa *CLIApp) runScanPhase(
ctx context.Context, cmd *cli.Command, s *mfer.Scanner, out io.Writer,
) error {
var (
scanProgress chan mfer.ScanStatus
scanWg sync.WaitGroup
)
if cmd.Bool("progress") {
scanProgress = make(chan mfer.ScanStatus, 1)
scanWg.Add(1)
go reportScanProgress(scanProgress, &scanWg)
}
err := s.ToManifest(ctx, out, scanProgress)
scanWg.Wait()
if err != nil {
return fmt.Errorf("generate manifest: %w", err)
}
return nil
}
func (mfa *CLIApp) generateManifestOperation(
ctx context.Context, cmd *cli.Command,
) error {
log.Debug("generateManifestOperation()")
outputPath, err := mfa.outputPath(cmd)
if err != nil {
return err
}
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd, outputPath))
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
// Phase 1: Enumeration - collect paths and stat files
err = mfa.runEnumeratePhase(cmd, s)
err := mfa.runEnumeratePhase(cmd, s)
if err != nil {
return err
}
showProgress := cmd.Bool("progress")
// Check if output file exists
outputPath := cmd.String("output")
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
}
@@ -314,9 +249,25 @@ func (mfa *CLIApp) generateManifestOperation(
}()
// Phase 2: Scan - read file contents and generate manifest
err = mfa.runScanPhase(ctx, cmd, s, outFile)
var (
scanProgress chan mfer.ScanStatus
scanWg sync.WaitGroup
)
if showProgress {
scanProgress = make(chan mfer.ScanStatus, 1)
scanWg.Add(1)
go reportScanProgress(scanProgress, &scanWg)
}
err = s.ToManifest(ctx, outFile, scanProgress)
scanWg.Wait()
if err != nil {
return err
return fmt.Errorf("generate manifest: %w", err)
}
// Close file before rename to ensure all data is flushed
-15
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
@@ -87,17 +86,3 @@ func (mfa *CLIApp) resolveManifestArg(cmd *cli.Command) (string, error) {
return findManifest(mfa.Fs, ".")
}
// resolveBasePath returns the directory a manifest's paths are resolved
// against: the one --base names, or else the directory holding the manifest,
// or the current directory for a manifest URL.
func resolveBasePath(cmd *cli.Command, manifestPath string) string {
switch {
case cmd.IsSet(flagBase):
return cmd.String(flagBase)
case isHTTPURL(manifestPath):
return "."
default:
return filepath.Dir(manifestPath)
}
}
+8 -11
View File
@@ -24,7 +24,6 @@ const (
cmdList = "list"
cmdVersion = "version"
flagBase = "base"
flagProgress = "progress"
flagTimeout = "timeout"
flagDest = "dest"
@@ -211,10 +210,9 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
},
&cli.StringFlag{
Name: "output",
Value: defaultManifestName,
Aliases: []string{"o"},
Usage: "File to write the manifest to (default: index.mf in " +
"the directory given, or beside the file given; with no " +
"path or several, index.mf in the current directory)",
Usage: "Specify output filename",
},
&cli.BoolFlag{
Name: "force",
@@ -261,11 +259,10 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
},
Flags: append(commonFlags(),
&cli.StringFlag{
Name: flagBase,
Name: "base",
Aliases: []string{"b"},
Usage: "Base directory for resolving relative paths from manifest " +
"(by default the directory holding the manifest, or the " +
"current directory for a manifest URL)",
Value: ".",
Usage: "Base directory for resolving relative paths from manifest",
},
&cli.BoolFlag{
Name: flagProgress,
@@ -295,10 +292,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
},
Flags: append(commonFlags(),
&cli.StringFlag{
Name: flagBase,
Name: "base",
Aliases: []string{"b"},
Usage: "Base directory for resolving relative paths " +
"(by default the directory holding the manifest)",
Value: ".",
Usage: "Base directory for resolving relative paths",
},
&cli.BoolFlag{
Name: "follow-symlinks",
+13 -23
View File
@@ -88,12 +88,12 @@ func gpgArgs(opts []string, positional ...string) []string {
}
// runGPG runs the gpg binary in batch mode with the given arguments and
// optional stdin, returning captured stdout and stderr. If gpg fails, the
// error ends with what gpg wrote to stderr. gpg is killed when ctx ends or
// gpgTimeout passes, whichever comes first.
// optional stdin, returning captured stdout. If gpg fails, the error ends
// with what gpg wrote to stderr. gpg is killed when ctx ends or gpgTimeout
// passes, whichever comes first.
func runGPG(
ctx context.Context, stdin io.Reader, args ...string,
) (*bytes.Buffer, *bytes.Buffer, error) {
) (*bytes.Buffer, error) {
// exec.CommandContext kills only gpg itself. A gpg-agent that gpg
// starts runs detached and holds none of gpg's output, but another
// process gpg leaves behind (a wrapper script that runs the real gpg
@@ -130,22 +130,12 @@ func runGPG(
}
}
if err != nil {
err = withStderr(err, &stderr)
}
return &stdout, &stderr, err
}
// withStderr returns err followed by what gpg wrote to stderr, or err alone
// when gpg wrote nothing.
func withStderr(err error, stderr *bytes.Buffer) error {
messages := strings.TrimSpace(stderr.String())
if messages == "" {
return err
if err != nil && messages != "" {
err = fmt.Errorf("%w: %s", err, messages)
}
return fmt.Errorf("%w: %s", err, messages)
return &stdout, err
}
// parseFingerprint extracts the first fingerprint from gpg --with-colons
@@ -199,7 +189,7 @@ func gpgSign(
// The signature goes to sigFile, so --status-fd 1 can send gpg's status
// lines to stdout; its messages go to stderr.
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
stdout, err := runGPG(ctx, bytes.NewReader(data),
"--detach-sign",
gpgOptArmor,
"--output", sigFile,
@@ -214,7 +204,7 @@ func gpgSign(
// made the signature.
created, ok := parseStatusLine(stdout.String(), "SIG_CREATED")
if !ok {
return nil, "", withStderr(errSigningKeyNotReported, stderr)
return nil, "", errSigningKeyNotReported
}
sig, err := os.ReadFile(sigFile) //nolint:gosec // G304: inside tmpDir, made above
@@ -228,7 +218,7 @@ func gpgSign(
// gpgExportPublicKey exports the public key for the specified key ID.
// Returns the armored public key.
func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
stdout, _, err := runGPG(ctx, nil,
stdout, err := runGPG(ctx, nil,
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
)
if err != nil {
@@ -244,7 +234,7 @@ func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
stdout, _, err := runGPG(ctx, nil,
stdout, err := runGPG(ctx, nil,
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
)
if err != nil {
@@ -264,7 +254,7 @@ func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
func gpgImportOneKey(ctx context.Context, gpgHome, pubKeyFile string) error {
// --status-fd 1 sends gpg's status lines to stdout, which importing
// otherwise leaves empty; its messages go to stderr.
importStdout, _, err := runGPG(ctx, nil,
importStdout, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, gpgHome, gpgOptStatusFD, "1", "--import"},
pubKeyFile)...,
)
@@ -338,7 +328,7 @@ func gpgVerify(ctx context.Context, data, signature, pubKey []byte) (string, err
// --status-fd 1 sends gpg's status lines to stdout, which verifying a
// detached signature otherwise leaves empty; its messages go to stderr.
verifyStdout, _, err := runGPG(ctx, nil,
verifyStdout, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptStatusFD, "1", gpgOptVerify},
sigFile, dataFile)...,
)
+1 -26
View File
@@ -451,7 +451,7 @@ func TestManifestRefusesSecondEmbeddedKeyWithoutUserID(t *testing.T) {
t.Setenv("GNUPGHOME", otherHome)
// Keeping only the user IDs that match "nobody" exports none.
otherPubKey, _, err := runGPG(context.Background(), nil,
otherPubKey, err := runGPG(context.Background(), nil,
gpgArgs([]string{
"--export", gpgOptArmor, "--export-filter", "keep-uid=uid = nobody",
}, string(otherKey))...)
@@ -616,31 +616,6 @@ func TestGPGTimeoutKillsGPG(t *testing.T) {
assert.EqualError(t, err, "gpg sign: timed out: context deadline exceeded")
}
// TestGPGSignKeyNotReportedKeepsStderr puts a fake gpg first on PATH that
// exits cleanly without reporting the key that signed, and checks that what
// it wrote to stderr is in the message.
func TestGPGSignKeyNotReportedKeepsStderr(t *testing.T) {
t.Setenv("PATH", fakeGPGPath(t,
"#!/bin/sh\necho 'gpg: note from the fake gpg' >&2\n"))
_, _, err := gpgSign(context.Background(), []byte("data"), GPGKeyID("any"))
require.ErrorIs(t, err, errSigningKeyNotReported)
assert.EqualError(t, err,
"gpg did not report the key that made the signature: "+
"gpg: note from the fake gpg")
}
// TestGPGFailureKeepsStderr puts a fake gpg first on PATH that writes to
// stderr and exits non-zero, and checks that what it wrote ends the message.
func TestGPGFailureKeepsStderr(t *testing.T) {
t.Setenv("PATH", fakeGPGPath(t,
"#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 2\n"))
_, _, err := gpgSign(context.Background(), []byte("data"), GPGKeyID("any"))
assert.EqualError(t, err,
"gpg sign: exit status 2: gpg: signing failed: No secret key")
}
// TestGPGCancelWhenChildHoldsOutput uses a fake gpg that runs sleep as a
// child instead of exec-ing it, the way a wrapper script around the real
// gpg might. Killing the fake gpg leaves sleep holding its stdout and
+16 -18
View File
@@ -139,20 +139,28 @@ func (s *Scanner) EnumerateFile(filePath string) error {
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
}
// EnumeratePath adds inputPath, a directory or a file, to the scanner as
// EnumeratePaths adds each of its paths.
// EnumeratePath walks a directory path and adds all files to the scanner.
// If progress is non-nil, status updates are sent as files are discovered.
// The progress channel is closed when the method returns.
func (s *Scanner) EnumeratePath(
inputPath string,
progress chan<- EnumerateStatus,
) error {
return s.EnumeratePaths(progress, inputPath)
if progress != nil {
defer close(progress)
}
abs, err := filepath.Abs(inputPath)
if err != nil {
return err
}
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
return s.enumerateFS(afs, abs, progress)
}
// EnumeratePaths adds to the scanner the files under each directory path,
// listed by their paths under it, and each file path, listed by its name
// as EnumerateFile lists it.
// EnumeratePaths walks multiple directory paths and adds all files to the scanner.
// If progress is non-nil, status updates are sent as files are discovered.
// The progress channel is closed when the method returns.
func (s *Scanner) EnumeratePaths(
@@ -169,19 +177,9 @@ func (s *Scanner) EnumeratePaths(
return err
}
info, err := s.fs.Stat(abs)
if err != nil {
return err
}
if info.IsDir() {
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
err = s.enumerateFS(afs, abs, progress)
} else {
err = s.enumerateFileWithInfo(
filepath.Base(abs), filepath.Dir(abs), info, progress)
}
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
err = s.enumerateFS(afs, abs, progress)
if err != nil {
return err
}
-43
View File
@@ -118,27 +118,6 @@ func TestScannerEnumeratePathWithProgress(t *testing.T) {
assert.Equal(t, FileSize(6), final.BytesFound)
}
// TestScannerEnumeratePathFile gives EnumeratePath a file: it is listed
// by its name, as EnumerateFile lists it, and the manifest can be built.
func TestScannerEnumeratePathFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/dir", 0o755))
require.NoError(t, afero.WriteFile(fs, "/dir/one.txt", []byte("1"), 0o644))
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
require.NoError(t, s.EnumeratePath("/dir/one.txt", nil))
var buf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
m, err := NewManifestFromReader(&buf)
require.NoError(t, err)
require.Len(t, m.Files(), 1)
assert.Equal(t, "one.txt", m.Files()[0].GetPath())
}
func TestScannerEnumeratePaths(t *testing.T) {
t.Parallel()
@@ -155,28 +134,6 @@ func TestScannerEnumeratePaths(t *testing.T) {
assert.Equal(t, FileCount(2), s.FileCount())
}
// TestScannerEnumeratePathsFile gives EnumeratePaths a directory and a
// file: the file is listed by its name, as EnumerateFile lists it.
func TestScannerEnumeratePathsFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/dir/sub", 0o755))
require.NoError(t, fs.MkdirAll("/other", 0o755))
require.NoError(t, afero.WriteFile(fs, "/dir/sub/one.txt", []byte("1"), 0o644))
require.NoError(t, afero.WriteFile(fs, "/other/two.txt", []byte("2"), 0o644))
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
require.NoError(t, s.EnumeratePaths(nil, "/dir", "/other/two.txt"))
paths := make([]RelFilePath, 0, s.FileCount())
for _, f := range s.Files() {
paths = append(paths, f.Path)
}
assert.Equal(t, []RelFilePath{"sub/one.txt", "two.txt"}, paths)
}
func TestScannerExcludeDotfiles(t *testing.T) {
t.Parallel()