Compare commits
6
Commits
519f433534
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23367c216 | ||
|
|
a5f218e42a | ||
|
|
6229c4eca0 | ||
|
|
e35cd4a045 | ||
|
|
c0b099cc48 | ||
|
|
dce5e050c3 |
@@ -9,8 +9,9 @@ downloading, streaming, and mirroring. It was first published in 2022. The
|
|||||||
manifest files' data is serialized with Google's
|
manifest files' data is serialized with Google's
|
||||||
[protobuf serialization format](https://developers.google.com/protocol-buffers).
|
[protobuf serialization format](https://developers.google.com/protocol-buffers).
|
||||||
The structure of these files can be found
|
The structure of these files can be found
|
||||||
[in the format specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
|
[in the format specification](docs/FORMAT.md), which refers to the protobuf
|
||||||
which is included in the [project repository](https://git.eeqj.de/sneak/mfer).
|
schema `mfer/mf.proto` for exact field numbers and types. Both are 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
|
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
|
has not yet been any versioned release. [SemVer](https://semver.org) will be
|
||||||
@@ -268,13 +269,33 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
|
|||||||
- recurses under current directory and writes out an `index.mf`
|
- recurses under current directory and writes out an `index.mf`
|
||||||
- records every file's mode as `0000` unless given `--include-permissions`,
|
- records every file's mode as `0000` unless given `--include-permissions`,
|
||||||
which records each file's permission bits (`0777` at most)
|
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 .`
|
- `mfer check` / `mfer check .`
|
||||||
- verifies checksums of all files in manifest, displaying error and exiting
|
- verifies checksums of all files in manifest, displaying error and exiting
|
||||||
nonzero if any files are missing or corrupted, or have permission bits
|
nonzero if any files are missing or corrupted, or have permission bits
|
||||||
other than the mode the manifest records, unless that is `0000`
|
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
|
- 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
|
list, hidden files included; with `--no-extra-files` each one is a failure
|
||||||
instead
|
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/`
|
- `mfer fetch https://example.com/stuff/`
|
||||||
- fetches `/stuff/index.mf` and downloads all files listed in manifest into
|
- fetches `/stuff/index.mf` and downloads all files listed in manifest into
|
||||||
the current directory, or the one given with `--dest`, and assures
|
the current directory, or the one given with `--dest`, and assures
|
||||||
|
|||||||
@@ -299,6 +299,10 @@ func (mfa *CLIApp) checkManifestOperation(
|
|||||||
return err
|
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
|
// URL manifests need to be downloaded to a temp file for the checker
|
||||||
if isHTTPURL(manifestPath) {
|
if isHTTPURL(manifestPath) {
|
||||||
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
|
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
|
||||||
@@ -311,7 +315,6 @@ func (mfa *CLIApp) checkManifestOperation(
|
|||||||
manifestPath = tmpPath
|
manifestPath = tmpPath
|
||||||
}
|
}
|
||||||
|
|
||||||
basePath := cmd.String("base")
|
|
||||||
showProgress := cmd.Bool("progress")
|
showProgress := cmd.Bool("progress")
|
||||||
|
|
||||||
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -979,6 +980,90 @@ 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.
|
// unlistableDirFs is a filesystem on which one directory cannot be listed.
|
||||||
type unlistableDirFs struct {
|
type unlistableDirFs struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
@@ -1204,6 +1289,225 @@ func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
|
|||||||
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testLink is the name of the symlink to a tree that the
|
||||||
|
// DirectoryNamedThroughSymlink tests make in a temp dir.
|
||||||
|
const testLink = "link"
|
||||||
|
|
||||||
|
// TestGenerateDirectoryNamedThroughSymlink runs gen on a directory named
|
||||||
|
// through a symlink, given as the argument or as the working directory: the
|
||||||
|
// manifest lists the files in the directory the symlink points to, and
|
||||||
|
// leaves out a symlink inside it, as gen does without --follow-symlinks.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestGenerateDirectoryNamedThroughSymlink(t *testing.T) {
|
||||||
|
// Paths are relative to a temp dir holding data and link, a symlink to
|
||||||
|
// data.
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
workDir string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
"argument": {".", []string{testLink}},
|
||||||
|
"working directory": {testLink, nil},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
data := filepath.Join(root, "data")
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
writeTestTree(t, fs, data)
|
||||||
|
require.NoError(t,
|
||||||
|
os.Symlink(testFileTxt, filepath.Join(data, "alias.txt")))
|
||||||
|
require.NoError(t, os.Symlink(data, filepath.Join(root, testLink)))
|
||||||
|
|
||||||
|
// t.Chdir sets PWD to the path it is given, as a shell does, and
|
||||||
|
// os.Getwd returns PWD when it names the working directory.
|
||||||
|
t.Chdir(filepath.Join(root, tc.workDir))
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat(
|
||||||
|
[]string{testApp, cmdGenerate, "-q"}, tc.args,
|
||||||
|
), fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{testFileTxt, "sub/nested.txt"},
|
||||||
|
manifestPaths(t, fs, filepath.Join(data, defaultManifestName)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -405,6 +405,48 @@ func TestFetchHashMismatchMessage(t *testing.T) {
|
|||||||
assert.EqualError(t, err, "download "+testFileTxt+": hash mismatch")
|
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) {
|
func TestURLRequiredMessage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+14
-12
@@ -295,19 +295,14 @@ func (h *freshenHasher) processEntry(e *freshenEntry) error {
|
|||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hash %s: %w", e.path, err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
h.hashedBytes += bytesRead
|
h.hashedBytes += bytesRead
|
||||||
h.hashedFiles++
|
h.hashedFiles++
|
||||||
|
|
||||||
// Add to builder with computed hash
|
// Add to builder with computed hash; a refused path is named in the error
|
||||||
err = addFileToBuilder(h.builder, e.path, e.size, e.mtime, e.mode, hash)
|
return 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
|
// writeFreshenedManifest writes the manifest atomically (write to a
|
||||||
@@ -384,6 +379,14 @@ func (mfa *CLIApp) freshenScan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The walk does not follow a symlink at its top, so a base directory
|
||||||
|
// named through one is resolved first. If that fails, the base is
|
||||||
|
// walked as named and the walk reports the problem.
|
||||||
|
resolved, err := filepath.EvalSymlinks(absBase)
|
||||||
|
if err == nil {
|
||||||
|
absBase = resolved
|
||||||
|
}
|
||||||
|
|
||||||
scanner := &freshenScanner{
|
scanner := &freshenScanner{
|
||||||
fs: mfa.Fs,
|
fs: mfa.Fs,
|
||||||
absBase: absBase,
|
absBase: absBase,
|
||||||
@@ -395,7 +398,7 @@ func (mfa *CLIApp) freshenScan(
|
|||||||
existingByPath: existingByPath,
|
existingByPath: existingByPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := afero.Walk(mfa.Fs, absBase, scanner.walk)
|
err = afero.Walk(mfa.Fs, absBase, scanner.walk)
|
||||||
|
|
||||||
if showProgress {
|
if showProgress {
|
||||||
log.ProgressDone()
|
log.ProgressDone()
|
||||||
@@ -491,7 +494,6 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
) error {
|
) error {
|
||||||
log.Debug("freshenManifestOperation()")
|
log.Debug("freshenManifestOperation()")
|
||||||
|
|
||||||
basePath := cmd.String("base")
|
|
||||||
showProgress := cmd.Bool("progress")
|
showProgress := cmd.Bool("progress")
|
||||||
|
|
||||||
// Find manifest file
|
// Find manifest file
|
||||||
@@ -506,7 +508,7 @@ func (mfa *CLIApp) freshenManifestOperation(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
absBase, err := filepath.Abs(basePath)
|
absBase, err := filepath.Abs(resolveBasePath(cmd, manifestPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid base path: %w", err)
|
return fmt.Errorf("invalid base path: %w", err)
|
||||||
}
|
}
|
||||||
@@ -600,7 +602,7 @@ func hashFile(r io.Reader, progress func(int64)) ([]byte, int64, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returned unwrapped: the caller adds the path.
|
// Returned unwrapped: a read error already names the file.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, total, err
|
return nil, total, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -300,6 +301,84 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
|
|||||||
manifestPaths(t, fs, manifestPath))
|
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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenDirectoryNamedThroughSymlink adds a file to a tree after gen
|
||||||
|
// made its manifest, then freshens it with the tree named through a symlink,
|
||||||
|
// given as the argument or as the working directory: the manifest lists the
|
||||||
|
// files in the tree, and leaves out a symlink inside it, as freshen does
|
||||||
|
// without --follow-symlinks.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestFreshenDirectoryNamedThroughSymlink(t *testing.T) {
|
||||||
|
// Paths are relative to a temp dir holding link, a symlink to the tree.
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
workDir string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
"argument": {".", []string{testLink}},
|
||||||
|
"working directory": {testLink, nil},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tree, manifestPath := setupFreshenDir(t, fs,
|
||||||
|
map[string]string{testFileTxt: "in the tree"})
|
||||||
|
writeTestFile(t, fs, filepath.Join(tree, "later.txt"), "added later")
|
||||||
|
require.NoError(t,
|
||||||
|
os.Symlink(testFileTxt, filepath.Join(tree, "alias.txt")))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.Symlink(tree, filepath.Join(root, testLink)))
|
||||||
|
|
||||||
|
// t.Chdir sets PWD to the path it is given, as a shell does, and
|
||||||
|
// os.Getwd returns PWD when it names the working directory.
|
||||||
|
t.Chdir(filepath.Join(root, tc.workDir))
|
||||||
|
|
||||||
|
opts := testOpts(slices.Concat(
|
||||||
|
[]string{testApp, cmdFreshen, "-q"}, tc.args,
|
||||||
|
), fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assertManifestLists(t, fs, tree, manifestPath, map[string]string{
|
||||||
|
testFileTxt: "in the tree", "later.txt": "added later",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry
|
// TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry
|
||||||
// with respect to MFFilePath.Mtime, which is a message pointer with
|
// with respect to MFFilePath.Mtime, which is a message pointer with
|
||||||
// proto3 field presence and may legitimately be absent.
|
// proto3 field presence and may legitimately be absent.
|
||||||
|
|||||||
+75
-26
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -26,6 +27,8 @@ var (
|
|||||||
// rendered message stays exactly as mfer has always printed it.
|
// rendered message stays exactly as mfer has always printed it.
|
||||||
errOutputExists = errors.New(
|
errOutputExists = errors.New(
|
||||||
"already exists (use --force to overwrite)")
|
"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
|
// reportEnumProgress renders enumeration progress until the channel
|
||||||
@@ -88,9 +91,40 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScannerOptions constructs scanner options from the CLI flags.
|
// outputPath returns the file gen writes the manifest to: the one --output
|
||||||
func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
|
// 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")
|
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 {
|
||||||
opts := &mfer.ScannerOptions{
|
opts := &mfer.ScannerOptions{
|
||||||
IncludeDotfiles: cmd.Bool("include-dotfiles"),
|
IncludeDotfiles: cmd.Bool("include-dotfiles"),
|
||||||
FollowSymLinks: cmd.Bool("follow-symlinks"),
|
FollowSymLinks: cmd.Bool("follow-symlinks"),
|
||||||
@@ -202,23 +236,54 @@ func (mfa *CLIApp) runEnumeratePhase(cmd *cli.Command, s *mfer.Scanner) error {
|
|||||||
return nil
|
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(
|
func (mfa *CLIApp) generateManifestOperation(
|
||||||
ctx context.Context, cmd *cli.Command,
|
ctx context.Context, cmd *cli.Command,
|
||||||
) error {
|
) error {
|
||||||
log.Debug("generateManifestOperation()")
|
log.Debug("generateManifestOperation()")
|
||||||
|
|
||||||
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
|
outputPath, err := mfa.outputPath(cmd)
|
||||||
|
|
||||||
// Phase 1: Enumeration - collect paths and stat files
|
|
||||||
err := mfa.runEnumeratePhase(cmd, s)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
showProgress := cmd.Bool("progress")
|
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd, outputPath))
|
||||||
|
|
||||||
|
// Phase 1: Enumeration - collect paths and stat files
|
||||||
|
err = mfa.runEnumeratePhase(cmd, s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if output file exists
|
// Check if output file exists
|
||||||
outputPath := cmd.String("output")
|
|
||||||
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
|
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
|
||||||
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
|
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
|
||||||
}
|
}
|
||||||
@@ -249,25 +314,9 @@ func (mfa *CLIApp) generateManifestOperation(
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Phase 2: Scan - read file contents and generate manifest
|
// Phase 2: Scan - read file contents and generate manifest
|
||||||
var (
|
err = mfa.runScanPhase(ctx, cmd, s, outFile)
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("generate manifest: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close file before rename to ensure all data is flushed
|
// Close file before rename to ensure all data is flushed
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -86,3 +87,17 @@ func (mfa *CLIApp) resolveManifestArg(cmd *cli.Command) (string, error) {
|
|||||||
|
|
||||||
return findManifest(mfa.Fs, ".")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-8
@@ -24,6 +24,7 @@ const (
|
|||||||
cmdList = "list"
|
cmdList = "list"
|
||||||
cmdVersion = "version"
|
cmdVersion = "version"
|
||||||
|
|
||||||
|
flagBase = "base"
|
||||||
flagProgress = "progress"
|
flagProgress = "progress"
|
||||||
flagTimeout = "timeout"
|
flagTimeout = "timeout"
|
||||||
flagDest = "dest"
|
flagDest = "dest"
|
||||||
@@ -210,9 +211,10 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
|||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "output",
|
Name: "output",
|
||||||
Value: defaultManifestName,
|
|
||||||
Aliases: []string{"o"},
|
Aliases: []string{"o"},
|
||||||
Usage: "Specify output filename",
|
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)",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "force",
|
Name: "force",
|
||||||
@@ -259,10 +261,11 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
|
|||||||
},
|
},
|
||||||
Flags: append(commonFlags(),
|
Flags: append(commonFlags(),
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "base",
|
Name: flagBase,
|
||||||
Aliases: []string{"b"},
|
Aliases: []string{"b"},
|
||||||
Value: ".",
|
Usage: "Base directory for resolving relative paths from manifest " +
|
||||||
Usage: "Base directory for resolving relative paths from manifest",
|
"(by default the directory holding the manifest, or the " +
|
||||||
|
"current directory for a manifest URL)",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: flagProgress,
|
Name: flagProgress,
|
||||||
@@ -292,10 +295,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
|
|||||||
},
|
},
|
||||||
Flags: append(commonFlags(),
|
Flags: append(commonFlags(),
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "base",
|
Name: flagBase,
|
||||||
Aliases: []string{"b"},
|
Aliases: []string{"b"},
|
||||||
Value: ".",
|
Usage: "Base directory for resolving relative paths " +
|
||||||
Usage: "Base directory for resolving relative paths",
|
"(by default the directory holding the manifest)",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "follow-symlinks",
|
Name: "follow-symlinks",
|
||||||
|
|||||||
+24
-14
@@ -88,12 +88,12 @@ func gpgArgs(opts []string, positional ...string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runGPG runs the gpg binary in batch mode with the given arguments and
|
// runGPG runs the gpg binary in batch mode with the given arguments and
|
||||||
// optional stdin, returning captured stdout. If gpg fails, the error ends
|
// optional stdin, returning captured stdout and stderr. If gpg fails, the
|
||||||
// with what gpg wrote to stderr. gpg is killed when ctx ends or gpgTimeout
|
// error ends with what gpg wrote to stderr. gpg is killed when ctx ends or
|
||||||
// passes, whichever comes first.
|
// gpgTimeout passes, whichever comes first.
|
||||||
func runGPG(
|
func runGPG(
|
||||||
ctx context.Context, stdin io.Reader, args ...string,
|
ctx context.Context, stdin io.Reader, args ...string,
|
||||||
) (*bytes.Buffer, error) {
|
) (*bytes.Buffer, *bytes.Buffer, error) {
|
||||||
// exec.CommandContext kills only gpg itself. A gpg-agent that gpg
|
// exec.CommandContext kills only gpg itself. A gpg-agent that gpg
|
||||||
// starts runs detached and holds none of gpg's output, but another
|
// starts runs detached and holds none of gpg's output, but another
|
||||||
// process gpg leaves behind (a wrapper script that runs the real gpg
|
// process gpg leaves behind (a wrapper script that runs the real gpg
|
||||||
@@ -130,12 +130,22 @@ func runGPG(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := strings.TrimSpace(stderr.String())
|
if err != nil {
|
||||||
if err != nil && messages != "" {
|
err = withStderr(err, &stderr)
|
||||||
err = fmt.Errorf("%w: %s", err, messages)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &stdout, err
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%w: %s", err, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFingerprint extracts the first fingerprint from gpg --with-colons
|
// parseFingerprint extracts the first fingerprint from gpg --with-colons
|
||||||
@@ -189,7 +199,7 @@ func gpgSign(
|
|||||||
|
|
||||||
// The signature goes to sigFile, so --status-fd 1 can send gpg's status
|
// The signature goes to sigFile, so --status-fd 1 can send gpg's status
|
||||||
// lines to stdout; its messages go to stderr.
|
// lines to stdout; its messages go to stderr.
|
||||||
stdout, err := runGPG(ctx, bytes.NewReader(data),
|
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
|
||||||
"--detach-sign",
|
"--detach-sign",
|
||||||
gpgOptArmor,
|
gpgOptArmor,
|
||||||
"--output", sigFile,
|
"--output", sigFile,
|
||||||
@@ -204,7 +214,7 @@ func gpgSign(
|
|||||||
// made the signature.
|
// made the signature.
|
||||||
created, ok := parseStatusLine(stdout.String(), "SIG_CREATED")
|
created, ok := parseStatusLine(stdout.String(), "SIG_CREATED")
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, "", errSigningKeyNotReported
|
return nil, "", withStderr(errSigningKeyNotReported, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
sig, err := os.ReadFile(sigFile) //nolint:gosec // G304: inside tmpDir, made above
|
sig, err := os.ReadFile(sigFile) //nolint:gosec // G304: inside tmpDir, made above
|
||||||
@@ -218,7 +228,7 @@ func gpgSign(
|
|||||||
// gpgExportPublicKey exports the public key for the specified key ID.
|
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||||
// Returns the armored public key.
|
// Returns the armored public key.
|
||||||
func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
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))...,
|
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -234,7 +244,7 @@ func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
|||||||
|
|
||||||
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
||||||
func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
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))...,
|
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -254,7 +264,7 @@ func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
|||||||
func gpgImportOneKey(ctx context.Context, gpgHome, pubKeyFile string) error {
|
func gpgImportOneKey(ctx context.Context, gpgHome, pubKeyFile string) error {
|
||||||
// --status-fd 1 sends gpg's status lines to stdout, which importing
|
// --status-fd 1 sends gpg's status lines to stdout, which importing
|
||||||
// otherwise leaves empty; its messages go to stderr.
|
// 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"},
|
gpgArgs([]string{gpgOptHomedir, gpgHome, gpgOptStatusFD, "1", "--import"},
|
||||||
pubKeyFile)...,
|
pubKeyFile)...,
|
||||||
)
|
)
|
||||||
@@ -328,7 +338,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
|
// --status-fd 1 sends gpg's status lines to stdout, which verifying a
|
||||||
// detached signature otherwise leaves empty; its messages go to stderr.
|
// 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},
|
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptStatusFD, "1", gpgOptVerify},
|
||||||
sigFile, dataFile)...,
|
sigFile, dataFile)...,
|
||||||
)
|
)
|
||||||
|
|||||||
+26
-1
@@ -451,7 +451,7 @@ func TestManifestRefusesSecondEmbeddedKeyWithoutUserID(t *testing.T) {
|
|||||||
t.Setenv("GNUPGHOME", otherHome)
|
t.Setenv("GNUPGHOME", otherHome)
|
||||||
|
|
||||||
// Keeping only the user IDs that match "nobody" exports none.
|
// Keeping only the user IDs that match "nobody" exports none.
|
||||||
otherPubKey, err := runGPG(context.Background(), nil,
|
otherPubKey, _, err := runGPG(context.Background(), nil,
|
||||||
gpgArgs([]string{
|
gpgArgs([]string{
|
||||||
"--export", gpgOptArmor, "--export-filter", "keep-uid=uid = nobody",
|
"--export", gpgOptArmor, "--export-filter", "keep-uid=uid = nobody",
|
||||||
}, string(otherKey))...)
|
}, string(otherKey))...)
|
||||||
@@ -616,6 +616,31 @@ func TestGPGTimeoutKillsGPG(t *testing.T) {
|
|||||||
assert.EqualError(t, err, "gpg sign: timed out: context deadline exceeded")
|
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
|
// TestGPGCancelWhenChildHoldsOutput uses a fake gpg that runs sleep as a
|
||||||
// child instead of exec-ing it, the way a wrapper script around the real
|
// 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
|
// gpg might. Killing the fake gpg leaves sleep holding its stdout and
|
||||||
|
|||||||
+25
-15
@@ -139,28 +139,20 @@ func (s *Scanner) EnumerateFile(filePath string) error {
|
|||||||
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
|
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnumeratePath walks a directory path and adds all files to the scanner.
|
// EnumeratePath adds inputPath, a directory or a file, to the scanner as
|
||||||
|
// EnumeratePaths adds each of its paths.
|
||||||
// If progress is non-nil, status updates are sent as files are discovered.
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
// The progress channel is closed when the method returns.
|
// The progress channel is closed when the method returns.
|
||||||
func (s *Scanner) EnumeratePath(
|
func (s *Scanner) EnumeratePath(
|
||||||
inputPath string,
|
inputPath string,
|
||||||
progress chan<- EnumerateStatus,
|
progress chan<- EnumerateStatus,
|
||||||
) error {
|
) error {
|
||||||
if progress != nil {
|
return s.EnumeratePaths(progress, inputPath)
|
||||||
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 walks multiple directory paths and adds all files to the scanner.
|
// 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.
|
||||||
// If progress is non-nil, status updates are sent as files are discovered.
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
// The progress channel is closed when the method returns.
|
// The progress channel is closed when the method returns.
|
||||||
func (s *Scanner) EnumeratePaths(
|
func (s *Scanner) EnumeratePaths(
|
||||||
@@ -177,9 +169,27 @@ func (s *Scanner) EnumeratePaths(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
info, err := s.fs.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
// The walk does not follow a symlink at its top, so a directory
|
||||||
|
// named through one is resolved first. If that fails, the
|
||||||
|
// directory is walked as named and the walk reports the problem.
|
||||||
|
resolved, evalErr := filepath.EvalSymlinks(abs)
|
||||||
|
if evalErr == nil {
|
||||||
|
abs = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
||||||
err = s.enumerateFS(afs, abs, progress)
|
err = s.enumerateFS(afs, abs, progress)
|
||||||
|
} else {
|
||||||
|
err = s.enumerateFileWithInfo(
|
||||||
|
filepath.Base(abs), filepath.Dir(abs), info, progress)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,27 @@ func TestScannerEnumeratePathWithProgress(t *testing.T) {
|
|||||||
assert.Equal(t, FileSize(6), final.BytesFound)
|
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) {
|
func TestScannerEnumeratePaths(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -134,6 +155,28 @@ func TestScannerEnumeratePaths(t *testing.T) {
|
|||||||
assert.Equal(t, FileCount(2), s.FileCount())
|
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) {
|
func TestScannerExcludeDotfiles(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user