fix(export): a resumed download can no longer splice two archives together

`serve_file` emitted no validator — no ETag, no Last-Modified — and ignored
If-Range entirely, while `resolve_export_file` re-reads `export_current` on
EVERY request and a download ticket survives 20 redemptions over 6 hours.

So: a guest's 500 MB Gallery.zip drops at 500 MB. The host takes a photo down
— epoch bumps, the rebuild lands, the old generation is pruned. The client
resumes with `Range: bytes=500000000-`. The ticket and session are both still
valid, the handler resolves the NEW archive, seeks 500 MB into a different
file of a different length, and streams. The client concatenates the halves
into a structurally corrupt ZIP. Nothing logs an error anywhere; a 404 would
have been the correct answer.

Now every response carries an ETag over the generation-stamped filename plus
the length, and a partial is served only against a matching If-Range. A Range
with no validator — curl -C -, wget -c, the Android download manager, all of
which resume blindly — gets the whole file instead. Restarting a download is a
cost; a corrupt keepsake is not recoverable.

Browsers send If-Range, so this is also the first release where their resume
works at all: with no validator to send, they simply refused to try.
This commit is contained in:
fabi
2026-08-12 19:10:45 +02:00
parent f403222200
commit 182e712a0e
2 changed files with 125 additions and 1 deletions

View File

@@ -0,0 +1,85 @@
/**
* A resumed keepsake download must never splice two different archives together.
*
* The download endpoint re-resolves `export_current` on EVERY request, and a download ticket
* outlives several redemptions. So the dangerous sequence was:
*
* guest's 500 MB download drops at 500 MB
* → host takes a photo down (epoch bumps, rebuild lands, old generation pruned)
* → client resumes with `Range: bytes=500000000-`
* → server seeks 500 MB into a DIFFERENT file of a different length and streams it
* → the client concatenates the two halves into a structurally corrupt ZIP
*
* Nothing anywhere logged an error. The archive is the one artifact the whole event exists to
* produce, so a partial is now served only against a matching `If-Range` validator.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Export — a resume cannot splice two archives', () => {
test('partial content requires a matching If-Range; a blind Range restarts instead', async ({
host,
}) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
await seedUpload(host.jwt, { caption: 'resumable' });
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: bearer,
});
expect(rel.status).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).zip?.status;
},
{ timeout: 45_000, intervals: [500] }
)
.toBe('done');
const mint = async () => {
const r = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
method: 'POST',
headers: bearer,
});
return (await r.json()).ticket as string;
};
const url = async () => `${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(await mint())}`;
// 1. The full download advertises a validator. Without one a browser will not even attempt a
// resume, so this header is what makes the feature work at all — and it is what the
// partial below is checked against.
const full = await fetch(await url());
expect(full.status).toBe(200);
const etag = full.headers.get('etag');
expect(etag, 'the archive must carry an ETag or no client can resume safely').toBeTruthy();
expect(full.headers.get('accept-ranges')).toBe('bytes');
// 2. A resume that PROVES continuity gets its partial.
const resumed = await fetch(await url(), {
headers: { Range: 'bytes=0-99', 'If-Range': etag! },
});
expect(resumed.status, 'a matching If-Range must still get 206').toBe(206);
expect(resumed.headers.get('content-range')).toMatch(/^bytes 0-99\/\d+$/);
// 3. A resume that cannot prove it — `curl -C -`, `wget -c`, the Android download manager —
// gets the whole file instead of a silently spliced one. Restarting a download is a cost;
// a corrupt keepsake is not recoverable.
const blind = await fetch(await url(), { headers: { Range: 'bytes=0-99' } });
expect(blind.status, 'a Range with no If-Range must NOT be served as a partial').toBe(200);
expect(blind.headers.get('content-range')).toBeNull();
// 4. And a stale validator — the exact case that used to splice — is refused a partial too.
const stale = await fetch(await url(), {
headers: { Range: 'bytes=0-99', 'If-Range': '"Gallery.some-other-event.99.zip-123"' },
});
expect(stale.status, 'an If-Range from a different generation must not get a partial').toBe(
200
);
});
});