Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
519f433534 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user