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

@@ -511,6 +511,9 @@ pub async fn download_zip(
headers
.get(axum::http::header::RANGE)
.and_then(|v| v.to_str().ok()),
headers
.get(axum::http::header::IF_RANGE)
.and_then(|v| v.to_str().ok()),
)
.await
}
@@ -578,6 +581,9 @@ pub async fn download_html(
headers
.get(axum::http::header::RANGE)
.and_then(|v| v.to_str().ok()),
headers
.get(axum::http::header::IF_RANGE)
.and_then(|v| v.to_str().ok()),
)
.await
}
@@ -596,6 +602,7 @@ async fn serve_file(
filename: &str,
content_type: &str,
range_header: Option<&str>,
if_range_header: Option<&str>,
) -> Result<axum::response::Response, AppError> {
use crate::handlers::upload::{RangeSpec, parse_range};
use axum::body::Body;
@@ -613,6 +620,26 @@ async fn serve_file(
.len();
let disposition = format!("attachment; filename=\"{filename}\"");
// A validator that CHANGES when the archive does, so a resume cannot splice two generations.
//
// The on-disk name is `{prefix}.{event_id}.{epoch}.zip`, so it already identifies the exact
// generation; length distinguishes a rebuild at the same epoch. Together they are a strong
// validator.
//
// Why this matters: `resolve_export_file` re-reads `export_current` on EVERY request, and a
// download ticket outlives several redemptions. So a guest whose 500 MB download drops at
// 500 MB, while the host takes a photo down (epoch bumps, rebuild lands, the old generation is
// pruned), used to resume with `Range: bytes=500000000-` against a DIFFERENT FILE of a
// different length — and the server would happily seek 500 MB into it and stream. The client
// concatenated the two halves into a structurally corrupt ZIP, with nothing logged anywhere.
let etag = format!(
"\"{}-{len}\"",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename)
);
let base = |status: StatusCode| {
Response::builder()
.status(status)
@@ -621,9 +648,21 @@ async fn serve_file(
// Advertised on EVERY response, including the 200. A client only knows it may resume
// if the first (unranged) response says so.
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, etag.clone())
};
match parse_range(range_header, len) {
// Serve a partial ONLY when the client proves it is resuming the same bytes.
//
// `If-Range` matching our ETag is that proof. A client that sends `Range` with no `If-Range`
// at all (curl -C -, wget -c, most download managers) cannot be given a partial safely — it
// has no way to notice the archive changed underneath it — so it gets a 200 and starts over.
// Restarting a download is a cost; a silently corrupt keepsake is not recoverable. Browsers
// send `If-Range`, so the ordinary resume path is unaffected, and this is the first release
// where their resume works at all: without a validator they simply refused to try.
let resume_is_safe = if_range_header.is_some_and(|v| v.trim() == etag);
let effective_range = if resume_is_safe { range_header } else { None };
match parse_range(effective_range, len) {
RangeSpec::Full => base(StatusCode::OK)
.header(header::CONTENT_LENGTH, len)
.body(Body::from_stream(ReaderStream::new(file)))