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 at the same point, so the refusal
comes from the library. 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 is contained in:
2026-10-07 09:49:54 +00:00
parent 2a174e3ba2
commit 1fc69d5115
7 changed files with 139 additions and 9 deletions
+4 -1
View File
@@ -112,7 +112,10 @@ func (mfa *CLIApp) fetchManifestToTemp(
}
tmpPath := tmpFile.Name()
_, cpErr := io.Copy(tmpFile, rc)
// One byte past mfer.MaxManifestSize is enough for loading the
// manifest to refuse one that is too large.
_, cpErr := io.Copy(tmpFile, io.LimitReader(rc, mfer.MaxManifestSize+1))
_ = rc.Close()
_ = tmpFile.Close()
+8 -4
View File
@@ -433,13 +433,15 @@ func fetchManifest(
// Read the whole manifest before parsing it, so that a connection
// lost partway through is retried rather than reported as a bad
// manifest.
// manifest. One byte past mfer.MaxManifestSize is enough for
// mfer.NewManifestFromReader to refuse a manifest that 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, mfer.MaxManifestSize+1))
return readErr
})
@@ -910,8 +912,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()
+74
View File
@@ -441,6 +441,80 @@ func TestFetchSizeMismatch(t *testing.T) {
"temp file should be cleaned up on size mismatch")
}
// TestFetchStopsReadingFilePastListedSize serves 1 MiB for a file the
// manifest lists 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) {
content := []byte("sixteen bytes...")
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(
manifestOf(t, map[string][]byte{testFileTxt: content})))
require.NoError(t, err)
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(make([]byte, 1<<20))
}))
defer server.Close()
chdirTemp(t)
err = downloadFile(context.Background(), testClient(),
server.URL+"/"+testFileTxt, ".", testFileTxt, manifest.Files()[0], nil)
require.ErrorIs(t, err, errSizeMismatch)
assert.EqualError(t, err, "size mismatch: expected 16 bytes, got 17")
assert.NoFileExists(t, tempPathFor(testFileTxt))
}
// 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
}
// TestManifestDownloadStopsPastLimit serves a manifest 32 MiB larger than
// mfer.MaxManifestSize to fetch and to check. Each must stop reading it
// once it passes the maximum, fail with an error naming the maximum and
// leave no temp file. The connection's buffers hold less than 32 MiB, so a
// client that stops reading leaves the server unable to send all of it.
//
//nolint:paralleltest // sets TMPDIR, where check writes the manifest
func TestManifestDownloadStopsPastLimit(t *testing.T) {
size := mfer.MaxManifestSize + 32<<20
limit := strconv.FormatInt(mfer.MaxManifestSize, 10)
tmpDir := t.TempDir()
t.Setenv("TMPDIR", tmpDir)
for _, command := range []string{cmdFetch, cmdCheck} {
var sent atomic.Int64
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n, _ := io.CopyN(w, zeros{}, size)
sent.Store(n)
}))
opts := testOpts([]string{testApp, command, "-q", server.URL + "/index.mf"},
afero.NewOsFs())
assert.Equal(t, 1, runCLI(opts), command)
assert.Contains(t, testStderr(t, opts),
"maximum allowed size of "+limit+" bytes", command)
server.Close()
assert.Less(t, sent.Load(), size, "%s read the whole manifest", command)
}
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