1use crate::cache::{ArtifactBuildCache, KeyMaterial as ArtifactCacheKeyMaterial};
10use crate::lean_process::LeanRunner;
11use crate::prelude_cache::PristineWallCache;
12use crate::{format, wall};
13use colored::Colorize;
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16use std::path::{Path, PathBuf};
17
18const AXIOM_WHITELIST: [&str; 3] = ["propext", "Classical.choice", "Quot.sound"];
19const CHECKED_ROOT: &str = "AverCertChecker.checked";
20const MAX_CANDIDATE_LEN: usize = 200;
21const TOOLCHAIN_ROOTS: [&str; 4] = ["Init", "Lake", "Lean", "Std"];
22const FRESH_REPLAY_ARGS: [&str; 4] = ["env", "leanchecker", "--fresh", "CheckerWitness"];
23
24pub const ARTIFACT_DECODE_LINE: &str =
28 "artifact-check: exact bytes and manifest accepted by the checker-owned Lean predicate";
29
30const CODE_EXEC_TOKENS: [&str; 20] = [
31 "#eval",
32 "run_cmd",
33 "run_elab",
34 "run_tac",
35 "initialize",
36 "builtin_initialize",
37 "macro",
38 "macro_rules",
39 "elab",
40 "elab_rules",
41 "syntax",
42 "notation",
43 "unsafe",
44 "implemented_by",
45 "extern",
46 "deriving",
47 "attribute",
48 "@[",
49 "«",
50 "open Lean",
51];
52
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub enum Verdict {
55 Certified { summary: String, faces: Vec<String> },
56 NoExports(String),
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
64pub enum CheckVerdict {
65 Checked { summary: String, faces: Vec<String> },
66 NoExports(String),
67}
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum Explanation {
71 Certified,
72 NoExports,
73}
74
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76enum ReplayMode {
77 Fresh,
78 TrustBuiltOleans,
79}
80
81struct CertifiedExport {
82 name: String,
83 policy: String,
84 face: String,
85}
86
87struct TrustedReport {
88 exports: Vec<CertifiedExport>,
89 contracts: Vec<String>,
90 profile: String,
91 abi: String,
92 artifact_hash: String,
93}
94
95struct CertifiedCandidate {
96 name: String,
97 class: String,
98 policy: String,
99 policy_lean: &'static str,
100 termination_lean: String,
101 dom: String,
102 cod: String,
103}
104
105#[derive(Clone, Copy)]
106enum StringHostRole {
107 Eq,
108 Concat,
109}
110
111struct Candidates {
112 certified: Vec<CertifiedCandidate>,
113 contracts: Vec<String>,
114 declared_uncertified: Vec<(String, String)>,
115 capabilities: Vec<(String, String)>,
116 start: Option<u32>,
117 host_role_table: (Option<u32>, Option<u32>, Option<u32>, Option<u32>),
118 string_host_roles: Vec<(u32, StringHostRole)>,
119 profile: String,
120 abi: String,
121}
122
123pub fn verify(artifact: &Path, cert_dir: &Path) -> Result<Verdict, String> {
124 let report = trusted_check(artifact, cert_dir, ReplayMode::Fresh)?;
125 let summary = summarize_report(artifact, report, "certified");
126 if summary.count == 0 {
127 Ok(Verdict::NoExports(summary.text))
128 } else {
129 Ok(Verdict::Certified {
130 summary: summary.text,
131 faces: summary.faces,
132 })
133 }
134}
135
136pub fn check(artifact: &Path, cert_dir: &Path) -> Result<CheckVerdict, String> {
144 let report = trusted_check(artifact, cert_dir, ReplayMode::TrustBuiltOleans)?;
145 let summary = summarize_report(artifact, report, "checked");
146 if summary.count == 0 {
147 Ok(CheckVerdict::NoExports(summary.text))
148 } else {
149 Ok(CheckVerdict::Checked {
150 summary: summary.text,
151 faces: summary.faces,
152 })
153 }
154}
155
156struct ReportSummary {
157 text: String,
158 faces: Vec<String>,
159 count: usize,
160}
161
162fn summarize_report(artifact: &Path, report: TrustedReport, status: &'static str) -> ReportSummary {
163 let count = report.exports.len();
164 let has_total = report
165 .exports
166 .iter()
167 .any(|export| export.policy == "simulatesModelTotally");
168 let has_partial = report
169 .exports
170 .iter()
171 .any(|export| export.policy == "simulatesModel");
172 let level = match (has_partial, has_total) {
173 (true, true) => "mixed L1/L3",
174 (false, true) => "L3",
175 _ => "L1",
176 };
177 let text = format!(
178 "{} ({} {status} export{}, level {})",
179 artifact.display(),
180 count,
181 if count == 1 { "" } else { "s" },
182 level,
183 );
184 let faces = report
185 .exports
186 .into_iter()
187 .map(|export| {
188 format!(
189 "{} policy: {} {}",
190 export.name, export.policy, export.face
191 )
192 })
193 .collect();
194 ReportSummary { text, faces, count }
195}
196
197fn kernel_replay_args(mode: ReplayMode) -> Option<&'static [&'static str]> {
198 match mode {
199 ReplayMode::Fresh => Some(&FRESH_REPLAY_ARGS),
200 ReplayMode::TrustBuiltOleans => None,
201 }
202}
203
204fn trusted_check(
205 artifact: &Path,
206 cert_dir: &Path,
207 replay_mode: ReplayMode,
208) -> Result<TrustedReport, String> {
209 let bytes = std::fs::read(artifact)
210 .map_err(|error| format!("cannot read artifact {}: {error}", artifact.display()))?;
211 wasmparser::Validator::new()
217 .validate_all(&bytes)
218 .map_err(|error| format!("artifact is not valid WebAssembly: {error}"))?;
219 let actual_hash = sha256_hex(&bytes);
220 let manifest = read_manifest(cert_dir)?;
221
222 let schema_version = manifest_u64(&manifest, "schema_version")?;
223 if schema_version != format::CERT_SCHEMA_VERSION as u64 {
224 return Err(format!(
225 "unsupported certificate schema_version {schema_version}; this checker accepts {}",
226 format::CERT_SCHEMA_VERSION
227 ));
228 }
229 let pinned_hash = manifest_str(&manifest, "wasm_sha256")?;
230 if pinned_hash != actual_hash {
231 return Err(format!(
232 "artifact hash mismatch: {} hashes to {actual_hash}, certificate pins {pinned_hash}",
233 artifact.display()
234 ));
235 }
236
237 let format_object = manifest
238 .get("format")
239 .and_then(Value::as_object)
240 .ok_or_else(|| "cert-manifest.json is missing object field `format`".to_string())?;
241 let format_version = format_object
242 .get("version")
243 .and_then(Value::as_u64)
244 .ok_or_else(|| "cert-manifest.json `format.version` must be an integer".to_string())?;
245 if format_version != format::FORMAT_VERSION as u64 {
246 return Err(format!(
247 "unsupported certificate format version {format_version}; this checker accepts {}",
248 format::FORMAT_VERSION
249 ));
250 }
251 let wall_id = format_object
252 .get("wall_id")
253 .and_then(Value::as_str)
254 .ok_or_else(|| "cert-manifest.json `format.wall_id` must be a string".to_string())?;
255 let selected_wall = wall::resolve(wall_id).ok_or_else(|| {
256 format!("unsupported certificate wall `{wall_id}`; no embedded wall matches")
257 })?;
258 let artifact_root = manifest_str(&manifest, "artifact_certificate_root")?;
259 if artifact_root != format::ARTIFACT_CERTIFICATE_ROOT {
260 return Err(format!(
261 "artifact certificate root mismatch: certificate pins {artifact_root}, checker expects {}",
262 format::ARTIFACT_CERTIFICATE_ROOT
263 ));
264 }
265
266 let candidates = read_candidates(&manifest)?;
267 let build = assemble_build(cert_dir, &bytes, selected_wall)?;
268 let lean = LeanRunner::new(selected_wall.toolchain)?;
269 let cache_pins = [("wasm_sha256", pinned_hash), ("wall_id", wall_id)];
270 let mut cache = ArtifactBuildCache::prepare(
271 &build.path,
272 &ArtifactCacheKeyMaterial {
273 schema_version,
274 pinned_sha256: &cache_pins,
275 toolchain_version: selected_wall.toolchain.trim(),
276 },
277 );
278 let data_cache_hit = cache.was_hit();
279 let mut wall_cache = if data_cache_hit {
280 PristineWallCache::disabled()
281 } else {
282 PristineWallCache::prepare(&build.path, selected_wall, &lean)
283 };
284
285 let mut data_build = run_lake(&lean, &build.path, &["build"])?;
286 if !data_build.status.success() && (data_cache_hit || wall_cache.was_seeded()) {
287 if data_cache_hit {
288 cache.invalidate(&build.path);
289 } else {
290 wall_cache.clear_build(&build.path);
291 }
292 data_build = run_lake(&lean, &build.path, &["build"])?;
293 if data_build.status.success() && wall_cache.was_seeded() {
294 wall_cache.evict();
295 }
296 }
297 if !data_build.status.success() {
298 return Err(format!(
299 "certificate data did not build:\n{}",
300 tail(&data_build.combined, 30)
301 ));
302 }
303 cache.publish(&build.path);
304
305 let witness = checker_witness(&actual_hash, &candidates);
306 std::fs::write(build.path.join("CheckerWitness.lean"), witness)
307 .map_err(|error| format!("cannot write checker witness: {error}"))?;
308 let elaborated = run_lake(
309 &lean,
310 &build.path,
311 &[
312 "env",
313 "lean",
314 "-o",
315 ".lake/build/lib/lean/CheckerWitness.olean",
316 "CheckerWitness.lean",
317 ],
318 )?;
319 if !elaborated.status.success() {
320 return Err(format!(
321 "certificate does not bind to this artifact: the checker-owned Lean witness failed:\n{}",
322 tail(&elaborated.combined, 30)
323 ));
324 }
325 if let Some(replay_args) = kernel_replay_args(replay_mode) {
326 let replayed = run_lake(&lean, &build.path, replay_args)?;
327 if !replayed.status.success() {
328 return Err(format!(
329 "certificate failed fresh-environment kernel replay:\n{}",
330 tail(&replayed.combined, 30)
331 ));
332 }
333 }
334
335 let exports = candidates
336 .certified
337 .iter()
338 .map(|candidate| CertifiedExport {
339 name: candidate.name.clone(),
340 policy: candidate.policy.clone(),
341 face: report_face(candidate),
342 })
343 .collect();
344 Ok(TrustedReport {
345 exports,
346 contracts: candidates.contracts,
347 profile: candidates.profile,
348 abi: candidates.abi,
349 artifact_hash: actual_hash,
350 })
351}
352
353fn report_face(candidate: &CertifiedCandidate) -> String {
354 let label = match candidate.class.as_str() {
355 "expr-fragment-v1" => "expression fragment",
356 "verbatim-string-eq" => "String.eq leaf",
357 "verbatim-string-concat" => "String.concat leaf",
358 "adt-constructor" => "ADT constructor",
359 "self-recursive" => "integer recursion",
360 "multi-argument self-recursive" => "integer accumulator recursion",
361 "mutual-recursive" => "mutual integer recursion",
362 "verbatim-dispatch" => "verbatim dispatch",
363 "int-dispatch" => "integer ADT dispatch",
364 "field-projection" => "field projection",
365 "cross-function-composition" => "cross-function composition",
366 other => other,
367 };
368 format!(
369 "class: {label} | certificate face: Dom {}, Cod {}",
370 display_safe(&candidate.dom),
371 display_safe(&candidate.cod)
372 )
373}
374
375fn checker_witness(sha: &str, candidates: &Candidates) -> String {
376 let names = lean_str_list(
377 &candidates
378 .certified
379 .iter()
380 .map(|candidate| candidate.name.clone())
381 .collect::<Vec<_>>(),
382 );
383 let report_entries = lean_string_pair_list(
384 &candidates
385 .certified
386 .iter()
387 .map(|candidate| (candidate.name.clone(), candidate.class.clone()))
388 .collect::<Vec<_>>(),
389 );
390 let policies = format!(
391 "[{}]",
392 candidates
393 .certified
394 .iter()
395 .map(|candidate| candidate.policy_lean)
396 .collect::<Vec<_>>()
397 .join(", ")
398 );
399 let terminations = format!(
400 "[{}]",
401 candidates
402 .certified
403 .iter()
404 .map(|candidate| candidate.termination_lean.as_str())
405 .collect::<Vec<_>>()
406 .join(", ")
407 );
408 let contracts = lean_str_list(&candidates.contracts);
409 let declared = lean_string_pair_list(&candidates.declared_uncertified);
410 let capabilities = lean_string_pair_list(&candidates.capabilities);
411 let start = lean_option_nat(candidates.start);
412 let roles = format!(
413 "({{ box := {}, add := {}, mul := {}, sub := {} }} : CertDecode.AddSub.Roles)",
414 lean_option_nat(candidates.host_role_table.0),
415 lean_option_nat(candidates.host_role_table.1),
416 lean_option_nat(candidates.host_role_table.2),
417 lean_option_nat(candidates.host_role_table.3),
418 );
419 let string_roles = format!(
420 "[{}]",
421 candidates
422 .string_host_roles
423 .iter()
424 .map(|(index, role)| {
425 let role = match role {
426 StringHostRole::Eq => ".eq",
427 StringHostRole::Concat => ".concat",
428 };
429 format!("({index}, {role})")
430 })
431 .collect::<Vec<_>>()
432 .join(", ")
433 );
434 let allowed = AXIOM_WHITELIST
435 .iter()
436 .map(|name| format!("`{name}"))
437 .collect::<Vec<_>>()
438 .join(", ");
439 format!(
440 "-- Authored by aver-cert; never accepted from the certificate.\n\
441 import Lean\n\
442 import AcceptedArtifact\n\
443 import ArtifactBytes\n\
444 import Manifest\n\
445 import Artifact\n\n\
446 import ArtifactCertificate\n\n\
447 set_option maxRecDepth 200000\n\n\
448 namespace AverCertChecker\n\n\
449 example : AverCert.Artifact.data.modBytes = AverCert.ArtifactBytes.modBytes := rfl\n\
450 example : AverCert.Artifact.data.modLen = AverCert.ArtifactBytes.modLen := rfl\n\
451 example : AverCert.Artifact.data.manifest = AverCert.manifest := rfl\n\n\
452 example : AverCert.manifest.subject.artifactHash = \"{sha}\" := rfl\n\
453 example : AverCert.manifest.subject.artifactRoot = \"{}\" := rfl\n\
454 example : AverCert.manifest.obligations.map (fun o => o.export_) = {names} := rfl\n\
455 example : AverCert.manifest.subject.exports = {names} := rfl\n\
456 example : AverCert.StandardFace.reportEntries AverCert.Artifact.data = some {report_entries} := rfl\n\
457 example : AverCert.manifest.obligations.map (fun o => o.policy) = {policies} := rfl\n\
458 example : AverCert.manifest.obligations.map (fun o => o.termination?) = {terminations} := rfl\n\
459 example : AverCert.manifest.subject.contracts = {contracts} := rfl\n\
460 example : AverCert.manifest.subject.declaredUncertified = {declared} := rfl\n\
461 example : AverCert.manifest.subject.capabilities = {capabilities} := rfl\n\
462 example : AverCert.manifest.subject.start = {start} := rfl\n\
463 example : AverCert.manifest.subject.hostRoleTable = {roles} := rfl\n\
464 example : AverCert.manifest.subject.stringHostRoles = {string_roles} := rfl\n\
465 example : AverCert.manifest.subject.profile = \"{}\" := rfl\n\
466 example : AverCert.manifest.subject.abi = \"{}\" := rfl\n\n\
467 theorem checked : AverCert.AcceptedArtifact.accepted AverCert.Artifact.data :=\n\
468 AverCert.Artifact.certificate\n\n\
469 end AverCertChecker\n\n\
470 open Lean in\n\
471 run_cmd do\n \
472 let allowed : List Lean.Name := [{allowed}]\n \
473 let axioms ← Lean.collectAxioms `{CHECKED_ROOT}\n \
474 for usedAxiom in axioms do\n \
475 unless allowed.contains usedAxiom do\n \
476 throwError s!\"non-whitelisted axiom: {{usedAxiom}}\"\n",
477 format::ARTIFACT_CERTIFICATE_ROOT,
478 candidates.profile,
479 candidates.abi,
480 )
481}
482
483fn read_manifest(cert_dir: &Path) -> Result<Value, String> {
484 let path = cert_dir.join("cert-manifest.json");
485 let text = std::fs::read_to_string(&path)
486 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
487 serde_json::from_str(&text)
488 .map_err(|error| format!("cert-manifest.json is not valid JSON: {error}"))
489}
490
491fn manifest_str<'a>(manifest: &'a Value, key: &str) -> Result<&'a str, String> {
492 manifest
493 .get(key)
494 .and_then(Value::as_str)
495 .ok_or_else(|| format!("cert-manifest.json is missing string field `{key}`"))
496}
497
498fn manifest_u64(manifest: &Value, key: &str) -> Result<u64, String> {
499 manifest
500 .get(key)
501 .and_then(Value::as_u64)
502 .ok_or_else(|| format!("cert-manifest.json is missing integer field `{key}`"))
503}
504
505fn read_candidates(manifest: &Value) -> Result<Candidates, String> {
506 let profile = manifest_str(manifest, "profile")?.to_string();
507 let abi = manifest_str(manifest, "abi")?.to_string();
508 let certified_json = manifest
509 .get("certified")
510 .and_then(Value::as_array)
511 .ok_or_else(|| "cert-manifest.json is missing array field `certified`".to_string())?;
512 let mut certified = Vec::with_capacity(certified_json.len());
513 for entry in certified_json {
514 let name = required_string(entry, "name", "certified[]")?;
515 let class = required_string(entry, "class", "certified[]")?;
516 let policy = required_string(entry, "policy", "certified[]")?;
517 let policy_lean = match policy.as_str() {
518 "simulatesModel" => ".simulatesModel",
519 "simulatesModelTotally" => ".simulatesModelTotally",
520 other => {
521 return Err(format!(
522 "certified export `{name}` uses unsupported policy `{other}`"
523 ));
524 }
525 };
526 let termination_lean = parse_termination(entry.get("termination_witness"), &name)?;
527 match (policy.as_str(), entry.get("termination_witness")) {
528 ("simulatesModel", None) | ("simulatesModelTotally", Some(_)) => {}
529 ("simulatesModel", Some(_)) => {
530 return Err(format!(
531 "partial export `{name}` must not carry a termination witness"
532 ));
533 }
534 ("simulatesModelTotally", None) => {
535 return Err(format!(
536 "total export `{name}` is missing `termination_witness`"
537 ));
538 }
539 _ => unreachable!(),
540 }
541 certified.push(CertifiedCandidate {
542 name,
543 class,
544 policy,
545 policy_lean,
546 termination_lean,
547 dom: required_string(entry, "dom", "certified[]")?,
548 cod: required_string(entry, "cod", "certified[]")?,
549 });
550 }
551
552 let contracts = string_array(manifest, "runtime_contracts")?;
553 let declared_uncertified =
554 object_pair_array(manifest, "declaredUncertified", "name", "reason")?;
555 let capabilities = object_pair_array(manifest, "capabilities", "module", "name")?;
556 let start_object = manifest
557 .get("start")
558 .ok_or_else(|| "cert-manifest.json is missing object field `start`".to_string())?;
559 exact_object_fields(start_object, "start", &["present", "function_index"])?;
560 let present = start_object
561 .get("present")
562 .and_then(Value::as_bool)
563 .ok_or_else(|| "cert-manifest.json `start.present` is not a boolean".to_string())?;
564 let start = match (present, start_object.get("function_index")) {
565 (false, Some(Value::Null)) => None,
566 (true, Some(value)) => Some(value_u32(value, "start.function_index")?),
567 (false, _) => {
568 return Err("absent start must use null `function_index`".to_string());
569 }
570 (true, None) => unreachable!("exact fields checked"),
571 };
572
573 let host_roles = manifest
574 .get("hostRoleTable")
575 .ok_or_else(|| "cert-manifest.json is missing object field `hostRoleTable`".to_string())?;
576 exact_object_fields(host_roles, "hostRoleTable", &["box", "add", "mul", "sub"])?;
577 let optional_index = |key: &str| -> Result<Option<u32>, String> {
578 match &host_roles[key] {
579 Value::Null => Ok(None),
580 value => Ok(Some(value_u32(value, &format!("hostRoleTable.{key}"))?)),
581 }
582 };
583 let host_role_table = (
584 optional_index("box")?,
585 optional_index("add")?,
586 optional_index("mul")?,
587 optional_index("sub")?,
588 );
589
590 let string_roles_json = manifest
591 .get("stringHostRoles")
592 .and_then(Value::as_array)
593 .ok_or_else(|| "cert-manifest.json is missing array field `stringHostRoles`".to_string())?;
594 let mut string_host_roles = Vec::with_capacity(string_roles_json.len());
595 for (index, entry) in string_roles_json.iter().enumerate() {
596 exact_object_fields(
597 entry,
598 &format!("stringHostRoles[{index}]"),
599 &["function_index", "role"],
600 )?;
601 let function_index = value_u32(
602 &entry["function_index"],
603 &format!("stringHostRoles[{index}].function_index"),
604 )?;
605 let role = match entry.get("role").and_then(Value::as_str) {
606 Some("stringEq") => StringHostRole::Eq,
607 Some("stringConcat") => StringHostRole::Concat,
608 _ => {
609 return Err(format!(
610 "stringHostRoles[{index}].role must be stringEq or stringConcat"
611 ));
612 }
613 };
614 string_host_roles.push((function_index, role));
615 }
616
617 let candidates = Candidates {
618 certified,
619 contracts,
620 declared_uncertified,
621 capabilities,
622 start,
623 host_role_table,
624 string_host_roles,
625 profile,
626 abi,
627 };
628 gate_candidates(&candidates)?;
629 Ok(candidates)
630}
631
632fn parse_termination(value: Option<&Value>, export: &str) -> Result<String, String> {
633 let Some(value) = value else {
634 return Ok("none".to_string());
635 };
636 let measure = value
637 .get("measure")
638 .and_then(Value::as_object)
639 .ok_or_else(|| format!("export `{export}` has malformed termination measure"))?;
640 if measure.get("kind").and_then(Value::as_str) != Some("intNatAbs") {
641 return Err(format!(
642 "export `{export}` uses an unsupported termination measure"
643 ));
644 }
645 let parameter = measure
646 .get("param_index")
647 .and_then(Value::as_u64)
648 .and_then(|value| u32::try_from(value).ok())
649 .ok_or_else(|| format!("export `{export}` has invalid termination parameter"))?;
650 let descent = value
651 .get("descent")
652 .and_then(Value::as_i64)
653 .ok_or_else(|| format!("export `{export}` has invalid termination descent"))?;
654 Ok(format!(
655 "some ({{ measure := .intNatAbs {parameter}, descent := ({descent} : Int) }} : AverCert.Schema.TerminationWitness)"
656 ))
657}
658
659fn required_string(value: &Value, key: &str, context: &str) -> Result<String, String> {
660 value
661 .get(key)
662 .and_then(Value::as_str)
663 .map(str::to_string)
664 .ok_or_else(|| format!("cert-manifest.json `{context}.{key}` is not a string"))
665}
666
667fn string_array(manifest: &Value, key: &str) -> Result<Vec<String>, String> {
668 manifest
669 .get(key)
670 .and_then(Value::as_array)
671 .ok_or_else(|| format!("cert-manifest.json is missing array field `{key}`"))?
672 .iter()
673 .map(|value| {
674 value
675 .as_str()
676 .map(str::to_string)
677 .ok_or_else(|| format!("cert-manifest.json `{key}[]` is not a string"))
678 })
679 .collect()
680}
681
682fn object_pair_array(
683 manifest: &Value,
684 key: &str,
685 left: &str,
686 right: &str,
687) -> Result<Vec<(String, String)>, String> {
688 manifest
689 .get(key)
690 .and_then(Value::as_array)
691 .ok_or_else(|| format!("cert-manifest.json is missing array field `{key}`"))?
692 .iter()
693 .enumerate()
694 .map(|(index, value)| {
695 exact_object_fields(value, &format!("{key}[{index}]"), &[left, right])?;
696 Ok((
697 required_string(value, left, &format!("{key}[{index}]"))?,
698 required_string(value, right, &format!("{key}[{index}]"))?,
699 ))
700 })
701 .collect()
702}
703
704fn exact_object_fields(value: &Value, context: &str, expected: &[&str]) -> Result<(), String> {
705 let object = value
706 .as_object()
707 .ok_or_else(|| format!("cert-manifest.json `{context}` is not an object"))?;
708 if object.len() != expected.len() || expected.iter().any(|key| !object.contains_key(*key)) {
709 return Err(format!(
710 "cert-manifest.json `{context}` must contain exactly fields {}",
711 expected.join(", ")
712 ));
713 }
714 Ok(())
715}
716
717fn value_u32(value: &Value, context: &str) -> Result<u32, String> {
718 value
719 .as_u64()
720 .and_then(|value| u32::try_from(value).ok())
721 .ok_or_else(|| format!("cert-manifest.json `{context}` must be a u32"))
722}
723
724fn gate_candidates(candidates: &Candidates) -> Result<(), String> {
725 for candidate in &candidates.certified {
726 gate_candidate("certified export name", &candidate.name)?;
727 gate_candidate("certified class", &candidate.class)?;
728 gate_candidate("source domain", &candidate.dom)?;
729 gate_candidate("source codomain", &candidate.cod)?;
730 }
731 for contract in &candidates.contracts {
732 gate_candidate("runtime contract", contract)?;
733 }
734 for (name, reason) in &candidates.declared_uncertified {
735 gate_candidate("declared-uncertified name", name)?;
736 gate_candidate("declared-uncertified reason", reason)?;
737 }
738 for (module, name) in &candidates.capabilities {
739 gate_candidate("capability module", module)?;
740 gate_candidate("capability name", name)?;
741 }
742 gate_candidate("profile", &candidates.profile)?;
743 gate_candidate("abi", &candidates.abi)
744}
745
746fn gate_candidate(kind: &str, value: &str) -> Result<(), String> {
747 let safe = value.len() <= MAX_CANDIDATE_LEN
748 && value
749 .bytes()
750 .all(|byte| (0x20..=0x7e).contains(&byte) && byte != b'"' && byte != b'\\');
751 if safe {
752 Ok(())
753 } else {
754 Err(format!(
755 "certificate {kind} is outside the allowed printable ASCII subset: {value:?}"
756 ))
757 }
758}
759
760fn is_checker_owned(name: &str, selected_wall: &wall::Wall) -> bool {
761 selected_wall
762 .sources
763 .iter()
764 .any(|source| source.name == name)
765 || matches!(
766 name,
767 "ArtifactBytes.lean" | "lakefile.lean" | "CheckerWitness.lean"
768 )
769}
770
771fn assemble_build(
772 cert_dir: &Path,
773 wasm_bytes: &[u8],
774 selected_wall: &wall::Wall,
775) -> Result<BuildDir, String> {
776 let build = BuildDir::new()?;
777 let mut roots = Vec::new();
778 let entries = std::fs::read_dir(cert_dir)
779 .map_err(|error| format!("cannot read cert dir {}: {error}", cert_dir.display()))?;
780 for entry in entries {
781 let entry = entry.map_err(|error| format!("cert dir read: {error}"))?;
782 if !entry
783 .file_type()
784 .map(|kind| kind.is_file())
785 .unwrap_or(false)
786 {
787 continue;
788 }
789 let name = entry.file_name().to_string_lossy().into_owned();
790 if !name.ends_with(".lean") || is_checker_owned(&name, selected_wall) {
791 continue;
792 }
793 let root = lean_module_root(&name)?;
794 let shadows_toolchain = TOOLCHAIN_ROOTS
795 .iter()
796 .any(|reserved| reserved.eq_ignore_ascii_case(&root));
797 let shadows_checker = selected_wall.sources.iter().any(|source| {
798 source
799 .name
800 .strip_suffix(".lean")
801 .is_some_and(|reserved| reserved.eq_ignore_ascii_case(&root))
802 }) || ["ArtifactBytes", "CheckerWitness", "lakefile"]
803 .iter()
804 .any(|reserved| reserved.eq_ignore_ascii_case(&root));
805 if shadows_toolchain || shadows_checker {
806 return Err(format!(
807 "cert data module `{root}` shadows a checker/toolchain import"
808 ));
809 }
810 let contents = std::fs::read(entry.path())
811 .map_err(|error| format!("cannot read cert file {name}: {error}"))?;
812 scan_for_code_exec(&name, &contents)?;
813 std::fs::write(build.path.join(&name), contents)
814 .map_err(|error| format!("cannot stage {name}: {error}"))?;
815 roots.push(root);
816 }
817 for source in selected_wall.sources {
818 std::fs::write(build.path.join(source.name), source.contents)
819 .map_err(|error| format!("cannot stage {}: {error}", source.name))?;
820 roots.push(
821 source
822 .name
823 .strip_suffix(".lean")
824 .expect("wall source is Lean")
825 .to_string(),
826 );
827 }
828 std::fs::write(
829 build.path.join("ArtifactBytes.lean"),
830 wall::render_artifact_bytes(wasm_bytes),
831 )
832 .map_err(|error| format!("cannot stage ArtifactBytes.lean: {error}"))?;
833 roots.push("ArtifactBytes".to_string());
834 roots.sort();
835 roots.dedup();
836 std::fs::write(build.path.join("lakefile.lean"), checker_lakefile(&roots))
837 .map_err(|error| format!("cannot write checker lakefile: {error}"))?;
838 std::fs::write(build.path.join("lean-toolchain"), selected_wall.toolchain)
839 .map_err(|error| format!("cannot write lean-toolchain: {error}"))?;
840 Ok(build)
841}
842
843fn lean_module_root(name: &str) -> Result<String, String> {
844 let stem = name
845 .strip_suffix(".lean")
846 .ok_or_else(|| format!("cert file `{name}` is not a Lean file"))?;
847 let mut chars = stem.chars();
848 let valid = matches!(chars.next(), Some(first) if first.is_ascii_alphabetic())
849 && chars.all(|character| character.is_ascii_alphanumeric() || character == '_');
850 if stem.is_empty() || !valid {
851 Err(format!(
852 "cert file name `{name}` must match ^[A-Za-z][A-Za-z0-9_]*\\.lean$"
853 ))
854 } else {
855 Ok(stem.to_string())
856 }
857}
858
859fn scan_for_code_exec(name: &str, contents: &[u8]) -> Result<(), String> {
860 let text = String::from_utf8_lossy(contents);
861 for token in CODE_EXEC_TOKENS {
862 if text.contains(token) {
863 return Err(format!(
864 "cert data file `{name}` contains elaboration-executing token `{token}`"
865 ));
866 }
867 }
868 Ok(())
869}
870
871fn checker_lakefile(roots: &[String]) -> String {
872 let roots = roots
873 .iter()
874 .map(|root| format!("`{root}"))
875 .collect::<Vec<_>>()
876 .join(", ");
877 format!(
878 "import Lake\nopen Lake DSL\n\npackage «avercert» where\n version := v!\"0.1.0\"\n\n@[default_target]\nlean_lib «AverCert» where\n srcDir := \".\"\n roots := #[{roots}]\n"
879 )
880}
881
882fn lean_str_list(items: &[String]) -> String {
883 format!(
884 "[{}]",
885 items
886 .iter()
887 .map(|item| format!("\"{item}\""))
888 .collect::<Vec<_>>()
889 .join(", ")
890 )
891}
892
893fn lean_string_pair_list(items: &[(String, String)]) -> String {
894 format!(
895 "[{}]",
896 items
897 .iter()
898 .map(|(left, right)| format!("(\"{left}\", \"{right}\")"))
899 .collect::<Vec<_>>()
900 .join(", ")
901 )
902}
903
904fn lean_option_nat(value: Option<u32>) -> String {
905 value.map_or_else(|| "none".to_string(), |value| format!("some {value}"))
906}
907
908fn sha256_hex(bytes: &[u8]) -> String {
909 format!("{:x}", Sha256::digest(bytes))
910}
911
912struct BuildDir {
913 path: PathBuf,
914}
915
916impl BuildDir {
917 fn new() -> Result<Self, String> {
918 let path = checker_temp_root()?.join(format!(
919 "aver-cert-check-{}-{}",
920 std::process::id(),
921 unique_nanos()
922 ));
923 let mut builder = std::fs::DirBuilder::new();
924 #[cfg(unix)]
925 {
926 use std::os::unix::fs::DirBuilderExt;
927 builder.mode(0o700);
928 }
929 builder
930 .create(&path)
931 .map_err(|error| format!("cannot create checker build dir: {error}"))?;
932 Ok(Self { path })
933 }
934}
935
936fn checker_temp_root() -> Result<PathBuf, String> {
937 #[cfg(unix)]
938 {
939 Ok(PathBuf::from("/tmp"))
940 }
941 #[cfg(windows)]
942 {
943 let home = std::env::var_os("USERPROFILE")
944 .or_else(|| std::env::var_os("HOME"))
945 .ok_or_else(|| {
946 "cannot select checker temp root: USERPROFILE/HOME is not set".to_string()
947 })?;
948 let root = PathBuf::from(home)
949 .join("AppData")
950 .join("Local")
951 .join("Temp");
952 std::fs::create_dir_all(&root)
953 .map_err(|error| format!("cannot create checker temp root: {error}"))?;
954 Ok(root)
955 }
956 #[cfg(not(any(unix, windows)))]
957 {
958 let home = std::env::var_os("HOME")
959 .ok_or_else(|| "cannot select checker temp root: HOME is not set".to_string())?;
960 let root = PathBuf::from(home).join(".aver-cert-tmp");
961 std::fs::create_dir_all(&root)
962 .map_err(|error| format!("cannot create checker temp root: {error}"))?;
963 Ok(root)
964 }
965}
966
967impl Drop for BuildDir {
968 fn drop(&mut self) {
969 let _ = std::fs::remove_dir_all(&self.path);
970 }
971}
972
973fn unique_nanos() -> u128 {
974 std::time::SystemTime::now()
975 .duration_since(std::time::UNIX_EPOCH)
976 .map(|duration| duration.as_nanos())
977 .unwrap_or(0)
978}
979
980struct LakeOut {
981 status: std::process::ExitStatus,
982 combined: String,
983}
984
985fn run_lake(lean: &LeanRunner, build_dir: &Path, arguments: &[&str]) -> Result<LakeOut, String> {
986 let output = lean.run_lake(build_dir, arguments).map_err(|error| {
987 format!(
988 "could not run pinned `elan run … lake {}`: {error} (is Elan installed?)",
989 arguments.join(" ")
990 )
991 })?;
992 Ok(LakeOut {
993 status: output.status,
994 combined: format!(
995 "{}{}",
996 String::from_utf8_lossy(&output.stdout),
997 String::from_utf8_lossy(&output.stderr)
998 ),
999 })
1000}
1001
1002fn tail(text: &str, lines: usize) -> String {
1003 let all = text.lines().collect::<Vec<_>>();
1004 all[all.len().saturating_sub(lines)..].join("\n")
1005}
1006
1007fn display_safe(value: &str) -> String {
1008 value
1009 .chars()
1010 .map(|character| {
1011 if (' '..='~').contains(&character) {
1012 character
1013 } else {
1014 '?'
1015 }
1016 })
1017 .collect()
1018}
1019
1020pub fn explain(artifact: &Path, cert_dir: &Path) -> Result<Explanation, String> {
1021 let report = trusted_check(artifact, cert_dir, ReplayMode::Fresh)?;
1022 println!("{}", "Artifact certificate".bold());
1023 println!(" artifact: {}", artifact.display());
1024 println!(" pinned sha256: {}", report.artifact_hash);
1025 println!(" profile: {} abi: {}", report.profile, report.abi);
1026 if report.exports.is_empty() {
1027 println!("\n{}", "NO CERTIFIED EXPORTS".yellow().bold());
1028 return Ok(Explanation::NoExports);
1029 }
1030 println!("\n{}", "CERTIFIED".green().bold());
1031 for export in report.exports {
1032 println!(" {}", export.name.bold());
1033 println!(" policy: {}", export.policy);
1034 println!(" {}", export.face);
1035 }
1036 if !report.contracts.is_empty() {
1037 println!("\n{}", "Runtime contracts".yellow().bold());
1038 for contract in report.contracts {
1039 println!(" - {contract}");
1040 }
1041 }
1042
1043 let manifest = read_manifest(cert_dir)?;
1044 if let Some(declined) = manifest.get("source_level_only").and_then(Value::as_array)
1045 && !declined.is_empty()
1046 {
1047 println!("\n{}", "DECLINED (informational)".yellow().bold());
1048 for entry in declined {
1049 let name = entry
1050 .get("name")
1051 .and_then(Value::as_str)
1052 .map(display_safe)
1053 .unwrap_or_else(|| "<unknown>".to_string());
1054 let reason = entry
1055 .get("reason")
1056 .and_then(Value::as_str)
1057 .map(display_safe)
1058 .unwrap_or_else(|| "unspecified".to_string());
1059 println!(" {name}: {reason}");
1060 }
1061 }
1062 Ok(Explanation::Certified)
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068
1069 #[test]
1070 fn module_names_are_plain() {
1071 assert_eq!(lean_module_root("Artifact.lean").unwrap(), "Artifact");
1072 assert!(lean_module_root("../Artifact.lean").is_err());
1073 assert!(lean_module_root("A, `Injected.lean").is_err());
1074 }
1075
1076 #[test]
1077 fn candidate_strings_cannot_escape_lean_literals() {
1078 assert!(gate_candidate("test", "plain ASCII").is_ok());
1079 assert!(gate_candidate("test", "quote: \"").is_err());
1080 assert!(gate_candidate("test", "line\nbreak").is_err());
1081 }
1082
1083 #[test]
1084 fn artifact_bytes_are_little_endian_nat() {
1085 let rendered = wall::render_artifact_bytes(&[0x00, 0x61, 0x73, 0x6d]);
1086 assert!(rendered.contains("def modBytes : Nat := 0x6d736100"));
1087 assert!(rendered.contains("def modLen : Nat := 4"));
1088 }
1089
1090 #[test]
1091 fn strict_and_trusted_olean_modes_differ_only_at_fresh_replay_dispatch() {
1092 assert_eq!(
1093 kernel_replay_args(ReplayMode::Fresh),
1094 Some(FRESH_REPLAY_ARGS.as_slice())
1095 );
1096 assert_eq!(kernel_replay_args(ReplayMode::TrustBuiltOleans), None);
1097 }
1098}