diff --git a/README.md b/README.md index cdc8d8d..0f9ffdb 100644 --- a/README.md +++ b/README.md @@ -273,9 +273,17 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues). - 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 - `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 diff --git a/internal/cli/check.go b/internal/cli/check.go index b629a93..63958ce 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -299,6 +299,10 @@ func (mfa *CLIApp) checkManifestOperation( return fmt.Errorf("check: %w", 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) @@ -311,7 +315,6 @@ func (mfa *CLIApp) checkManifestOperation( manifestPath = tmpPath } - basePath := cmd.String("base") showProgress := cmd.Bool("progress") log.Infof("checking manifest %s with base %s", manifestPath, basePath) diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index dabfb17..ef23958 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "math/rand" + "net/http/httptest" "os" "path/filepath" "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. type unlistableDirFs struct { afero.Fs diff --git a/internal/cli/freshen.go b/internal/cli/freshen.go index e39fbb5..cc80387 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -497,7 +497,6 @@ func (mfa *CLIApp) freshenManifestOperation( ) error { log.Debug("freshenManifestOperation()") - basePath := cmd.String("base") showProgress := cmd.Bool("progress") // Find manifest file @@ -512,7 +511,7 @@ func (mfa *CLIApp) freshenManifestOperation( return err } - absBase, err := filepath.Abs(basePath) + absBase, err := filepath.Abs(resolveBasePath(cmd, manifestPath)) if err != nil { return fmt.Errorf("freshen: invalid base path: %w", err) } diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index bb86985..addac24 100644 --- a/internal/cli/freshen_test.go +++ b/internal/cli/freshen_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" @@ -300,6 +301,41 @@ 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. diff --git a/internal/cli/manifest_loader.go b/internal/cli/manifest_loader.go index ca3e64b..c032573 100644 --- a/internal/cli/manifest_loader.go +++ b/internal/cli/manifest_loader.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "path/filepath" "strings" "time" @@ -88,3 +89,17 @@ 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) + } +} diff --git a/internal/cli/mfer.go b/internal/cli/mfer.go index c2a0366..1b8d83d 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -24,6 +24,7 @@ const ( cmdList = "list" cmdVersion = "version" + flagBase = "base" flagProgress = "progress" flagTimeout = "timeout" flagDest = "dest" @@ -259,10 +260,11 @@ func (mfa *CLIApp) checkCommand() *cli.Command { }, Flags: append(commonFlags(), &cli.StringFlag{ - Name: "base", + Name: flagBase, 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{ Name: flagProgress, @@ -292,10 +294,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command { }, Flags: append(commonFlags(), &cli.StringFlag{ - Name: "base", + Name: flagBase, 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{ Name: "follow-symlinks",