Compare commits
1
Commits
next
...
97875a82bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97875a82bc |
+1
-1
@@ -8,7 +8,7 @@ vendor.tzst
|
|||||||
modcache.tzst
|
modcache.tzst
|
||||||
|
|
||||||
# Generated manifest files
|
# Generated manifest files
|
||||||
.index.mf
|
/index.mf
|
||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ Generate a manifest for a directory tree, verify it later, and fetch a published
|
|||||||
tree by URL:
|
tree by URL:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Write .index.mf, a manifest of the files under the current directory.
|
# Write index.mf, a manifest of the files under the current directory.
|
||||||
bin/mfer gen .
|
bin/mfer gen .
|
||||||
|
|
||||||
# Verify the files on disk against the manifest. Exits nonzero if any file
|
# Verify the files on disk against the manifest. Exits nonzero if any file
|
||||||
# is missing or corrupted.
|
# is missing or corrupted.
|
||||||
bin/mfer check .index.mf
|
bin/mfer check index.mf
|
||||||
|
|
||||||
# Download and cryptographically verify a tree published over HTTP: mfer
|
# Download and cryptographically verify a tree published over HTTP: mfer
|
||||||
# fetches <url>/index.mf, then downloads every file it lists.
|
# fetches <url>/index.mf, then downloads every file it lists.
|
||||||
|
|||||||
+12
-15
@@ -75,25 +75,22 @@ func safeRateUint64(rate float64) uint64 {
|
|||||||
return uint64(rate)
|
return uint64(rate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findManifest looks for a manifest file in the given directory.
|
// findManifest returns the path of the manifest with the default name in
|
||||||
// It checks for index.mf and .index.mf, returning the first one found.
|
// dir, or an error if there is none.
|
||||||
func findManifest(fs afero.Fs, dir string) (string, error) {
|
func findManifest(fs afero.Fs, dir string) (string, error) {
|
||||||
candidates := []string{"index.mf", ".index.mf"}
|
path := filepath.Join(dir, defaultManifestName)
|
||||||
for _, name := range candidates {
|
|
||||||
path := filepath.Join(dir, name)
|
|
||||||
|
|
||||||
exists, err := afero.Exists(fs, path)
|
exists, err := afero.Exists(fs, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf(
|
if !exists {
|
||||||
"%w in %s (looked for index.mf and .index.mf)", errNoManifestFound, dir)
|
return "", fmt.Errorf("%w in %s (looked for %s)",
|
||||||
|
errNoManifestFound, dir, defaultManifestName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchManifestToTemp downloads a manifest URL to a temporary file and
|
// fetchManifestToTemp downloads a manifest URL to a temporary file and
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -743,6 +744,103 @@ func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
|
|||||||
assert.Equal(t, "existing", string(content), "original file should be preserved")
|
assert.Equal(t, "existing", string(content), "original file should be preserved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// manifestPaths returns the file paths listed by the manifest at path.
|
||||||
|
func manifestPaths(t *testing.T, fs afero.Fs, path string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
|
Path: path,
|
||||||
|
Fs: fs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
paths := make([]string, 0, len(manifest.Files()))
|
||||||
|
for _, f := range manifest.Files() {
|
||||||
|
paths = append(paths, f.GetPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateLeavesOutputOutOfListing overwrites an output file inside the
|
||||||
|
// scanned tree with --force: the old file is not listed, even when the tree
|
||||||
|
// is named through a symlink, while a file named index.mf in a subdirectory
|
||||||
|
// still is. The output file is recognized by file identity, which needs
|
||||||
|
// the real filesystem.
|
||||||
|
func TestGenerateLeavesOutputOutOfListing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Paths are relative to a temp dir holding data/tree and link, a
|
||||||
|
// symlink to data.
|
||||||
|
for name, tc := range map[string]struct{ input, output string }{
|
||||||
|
"default name": {"data/tree", "data/tree/index.mf"},
|
||||||
|
"other name in a subdirectory": {"data/tree", "data/tree/sub/listing.mf"},
|
||||||
|
"tree named through a symlink": {"link/tree", "data/tree/index.mf"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
tree := filepath.Join(root, "data", "tree")
|
||||||
|
output := filepath.Join(root, tc.output)
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Join(tree, "sub"), 0o750))
|
||||||
|
require.NoError(t,
|
||||||
|
os.Symlink(filepath.Join(root, "data"), filepath.Join(root, "link")))
|
||||||
|
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
|
||||||
|
writeTestFile(t, fs, filepath.Join(tree, "sub", "index.mf"), "an ordinary file")
|
||||||
|
writeTestFile(t, fs, output, "previous manifest")
|
||||||
|
|
||||||
|
opts := testOpts([]string{
|
||||||
|
testApp, cmdGenerate, "-q", "--force", "-o", output, filepath.Join(root, tc.input),
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{testFileTxt, "sub/index.mf"},
|
||||||
|
manifestPaths(t, fs, output))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateDefaultOutputLeftOutOfListing runs gen with --force and no
|
||||||
|
// other arguments, so it scans the current directory and writes the
|
||||||
|
// relative path index.mf: the index.mf already there is not listed.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the process-global working directory
|
||||||
|
func TestGenerateDefaultOutputLeftOutOfListing(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
writeTestFile(t, fs, testFileTxt, "hello")
|
||||||
|
writeTestFile(t, fs, "index.mf", "previous manifest")
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "--force"}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, "index.mf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateLeavesLeftoverTempFileOutOfListing runs gen where an
|
||||||
|
// interrupted run left its temp file, index.mf.tmp, beside the output:
|
||||||
|
// the leftover is not listed, and gen does not fail when it overwrites
|
||||||
|
// it.
|
||||||
|
func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
output := filepath.Join(root, "index.mf")
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
||||||
|
writeTestFile(t, fs, output+".tmp", "part of a manifest")
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", output, root}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output))
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func TestNoManifestFoundMessage(t *testing.T) {
|
|||||||
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
|
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
|
||||||
require.ErrorIs(t, err, errNoManifestFound)
|
require.ErrorIs(t, err, errNoManifestFound)
|
||||||
assert.EqualError(t, err,
|
assert.EqualError(t, err,
|
||||||
"no manifest found in /tmp/x (looked for index.mf and .index.mf)")
|
"no manifest found in /tmp/x (looked for index.mf)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifyRequiredSignerMessages(t *testing.T) {
|
func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ func checkNoSymlinks(p string) error {
|
|||||||
|
|
||||||
// resolveManifestURL takes a URL and returns the manifest URL.
|
// resolveManifestURL takes a URL and returns the manifest URL.
|
||||||
// If the URL already ends with .mf, it's returned as-is.
|
// If the URL already ends with .mf, it's returned as-is.
|
||||||
// Otherwise, index.mf is appended.
|
// Otherwise, the default manifest name is appended.
|
||||||
func resolveManifestURL(inputURL string) (string, error) {
|
func resolveManifestURL(inputURL string) (string, error) {
|
||||||
parsed, err := url.Parse(inputURL)
|
parsed, err := url.Parse(inputURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -330,8 +330,7 @@ func resolveManifestURL(inputURL string) (string, error) {
|
|||||||
parsed.Path += "/"
|
parsed.Path += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append index.mf
|
parsed.Path += defaultManifestName
|
||||||
parsed.Path += "index.mf"
|
|
||||||
|
|
||||||
return parsed.String(), nil
|
return parsed.String(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-8
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -56,7 +57,8 @@ type freshenEntry struct {
|
|||||||
type freshenScanner struct {
|
type freshenScanner struct {
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
absBase string
|
absBase string
|
||||||
manifestBase string
|
manifestInfo fs.FileInfo // the manifest being freshened
|
||||||
|
tmpInfo fs.FileInfo // its leftover temp file; nil when there is none
|
||||||
includeDotfiles bool
|
includeDotfiles bool
|
||||||
followSymlinks bool
|
followSymlinks bool
|
||||||
showProgress bool
|
showProgress bool
|
||||||
@@ -156,11 +158,6 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
|
|||||||
"freshen: failed to compute relative path for %s: %w", path, err)
|
"freshen: failed to compute relative path for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the manifest file itself
|
|
||||||
if relPath == s.manifestBase || relPath == "."+s.manifestBase {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dotfiles
|
// Handle dotfiles
|
||||||
if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
@@ -185,6 +182,13 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
|
|||||||
info = realInfo
|
info = realInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip the manifest file itself and a temp file left beside it by an
|
||||||
|
// interrupted run, however their paths are spelled; gen leaves out its
|
||||||
|
// output file the same way. os.SameFile never matches a nil FileInfo.
|
||||||
|
if os.SameFile(info, s.manifestInfo) || os.SameFile(info, s.tmpInfo) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
s.scanCount++
|
s.scanCount++
|
||||||
|
|
||||||
// Check against existing manifest
|
// Check against existing manifest
|
||||||
@@ -364,17 +368,33 @@ func (mfa *CLIApp) freshenScan(
|
|||||||
startScan := time.Now()
|
startScan := time.Now()
|
||||||
showProgress := ctx.Bool("progress")
|
showProgress := ctx.Bool("progress")
|
||||||
|
|
||||||
|
manifestInfo, err := mfa.Fs.Stat(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("freshen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The temp file writeFreshenedManifest writes, if an interrupted run
|
||||||
|
// left one. One that cannot be stat'd, normally because it does not
|
||||||
|
// exist, needs no leaving out.
|
||||||
|
var tmpInfo fs.FileInfo
|
||||||
|
|
||||||
|
info, err := mfa.Fs.Stat(manifestPath + ".tmp")
|
||||||
|
if err == nil {
|
||||||
|
tmpInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
scanner := &freshenScanner{
|
scanner := &freshenScanner{
|
||||||
fs: mfa.Fs,
|
fs: mfa.Fs,
|
||||||
absBase: absBase,
|
absBase: absBase,
|
||||||
manifestBase: filepath.Base(manifestPath),
|
manifestInfo: manifestInfo,
|
||||||
|
tmpInfo: tmpInfo,
|
||||||
includeDotfiles: ctx.Bool("include-dotfiles"),
|
includeDotfiles: ctx.Bool("include-dotfiles"),
|
||||||
followSymlinks: ctx.Bool("follow-symlinks"),
|
followSymlinks: ctx.Bool("follow-symlinks"),
|
||||||
showProgress: showProgress,
|
showProgress: showProgress,
|
||||||
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()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ func (s stubFileInfo) IsDir() bool { return false }
|
|||||||
func (s stubFileInfo) Sys() any { return nil }
|
func (s stubFileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
// setupFreshenDir populates /testdir with two files, scans it, and
|
// setupFreshenDir populates /testdir with two files, scans it, and
|
||||||
// writes the resulting manifest to /testdir/.index.mf.
|
// writes the resulting manifest to /testdir/index.mf.
|
||||||
func setupFreshenDir(t *testing.T, fs afero.Fs) {
|
func setupFreshenDir(t *testing.T, fs afero.Fs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ func setupFreshenDir(t *testing.T, fs afero.Fs) {
|
|||||||
|
|
||||||
// Write manifest to filesystem
|
// Write manifest to filesystem
|
||||||
require.NoError(t,
|
require.NoError(t,
|
||||||
afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
afero.WriteFile(fs, "/testdir/index.mf", manifestBuf.Bytes(), 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFreshenUnchanged(t *testing.T) {
|
func TestFreshenUnchanged(t *testing.T) {
|
||||||
@@ -59,7 +60,7 @@ func TestFreshenUnchanged(t *testing.T) {
|
|||||||
|
|
||||||
// Parse manifest to verify
|
// Parse manifest to verify
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
Path: "/testdir/.index.mf",
|
Path: "/testdir/index.mf",
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -74,7 +75,7 @@ func TestFreshenWithChanges(t *testing.T) {
|
|||||||
|
|
||||||
// Verify initial manifest has 2 files
|
// Verify initial manifest has 2 files
|
||||||
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{
|
||||||
Path: "/testdir/.index.mf",
|
Path: "/testdir/index.mf",
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -101,6 +102,64 @@ func TestFreshenWithChanges(t *testing.T) {
|
|||||||
assert.Equal(t, "modified content2", string(content))
|
assert.Equal(t, "modified content2", string(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a
|
||||||
|
// subdirectory of the tree it lists: the manifest is not listed, while an
|
||||||
|
// ordinary file of the same name at the top of the tree is. The manifest
|
||||||
|
// is recognized by file identity, which needs the real filesystem.
|
||||||
|
func TestFreshenLeavesManifestOutOfListing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
manifestPath := filepath.Join(root, "sub", "listing.mf")
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Join(root, "sub"), 0o750))
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, "listing.mf"), "an ordinary file")
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
// A new file gives freshen something to write.
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
|
||||||
|
|
||||||
|
opts = testOpts([]string{
|
||||||
|
testApp, "freshen", "-q", testFlagBase, root, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{testFileTxt, "listing.mf", "added.txt"},
|
||||||
|
manifestPaths(t, fs, manifestPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFreshenLeavesLeftoverTempFileOutOfListing freshens a manifest where
|
||||||
|
// an interrupted run left its temp file, index.mf.tmp, beside it: the
|
||||||
|
// leftover is not listed.
|
||||||
|
func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
manifestPath := filepath.Join(root, "index.mf")
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
||||||
|
|
||||||
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
writeTestFile(t, fs, manifestPath+".tmp", "part of a manifest")
|
||||||
|
// A new file gives freshen something to write.
|
||||||
|
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
|
||||||
|
|
||||||
|
opts = testOpts([]string{
|
||||||
|
testApp, "freshen", "-q", testFlagBase, root, manifestPath,
|
||||||
|
}, fs)
|
||||||
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{testFileTxt, "added.txt"},
|
||||||
|
manifestPaths(t, fs, manifestPath))
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions {
|
|||||||
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
||||||
IncludeTimestamps: ctx.Bool("include-timestamps"),
|
IncludeTimestamps: ctx.Bool("include-timestamps"),
|
||||||
Fs: mfa.Fs,
|
Fs: mfa.Fs,
|
||||||
|
OutputPath: ctx.String("output"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set seed for deterministic UUID if provided
|
// Set seed for deterministic UUID if provided
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveManifestArg resolves the manifest path from CLI arguments.
|
// resolveManifestArg resolves the manifest path from CLI arguments.
|
||||||
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf/.index.mf.
|
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf.
|
||||||
// If no argument is given, the current directory is searched.
|
// If no argument is given, the current directory is searched.
|
||||||
func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) {
|
func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) {
|
||||||
if ctx.Args().Len() > 0 {
|
if ctx.Args().Len() > 0 {
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const (
|
|||||||
flagProgress = "progress"
|
flagProgress = "progress"
|
||||||
|
|
||||||
manifestArgsUsage = "[manifest file]"
|
manifestArgsUsage = "[manifest file]"
|
||||||
|
|
||||||
|
// defaultManifestName is the filename gen writes by default, the one
|
||||||
|
// looked for when a command is given a directory, and the one fetch
|
||||||
|
// appends to a directory URL.
|
||||||
|
defaultManifestName = "index.mf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnknownCommand indicates an unrecognized command argument.
|
// errUnknownCommand indicates an unrecognized command argument.
|
||||||
@@ -156,7 +161,7 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
|
|||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "output",
|
Name: "output",
|
||||||
Value: "./.index.mf",
|
Value: defaultManifestName,
|
||||||
Aliases: []string{"o"},
|
Aliases: []string{"o"},
|
||||||
Usage: "Specify output filename",
|
Usage: "Specify output filename",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const (
|
|||||||
testExistsFile = "exists.txt"
|
testExistsFile = "exists.txt"
|
||||||
testManifestPath = "/manifest.mf"
|
testManifestPath = "/manifest.mf"
|
||||||
testDataDir = "/data"
|
testDataDir = "/data"
|
||||||
|
// testDataManifestPath is a manifest kept inside the checked tree.
|
||||||
|
testDataManifestPath = testDataDir + "/index.mf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStatusString(t *testing.T) {
|
func TestStatusString(t *testing.T) {
|
||||||
@@ -464,7 +466,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
|
|||||||
manifestFiles := map[string][]byte{
|
manifestFiles := map[string][]byte{
|
||||||
testFile1: []byte("in manifest"),
|
testFile1: []byte("in manifest"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, "/data/.index.mf", manifestFiles)
|
createTestManifest(t, fs, testDataManifestPath, manifestFiles)
|
||||||
createFilesOnDisk(t, fs, map[string][]byte{
|
createFilesOnDisk(t, fs, map[string][]byte{
|
||||||
testFile1: []byte("in manifest"),
|
testFile1: []byte("in manifest"),
|
||||||
})
|
})
|
||||||
@@ -476,7 +478,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
|
|||||||
require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644))
|
require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644))
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker(&CheckerOptions{
|
||||||
ManifestPath: "/data/.index.mf",
|
ManifestPath: testDataManifestPath,
|
||||||
BasePath: testDataDir,
|
BasePath: testDataDir,
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
})
|
})
|
||||||
@@ -491,7 +493,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
|
|||||||
extras = append(extras, r)
|
extras = append(extras, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should only report extra.txt, not .hidden, .config/settings, or .index.mf
|
// Should only report extra.txt, not .hidden, .config/settings, or index.mf
|
||||||
for _, e := range extras {
|
for _, e := range extras {
|
||||||
t.Logf("extra: %s", e.Path)
|
t.Logf("extra: %s", e.Path)
|
||||||
}
|
}
|
||||||
@@ -656,7 +658,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
|
|||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testFile1: []byte("in manifest"),
|
testFile1: []byte("in manifest"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, "/data/.index.mf", files)
|
createTestManifest(t, fs, testDataManifestPath, files)
|
||||||
createFilesOnDisk(t, fs, files)
|
createFilesOnDisk(t, fs, files)
|
||||||
|
|
||||||
// Add dotfiles and manifest file on disk
|
// Add dotfiles and manifest file on disk
|
||||||
@@ -666,7 +668,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
|
|||||||
afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644))
|
afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644))
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker(&CheckerOptions{
|
||||||
ManifestPath: "/data/.index.mf",
|
ManifestPath: testDataManifestPath,
|
||||||
BasePath: testDataDir,
|
BasePath: testDataDir,
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
})
|
})
|
||||||
@@ -695,11 +697,11 @@ func TestFindExtraFilesSkipsManifestFile(t *testing.T) {
|
|||||||
files := map[string][]byte{
|
files := map[string][]byte{
|
||||||
testFile1: []byte("content"),
|
testFile1: []byte("content"),
|
||||||
}
|
}
|
||||||
createTestManifest(t, fs, "/data/index.mf", files)
|
createTestManifest(t, fs, testDataManifestPath, files)
|
||||||
createFilesOnDisk(t, fs, files)
|
createFilesOnDisk(t, fs, files)
|
||||||
|
|
||||||
chk, err := NewChecker(&CheckerOptions{
|
chk, err := NewChecker(&CheckerOptions{
|
||||||
ManifestPath: "/data/index.mf",
|
ManifestPath: testDataManifestPath,
|
||||||
BasePath: testDataDir,
|
BasePath: testDataDir,
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
})
|
})
|
||||||
|
|||||||
+35
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -57,6 +58,15 @@ type ScannerOptions struct {
|
|||||||
SigningOptions *SigningOptions
|
SigningOptions *SigningOptions
|
||||||
// Seed, if set, derives a deterministic UUID from this seed.
|
// Seed, if set, derives a deterministic UUID from this seed.
|
||||||
Seed string
|
Seed string
|
||||||
|
// OutputPath, if set, is the file the manifest will be written to.
|
||||||
|
// Enumeration leaves out that file and OutputPath + ".tmp", the temp
|
||||||
|
// file mfer gen writes first, if they exist when the scanner is
|
||||||
|
// created, however their paths are spelled (through a symlink, a hard
|
||||||
|
// link, or in different letter case), so neither a manifest being
|
||||||
|
// replaced nor a temp file left by an interrupted run is ever listed.
|
||||||
|
// Files are matched with os.SameFile, which only recognizes files on
|
||||||
|
// the operating system's filesystem.
|
||||||
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileEntry represents a file that has been enumerated.
|
// FileEntry represents a file that has been enumerated.
|
||||||
@@ -75,6 +85,8 @@ type Scanner struct {
|
|||||||
totalBytes FileSize // cached sum of all file sizes
|
totalBytes FileSize // cached sum of all file sizes
|
||||||
options *ScannerOptions
|
options *ScannerOptions
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
|
outputInfo fs.FileInfo // the existing output file; nil when there is none
|
||||||
|
tmpInfo fs.FileInfo // its leftover temp file; nil when there is none
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScanner creates a new Scanner with default options.
|
// NewScanner creates a new Scanner with default options.
|
||||||
@@ -93,11 +105,27 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner {
|
|||||||
fs = afero.NewOsFs()
|
fs = afero.NewOsFs()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Scanner{
|
s := &Scanner{
|
||||||
files: make([]*FileEntry, 0),
|
files: make([]*FileEntry, 0),
|
||||||
options: opts,
|
options: opts,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.OutputPath != "" {
|
||||||
|
// A file that cannot be stat'd, normally because it does not
|
||||||
|
// exist, needs no leaving out.
|
||||||
|
info, err := s.fs.Stat(opts.OutputPath)
|
||||||
|
if err == nil {
|
||||||
|
s.outputInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err = s.fs.Stat(opts.OutputPath + ".tmp")
|
||||||
|
if err == nil {
|
||||||
|
s.tmpInfo = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnumerateFile adds a single file to the scanner, calling stat() to get metadata.
|
// EnumerateFile adds a single file to the scanner, calling stat() to get metadata.
|
||||||
@@ -435,6 +463,12 @@ func (s *Scanner) enumerateFileWithInfo(
|
|||||||
info = realInfo
|
info = realInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neither the manifest being written nor its temp file is one of the
|
||||||
|
// files it lists. os.SameFile never matches a nil FileInfo.
|
||||||
|
if os.SameFile(info, s.outputInfo) || os.SameFile(info, s.tmpInfo) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
entry := &FileEntry{
|
entry := &FileEntry{
|
||||||
Path: RelFilePath(cleanPath),
|
Path: RelFilePath(cleanPath),
|
||||||
AbsPath: AbsFilePath(absPath),
|
AbsPath: AbsFilePath(absPath),
|
||||||
|
|||||||
Reference in New Issue
Block a user