use std::net::SocketAddr;
use std::process::Command;
use super::cli::ServeArgs;
use super::download;
use super::evaluate;
use super::flowlog::{FlowSink, LogLevel};
use super::http::{BoundServer, ServeHandle};
use super::limits::{DEFAULT_ALLOW_DOWNLOAD, DEFAULT_ALLOW_PROVIDER_FALLBACK, DEFAULT_PROVIDER};
use super::model_plan::{ModelPlan, ModelPlanError, ModelSnapshot, Pipeline, PlanReverification};
use super::security::{
NonLoopbackBindError, PlaceholderError, RandomError, ServeToken, TOKEN_PLACEHOLDER,
assert_no_placeholders_left, generate_nonce, inject, random_source,
};
use super::server::ServeContext;
use super::state::{ServeStartup, StartupConfigError};
pub(crate) const PAGE_TEMPLATE: &str = include_str!("../web/index.html");
pub(crate) const PAGE_PATH: &str = "src/bin/web/index.html";
#[derive(Debug)]
pub(crate) enum ServeStartError {
Bind { address: SocketAddr, detail: String },
NonLoopback(NonLoopbackBindError),
Config(StartupConfigError),
Models(ModelPlanError),
Page(PageError),
FormulaDetector { path: std::path::PathBuf },
TableModel { path: std::path::PathBuf },
EvalRoot {
path: std::path::PathBuf,
detail: String,
},
Random(RandomError),
ModelsUnusable {
report: Box<super::model_plan::PlanReverification>,
},
Runtime(std::io::Error),
}
impl std::fmt::Display for ServeStartError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bind { address, detail } => write!(
f,
"cannot bind {address}: {detail}; rapidstruct serve reports the conflict instead of \
silently switching ports (docs/05 §3)"
),
Self::NonLoopback(error) => write!(f, "{error}"),
Self::Config(error) => write!(f, "startup configuration rejected: {error}"),
Self::Models(error) => write!(f, "model inventory rejected: {error}"),
Self::Page(error) => write!(f, "{error}"),
Self::FormulaDetector { path } => write!(
f,
"--formula-detector {} does not exist (or is not a file); the formula queue needs \
the page formula detection model, and a wrong path must fail at startup instead \
of on the first formula request (docs/05 §4.2)",
path.display()
),
Self::TableModel { path } => write!(
f,
"--table-model {} does not exist (or is not a file); `?table=1` needs the table \
structure model, and a wrong path must fail at startup instead of on the first \
table request (docs/11 §4.2)",
path.display()
),
Self::Runtime(error) => write!(f, "cannot start the serve runtime: {error}"),
Self::Random(error) => write!(
f,
"refusing to start: {error}; the service token and the CSP nonce must come from a \
cryptographic RNG (docs/05 §7.2, M1 review P2-4)"
),
Self::EvalRoot { path, detail } => write!(
f,
"--eval-root {} is not usable as an evaluation sandbox: {detail}; \
POST /api/evaluate must not read local paths outside an explicitly configured \
root (docs/05 §4.2, M1 review P2-3)",
path.display()
),
Self::ModelsUnusable { report } => {
write!(
f,
"refusing to start: --reverify-models could not verify every model file this \
run will load. Offending file(s) by pipeline:\n {}",
report.blocking_summary()
)?;
for file in report.files() {
write!(f, "\n serve: {}", file.describe())?;
}
write!(
f,
"\n (docs/05 §3: --reverify-models verifies this run's whole plan — the text \
pipeline and, when formula routing is enabled, the formula pipeline — at \
startup and refuses to serve from an unusable model set)"
)
}
}
}
}
impl std::error::Error for ServeStartError {}
impl From<NonLoopbackBindError> for ServeStartError {
fn from(error: NonLoopbackBindError) -> Self {
Self::NonLoopback(error)
}
}
impl From<StartupConfigError> for ServeStartError {
fn from(error: StartupConfigError) -> Self {
Self::Config(error)
}
}
impl From<ModelPlanError> for ServeStartError {
fn from(error: ModelPlanError) -> Self {
Self::Models(error)
}
}
impl From<PageError> for ServeStartError {
fn from(error: PageError) -> Self {
Self::Page(error)
}
}
#[derive(Debug)]
pub(crate) enum PageError {
Residual(PlaceholderError),
NonceMismatch { found: Vec<String> },
TokenMissing,
}
impl std::fmt::Display for PageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Residual(error) => write!(f, "{PAGE_PATH}: {error}"),
Self::NonceMismatch { found } => write!(
f,
"{PAGE_PATH}: the rendered page has nonce attributes that do not match the CSP \
nonce ({found:?}); the page would be blocked by its own CSP"
),
Self::TokenMissing => write!(
f,
"{PAGE_PATH}: the rendered page does not contain {TOKEN_PLACEHOLDER}'s value; the \
page would silently fall back to offline preview mode"
),
}
}
}
impl std::error::Error for PageError {}
pub(crate) fn run(args: ServeArgs) -> Result<(), ServeStartError> {
let bound = BoundServer::bind(args.port)?;
let local = super::security::LocalOrigin::from_bound(bound.local_addr())?;
let model_dir = args.model_dir();
let token = ServeToken::generate().map_err(ServeStartError::Random)?;
let nonce = generate_nonce().map_err(ServeStartError::Random)?;
let page = render_page(&token, &nonce)?;
println!("serve: token/nonce entropy source: {}", random_source());
let log_level = args.log_level()?;
println!(
"serve: flow logging: --log-level={} (RAPID_STRUCTURE_SERVE_LOG={}); {}",
log_level.name(),
std::env::var("RAPID_STRUCTURE_SERVE_LOG").unwrap_or_else(|_| "<unset>".to_string()),
match log_level {
LogLevel::Off =>
"one line per HTTP request and per job lifecycle is off (pass \
--log-level flow to turn it on)",
LogLevel::Flow =>
"one line per HTTP request and per job lifecycle is on: \
request id, method/path/status/bytes/duration, and the job's \
admission/running/terminal lines share that request id",
}
);
let raw_engine = args.engine_config()?;
let yaml_provider = raw_engine.runtime.provider_preference;
let yaml_max_side = raw_engine.global.max_side_len;
let startup = ServeStartup::validate(
args.raw_limits(),
raw_engine,
args.provider_preference(),
args.max_side,
args.allow_provider_fallback(),
)?;
let allow_download_hosts = download::validate_extra_hosts(&args.allow_download_host)
.map_err(StartupConfigError::Limit)?;
if let Some(path) = args.formula_detector.as_ref()
&& !path.is_file()
{
return Err(ServeStartError::FormulaDetector { path: path.clone() });
}
if let Some(path) = args.table_model.as_ref()
&& !path.is_file()
{
return Err(ServeStartError::TableModel { path: path.clone() });
}
let model_plan = ModelPlan::resolve(
&model_dir,
&startup.plan.engine,
args.formula_detector.as_deref(),
args.table_model.as_deref(),
)?;
let snapshot = model_plan.snapshot();
let engine_config_min_side = startup.plan.engine.global.min_side_len;
debug_assert!(engine_config_min_side > 0);
let routing = super::server::OcrRouting::from_plan(&model_plan);
if args.reverify_models {
reverify_gate(&model_plan)?;
}
let eval_root = match args.eval_root.as_ref() {
Some(directory) => Some(evaluate::EvalRoot::new(directory).map_err(|detail| {
ServeStartError::EvalRoot {
path: directory.clone(),
detail,
}
})?),
None => None,
};
log_startup(
&args,
&startup,
&local,
&snapshot,
&allow_download_hosts,
yaml_provider_is_overridden(&args),
yaml_max_side,
yaml_provider,
&routing,
model_plan
.formula_detector()
.map(|spec| spec.path.as_path()),
model_plan.table_model().map(|spec| spec.path.as_path()),
eval_root.as_ref(),
);
let port = local.port();
let context = ServeContext {
limits: startup.limits,
plan: startup.plan,
model_plan,
snapshot,
token,
local,
page,
nonce,
allow_download: args.allow_download,
allow_download_hosts,
allow_provider_fallback: args.allow_provider_fallback(),
eval_root,
flow: FlowSink::for_level(log_level),
engine_factory: super::engine::real_engine_factory(),
downloader: super::download::real_downloader_factory(),
free_space: super::server::real_free_space(),
post_verify: None,
};
let mut service = ServeHandle::start(bound, context)?;
if port != service.local_addr().port() {
return Err(ServeStartError::Bind {
address: service.local_addr(),
detail: format!(
"the allow-list was built for port {port} but the listener bound {}",
service.local_addr().port()
),
});
}
log_engine(&service);
if args.open {
open_browser(service.shared().local().primary_origin());
}
service.accept_loop();
Ok(())
}
pub(crate) fn render_page(token: &ServeToken, nonce: &str) -> Result<String, PageError> {
render_page_with(PAGE_TEMPLATE, nonce, token.as_str())
}
pub(crate) fn reverify_gate(model_plan: &ModelPlan) -> Result<PlanReverification, ServeStartError> {
let started = std::time::Instant::now();
let report = model_plan.reverify();
let elapsed_ms = started.elapsed().as_millis();
for file in report.files() {
println!(
"serve: --reverify-models {} | {} | digest computed by this call: {}",
file.describe(),
file.cause.as_str(),
file.sha256.is_some()
);
}
if !report.content_changed().is_empty() {
println!(
"serve: --reverify-models content changed for: {} (the stat identity matched but the \
first/last 64 KiB did not)",
report.content_changed().join(", ")
);
}
println!(
"serve: --reverify-models verified {} file(s) in {elapsed_ms} ms ({} full digest(s) \
computed by this call, {} missing/corrupt in this run's plan)",
report.files().len(),
report.digests_computed(),
report.blocking().len()
);
if report.blocking().is_empty() {
return Ok(report);
}
let fresh = model_plan.snapshot();
for pipeline in Pipeline::ALL {
let expected: Vec<String> = match pipeline {
Pipeline::Text => fresh.blocking_names(),
Pipeline::Formula => fresh.formula_blocking_names(),
Pipeline::Table => fresh.table_blocking_names(),
};
let observed: Vec<String> = report
.blocking_in(pipeline)
.iter()
.map(|file| file.name.clone())
.collect();
if observed != expected {
eprintln!(
"serve: WARNING --reverify-models found {observed:?} in the {} pipeline but the \
engine's readiness snapshot reports {expected:?}; both come from the same plan, \
so this is a bug",
pipeline.as_str()
);
}
}
Err(ServeStartError::ModelsUnusable {
report: Box::new(report),
})
}
pub(crate) fn render_page_with(
template: &str,
nonce: &str,
token: &str,
) -> Result<String, PageError> {
let page = inject(template, nonce, token);
assert_no_placeholders_left(&page).map_err(PageError::Residual)?;
check_nonce_attributes(&page, nonce)?;
if !page.contains(token) {
return Err(PageError::TokenMissing);
}
Ok(page)
}
fn check_nonce_attributes(page: &str, nonce: &str) -> Result<(), PageError> {
let markup = strip_html_comments(page);
let mut found = Vec::new();
let mut rest = markup.as_str();
while let Some(index) = rest.find("nonce=\"") {
rest = &rest[index + "nonce=\"".len()..];
let Some(end) = rest.find('"') else {
break;
};
found.push(rest[..end].to_string());
rest = &rest[end + 1..];
}
if found.is_empty() || found.iter().any(|value| value != nonce) {
return Err(PageError::NonceMismatch { found });
}
Ok(())
}
fn strip_html_comments(page: &str) -> String {
let mut out = String::with_capacity(page.len());
let mut rest = page;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + "-->".len()..],
None => return out,
}
}
out.push_str(rest);
out
}
fn args_max_side_is_overridden(args: &ServeArgs) -> bool {
args.max_side.is_some()
}
#[allow(clippy::too_many_arguments)]
fn log_startup(
args: &ServeArgs,
startup: &ServeStartup,
local: &super::security::LocalOrigin,
snapshot: &ModelSnapshot,
allow_download_hosts: &[String],
provider_overridden: bool,
yaml_max_side: usize,
yaml_provider: rapid_structure_rs::ProviderPreference,
routing: &super::server::OcrRouting,
formula_detector: Option<&std::path::Path>,
table_model: Option<&std::path::Path>,
eval_root: Option<&evaluate::EvalRoot>,
) {
println!(
"serve: listening on {} (IPv4 loopback only; there is deliberately no --host option)",
local.primary_origin()
);
println!(
"serve: allowed Host: {} | allowed Origin: {}",
local.allowed_hosts().join(", "),
local.allowed_origins().join(", ")
);
println!(
"serve: model dir {} (source: {}, {})",
snapshot.model_dir().display(),
snapshot.source_label(),
snapshot.summary()
);
println!(
"serve: formula queue {} (formula detector: {}; ordinary OCR is independent of it)",
if routing.formula {
"enabled by a formula detector"
} else {
"disabled (no formula detector configured)"
},
formula_detector
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<none>".to_string())
);
let formula_blocking = snapshot.formula_blocking_names();
println!(
"serve: formula pipeline {}",
if !snapshot.formula_in_plan() {
"not part of this run's model plan (no formula detector is configured)".to_string()
} else if formula_blocking.is_empty() {
"complete (formula recognizer + detector are in this run's plan and verified)"
.to_string()
} else {
format!(
"incomplete: {} (the formula queue returns 409 until it is present; text OCR is \
unaffected)",
formula_blocking.join(", ")
)
}
);
println!(
"serve: table routing {} (table structure model: {})",
if routing.table {
"enabled by a table structure model"
} else {
"disabled (no table structure model configured)"
},
table_model
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<none>".to_string())
);
let table_blocking = snapshot.table_blocking_names();
println!(
"serve: table pipeline {}",
if !snapshot.table_in_plan() {
"not part of this run's model plan (no table structure model is configured)".to_string()
} else if table_blocking.is_empty() {
"complete (the table structure model is in this run's plan and verified)".to_string()
} else {
format!(
"incomplete: {} (`?table=1` returns 409 until it is present; text OCR and the \
formula queue are unaffected)",
table_blocking.join(", ")
)
}
);
println!(
"serve: startup model verification {}",
if args.reverify_models {
"cold (--reverify-models: every file this run uses was re-hashed at startup, and a \
missing or corrupt file refuses to start)"
} else {
"on first use (the identity-keyed cache verifies each file the first time it is asked \
for; pass --reverify-models to move that to startup and fail fast, or use \
POST /api/models/reverify to force a fresh check while running)"
}
);
println!(
"serve: evaluation {}",
match eval_root {
Some(root) => format!(
"enabled with --eval-root {} (the manifest and every image it references must \
resolve inside that directory)",
root.root().display()
),
None => "disabled (no --eval-root): POST /api/evaluate refuses with a locating error, \
because evaluation reads local paths instead of uploaded bytes"
.to_string(),
}
);
println!(
"serve: provider requested={} (documented default: {DEFAULT_PROVIDER}; {}), \
allow_provider_fallback={} (documented default: {DEFAULT_ALLOW_PROVIDER_FALLBACK}), \
fail_if_provider_unavailable={}",
startup.plan.requested_label(),
if provider_overridden {
format!(
"--provider overrides --config {}",
rapid_structure_rs::format_provider_preference(yaml_provider)
)
} else {
"from --config / built-in default".to_string()
},
args.allow_provider_fallback(),
startup.plan.fail_if_provider_unavailable
);
println!(
"serve: max_side_len={} ({})",
startup.plan.engine.global.max_side_len,
if args_max_side_is_overridden(args) {
format!("--max-side overrides --config {yaml_max_side}")
} else {
format!("from --config / built-in default ({yaml_max_side})")
}
);
println!(
"serve: limits max_body={} B max_result={} B max_queue_text={} max_queue_formula={} \
max_retained={} job_ttl={} ms ({})",
startup.limits.max_body_bytes,
startup.limits.max_result_bytes,
startup.limits.max_queue_text,
startup.limits.max_queue_formula,
startup.limits.max_retained,
startup.limits.job_ttl_ms,
if args.uses_documented_defaults() {
"all documented defaults"
} else {
"some values overridden on the command line"
}
);
println!(
"serve: downloads {} (documented default: {}), token required on every /api/* request \
(the token is printed only into the page)",
if args.allow_download {
"enabled via --allow-download"
} else {
"disabled (no --allow-download)"
},
if DEFAULT_ALLOW_DOWNLOAD {
"enabled"
} else {
"disabled"
}
);
println!(
"serve: trusted download hosts {}",
effective_hosts(allow_download_hosts).join(", ")
);
println!(
"serve: formula render assets: {} embedded file(s) served from /static/* (KaTeX + \
MathJax tex-svg; same-origin, zero CDN)",
super::assets::asset_count()
);
if !allow_download_hosts.is_empty() {
eprintln!(
"serve: WARNING --allow-download-host extends the trusted download allow-list beyond \
the compiled-in set [{}]: [{}]. The compiled-in constant is what makes a local \
manifest.json unable to point downloads at arbitrary hosts: a manifest is a \
*resource description*, and docs/05 §6.1 item 3 (OWASP SSRF prevention) requires the \
allow-list to come from trusted configuration instead. Every host you add here is \
accepted for model downloads; add only hosts you control or trust, and remove the \
flag when you no longer need it.",
rapid_structure_rs::ALLOWED_DOWNLOAD_HOSTS.join(", "),
allow_download_hosts.join(", ")
);
}
}
fn effective_hosts(extra: &[String]) -> Vec<String> {
let mut hosts: Vec<String> = rapid_structure_rs::ALLOWED_DOWNLOAD_HOSTS
.iter()
.map(|host| (*host).to_string())
.collect();
hosts.extend(extra.iter().cloned());
hosts
}
fn log_engine(handle: &ServeHandle) {
let shared = handle.shared();
println!(
"serve: service state = {:?}, engine state = {}",
shared.service_state(),
shared.engine_state_name()
);
let provider = shared.provider_status();
println!(
"serve: engine provider requested={} selected_ep={} fallback_to_cpu={}",
provider.requested,
provider.selected_ep.as_deref().unwrap_or("<unknown>"),
match provider.fallback_to_cpu {
Some(value) => value.to_string(),
None => "<unknown>".to_string(),
}
);
}
fn open_browser(url: &str) {
if url.is_empty() {
return;
}
match Command::new("cmd").args(["/C", "start", "", url]).spawn() {
Ok(_) => println!("serve: opened {url}"),
Err(error) => eprintln!("serve: cannot open a browser ({error}); open {url} manually"),
}
}
fn yaml_provider_is_overridden(args: &ServeArgs) -> bool {
args.provider.is_some()
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use rapid_structure_rs::{EngineConfig, sha256_file};
use super::{
PAGE_TEMPLATE, PageError, ServeStartError, check_nonce_attributes, render_page_with,
reverify_gate, strip_html_comments,
};
use crate::serve::model_plan::{ModelPlan, Pipeline};
fn plausible_onnx_bytes(tag: &str) -> Vec<u8> {
let mut bytes = vec![0x08, 0x07];
bytes.extend_from_slice(tag.as_bytes());
bytes
}
fn fixture_dir(name: &str, with_detector: bool) -> PathBuf {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target/m1-review-evidence")
.join(format!("reverify-{name}"));
std::fs::create_dir_all(&dir).expect("create the fixture dir");
let mut files: Vec<(&str, &str)> = vec![
("det.onnx", "detector"),
("rec.onnx", "recognizer"),
("dict.txt", "dictionary"),
("fx.onnx", "formula_recognizer"),
];
if with_detector {
files.push(("mfd.onnx", "formula_detector"));
}
let mut manifest = String::from(
"{\"schema_version\":1,\"id\":\"reverify-set\",\"family\":\"PP-OCR\",\
\"version\":\"v-test\",\"files\":[",
);
for (index, (file, role)) in files.iter().enumerate() {
let path = dir.join(file);
let body = if *file == "mfd.onnx" {
plausible_onnx_bytes("reverify detector")
} else {
format!("reverify fixture {file}").into_bytes()
};
std::fs::write(&path, &body).expect("write the fixture");
let sha = sha256_file(&path).expect("hash the fixture");
if index > 0 {
manifest.push(',');
}
manifest.push_str(&format!(
"{{\"name\":\"{file}\",\"role\":\"{role}\",\"sha256\":\"{sha}\"}}"
));
}
manifest.push_str("]}");
std::fs::write(dir.join("manifest.json"), manifest).expect("write the manifest");
dir
}
fn plan_for(dir: &Path, detector: Option<&Path>) -> ModelPlan {
ModelPlan::resolve(dir, &EngineConfig::default(), detector, None).expect("model plan")
}
#[test]
fn the_startup_gate_verifies_every_file_the_run_uses_with_a_fresh_digest() {
let dir = fixture_dir("healthy", true);
let plan = plan_for(&dir, None);
let report = reverify_gate(&plan).expect("healthy models must pass");
assert_eq!(
report.digests_computed(),
report.files().len(),
"one computed digest per file the run uses"
);
assert_eq!(
report.files().len(),
5,
"text three + formula recognizer + formula detector: {:?}",
report.files().iter().map(|f| &f.name).collect::<Vec<_>>()
);
assert!(report.blocking().is_empty());
for file in report.files() {
assert!(
file.sha256.is_some(),
"{} must carry the digest this call computed",
file.name
);
assert_eq!(
file.state.as_str(),
"present",
"{} must be present: {:?}",
file.name,
file.state
);
assert_eq!(
file.cause.as_str(),
"first_sight",
"a cold verification never reports a cache hit ({}): {:?}",
file.name,
file.cause
);
}
let names: Vec<&str> = report.files().iter().map(|f| f.name.as_str()).collect();
assert!(!names.contains(&"manifest.json"), "{names:?}");
assert_eq!(
report.files_in(Pipeline::Formula).len(),
2,
"the formula pipeline is part of this run's plan"
);
}
#[test]
fn a_corrupt_formula_recognizer_is_out_of_scope_when_formula_is_disabled() {
let dir = fixture_dir("formula-disabled", false);
let plan = plan_for(&dir, None);
std::fs::write(dir.join("fx.onnx"), b"corrupted formula recognizer bytes")
.expect("corrupt the formula recognizer");
let report = reverify_gate(&plan).expect("formula is not part of this run's plan");
assert_eq!(
report.digests_computed(),
3,
"only the text pipeline's three files may be hashed: {:?}",
report.files().iter().map(|f| &f.name).collect::<Vec<_>>()
);
assert!(
report.files_in(Pipeline::Formula).is_empty(),
"the 566 MB formula recognizer must not be in the plan at all"
);
let names: Vec<&str> = report.files().iter().map(|f| f.name.as_str()).collect();
assert!(!names.contains(&"fx.onnx"), "{names:?}");
}
#[test]
fn the_startup_gate_fails_on_a_corrupt_formula_detector_and_names_it() {
let dir = fixture_dir("corrupt-detector", true);
let plan = plan_for(&dir, None);
std::fs::write(dir.join("mfd.onnx"), b"corrupted detector bytes")
.expect("corrupt the detector");
let error =
reverify_gate(&plan).expect_err("a corrupt formula detector must refuse to start");
let text = error.to_string();
assert!(text.contains("mfd.onnx"), "{text}");
assert!(text.contains("corrupt"), "{text}");
assert!(
text.contains("formula pipeline"),
"the error must say which pipeline the file belongs to: {text}"
);
assert!(text.contains("--reverify-models"), "{text}");
match error {
ServeStartError::ModelsUnusable { report } => {
let blocking: Vec<&str> = report
.blocking()
.iter()
.map(|file| file.name.as_str())
.collect();
assert_eq!(
blocking,
vec!["mfd.onnx"],
"only the corrupt detector blocks"
);
let detector = report
.files()
.iter()
.find(|file| file.name == "mfd.onnx")
.expect("the detector is in this run's plan");
assert_eq!(detector.pipeline, Pipeline::Formula);
assert!(
detector.sha256.is_some(),
"this call must have computed its digest"
);
assert!(report.blocking_in(Pipeline::Text).is_empty());
}
other => panic!("expected ModelsUnusable, got {other:?}"),
}
}
#[test]
fn the_startup_gate_fails_on_a_corrupt_formula_recognizer_when_formula_is_enabled() {
let dir = fixture_dir("corrupt-formula-required", true);
let plan = plan_for(&dir, None);
std::fs::write(dir.join("fx.onnx"), b"corrupted formula recognizer bytes")
.expect("corrupt the formula recognizer");
let error = reverify_gate(&plan).expect_err("the formula model is part of the plan");
let text = error.to_string();
assert!(text.contains("fx.onnx"), "{text}");
assert!(text.contains("formula pipeline"), "{text}");
assert!(
!text.contains("text pipeline:"),
"the text pipeline is not the offender here: {text}"
);
}
#[test]
fn the_startup_gate_covers_an_external_detector_without_a_declared_digest() {
let dir = fixture_dir("external-detector", false);
let outside = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target/m1-review-evidence/reverify-external-detector.onnx");
std::fs::write(&outside, plausible_onnx_bytes("external detector")).expect("detector");
let plan = plan_for(&dir, Some(&outside));
let report = reverify_gate(&plan).expect("a plausible external detector passes");
assert_eq!(
report.files().len(),
5,
"text three + the formula recognizer + the external detector: {:?}",
report.files().iter().map(|f| &f.name).collect::<Vec<_>>()
);
assert_eq!(report.digests_computed(), 5);
let detector = report
.files()
.iter()
.find(|file| file.name == "reverify-external-detector.onnx")
.expect("the external detector must be in this run's plan");
assert_eq!(detector.pipeline, Pipeline::Formula);
assert_eq!(detector.declared_sha256, None);
assert!(
detector.sha256.is_some(),
"the digest of a file without a declared value is still reported: {}",
detector.describe()
);
std::fs::write(&outside, b"not a model at all").expect("corrupt the detector");
let error = reverify_gate(&plan).expect_err("a file that is not an ONNX model blocks");
let text = error.to_string();
assert!(text.contains("reverify-external-detector.onnx"), "{text}");
assert!(text.contains("not a plausible ONNX model"), "{text}");
assert!(text.contains("formula pipeline"), "{text}");
std::fs::remove_file(&outside).ok();
}
#[test]
fn the_startup_gate_refuses_to_start_and_names_the_corrupt_file() {
let dir = fixture_dir("corrupt", false);
let plan = plan_for(&dir, None);
std::fs::write(dir.join("rec.onnx"), b"corrupted recognizer bytes")
.expect("corrupt the recognizer");
let error = reverify_gate(&plan).expect_err("a corrupt plan model must refuse to start");
let text = error.to_string();
assert!(text.contains("rec.onnx"), "{text}");
assert!(text.contains("corrupt"), "{text}");
assert!(text.contains("--reverify-models"), "{text}");
assert!(
text.contains("text pipeline"),
"the error must say which pipeline the file belongs to: {text}"
);
match error {
ServeStartError::ModelsUnusable { report } => {
let blocking: Vec<&str> = report
.blocking()
.iter()
.map(|file| file.name.as_str())
.collect();
assert_eq!(blocking, vec!["rec.onnx"], "only the corrupt file blocks");
}
other => panic!("expected ModelsUnusable, got {other:?}"),
}
}
#[test]
fn the_startup_gate_refuses_a_missing_file_too() {
let dir = fixture_dir("missing", false);
let plan = plan_for(&dir, None);
std::fs::remove_file(dir.join("dict.txt")).expect("remove the dictionary");
let error = reverify_gate(&plan).expect_err("a missing plan model must refuse to start");
let text = error.to_string();
assert!(text.contains("dict.txt"), "{text}");
assert!(text.contains("missing"), "{text}");
assert!(text.contains("text pipeline"), "{text}");
}
#[test]
fn the_real_page_injects_cleanly_and_matches_the_csp_nonce() {
let page = render_page_with(PAGE_TEMPLATE, "n0nce", "t0ken").expect("the page injects");
assert!(!page.contains("__CSP_NONCE__"));
assert!(!page.contains("__SRV_TOKEN__"));
assert_eq!(page.matches("nonce=\"n0nce\"").count(), 3);
assert_eq!(page.matches("n0nce").count(), 4);
assert_eq!(page.matches("t0ken").count(), 3);
}
#[test]
fn a_residual_placeholder_refuses_to_start() {
let error = render_page_with("__SRV_TOKEN__", "n", "__CSP_NONCE__")
.expect_err("a residual placeholder must refuse to start");
assert!(matches!(error, PageError::Residual(_)), "{error:?}");
assert!(error.to_string().contains("__CSP_NONCE__"), "{error}");
}
#[test]
fn a_foreign_nonce_refuses_to_start() {
let error = render_page_with(
r#"<style nonce="deadbeef"></style><script nonce="deadbeef">window.__RROCR__={token:"__SRV_TOKEN__"};</script>"#,
"n0nce",
"t0ken",
)
.expect_err("a foreign nonce must refuse to start");
match error {
PageError::NonceMismatch { found } => {
assert_eq!(found, vec!["deadbeef".to_string(), "deadbeef".to_string()]);
}
other => panic!("expected a nonce mismatch, got {other:?}"),
}
}
#[test]
fn a_page_without_a_nonce_or_a_token_refuses_to_start() {
assert!(matches!(
render_page_with("<p>__SRV_TOKEN__</p>", "n0nce", "t0ken"),
Err(PageError::NonceMismatch { .. })
));
assert!(matches!(
render_page_with("<p nonce=\"__CSP_NONCE__\">x</p>", "n0nce", "t0ken"),
Err(PageError::TokenMissing)
));
}
#[test]
fn nonce_attributes_are_compared_byte_for_byte() {
assert!(check_nonce_attributes(r#"<a nonce="abc">"#, "abc").is_ok());
assert!(check_nonce_attributes(r#"<a nonce="abc"><b nonce="ABC">"#, "abc").is_err());
assert!(check_nonce_attributes("<a>", "abc").is_err());
assert!(check_nonce_attributes(r#"<!-- nonce="…" --><a nonce="abc">"#, "abc").is_ok());
}
#[test]
fn html_comments_are_removed_including_an_unterminated_tail() {
assert_eq!(strip_html_comments("a<!-- b -->c"), "ac");
assert_eq!(strip_html_comments("a<!-- b -->c<!-- d -->e"), "ace");
assert_eq!(strip_html_comments("a<!-- b"), "a");
assert_eq!(strip_html_comments("plain"), "plain");
}
}