fix(build): stop a stale or missing keepsake viewer from shipping silently

Three ways the compiled-in viewer could be wrong, none of which anything would
have reported. Found by mutation-testing the guard added below — it failed
when it should have passed, and the reason was the second bullet.

* `include_dir!` registers NO rebuild dependency. Run `npm run build` in
  frontend/export-viewer, then `cargo build`, and cargo sees no source change
  and reuses the cached binary — carrying the PREVIOUS index.html. The file on
  disk and the file in the binary disagree, git is clean, every check passes,
  and Memories.zip ships a stale viewer. Confirmed empirically: after replacing
  the artifact the compiled-in copy did not change until a source file was
  touched. A build.rs now declares `rerun-if-changed` for
  `static/export-viewer` AND `migrations` — sqlx::migrate!() embeds its
  directory the same way, and there the stale snapshot is worse still: the
  binary boots against a database that already ran a newer migration and
  crash-loops with VersionMissing.

* `emptyOutDir: true` deleted the committed artifact BEFORE generating. That
  was safe while the build could not fail; it no longer is, because
  `inlineThemeFonts` now calls `this.error` on a keepsake that is not
  self-contained. A failed build left the directory empty — and include_dir!
  over an empty directory compiles fine, while `write_viewer_with_data`
  iterates zero files and returns Ok. The result is a valid archive with every
  photo and no viewer. The output is one overwritten file, so nothing
  accumulates without the wipe.

* Nothing asserted the viewer was there at all. Now asserted at the point of
  use (bail rather than write a viewer-less keepsake) and in a test that checks
  presence, plausible size, and that no `url(/...)` survived inlining — the
  three ways it can be present but useless.

The Dockerfile copies build.rs with the sources rather than with Cargo.toml, so
the dependency-cache layer stays byte-identical and the dummy build does not
run it.
This commit is contained in:
fabi
2026-08-12 20:51:51 +02:00
parent 8af8c4fab7
commit 010bcc0e3c
4 changed files with 83 additions and 1 deletions

View File

@@ -1359,6 +1359,18 @@ async fn run_html_export_inner(
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
// index.html (file://), where a cross-origin fetch() of a sibling file is
// blocked — so the data must be inlined rather than fetched from data.json.
// The viewer IS the keepsake — Memories.zip without it is a folder of files with no way to
// look at them. `write_viewer_with_data` walks `dir.files()`, which iterates nothing at all
// when the compiled-in directory is empty, so a viewer build that failed after Vite emptied
// its output directory used to produce a perfectly valid archive with no viewer in it,
// silently. Asserted here rather than trusted: this costs one lookup per export.
if VIEWER_DIR.get_file("index.html").is_none() {
anyhow::bail!(
"the keepsake viewer is missing from this binary (static/export-viewer/index.html \
was not compiled in). Run `npm run build` in frontend/export-viewer and rebuild — \
an archive without the viewer is not a keepsake."
);
}
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json, theme_css.as_deref()).await?;
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
@@ -2323,6 +2335,34 @@ Viel Freude mit den Erinnerungen!\n";
#[cfg(test)]
mod tests {
/// The viewer must actually be compiled into the binary.
///
/// `include_dir!` over an empty directory is not an error, and `write_viewer_with_data`
/// iterates `dir.files()` — zero files, zero writes, `Ok(())`. So a viewer build that failed
/// after Vite emptied its output directory produced a binary whose Memories.zip contains every
/// photo and no way to view them, with nothing anywhere reporting it. This is the cheapest
/// place to notice, and it runs on every `cargo test`.
#[test]
fn the_keepsake_viewer_is_compiled_into_this_binary() {
let index = super::VIEWER_DIR
.get_file("index.html")
.expect("static/export-viewer/index.html must be compiled in — run `npm run build` in frontend/export-viewer");
// Not just present: substantial. An empty or truncated file would satisfy `get_file` and
// still ship a blank keepsake. The real artifact is ~235 KB with the fonts inlined.
assert!(
index.contents().len() > 50_000,
"the compiled-in viewer is only {} bytes — that is not a complete keepsake viewer",
index.contents().len()
);
// And it must be self-contained: the whole point of the inlining is that it opens from
// file:// with no network. A `/fonts/...` reference here is the bug shipping again.
let html = std::str::from_utf8(index.contents()).expect("viewer is valid UTF-8");
assert!(
!html.contains("url(/"),
"the compiled-in viewer references an external asset — it will 404 silently from file://"
);
}
use super::*;
const EVT: &str = "11111111-1111-1111-1111-111111111111";