Load the manifest check downloads from memory, not a temp file (closes #223)
check / check (push) Waiting to run
check / check (push) Waiting to run
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
This commit is contained in:
+27
-43
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-15
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+35
-15
@@ -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,12 +122,30 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
|
|||||||
fs = afero.NewOsFs()
|
fs = afero.NewOsFs()
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := NewManifestFromFile(&ManifestFromFileOptions{
|
var (
|
||||||
Path: opts.ManifestPath,
|
m *manifest
|
||||||
Fs: fs,
|
manifestInfo os.FileInfo
|
||||||
})
|
err error
|
||||||
if err != nil {
|
)
|
||||||
return nil, err
|
|
||||||
|
if opts.ManifestReader != nil {
|
||||||
|
m, err = NewManifestFromReader(opts.ManifestReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m, err = NewManifestFromFile(&ManifestFromFileOptions{
|
||||||
|
Path: opts.ManifestPath,
|
||||||
|
Fs: fs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user