| 50 |
50 |
|
/// multi-gigabyte URL in the manifest is a mistake, not a big file.
|
| 51 |
51 |
|
const MAX_ASSET_BYTES: u64 = 64 * 1024 * 1024;
|
| 52 |
52 |
|
|
|
53 |
+ |
/// How many times to fetch one asset before believing a digest mismatch.
|
|
54 |
+ |
///
|
|
55 |
+ |
/// A mismatch has two causes and they want opposite responses: the asset really
|
|
56 |
+ |
/// changed at the source (stop, re-check the licence), or this particular
|
|
57 |
+ |
/// response was not the asset (retry, and it comes back right). Build 60 on
|
|
58 |
+ |
/// 2026-08-19 was the second: seventeen pinned assets mismatched in one burst
|
|
59 |
+ |
/// and every one of them re-fetched byte-identical to its pin afterwards, from
|
|
60 |
+ |
/// the same machine. One retry separates the two cheaply.
|
|
61 |
+ |
const FETCH_ATTEMPTS: u32 = 3;
|
|
62 |
+ |
|
| 53 |
63 |
|
/// One curated file: where it comes from, what it is, and who to credit.
|
| 54 |
64 |
|
#[derive(Debug, Clone, Deserialize)]
|
| 55 |
65 |
|
pub struct Asset {
|
| 297 |
307 |
|
}
|
| 298 |
308 |
|
|
| 299 |
309 |
|
let url = asset.url.as_deref().unwrap_or_default().trim();
|
|
310 |
+ |
let mut last = String::new();
|
|
311 |
+ |
for attempt in 1..=FETCH_ATTEMPTS {
|
|
312 |
+ |
match fetch_once(client, asset, url).await {
|
|
313 |
+ |
Ok(bytes) => {
|
|
314 |
+ |
if let Err(e) = std::fs::write(&cached, &bytes) {
|
|
315 |
+ |
tracing::debug!(id = %asset.id, error = ?e, "example seed: could not cache asset");
|
|
316 |
+ |
}
|
|
317 |
+ |
tracing::info!(id = %asset.id, bytes = bytes.len(), "example seed: fetched media asset");
|
|
318 |
+ |
return Ok(bytes);
|
|
319 |
+ |
}
|
|
320 |
+ |
Err(Fetch::Fatal(reason)) => return Err(reason),
|
|
321 |
+ |
Err(Fetch::Retryable(reason)) => {
|
|
322 |
+ |
tracing::warn!(
|
|
323 |
+ |
id = %asset.id,
|
|
324 |
+ |
attempt,
|
|
325 |
+ |
of = FETCH_ATTEMPTS,
|
|
326 |
+ |
reason = %reason,
|
|
327 |
+ |
"example seed: asset fetch failed; retrying"
|
|
328 |
+ |
);
|
|
329 |
+ |
last = reason;
|
|
330 |
+ |
}
|
|
331 |
+ |
}
|
|
332 |
+ |
}
|
|
333 |
+ |
Err(format!(
|
|
334 |
+ |
"{last} (unchanged over {FETCH_ATTEMPTS} attempts, so this is not a one-off bad response)"
|
|
335 |
+ |
))
|
|
336 |
+ |
}
|
|
337 |
+ |
|
|
338 |
+ |
/// Why one attempt failed, and whether another attempt could do better.
|
|
339 |
+ |
enum Fetch {
|
|
340 |
+ |
/// Nothing about trying again would help: the URL is wrong, or the body is
|
|
341 |
+ |
/// over the ceiling.
|
|
342 |
+ |
Fatal(String),
|
|
343 |
+ |
/// The origin may answer differently next time — a transport error, a 5xx or
|
|
344 |
+ |
/// 429, a body that is not the asset, or bytes that miss the pinned digest.
|
|
345 |
+ |
Retryable(String),
|
|
346 |
+ |
}
|
|
347 |
+ |
|
|
348 |
+ |
/// One HTTP attempt at an asset, verified against the manifest.
|
|
349 |
+ |
async fn fetch_once(client: &reqwest::Client, asset: &Asset, url: &str) -> Result<Vec<u8>, Fetch> {
|
| 300 |
350 |
|
let response = client
|
| 301 |
351 |
|
.get(url)
|
| 302 |
352 |
|
.send()
|
| 303 |
353 |
|
.await
|
| 304 |
|
- |
.map_err(|e| format!("fetching {url}: {e}"))?;
|
| 305 |
|
- |
if !response.status().is_success() {
|
| 306 |
|
- |
return Err(format!("fetching {url}: HTTP {}", response.status()));
|
|
354 |
+ |
.map_err(|e| Fetch::Retryable(format!("fetching {url}: {e}")))?;
|
|
355 |
+ |
let status = response.status();
|
|
356 |
+ |
if !status.is_success() {
|
|
357 |
+ |
let reason = format!("fetching {url}: HTTP {status}");
|
|
358 |
+ |
// 5xx and 429 are the origin having a bad moment; a 404 or a 403 is a
|
|
359 |
+ |
// fact about the manifest and retrying only slows the failure down.
|
|
360 |
+ |
return Err(if status.is_server_error() || status.as_u16() == 429 {
|
|
361 |
+ |
Fetch::Retryable(reason)
|
|
362 |
+ |
} else {
|
|
363 |
+ |
Fetch::Fatal(reason)
|
|
364 |
+ |
});
|
|
365 |
+ |
}
|
|
366 |
+ |
// A CDN error page, a challenge, or a consent interstitial is a 200 with a
|
|
367 |
+ |
// non-empty body, and without this it reads as "the asset changed at the
|
|
368 |
+ |
// source" — which sends whoever is holding the red build off to re-check a
|
|
369 |
+ |
// licence that never moved.
|
|
370 |
+ |
//
|
|
371 |
+ |
// The test is deliberately "did we get a DOCUMENT where media was declared",
|
|
372 |
+ |
// not "does the type equal media_type". Content types for the same bytes
|
|
373 |
+ |
// legitimately vary — Wikimedia serves `application/ogg` for files this
|
|
374 |
+ |
// manifest declares `audio/ogg`, which is correct on both ends — so an
|
|
375 |
+ |
// equality check would fail good fetches. Nobody's JPEG is ever text/html.
|
|
376 |
+ |
if let Some(got) = response
|
|
377 |
+ |
.headers()
|
|
378 |
+ |
.get(reqwest::header::CONTENT_TYPE)
|
|
379 |
+ |
.and_then(|v| v.to_str().ok())
|
|
380 |
+ |
.map(|v| v.split(';').next().unwrap_or(v).trim().to_ascii_lowercase())
|
|
381 |
+ |
&& is_document(&got)
|
|
382 |
+ |
&& !is_document(&asset.media_type.to_ascii_lowercase())
|
|
383 |
+ |
{
|
|
384 |
+ |
return Err(Fetch::Retryable(format!(
|
|
385 |
+ |
"fetching {url}: the origin answered with {got} where the manifest \
|
|
386 |
+ |
declares {}, so this is a page about the asset rather than the \
|
|
387 |
+ |
asset. Its digest says nothing about whether the asset changed.",
|
|
388 |
+ |
asset.media_type
|
|
389 |
+ |
)));
|
| 307 |
390 |
|
}
|
| 308 |
391 |
|
// Refuse an oversized body before buffering it, when the server declares one.
|
| 309 |
392 |
|
if let Some(len) = response.content_length()
|
| 310 |
393 |
|
&& len > MAX_ASSET_BYTES
|
| 311 |
394 |
|
{
|
| 312 |
|
- |
return Err(format!(
|
|
395 |
+ |
return Err(Fetch::Fatal(format!(
|
| 313 |
396 |
|
"fetching {url}: {len} bytes exceeds the {MAX_ASSET_BYTES}-byte asset ceiling"
|
| 314 |
|
- |
));
|
|
397 |
+ |
)));
|
| 315 |
398 |
|
}
|
| 316 |
399 |
|
let bytes = response
|
| 317 |
400 |
|
.bytes()
|
| 318 |
401 |
|
.await
|
| 319 |
|
- |
.map_err(|e| format!("reading {url}: {e}"))?
|
|
402 |
+ |
.map_err(|e| Fetch::Retryable(format!("reading {url}: {e}")))?
|
| 320 |
403 |
|
.to_vec();
|
| 321 |
404 |
|
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
| 322 |
|
- |
return Err(format!(
|
|
405 |
+ |
return Err(Fetch::Fatal(format!(
|
| 323 |
406 |
|
"fetching {url}: {} bytes exceeds the {MAX_ASSET_BYTES}-byte asset ceiling",
|
| 324 |
407 |
|
bytes.len()
|
| 325 |
|
- |
));
|
|
408 |
+ |
)));
|
| 326 |
409 |
|
}
|
| 327 |
410 |
|
if bytes.is_empty() {
|
| 328 |
|
- |
return Err(format!("fetching {url}: empty body"));
|
|
411 |
+ |
return Err(Fetch::Retryable(format!("fetching {url}: empty body")));
|
| 329 |
412 |
|
}
|
| 330 |
413 |
|
|
| 331 |
414 |
|
let got = digest_hex(&bytes);
|
| 332 |
415 |
|
match asset.sha256.as_deref().map(str::trim) {
|
| 333 |
416 |
|
Some(want) if !want.is_empty() => {
|
| 334 |
417 |
|
if !got.eq_ignore_ascii_case(want) {
|
| 335 |
|
- |
return Err(format!(
|
|
418 |
+ |
return Err(Fetch::Retryable(format!(
|
| 336 |
419 |
|
"digest mismatch for {url}: manifest pins {want}, fetched {got}. \
|
| 337 |
|
- |
The asset changed at the source; re-check the licence before repinning."
|
| 338 |
|
- |
));
|
|
420 |
+ |
Either the asset changed at the source (re-check the licence \
|
|
421 |
+ |
before repinning) or this response was not the asset."
|
|
422 |
+ |
)));
|
| 339 |
423 |
|
}
|
| 340 |
424 |
|
}
|
| 341 |
425 |
|
_ => tracing::warn!(
|
| 345 |
429 |
|
),
|
| 346 |
430 |
|
}
|
| 347 |
431 |
|
|
| 348 |
|
- |
if let Err(e) = std::fs::write(&cached, &bytes) {
|
| 349 |
|
- |
tracing::debug!(id = %asset.id, error = ?e, "example seed: could not cache asset");
|
| 350 |
|
- |
}
|
| 351 |
|
- |
tracing::info!(id = %asset.id, bytes = bytes.len(), "example seed: fetched media asset");
|
| 352 |
432 |
|
Ok(bytes)
|
| 353 |
433 |
|
}
|
| 354 |
434 |
|
|
|
435 |
+ |
/// Whether a lowercased content type names a document rather than a media file.
|
|
436 |
+ |
///
|
|
437 |
+ |
/// These are the shapes an origin answers with when it is telling you something
|
|
438 |
+ |
/// instead of giving you the bytes: an error page, a bot challenge, a JSON API
|
|
439 |
+ |
/// error. Anything else — including container types like `application/ogg` and
|
|
440 |
+ |
/// the `application/octet-stream` a plain file server falls back to — is
|
|
441 |
+ |
/// treated as media and left to the digest to judge.
|
|
442 |
+ |
fn is_document(content_type: &str) -> bool {
|
|
443 |
+ |
content_type.starts_with("text/")
|
|
444 |
+ |
|| matches!(
|
|
445 |
+ |
content_type,
|
|
446 |
+ |
"application/json" | "application/xml" | "application/xhtml+xml"
|
|
447 |
+ |
)
|
|
448 |
+ |
}
|
|
449 |
+ |
|
| 355 |
450 |
|
/// Lowercase hex SHA-256.
|
| 356 |
451 |
|
fn digest_hex(bytes: &[u8]) -> String {
|
| 357 |
452 |
|
use sha2::{Digest, Sha256};
|
| 367 |
462 |
|
Manifest::parse(EMBEDDED_MANIFEST).expect("the embedded manifest must always be valid");
|
| 368 |
463 |
|
}
|
| 369 |
464 |
|
|
|
465 |
+ |
/// A curated asset pointed at `url`, pinned to the digest of `body`.
|
|
466 |
+ |
fn pinned_asset(url: &str, body: &[u8]) -> Asset {
|
|
467 |
+ |
Asset {
|
|
468 |
+ |
id: "a".into(),
|
|
469 |
+ |
url: Some(url.into()),
|
|
470 |
+ |
sha256: Some(digest_hex(body)),
|
|
471 |
+ |
media_type: "image/jpeg".into(),
|
|
472 |
+ |
filename: "a.jpg".into(),
|
|
473 |
+ |
license: "CC0-1.0".into(),
|
|
474 |
+ |
title: "A".into(),
|
|
475 |
+ |
author: None,
|
|
476 |
+ |
source: "https://example.test/a".into(),
|
|
477 |
+ |
}
|
|
478 |
+ |
}
|
|
479 |
+ |
|
|
480 |
+ |
fn seed_client() -> reqwest::Client {
|
|
481 |
+ |
crate::crypto::install_default_crypto_provider();
|
|
482 |
+ |
reqwest::Client::builder().build().unwrap()
|
|
483 |
+ |
}
|
|
484 |
+ |
|
|
485 |
+ |
#[tokio::test]
|
|
486 |
+ |
async fn a_transient_bad_response_is_retried_rather_than_believed() {
|
|
487 |
+ |
use wiremock::matchers::{method, path};
|
|
488 |
+ |
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
489 |
+ |
|
|
490 |
+ |
// Sando build 60 (2026-08-19): seventeen pinned assets came back wrong in
|
|
491 |
+ |
// one burst and every one of them re-fetched byte-identical to its pin
|
|
492 |
+ |
// afterwards. Before this, the first bad response ended the build and the
|
|
493 |
+ |
// message sent whoever read it off to re-check a licence that never moved.
|
|
494 |
+ |
let server = MockServer::start().await;
|
|
495 |
+ |
let good = b"the real asset bytes";
|
|
496 |
+ |
Mock::given(method("GET"))
|
|
497 |
+ |
.and(path("/a.jpg"))
|
|
498 |
+ |
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"a CDN error page".as_ref()))
|
|
499 |
+ |
.up_to_n_times(1)
|
|
500 |
+ |
.mount(&server)
|
|
501 |
+ |
.await;
|
|
502 |
+ |
Mock::given(method("GET"))
|
|
503 |
+ |
.and(path("/a.jpg"))
|
|
504 |
+ |
.respond_with(ResponseTemplate::new(200).set_body_bytes(good.as_ref()))
|
|
505 |
+ |
.mount(&server)
|
|
506 |
+ |
.await;
|
|
507 |
+ |
|
|
508 |
+ |
let url = format!("{}/a.jpg", server.uri());
|
|
509 |
+ |
let asset = pinned_asset(&url, good);
|
|
510 |
+ |
let cache = tempfile::tempdir().unwrap();
|
|
511 |
+ |
|
|
512 |
+ |
let bytes = fetch_asset(&seed_client(), cache.path(), &asset)
|
|
513 |
+ |
.await
|
|
514 |
+ |
.expect("the second attempt returns the pinned bytes");
|
|
515 |
+ |
assert_eq!(bytes, good);
|
|
516 |
+ |
}
|
|
517 |
+ |
|
|
518 |
+ |
#[tokio::test]
|
|
519 |
+ |
async fn a_body_that_is_not_the_declared_type_says_so_instead_of_blaming_the_pin() {
|
|
520 |
+ |
use wiremock::matchers::{method, path};
|
|
521 |
+ |
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
522 |
+ |
|
|
523 |
+ |
let server = MockServer::start().await;
|
|
524 |
+ |
Mock::given(method("GET"))
|
|
525 |
+ |
.and(path("/a.jpg"))
|
|
526 |
+ |
.respond_with(
|
|
527 |
+ |
ResponseTemplate::new(200)
|
|
528 |
+ |
.insert_header("content-type", "text/html")
|
|
529 |
+ |
.set_body_bytes(b"<html>are you a robot</html>".as_ref()),
|
|
530 |
+ |
)
|
|
531 |
+ |
.mount(&server)
|
|
532 |
+ |
.await;
|
|
533 |
+ |
|
|
534 |
+ |
let url = format!("{}/a.jpg", server.uri());
|
|
535 |
+ |
let asset = pinned_asset(&url, b"the real asset bytes");
|
|
536 |
+ |
let cache = tempfile::tempdir().unwrap();
|
|
537 |
+ |
|
|
538 |
+ |
let err = fetch_asset(&seed_client(), cache.path(), &asset)
|
|
539 |
+ |
.await
|
|
540 |
+ |
.expect_err("an interstitial is not the asset");
|
|
541 |
+ |
assert!(
|
|
542 |
+ |
err.contains("text/html") && err.contains("image/jpeg"),
|
|
543 |
+ |
"the error must name what came back instead: {err}"
|
|
544 |
+ |
);
|
|
545 |
+ |
assert!(
|
|
546 |
+ |
!err.contains("changed at the source"),
|
|
547 |
+ |
"a wrong content type is not evidence the asset changed: {err}"
|
|
548 |
+ |
);
|
|
549 |
+ |
}
|
|
550 |
+ |
|
|
551 |
+ |
#[tokio::test]
|
|
552 |
+ |
async fn a_container_type_that_differs_from_the_declared_one_is_still_the_asset() {
|
|
553 |
+ |
use wiremock::matchers::{method, path};
|
|
554 |
+ |
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
555 |
+ |
|
|
556 |
+ |
// Wikimedia serves `application/ogg` for the three .ogg files this
|
|
557 |
+ |
// manifest declares `audio/ogg`, and both are correct. An equality check
|
|
558 |
+ |
// on content type fails all three, which is how the first cut of the
|
|
559 |
+ |
// document guard was caught.
|
|
560 |
+ |
let server = MockServer::start().await;
|
|
561 |
+ |
let good = b"ogg bytes";
|
|
562 |
+ |
Mock::given(method("GET"))
|
|
563 |
+ |
.and(path("/a.ogg"))
|
|
564 |
+ |
.respond_with(
|
|
565 |
+ |
ResponseTemplate::new(200)
|
|
566 |
+ |
.insert_header("content-type", "application/ogg")
|
|
567 |
+ |
.set_body_bytes(good.as_ref()),
|
|
568 |
+ |
)
|
|
569 |
+ |
.mount(&server)
|
|
570 |
+ |
.await;
|
|
571 |
+ |
|
|
572 |
+ |
let url = format!("{}/a.ogg", server.uri());
|
|
573 |
+ |
let mut asset = pinned_asset(&url, good);
|
|
574 |
+ |
asset.media_type = "audio/ogg".into();
|
|
575 |
+ |
let cache = tempfile::tempdir().unwrap();
|
|
576 |
+ |
|
|
577 |
+ |
let bytes = fetch_asset(&seed_client(), cache.path(), &asset)
|
|
578 |
+ |
.await
|
|
579 |
+ |
.expect("application/ogg is an ogg file, not a page about one");
|
|
580 |
+ |
assert_eq!(bytes, good);
|
|
581 |
+ |
}
|
|
582 |
+ |
|
|
583 |
+ |
#[tokio::test]
|
|
584 |
+ |
async fn a_404_fails_once_rather_than_retrying() {
|
|
585 |
+ |
use wiremock::matchers::{method, path};
|
|
586 |
+ |
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
587 |
+ |
|
|
588 |
+ |
// A missing URL is a fact about the manifest. Retrying it only makes the
|
|
589 |
+ |
// build take three times as long to say the same thing.
|
|
590 |
+ |
let server = MockServer::start().await;
|
|
591 |
+ |
Mock::given(method("GET"))
|
|
592 |
+ |
.and(path("/a.jpg"))
|
|
593 |
+ |
.respond_with(ResponseTemplate::new(404))
|
|
594 |
+ |
.expect(1)
|
|
595 |
+ |
.mount(&server)
|
|
596 |
+ |
.await;
|
|
597 |
+ |
|
|
598 |
+ |
let url = format!("{}/a.jpg", server.uri());
|
|
599 |
+ |
let asset = pinned_asset(&url, b"the real asset bytes");
|
|
600 |
+ |
let cache = tempfile::tempdir().unwrap();
|
|
601 |
+ |
|
|
602 |
+ |
let err = fetch_asset(&seed_client(), cache.path(), &asset)
|
|
603 |
+ |
.await
|
|
604 |
+ |
.expect_err("a 404 is fatal");
|
|
605 |
+ |
assert!(err.contains("404"), "{err}");
|
|
606 |
+ |
// MockServer asserts the `expect(1)` on drop.
|
|
607 |
+ |
}
|
|
608 |
+ |
|
|
609 |
+ |
#[tokio::test]
|
|
610 |
+ |
async fn a_digest_that_never_matches_still_fails_after_the_retries() {
|
|
611 |
+ |
use wiremock::matchers::{method, path};
|
|
612 |
+ |
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
613 |
+ |
|
|
614 |
+ |
// The retry must not turn a genuine change at the source into a pass.
|
|
615 |
+ |
let server = MockServer::start().await;
|
|
616 |
+ |
Mock::given(method("GET"))
|
|
617 |
+ |
.and(path("/a.jpg"))
|
|
618 |
+ |
.respond_with(
|
|
619 |
+ |
ResponseTemplate::new(200)
|
|
620 |
+ |
.insert_header("content-type", "image/jpeg")
|
|
621 |
+ |
.set_body_bytes(b"different bytes every build".as_ref()),
|
|
622 |
+ |
)
|
|
623 |
+ |
.expect(u64::from(FETCH_ATTEMPTS))
|
|
624 |
+ |
.mount(&server)
|
|
625 |
+ |
.await;
|
|
626 |
+ |
|
|
627 |
+ |
let url = format!("{}/a.jpg", server.uri());
|
|
628 |
+ |
let asset = pinned_asset(&url, b"the real asset bytes");
|
|
629 |
+ |
let cache = tempfile::tempdir().unwrap();
|
|
630 |
+ |
|
|
631 |
+ |
let err = fetch_asset(&seed_client(), cache.path(), &asset)
|
|
632 |
+ |
.await
|
|
633 |
+ |
.expect_err("a real change must still fail the seed");
|
|
634 |
+ |
assert!(err.contains("digest mismatch"), "{err}");
|
|
635 |
+ |
assert!(
|
|
636 |
+ |
err.contains("re-check the licence"),
|
|
637 |
+ |
"a persistent mismatch is the case that wants the licence check: {err}"
|
|
638 |
+ |
);
|
|
639 |
+ |
}
|
|
640 |
+ |
|
| 370 |
641 |
|
#[test]
|
| 371 |
642 |
|
fn duplicate_ids_are_refused() {
|
| 372 |
643 |
|
let text = r#"
|