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", "CheckerWitness"];
23
24/// Emitted on a green verdict. All trust-bearing byte facts and claim metadata
25/// are checked by the embedded Lean wall; Rust performs no parallel verdict
26/// reconstruction.
27pub 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/// Developer preflight result. A green value means the checker-owned witness
60/// elaborated successfully while trusting the local Lake `.olean` graph. It is
61/// deliberately distinct from [`Verdict`]: only [`verify`] performs the final
62/// fresh-environment replay required for certification.
63#[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
136/// Fast developer/CI preflight which trusts locally built or explicitly
137/// cached `.olean` imports. Rust validation, `lake build`, and fresh
138/// elaboration of the checker-owned witness still run; only the final
139/// `leanchecker --fresh` whole-closure replay is omitted.
140///
141/// This function cannot produce [`Verdict::Certified`]. Release and admission
142/// gates must use [`verify`].
143pub 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    // Artifact acceptance reasons about a valid WebAssembly module. The Lean
212    // wall decodes every trust-bearing section and instruction, but it is not
213    // yet a complete Wasm validation algorithm (stack/control typing included).
214    // Keep this one standard validator gate; none of the producer classifier or
215    // obligation reconstruction is linked into the verifier.
216    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}