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