Limit how much fetch and check read for a manifest or a file (closes #168)
check / check (push) Waiting to run

NewManifestFromReader reads at most one byte past MaxManifestSize, a new
constant of 258 MiB: the 256 MiB decompressed limit grown by zstd's worst
case of 1/256, plus 1 MiB for the signature, the signing key and the
other outer fields. It refuses a larger manifest. fetch, and check given
a URL, stop downloading a manifest one byte past the same size and report
it as too large; tests lower that size to keep their memory small. fetch
stops reading a file one byte past its listed size, so a longer body ends
in the size mismatch at once instead of filling the disk. docs/FORMAT.md
states the limit and gives the decompressed limit as 256 MiB, the size
the code uses.

Model: opus-5-5
This commit was merged in pull request #172.
This commit is contained in:
2026-10-07 14:25:45 +02:00
parent 0762a728d4
commit f663f4242d
10 changed files with 176 additions and 20 deletions
+8 -1
View File
@@ -112,10 +112,17 @@ func (mfa *CLIApp) fetchManifestToTemp(
}
tmpPath := tmpFile.Name()
_, cpErr := io.Copy(tmpFile, rc)
// Copying stops one byte past mfa.maxManifestSize, which is enough to
// tell that the manifest is too large.
written, cpErr := io.Copy(tmpFile, io.LimitReader(rc, mfa.maxManifestSize+1))
_ = rc.Close()
_ = tmpFile.Close()
if cpErr == nil && written > mfa.maxManifestSize {
cpErr = fmt.Errorf("%w of %d bytes", errManifestTooLarge, mfa.maxManifestSize)
}
if cpErr != nil {
_ = mfa.Fs.Remove(tmpPath)
+10 -8
View File
@@ -5,6 +5,7 @@ import (
"os"
"github.com/spf13/afero"
"sneak.berlin/go/mfer/mfer"
)
// NoColor disables colored output when set. Automatically true if the
@@ -56,14 +57,15 @@ func Run(appname, version, gitrev string) int {
// RunWithOptions creates and runs the CLI application with the given options.
func RunWithOptions(opts *RunOptions) int {
m := &CLIApp{
appname: opts.Appname,
version: opts.Version,
gitrev: opts.Gitrev,
exitCode: 0,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
Fs: opts.Fs,
appname: opts.Appname,
version: opts.Version,
gitrev: opts.Gitrev,
exitCode: 0,
maxManifestSize: mfer.MaxManifestSize,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
Fs: opts.Fs,
}
m.run(opts.Args)
+15 -6
View File
@@ -361,7 +361,7 @@ func (mfa *CLIApp) fetchManifestOperation(
firstDelay: firstRetryDelay,
}
manifestData, files, err := fetchManifest(ctx, cmd, client, manifestURL)
manifestData, files, err := mfa.fetchManifest(ctx, cmd, client, manifestURL)
if err != nil {
return err
}
@@ -426,20 +426,22 @@ func (mfa *CLIApp) fetchManifestOperation(
// that lists a file where fetch writes another or a mode outside 0777. It
// returns the manifest as downloaded, to be saved once the files are in
// place, and the files it lists.
func fetchManifest(
func (mfa *CLIApp) fetchManifest(
ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string,
) ([]byte, []*mfer.MFFilePath, error) {
log.Infof("fetching manifest from %s", manifestURL)
// Read the whole manifest before parsing it, so that a connection
// lost partway through is retried rather than reported as a bad
// manifest.
// manifest. Reading stops one byte past mfa.maxManifestSize, which is
// enough to tell that the manifest is too large.
var manifestData []byte
err := client.get(ctx, manifestURL, func(resp *http.Response) error {
var readErr error
manifestData, readErr = io.ReadAll(resp.Body)
manifestData, readErr = io.ReadAll(
io.LimitReader(resp.Body, mfa.maxManifestSize+1))
return readErr
})
@@ -447,6 +449,11 @@ func fetchManifest(
return nil, nil, fmt.Errorf("failed to fetch manifest: %w", err)
}
if int64(len(manifestData)) > mfa.maxManifestSize {
return nil, nil, fmt.Errorf("failed to fetch manifest: %w of %d bytes",
errManifestTooLarge, mfa.maxManifestSize)
}
// Parse manifest
//nolint:contextcheck // mfer loads a manifest without a context
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
@@ -908,8 +915,10 @@ func saveResponse(
progress: progress,
}
// Copy content while hashing and reporting progress
written, copyErr := io.Copy(pw, resp.Body)
// Copy content while hashing and reporting progress. One byte past
// the listed size is enough for finishDownload to report a size
// mismatch.
written, copyErr := io.Copy(pw, io.LimitReader(resp.Body, expectedSize+1))
// Close file before checking errors (to flush writes)
closeErr := out.Close()
+70
View File
@@ -26,6 +26,7 @@ import (
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
urfcli "github.com/urfave/cli/v3"
"google.golang.org/protobuf/proto"
"sneak.berlin/go/mfer/mfer"
)
@@ -441,6 +442,75 @@ func TestFetchSizeMismatch(t *testing.T) {
"temp file should be cleaned up on size mismatch")
}
// zeros is an io.Reader of zero bytes without end.
type zeros struct{}
func (zeros) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
// TestFetchStopsReadingFilePastListedSize serves a body that never ends
// for a file listed at 16 bytes. fetch must stop reading one byte past
// the listed size, report the size mismatch and remove its temp file.
//
//nolint:paralleltest // changes the process-global working directory
func TestFetchStopsReadingFilePastListedSize(t *testing.T) {
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.Copy(w, zeros{})
}))
defer server.Close()
chdirTemp(t)
err := downloadFile(context.Background(), testClient(),
server.URL+"/"+testFileTxt, ".", testFileTxt,
&mfer.MFFilePath{Path: testFileTxt, Size: 16}, nil)
require.ErrorIs(t, err, errSizeMismatch)
require.EqualError(t, err, "size mismatch: expected 16 bytes, got 17")
assert.NoFileExists(t, tempPathFor(testFileTxt))
}
// TestManifestDownloadStopsAtLimit serves a manifest that never ends to
// 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
// naming it and leave no temp file.
func TestManifestDownloadStopsAtLimit(t *testing.T) {
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.Copy(w, zeros{})
}))
defer server.Close()
tmpDir := t.TempDir()
t.Setenv("TMPDIR", tmpDir)
mfa := &CLIApp{Fs: afero.NewOsFs(), maxManifestSize: 64 << 10}
fetch := mfa.fetchCommand()
fetch.Action = mfa.fetchManifestOperation
check := mfa.checkCommand()
check.Action = mfa.checkManifestOperation
for _, cmd := range []*urfcli.Command{fetch, check} {
// Both operations log to the process-global logger.
err := runLocked(func() error {
return cmd.Run(context.Background(),
[]string{cmd.Name, server.URL + "/index.mf"})
})
require.ErrorIs(t, err, errManifestTooLarge, cmd.Name)
require.ErrorContains(t, err,
"maximum allowed size of 65536 bytes", cmd.Name)
}
leftover, err := os.ReadDir(tmpDir)
require.NoError(t, err)
assert.Empty(t, leftover)
}
//nolint:paralleltest // changes the process-global working directory
func TestFetchProgress(t *testing.T) {
// Create source filesystem with a larger test file
+4
View File
@@ -23,6 +23,10 @@ const manifestFetchTimeout = 30 * time.Second
// its message.
var errHTTPStatus = errors.New("HTTP")
// errManifestTooLarge indicates a manifest download that passed
// CLIApp.maxManifestSize.
var errManifestTooLarge = errors.New("manifest exceeds maximum allowed size")
// isHTTPURL returns true if the string starts with http:// or https://.
func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
+4
View File
@@ -59,6 +59,10 @@ type CLIApp struct {
exitCode int
app *cli.Command
// maxManifestSize is the most of a manifest that fetch, and check
// given a URL, download: mfer.MaxManifestSize, which tests lower.
maxManifestSize int64
Stdin io.Reader // Standard input stream
Stdout io.Writer // Standard output stream for normal output
Stderr io.Writer // Standard error stream for diagnostics