Skip to main content

aver_cert/
verifier.rs

1//! Minimal consumer for Aver artifact certificates.
2//!
3//! Rust owns transport: it selects the embedded wall, stages untrusted DATA,
4//! injects the exact artifact bytes, and runs Lean. It does not disassemble the
5//! module or reconstruct an obligation. The checker-owned Lean predicate binds
6//! every accepted claim to the bytes and derives its standard face, policy,
7//! termination witness, host table, and runtime contracts.
8
9use 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"];
23/// User-facing name of the `lake build` step in timeout and failure messages.
24const PROOF_BUILD_PHASE: &str = "certificate proof build";
25
26/// Emitted on a green verdict. All trust-bearing byte facts and claim metadata
27/// are checked by the embedded Lean wall; Rust performs no parallel verdict
28/// reconstruction.
29pub 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/// Developer preflight result. A green value means the checker-owned witness
62/// elaborated successfully while trusting the local Lake `.olean` graph. It is
63/// deliberately distinct from [`Verdict`]: only [`verify`] performs the final
64/// fresh-environment replay required for certification.
65#[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
141/// Fast developer/CI preflight which trusts locally built or explicitly
142/// cached `.olean` imports. Rust validation, `lake build`, and fresh
143/// elaboration of the checker-owned witness still run; only the final
144/// `leanchecker --fresh` whole-closure replay is omitted.
145///
146/// This function cannot produce [`Verdict::Certified`]. Release and admission
147/// gates must use [`verify`].
148pub 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    // Artifact acceptance reasons about a valid WebAssembly module. The Lean
217    // wall decodes every trust-bearing section and instruction, but it is not
218    // yet a complete Wasm validation algorithm (stack/control typing included).
219    // Keep this one standard validator gate; none of the producer classifier or
220    // obligation reconstruction is linked into the verifier.
221    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
360/// The per-export line printed under a CERTIFIED/CHECKED verdict. Everything
361/// on it must be kernel-pinned: the class is rfl-bound to
362/// `StandardFace.reportEntries` by the checker witness (like the name, policy,
363/// and termination). The manifest's `dom`/`cod` strings are NOT pinned by any
364/// witness line, so they must never appear here — `explain` shows them,
365/// explicitly labeled as manifest-declared.
366fn 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    // `null` declares the absence of a host-role table (a module without the
597    // Int carrier); the Lean witness pins that declaration against the byte
598    // decoder returning `none`, so it stays exactly as constraining as the
599    // `some`-table case.
600    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    // Any step failure — including a timeout — fails the whole verify/check
1020    // closed; only the opt-in prelude cache may downgrade a step error.
1021    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}