use base64::Engine as _; use crate::docs::annotations::AnnotationFile; use crate::docs::snapshot::SchemaSnapshot; /// The viewer bundle, built by `pnpm build:docs-export` and committed. /// /// Committed rather than built by cargo because Rust cannot run Vite and /// `dbx docs` must work from a plain `cargo install` on a machine with no /// Node. `docs_export_bundle_is_current` is what keeps it honest. const EXPORT_JS: &str = include_str!("../../assets/docs-export.js"); const EXPORT_CSS: &str = include_str!("../../assets/docs-export.css"); pub const EXPORT_LANGUAGES: [&str; 8] = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"]; /// Render a snapshot as one self-contained HTML file. /// /// `snapshot` must already have annotations applied — `apply_annotations` is /// Rust and the export has no Rust at runtime. `annotations` travels too, /// because the merge erases what the viewer needs to colour groups: /// `snapshot.groups` holds resolved `TableGroup`s, `annotations.groups` holds /// the hue. pub fn to_standalone_html( snapshot: &SchemaSnapshot, annotations: &AnnotationFile, lang: &str, ) -> Result { if !EXPORT_LANGUAGES.contains(&lang) { return Err(format!("Unknown language \"{lang}\". Valid values: {}.", EXPORT_LANGUAGES.join(", "))); } let payload = serde_json::json!({ "snapshot": snapshot, "annotations": annotations, "lang": lang }); let json = serde_json::to_vec(&payload) .map_err(|error| format!("Failed to serialise the documentation payload: {error}"))?; // base64 rather than escaped JSON: the alphabet cannot contain `<`, so no // escaping rule exists to forget. The alternative's correctness depends // on every serialisation path applying the escape. let encoded = base64::engine::general_purpose::STANDARD.encode(&json); let title = html_escape(&snapshot.project.name); Ok(format!( "\n\n\n\n\n{title}\n\n\n\n
\n\n\n\n\n" )) } fn html_escape(value: &str) -> String { value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } #[cfg(test)] mod tests { use super::*; use crate::docs::annotations::AnnotationFile; use crate::docs::snapshot::SchemaSnapshot; /// `SchemaSnapshot` is `#[serde(rename_all = "camelCase")]` with /// `format_version` at the TOP level — not inside `project` — and /// `ProjectMeta` requires `name`, `databaseType`, `schemas` and /// `generatedAt`. `AnnotationFile` does NOT derive `Default`, and its /// `format_version` must be 1, so it is built explicitly. fn fixture() -> (SchemaSnapshot, AnnotationFile) { let snapshot: SchemaSnapshot = serde_json::from_str( r#"{"formatVersion":1,"project":{"name":"shop","databaseType":"postgres","database":"shop","schemas":["public"],"generatedAt":"2026-08-06T00:00:00Z","note":null},"tables":[],"enums":[],"relationships":[],"groups":[],"warnings":[]}"#, ) .expect("fixture snapshot"); let annotations = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: std::collections::BTreeMap::new(), }; (snapshot, annotations) } #[test] fn a_note_containing_a_closing_script_tag_survives() { // THE reason the payload is base64. A note discussing HTML is // entirely plausible in a schema document, and inlined as text it // would terminate the script element early and inject the rest of // the payload as markup. let (snapshot, mut annotations) = fixture(); annotations.project = Some(crate::docs::annotations::ProjectAnnotation { name: None, note: Some("".into()), }); let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); assert!(!html.contains("").count(), 2, "exactly the two real script elements"); } #[test] fn the_payload_round_trips() { let (snapshot, annotations) = fixture(); let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); let start = html.find("application/dbx-snapshot").expect("payload element"); let body = &html[start..]; let encoded = body[body.find('>').unwrap() + 1..body.find("").unwrap()].trim(); let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).expect("valid base64"); let value: serde_json::Value = serde_json::from_slice(&decoded).expect("valid json"); assert_eq!(value["snapshot"]["project"]["name"], "shop"); assert_eq!(value["lang"], "en"); } #[test] fn the_shell_and_stylesheet_reference_no_external_resources() { // This test's reach stops at the hand-authored shell and the // bundled CSS's `url(...)` references — it does not scan EXPORT_JS // for network calls. A substring scan of the bundle can't prove // that: it legitimately contains the literal `http://` four times // (three SVG/MathML/xlink XML namespace URIs passed to // `createElementNS`, one inside the markdown autolinker building an // href for `www.`-prefixed text), none of which are fetched. The // bundle's purity is covered on the viewer side instead: // `componentContract.spec.ts` forbids `fetch(`, `axios` and // `invoke(` in every viewer source, and the manifest guard ties the // committed bundle to those sources. let (snapshot, annotations) = fixture(); let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); // 1. Every `url(...)` in the emitted stylesheet must be a `data:` // URI — an allowlist of the one legitimate scheme, not a // blocklist of bad ones, so a new absolute font or image url() // fails without anyone having to extend a list. The match is // case-insensitive because CSS's `url()` function name is // case-insensitive per spec (`URL(...)` is legal); an ASCII-only // bundle makes a byte-wise check safe here. fn find_url_ci(s: &str, from: usize) -> Option { let bytes = s.as_bytes(); (from..bytes.len().saturating_sub(3)).find(|&i| bytes[i..i + 4].eq_ignore_ascii_case(b"url(")) } let style_start = html.find("").expect("style element closes"); let style = &html[style_start..style_end]; let mut cursor = 0; let mut url_count = 0; while let Some(pos) = find_url_ci(style, cursor) { let rest = &style[pos + "url(".len()..]; let end = rest.find(')').expect("unterminated url("); let value = rest[..end].trim().trim_matches('\'').trim_matches('"'); assert!(value.starts_with("data:"), "stylesheet references a non-data url(): {value}"); url_count += 1; cursor = pos + "url(".len() + end + 1; } // A scan that finds nothing hasn't verified anything — today the // font is inlined via exactly one `url()`, so zero would mean the // build stopped inlining it, not that there is nothing left to check. assert!(url_count >= 1, "found no url(...) in the stylesheet — the scan above verified nothing"); // 2. The hand-authored shell — the document with the bundle's own // CSS, JS, and the base64 payload removed — must contain no // `src=` or `href=` at all. Checked against the shell alone so // neither the bundle's contents nor the payload can influence // the result. let payload_start = html.find("application/dbx-snapshot").expect("payload element"); let payload_body = &html[payload_start..]; let encoded = payload_body[payload_body.find('>').unwrap() + 1..payload_body.find("").unwrap()].trim(); let shell = html.replace(EXPORT_CSS, "").replace(EXPORT_JS, "").replace(encoded, ""); assert!(!shell.contains("src="), "the shell references an external src"); assert!(!shell.contains("href="), "the shell references an external href"); } #[test] fn an_unknown_language_is_rejected() { let (snapshot, annotations) = fixture(); let error = to_standalone_html(&snapshot, &annotations, "kl").expect_err("should reject"); assert!(error.contains("kl"), "got: {error}"); assert!(error.contains("en"), "the error must list the valid locales, got: {error}"); } /// The committed bundle must match the sources it was built from. /// /// Skips only when the crate is consumed from a published package, where /// `apps/desktop/` does not exist. That skip is itself a hazard — a /// vacuous skip in CI would silently disable this guard — so it keys off /// a repository-only marker rather than off the absence of the sources. #[test] fn docs_export_bundle_is_current() { use sha2::{Digest, Sha256}; let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("workspace root"); if !workspace.join("pnpm-workspace.yaml").exists() { return; // packaged crate: the sources are genuinely absent } let manifest: serde_json::Value = serde_json::from_str(include_str!("../../assets/docs-export.manifest.json")) .expect("manifest is valid JSON"); let sources = manifest["sources"].as_object().expect("manifest.sources"); assert!(sources.len() > 5, "manifest lists only {} sources — the build emitted an empty graph", sources.len()); let mut stale = Vec::new(); for (relative, expected) in sources { let path = workspace.join(relative); let Ok(bytes) = std::fs::read(&path) else { stale.push(format!("{relative} (missing)")); continue; }; let actual = format!("{:x}", Sha256::digest(&bytes)); if actual != expected.as_str().unwrap_or_default() { stale.push(relative.clone()); } } assert!( stale.is_empty(), "the committed docs export bundle is stale.\nChanged: {}\nRun: pnpm build:docs-export", stale.join(", ") ); } /// `EXPORT_JS` is interpolated into `` raw — /// unlike the base64 payload, nothing escapes it. That is safe against a /// literal `` (asserted above), but the HTML tokenizer has two /// more states that can hide one: inside a `` no longer closes the element. Both sequences exist in /// EXPORT_JS today (third-party minified output) and are safe only /// because every `` before the next `").map(|offset| open + offset); let next_script = lower[open..].find(" assert!( close < script, "an unmatched closes — this would trap the browser in \ script-data-double-escaped state and swallow our own closing " ), (None, Some(script)) => panic!( "an unclosed anywhere after it" ), (Some(_), None) | (None, None) => {} } pos = open + "