1 Commits
Author SHA1 Message Date
sneak 59b3cbd1dd Load the manifest check downloads from memory, not a temp file (closes #223)
check / check (push) Canceled after 0s
check given a URL wrote the manifest it downloaded to a file in the temp
directory before loading it, so it failed wherever that directory is
missing or cannot be written, such as the scratch image script/cibuild
builds. It now keeps the manifest in memory, as fetch, list and export
do. NewChecker reads a manifest handed to it as a reader through the new
CheckerOptions.ManifestReader field, in place of ManifestPath, because it
otherwise reads the manifest from the filesystem holding the files it
checks. fetch now hands its downloaded manifest to the Checker the same
way, not through an in-memory filesystem.

Model: opus-5-5
2026-10-15 18:48:23 +00:00
6 changed files with 125 additions and 82 deletions
+27 -43
View File
@@ -2,6 +2,7 @@
package cli package cli
import ( import (
"bytes"
"context" "context"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -94,42 +95,29 @@ func findManifest(fs afero.Fs, dir string) (string, error) {
return path, nil return path, nil
} }
// fetchManifestToTemp downloads a manifest URL to a temporary file and // downloadManifest downloads the manifest at url into memory. It refuses a
// returns the temp file path. The caller is responsible for removing it. // manifest larger than mfa.maxManifestSize.
func (mfa *CLIApp) fetchManifestToTemp( func (mfa *CLIApp) downloadManifest(ctx context.Context, url string) ([]byte, error) {
ctx context.Context, url string, rc, err := mfa.openManifestReader(ctx, url)
) (string, error) { if err != nil {
rc, fetchErr := mfa.openManifestReader(ctx, url) return nil, err
if fetchErr != nil {
return "", fetchErr
} }
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf") defer func() { _ = rc.Close() }()
if tmpErr != nil {
_ = rc.Close()
return "", tmpErr // Reading stops one byte past mfa.maxManifestSize, which is enough to
}
tmpPath := tmpFile.Name()
// Copying stops one byte past mfa.maxManifestSize, which is enough to
// tell that the manifest is too large. // tell that the manifest is too large.
written, cpErr := io.Copy(tmpFile, io.LimitReader(rc, mfa.maxManifestSize+1)) manifestData, err := io.ReadAll(io.LimitReader(rc, mfa.maxManifestSize+1))
_ = rc.Close() if err != nil {
_ = tmpFile.Close() return nil, fmt.Errorf("download manifest: %w", err)
if cpErr == nil && written > mfa.maxManifestSize {
cpErr = fmt.Errorf("%w of %d bytes", errManifestTooLarge, mfa.maxManifestSize)
} }
if cpErr != nil { if int64(len(manifestData)) > mfa.maxManifestSize {
_ = mfa.Fs.Remove(tmpPath) return nil, fmt.Errorf("download manifest: %w of %d bytes",
errManifestTooLarge, mfa.maxManifestSize)
return "", fmt.Errorf("download manifest: %w", cpErr)
} }
return tmpPath, nil return manifestData, nil
} }
// verifyRequiredSigner enforces the --require-signature fingerprint // verifyRequiredSigner enforces the --require-signature fingerprint
@@ -299,32 +287,28 @@ 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) basePath := resolveBasePath(cmd, manifestPath)
// URL manifests need to be downloaded to a temp file for the checker opts := &mfer.CheckerOptions{BasePath: basePath, Fs: mfa.Fs}
// A manifest given by URL is downloaded into memory and read from
// there.
if isHTTPURL(manifestPath) { if isHTTPURL(manifestPath) {
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath) manifestData, downloadErr := mfa.downloadManifest(ctx, manifestPath)
if tmpErr != nil { if downloadErr != nil {
return tmpErr return downloadErr
} }
defer func() { _ = mfa.Fs.Remove(tmpPath) }() opts.ManifestReader = bytes.NewReader(manifestData)
} else {
manifestPath = tmpPath opts.ManifestPath = manifestPath
} }
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)
// Create checker chk, err := mfer.NewChecker(opts)
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
ManifestPath: manifestPath,
BasePath: basePath,
Fs: mfa.Fs,
})
if err != nil { if err != nil {
return fmt.Errorf("load manifest: %w", err) return fmt.Errorf("load manifest: %w", err)
} }
+23
View File
@@ -1167,6 +1167,29 @@ func TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory(t *testing.T) {
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
} }
// TestCheckURLManifestWithoutTempDirectory checks the tree under --base
// against a manifest given by URL while TMPDIR names a directory that does
// not exist. check keeps the manifest it downloads in memory, so it passes.
// Setting TMPDIR keeps it from running in parallel with other tests.
func TestCheckURLManifestWithoutTempDirectory(t *testing.T) {
files := map[string][]byte{testFileTxt: []byte("hello")}
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
defer server.Close()
base := t.TempDir()
require.NoError(t,
os.WriteFile(filepath.Join(base, testFileTxt), files[testFileTxt], 0o600))
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
opts := testOpts([]string{
testApp, cmdCheck, testFlagNoExtra, testFlagBase, base,
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
+3 -14
View File
@@ -19,7 +19,6 @@ import (
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/multiformats/go-multihash" "github.com/multiformats/go-multihash"
"github.com/spf13/afero"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"sneak.berlin/go/mfer/internal/log" "sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer" "sneak.berlin/go/mfer/mfer"
@@ -554,22 +553,12 @@ func checkNoNameClash(files []*mfer.MFFilePath) error {
} }
// verifyFetchedSigner enforces --require-signature on the fetched manifest // verifyFetchedSigner enforces --require-signature on the fetched manifest
// exactly as check does. verifyRequiredSigner takes a Checker, which loads // exactly as check does. The Checker it builds is used only for the
// its manifest from a file, so the manifest is handed to it as a file in // manifest's signature, so its base path is never read.
// memory.
func verifyFetchedSigner(manifestData []byte, requiredSigner string) error { func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
memFs := afero.NewMemMapFs()
manifestPath := "/" + defaultManifestName
err := afero.WriteFile(memFs, manifestPath, manifestData, filePerms)
if err != nil {
return err
}
chk, err := mfer.NewChecker(&mfer.CheckerOptions{ chk, err := mfer.NewChecker(&mfer.CheckerOptions{
ManifestPath: manifestPath, ManifestReader: bytes.NewReader(manifestData),
BasePath: "/", BasePath: "/",
Fs: memFs,
}) })
if err != nil { if err != nil {
return fmt.Errorf("load manifest: %w", err) return fmt.Errorf("load manifest: %w", err)
+4 -9
View File
@@ -489,18 +489,17 @@ func TestFetchStopsReadingFilePastListedSize(t *testing.T) {
// TestManifestDownloadStopsAtLimit serves a manifest that never ends to // TestManifestDownloadStopsAtLimit serves a manifest that never ends to
// fetch and to check, with the most they download of a manifest lowered // fetch and to check, with the most they download of a manifest lowered
// to 64 KiB. Each must stop reading at that limit, fail with an error // to 64 KiB. Each must stop reading at that limit and fail with an error
// naming it and leave no temp file. // naming it.
func TestManifestDownloadStopsAtLimit(t *testing.T) { func TestManifestDownloadStopsAtLimit(t *testing.T) {
t.Parallel()
server := httptest.NewServer( server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.Copy(w, zeros{}) _, _ = io.Copy(w, zeros{})
})) }))
defer server.Close() defer server.Close()
tmpDir := t.TempDir()
t.Setenv("TMPDIR", tmpDir)
mfa := &CLIApp{Fs: afero.NewOsFs(), maxManifestSize: 64 << 10} mfa := &CLIApp{Fs: afero.NewOsFs(), maxManifestSize: 64 << 10}
fetch := mfa.fetchCommand() fetch := mfa.fetchCommand()
@@ -520,10 +519,6 @@ func TestManifestDownloadStopsAtLimit(t *testing.T) {
"download manifest: file exceeds maximum allowed size of 65536 bytes", "download manifest: file exceeds maximum allowed size of 65536 bytes",
cmd.Name) cmd.Name)
} }
leftover, err := os.ReadDir(tmpDir)
require.NoError(t, err)
assert.Empty(t, leftover)
} }
//nolint:paralleltest // changes the process-global working directory //nolint:paralleltest // changes the process-global working directory
+30 -10
View File
@@ -80,7 +80,8 @@ type Checker struct {
// manifestPaths is a set of paths in the manifest for quick lookup // manifestPaths is a set of paths in the manifest for quick lookup
manifestPaths map[RelFilePath]struct{} manifestPaths map[RelFilePath]struct{}
// manifestInfo is the manifest file, which FindExtraFiles leaves out, // manifestInfo is the manifest file, which FindExtraFiles leaves out,
// matched with os.SameFile. // matched with os.SameFile. It is nil for a manifest read from
// CheckerOptions.ManifestReader.
manifestInfo os.FileInfo manifestInfo os.FileInfo
// signature info from the manifest // signature info from the manifest
signature []byte signature []byte
@@ -90,8 +91,13 @@ type Checker struct {
// CheckerOptions configures a Checker. // CheckerOptions configures a Checker.
type CheckerOptions struct { type CheckerOptions struct {
// ManifestPath is the manifest file to check against (required). // ManifestPath is the manifest file to check against. It is required
// unless ManifestReader is given.
ManifestPath string ManifestPath string
// ManifestReader, if not nil, is read for the manifest to check against
// in place of ManifestPath, such as a manifest downloaded into memory.
// FindExtraFiles then has no manifest file to leave out.
ManifestReader io.Reader
// BasePath is the directory relative to which manifest paths are // BasePath is the directory relative to which manifest paths are
// resolved (required). // resolved (required).
BasePath string BasePath string
@@ -100,9 +106,10 @@ type CheckerOptions struct {
} }
// NewChecker creates a new Checker with the given options. It returns an // NewChecker creates a new Checker with the given options. It returns an
// error if opts is nil or either path is empty. // error if opts is nil, the base path is empty, or neither a manifest path
// nor a manifest reader is given.
func NewChecker(opts *CheckerOptions) (*Checker, error) { func NewChecker(opts *CheckerOptions) (*Checker, error) {
if opts == nil || opts.ManifestPath == "" { if opts == nil || (opts.ManifestPath == "" && opts.ManifestReader == nil) {
return nil, errManifestPathEmpty return nil, errManifestPathEmpty
} }
@@ -115,7 +122,19 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
fs = afero.NewOsFs() fs = afero.NewOsFs()
} }
m, err := NewManifestFromFile(&ManifestFromFileOptions{ var (
m *manifest
manifestInfo os.FileInfo
err error
)
if opts.ManifestReader != nil {
m, err = NewManifestFromReader(opts.ManifestReader)
if err != nil {
return nil, err
}
} else {
m, err = NewManifestFromFile(&ManifestFromFileOptions{
Path: opts.ManifestPath, Path: opts.ManifestPath,
Fs: fs, Fs: fs,
}) })
@@ -123,6 +142,12 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
return nil, err return nil, err
} }
manifestInfo, err = fs.Stat(opts.ManifestPath)
if err != nil {
return nil, err
}
}
abs, err := filepath.Abs(opts.BasePath) abs, err := filepath.Abs(opts.BasePath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -135,11 +160,6 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
manifestPaths[RelFilePath(f.GetPath())] = struct{}{} manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
} }
manifestInfo, err := fs.Stat(opts.ManifestPath)
if err != nil {
return nil, err
}
return &Checker{ return &Checker{
basePath: AbsFilePath(abs), basePath: AbsFilePath(abs),
files: files, files: files,
+32
View File
@@ -189,6 +189,38 @@ func TestNewCheckerNilFsUsesOsFs(t *testing.T) {
assert.Equal(t, StatusOK, (<-results).Status) assert.Equal(t, StatusOK, (<-results).Status)
} }
// TestNewCheckerReadsManifestFromReader gives NewChecker the manifest as a
// reader and no manifest path, on a filesystem that holds only the files it
// lists: it checks those files and finds nothing unlisted.
func TestNewCheckerReadsManifestFromReader(t *testing.T) {
t.Parallel()
files := map[string][]byte{testFile1: []byte("hello")}
manifestFs := afero.NewMemMapFs()
createTestManifest(t, manifestFs, testManifestPath, files)
manifestData, err := afero.ReadFile(manifestFs, testManifestPath)
require.NoError(t, err)
fs := afero.NewMemMapFs()
createFilesOnDisk(t, fs, files)
chk, err := NewChecker(&CheckerOptions{
ManifestReader: bytes.NewReader(manifestData),
BasePath: testDataDir,
Fs: fs,
})
require.NoError(t, err)
results := make(chan Result, 1)
require.NoError(t, chk.Check(context.Background(), results, nil))
assert.Equal(t, Result{Path: testFile1, Status: StatusOK}, <-results)
extras := make(chan Result, len(files))
require.NoError(t, chk.FindExtraFiles(context.Background(), extras))
assert.Empty(t, extras)
}
func TestCheckerFileCountAndTotalBytes(t *testing.T) { func TestCheckerFileCountAndTotalBytes(t *testing.T) {
t.Parallel() t.Parallel()