diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index b27ea7e..bf2c718 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -1369,6 +1369,51 @@ func TestGenerateSeveralPathsDefaultOutput(t *testing.T) { } } +// 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 diff --git a/internal/cli/freshen.go b/internal/cli/freshen.go index 5e0e2e0..eace12a 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -379,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{ fs: mfa.Fs, absBase: absBase, @@ -390,7 +398,7 @@ func (mfa *CLIApp) freshenScan( existingByPath: existingByPath, } - err := afero.Walk(mfa.Fs, absBase, scanner.walk) + err = afero.Walk(mfa.Fs, absBase, scanner.walk) if showProgress { log.ProgressDone() diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index addac24..d6fbfe2 100644 --- a/internal/cli/freshen_test.go +++ b/internal/cli/freshen_test.go @@ -336,6 +336,49 @@ func TestFreshenResolvesEntriesAgainstManifestDirectory(t *testing.T) { } } +// 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 // with respect to MFFilePath.Mtime, which is a message pointer with // proto3 field presence and may legitimately be absent. diff --git a/mfer/scanner.go b/mfer/scanner.go index 058c227..d8be7bc 100644 --- a/mfer/scanner.go +++ b/mfer/scanner.go @@ -155,6 +155,14 @@ func (s *Scanner) EnumeratePath( return err } + // 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, err := filepath.EvalSymlinks(abs) + if err == nil { + abs = resolved + } + afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs)) return s.enumerateFS(afs, abs, progress) @@ -185,6 +193,14 @@ func (s *Scanner) EnumeratePaths( } 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) } else {