From bd59031dcd7b0b6a816b8f3e966a9f8ecd610f9e Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Thu, 1 Oct 2026 18:43:13 -0400 Subject: [PATCH] Verify doc-owned specifications over recursive mathematical models Associate spec-only Rust doc fences with their syntax owners and derive original binders from verified signatures. Recursively decode fields into nominal mathematical carriers; inline type models may refine those fields with a fallible decoder. Plain clauses and dependent ghosts use decoded values, while explicit raw clauses retain extracted representations. Freeze native providers and thread every original type parameter's dictionary explicitly. Successful execution must produce a decodable result shared by all postconditions. Check ordinary canonical proofs and independent expectations against both complete models. The initial scope covers four arithmetic helpers; broad Raw lemmas retain their representation domains. Preserve nominal tuple structs using a reproducible patch exposing the existing upstream option. Audit source snapshots, exact annotation ownership and axioms; retain source maps and a stable Lean development project. Define generic shapes solely over mathematical carrier types. Generate structural decoder decomposition with retained child witnesses. Complete only omitted proposition proofs at decoder construction sites, bounded by the caller heartbeat budget, and preserve native module exports. Protect edited Lean projections and inspect compiled effective specs. Add native Lean edit regions and guarded copy-back into owned Rust doc fences. Preserve unrelated edits and bytes, reject stale source or generated scaffolding changes, and recover interrupted source/baseline replacements. Validate native editor/copy/regeneration round trips at the first and final stack stages and preserve byte-identical CI assembly throughout the stack. Compare every inline contract with its independent expectation for arbitrary execution outcomes, then specialize to the canonical implementation proof. Record the complete design and deferred decisions in DESIGN.md. Derive fixed registration names from their owner and share nominal case assembly. Normalize source ownership once, retain generated lines with their locations, and reuse checked editing regions. Share file rosters while keeping independent source ownership and contract adequacy checks separate. Explain the pipeline and the purpose of its guards for readers new to the integration. Derive extraction roots solely from present annotations. Every discovered specification is mandatory in both builds; removing a specification and its proof deliberately removes that claim. No method roster or root marker is required. Preserve Unit decoding through reduction so total assertion harnesses use the same contract path as ordinary value-returning functions. Validation: fresh pinned extraction, complete golden comparison, separate golden/live builds, independent required contracts and axiom audits. gherrit-pr-id: Gf75ukj4vvtufg6dwjpfa3bx5a457sw74 Agent-authored-by: AI agent acting on joshlf's behalf --- .gitattributes | 4 + .github/workflows/ci.yml | 67 +- .gitignore | 22 + skills/zerocopy-review/SKILL.md | 38 + tools/Cargo.lock | 25 + tools/Cargo.toml | 1 + tools/aeneas-inline/Cargo.toml | 20 + tools/aeneas-inline/src/main.rs | 683 +++++++++ verification/aeneas/DESIGN.md | 342 +++++ verification/aeneas/README.md | 698 +++++++++ verification/aeneas/build-aeneas.nix | 34 + verification/aeneas/build-aeneas.sh | 63 + verification/aeneas/copy_back.py | 373 +++++ verification/aeneas/dev.sh | 86 ++ verification/aeneas/golden.py | 188 +++ verification/aeneas/golden/Funs.lean | 140 ++ .../aeneas/golden/FunsExternal_Template.lean | 52 + verification/aeneas/golden/Types.lean | 42 + .../aeneas/golden/TypesExternal_Template.lean | 42 + verification/aeneas/inline.py | 1322 +++++++++++++++++ verification/aeneas/lean/Check.lean | 370 +++++ .../aeneas/lean/ContractAuditTests.lean | 113 ++ verification/aeneas/lean/ContractSimps.lean | 27 + verification/aeneas/lean/DeriveModels.lean | 507 +++++++ verification/aeneas/lean/InspectSpec.lean | 79 + verification/aeneas/lean/MathViews.lean | 96 ++ verification/aeneas/lean/ModelCompletion.lean | 86 ++ .../aeneas/lean/ModelCompletionTests.lean | 135 ++ .../aeneas/lean/ModelExportProducer.lean | 65 + .../aeneas/lean/ModelExportTests.lean | 43 + verification/aeneas/lean/ModelLemmas.lean | 169 +++ verification/aeneas/lean/ModelPrelude.lean | 122 ++ verification/aeneas/lean/ModelTests.lean | 294 ++++ verification/aeneas/lean/Obligations.lean | 74 + verification/aeneas/lean/Proofs.lean | 44 + verification/aeneas/lean/Proofs/Util.lean | 148 ++ .../aeneas/lean/RequiredContractTests.lean | 112 ++ .../aeneas/lean/RequiredContracts.lean | 198 +++ .../aeneas/lean/RequiredModelContracts.lean | 28 + .../lean/RequiredModelContracts/Util.lean | 81 + verification/aeneas/lean/RustModel.lean | 131 ++ verification/aeneas/lean/SpecPrelude.lean | 25 + verification/aeneas/lean/SpecsSyntax.lean | 398 +++++ .../aeneas/lean/SpecsSyntaxTests.lean | 251 ++++ verification/aeneas/lean/Zerocopy.lean | 19 + .../aeneas/lean/Zerocopy/FunsExternal.lean | 24 + .../aeneas/lean/Zerocopy/TypesExternal.lean | 20 + verification/aeneas/negative-controls.sh | 1286 ++++++++++++++++ .../aeneas/patches/use-tuple-structs.patch | 13 + verification/aeneas/prepare.py | 155 ++ verification/aeneas/run.sh | 104 ++ verification/aeneas/setup.sh | 76 + .../aeneas/tests/check_nominal_tuples.py | 28 + verification/aeneas/tests/nominal-tuples.sh | 61 + .../aeneas/tests/nominal-tuples/Cargo.lock | 7 + .../aeneas/tests/nominal-tuples/Cargo.toml | 15 + .../aeneas/tests/nominal-tuples/Check.lean | 50 + .../aeneas/tests/nominal-tuples/Default.lean | 19 + .../aeneas/tests/nominal-tuples/src/lib.rs | 95 ++ .../aeneas/tests/rounding_model_controls.py | 223 +++ verification/aeneas/tests/test_copy_back.py | 393 +++++ verification/aeneas/tests/test_golden.py | 97 ++ .../aeneas/tests/test_import_graph.py | 60 + verification/aeneas/tests/test_inline.py | 933 ++++++++++++ verification/aeneas/tests/test_inspect.py | 79 + verification/aeneas/tests/test_prepare.py | 102 ++ .../tests/test_rounding_model_controls.py | 176 +++ verification/aeneas/tests/test_workspace.py | 154 ++ .../reject-raw-parameter.lean | 20 + verification/aeneas/toolchain.sh | 40 + verification/aeneas/workspace.py | 288 ++++ zerocopy/src/util/mod.rs | 21 + 72 files changed, 12395 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 tools/aeneas-inline/Cargo.toml create mode 100644 tools/aeneas-inline/src/main.rs create mode 100644 verification/aeneas/DESIGN.md create mode 100644 verification/aeneas/README.md create mode 100644 verification/aeneas/build-aeneas.nix create mode 100755 verification/aeneas/build-aeneas.sh create mode 100644 verification/aeneas/copy_back.py create mode 100644 verification/aeneas/dev.sh create mode 100644 verification/aeneas/golden.py create mode 100644 verification/aeneas/golden/Funs.lean create mode 100644 verification/aeneas/golden/FunsExternal_Template.lean create mode 100644 verification/aeneas/golden/Types.lean create mode 100644 verification/aeneas/golden/TypesExternal_Template.lean create mode 100644 verification/aeneas/inline.py create mode 100644 verification/aeneas/lean/Check.lean create mode 100644 verification/aeneas/lean/ContractAuditTests.lean create mode 100644 verification/aeneas/lean/ContractSimps.lean create mode 100644 verification/aeneas/lean/DeriveModels.lean create mode 100644 verification/aeneas/lean/InspectSpec.lean create mode 100644 verification/aeneas/lean/MathViews.lean create mode 100644 verification/aeneas/lean/ModelCompletion.lean create mode 100644 verification/aeneas/lean/ModelCompletionTests.lean create mode 100644 verification/aeneas/lean/ModelExportProducer.lean create mode 100644 verification/aeneas/lean/ModelExportTests.lean create mode 100644 verification/aeneas/lean/ModelLemmas.lean create mode 100644 verification/aeneas/lean/ModelPrelude.lean create mode 100644 verification/aeneas/lean/ModelTests.lean create mode 100644 verification/aeneas/lean/Obligations.lean create mode 100644 verification/aeneas/lean/Proofs.lean create mode 100644 verification/aeneas/lean/Proofs/Util.lean create mode 100644 verification/aeneas/lean/RequiredContractTests.lean create mode 100644 verification/aeneas/lean/RequiredContracts.lean create mode 100644 verification/aeneas/lean/RequiredModelContracts.lean create mode 100644 verification/aeneas/lean/RequiredModelContracts/Util.lean create mode 100644 verification/aeneas/lean/RustModel.lean create mode 100644 verification/aeneas/lean/SpecPrelude.lean create mode 100644 verification/aeneas/lean/SpecsSyntax.lean create mode 100644 verification/aeneas/lean/SpecsSyntaxTests.lean create mode 100644 verification/aeneas/lean/Zerocopy.lean create mode 100644 verification/aeneas/lean/Zerocopy/FunsExternal.lean create mode 100644 verification/aeneas/lean/Zerocopy/TypesExternal.lean create mode 100755 verification/aeneas/negative-controls.sh create mode 100644 verification/aeneas/patches/use-tuple-structs.patch create mode 100644 verification/aeneas/prepare.py create mode 100755 verification/aeneas/run.sh create mode 100755 verification/aeneas/setup.sh create mode 100644 verification/aeneas/tests/check_nominal_tuples.py create mode 100755 verification/aeneas/tests/nominal-tuples.sh create mode 100644 verification/aeneas/tests/nominal-tuples/Cargo.lock create mode 100644 verification/aeneas/tests/nominal-tuples/Cargo.toml create mode 100644 verification/aeneas/tests/nominal-tuples/Check.lean create mode 100644 verification/aeneas/tests/nominal-tuples/Default.lean create mode 100644 verification/aeneas/tests/nominal-tuples/src/lib.rs create mode 100644 verification/aeneas/tests/rounding_model_controls.py create mode 100644 verification/aeneas/tests/test_copy_back.py create mode 100644 verification/aeneas/tests/test_golden.py create mode 100644 verification/aeneas/tests/test_import_graph.py create mode 100644 verification/aeneas/tests/test_inline.py create mode 100644 verification/aeneas/tests/test_inspect.py create mode 100644 verification/aeneas/tests/test_prepare.py create mode 100644 verification/aeneas/tests/test_rounding_model_controls.py create mode 100644 verification/aeneas/tests/test_workspace.py create mode 100644 verification/aeneas/tests/type-only-shapes/reject-raw-parameter.lean create mode 100644 verification/aeneas/toolchain.sh create mode 100644 verification/aeneas/workspace.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..aebbd2f623 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Preserve complete generated Lean snapshots, including literal bytes. Aeneas's +# pretty printer can emit trailing code spaces; the conservative comparison and +# independent proof builds check these files instead of hand-formatting them. +verification/aeneas/golden/*.lean whitespace=-blank-at-eol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5ae5742e2..55cfde3425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1052,6 +1052,71 @@ jobs: name: ${{ env.ZC_CI_IMAGE_ARCHIVE }} path: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }} + aeneas: + name: Aeneas translation and Lean proofs + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + # Charon uses its own pinned Rust compiler. Keep the ordinary Rust CI + # flags (including randomized layouts) out of this extraction. + RUSTFLAGS: "" + RUSTDOCFLAGS: "" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install rustup + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # zizmor: ignore[superfluous-actions] + with: + toolchain: stable + + - name: Install Nix for the pinned Aeneas source build + uses: DeterminateSystems/determinate-nix-action@8d87e8d5e5b8a8309d4281094560f127d9a265f1 # v3.22.5 + + - name: Install the pinned Aeneas, Charon, Rust, and Lean bundle + run: bash verification/aeneas/setup.sh + + - name: Test Rust syntax ownership, discovery, and golden comparison + run: | + source verification/aeneas/toolchain.sh + export CARGO_TARGET_DIR="$PWD/target/aeneas/annotation-tool" + cargo +"$AENEAS_RUST_TOOLCHAIN" test --locked --manifest-path tools/Cargo.toml -p aeneas-inline + cargo +"$AENEAS_RUST_TOOLCHAIN" build --locked --manifest-path tools/Cargo.toml -p aeneas-inline + python3 -B -m unittest discover -s verification/aeneas/tests + + - name: Check nominal type extraction and upstream compatibility + run: bash verification/aeneas/tests/nominal-tuples.sh + + # run.sh checks the same contracts against two definition sets. Keep + # negative controls after this positive baseline: a missing dependency + # or an already broken proof cannot count as rejection of a mutation. + - name: Compare live extraction with goldens and prove both models + run: bash verification/aeneas/run.sh + + - name: Test compiled effective-spec inspection + run: | + source verification/aeneas/toolchain.sh + export PATH="${AENEAS_TOOLCHAIN_DIR:-$PWD/target/aeneas/toolchain}/lean/bin:$PATH" + export AENEAS_INSPECTION_PROJECT="$PWD/target/aeneas/verification" + python3 -B -m unittest discover -s verification/aeneas/tests -p test_inspect.py + + - name: Verify that admissions and incorrect code fail + env: + LEAN_NUM_THREADS: "2" + run: | + # Each suite mutates only its own generated project. Collect both + # statuses so a failure cannot leave the other suite unobserved. + bash verification/aeneas/negative-controls.sh golden-verification > target/aeneas/golden-controls.log 2>&1 & + golden_pid=$! + bash verification/aeneas/negative-controls.sh verification > target/aeneas/live-controls.log 2>&1 & + live_pid=$! + status=0 + wait "$golden_pid" || status=1 + wait "$live_pid" || status=1 + cat target/aeneas/golden-controls.log target/aeneas/live-controls.log + exit "$status" + # Used to signal to branch protections that all other jobs have succeeded. all-jobs-succeed: # WARNING: This name is load-bearing! It's how GitHub's settings UI configures which jobs @@ -1062,7 +1127,7 @@ jobs: # success in branch protection, so this aggregation must fail closed. if: ${{ always() }} runs-on: ubuntu-latest - needs: [build_test, miri, codegen, coverage, kani, check_be_aarch64, check_avr_atmega, check_fmt, check_actions, check_readme, check_versions, check_msrv_is_minimal, check_stale_stderr, check-all-toolchains-tested, check-job-dependencies, check-todo, run-git-hooks, zizmor, build_docker_env] + needs: [aeneas, build_test, miri, codegen, coverage, kani, check_be_aarch64, check_avr_atmega, check_fmt, check_actions, check_readme, check_versions, check_msrv_is_minimal, check_stale_stderr, check-all-toolchains-tested, check-job-dependencies, check-todo, run-git-hooks, zizmor, build_docker_env] steps: - name: Reject workflow cancellation if: ${{ cancelled() }} diff --git a/.gitignore b/.gitignore index 9891c1a7b4..356a34c075 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,28 @@ target lcov.info +# Generated inputs and caches for the native Aeneas proof development project. +verification/aeneas/__pycache__/ +verification/aeneas/lean/.lake/ +verification/aeneas/lean/lakefile.lean +verification/aeneas/lean/lake-manifest.json +verification/aeneas/lean/lean-toolchain +verification/aeneas/lean/Specs.lean +verification/aeneas/lean/ModelShapes.lean +verification/aeneas/lean/Models.lean +verification/aeneas/lean/Required.lean +verification/aeneas/lean/Specs.source-map.json +verification/aeneas/lean/ModelShapes.source-map.json +verification/aeneas/lean/Models.source-map.json +verification/aeneas/lean/Specs.sources.sha256 +verification/aeneas/lean/.inline-projections.json +verification/aeneas/lean/bindings.json +verification/aeneas/lean/proof-dependencies.json +verification/aeneas/lean/Zerocopy/Types.lean +verification/aeneas/lean/Zerocopy/Funs.lean +verification/aeneas/lean/Zerocopy/TypesExternal_Template.lean +verification/aeneas/lean/Zerocopy/FunsExternal_Template.lean + # VSCode workspace files *.code-workspace diff --git a/skills/zerocopy-review/SKILL.md b/skills/zerocopy-review/SKILL.md index 249d05ad0f..32629fff8b 100644 --- a/skills/zerocopy-review/SKILL.md +++ b/skills/zerocopy-review/SKILL.md @@ -54,6 +54,44 @@ Check the issues that are relevant to the changed behavior, including: Before suggesting a new dependency, check `Cargo.toml` and the existing workspace for an appropriate dependency or utility. +## Specification Meaning + +For every specification under review, reconstruct the guarantee a human likely +intends. Ground that interpretation in the specification, surrounding +documentation, callers, reference implementations, and the bug or change that +motivated it. Compare the intended guarantee with what the specification +actually states. A passing proof establishes the written proposition; it does +not establish that the proposition captures the intended behavior. + +Look for ways the written guarantee could be weaker than that intention: + +- Requirements, ghost parameters, or branch guards exclude intended inputs. + Inconsistent requirements, a ghost parameter with an empty type, or an + admission predicate that always returns `false` can make a claim vacuous: + there are no cases in which the promised behavior must hold. +- Automatic input decoding or type-model invariants restrict the domain beyond + what a reader would reasonably infer from the function's specification. +- Postconditions omit intended observations, or compare projections that + discard the information whose correctness matters. +- An equivalence harness checks only successful results or skips comparisons + on relevant paths, leaving intended failure behavior or other cases unchecked. +- A reference implementation shares the very logic whose correctness the + equivalence check is meant to establish. +- A partial-correctness contract allows divergence where termination is + intended, or a proof covers fewer inputs or configurations than claimed. + +A total `ensures _ => True` can be sufficient for an assertion harness: +successful termination requires its assertions to pass on admitted inputs. +Inspect the assertions and admission conditions rather than rejecting the +postcondition merely because it is `True`. Concrete admission examples can +expose vacuity, but do not establish coverage of every intended input. + +Report substantiated gaps even when the proofs pass. State the intended +guarantee, the weaker written claim, and a distinguishing case when possible. +Keep inferred intention separate from the existing contract. When intention is +unclear, make that uncertainty explicit and seek clarification when needed; +do not silently invent a stronger requirement. + ## Findings Report actionable findings, not private chain-of-thought. For each finding: diff --git a/tools/Cargo.lock b/tools/Cargo.lock index dcf1c43a89..459e8b6ecb 100644 --- a/tools/Cargo.lock +++ b/tools/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aeneas-inline" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "rustc_lexer", + "serde_json", + "syn", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -432,6 +442,15 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc_lexer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" +dependencies = [ + "unicode-xid", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -729,6 +748,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.2" diff --git a/tools/Cargo.toml b/tools/Cargo.toml index f7a1919ab9..59cdf9da94 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -8,6 +8,7 @@ [workspace] members = [ + "aeneas-inline", "cargo-zerocopy", "generate-readme", "ui-runner", diff --git a/tools/aeneas-inline/Cargo.toml b/tools/aeneas-inline/Cargo.toml new file mode 100644 index 0000000000..0af4a50c31 --- /dev/null +++ b/tools/aeneas-inline/Cargo.toml @@ -0,0 +1,20 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +[package] +name = "aeneas-inline" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +proc-macro2 = { version = "1", features = ["span-locations"] } +rustc_lexer = "=0.1.0" +serde_json = "1" +syn = { version = "2", features = ["full", "visit"] } diff --git a/tools/aeneas-inline/src/main.rs b/tools/aeneas-inline/src/main.rs new file mode 100644 index 0000000000..f54fa089d4 --- /dev/null +++ b/tools/aeneas-inline/src/main.rs @@ -0,0 +1,683 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! Report source ownership without compiling or expanding Rust. +//! +//! `syn` associates literal outer documentation with functions and nominal types. +//! The lexer independently reports every real comment, including comments that +//! have no supported owner. Python compares these reports so an orphaned fence +//! cannot disappear, while a fence inside a Rust string remains ordinary data. +//! All offsets refer to the exact source returned in the same JSON report. We +//! inspect inactive cfg branches too: coverage must not depend on the host cfg. + +use std::{fs, io}; + +use proc_macro2::LineColumn; +use serde_json::{json, Value}; +use syn::{ + spanned::Spanned, + visit::{self, Visit}, +}; + +struct Functions<'a> { + source: &'a str, + modules: Vec, + // Count enclosing contexts that cannot own supported contracts. A counter + // preserves the outer restriction when a nested visitor enters and leaves. + unsupported: usize, + impl_type: Option, + impl_generics: Vec, + inherent: bool, + functions: Vec, + implementations: Vec, + types: Vec, + macros: Vec, + attributes: Vec, +} + +impl Functions<'_> { + // Span columns count characters; the editing protocol needs byte offsets. + // Keep that conversion here so every reported range has the same meaning. + fn offset(&self, pos: LineColumn) -> usize { + let start = + self.source.split_inclusive('\n').take(pos.line - 1).map(str::len).sum::(); + start + + self.source[start..] + .char_indices() + .nth(pos.column) + .map_or(self.source[start..].len(), |(byte, _)| byte) + } + + fn docs(&self, attrs: &[syn::Attribute]) -> Vec { + // Return decoded literal text and the physical attribute range together. + // Computed docs are audited separately; expanding them would erase their + // source ownership and could depend on cfg or external files. + attrs + .iter() + .filter_map(|attr| { + if !attr.path().is_ident("doc") || !matches!(attr.style, syn::AttrStyle::Outer) { + return None; + } + let syn::Meta::NameValue(meta) = &attr.meta else { + return None; + }; + let syn::Expr::Lit(expr) = &meta.value else { + return None; + }; + let syn::Lit::Str(text) = &expr.lit else { + return None; + }; + Some(json!({"start": self.offset(attr.span().start()), + "end": self.offset(attr.span().end()), "text": text.value(), + "line": attr.span().start().line})) + }) + .collect() + } + + fn nominal( + &mut self, + ident: &syn::Ident, + generics: &syn::Generics, + attrs: &[syn::Attribute], + span: proc_macro2::Span, + kind: &str, + fields: Value, + ) { + // Preserve source parameter order, even for parameters unused by fields. + // Field admission is structural: an authored decoder cannot make a + // borrowed or otherwise unsupported raw carrier acceptable here. + let mut path = self.modules.clone(); + path.push(ident.to_string()); + let supported = self.unsupported == 0 + && generics.const_params().next().is_none() + && generics.type_params().all(|p| p.bounds.is_empty()) + && generics.where_clause.is_none() + && !has_borrowed_fields(&fields); + let (computed_docs, include_str_docs) = doc_flags(attrs); + self.types.push(json!({"path": path.join("::"), "kind": kind, + "start": self.offset(ident.span().start()), "close": self.offset(span.end()), + "supported": supported, "generics": generics.type_params().map(|p| p.ident.to_string()).collect::>(), + "inputs": [], "docs": self.docs(attrs), "computed_docs": computed_docs, + "include_str_docs": include_str_docs, "fields": fields})); + } + + fn function( + &mut self, + sig: &syn::Signature, + block: &syn::Block, + attrs: &[syn::Attribute], + method: bool, + ) { + // The report identifies the original function and original argument + // names. Python later checks this order against extraction before Lean + // can construct the application used by the generated proposition. + let open = self.offset(block.brace_token.span.open().start()); + assert_eq!(&self.source[open..open + 1], "{", "Invalid function brace span"); + let mut path = self.modules.clone(); + path.push(sig.ident.to_string()); + let mut supported = self.unsupported == 0; + let mut inputs = Vec::new(); + for input in &sig.inputs { + match input { + syn::FnArg::Receiver(receiver) => { + inputs.push("self".to_string()); + supported &= receiver.reference.is_none() || receiver.mutability.is_none(); + } + syn::FnArg::Typed(input) => { + supported &= !unsupported_borrow(&input.ty, true); + match input.pat.as_ref() { + syn::Pat::Ident(pattern) + if pattern.by_ref.is_none() && pattern.subpat.is_none() => + { + inputs.push(pattern.ident.to_string()); + } + _ => supported = false, + } + } + } + } + if let syn::ReturnType::Type(_, ty) = &sig.output { + supported &= !unsupported_borrow(ty, false); + } + supported &= sig.generics.where_clause.is_none(); + let mut generics = if method { self.impl_generics.clone() } else { Vec::new() }; + for parameter in &sig.generics.params { + match parameter { + syn::GenericParam::Type(parameter) => { + generics.push(parameter.ident.to_string()); + supported &= parameter.bounds.is_empty(); + } + syn::GenericParam::Lifetime(_) => {} + syn::GenericParam::Const(_) => supported = false, + } + } + let (computed_docs, include_str_docs) = doc_flags(attrs); + self.functions.push(json!({ + "path": path.join("::"), "kind": "function", "open": open, + "start": self.offset(sig.fn_token.span.start()), "docs": self.docs(attrs), + "computed_docs": computed_docs, "include_str_docs": include_str_docs, + "close": self.offset(block.brace_token.span.close().end()), + "supported": supported, "inputs": inputs, "generics": generics, + "method": method, "impl_type": self.impl_type, "inherent": self.inherent, + })); + } +} + +// Literal source docs own annotations. Other computed docs remain ordinary +// Rust documentation unless that owner also carries an annotation. Direct +// include_str! is detected without expanding any documentation macro. +fn doc_flags(attrs: &[syn::Attribute]) -> (bool, bool) { + fn flags(meta: &syn::Meta) -> (bool, bool) { + match meta { + syn::Meta::NameValue(value) if value.path.is_ident("doc") => { + struct Includes(bool); + impl<'ast> Visit<'ast> for Includes { + fn visit_macro(&mut self, mac: &'ast syn::Macro) { + self.0 |= + mac.path.segments.last().is_some_and(|p| p.ident == "include_str") + || tokens_include_str(mac.tokens.clone()); + } + } + let mut includes = Includes(false); + includes.visit_expr(&value.value); + ( + !matches!(&value.value, syn::Expr::Lit(expr) if matches!(expr.lit, syn::Lit::Str(_))), + includes.0, + ) + } + syn::Meta::List(list) if list.path.is_ident("cfg_attr") => { + use syn::parse::Parser as _; + let parser = + syn::punctuated::Punctuated::::parse_terminated; + match parser.parse2(list.tokens.clone()) { + Ok(entries) => entries + .iter() + .skip(1) + .map(flags) + .fold((false, false), |a, b| (a.0 || b.0, a.1 || b.1)), + Err(_) => ( + mentions_doc(list.tokens.clone()), + mentions_doc(list.tokens.clone()) + && tokens_include_str(list.tokens.clone()), + ), + } + } + _ => (false, false), + } + } + attrs.iter().map(|attr| flags(&attr.meta)).fold((false, false), |a, b| (a.0 || b.0, a.1 || b.1)) +} + +fn tokens_include_str(tokens: proc_macro2::TokenStream) -> bool { + // Macros hide syntax from syn's expression visitor, so inspect their token + // groups too. Require an actual invocation, not the word in a literal. + use proc_macro2::TokenTree; + let tokens: Vec<_> = tokens.into_iter().collect(); + tokens.windows(3).any(|window| { + matches!(window, + [TokenTree::Ident(name), TokenTree::Punct(bang), TokenTree::Group(_)] + if name == "include_str" && bang.as_char() == '!') + }) || tokens.iter().any(|token| match token { + TokenTree::Group(group) => tokens_include_str(group.stream()), + _ => false, + }) +} + +fn mentions_doc(tokens: proc_macro2::TokenStream) -> bool { + // This conservative fallback handles conditional docs that cannot be parsed + // as ordinary metadata. Python must still see their literal fragments. + tokens.into_iter().any(|token| match token { + proc_macro2::TokenTree::Ident(ident) => ident == "doc", + proc_macro2::TokenTree::Group(group) => mentions_doc(group.stream()), + _ => false, + }) +} + +fn token_strings(tokens: proc_macro2::TokenStream, output: &mut Vec) { + // Collect literal fragments without evaluating the surrounding macro. This + // supports the reserved-fence audit but never authorizes a computed fence. + for token in tokens { + match token { + proc_macro2::TokenTree::Literal(literal) => { + if let Ok(text) = syn::parse_str::(&literal.to_string()) { + output.push(text.value()); + } + } + proc_macro2::TokenTree::Group(group) => token_strings(group.stream(), output), + _ => {} + } + } +} + +fn doc_fragments(meta: &syn::Meta) -> Vec { + // Token groups and parsed metadata represent different attribute spellings. + // Both contribute strings to the audit, not to the owner-doc payload. + struct Strings(Vec); + impl<'ast> Visit<'ast> for Strings { + fn visit_lit_str(&mut self, text: &'ast syn::LitStr) { + self.0.push(text.value()); + } + fn visit_macro(&mut self, mac: &'ast syn::Macro) { + token_strings(mac.tokens.clone(), &mut self.0); + } + } + let mut strings = Strings(Vec::new()); + if let syn::Meta::List(list) = meta { + token_strings(list.tokens.clone(), &mut strings.0); + } else { + strings.visit_meta(meta); + } + strings.0 +} + +// Shared input references are supported, but mutable inputs need backward borrow +// reconstruction. Outputs and nominal fields cannot retain any reference. One +// traversal enforces both policies, including references nested in containers. +// Function pointers remain unsupported under either policy. +fn unsupported_borrow(ty: &syn::Type, allow_shared: bool) -> bool { + struct Borrow { + found: bool, + allow_shared: bool, + } + impl<'ast> Visit<'ast> for Borrow { + fn visit_type_reference(&mut self, ty: &'ast syn::TypeReference) { + self.found |= !self.allow_shared || ty.mutability.is_some(); + visit::visit_type_reference(self, ty); + } + fn visit_type_bare_fn(&mut self, _: &'ast syn::TypeBareFn) { + self.found = true; + } + } + let mut visitor = Borrow { found: false, allow_shared }; + visitor.visit_type(ty); + visitor.found +} + +fn has_borrowed_fields(fields: &Value) -> bool { + // Struct fields and enum variants share the report's recursive container + // shape. A borrowed field in any variant makes the nominal owner unsupported. + match fields { + Value::Array(values) => values.iter().any(has_borrowed_fields), + Value::Object(values) => { + values.get("borrowed") == Some(&Value::Bool(true)) + || values.values().any(has_borrowed_fields) + } + _ => false, + } +} + +fn fields(fields: &syn::Fields) -> Value { + // Tuple positions are names too; preserve their order rather than inventing + // source identifiers that could disagree with the extracted carrier. + json!(fields + .iter() + .enumerate() + .map(|(index, field)| { + json!({"name": field.ident.as_ref().map_or(index.to_string(), ToString::to_string), + "borrowed": unsupported_borrow(&field.ty, false)}) + }) + .collect::>()) +} + +impl<'ast> Visit<'ast> for Functions<'_> { + fn visit_item_macro(&mut self, item: &'ast syn::ItemMacro) { + // Compiler-expanded nominal types can point into a macro's source + // tokens instead of a syn nominal item. Report only the token range; + // Python still requires an exact LLBC source snapshot and checks the + // expanded structural type before accepting an unannotated carrier. + let span = item.mac.tokens.span(); + self.macros.push(json!({ + "start": self.offset(span.start()), + "end": self.offset(span.end()), + "modules": self.modules, + })); + visit::visit_item_macro(self, item); + } + + fn visit_attribute(&mut self, attr: &'ast syn::Attribute) { + // Include every doc spelling, even forms that cannot be evaluated by syn. + // Python rejects reserved fences in conditional/computed doc forms. + let conditional_doc = match &attr.meta { + syn::Meta::List(list) if attr.path().is_ident("cfg_attr") => { + mentions_doc(list.tokens.clone()) + } + _ => false, + }; + if attr.path().is_ident("doc") || conditional_doc { + let start = self.offset(attr.span().start()); + let end = self.offset(attr.span().end()); + self.attributes.push(json!({"start": start, "end": end, + "outer": matches!(attr.style, syn::AttrStyle::Outer), + "doc": attr.path().is_ident("doc"), "fragments": doc_fragments(&attr.meta)})); + } + visit::visit_attribute(self, attr); + } + + fn visit_item_struct(&mut self, item: &'ast syn::ItemStruct) { + self.nominal( + &item.ident, + &item.generics, + &item.attrs, + item.span(), + "struct", + fields(&item.fields), + ); + visit::visit_item_struct(self, item); + } + + fn visit_item_enum(&mut self, item: &'ast syn::ItemEnum) { + let variants = item.variants.iter().map(|variant| { + json!({"name": variant.ident.to_string(), "fields": fields(&variant.fields)}) + }).collect::>(); + self.nominal( + &item.ident, + &item.generics, + &item.attrs, + item.span(), + "enum", + json!(variants), + ); + visit::visit_item_enum(self, item); + } + + fn visit_item_mod(&mut self, item: &'ast syn::ItemMod) { + self.modules.push(item.ident.to_string()); + visit::visit_item_mod(self, item); + self.modules.pop(); + } + + fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) { + self.function(&item.sig, &item.block, &item.attrs, false); + // Never attribute nested functions to the enclosing function or method. + self.unsupported += 1; + visit::visit_item_fn(self, item); + self.unsupported -= 1; + } + + fn visit_item_impl(&mut self, item: &'ast syn::ItemImpl) { + let previous_type = self.impl_type.clone(); + let previous_inherent = self.inherent; + let previous_generics = self.impl_generics.clone(); + self.impl_generics = item.generics.type_params().map(|p| p.ident.to_string()).collect(); + self.impl_type = match item.self_ty.as_ref() { + syn::Type::Path(path) => path.path.segments.last().map(|s| s.ident.to_string()), + _ => None, + }; + self.inherent = item.trait_.is_none(); + // A local named Self may apply the impl's own type parameters. + // Specializations, qualified Self and trait impls still fail closed. + let ident = match item.self_ty.as_ref() { + syn::Type::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => { + let segment = &path.path.segments[0]; + let supported = match &segment.arguments { + syn::PathArguments::None => true, + syn::PathArguments::AngleBracketed(args) => args.args.iter().all(|arg| { + let syn::GenericArgument::Type(syn::Type::Path(arg)) = arg else { + return false; + }; + arg.qself.is_none() + && arg.path.get_ident().is_some_and(|ident| { + item.generics.type_params().any(|param| param.ident == *ident) + }) + }), + _ => false, + }; + supported.then_some(&segment.ident) + } + _ => None, + }; + let ident = ident.filter(|_| { + item.trait_.is_none() + && self.unsupported == 0 + && item.generics.const_params().next().is_none() + && item.generics.type_params().all(|p| p.bounds.is_empty()) + && item.generics.where_clause.is_none() + }); + let macros: Vec<_> = item + .items + .iter() + .filter_map(|item| match item { + syn::ImplItem::Macro(item) => Some( + item.mac + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect::>() + .join("::"), + ), + _ => None, + }) + .collect(); + // Record impls and direct macro invocations before cfg filtering. An + // empty or macro-only impl must not disappear from closed coverage. + self.implementations.push(json!({ + "type": self.impl_type, "inherent": self.inherent, + "supported": ident.is_some(), "macros": macros, "modules": self.modules, + })); + if let Some(ident) = ident { + self.modules.push(ident.to_string()); + visit::visit_item_impl(self, item); + self.modules.pop(); + } else { + self.unsupported += 1; + visit::visit_item_impl(self, item); + self.unsupported -= 1; + } + self.impl_type = previous_type; + self.inherent = previous_inherent; + self.impl_generics = previous_generics; + } + + fn visit_impl_item_fn(&mut self, item: &'ast syn::ImplItemFn) { + self.function(&item.sig, &item.block, &item.attrs, true); + self.unsupported += 1; + visit::visit_impl_item_fn(self, item); + self.unsupported -= 1; + } + + fn visit_item_trait(&mut self, item: &'ast syn::ItemTrait) { + self.unsupported += 1; + visit::visit_item_trait(self, item); + self.unsupported -= 1; + } +} + +fn inspect(source: &str) -> Result { + // Keep comment discovery independent of AST ownership. A source string that + // only resembles a comment must never enter this roster, while a real + // orphaned comment must remain visible even when the AST has no valid owner. + let mut offset = 0; + let mut comments = Vec::new(); + for token in rustc_lexer::tokenize(source) { + match token.kind { + rustc_lexer::TokenKind::LineComment | rustc_lexer::TokenKind::BlockComment { .. } => { + comments.push(json!({"start": offset, "end": offset + token.len})); + } + _ => {} + } + offset += token.len; + } + let syntax = syn::parse_file(source)?; + let mut visitor = Functions { + source, + modules: Vec::new(), + unsupported: 0, + impl_type: None, + impl_generics: Vec::new(), + inherent: false, + functions: Vec::new(), + implementations: Vec::new(), + types: Vec::new(), + macros: Vec::new(), + attributes: Vec::new(), + }; + visitor.visit_file(&syntax); + Ok(json!({"source": source, "comments": comments, "functions": visitor.functions, + "implementations": visitor.implementations, "types": visitor.types, + "macros": visitor.macros, + "attributes": visitor.attributes})) +} + +fn main() -> Result<(), Box> { + let paths: Vec = serde_json::from_reader(io::stdin())?; + let mut result = serde_json::Map::new(); + for path in paths { + let source = fs::read_to_string(&path)?; + let inspected = inspect(&source).map_err(|error| format!("{path}: {error}"))?; + result.insert(path, inspected); + } + serde_json::to_writer(io::stdout(), &result)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_real_comments_but_not_raw_string_contents() { + let source = + "fn f() { // real\n let x = r###\"// fake /* fake */\"###; /* outer /* inner */ */ }"; + let value = inspect(source).unwrap(); + assert_eq!(value["comments"].as_array().unwrap().len(), 2); + assert_eq!(value["functions"][0]["path"], "f"); + } + + #[test] + fn reports_macro_token_range_without_inventing_nominal_owners() { + let source = "macro_rules! make { ($name:ident) => { struct $name(u8); }; }"; + let value = inspect(source).unwrap(); + assert!(value["types"].as_array().unwrap().is_empty()); + let range = &value["macros"][0]; + let start = range["start"].as_u64().unwrap() as usize; + let end = range["end"].as_u64().unwrap() as usize; + assert!(source[start..end].contains("struct $name(u8)")); + assert!(start < end); + } + + #[test] + fn tracks_unicode_spans_modules_and_nested_functions_before_cfg_filtering() { + let source = "const 雪: u8 = 0; mod m { #[cfg(any())] fn f() { fn inner() {} } }"; + let value = inspect(source).unwrap(); + let functions = value["functions"].as_array().unwrap(); + assert_eq!(functions[0]["path"], "m::f"); + assert_eq!(functions[0]["supported"], true); + assert_eq!(functions[1]["supported"], false); + let open = functions[0]["open"].as_u64().unwrap() as usize; + assert!(source[open..].starts_with("{ fn inner()")); + } + + #[test] + fn resolves_simple_inherent_methods_and_rejects_other_impl_forms() { + for (implementation, supported) in [ + ("impl S", true), + ("impl S", true), + ("impl S", false), + ("impl Trait for S", false), + ("impl m::S", false), + ] { + let source = + format!("mod m {{ {implementation} {{ fn f() {{ fn nested() {{}} }} }} }}"); + let value = inspect(&source).unwrap(); + assert_eq!(value["functions"][0]["supported"], supported); + assert_eq!(value["functions"][1]["supported"], false); + if supported { + assert_eq!(value["functions"][0]["path"], "m::S::f"); + } + } + let value = inspect("impl S { fn f() {} }").unwrap(); + assert_eq!(value["functions"][0]["supported"], true); + } + + #[test] + fn preserves_input_order_and_impl_generics_and_rejects_patterns() { + let value = inspect("impl S { fn f(self, mut x: T, y: E) {} }").unwrap(); + assert_eq!(value["functions"][0]["generics"], json!(["E", "T"])); + assert_eq!(value["functions"][0]["inputs"], json!(["self", "x", "y"])); + assert_eq!(value["functions"][0]["supported"], true); + for source in [ + "fn f((x, y): (u8, u8)) {}", + "fn f(_: u8) {}", + "fn f() {}", + "fn f(x: &mut u8) {}", + "fn f() -> &'static u8 {}", + "impl S { fn f(&mut self) {} }", + ] { + assert_eq!(inspect(source).unwrap()["functions"][0]["supported"], false); + } + } + + #[test] + fn borrow_policy_distinguishes_shared_inputs_from_outputs_and_fields() { + for (ty, input_supported) in [ + ("u8", true), + ("Option<&'static u8>", true), + ("(&'static u8, Option<&'static mut u8>)", false), + ("&'static &'static mut u8", false), + ("Option u8>", false), + ] { + let parsed: syn::Type = syn::parse_str(ty).unwrap(); + assert_eq!(unsupported_borrow(&parsed, true), !input_supported, "{ty}"); + assert_eq!(unsupported_borrow(&parsed, false), ty != "u8", "{ty}"); + } + } + + #[test] + fn records_empty_and_macro_only_impls_before_cfg_filtering() { + let value = inspect( + "impl S {} #[cfg(any())] impl m::S { #[cfg(any())] methods!(); } \ + impl Trait for S { methods!(); } fn f() { unrelated!(); }", + ) + .unwrap(); + let implementations = value["implementations"].as_array().unwrap(); + assert_eq!(implementations.len(), 3); + assert_eq!(implementations[0]["type"], "S"); + assert_eq!(implementations[0]["supported"], true); + assert_eq!(implementations[0]["macros"], json!([])); + assert_eq!(implementations[1]["type"], "S"); + assert_eq!(implementations[1]["inherent"], true); + assert_eq!(implementations[1]["supported"], false); + assert_eq!(implementations[1]["macros"], json!(["methods"])); + assert_eq!(implementations[2]["inherent"], false); + let nested = inspect("mod m { impl S { fn f() {} } }").unwrap(); + assert_eq!(nested["implementations"][0]["modules"], json!(["m"])); + assert_eq!(nested["functions"][0]["path"], "m::S::f"); + } + #[test] + fn owns_outer_literal_doc_attributes_and_preserves_nominal_metadata() { + let source = "/// ```aeneas\n/// spec f_spec\n/// ensures r => True\n/// ```\n#[allow(unused)] fn f(x: T) {}\n#[doc = \"```aeneas\\nmodel SValue := Fields\\ndecode self => self\\n```\"] struct S(T); enum E { A, B(S) }"; + let value = inspect(source).unwrap(); + assert_eq!(value["functions"][0]["docs"].as_array().unwrap().len(), 4); + assert_eq!(value["functions"][0]["docs"][0]["text"], " ```aeneas"); + assert_eq!(value["types"][0]["kind"], "struct"); + assert_eq!(value["types"][0]["generics"], json!(["T"])); + assert_eq!(value["types"][0]["fields"][0]["name"], "0"); + assert_eq!(value["types"][1]["kind"], "enum"); + assert_eq!(value["types"][1]["fields"][1]["name"], "B"); + assert_eq!(value["attributes"].as_array().unwrap().len(), 5); + } + + #[test] + fn distinguishes_computed_docs_from_visible_include_str_invocations() { + let source = "#[doc = condVersionLink!(\"version\", \"link\")] fn f() {} \ + #[cfg_attr(any(), doc = condVersionLink!(\"version\", \"link\"))] struct S; \ + #[doc = concat!(\"ordinary\", std::include_str!(\"contract.md\"))] fn g() {} \ + #[cfg_attr(any(), cfg_attr(all(), doc = wrapper!(::std::include_str!(\"contract.md\"))))] enum E { A }"; + let value = inspect(source).unwrap(); + assert_eq!(value["functions"][0]["computed_docs"], true); + assert_eq!(value["functions"][0]["include_str_docs"], false); + assert_eq!(value["types"][0]["computed_docs"], true); + assert_eq!(value["types"][0]["include_str_docs"], false); + assert_eq!(value["functions"][1]["include_str_docs"], true); + assert_eq!(value["types"][1]["include_str_docs"], true); + let value = inspect("#[doc = \"include_str!(not an invocation)\"] fn f() {}").unwrap(); + assert_eq!(value["functions"][0]["computed_docs"], false); + assert_eq!(value["functions"][0]["include_str_docs"], false); + } +} diff --git a/verification/aeneas/DESIGN.md b/verification/aeneas/DESIGN.md new file mode 100644 index 0000000000..d52ac56f85 --- /dev/null +++ b/verification/aeneas/DESIGN.md @@ -0,0 +1,342 @@ + + +# Aeneas integration: design and development plan + +This document records the unified design, the immediate implementation work, +and the decisions deliberately deferred until more use supplies evidence. +`README.md` describes commands and current behavior; `SEMANTICS.md` describes +the independent layout semantics and explicit Rust correspondence premise. +Changes to this plan must preserve the stated claims or identify an +intentional change to their domain or observations. Implementation status +belongs with the relevant item below rather than in an unrelated backlog. + +## Purpose and scope + +The integration proves conditional functional behavior of selected, actual +zerocopy functions using their Aeneas extractions. An inline Rust +specification must have unambiguous ownership, refer to the original extracted +function and arguments, and receive a checked ordinary Lean proof. The same +claims must hold independently for checked-in generated goldens and fresh +extraction. + +The mathematical model is a common language for operation contracts. +Operations give that language its behavioral meaning. Decoding determines +interpretation and admission; it is an implementation detail that proofs may +unfold or describe with reusable lemmas. There is no universal requirement for +an encoder, injectivity, canonical raw representation, round trips, or a +separately authored representation relation. A map model may forget its seed. +A model must retain enough information for the operations and observations we +actually promise. + +These are conditional contracts. They do not establish that every safe caller +meets preconditions, that every constructor or mutation preserves an +invariant, or that pointer operations are free from undefined behavior. +Anneal's stronger claim would need those additional obligations. Numerical +layout proofs use the explicit Rust premise rather than a formal proof of +rustc. + +## Semantic core + +`RustModel Raw` contains only a mathematical `Model` and `decode : Raw -> +Option Model`. Successful decoding defines admission. Mathematical values can +carry proof fields, so their constraints follow from construction. + +The complete decoder recursively decodes every field before applying a local +decoder, including fields whose information the local model discards. Enum +decoding visits the selected payload. An infallible local decoder adds no +restriction beyond child admission; a fallible one defines additional +admission. Unannotated supported types receive structural models. Missing or +unsupported models fail explicitly rather than receiving a universal trivial +fallback. + +Model shapes depend only on mathematical child types. Full providers depend on +the actual child decoders. This separates shapes, ordinary support lemmas, and +decoder construction without requiring another public typeclass. Distinct raw +nominal types remain distinct through the pinned Aeneas tuple-newtype option. + +Function annotations precede their Rust owner and contain exactly one `spec` +or `partial spec`. Original source type parameters and value arguments are +inferred from the verified extraction. Every original type parameter receives +a modeling dictionary, including unused and result-only parameters. Automatic +providers are fixed before decoded witnesses, ghosts, requirements, and +authored instances. + +Plain clauses expose decoded value inputs and outputs. `(raw)` clauses expose +their original representations. Requirements are successive named premises; +postconditions are conjunctive and share one output witness. A successful raw +payload must decode even for raw-only or `True` postconditions. Execution +always uses the original raw arguments. + +Ghosts elaborate in mathematical input scope and may depend on previous +ghosts. They are universally quantified premises fixed for the contract, not +arguments to the extracted function or choices made after execution. Their +types affect coverage: `Fin xs.length` admits no position for an empty list; +`False` admits no instance at all. Such domains are allowed when intended and +must not conceal a broader promised domain. + +Total contracts require successful termination and exclude panic and +divergence. Explicit partial contracts permit divergence, exclude panic, and +check every successful return. Decoder rejection, Rust `Option`/`Result` +payloads, and Aeneas execution failure remain distinct. + +Decoder-scoped completion uses native record `..` and ordinary kernel-checked +proof search for omitted propositions. Missing data requires ordinary +inference or explicit author input. Explicit proofs remain available. Failure, +including inside a fallible decoder, is a compilation error and never becomes +rejection. There is no per-model tactic registry unless concrete repeated +needs justify it. + +## Proofs and independent contract checks + +The canonical theorem targets the transparent generated `Specs` proposition. +Proofs and helper lemmas are ordinary checked-in Lean modules. Native imports, +declaration order, and Aeneas theorem registration provide assembly. The +compiled proof graph reports dependencies; it does not dictate a proof +structure or a Rust call graph. Raw lemmas remain useful for broad domains and +intermediate representations. There is no requirement to decode after every +assignment. + +Handwritten `Obligations` are independently expressed behavioral expectations. +Their role is specification adequacy and regression protection for promised +domains and observations, not a second universal certification of each model. +When a spec already expresses the complete intended claim, duplication +supplies additional checking evidence rather than another kind of correctness +guarantee. + +The independent comparison must abstract an execution outcome. For original +arguments `args` and arbitrary `run : Result RawOutput`, it must establish +`AuthoredAt args run -> RequiredAt args run`. Separately, the canonical proof +establishes `AuthoredAt args (extractedFunction args)`. The outcome precedes +modeling dictionaries, decoded witnesses, ghosts, and requirements so all +instances concern one execution. + +Comparing concrete propositions permits an adapter to ignore the authored spec +and independently reprove the implementation. Concrete equivalence has the +same problem when both propositions are true. An arbitrary outcome exposes +weak postconditions, accidental partial correctness, and unintended domain +narrowing. The required input domain must be independently maintained where +admission is part of the promise; sharing a changed decoder on both sides can +be vacuous. + +The original execution node and argument prefix remain audited. Its abstracted +WP node must use the fresh outcome, and specialization must be exactly the +original spec. A family that ignores its outcome cannot pass merely because +its concrete specialization agrees. The independently authored family remains +ordinary Lean, with a checked connection to the concrete required proposition. + +Existing required guarantees must survive migrations. New requirements or +decoder restrictions that narrow a theorem's domain are semantic changes and +need explicit review. Useful broader raw theorems can be preserved as helpers. +Representation and admission lemmas are ordinary tools for proving operation +contracts or their adequacy; their existence is not a universal type-level +duty. + +## Implementation organization + +Share a rule when one fact determines its result; retain separate observations +when their independence supplies evidence. For example, a nominal Rust owner's +name determines its generated `Fields`, `decode`, and `aeneasModel` names. The +registry stores the owner once and computes those names. Decoder construction +and its decomposition theorem use the same constructor traversal, while their +different field operations remain explicit: one sequences decoding and the +other states existential witnesses and decoder equations. + +The source tools likewise normalize an extracted type's unique source owner +once and append a generated line with its diagnostic location in one operation. +Copy-back planning reuses region parses only after complete baseline validation. +The editor and CI share the generated-module roster, and failure controls share +one roster for fixture preflight and cleanup. These choices remove independent +state and repeated decisions without adding a new public abstraction. + +The Rust parser and comment audit remain separate, as do authored specs and +independent expectations. Merging those checks would remove the evidence that +catches a missed annotation or weakened claim. Comments explain both the shared +mechanisms and these deliberate boundaries, starting with the reader overview +in `README.md` and continuing beside the relevant implementation. + +## Extraction, CI, and development + +Application behavior can be stated in ordinary Rust. An independent reference +calculation and an assertion harness are extracted together with the production +functions they call. A total `ensures _ => True` on the harness requires successful +termination, so its assertions prove the comparison even though the written +postcondition says nothing about the unit result. Guards are part of this visible +claim: returning early makes no comparison for that input. Reviewers must inspect +guards, reference independence, and complete result comparisons when reconstructing +the intended guarantee. A proof does not establish that the reference implements +rustc; the explicit Rust premise and compiler regression tests remain necessary. + +Every present annotation uses the same mandatory checking path. A harness needs +no special root declaration, and a separate coverage roster would add a second +place for the reviewer to reconstruct the checked set. Removing an annotation +and its proof intentionally reduces that set. Independent expected propositions +still challenge contract elaboration and preserve existing helper domains; they +do not supply hidden application claims that a Rust harness needs to express. + +Goldens contain complete generated modules. CI checks conservative fuzzy +textual equivalence and independently compiles proofs, required comparisons, +corollaries, and axiom audits for both golden and live models. Inline +mathematical decoders never replace the live extracted function definitions. +Both builds establish the checked properties, not equivalence of all unclaimed +behaviors. + +Reserved fences, Rust ownership, annotation-derived extraction roots, nominal +type/provider registration, exact function binding, complete LLBC extraction, +inspected source snapshots, configuration, pins, and external primitive +signatures protect the connection between a kernel-checked proposition and the +Rust item. Every present annotation is checked on both golden and live models. +Removing an annotation and its associated proofs intentionally removes that claim +from the checked set, visibly in the source and generated golden changes. + +The trust boundary includes the pinned Rust/Charon/Aeneas translation, builtin +models, explicit primitive correspondences, Lean's kernel and permitted logic +axioms, source association, and specification adequacy. ABI reads are audited +data inputs, not axioms asserting useful properties. Type erasure does not +supply a universal ABI interpretation. `SEMANTICS.md` records the exact +bridge. + +Failure controls challenge behavioral promises and required domains. Faulty +models must elaborate before proof rejection counts as evidence. Controls +should survive reorganizing helper lemmas: a constant rounding decoder fails +constructor and getter promises; a rejecting decoder fails promised output +admission or independently required input coverage. + +Native Lean editing uses protected generated projections and real handwritten +proof paths. Rust annotations are authoritative; saved snapshots and owned +spans authorize owner-scoped copy-back. Source maps provide diagnostics. +Preview, conflict rejection, exact regeneration, and interruption recovery +preserve edits. Compiled inspection reports its actual subject and freshness +without claiming a new verification result. Source clones, credentials, and +irreplaceable editing state remain outside disposable `target` assets. Monitor +disk headroom before expensive extraction and while sustained builds run. + +## Implemented changes in this stack + +The stack implements the following changes in its earliest useful commits. + +1. Replace concrete required-contract conversion with arbitrary-outcome adequacy + and validate every existing contract. Retain source binding, both builds, + independent domains, coverage, and axiom auditing. Include cross-module and + adversarial tests for ignored outcomes, weakened posts, partial correctness, + empty ghosts, and domain narrowing. +2. Normalize explicit decoder reasoning consistently. Reuse decomposition, + `simp`, scalar facts, and `WP.spec_mono`; add a small sequencing tactic only + where it eliminates repeated bridge work. Mathematical carriers alone cannot + identify a decoder because different providers can share the same carrier. +3. Migrate `extend` to mathematical clauses while preserving its existing exact + placement, alignment, flag, and payload guarantees. Keep useful raw helpers; + avoid proof churn that merely changes presentation. +4. Retarget semantic decoder controls to operation promises and coverage rather + than named representation-helper failures. +5. Allow ordinary acyclic model-support dependencies. Independent comparisons + protect required getter domains and output admission; mutation controls + challenge those guarantees without relying on helper names or import bans. +6. Integrate each change in the earliest useful commit of the existing stack. + Preserve coherent review boundaries and validate intermediate states. Worker + boundaries do not determine PR boundaries. Preserve GHerrit identities unless + reordering requires regeneration to avoid the known auto-close bug. +7. Simplify implementation state and repeated traversals while preserving the + checked boundaries above. Add module introductions, local explanations, and + reader guidance for the source tools, Lean core, proof layers, and CI drivers. +8. Add Rust assertion harnesses and independent reference arithmetic in the + earliest commits that can prove them. Retain the existing mathematical helper + guarantees while making the complete numerical comparisons visible in Rust. + Discover the checked set solely from present annotations, without a separate + method roster or special harness classification. + +## Shared Rust Lean library + +The selected source location is `anneal/lean/`, with Lean modules and namespace +`Rust`. The library contains mathematical models of Rust and proofs about those +models. It is an independent Lake package with its own tests, usable by Anneal +V1, Anneal V2, and this integration. The shared package is implemented as the foundation of this stack. + +Distribute the package through Anneal's existing omnibus dependency archive, +installed by exocrate during `cargo anneal setup`. Installed consumers require +that package from the archive. Repository development and zerocopy CI can use +the canonical source package directly with compatible pinned dependencies. +There is no additional Rust assets crate or separately downloaded library. + +Archive construction must include the library's source, package configuration, +and compatible dependencies. Its identity and CI cache inputs must account for +changes under `anneal/lean/`; existing cache keys intentionally omit most Anneal +source files. Test the library independently and exercise imports from the +relocated installed archive, without relying on the repository checkout. + +Share mathematical definitions and proved lemmas first. Keep frontend-specific +annotation machinery in its consumer, and retain explicit Rust correspondence +premises and proof-dependency audits. The shared namespace does not imply that +the library already models every part of Rust's abstract machine. + +## Deferred decisions and reopening evidence + +- **Canonical layout abstraction.** Retain structural `DstLayout` and existing + `ModelViews` initially. Padding and trailing-size pilots already use those + views. Use `extend`, sequence queries, and capacity contracts to compare total + specification, decoder, bridge, and consumer burden before adopting + `LayoutMath.LayoutValue` as the canonical model. Preserve physical offset, + stride, flags, exact overflow outcomes, nested padding, and conservative query + behavior. `DstLayout` intentionally admits intermediate values that do not + describe completed Rust types; no global physical-realizability invariant. +- **Remaining raw clauses.** Migrate where mathematical language improves the + whole proof. Raw clauses remain appropriate for representation-sensitive + helpers and explicit ABI read premises. Do not require a zero raw-clause + count. +- **Per-function independent duplication.** Retain current required contracts + during this migration. After outcome adequacy is exercised, evaluate whether + generic expansion tests can replace routine duplicate declarations while + retaining independent semantic regression boundaries for layout operations. + This is a policy choice, not a kernel requirement. +- **Richer automation.** Prefer upstream checked-arithmetic stepping, scalar + tactics, and the existing loop adapter. Introduce a larger lifting DSL, + per-model tactics, or new loop abstraction only after concrete repeated + failures show a net benefit over ordinary lemmas and a thin tactic. +- **Broader carriers and effects.** Recursive nominal groups, indexed carriers, + trait/const generics, and escaping borrow reconstruction remain unsupported + with explicit errors. Add semantics and admission checks before accepting + them. Stateful or nondeterministic APIs may need observable traces and + effects, rather than only result contracts. +- **Anneal's stronger guarantees.** Construction/mutation closure, universal + caller admission, memory validity, provenance, aliasing, and UB-freedom belong + to a separately stated stronger claim. Conditional functional proofs can feed + it without silently being presented as that claim. +- **Compiler correspondence.** Independent recursive semantics plus explicit + Rust premises are the selected approach. Compiler regression tests challenge + those premises for tested configurations. Formal rustc verification, + exhaustive target coverage, and compatibility across compiler intervals are separate + projects, not consequences of these tests. +- **Editing and execution conveniences.** Existing Lake incrementality, + inspection, source maps, and copy-back are the baseline. Add watch mode, + finer proof modules, doctor checks, or owned-cache pruning only for concrete + latency or operational problems. Cache cleanup must preserve source and + recovery data; disk pressure never authorizes deleting unique work. +- **Further implementation simplification.** The shared nominal traversal and + derived registration names are implemented. Apply the same rule to future + repeated state only when it reduces the whole implementation's reading burden. + Preserve independent Rust ownership/count checks and regressions for discarded + invalid fields, enum payloads, generic shapes, provider selection, and exports. +- **Upstreaming nominal-type support.** Keep the tuple-newtype option patch + reproducible and included in the pin identity. Adopt upstream support when + available with fresh extraction, complete golden review, and both proof + builds. + +## Historical choices that should not be reintroduced accidentally + +The integration no longer uses inline Aeneas function bodies or inline proofs, +model replacement slots, proof templates, per-function declaration sorting, +`depends_on` rosters, `refines` clauses, or separate automatically selected +validity predicates. Native modules, transparent specs, direct retained +modeling providers, recursive decoding, and ordinary postconditions replace +them. + +The generic-field staging problem and copy-back gap are resolved. Model proof +completion is scoped to decoder construction rather than defaults attached to +the model declaration. Encoders, losslessness, and representation certificates +remain optional properties justified by particular operation proofs. diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md new file mode 100644 index 0000000000..be38f7e89f --- /dev/null +++ b/verification/aeneas/README.md @@ -0,0 +1,698 @@ + + +# Aeneas verification in CI + +The `aeneas` job in `.github/workflows/ci.yml` compiles selected zerocopy functions +with Charon and Aeneas, compares complete generated Lean goldens, and proves +the inline Rust specifications against both the checked-in and live models. +The required `All checks succeeded (ci.yml)` job depends on it. The settled design +and deferred work are recorded in [DESIGN.md](DESIGN.md). + +Application claims can use ordinary Rust assertion harnesses with total +`ensures _ => True` specifications. Successful termination requires every +assertion to pass. Review the independent calculation, complete comparisons, +and early returns together: returning early excludes that input from the +comparison. + +These harnesses use the same annotation and proof rules as every other function. +There is no root marker or separately maintained list of required functions. +Every present specification must have its corresponding proof in both CI builds. +Removing a specification deliberately removes that claim; the Rust diff must +make that decision visible. Helper specifications remain useful proof interfaces, +but understanding their Lean proofs is unnecessary to assess what a Rust +assertion harness promises. Reviewing the integration itself still requires +reviewing the extraction, contract elaboration, and audit machinery below. + +## How the implementation fits together + +The integration connects a comment beside a Rust function to a proposition that +Lean has proved about that function. Several tools make that connection, because +each tool answers a different question: + +1. Rust's parser identifies the item that owns the comment. The independent + source audit accounts for every reserved fence, including fences the parser + cannot associate with a supported item. These checks prevent an annotation + that looks meaningful to a reader from being silently ignored. +2. Charon translates the selected Rust implementation to LLBC, an intermediate + representation of its types and control flow. Aeneas translates LLBC to Lean. + This is the **extracted model**: executable Lean definitions representing the + Rust program. Extraction includes called functions, not just the annotated + function's body. +3. Our Lean declarations describe how extracted values are interpreted as + mathematical values. A `RustModel` supplies a mathematical type and a decoder. + Successful decoding defines which representations a contract admits. The + decoder first checks every field, then applies the type's authored decoder. + A parent that ignores a field cannot thereby skip that field's admission. + The binding manifest also accounts for every compiled raw type, including + trait dictionaries and external nominal types. Live classification comes + from Charon declarations; Lean checks the complete compiled image against + that roster. Local nominal types additionally need their exact source-bound + model provider. Macro-generated dependency types can use a structural model + only when their inspected source span belongs to an item macro. This does + not permit annotations hidden inside macro input. +4. The `spec` command turns the inline requirements and postconditions into a + transparent proposition in `Specs`. A handwritten Lean theorem proves that + proposition. The command supplies input decoding and successful-result + decoding, even when the authored postcondition says only `True`. +5. Separately written `Obligations` describe the behavior and input domain we + intend to retain. `check_contract` proves that the authored spec implies that + expectation for an arbitrary execution outcome. It then applies that + implication to the actual function and its canonical proof. `Check` audits + these compiled declarations, their source bindings, and their assumptions. + +The extracted model and the mathematical model have different purposes. The +first represents the computation we prove properties about; the second supplies +the vocabulary in which we state those properties. For example, a Rust nonzero word +is interpreted as a natural number with machine bounds and a proof of positivity. Plain clauses use mathematical values; `(raw)` +clauses use extracted representations. Both execute the same extracted function +on its original arguments and require the actual returned value to decode. + +Aeneas also distinguishes execution from the value returned by execution. +`Aeneas.Std.Result` represents success, failure such as panic, or divergence. +A successful computation may itself return a Rust `Option` or Rust `Result`. +Thus successful execution returning Rust `None`, a decoder rejecting a value, +and the function panicking are three different events. Total `spec` requires +success; `partial spec` also allows divergence, while still excluding panic. + +The independent comparison checks the specification itself. For an identity +function, both `ensures r => True` and `ensures r => r = x` are provable about +the real implementation. Comparing only those concrete truths would not reveal +that the first specification promises less. Comparing an arbitrary outcome +does: a wrong result satisfies `True` but fails the independently required +identity property. The required domain is independent too, so a decoder that +rejects every input cannot silently remove previously promised coverage. + +`run.sh` builds this whole chain twice. One build uses the checked-in extraction; +the other uses a fresh extraction. Fuzzy comparison permits specified textual +differences between them, but each build must prove all the same contracts and +pass the same audits. Neither build substitutes the inline mathematical decoder +for an extracted function definition. + +For a first review of the verification machinery, follow this order: + +| Start here | Question answered | +| --- | --- | +| A Rust `aeneas` fence | What behavior does the author promise? | +| `RustModel.lean`, `DeriveModels.lean` | How are representations admitted and interpreted? | +| `SpecsSyntax.lean` | What proposition does the fence mean? | +| `Proofs/Util.lean` or a named theorem in `Proofs.lean` | Why does the extracted computation satisfy it? | +| `Obligations.lean`, `RequiredContracts.lean` | Does that promise retain the independently stated behavior and domain? | +| `Check.lean`, `run.sh` | How does CI check that every source annotation has the required proofs? | +| `copy_back.py`, `workspace.py` | How can native Lean editing update the authoritative Rust comments safely? | + +When reviewing specifications, follow the +[agent review guidance](../../skills/zerocopy-review/SKILL.md#specification-meaning): +reconstruct the intended guarantee and check whether the written domain and +postconditions could promise less. Passing proofs establish the written claims; +they do not establish that those claims capture the intended behavior. + +The comments in these modules explain their inputs, outputs, and non-obvious +checks. This integration proves conditional functional +behavior; it does not prove that every caller meets the requirements or that +zerocopy's unsafe pointer operations are sound. + +## Scope and proofs + +Extraction starts from the function and nominal-type owners of every present +`aeneas` fence in `zerocopy/src`, including their dependencies. Every present +fence must have a supported Rust owner, exact extraction binding, and the +corresponding checked Lean declarations. Unannotated functions and impls can be +added without extending the specification set. + +| Rust function | Checked property | +| --- | --- | +| `max` | Terminates successfully and returns the mathematical maximum. | +| `min` | Terminates successfully and returns the mathematical minimum. | +| `padding_needed_for` | For any positive alignment, succeeds with padding strictly below that alignment. | +| `round_down_to_next_multiple_of_alignment` | For any positive power-of-two alignment, succeeds with a result no larger than the input and divisible by the alignment. | + +Every registered function uses total `spec`: accepted raw representations, +supplied mathematical ghosts, and explicit requirements imply successful +termination, successful output decoding, and the postconditions. These are +quantified conditional contracts, not finite test inputs. Broader ordinary Raw +lemmas retain useful representation-level domains. + +This initial arithmetic scope establishes extrema, padding bounds and an +aligned result no larger than the input. It does not claim least padding or +the greatest aligned predecessor. + +Plain arithmetic clauses use mathematical word values carrying machine bounds +and NonZero positivity. Their Nat/Int arithmetic does not wrap; explicit raw +clauses preserve the extracted representation vocabulary while retaining +automatic input and output decoding. + +CI uses the default features, debug assertions and the runner's native target. +Local replay also supports macOS arm64. These conditional contracts do not +certify other extraction configurations, rustc, zerocopy's pointer safety or +whole-crate unsafe behavior. + +## Shared mathematical library + +The independent Lake package at `anneal/lean` supplies the `Rust` models. This +integration uses a local path dependency and recompiles it with this project's +pinned Lean, independently of Anneal's archive pin. It reuses the existing +backend packages without fetching a second Mathlib tree. The golden and live +projects both import the same authored mathematical library; their extracted +Rust definitions remain independent. + +The library's own `RustTests` and `RustBytesTests` targets run in the Anneal +archive build. This integration also audits the actual transitive dependencies +of its proofs, including shared-library lemmas. + +## Reproduce + +Install Nix, `rustup`, Python 3.9+, Git, curl, tar, and zstd, then run from the repository +root: + +```bash +bash verification/aeneas/setup.sh +bash verification/aeneas/run.sh +bash verification/aeneas/negative-controls.sh +python3 -B -m unittest discover -s verification/aeneas/tests +``` + +The runner builds `tools/aeneas-inline` with the pinned compiler. To run that +tool's own tests, use: + +```bash +source verification/aeneas/toolchain.sh +CARGO_TARGET_DIR=target/aeneas/annotation-tool \ + cargo +"$AENEAS_RUST_TOOLCHAIN" test --locked \ + --manifest-path tools/Cargo.toml -p aeneas-inline +``` + +The local failure-control command checks both models sequentially. To select +one isolated project, pass `golden-verification` or `verification`. CI runs +these two suites concurrently, waits for both, and reports both logs; either +failure fails the job. + +`setup.sh` downloads SHA-256-checked release archives, builds the pinned +Aeneas source with the checked-in CLI patch using its locked Nix dependencies, +and installs the matching Rust compiler with `rustc-dev` and `rust-src`. It fetches the pinned Lean +backend's Mathlib dependencies using its supplied lockfile. Cache downloads +are limited to imports of the backend and handwritten proof modules, +together with their dependencies. + +The resulting tools live in ignored `target/aeneas/toolchain`. To reuse an +existing installation with the same bundle layout, set `AENEAS_TOOLCHAIN_DIR`. +`run.sh` checks tool versions before extraction. It runs Charon with +`--preset=aeneas --sysroot default`, using Aeneas's builtin models for standard +library operations instead of regenerating a sysroot. Cargo resolves the +library's dependencies from its checked-in vendor configuration, offline and +locked. Charon is a compiler driver and selects its own Rust version; it does +not use zerocopy's ordinary Cargo wrapper or its stable/nightly aliases. + +Every run replaces `target/aeneas/verification` (live extraction) and +`target/aeneas/golden-verification` (checked-in model), including their compiled +Lean artifacts. This prevents stale declarations or the other model's artifacts +from passing after source is removed or renamed. Both retain Lean sources, +handwritten models/proofs, and resolved Lake manifests for inspection; the live +directory also retains LLBC. External templates are compared but never imported +into proofs as axioms. + +## Inline specifications and coverage + +Annotations are outer Rust doc attributes before the decorated item. The Rust +parser owns the association, including attributes between docs and the item. +Every reserved fence is accounted for: malformed or misspelled fences, unsupported +owners, duplicate declarations, and orphaned annotations fail. Literal `#[doc]` +attributes are also supported. An owner with a literal Aeneas annotation cannot +also carry computed doc strings. Direct `include_str!` in owner doc expressions +is rejected, including nested, qualified, and conditional invocations. Visible +reserved literal fragments in computed docs are rejected even when split across +strings. Conditional and inner doc forms with reserved fences also fail. +Unannotated computed documentation otherwise retains its ordinary Rust meaning. +Macro-produced documentation is outside the annotation language: the checker +does not expand macros or claim to inspect arbitrary external text they produce. + +````rust +/// ```aeneas +/// spec identity_spec +/// ensures result => result = x +/// ``` +fn identity(x: T) -> T { x } +```` + +A function fence contains exactly one `spec` or `partial spec` declaration. +Only explicitly typed ghost binders are authored. The original Rust type +parameters, receiver `self`, and arguments come from the exact extracted +signature. Each original type parameter receives one `RustModel T` dictionary, +including unused and result-only parameters. + +Plain clauses expose value arguments as their mathematical models. Raw clauses +expose their original Aeneas representations: + +```text +requires name : proposition +requires(raw) name : proposition +ensures resultPattern => proposition +ensures(raw) resultPattern => proposition +``` + +Every requirement is a named implication premise; later clauses can use its +proof. Requirements precede all postconditions. Every postcondition contributes +a conjunct, and all plain postconditions share one decoded output witness. +Ghost types elaborate in mathematical input scope, so `(position : Fin self.length)` +can depend on a decoded sequence. Ghosts may depend on earlier ghosts. Inputs, +ghosts, and earlier requirement names cannot be shadowed. No ghost is an argument +to the extracted function or is selected after execution. + +The proposition retains the original contiguous raw-input telescope. It then +introduces modeling dictionaries, mathematical inputs, decoding equations, +ghosts, and requirements before executing the original extracted function on +its original raw arguments. A successful payload must decode, even when every +postcondition is raw or is `True`. Total specifications require successful +termination and exclude panic and divergence. Explicit `partial spec` permits +divergence while excluding panic and checking every successful return. + +A type fence defines one mathematical shape and one local decoder: + +````rust +/// ```aeneas +/// model AlignValue where +/// value : Nat +/// power_of_two : value.isPowerOfTwo +/// decode? self => +/// if h : self.value.value.isPowerOfTwo then +/// some { value := self.value.value, power_of_two := h } +/// else none +/// ``` +struct Align { value: NonZeroUsize } +```` + +The alternative `model Name := ExistingType` associates an existing mathematical +type. `decode self => term` is locally infallible; `decode? self => term` may +reject the decoded fields. Both consume the owner's generated `Fields` carrier. +The full decoder first decodes every field of a structure or the active enum +constructor, including fields ignored by the local decoder. Child rejection +rejects the entire value. There is no independent validity dictionary or predicate. +An unannotated nominal owner's model is its own `Fields` carrier. `model Name := Fields` +uses that same representation under an authored name. Different Rust owners +retain distinct default carriers; aliases inherit their carrier's model. + +Native adapters carry exact scalar values and machine bounds, plus positivity +or nonzero proofs for NonZero types. Products, options, Rust results, lists, +slices, and arrays traverse their child providers and preserve their constructors +and lengths. Rust `Option.none` and Rust `Result.Err` are ordinary successfully +decoded cases; Aeneas panic/failure remains a separate execution outcome. +Unsupported opaque leaves, recursive nominal groups, indexed carriers, +trait/const generics, and escaping borrow continuations fail explicitly. +Generic shapes take mathematical type parameters: `Box.Fields TModel` needs +only its child carrier type. Authored generic model declarations bind `TModel` +for each Rust parameter `T`; decoder bodies and function specifications retain +the real dictionaries and `ModelOf T`. A field of type `Box` can therefore +declare its mathematical shape before Child's decoder exists. Slice and array +shapes retain their length bounds without a child decoder. Full decoding still +requires the actual child providers. + +Each generated nominal decoder has a `decode_decompose` theorem. It characterizes +successful decoding by existential decoded child values, their child decoder +equations, and the local `decodeFields` equation. Enum cases follow their active +constructor. These witnesses remain present when a local model discards fields; +no reconstruction or injectivity assumption is required. + +Automatic decoding uses directly named native/nominal providers, fixed container +combinators, and the original generic dictionary variables. Providers are selected +before decoded witnesses and ghosts and retained explicitly, so a later ghost +instance cannot redirect admission or output meaning. Ordinary instances remain +available for authored expressions and helper lemmas. No global instance-selection +comparison is part of the automatic-decoding audit. + +Generated modules follow the proof-construction dependency: + +```text +raw Types + ModelPrelude → ModelShapes → Models → Specs + Proofs +``` + +`ModelShapes` declares nominal mathematical fields and inline model types in +verified extraction order. Handwritten `ModelSupport` modules can supply ordinary +Lean helpers. Lean enforces an acyclic import graph; there is no additional ban on +harmless dependencies on extracted functions. `Models` elaborates local decoders +and composes full decoders and named providers. The generated shapes, decoders, +and specifications all carry Rust source maps. Every handwritten module is +discovered and audited; generated modules are excluded from that discovery. +Handwritten module path components must be simple identifiers +(`[A-Za-z_][A-Za-z_0-9]*`); literal dots, whitespace, and quoted components in +filenames are rejected so distinct Lean Name segments cannot be conflated by the +file inventory. + +These are conditional contracts over accepted representations, supplied ghosts, +and satisfied requirements. A decoder may intentionally forget observations or +reject raw values. A ghost with type `False`, or a dependent ghost with an empty +domain, makes the relevant contract vacuous. Neither the pipeline nor a decoder +proves that every Rust caller meets the domain. Operation-specific +alignment and fit conditions remain explicit. The independently written operation expectations check both intended input +admission and promised behavior. Representation laws can be useful proof helpers; +they are not an additional universal model-certification requirement. + +The fully qualified model application is generated and cannot be supplied in +Rust. Charon's root identity, exact source body, source path, and signature must +match the annotation, including inherent Self types and inactive `cfg` +alternatives. Discovery retains exact inspected source snapshots; later digest, +binding, and assembly steps reject changes to any inspected input. The source +digest includes unannotated dependency files. Every +contents-bearing Charon-local file must agree with its inspected snapshot, so an +unchanged caller cannot hide a changed dependency. Pinned external `/rustc` files +without source contents remain within the translation premise. +The elaborated call must pass exactly its original Rust input +variables, with no swapping, coercion, or substitution. Introduced signatures are +inspectable in the generated Lean propositions and native editor. + +The source annotations determine the extraction roots. Removing an annotation +and its associated proof intentionally removes that claim from the checked set; +the source and generated golden changes make the reduced coverage reviewable. +There is no separately maintained function or type coverage roster. Nominal +types are still registered and audited independently from their specifications. + +Every present function annotation must match its exact extracted function. +Called functions and constants remain part of the complete dependency model. +Model +names, theorem identities, filenames, and dependency edges are derived rather +than stored as additional synchronized inventory fields. Discovery scans all +repository Rust sources except generated and vendored directories. A Rust +lexer distinguishes comments from strings and `syn` establishes ownership +before conditional compilation. + +No parser can recognize every informal prose claim as a proof. The `aeneas` +fence is the reserved CI-checked convention. Near-miss guard detection and the +independent policy protect that convention without treating all mathematical +prose as annotations. + +## Native Lean proofs and independent checks + +`Specs.lean` is generated from the Rust comments in a fixed context containing +the extracted model and mathematical vocabulary. Each `Zerocopy.Specs.*` is a +transparent proposition. The module is elaborated before proof modules are +imported, so proof declarations cannot change what the comment means. + +Handwritten proofs are ordinary checked-in `.lean` modules. Their theorem types +refer directly to generated propositions rather than restating them: + +```lean +theorem example_spec : Zerocopy.Specs.example_spec := by + intro input h + ... +``` + +Changing the Rust specification changes this theorem's goal. An old proof is +accepted only when Lean still establishes that goal. Normal imports and Lean +declaration order assemble proofs. There are no proof templates, insertion +slots, or hand-maintained dependency lists. + +Each function annotation requires its canonical exported proof and an independent +proposition in `Obligations.lean`. Missing canonical proofs, stale `Specs` +references, duplicate declarations, and unrelated required theorem types fail. +Additional helper theorems are allowed in the configured proof modules. The +required checks prove that the inline contract implies its independently authored +expectation for **every execution outcome**, before specializing that implication +to the proved implementation. The generated `Specs._contract` family +abstracts only the execution node; the audit checks that every original premise, +decoding check, and postcondition remains unchanged. Handwritten +`Obligations._contract` families independently state the expected domain and +result. Their concrete `Obligations.` aliases are checked to specialize the +same families to the original calls. + +This comparison cannot pass merely because a weaker specification and the +expected behavior happen to hold for the current implementation. It also catches +an unexpectedly narrowed domain. The generated checks retain both an +`_adequate` implication and an `_checked` concrete theorem for kernel +and axiom audits. Every canonical theorem must directly name its inline `Specs` +proposition; the audit requires both independent witnesses in the generated checks +module. It derives required theorems from compiled specifications, without a +separate generated roster. The expectations are reviewable sanity checks on +specification meaning, not an additional axiom or proof language. + +The audit also compares the existing verified Rust-to-model mapping in +`bindings.json` with the functions actually called by compiled specifications +declared in `Specs`. Every mapped function must have exactly one such +specification; missing, extra, or repeated functions fail. Each specification +in turn requires its canonical theorem and independent required theorem. +The same versioned table records every extracted nominal owner's source, +raw carrier, Fields carrier, mathematical type, decoder, and provider. The compiled +audit checks those identities and their generated-module ownership in both +directions, independently scans raw nominal types, and rejects missing or extra +provider registrations. This reuses one verified table without another coverage roster. + +`Check.lean` derives dependencies from elaborated theorem types and terms, +following helpers across handwritten modules, including private helpers, and +writes `proof-dependencies.json` in each proof workspace. It imports and audits all +declarations from every handwritten Lean module, including unused auxiliary +modules, together with the model, vocabulary, obligations, and generated +specifications. Only `propext`, `Classical.choice`, and `Quot.sound` are +permitted. +New axioms, `sorryAx`, and native evaluator proof axioms fail. + +Callers reuse ordinary exported theorems, either explicitly with `step with` or +through Aeneas's existing `step` registration. For a theorem whose type is a +transparent `Specs` proposition, `register_spec_step theorem_name` supplies a +kernel-checked alias with the expanded type for that registry. The canonical +theorem keeps its ordinary `Specs` type. Golden caller proofs use golden +callee theorems; live caller proofs use live callee theorems. Both models are +built in separate fresh workspaces from the same handwritten source, never +from shared compiled proofs or substituted model definitions. Handwritten +modules must not create import cycles through generated `Required` or `Check`. + +## Decoder proof completion + +Model fields declare ordinary Lean constraints. A decoder can supply only its +data and use native `..` to omit proof fields: + +For a hypothetical type with a machine-word field `word`: + +```lean +model SuccessorValue where + value : Nat + positive : 0 < value +decode self => + { value := self.word.value + 1, .. } +``` + +Both `decode` and `decode?` implicitly use `model_value` completion on the whole +body; outside decoders write `model_value { ... , .. }` explicitly. Native +elaboration creates the omitted-field goals, so no field discovery or defaults +are attached to model declarations. Every remaining goal must be a proposition. +Missing data (including a field of type `Prop`) is an error. Proof search uses +ordinary assumptions, arithmetic, and shared logarithm/word-bound facts with a +maximum of 100000 Lean heartbeats; explicit field proofs and complete `by` +bodies retain their ordinary meaning. + +Proofs are completed in their own local contexts, including `let` bindings and +conditional hypotheses, even in nested records or `some { ... , .. }`. An +unproved constraint fails compilation, including in a fallible decoder; it +never becomes `none` or changes the decoder's domain. Ordinary proof fields +ensure that chosen data satisfy the model constraints. The independent meaning +and admission laws still check what those data describe and which raw values +are accepted. `ModelCompletionTests.lean` covers both behaviors. + +## Development + +Run `bash verification/aeneas/dev.sh` to prepare the stable development project from the +checked-in golden model. Add `--live` to freshly extract the Rust implementation +and use that model instead. This extraction mode prepares editable goals even +when proofs are unfinished; it does not claim that golden comparison or proof +checks have passed. Add `--check` to build the development project and audit its +proofs. + +The project keeps handwritten proof modules at their checked-in paths and +generates the selected extraction, `ModelShapes.lean`, `Models.lean`, and +`Specs.lean` for native Lean editing. +Incremental Lake builds reuse +that project's compiled dependencies. The generated source map connects +specification diagnostics to the Rust comments; proof diagnostics already point +to their checked-in Lean source. + +Development regeneration records a version 2 baseline of the three inline +projections and their owned Rust doc payloads, and refuses to overwrite local +edits before extraction or project writes. Edit only the marked authored +regions: specifications in `Specs`, model shapes in `ModelShapes`, and the +`decode`/`decode?` clause in `Models`. A type's shape and decoder copy back +together. Generated imports, `for` targets, and derive/check commands remain +unchanged. Handwritten proofs continue to be edited at their real paths. + +Preview the exact Rust diff, then apply it for one full Rust owner: + +```sh +bash verification/aeneas/dev.sh --copy-back zerocopy::util::padding_needed_for --dry-run +bash verification/aeneas/dev.sh --copy-back zerocopy::util::padding_needed_for +``` + +Copy-back reads the existing project without regeneration, extraction, or +builds, and cannot be combined with `--live` or `--check`. Preview writes +nothing. Apply updates one annotation in one Rust file, preserving its doc +indentation, newline style, other source bytes, and file permissions. It accepts +only contiguous, uniformly indented `///` fences; block comments and literal +doc attributes remain readable but require manual editing. The saved source +snapshot and owned spans authorize the edit; source maps are diagnostic only. +Edits outside authored regions and changes to the original Rust file fail +without writes. Authored edits must retain the formatting produced by generation; +copy-back rejects formatting that would be discarded on the next regeneration. Other owners' edits stay protected until copied back in turn; +then rerun the development command to regenerate from Rust. + +Rust and its projection baseline are replaced separately. If interruption +occurs after writing Rust, rerun the same copy-back command with unchanged +Lean projections: it recognizes the exact planned Rust result and finishes +updating the baseline. Keep the baseline and edited projections together for +this recovery. There is no force option. + +Older version 1 baselines still protect edited projections, but cannot copy +back automatically. Clean regeneration upgrades them. For unbaselined or +older edited projections, preserve the files and manually move authored +fragments into the Rust doc fences before establishing a clean baseline. +Unannotated structural models have no authored fragment; edit their Rust type +or the generator. + +After a Lake build, inspect an effective compiled specification with: + +```sh +python3 -B verification/aeneas/workspace.py inspect verification/aeneas/lean \ + --root "$PWD" --spec max_spec +``` + +This reads compiled declarations without running another build. It displays the +actual proposition, original raw call, selected input/result providers and +decoding equations, total/partial WP judgment, all binder types, compiled axiom +dependencies, existing proof graph, and available extraction metadata. Mathematical +witnesses and ghosts are shown as binders; proposition-valued ghosts and named +requirements have the same universal meaning and are displayed together. Rust/ABI +correspondence assumptions are documented in `SEMANTICS.md` when that document is +present; the scope and translation trust boundary are described above. Missing +extraction provenance is reported as unavailable; legacy LLBC metadata is explicitly uncertified against +compiled imports. An edited source may differ from its compiled declaration; +refresh and audit with `dev.sh --check` before relying on inspection. This command +provides inspection, not a new verification result. + +Edit a specification in Rust and run the development command again to refresh +its generated proposition. Edit its proof directly in Lean. No proof copying +back to Rust is required. This development cache is for iteration; CI always +regenerates specifications and builds both models in fresh isolated projects. + +## Updating models and fuzzy comparison + +`golden/` stores all four complete generated Aeneas modules, including types, +function bodies, trait dictionaries, constants, and external templates. There +are no per-function model slots or separate scaffolding. Handwritten external +models remain under `lean/Zerocopy/` and templates are never imported as axioms. + +Regenerate the checked-in output with: + +```bash +bash verification/aeneas/run.sh --update-goldens +``` + +The command extracts live output and checks its proofs and independent required +propositions before updating the complete generated module set. It then checks +comparison and both model builds. Rust comments, handwritten proofs, and +executable Rust are preserved. Review and commit the changed goldens alongside +the Rust or pin changes that prompted them. Ordinary CI never updates them. + +To add coverage, add the fenced specification, its ordinary Lean theorem, and an +independent required proposition. +Regenerate the complete goldens. Each annotation's model declaration is checked +in the complete generated modules; missing or duplicate declarations fail. + +Fuzzy comparison ignores line and nested block comments, including source +locations, empty nonliteral lines, and trailing horizontal whitespace outside +literals. It preserves code, indentation, nonempty line boundaries, interior +whitespace, and string/character/quoted-identifier contents. Unsupported raw or +interpolated strings and malformed comments/literals fail closed. Missing or +additional generated files, changes to external-template signatures, and code +changes fail with a normalized diff and require regeneration. + +CI compiles the checked-in model and the unmodified live model in separate fresh +Lake workspaces. Both must prove all inline specifications and any checked-in composition +corollaries and pass required checks and the complete axiom audit. The normalized +text is used only for comparison, never compilation. This independently checks +that accepted differences preserve the proved properties; it does not by itself +prove full semantic equivalence beyond those properties. + +## Pins and upgrade procedure + +The selected release is +[`nightly-2026.10.01-ca282ec`](https://github.com/AeneasVerif/aeneas/releases/tag/nightly-2026.10.01-ca282ec). +`toolchain.sh` records its full Aeneas commit, the compatible Charon commit from +[`charon-pin`](https://github.com/AeneasVerif/aeneas/blob/ca282ec2312a96f6a985a4d23a663690816d3f3e/charon-pin), +Rust `nightly-2026-09-17`, Lean `4.31.0`, and platform archive digests. The release +supplies prebuilt Aeneas/Charon binaries and compiled Lean backend artifacts. +The checked-in patch exposes the existing `Config.use_tuple_structs` option as +`-use-tuple-structs false`, which our extraction selects. The upstream default +remains unchanged. Source and patch digests, a distinct tool version, and +`tests/nominal-tuples.sh` keep this change reproducible and check constructors, +projections, patterns, updates, and default-mode compatibility. +Its `lake-manifest.json` pins Mathlib and transitive dependencies; the generated +proof workspace shares those fetched dependencies through path entries instead +of resolving a second copy. + +Upgrade this tuple together. Verify archive digests, resolve the new +`charon-pin`, inspect that Charon revision's Rust toolchain and Aeneas's Lean +version, and run extraction and all proofs from fresh output. Review new +external templates, the generated helper bodies, proof statements, and axiom +results. Remove or adapt the naming workaround only after inspecting the new +output. Do not pair Aeneas with an independently selected Charon nightly. + +## Trust boundary and failure checks + +These are Lean theorems about generated functions. Transferring them to Rust +requires the following external correspondence premises, which this CI job +records but does not formally prove: + +- Rust MIR generation and Charon's LLBC extraction preserve these selected + functions' semantics for the pinned configuration. +- Aeneas's translation and the pinned integer, comparison, wrapping-subtraction, + checked-addition, bitwise, and assertion models represent those Rust + operations faithfully. The translated layout records and variants represent + their stored fields, not physical Rust layout or niche encoding. +- The handwritten `NonZero` wrapper represents its stored value; + `NonZero::get` succeeds with that value and `NonZeroUsizeInner::clone` + succeeds with a copy. These models abstract values, not niche encoding, + layout, validity, or memory operations. The wrapper over-approximates valid + Rust values by admitting zero; the generated conditional contracts assume + successful recursive decoding of inputs and prove it for returned values. + This introduces no axiom asserting that every modeled wrapper is nonzero. +- Lean's kernel, its standard logic axioms, and the imported proof artifacts + check the encoded propositions correctly. Release checksums establish + artifact identity, not a proof of compiler or model correctness. + +Failure controls challenge missing annotations and proofs, orphaned proofs, +incorrect binding and parameter order, malformed specifications, admitted +proofs, unrelated theorem types, changed generated callees, and comments or +whitespace accepted by fuzzy comparison. Each model must pass the same controls. +Control families are selected from available model capabilities. Once selected, +a missing fixture or mutation target is an error. Semantic model mutations must +compile before their proof failures count as evidence; malformed mutants fail +the control run. Valid weaker contracts and weakened outcome predicates must +also be rejected. Controls edit and restore scratch workspaces, then recheck the +original results. + +Extraction rejects warnings, translation errors, missing artifacts, and LLBC +whose `has_errors` is not exactly `false`, even when Charon exits zero. +`prepare.py` repairs one pinned Aeneas naming collision: `ZeroablePrimitive` has +two `Copy` parent dictionaries that Aeneas names `markerCopyInst`. The second +projection and initializer are renamed `innerCopyInst`; dictionary types and +values are preserved and no function body changes. Unexpected output fails. + +## Research used + +The upstream [Aeneas README](https://github.com/AeneasVerif/aeneas) describes the +Charon-to-LLBC-to-Lean pipeline, required compatible pins, external model +workflow, and safe-subset limitations. The repository's `reference` branch +provided useful prior evidence: + +- [Compatible bundle drift](https://github.com/google/zerocopy/blob/ba4556c30835b56d108b0a6854a760bccf8438c5/reports/aeneas-compatible-bundle-source-drift-2026-09-30/REPORT.md) + identifies the newer Lean module output and the need to resolve Charon from + Aeneas's pin rather than release dates. +- [External models](https://github.com/google/zerocopy/blob/ba4556c30835b56d108b0a6854a760bccf8438c5/reports/aeneas-external-models-nightly-2026-06-03/REPORT.md) + explains the generated templates and the semantic boundary of model + registrations. +- [Whole-library Charon extraction](https://github.com/google/zerocopy/blob/ba4556c30835b56d108b0a6854a760bccf8438c5/reports/charon-zerocopy-library-repeat-order-nightly-2026-05-31/REPORT.md) + observed successful process exits with `has_errors: true`, supporting the + explicit LLBC completeness check. + +Those older experiments are not assumed to describe the current release's +runtime. This integration was validated with the pinned October 1 bundle and +fresh translations of the current checkout. diff --git a/verification/aeneas/build-aeneas.nix b/verification/aeneas/build-aeneas.nix new file mode 100644 index 0000000000..a521524afd --- /dev/null +++ b/verification/aeneas/build-aeneas.nix @@ -0,0 +1,34 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# Use the upstream locked compiler/dependencies, changing only the version and +# dropping the post-install Charon symlink. The existing release supplies Charon. +let + upstream = builtins.getFlake (builtins.getEnv "AENEAS_SOURCE_DIR"); + system = builtins.currentSystem; + pkgs = import upstream.inputs.nixpkgs { inherit system; }; + base = if pkgs.stdenv.isLinux + then upstream.packages.${system}.aeneas-static + else upstream.packages.${system}.aeneas; + aeneas = base.overrideAttrs (_: { + AENEAS_VERSION = builtins.getEnv "AENEAS_VERSION"; + postInstall = ""; + }); +in pkgs.runCommand "aeneas-nominal-bundle" { + nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isDarwin + [ pkgs.macdylibbundler ]; +} '' + mkdir -p $out + cp ${aeneas}/bin/aeneas $out/aeneas + # macOS needs the executable's dynamic libraries alongside it after copying + # out of the Nix store. Linux uses the upstream static package instead. + ${pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + chmod +w $out/aeneas + dylibbundler -od -b -x $out/aeneas -d $out/libs -p @executable_path/libs + ''} +'' diff --git a/verification/aeneas/build-aeneas.sh b/verification/aeneas/build-aeneas.sh new file mode 100755 index 0000000000..71d1f306c0 --- /dev/null +++ b/verification/aeneas/build-aeneas.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. +set -euo pipefail +cd "$(dirname "$0")/../.." +source verification/aeneas/toolchain.sh +[[ $# == 1 && -d "$1" ]] || { echo "Usage: $0 TOOLCHAIN_DIRECTORY" >&2; exit 1; } +tools_dir=$(cd "$1" && pwd) +command -v nix >/dev/null || { echo 'Building the patched Aeneas requires Nix.' >&2; exit 1; } +mkdir -p target/aeneas +staging=$(mktemp -d "$PWD/target/aeneas/build.XXXXXX") +trap 'rm -rf "$staging"' EXIT +# Check both producer inputs before applying the CLI patch. The build record +# below also hashes the resulting executable so inspection can identify it. +check_hash() { + python3 - "$1" "$2" <<'PY' +import hashlib, sys +from pathlib import Path +actual = hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest() +if actual != sys.argv[2]: + raise SystemExit(f"SHA-256 mismatch for {sys.argv[1]}: {actual}") +PY +} +curl --fail --location --silent --show-error --retry 5 \ + "https://codeload.github.com/AeneasVerif/aeneas/tar.gz/$AENEAS_REV" \ + -o "$staging/source.tar.gz" +check_hash "$staging/source.tar.gz" "$AENEAS_SOURCE_SHA256" +check_hash verification/aeneas/patches/use-tuple-structs.patch "$AENEAS_PATCH_SHA256" +tar -xzf "$staging/source.tar.gz" -C "$staging" +export AENEAS_SOURCE_DIR="$staging/aeneas-$AENEAS_REV" +export AENEAS_VERSION +( + cd "$AENEAS_SOURCE_DIR" + patch -F 0 -p1 < "$OLDPWD/verification/aeneas/patches/use-tuple-structs.patch" +) +# No update/override of flake inputs: the source archive includes flake.lock. +# Limit simultaneous derivations and compiler jobs on small developer machines. +artifact=$(nix --extra-experimental-features "nix-command flakes" build --impure --file verification/aeneas/build-aeneas.nix \ + --no-link --print-out-paths --option max-jobs 1 --option cores 2) +[[ $("$artifact/aeneas" -version) == "aeneas $AENEAS_VERSION" ]] +"$artifact/aeneas" -help | python3 -c 'import sys; assert "-use-tuple-structs" in sys.stdin.read()' +[[ $("$tools_dir/aeneas" -version) == "aeneas $AENEAS_RELEASE" ]] +# Preserve the unpatched release executable as the compatibility oracle for +# nominal-tuples.sh; the patched default must still produce its exact output. +cp "$tools_dir/aeneas" "$tools_dir/aeneas-upstream" +cp "$artifact/aeneas" "$tools_dir/aeneas" +if [[ -d "$artifact/libs" ]]; then + mkdir -p "$tools_dir/libs" + cp "$artifact/libs/"* "$tools_dir/libs/" +fi +python3 - "$tools_dir" "$AENEAS_REV" "$AENEAS_SOURCE_SHA256" "$AENEAS_PATCH_SHA256" "$AENEAS_VERSION" <<'PY' +import hashlib, json, sys +from pathlib import Path +root = Path(sys.argv[1]) +provenance = dict(zip(("source_rev", "source_sha256", "patch_sha256", "version"), sys.argv[2:])) +provenance["binary_sha256"] = hashlib.sha256((root / "aeneas").read_bytes()).hexdigest() +(root / "aeneas-build.json").write_text(json.dumps(provenance, indent=2) + "\n") +PY diff --git a/verification/aeneas/copy_back.py b/verification/aeneas/copy_back.py new file mode 100644 index 0000000000..3e6ea44d83 --- /dev/null +++ b/verification/aeneas/copy_back.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Copy one marked development projection into its original owned /// fence. + +The persisted generation snapshot is reverse-edit authority. Source maps only +explain Lean diagnostics. Rust and the baseline have separate atomic writes; +an interrupted baseline write is reconciled against the exact planned Rust. +""" + +import copy +import difflib +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import tempfile + +MODULES = ('ModelShapes', 'Models', 'Specs') +STATE = '.inline-projections.json' +OWNER = r'zerocopy(?:::[A-Za-z_][A-Za-z_0-9]*)+' +MARK = re.compile(r'^-- aeneas-copy-(begin|end) (' + OWNER + r') (spec|shape|decoder)\n', re.M) +PART_MODULE = {'spec': 'Specs', 'shape': 'ModelShapes', 'decoder': 'Models'} + + +def digest(text): + """Hash exact UTF-8 projection text, including its line endings and framing.""" + return hashlib.sha256(text.encode('utf-8')).hexdigest() + + +def regions(text): + """Return strict marker framing, rejecting nested, fake and duplicate marks.""" + pieces, values, position, active = [], {}, 0, None + matches = list(MARK.finditer(text)) + # Even a malformed or embedded lookalike must not become authored content. + if text.count('aeneas-copy-') != len(matches): + raise ValueError('Malformed copy-back marker') + for match in matches: + key = (match[2], match[3]) + if match[1] == 'begin': + if active is not None or key in values: + raise ValueError('Nested or duplicate copy-back marker') + pieces.append(text[position:match.end()]) + active = (key, match.end()) + else: + if active is None or active[0] != key: + raise ValueError('Unmatched copy-back marker') + values[key] = text[active[1]:match.start()] + pieces.append(key) + position, active = match.start(), None + if active is not None: + raise ValueError('Unterminated copy-back marker') + pieces.append(text[position:]) + return pieces, values + + +def render(pieces, values): + """Fill checked regions while preserving every generated framing byte.""" + return ''.join(values[piece] if isinstance(piece, tuple) else piece for piece in pieces) + + +def authored(entry, values): + """Recover one payload while preserving generated application and formatting. + + Parsing alone is insufficient: forward assembly must reproduce every edited + byte. Check that round trip before allowing the payload to replace Rust. + """ + import inline + owner = entry['rust'] + if entry['kind'] == 'function': + text = values[(owner, 'spec')] + lines = text.splitlines(keepends=True) + application = entry['for_line'] + '\n' + if lines.count(application) != 1: + raise ValueError('Generated for target must remain exactly unchanged') + application_index = lines.index(application) + lines.remove(application) + payload = ''.join(lines) + _, clause_line = inline.parse_spec(payload, entry['generics'], entry['inputs']) + if application_index != clause_line: + raise ValueError('Generated for target must remain immediately before the first specification clause') + # Match assemble's projection rules before acknowledging edited text. + # A later regeneration must reproduce every selected authored byte. + projected = payload.rstrip('\n').split('\n') + projected.insert(clause_line, entry['for_line']) + if text != '\n'.join(projected) + '\n': + raise ValueError('Specification formatting would change on regeneration; ' + 'remove trailing blank lines from the authored region and retry') + else: + payload = values[(owner, 'shape')].rstrip('\n') + '\n' + values[(owner, 'decoder')] + model = inline.parse_model(payload, entry['generics']) + shape = '\n'.join(model['shape_text'].splitlines()) + '\n' + decoder = (f'{model["decoder_mode"]} {model["value_binder"]} =>\n' + + ''.join(' ' + line + '\n' for line in model['decoder_text'].splitlines())) + if values[(owner, 'shape')] != shape or values[(owner, 'decoder')] != decoder: + raise ValueError('Model formatting would change on regeneration; remove trailing blank lines, ' + 'put the decode/decode? body on separate lines indented by two spaces, and retry') + if not payload.endswith('\n') or '\r' in payload or '```' in payload: + raise ValueError('Authored payload requires LF lines without Rust fence delimiters') + return payload + + +def record(work, annotations): + """Save complete projections and inspected Rust, not just diagnostic maps. + + The hashes detect changes; the saved text and physical spans explain which + bytes an owner may change. Validate the assembled authority before saving it. + """ + projections = {name: (work / f'{name}.lean').read_bytes().decode('utf-8') for name in MODULES} + all_values = {} + for text in projections.values(): + all_values.update(regions(text)[1]) + owners, sources = {}, {} + for rust, annotation in annotations.items(): + entry = {key: annotation[key] for key in ('rust', 'file', 'kind', 'generics', 'inputs', 'copy_span')} + entry['payload'] = annotation['spec_text'] if entry['kind'] == 'function' else annotation['model_text'] + if entry['kind'] == 'function': + candidates = [line for line in all_values[(rust, 'spec')].splitlines() if line.startswith(' for ')] + if len(candidates) != 1: + raise ValueError('Missing generated specification application') + entry['for_line'] = candidates[0] + owners[rust] = entry + sources[entry['file']] = annotations.source_snapshots[entry['file']].decode('utf-8') + state = {'version': 2, 'sha256': {name: digest(text) for name, text in projections.items()}, + 'projections': projections, 'owners': owners, 'sources': sources} + validate(state) + (work / STATE).write_text(json.dumps(state, ensure_ascii=False, indent=2) + '\n') + + +def validate(state): + """Validate saved authority and return its parsed layouts and authored bytes. + + A baseline must agree in both directions: every marker has a saved owner, + and every owner has exactly its expected regions. Physical spans must also + reproduce the saved Rust payload. Callers may reuse the checked parse after + this function succeeds; no portion becomes authority on an early failure. + """ + import inline + try: + if (not isinstance(state, dict) or set(state) != {'version', 'sha256', 'projections', 'owners', 'sources'} + or state['version'] != 2 or set(state['sha256']) != set(MODULES) + or set(state['projections']) != set(MODULES) or not state['owners']): + raise ValueError('Copy-back requires a complete version 2 baseline; regenerate clean projections to upgrade') + values, projections = {}, {} + for module in MODULES: + text = state['projections'][module] + if not isinstance(text, str) or state['sha256'][module] != digest(text): + raise ValueError('Projection baseline digest disagrees with saved text') + layout, parts = regions(text) + if any(PART_MODULE[part] != module for _, part in parts): + raise ValueError('Projection region is in the wrong module') + values.update(parts) + projections[module] = (layout, parts) + expected, spans = set(), {} + if set(state['sources']) != {e['file'] for e in state['owners'].values()}: + raise ValueError('Source snapshot roster disagrees with owners') + for owner, entry in state['owners'].items(): + keys = {'rust', 'file', 'kind', 'generics', 'inputs', 'copy_span', 'payload'} + if entry.get('kind') == 'function': + keys.add('for_line') + if (set(entry) != keys or entry['rust'] != owner or not re.fullmatch(OWNER, owner) + or entry['kind'] not in ('function', 'type') or not inline.valid_file(entry['file']) + or any(not isinstance(entry[k], list) or any(not isinstance(n, str) or + not re.fullmatch(inline.IDENTIFIER, n) for n in entry[k]) for k in ('generics', 'inputs'))): + raise ValueError('Invalid saved annotation ownership') + expected.update((owner, part) for part in (['spec'] if entry['kind'] == 'function' else ['shape', 'decoder'])) + # Decode the original authored projection independently of Rust's doc + # spelling. Inline decoders are canonically expanded onto two lines. + original = authored(entry, values) + if entry['kind'] == 'function': + # Forward assembly strips final empty specification lines; the + # complete source payload remains authoritative for no-op edits. + equivalent = original == entry['payload'].rstrip('\n') + '\n' + else: + before = inline.parse_model(entry['payload'], entry['generics']) + after = inline.parse_model(original, entry['generics']) + equivalent = all(before[k] == after[k] for k in ('shape_text', 'decoder_mode', 'value_binder', 'decoder_text')) + if not equivalent: + raise ValueError('Saved projection disagrees with original annotation') + span = entry['copy_span'] + if span is None: + continue # Existing block/attribute docs remain readable, not writable. + if (not isinstance(span, dict) or set(span) != {'start', 'end', 'prefix', 'newline'} + or type(span['start']) is not int or type(span['end']) is not int + or not 0 <= span['start'] < span['end'] + or not re.fullmatch(r'[ \t]*/// ?', span['prefix']) or span['newline'] not in ('\n', '\r\n')): + raise ValueError('Invalid owned doc payload span') + source = state['sources'][entry['file']].encode('utf-8') + if span['end'] > len(source): + raise ValueError('Owned doc span escapes source snapshot') + text = source[span['start']:span['end']].decode('utf-8') + lines = text.splitlines(keepends=True) + if (not lines or any(not line.startswith(span['prefix']) or not line.endswith(span['newline']) for line in lines) + or ''.join(line[len(span['prefix']):-len(span['newline'])] + '\n' for line in lines) != entry['payload'] + or (span['start'] and source[span['start'] - 1:span['start']] != b'\n') + or not re.fullmatch(rb'[ \t]*/// ```aeneas\r?\n', source[:span['start']].splitlines(keepends=True)[-1]) + or not re.match(rb'[ \t]*/// ```\r?\n', source[span['end']:])): + raise ValueError('Owned doc payload disagrees with original source fence') + spans.setdefault(entry['file'], []).append((span['start'], span['end'])) + if set(values) != expected: + raise ValueError('Copy-back marker roster disagrees with saved ownership') + for file_spans in spans.values(): + ordered = sorted(file_spans) + if any(a[1] > b[0] for a, b in zip(ordered, ordered[1:])): + raise ValueError('Owned annotation payload spans overlap') + return projections, values + except (KeyError, TypeError, AttributeError, UnicodeError, IndexError) as error: + raise ValueError('Malformed projection baseline') from error + + +def checked_file(root, relative): + """Reject escaping and symlinks; remember parent and file identities.""" + path = Path(relative) + if path.is_absolute() or not path.parts or any(p in ('.', '..') for p in path.parts): + raise ValueError('Copy-back path must stay inside the repository') + parents = [] + current = root + for part in (None, *path.parts): + if part is not None: + current = current / part + info = current.lstat() + if stat.S_ISLNK(info.st_mode): + raise ValueError(f'Copy-back refuses symlink traversal: {current}') + parents.append((current, info.st_dev, info.st_ino, info.st_mode)) + if not stat.S_ISREG(info.st_mode): + raise ValueError(f'Copy-back requires a regular file: {current}') + return {'path': current, 'identities': parents, 'bytes': current.read_bytes(), 'mode': stat.S_IMODE(info.st_mode)} + + +def unchanged(record): + """Recheck every traversed identity and the contents before a replacement. + + Equal bytes alone do not authorize replacing a different inode or following + a parent that has become a symlink since the preflight read. + """ + for path, device, inode, mode in record['identities']: + info = path.lstat() + if (info.st_dev, info.st_ino, info.st_mode) != (device, inode, mode): + raise ValueError(f'Copy-back input identity changed: {path}') + if record['path'].read_bytes() != record['bytes']: + raise ValueError(f'Copy-back input changed: {record["path"]}') + + +def replace(record, contents, guard): + """Prepare a durable same-directory file, then guard and replace atomically. + + Preserve permissions and recheck inputs after preparing the temporary file. + Always remove a leftover temporary file if preparation or the guard fails. + """ + path = record['path'] + descriptor, temporary = tempfile.mkstemp(prefix='.aeneas-copy-', dir=path.parent) + try: + with os.fdopen(descriptor, 'wb') as stream: + os.fchmod(stream.fileno(), record['mode']) + stream.write(contents) + stream.flush() + os.fsync(stream.fileno()) + guard() + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def copy_back(root, work, owner, apply=False, after_source_write=None): + """Plan one owner's reverse edit before writing either source or baseline. + + Current Rust must equal the saved snapshot or the exact planned replacement. + The second case lets a retry complete the baseline after an interrupted + source write. Other owners' pending projection edits remain pending, and + all generated framing must still match the saved generation. + """ + root, work = Path(root).absolute(), Path(work).absolute() + try: + relative_work = work.relative_to(root) + except ValueError as error: + raise ValueError('Copy-back project must be inside the repository') from error + if not re.fullmatch(OWNER, owner): + raise ValueError('Copy-back requires the full Rust owner identity') + control = checked_file(root, relative_work / STATE) + try: + state = json.loads(control['bytes']) + except (ValueError, UnicodeError) as error: + raise ValueError('Malformed projection baseline') from error + baseline_projections, baseline_values = validate(state) + if owner not in state['owners']: + raise ValueError('Rust owner is absent from the generation baseline') + entry = state['owners'][owner] + if entry['copy_span'] is None: + raise ValueError('Copy-back supports only contiguous uniformly indented /// fences; other doc forms are read-only') + records, current_values, layouts = [control], {}, {} + for module in MODULES: + record_ = checked_file(root, relative_work / f'{module}.lean') + records.append(record_) + current = record_['bytes'].decode('utf-8') + layout, values = regions(current) + original_layout, original_values = baseline_projections[module] + if layout != original_layout or values.keys() != original_values.keys(): + raise ValueError(f'Edits outside authored regions in {module}.lean are unsupported') + layouts[module] = original_layout + current_values.update(values) + payload = authored(entry, current_values) + source = checked_file(root, entry['file']) + records.append(source) + before = state['sources'][entry['file']].encode('utf-8') + span = entry['copy_span'] + # Keep a physically unchanged fence unchanged, including inline decode + # spelling, when the selected authored regions have no edits. + selected = {key for key in baseline_values if key[0] == owner} + edited = any(current_values[key] != baseline_values[key] for key in selected) + if not edited: + payload = entry['payload'] + replacement = ''.join(span['prefix'] + line + span['newline'] for line in payload[:-1].split('\n')).encode('utf-8') + after = before[:span['start']] + replacement + before[span['end']:] + if source['bytes'] not in (before, after): + raise ValueError('Rust source changed since generation; copy-back has no writes') + diff = ''.join(difflib.unified_diff(before.decode('utf-8').splitlines(keepends=True), + after.decode('utf-8').splitlines(keepends=True), + fromfile=entry['file'], tofile=entry['file'])) + print(diff, end='') + if not diff: + print(f'No Rust payload changes for {owner}.') + if not apply: + return diff + updated = copy.deepcopy(state) + for module, layout in layouts.items(): + values = dict(baseline_projections[module][1]) + values.update({key: current_values[key] for key in selected if key in values}) + updated['projections'][module] = render(layout, values) + updated['sha256'][module] = digest(updated['projections'][module]) + delta = len(replacement) - (span['end'] - span['start']) + updated['sources'][entry['file']] = after.decode('utf-8') + updated['owners'][owner]['payload'] = payload + for other, annotation in updated['owners'].items(): + other_span = annotation['copy_span'] + if annotation['file'] != entry['file'] or other_span is None: + continue + if other == owner: + other_span['end'] += delta + elif other_span['start'] >= span['end']: + other_span['start'] += delta + other_span['end'] += delta + validate(updated) + + # Finish every parsing, ownership, formatting, and conflict check before + # either atomic write. Rust and the baseline cannot be replaced together. + # Accepting exactly the planned after-source lets a retry finish a baseline + # write interrupted after Rust was replaced, without accepting other edits. + + def guard(): + for record_ in records: + unchanged(record_) + + guard() + if source['bytes'] != after: + replace(source, after, guard) + records[-1] = checked_file(root, entry['file']) + if records[-1]['bytes'] != after: + raise ValueError('Rust source changed after replacement; retain projections and baseline for recovery') + if after_source_write is not None: + after_source_write() + encoded = (json.dumps(updated, ensure_ascii=False, indent=2) + '\n').encode('utf-8') + if encoded != control['bytes']: + replace(control, encoded, guard) + return diff diff --git a/verification/aeneas/dev.sh b/verification/aeneas/dev.sh new file mode 100644 index 0000000000..786f5c2317 --- /dev/null +++ b/verification/aeneas/dev.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# Prepare the tracked Lean sources for native editor and incremental Lake use. +set -euo pipefail +check=false +live=false +copy_owner="" +dry_run=false +while (($#)); do + case "$1" in + --check) check=true ;; + --live) live=true ;; + --dry-run) dry_run=true ;; + --copy-back) + if [[ $# -lt 2 || -n "$copy_owner" || "$2" == --* ]]; then + echo "--copy-back requires one full Rust owner" >&2; exit 1 + fi + copy_owner=$2 + shift ;; + *) echo "Usage: $0 [--check] [--live] | --copy-back OWNER [--dry-run]" >&2; exit 1 ;; + esac + shift +done +if [[ -n "$copy_owner" ]]; then + if "$check" || "$live"; then + echo "--copy-back cannot be combined with --check or --live" >&2; exit 1 + fi +elif "$dry_run"; then + echo "--dry-run requires --copy-back" >&2; exit 1 +fi +cd "$(dirname "$0")/../.." +repo=$PWD +if [[ -n "$copy_owner" ]]; then + copy_args=(copy-back "$repo/verification/aeneas/lean" --root "$repo" --owner "$copy_owner") + if ! "$dry_run"; then copy_args+=(--apply); fi + python3 -B verification/aeneas/workspace.py "${copy_args[@]}" + exit +fi +source verification/aeneas/toolchain.sh +tools_dir=${AENEAS_TOOLCHAIN_DIR:-"$repo/target/aeneas/toolchain"} +backend="$tools_dir/backends/lean" +[[ $(cat "$backend/lean-toolchain") == "$AENEAS_LEAN_TOOLCHAIN" ]] +export PATH="$tools_dir/lean/bin:$PATH" +work="$repo/verification/aeneas/lean" +python3 -B verification/aeneas/workspace.py protect "$work" --root "$repo" +if "$live"; then + # Refresh the model even when changed Rust invalidates an existing proof. + bash verification/aeneas/run.sh --extract-only + model="$repo/target/aeneas/verification/Zerocopy" +else + model="$repo/verification/aeneas/golden" +fi +CARGO_TARGET_DIR="$repo/target/aeneas/annotation-tool" \ + cargo +"$AENEAS_RUST_TOOLCHAIN" build --locked --jobs 2 \ + --manifest-path tools/Cargo.toml -p aeneas-inline +# The golden checker owns the generated-file roster. Validate the complete +# producer output before copying those same modules into the editor project; +# handwritten external implementations stay with the authored proof sources. +python3 -B - "$model" "$work/Zerocopy" <<'PY' +from pathlib import Path +import shutil, sys +sys.path.insert(0, 'verification/aeneas') +import golden +source, destination = map(Path, sys.argv[1:]) +generated = golden.files(source, live=True) +destination.mkdir(parents=True, exist_ok=True) +for name in generated: + shutil.copyfile(source / name, destination / name) +PY +cp "$backend/lean-toolchain" "$work/lean-toolchain" +python3 -B verification/aeneas/inline.py dev-assemble --root "$repo" \ + --tool "$repo/target/aeneas/annotation-tool/debug/aeneas-inline" --work "$work" +python3 -B verification/aeneas/prepare.py workspace "$work" --backend "$backend" +echo "Lean development project: $work" +echo "Model: $model; specifications refreshed from current Rust comments." +echo "Development checks do not replace the fresh golden/live CI verification." +if "$check"; then + python3 -B verification/aeneas/workspace.py check "$work" --root "$repo" +fi diff --git a/verification/aeneas/golden.py b/verification/aeneas/golden.py new file mode 100644 index 0000000000..4ab15c4825 --- /dev/null +++ b/verification/aeneas/golden.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Compare complete Aeneas outputs while ignoring only presentation changes. + +The file roster includes external primitive templates because their signatures +belong to the translation boundary. Live projects may also contain handwritten +external implementations; those are proof inputs, not generated snapshots. +This comparison checks text. run.sh separately compiles proofs against each +definition set rather than treating normalized text equality as a proof. +""" + +import argparse +import difflib +import re +from pathlib import Path + +FILES = ("Types.lean", "Funs.lean", "TypesExternal_Template.lean", + "FunsExternal_Template.lean") +HANDWRITTEN = {"TypesExternal.lean", "FunsExternal.lean"} +CHAR = re.compile(r"'(?:\\(?:u\{[0-9a-fA-F]+\}|.)|[^'\\\r\n])'") +HEADER = """/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +""" + + +def normalize(source): + """Remove only comments, empty code lines, and trailing nonliteral spaces. + + Literal contents and indentation survive because either can change meaning. + Unknown literal forms fail explicitly: an extractor upgrade must receive a + reviewed lexer change before its output can pass this comparison. + """ + # Preserve code, indentation, line boundaries, and literal bytes. Comments + # become a space (not concatenation), retaining their newlines. Only empty + # nonliteral lines and nonliteral trailing horizontal spaces are dropped. + lines = [] + line = [] + i = 0 + + def emit(char, literal=False): + if char == "\n": + finish() + else: + line.append((char, literal)) + + def finish(): + while line and line[-1][0] in " \t\r" and not line[-1][1]: + line.pop() + if line: + lines.append("".join(char for char, _ in line)) + line.clear() + + while i < len(source): + if source.startswith("--", i): + end = source.find("\n", i) + end = len(source) if end == -1 else end + emit(" ") + i = end + elif source.startswith("/-", i): + depth = 1 + emit(" ") + i += 2 + while depth: + if i == len(source): + raise ValueError("Unterminated Lean block comment") + if source.startswith("/-", i): + depth += 1 + i += 2 + elif source.startswith("-/", i): + depth -= 1 + i += 2 + else: + if source[i] == "\n": + emit("\n") + i += 1 + elif source[i] == '"' or source[i] == "«": + end_char = '"' if source[i] == '"' else "»" + # Raw and interpolated strings need their own lexer. This pinned + # generator emits neither; fail closed if an upgrade adds them. + if end_char == '"' and (re.search(r"r#*$", source[:i]) or + source[:i].endswith('!')): + raise ValueError("Unsupported raw or interpolated Lean string") + emit(source[i], True) + i += 1 + while True: + if i == len(source): + raise ValueError("Unterminated Lean literal") + char = source[i] + # Preserve empty lines INSIDE literals, unlike empty code lines. + if char == "\n" and not line: + line.append(("", True)) + emit(char, True) + i += 1 + if char == end_char: + break + if char == "\\" and end_char == '"': + if i == len(source): + raise ValueError("Unterminated Lean escape") + emit(source[i], True) + i += 1 + elif source[i] == "'" and (match := CHAR.match(source, i)): + for char in match[0]: + emit(char, True) + i = match.end() + else: + emit(source[i]) + i += 1 + finish() + return lines + + +def files(directory, live=False): + """Read exactly the generated modules, allowing authored externals in live. + + Reject missing or additional files before returning any module. Callers use + this same roster for comparison, golden updates, and editor projections. + """ + actual = {str(p.relative_to(directory)) for p in directory.rglob("*") if p.is_file()} + if live: + actual -= HANDWRITTEN + if actual != set(FILES): + raise ValueError(f"Unexpected generated file set in {directory}: " + f"missing={sorted(set(FILES) - actual)}, " + f"extra={sorted(actual - set(FILES))}") + return {name: (directory / name).read_bytes().decode("utf-8") for name in FILES} + + +def compare(live, golden): + """Report every normalized module difference, including template signatures.""" + generated = files(live, live=True) + checked_in = files(golden) + differences = [] + for name in FILES: + before, after = normalize(checked_in[name]), normalize(generated[name]) + if before != after: + differences.extend(difflib.unified_diff( + before, after, fromfile=f"golden/{name}", + tofile=f"live/{name}", lineterm="")) + return differences + + +def update(live, golden): + """Write a validated extraction; run.sh checks its live proofs before calling.""" + generated = files(live, live=True) + # Validate the complete candidate before changing any checked-in file. + for source in generated.values(): + normalize(source) + if golden.exists(): + actual = {str(p.relative_to(golden)) for p in golden.rglob("*") if p.is_file()} + if actual - set(FILES): + raise ValueError(f"Unexpected checked-in generated files: {sorted(actual - set(FILES))}") + golden.mkdir(parents=True, exist_ok=True) + for name in FILES: + (golden / name).write_text(HEADER + generated[name], encoding="utf-8") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["compare", "update"]) + parser.add_argument("live", type=Path) + parser.add_argument("golden", type=Path) + args = parser.parse_args() + if args.command == "update": + update(args.live, args.golden) + else: + differences = compare(args.live, args.golden) + if differences: + print("\n".join(differences)) + raise SystemExit("Aeneas goldens differ. Review the extraction and run " + "bash verification/aeneas/run.sh --update-goldens") + print("Live Aeneas output matches goldens under the fuzzy comparison") + + +if __name__ == "__main__": + main() diff --git a/verification/aeneas/golden/Funs.lean b/verification/aeneas/golden/Funs.lean new file mode 100644 index 0000000000..66e3008882 --- /dev/null +++ b/verification/aeneas/golden/Funs.lean @@ -0,0 +1,140 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +-- THIS FILE WAS AUTOMATICALLY GENERATED BY AENEAS +-- [zerocopy]: function definitions +module +public import Aeneas +public import Zerocopy.Types +public import Zerocopy.FunsExternal +@[expose] public section +open Aeneas Aeneas.Std Result ControlFlow Error +set_option linter.dupNamespace false +set_option linter.hashCommand false +set_option linter.unusedVariables false +set_option linter.style.whitespace false +set_option linter.style.setOption false +set_option linter.style.longLine false + +/- You can set the `maxHeartbeats` value with the `-max-heartbeats` CLI option -/ +set_option maxHeartbeats 1000000 + +/- You can set the `maxRecDepth` value with the `-max-recdepth` CLI option -/ +set_option maxRecDepth 2048 + +/- You can remove the following line by using the CLI option `-all-computable`: -/ +noncomputable section + +namespace Zerocopy + +/-- Trait implementation: [core::num::niche_types::{impl core::clone::Clone for core::num::niche_types::NonZeroUsizeInner}] + Source: '/rustc/library/core/src/num/niche_types.rs', lines 17:17-17:22 + Name pattern: [core::clone::Clone] -/ +@[reducible, rust_trait_impl + "core::clone::Clone"] +def core.num.niche_types.NonZeroUsizeInner.Insts.CoreCloneClone : + core.clone.Clone core.num.niche_types.NonZeroUsizeInner := { + clone := core.num.niche_types.NonZeroUsizeInner.Insts.CoreCloneClone.clone +} + +/-- Trait implementation: [core::num::niche_types::{impl core::marker::Copy for core::num::niche_types::NonZeroUsizeInner}] + Source: '/rustc/library/core/src/num/niche_types.rs', lines 17:24-17:28 + Name pattern: [core::marker::Copy] -/ +@[reducible, rust_trait_impl + "core::marker::Copy"] +def core.num.niche_types.NonZeroUsizeInner.Insts.CoreMarkerCopy : + core.marker.Copy core.num.niche_types.NonZeroUsizeInner := { + cloneInst := core.num.niche_types.NonZeroUsizeInner.Insts.CoreCloneClone +} + +/-- Trait implementation: [core::num::nonzero::{impl core::num::nonzero::ZeroablePrimitive for usize}] + Source: '/rustc/library/core/src/num/nonzero.rs', lines 47:12-47:56 + Name pattern: [core::num::nonzero::ZeroablePrimitive] -/ +@[reducible, rust_trait_impl + "core::num::nonzero::ZeroablePrimitive"] +def Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner : + core.num.nonzero.ZeroablePrimitive Std.Usize + core.num.niche_types.NonZeroUsizeInner := { + markerCopyInst := core.marker.CopyUsize + innerCopyInst := core.num.niche_types.NonZeroUsizeInner.Insts.CoreMarkerCopy +} + +/-- [zerocopy::util::padding_needed_for]: + Source: 'src/util/mod.rs', lines 160:0-220:1 -/ +def util.padding_needed_for + (len : Std.Usize) + (align : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) : + Result Std.Usize + := do + let i ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner align + let mask ← i - 1#usize + let i1 ← lift (core.num.Usize.wrapping_sub len 1#usize) + let i2 ← lift (~~~ i1) + ok (i2 &&& mask) + +/-- [zerocopy::util::round_down_to_next_multiple_of_alignment]: + Source: 'src/util/mod.rs', lines 247:0-265:1 -/ +def util.round_down_to_next_multiple_of_alignment + (n : Std.Usize) + (align : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) : + Result Std.Usize + := do + let align1 ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner align + let b ← core.num.Usize.is_power_of_two align1 + massert b + let i ← align1 - 1#usize + let mask ← lift (~~~ i) + ok (n &&& mask) + +/-- [zerocopy::util::max]: + Source: 'src/util/mod.rs', lines 274:0-280:1 -/ +def util.max + (a : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) + (b : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) : + Result (core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) + := do + let i ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner a + let i1 ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner b + if i < i1 + then ok b + else ok a + +/-- [zerocopy::util::min]: + Source: 'src/util/mod.rs', lines 289:0-295:1 -/ +def util.min + (a : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) + (b : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) : + Result (core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) + := do + let i ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner a + let i1 ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner b + if i > i1 + then ok b + else ok a + +end Zerocopy diff --git a/verification/aeneas/golden/FunsExternal_Template.lean b/verification/aeneas/golden/FunsExternal_Template.lean new file mode 100644 index 0000000000..ccb87a43d6 --- /dev/null +++ b/verification/aeneas/golden/FunsExternal_Template.lean @@ -0,0 +1,52 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +-- THIS FILE WAS AUTOMATICALLY GENERATED BY AENEAS +-- [zerocopy]: external functions. +-- This is a template file: rename it to "FunsExternal.lean" and fill the holes. +module +public import Aeneas +public import Zerocopy.Types +@[expose] public section +open Aeneas Aeneas.Std Result ControlFlow Error +set_option linter.dupNamespace false +set_option linter.hashCommand false +set_option linter.unusedVariables false +set_option linter.style.whitespace false +set_option linter.style.setOption false +set_option linter.style.longLine false + +/- You can set the `maxHeartbeats` value with the `-max-heartbeats` CLI option -/ +set_option maxHeartbeats 1000000 + +/- You can set the `maxRecDepth` value with the `-max-recdepth` CLI option -/ +set_option maxRecDepth 2048 +open Zerocopy + +/-- [core::num::niche_types::{impl core::clone::Clone for core::num::niche_types::NonZeroUsizeInner}::clone]: + Source: '/rustc/library/core/src/num/niche_types.rs', lines 17:17-17:22 + Name pattern: [core::num::niche_types::{core::clone::Clone}::clone] + Visibility: public -/ +@[rust_fun + "core::num::niche_types::{core::clone::Clone}::clone"] +axiom core.num.niche_types.NonZeroUsizeInner.Insts.CoreCloneClone.clone + : + core.num.niche_types.NonZeroUsizeInner → Result + core.num.niche_types.NonZeroUsizeInner + +/-- [core::num::nonzero::{core::num::nonzero::NonZero}::get]: + Source: '/rustc/library/core/src/num/nonzero.rs', lines 467:4-467:31 + Name pattern: [core::num::nonzero::{core::num::nonzero::NonZero<@T, @Clause0_NonZeroInner>}::get] + Visibility: public -/ +@[rust_fun + "core::num::nonzero::{core::num::nonzero::NonZero<@T, @Clause0_NonZeroInner>}::get"] +axiom core.num.nonzero.NonZero.get + {T : Type} {Clause0_NonZeroInner : Type} (ZeroablePrimitiveInst : + core.num.nonzero.ZeroablePrimitive T Clause0_NonZeroInner) : + core.num.nonzero.NonZero T Clause0_NonZeroInner → Result T + diff --git a/verification/aeneas/golden/Types.lean b/verification/aeneas/golden/Types.lean new file mode 100644 index 0000000000..687cf0b1f8 --- /dev/null +++ b/verification/aeneas/golden/Types.lean @@ -0,0 +1,42 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +-- THIS FILE WAS AUTOMATICALLY GENERATED BY AENEAS +-- [zerocopy]: type definitions +module +public import Aeneas +public import Zerocopy.TypesExternal +@[expose] public section +open Aeneas Aeneas.Std Result ControlFlow Error +set_option linter.dupNamespace false +set_option linter.hashCommand false +set_option linter.unusedVariables false +set_option linter.style.whitespace false +set_option linter.style.setOption false +set_option linter.style.longLine false + +/- You can set the `maxHeartbeats` value with the `-max-heartbeats` CLI option -/ +set_option maxHeartbeats 1000000 + +/- You can set the `maxRecDepth` value with the `-max-recdepth` CLI option -/ +set_option maxRecDepth 2048 + +namespace Zerocopy + +/-- Trait declaration: [core::num::nonzero::ZeroablePrimitive] + Source: '/rustc/library/core/src/num/nonzero.rs', lines 34:0-34:59 + Name pattern: [core::num::nonzero::ZeroablePrimitive] + Visibility: public -/ +@[rust_trait "core::num::nonzero::ZeroablePrimitive" + (parentClauses := ["markerCopyInst", "innerCopyInst"])] +structure core.num.nonzero.ZeroablePrimitive (Self : Type) (Self_NonZeroInner : + Type) where + markerCopyInst : core.marker.Copy Self + innerCopyInst : core.marker.Copy Self_NonZeroInner + +end Zerocopy diff --git a/verification/aeneas/golden/TypesExternal_Template.lean b/verification/aeneas/golden/TypesExternal_Template.lean new file mode 100644 index 0000000000..eb785fcd09 --- /dev/null +++ b/verification/aeneas/golden/TypesExternal_Template.lean @@ -0,0 +1,42 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +-- THIS FILE WAS AUTOMATICALLY GENERATED BY AENEAS +-- [zerocopy]: external types. +-- This is a template file: rename it to "TypesExternal.lean" and fill the holes. +module +public import Aeneas +@[expose] public section +open Aeneas Aeneas.Std Result ControlFlow Error +set_option linter.dupNamespace false +set_option linter.hashCommand false +set_option linter.unusedVariables false +set_option linter.style.whitespace false +set_option linter.style.setOption false +set_option linter.style.longLine false + +/- You can set the `maxHeartbeats` value with the `-max-heartbeats` CLI option -/ +set_option maxHeartbeats 1000000 + +/- You can set the `maxRecDepth` value with the `-max-recdepth` CLI option -/ +set_option maxRecDepth 2048 + +/-- [core::num::niche_types::NonZeroUsizeInner] + Source: '/rustc/library/core/src/num/niche_types.rs', lines 20:8-20:55 + Name pattern: [core::num::niche_types::NonZeroUsizeInner] + Visibility: public -/ +@[rust_type "core::num::niche_types::NonZeroUsizeInner"] +axiom core.num.niche_types.NonZeroUsizeInner : Type + +/-- [core::num::nonzero::NonZero] + Source: '/rustc/library/core/src/num/nonzero.rs', lines 112:0-112:40 + Name pattern: [core::num::nonzero::NonZero] + Visibility: public -/ +@[rust_type "core::num::nonzero::NonZero"] +axiom core.num.nonzero.NonZero (T : Type) (Clause0_NonZeroInner : Type) : Type + diff --git a/verification/aeneas/inline.py b/verification/aeneas/inline.py new file mode 100644 index 0000000000..09a451f5f8 --- /dev/null +++ b/verification/aeneas/inline.py @@ -0,0 +1,1322 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Bind inline contracts to inspected Rust and assemble their Lean projections. + +Rust syntax inspection establishes ownership; the independent comment audit +rejects reserved fences that ownership cannot explain. Extraction then binds +those owners to exact source snapshots, parameter order, and generated names. +Assembly consumes that checked binding rather than trusting generated doc names +alone. Development projections use the same authored bytes, with explicit edit +regions whose saved snapshots authorize owner-scoped copy-back. +""" + +import argparse +import difflib +import hashlib +import json +import os +import re +import subprocess +from pathlib import Path + +import golden +import workspace + +MARKER = re.compile(r'`{2,}\s*([^\s`]+)') +IDENTIFIER = r'[A-Za-z_][A-Za-z_0-9]*' + + +class Annotations(dict): + """Keep authored declarations alongside independently inspected source data. + + The dictionary contains annotations. `types` also retains unannotated nominal + owners; extraction selects exact supported owners into `extracted_types`. + `source_snapshots` fixes all inspected Rust, so a later source edit cannot + inherit an earlier discovery result. + """ + + def __init__(self): + super().__init__() + self.types = [] + self.macros = [] + self.extracted_types = None + self.type_classes = None + self.external_type_parameters = None + self.source_snapshots = {} + + +class GeneratedLines(list): + """Keep generated lines and their optional Rust locations in one append. + + Maps use one-based Lean line numbers and may omit purely generated lines. + Recording a location before appending its line prevents framing commands or + development markers from shifting the mapping away from the intended line. + The list still preserves the exact ordering and blank lines of assembly. + """ + + def __init__(self, lines): + super().__init__(lines) + self.locations = {} + + def append(self, line, location=None): + if location is not None: + self.locations[str(len(self) + 1)] = location + super().append(line) + + +def suspicious(text): + """Flag reserved and near-miss fence labels for later ownership checks. + + This is discovery, not admission: only the exact aeneas fence is accepted. + A lookalike inside a Rust literal is excluded by syntax and lexer inspection. + """ + return any(word.lower().startswith('aeneas') or + difflib.SequenceMatcher(None, word.lower(), 'aeneas').ratio() >= 0.7 + for word in MARKER.findall(text)) + + +def read_source(path): + """Decode source bytes without translating CRLF line endings. + + Physical spelling matters to saved snapshots and reverse-edit spans. + """ + return path.read_bytes().decode('utf-8') + + +def rust_identity(file, syntax): + """Combine the source module path with the syntax owner path. + + lib.rs and mod.rs name their containing module; other filenames add a + segment. Extraction must later confirm this proposed identity. + """ + relative = Path(file).relative_to('zerocopy/src') + modules = list(relative.parent.parts) + if relative.stem not in ('mod', 'lib'): + modules.append(relative.stem) + return '::'.join(['zerocopy', *modules, syntax]) + + +def valid_file(file): + """Restrict model owners to relative Rust paths under zerocopy/src.""" + return (isinstance(file, str) and file.startswith('zerocopy/src/') + and '..' not in Path(file).parts and file.endswith('.rs')) + + +def source_files(root): + """Inspect all crate Rust sources and reserved-fence candidates elsewhere. + + Unannotated dependencies and inactive cfg alternatives still contribute to + the exact source snapshot. Disposable build and dependency trees are skipped. + """ + paths = set() + for directory, dirs, filenames in os.walk(root): + dirs[:] = [d for d in dirs if d not in {'.git', 'target', 'vendor', '.lake'}] + for name in filenames: + if name.endswith('.rs'): + path = Path(directory) / name + relative = str(path.relative_to(root)) + if (relative.startswith('zerocopy/src/') + or suspicious(read_source(path))): + paths.add(relative) + return sorted(paths) + + +def binder_header(text, position): + """Read our deliberately small named-binder subset; Lean checks the types.""" + names = [] + pairs = {'(': ')', '{': '}', '[': ']'} + while True: + while position < len(text) and text[position].isspace(): + position += 1 + if position == len(text) or text[position] not in pairs: + return names, position + start = position + stack = [pairs[text[position]]] + position += 1 + literal = None + while stack: + if position == len(text): + raise ValueError('Unterminated specification binder') + char = text[position] + if literal: + if char == '\\' and literal == '"': + position += 2 + continue + if char == literal: + literal = None + elif char in ('"', '«'): + literal = '"' if char == '"' else '»' + elif char in pairs: + stack.append(pairs[char]) + elif char in pairs.values(): + if char != stack.pop(): + raise ValueError('Mismatched specification binder') + position += 1 + declaration = text[start + 1:position - 1] + before_type, separator, _ = declaration.partition(':') + if not separator or not re.fullmatch(IDENTIFIER + r'(?:\s+' + IDENTIFIER + ')*', before_type.strip()): + raise ValueError('Specification binders must have explicit names and types') + names.extend(before_type.split()) + + +def code_tokens(text): + """Mask Lean literal contents after the shared lexer validates them.""" + chars = list(text) + index = 0 + while index < len(chars): + char = text[index] + literal = golden.CHAR.match(text, index) if char == "'" else None + if literal: + end = literal.end() + elif char in ('"', '«'): + close = '"' if char == '"' else '»' + end = index + 1 + while end < len(chars): + if text[end] == '\\' and close == '"': + end += 2 + elif text[end] == close: + end += 1 + break + else: + end += 1 + else: + index += 1 + continue + for offset in range(index, end): + if chars[offset] != '\n': + chars[offset] = ' ' + index = end + return ''.join(chars) + + +def checked_payload(text): + """Validate comments/literals and keep generated command framing private.""" + normalized = '\n'.join(golden.normalize(text)) + if re.search(r'\baeneas_(?:spec|model|invariant)_(?:begin|end)\b', code_tokens(normalized)): + raise ValueError('Annotation parser framing markers are reserved for generated Lean') + return normalized + + +COMMAND = (r'(?:for|proof:|model:|contract|theorem|def|abbrev|axiom|opaque|' + r'instance|namespace|section|end|import|open|export|attribute|' + r'set_option|syntax|macro|elab|run_elab|initialize|deriving|' + r'spec|partial|refines|invariant|model|decode\??|example|constant|inductive|structure|class|mutual|noncomputable|private|protected|public|meta|unsafe|notation|local|scoped|aeneas_\w+|derive_\w+|check_\w+|register_\w+)') + + +def reject_commands(text): + """Reject declarations that could escape the authored expression scope. + + Literal contents have been masked, so quoted Lean text does not accidentally + become a command. The accepted annotation subset remains deliberately small. + """ + if re.search(r'^\s*(?:' + COMMAND + r'(?=\s|:|$)|#)', code_tokens(text), re.M): + raise ValueError('Aeneas fence contains an unsupported application or declaration') + + +def parse_spec(text, generics, inputs): + """Validate one contract header and its ordered requirements and posts. + + Original Rust parameters are inferred, not authored binders. Ghosts and + named requirements must not shadow them. Lean checks expression types later. + """ + normalized = checked_payload(text) + match = re.match(r'^(?:partial )?spec (' + IDENTIFIER + r')(?=\s|\(|\{|$)', normalized) + if not match: + raise ValueError('Aeneas fence requires exactly one spec declaration') + names, body = binder_header(normalized, match.end()) + if len(set(names)) != len(names): + raise ValueError('Duplicate specification binder') + expected = generics + inputs + if set(names) & set(expected): + raise ValueError('Ghost binders must not shadow original Rust parameters') + if any(line and not line.startswith(' ') for line in text.splitlines()[1:]): + raise ValueError('Specification continuations must be indented by two spaces') + clauses = normalized[body:] + reject_commands(clauses) + if re.search(r'(?m)^\s*(?:requires|ensures)(?!\(raw\)|\s)', clauses): + raise ValueError('Unsupported clause mode') + headers = list(re.finditer(r'(?m)^ (requires|ensures)(\(raw\))?\s+', clauses)) + # binder_header skips leading whitespace; restore the first clause's line. + if re.match(r'(?:requires|ensures)(?:\(raw\))?\s+', clauses): + clauses = ' ' + clauses + headers = list(re.finditer(r'(?m)^ (requires|ensures)(\(raw\))?\s+', clauses)) + if not headers or clauses[:headers[0].start()].strip(): + raise ValueError('Specification requires a postcondition; clauses must start on their own lines') + requirements, seen_post = [], False + for index, header in enumerate(headers): + end = headers[index + 1].start() if index + 1 < len(headers) else len(clauses) + clause = clauses[header.end():end].strip() + if header[1] == 'requires': + if seen_post: + raise ValueError('Requirements must precede postconditions') + requirement = re.fullmatch(r'(' + IDENTIFIER + r')\s*:\s*(.+)', clause, re.S) + if not requirement: + raise ValueError('Requirements must have an explicit name and proposition') + requirements.append(requirement[1]) + else: + seen_post = True + if not re.fullmatch(r'.+?\s*=>\s*.+', clause, re.S): + raise ValueError('Postconditions require a result pattern and proposition') + if not seen_post: + raise ValueError('Specification requires at least one postcondition') + if len(set(requirements)) != len(requirements) or set(requirements) & set(names + expected): + raise ValueError('Requirement names must be unique and must not shadow specification binders') + _, original_body = binder_header(text, re.match(r'^(?:partial )?spec\s+' + IDENTIFIER, text).end()) + clause_line = text[:original_body].count('\n') + if clause_line == 0 or text[:original_body].rsplit('\n', 1)[1].strip(): + raise ValueError('Specification clauses must begin on their own indented lines') + return match[1], clause_line + + +def parse_model(text, generics): + """Separate one mathematical shape from its local decoder expression. + + Shapes use the original ordered type parameters. Both authored bodies are + expressions within generated commands, so additional declarations are rejected. + """ + checked_payload(text) + shape = re.match(r'^model (' + IDENTIFIER + r')\s*(where|:=)(?=\s|$)', text) + if not shape or shape[1] in [*generics, 'Fields']: + raise ValueError('Aeneas type fence requires one model name where fields or model name := type declaration') + decoders = list(re.finditer(r'^decode(\?)? (' + IDENTIFIER + r')\s*=>[ \t]*', text, re.M)) + if len(decoders) != 1: + raise ValueError('Aeneas model requires exactly one decode or decode? clause') + decoder = decoders[0] + if decoder[2] in generics: + raise ValueError('Decoder value must not shadow a Rust type parameter') + shape_text = text[:decoder.start()].rstrip('\n') + shape_body = text[shape.end():decoder.start()] + decoder_start = decoder.end() + (text[decoder.end():].startswith('\n')) + decoder_text = '\n'.join(line[2:] if line.startswith(' ') else line + for line in text[decoder_start:].rstrip('\n').splitlines()) + if not shape_body.strip() or not decoder_text.strip(): + raise ValueError('Model shapes and decoders must have nonempty bodies') + reject_commands(checked_payload(shape_body)) + reject_commands(checked_payload(decoder_text)) + for index, line in enumerate(text.splitlines()): + if index == 0 or index == text[:decoder.start()].count('\n'): + continue + if line and not line.startswith(' '): + raise ValueError('Model continuations must be indented by two spaces') + return {'model_name': shape[1], 'shape_text': shape_text, + 'decoder_mode': 'decode?' if decoder[1] else 'decode', + 'value_binder': decoder[2], 'decoder_text': decoder_text, + 'decoder_line': text[:decoder_start].count('\n'), + 'decoder_clause_line': text[:decoder.start()].count('\n'), + 'model_text': text} + + +def parse_file(source, syntax): + """Read literal owner docs, then account for every reserved source fence. + + Diagnostic lines describe decoded docs, but copy-back needs physical bytes. + Only contiguous, uniformly prefixed /// payloads receive writable spans. + Other accepted literal doc forms remain readable annotations. + """ + if syntax.get('source') != source: + raise ValueError('Rust source changed during annotation inspection; retry from a stable source snapshot') + + def char_offset(byte): + return len(source.encode('utf-8')[:byte].decode('utf-8')) + + found, owned = [], set() + owners = [*syntax['functions'], *syntax.get('types', [])] + for owner in owners: + if owner.get('include_str_docs'): + raise ValueError('Direct include_str! in owner doc expressions is unsupported; use literal outer docs') + docs = owner.get('docs', []) + if owner.get('computed_docs') and any(suspicious(doc['text']) for doc in docs): + raise ValueError('Computed doc strings on function or nominal type owners are unsupported; use literal outer docs') + content, lines = [], [] + for doc in docs: + owned.add((doc['start'], doc['end'])) + # /// desugars to a string beginning with one conventional space. + # Literal #[doc = "..."] uses the same decoded Rust string semantics. + decoded = doc['text'].splitlines() or [''] + raw_doc = source[char_offset(doc['start']):char_offset(doc['end'])] + block_stars = raw_doc.startswith('/**') and all( + not line.strip() or re.match(r'^\s*\*(?: |$)', line) for line in decoded) + for index, line in enumerate(decoded): + if block_stars: + line = re.sub(r'^\s*\*(?: |$)', '', line) + elif (raw_doc.startswith('///') or (raw_doc.startswith('/**') and index == 0)) and line.startswith(' '): + line = line[1:] + content.append(line) + lines.append(doc['line'] + index if source[char_offset(doc['start']):].startswith(('///', '/**')) else doc['line']) + index, count = 0, 0 + while index < len(content): + line = content[index] + if not suspicious(line): + index += 1 + continue + if line.rstrip() != '```aeneas': + raise ValueError('Malformed, unsupported, or misspelled aeneas fence') + if not owner['supported']: + raise ValueError('Aeneas fence has an unsupported Rust owner (including borrowed/backward forms)') + count += 1 + if count != 1: + raise ValueError('Duplicate Aeneas declarations on one Rust owner') + start = index + 1 + index = start + while index < len(content) and content[index].rstrip() != '```': + if suspicious(content[index]) or content[index].startswith('```'): + raise ValueError('Malformed or nested aeneas fence') + index += 1 + if index == len(content): + raise ValueError('Unterminated aeneas fence') + payload = '\n'.join(content[start:index]) + '\n' + kind = 'function' if owner.get('kind', 'function') == 'function' else 'type' + if kind == 'function': + name, clause_line = parse_spec(payload, owner['generics'], owner['inputs']) + extra = {'spec_text': payload, 'clause_line': clause_line, + 'ghosts': binder_header(payload, re.match(r'^(?:partial )?spec\s+' + IDENTIFIER, payload).end())[0]} + else: + extra = parse_model(payload, owner['generics']) + name = extra['model_name'] + extra.update(nominal_kind=owner['kind'], fields=owner['fields']) + close = char_offset(owner['close']) + declaration = char_offset(owner['start']) + # Reverse edits use owned physical doc spans, never diagnostic maps. + physical = source.split('\n') + physical = [line + '\n' for line in physical[:-1]] + ([physical[-1]] if physical[-1] else []) + selected = physical[lines[start] - 1:lines[index - 1]] + prefixes = [re.match(r'^([ \t]*/// ?)', line) for line in selected] + line_docs = {doc['line'] for doc in docs + if source[char_offset(doc['start']):char_offset(doc['end'])].startswith('///')} + copy_span = None + if (selected and len(selected) == index - start and all(prefixes) + and all(number in line_docs for number in lines[start:index]) + and len({m[1] for m in prefixes}) == 1 + and len({'\r\n' if line.endswith('\r\n') else '\n' for line in selected}) == 1): + begin = len(''.join(physical[:lines[start] - 1]).encode('utf-8')) + finish = begin + len(''.join(selected).encode('utf-8')) + copy_span = {'start': begin, 'end': finish, 'prefix': prefixes[0][1], + 'newline': '\r\n' if selected[0].endswith('\r\n') else '\n'} + found.append({'syntax': owner['path'], 'kind': kind, 'theorem': name, + 'copy_span': copy_span, + 'generics': owner['generics'], 'inputs': owner['inputs'], + 'source_lines': lines[start:index], + 'newline': '\r\n' if '\r\n' in source else '\n', + 'open_line': source[:declaration].count('\n') + 1, + 'end_line': source[:close].count('\n') + 1, + 'end_col': close - source.rfind('\n', 0, close) - 1, + **extra}) + index += 1 + # The AST's ownership relation is authoritative. Audit the lexer and every + # doc spelling so an unsupported/orphaned reserved fence cannot disappear. + for attr in syntax.get('attributes', []): + span = (attr['start'], attr['end']) + raw = source[char_offset(span[0]):char_offset(span[1])] + if (suspicious(raw) or suspicious(''.join(attr.get('fragments', [])))) and span not in owned: + raise ValueError('Reserved aeneas fence in unsupported, conditional, computed, inner, or orphaned doc attribute') + for comment in syntax['comments']: + span = (comment['start'], comment['end']) + raw = source[char_offset(span[0]):char_offset(span[1])] + if suspicious(raw) and not any(a <= span[0] and span[1] <= b for a, b in owned): + raise ValueError('Reserved aeneas fence must belong to an outer Rust doc attribute on a supported function or nominal type') + return found + + +def write_scan(annotations, work): + """Select exactly the Rust owners of the present annotations as roots. + + CI checks every present specification so an owned fence cannot escape + extraction, canonical proof checking, or either model's compiled audit. + """ + (work / 'annotations.json').write_text(json.dumps(annotations, indent=2) + '\n') + (work / 'source-types.json').write_text(json.dumps(getattr(annotations, 'types', []), indent=2) + '\n') + (work / 'roots.txt').write_text(','.join(annotations) + '\n') + + +def discover(root, tool): + """Capture one inspected snapshot, including unannotated dependency sources. + + Keep every nominal owner, not just annotated types. Generated structural + models need the same source ownership checks as authored models. Multiple + cfg alternatives can share a name, so extraction must later select the + candidate whose source span actually matches. + """ + paths = source_files(root) + result = subprocess.run([str(tool)], cwd=root, input=json.dumps(paths), text=True, capture_output=True) + if result.returncode: + raise ValueError(f'Rust syntax inspection failed: {result.stderr}') + syntax = json.loads(result.stdout) + annotations = Annotations() + for file in paths: + source = read_source(root / file) + try: + found = parse_file(source, syntax[file]) + except ValueError as error: + raise ValueError(f'{file}: {error}') from error + encoded = source.encode('utf-8') + annotations.source_snapshots[file] = encoded + if valid_file(file): + for macro in syntax[file].get('macros', []): + start = len(encoded[:macro['start']].decode('utf-8')) + end = len(encoded[:macro['end']].decode('utf-8')) + annotations.macros.append({ + 'file': file, + 'modules': macro['modules'], + 'start': len(source[:start].replace('\r\n', '\n')), + 'end': len(source[:end].replace('\r\n', '\n')), + }) + for owner in syntax[file].get('types', []): + start = len(encoded[:owner['start']].decode('utf-8')) + close = len(encoded[:owner['close']].decode('utf-8')) + annotations.types.append({ + 'rust': rust_identity(file, owner['path']), 'file': file, + 'syntax': owner['path'], 'kind': 'type', 'nominal_kind': owner['kind'], + 'generics': owner['generics'], 'inputs': [], 'supported': owner['supported'], + 'fields': owner['fields'], 'open_line': source[:start].count('\n') + 1, + 'end_line': source[:close].count('\n') + 1, + 'end_col': close - source.rfind('\n', 0, close) - 1}) + for annotation in found: + if not valid_file(file): + raise ValueError(f'Unsupported annotated source location: {file}') + rust = rust_identity(file, annotation['syntax']) + if rust in annotations: + raise ValueError(f'Duplicate annotation: {rust}') + if annotation['kind'] == 'function' and annotation['theorem'] in {a['theorem'] for a in annotations.values() if a['kind'] == 'function'}: + raise ValueError(f'Duplicate specification name: {annotation["theorem"]}') + annotations[rust] = {**annotation, 'rust': rust, 'file': file} + if not annotations: + raise ValueError('No Aeneas specifications discovered') + return annotations + + +def check_goldens(root, allow_missing=False): + """Check the canonical module roster, allowing initial population on request. + + scan-update may start with missing modules, but unexpected files still fail. + Ordinary scan and later comparison require the complete golden roster. + """ + directory = root / 'verification/aeneas/golden' + if allow_missing: + actual = {str(path.relative_to(directory)) for path in directory.rglob('*') if path.is_file()} + if actual <= set(golden.FILES): + return + golden.files(directory) + + +def render(root, destination): + """Copy whole generated goldens while keeping Rust annotations authoritative.""" + generated = golden.files(root / 'verification/aeneas/golden') + destination.mkdir(parents=True, exist_ok=True) + for name, text in generated.items(): + (destination / name).write_text(text) + + +def generated_nominal_types(source): + """Read local nominal declarations in Aeneas's extracted dependency order. + + These generated names propose the correspondence; inspected source and LLBC + checks establish its authority. Aliases reuse their existing carrier and + therefore do not acquire a competing nominal model provider. + """ + starts = list(re.finditer(r'^/-- \[(zerocopy::[^\]\n]+)\][^\n]*\n', source, re.M)) + result, seen = [], set() + for index, match in enumerate(starts): + end = starts[index + 1].start() if index + 1 < len(starts) else source.rfind('\nend Zerocopy') + close = source.find('-/\n', match.start(), end) + if close < 0 or end < 0: + raise ValueError('Missing generated type documentation or namespace end') + declaration = source[close + 3:end] + declaration = re.sub(r'^(?:@\[[^\]\n]*\]\n)+', '', declaration) + header = re.match(r'(structure|inductive) (' + IDENTIFIER + r'(?:\.' + IDENTIFIER + r')*)\b', declaration) + if not header: + # Genuine aliases are normalized to their existing carriers. In + # particular they never receive a competing model provider. + if re.match(r'(?:def|abbrev)\s', declaration): + continue + raise ValueError(f'Unsupported generated type declaration: {match[1]}') + if match[1] in seen: + raise ValueError(f'Duplicate generated Lean type identity: {match[1]}') + seen.add(match[1]) + names, _ = binder_header(declaration, header.end()) + result.append({'rust': match[1], 'model': header[2], 'generics': names, + 'nominal_kind': 'struct' if header[1] == 'structure' else 'enum'}) + return result + + +def generated_type_image(source): + """Enumerate every raw inductive emitted in Types, including dictionaries. + + The generated spelling proposes a correspondence; verified mode classifies + its Rust identity independently from LLBC before the compiled Lean audit. + """ + starts = list(re.finditer(r'^/-- (Trait declaration: )?\[([^\]\n]+)\][^\n]*\n', source, re.M)) + result, seen_rust, seen_raw = [], set(), set() + for index, match in enumerate(starts): + end = starts[index + 1].start() if index + 1 < len(starts) else source.rfind('\nend Zerocopy') + close = source.find('-/\n', match.start(), end) + if close < 0 or end < 0: + raise ValueError('Missing generated type documentation or namespace end') + declaration = source[close + 3:end] + external_attribute = bool(re.search(r'@\[rust_type\s+"' + re.escape(match[2]) + r'"\]', declaration)) + header = re.search(r'^(structure|inductive) (' + IDENTIFIER + r'(?:\.' + IDENTIFIER + r')*)\b', declaration, re.M) + if not header: + if re.search(r'^(?:def|abbrev)\s', declaration, re.M): + continue + raise ValueError(f'Unsupported generated type declaration: {match[2]}') + if match[2] in seen_rust or header[2] in seen_raw: + raise ValueError(f'Duplicate generated raw type identity: {match[2]}') + seen_rust.add(match[2]) + seen_raw.add(header[2]) + names, _ = binder_header(declaration, header.end()) + result.append({'rust': match[2], 'raw': 'Zerocopy.' + header[2], + 'parameters': len(names), 'nominal_kind': 'struct' if header[1] == 'structure' else 'enum', + 'trait_marker': bool(match[1]), 'external_attribute': external_attribute}) + return result + + +def generated_bindings(source, annotations, types_source=None): + """Associate root definitions with pinned Aeneas Rust identity metadata. + + Ignore loop helpers and dependencies, then check original parameter order. + Names alone do not establish source ownership: the separate LLBC audit also + binds each annotated root to its inspected body and argument names. + """ + starts = list(re.finditer(r'^/-- [^\n]*\n', source, re.M)) + models = {} + for index, match in enumerate(starts): + display = re.match(r'/-- \[(zerocopy::[^\]\n]+)\]:', match[0]) + if not display: + continue + rust = display[1] + if '{' in rust: + method = re.fullmatch(r'(.+::)\{([^{}]+)::(' + IDENTIFIER + r')(?:<[^{}]+>)?\}::(' + IDENTIFIER + ')', rust) + if not method or method[1] != method[2] + '::': + # Called trait implementations have different display names. + # They remain in the complete model, but cannot own supported + # annotations. Ignore their labels here; the exact root-set + # check below still rejects any missing or unsupported root. + continue + rust = method[1] + method[3] + '::' + method[4] + if (rust not in annotations or annotations[rust].get('kind', 'function') != 'function' + or re.search(r': loop(?: body)? \d+:', match[0])): + continue + end = starts[index + 1].start() if index + 1 < len(starts) else source.rfind('\nend Zerocopy') + close = source.find('-/\n', match.start(), end) + if close < 0 or end < 0: + raise ValueError('Missing generated root documentation or namespace end') + declaration = source[close + 3:end] + header = re.match(r'def (' + IDENTIFIER + r'(?:\.' + IDENTIFIER + r')*)\b', declaration) + if not header: + raise ValueError(f'Unsupported generated root declaration for {rust}') + if rust in models: + raise ValueError(f'Duplicate generated Lean root identity: {rust}') + names, _ = binder_header(declaration, header.end()) + expected = annotations[rust]['generics'] + annotations[rust]['inputs'] + if names != expected: + raise ValueError(f'Generated model parameters disagree with Rust parameters for {rust}: {names} != {expected}') + models[rust] = header[1] + type_annotations = {rust: a for rust, a in annotations.items() if a.get('kind') == 'type'} + if type_annotations: + if types_source is None: + raise ValueError('Generated Types.lean is required to bind type models') + for declaration in generated_nominal_types(types_source): + rust = declaration['rust'] + if rust not in type_annotations: + continue + annotation = type_annotations[rust] + if declaration['nominal_kind'] != annotation['nominal_kind']: + raise ValueError(f'Generated type kind disagrees with Rust owner: {rust}') + if declaration['generics'] != annotation['generics']: + raise ValueError(f'Generated model type parameters disagree with Rust parameters for {rust}') + models[rust] = declaration['model'] + if models.keys() != annotations.keys(): + raise ValueError('Generated Lean root declarations do not match discovered specifications') + return models + + +def source_digest(root, annotations): + """Hash fixed inspected inputs, rejecting edits since their discovery.""" + snapshots = getattr(annotations, 'source_snapshots', None) + if not snapshots: + raise ValueError('Fixed Rust source snapshots are required for model bindings') + digest = hashlib.sha256() + for file, snapshot in sorted(snapshots.items()): + try: + current = (root / file).read_bytes() + except OSError as error: + raise ValueError(f'Scanned source is no longer available: {file}') from error + if current != snapshot: + raise ValueError(f'Scanned source changed after annotation discovery: {file}') + digest.update(file.encode()) + digest.update(snapshot) + return digest.hexdigest() + + +def model_digest(work): + """Fingerprint normalized generated function and nominal-type code. + + Comment-only changes may pass golden comparison, but changed Lean code must + invalidate the saved binding before projection assembly. + """ + normalized = '\n'.join(golden.normalize((work / 'Zerocopy/Funs.lean').read_text())) + types = work / 'Zerocopy/Types.lean' + if types.exists(): + normalized += '\n' + '\n'.join(golden.normalize(types.read_text())) + return hashlib.sha256(normalized.encode()).hexdigest() + + +def save_bindings(root, annotations, work, development=False): + """Persist a checked name correspondence for both projection builds. + + Live extraction has already selected one source owner per nominal type. + Development uses the inspected candidates and therefore still requires an + unambiguous owner. Normalize both paths to candidate lists for the ownership + check, then use only its selected owners when constructing the manifest. + """ + source_digest(root, annotations) + types = work / 'Zerocopy/Types.lean' + models = generated_bindings((work / 'Zerocopy/Funs.lean').read_text(), annotations, + types.read_text() if types.exists() else None) + nominal = generated_nominal_types(types.read_text()) if types.exists() else [] + extracted_types = getattr(annotations, 'extracted_types', None) + source_candidates = {} + if extracted_types is None: + for source in getattr(annotations, 'types', []): + source_candidates.setdefault(source['rust'], []).append(source) + else: + source_candidates = {rust: [source] for rust, source in extracted_types.items()} + if {declaration['rust'] for declaration in nominal} != set(extracted_types): + raise ValueError('Generated nominal type declarations do not match extracted Rust nominal types') + source_types = {} + for declaration in nominal: + candidates = source_candidates.get(declaration['rust'], []) + if len(candidates) != 1: + raise ValueError(f'Generated nominal type lacks unambiguous verified source ownership: {declaration["rust"]}') + source = candidates[0] + if (not source['supported'] or declaration['generics'] != source['generics'] + or declaration['nominal_kind'] != source['nominal_kind']): + raise ValueError(f'Generated nominal type disagrees with supported Rust source: {declaration["rust"]}') + source_types[declaration['rust']] = source + bindings = {} + for rust, raw in models.items(): + a = annotations[rust] + if a.get('kind', 'function') == 'function': + bindings[rust] = {'kind': 'function', 'raw': 'Zerocopy.' + raw, + 'generics': a['generics'], 'inputs': a['inputs'], + 'file': a['file'], 'syntax': a['syntax'], 'spec': a['theorem'], + 'open_line': a['open_line'], 'end_line': a['end_line'], 'end_col': a['end_col']} + for declaration in nominal: + rust = declaration['rust'] + source = source_types[rust] + a = annotations.get(rust) + raw = 'Zerocopy.' + declaration['model'] + model_name = a['model_name'] if a and a['kind'] == 'type' else 'Fields' + bindings[rust] = {'kind': 'type', 'raw': raw, 'fields': raw + '.Fields', + 'model': raw + '.' + model_name, 'decoder': raw + '.decode', + 'provider': raw + '.aeneasModel', 'generics': declaration['generics'], + 'nominal_kind': declaration['nominal_kind'], + 'authored': bool(a and a['kind'] == 'type'), + 'macro_expanded': bool(source.get('macro_expanded')), + 'file': source['file'], 'syntax': source['syntax'], + 'open_line': source['open_line'], 'end_line': source['end_line'], 'end_col': source['end_col']} + type_image = [] + classes = getattr(annotations, 'type_classes', None) if not development else None + if not development and types.exists() and classes is None: + raise ValueError('Verified live type image requires checked LLBC classification') + for declaration in generated_type_image(types.read_text()) if types.exists() else []: + rust = declaration['rust'] + generated_path = rust.removeprefix('zerocopy::').replace('::', '.') + if declaration['raw'] != 'Zerocopy.' + generated_path: + raise ValueError(f'Generated raw type name disagrees with extracted Rust identity: {rust}') + category = classes.get(rust) if classes is not None else ( + 'local-nominal' if rust in source_types else + 'trait-dictionary' if declaration['trait_marker'] else + 'external-nominal' if declaration['external_attribute'] else None) + if category is None: + raise ValueError(f'Generated raw type lacks classified extraction ownership: {rust}') + if category == 'local-nominal': + if (rust not in source_types or declaration['trait_marker'] or declaration['external_attribute'] + or bindings[rust]['raw'] != declaration['raw']): + raise ValueError(f'Generated nominal type disagrees with source binding: {rust}') + elif category == 'trait-dictionary': + if not declaration['trait_marker'] or declaration['nominal_kind'] != 'struct' or rust in source_types: + raise ValueError(f'Generated trait dictionary disagrees with extraction: {rust}') + elif category == 'external-nominal': + external_parameters = getattr(annotations, 'external_type_parameters', None) + if (not declaration['external_attribute'] or declaration['trait_marker'] or rust in source_types + or (classes is not None and (external_parameters is None + or declaration['parameters'] != external_parameters.get(rust)))): + raise ValueError(f'Generated external nominal type disagrees with extraction: {rust}') + else: + raise ValueError(f'Unsupported raw type classification: {category}') + type_image.append({'rust': rust, 'raw': declaration['raw'], + 'category': category, 'parameters': declaration['parameters']}) + if {row['rust'] for row in type_image if row['category'] == 'local-nominal'} != set(source_types): + raise ValueError('Generated raw type image omits a source-bound nominal type') + manifest = {'version': 3, 'bindings': bindings, + 'type_image': type_image, + 'mode': 'development' if development else 'verified-live', + 'sources': source_digest(root, annotations), 'model': model_digest(work)} + (work / 'bindings.json').write_text(json.dumps(manifest, indent=2) + '\n') + + +def assemble(root, annotations, work, development=False): + """Project checked owners into native Lean modules without replacing code. + + The live binding manifest fixes extracted names and source snapshots. Both + live and golden builds consume it; development explicitly uses a weaker + mode. Only authored fragments receive edit markers. Structural derivations, + function applications, and audit commands remain generated framing. + """ + if not (root / 'verification/aeneas/lean/RequiredContracts.lean').is_file(): + raise ValueError('RequiredContracts.lean is required for independent specification checks') + # CI saves this mapping only after exact-body Charon checks and live Aeneas + # generation. The golden build consumes that same mapping, never its docs. + saved = work / 'bindings.json' + if not saved.exists(): + raise ValueError('Verified live model bindings are required before specification generation') + manifest = json.loads(saved.read_text()) + expected_mode = 'development' if development else 'verified-live' + if (manifest.get('version') != 3 or manifest.get('mode') != expected_mode + or manifest.get('sources') != source_digest(root, annotations) + or manifest.get('model') != model_digest(work)): + raise ValueError('Model bindings are stale; regenerate specs from the current sources and model') + if development: + workspace.protect_projections(work) + workspace.retire_generated(work) + bindings = manifest['bindings'] + claimed = {rust for rust, entry in bindings.items() if entry['kind'] == 'function' or entry['authored']} + if claimed != set(annotations): + raise ValueError('Verified live bindings do not cover every specification') + imports = ['module', 'public import Zerocopy.Funs', 'public import SpecPrelude', 'public import SpecsSyntax', 'public import Models'] + if (root / 'verification/aeneas/lean/LayoutModel.lean').exists(): + imports.append('public import LayoutModel') + elif (root / 'verification/aeneas/lean/Arithmetic.lean').exists(): + imports.append('public import Arithmetic') + specs = GeneratedLines([*golden.HEADER.rstrip().splitlines(), '', *imports, '@[expose] public section', '', + 'open Aeneas Aeneas.Std', 'open Zerocopy.Proofs', 'namespace Zerocopy.Specs', '']) + for a in annotations.values(): + if a.get('kind', 'function') != 'function': + continue + model = bindings[a['rust']]['raw'] + if not re.fullmatch(IDENTIFIER + r'(?:\.' + IDENTIFIER + ')*', model): + raise ValueError('Invalid verified generated model name') + lines = a['spec_text'].rstrip('\n').split('\n') + clause = a['clause_line'] + application = f'@{model} with {len(a["generics"])} type parameters' + lines.insert(clause, ' for ' + application) + specs.append(f'check_model_inputs {model} with {len(a["generics"])} type parameters', + {'file': a['file'], 'line': a['source_lines'][0]}) + specs.append('aeneas_spec_begin') + if development: + specs.append(f'-- aeneas-copy-begin {a["rust"]} spec') + for index, line in enumerate(lines): + original = max(0, index - (index >= clause)) + specs.append(line, {'file': a['file'], 'line': a['source_lines'][original]}) + if development: + specs.append(f'-- aeneas-copy-end {a["rust"]} spec') + specs.append('aeneas_spec_end', {'file': a['file'], 'line': a['source_lines'][-1]}) + specs.append(f'check_spec_binding Zerocopy.Specs.{a["theorem"]} ' + f'for {model} with {len(a["generics"]) + len(a["inputs"])}', + {'file': a['file'], 'line': a['source_lines'][0]}) + specs.append('') + specs.extend(['end Zerocopy.Specs', '']) + (work / 'Specs.lean').write_text('\n'.join(specs)) + (work / 'Specs.source-map.json').write_text(json.dumps(specs.locations, indent=2) + '\n') + shape_imports = ['Zerocopy.Types', 'ModelPrelude', 'DeriveModels'] + support = workspace.model_support(root) + model_imports = ['ModelShapes', 'DeriveModels', *support] + shapes = GeneratedLines([*golden.HEADER.rstrip().splitlines(), '', 'module', + *('public import ' + name for name in shape_imports), + '@[expose] public section', '', 'open Aeneas Aeneas.Std AeneasSpecs', '']) + decoders = GeneratedLines([*golden.HEADER.rstrip().splitlines(), '', 'module', + *('public import ' + name for name in model_imports), + '@[expose] public section', '', 'open Aeneas Aeneas.Std AeneasSpecs', '']) + # This is the single source/extraction binding table's verified order. + for rust, entry in bindings.items(): + if entry['kind'] != 'type': + continue + raw, count = entry['raw'], len(entry['generics']) + a = annotations.get(rust) + if entry['authored']: + shapes.append(f'aeneas_model_shape {raw} with {count} type parameters begin', + {'file': a['file'], 'line': a['source_lines'][0]}) + if development: + shapes.append(f'-- aeneas-copy-begin {rust} shape') + for index, line in enumerate(a['shape_text'].splitlines()): + shapes.append(line, {'file': a['file'], 'line': a['source_lines'][index]}) + if development: + shapes.append(f'-- aeneas-copy-end {rust} shape') + location = {'file': a['file'], 'line': a['source_lines'][a['decoder_clause_line']]} + shapes.append('end', location) + if development: + decoders.append(f'derive_rust_model {raw} with {count} type parameters', location) + decoders.append(f'-- aeneas-copy-begin {rust} decoder') + decoders.append(f'{a["decoder_mode"]} {a["value_binder"]} =>', location) + else: + decoders.append(f'derive_rust_model {raw} with {count} type parameters ' + f'{a["decoder_mode"]} {a["value_binder"]} =>', location) + for index, line in enumerate(a['decoder_text'].splitlines()): + decoders.append(' ' + line, {'file': a['file'], + 'line': a['source_lines'][min(a['decoder_line'] + index, len(a['source_lines']) - 1)]}) + if development: + decoders.append(f'-- aeneas-copy-end {rust} decoder') + else: + location = {'file': entry['file'], 'line': entry['open_line']} + shapes.append(f'derive_model_shape {raw} with {count} type parameters', location) + decoders.append(f'derive_rust_model {raw} with {count} type parameters', location) + decoders.append(f'check_model_binding {raw} with {count} type parameters', location) + shapes.append('') + decoders.append('') + for module, lines in [('ModelShapes', shapes), ('Models', decoders)]: + (work / f'{module}.lean').write_text('\n'.join(lines) + '\n') + (work / f'{module}.source-map.json').write_text(json.dumps(lines.locations, indent=2) + '\n') + modules = ['Proofs', *sorted(str(path.relative_to(root / 'verification/aeneas/lean')).removesuffix('.lean').replace('/', '.') + for path in (root / 'verification/aeneas/lean/Proofs').rglob('*.lean'))] + # Import every configured native proof module, even when its declarations + # are unused by the aggregate. Otherwise a private helper could evade the + # axiom audit simply by being omitted from the aggregate's imports. + audit_modules = sorted({'Specs', 'ModelShapes', 'Models', 'Zerocopy.Funs', 'Zerocopy.Types', + 'Proofs', 'Obligations', + *(str(path).removesuffix('.lean').replace('/', '.') + for path in workspace.handwritten(root) + if path != Path('Check.lean'))}) + required = ['import Lean', *('import ' + module for module in audit_modules)] + required += ['open Lean', ''] + if (root / 'verification/aeneas/lean/Arithmetic.lean').exists(): + required += ['attribute [local contract_simps] Zerocopy.Arithmetic.coe_min', ''] + for a in annotations.values(): + if a.get('kind', 'function') != 'function': + continue + name = a['theorem'] + required.append(f'example : Zerocopy.Specs.{name} := @Zerocopy.Proofs.{name}') + required.append(f'check_contract Zerocopy.Obligations.{name} using @Zerocopy.Proofs.{name}') + required.append('def proofModuleNames : Array Name := #[' + ', '.join('`' + module for module in modules) + ']') + required.append('def auditModuleNames : Array Name := #[' + ', '.join('`' + module for module in audit_modules) + ']') + (work / 'Required.lean').write_text('\n'.join(required) + '\n') + if source_digest(root, annotations) != manifest['sources']: + raise ValueError('Source snapshots disagree with verified model bindings') + (work / 'Specs.sources.sha256').write_text(manifest['sources'] + '\n') + if development: + workspace.record_projections(work, annotations) + + +def check_bindings(root, annotations, llbc): + """Audit extraction against the complete inspected source snapshot. + + Names alone cannot distinguish cfg alternatives or macro-generated owners. + Require exact local contents, owner spans, ordered generics, and original + argument names before accepting the function and nominal-type rosters. + """ + if isinstance(annotations, Annotations): + annotations.extracted_types = None + annotations.type_classes = None + annotations.external_type_parameters = None + source_digest(root, annotations) + translated = json.loads(llbc.read_text())['translated'] + files = {f['id']: f for f in translated['files']} + if len(files) != len(translated['files']): + raise ValueError('Duplicate extracted source file identity') + for file in files.values(): + name = file.get('name') + if (file.get('contents') is None and isinstance(name, dict) + and set(name) == {'Local'} and isinstance(name['Local'], str) + and name['Local'].startswith('/rustc/')): + # These pinned external sources remain a translation premise. + continue + if not isinstance(name, dict) or set(name) != {'Local'}: + raise ValueError('Unsupported extracted source file identity') + relative = name['Local'] + path = 'zerocopy/' + relative if isinstance(relative, str) else '' + if not valid_file(path) or path not in annotations.source_snapshots: + raise ValueError(f'Extracted local dependency file lacks an inspected source snapshot: {relative}') + if not isinstance(file.get('contents'), str): + raise ValueError(f'Extracted local dependency file is missing source contents: {relative}') + if file['contents'].replace('\r\n', '\n') != annotations.source_snapshots[path].decode('utf-8').replace('\r\n', '\n'): + raise ValueError(f'Charon extracted a different source body for local dependency file {relative}') + # At the pinned Charon version named Self types may be deduplicated. Only + # collect the Adt type domain, not unrelated Value/Deduplicated domains. + adts = {} + + def collect_adts(value): + # Charon may store a named Self carrier once and refer to it by index. + # Gather only the Adt domain: other deduplicated values can reuse the + # same integer without identifying a type. + if isinstance(value, dict): + if (set(value) == {'Value'} and isinstance(value['Value'], list) + and len(value['Value']) == 2 and isinstance(value['Value'][0], int)): + index, body = value['Value'] + if (isinstance(body, dict) and set(body) == {'Adt'} + and isinstance(body['Adt'], dict) + and set(body['Adt']) == {'id', 'generics', 'builtin'}): + if index in adts and adts[index] != body: + raise ValueError('Ambiguous deduplicated Charon Self type') + adts[index] = body + for child in value.values(): + collect_adts(child) + elif isinstance(value, list): + for child in value: + collect_adts(child) + + collect_adts(translated) + type_names = {t['def_id']: t['item_meta']['name'] + for t in translated.get('type_decls', []) if t} + + def identifiers(name): + if not all(set(element) == {'Ident'} and element['Ident'][1] == 0 for element in name): + raise ValueError('Unsupported extracted root identity') + return [element['Ident'][0] for element in name] + + def identity(name): + # An inherent method name embeds its Self carrier. Resolve that carrier + # back to the locally defined nominal type instead of accepting a + # display spelling that could hide a trait or qualified impl. + impls = [i for i, element in enumerate(name) if 'Impl' in element] + if not impls: + return '::'.join(identifiers(name)) + if impls != [len(name) - 2]: + raise ValueError('Unsupported extracted inherent method identity') + index = impls[0] + implementation = name[index]['Impl'] + if set(implementation) != {'Ty'}: + raise ValueError('Trait impl roots are unsupported') + implementation = implementation['Ty'] + if implementation['kind'] != 'InherentImplBlock': + raise ValueError('Only inherent impl roots are supported') + wrapped = implementation['skip_binder'] + if set(wrapped) == {'Value'}: + body = wrapped['Value'][1] + elif set(wrapped) == {'Deduplicated'}: + body = adts.get(wrapped['Deduplicated'], {}) + else: + body = {} + if set(body) != {'Adt'}: + raise ValueError('Inherent Self must resolve to a named type') + adt = body['Adt'] + if adt['builtin'] is not None: + raise ValueError('Builtin Self roots are unsupported') + self_name = identifiers(type_names.get(adt['id'], [])) + prefix = identifiers(name[:index]) + if not self_name or self_name[:-1] != prefix: + raise ValueError('Inherent Self must be defined in the annotated module') + return '::'.join([*self_name, *identifiers(name[index + 1:])]) + + roots = {} + for function in translated['fun_decls']: + if not function or function['item_meta'].get('started_from') is not True: + continue + meta = function['item_meta'] + name = identity(meta['name']) + if name in roots: + raise ValueError('Duplicate extracted root identity') + roots[name] = function + function_annotations = {rust: a for rust, a in annotations.items() + if a.get('kind', 'function') == 'function'} + if roots.keys() != function_annotations.keys(): + raise ValueError('Extracted roots do not match the present function annotations') + for declaration in translated.get('type_decls', []): + if not declaration: + continue + meta = declaration['item_meta'] + # Dependency types may use unsupported external identities. Only local + # model roots participate in this exact source correspondence audit. + if meta.get('is_local') is not True: + continue + rust = identity(meta['name']) + if rust not in annotations or annotations[rust].get('kind') != 'type': + continue + if rust in roots: + raise ValueError(f'Duplicate extracted type identity: {rust}') + roots[rust] = declaration + if roots.keys() != annotations.keys(): + raise ValueError('Extracted type roots do not match the present annotations') + def verify_source(rust, annotation, function): + # File equality binds the whole inspected snapshot; the final source + # position distinguishes same-named cfg alternatives within that file. + # Normalizing CRLF matches Charon's text convention without discarding + # physical byte spelling from the editing baseline. + meta = function['item_meta'] + span = meta['span']['Untagged'] + data = span['data'] + file = files[data['file_id']] + source = annotations.source_snapshots[annotation['file']].decode('utf-8').replace('\r\n', '\n') + if (meta.get('is_local') is not True or span['generated_from_span'] is not None + or file['name'] != {'Local': annotation['file'].removeprefix('zerocopy/')} + or file['contents'].replace('\r\n', '\n') != source + or not data['beg']['line'] <= annotation['open_line'] <= data['end']['line'] + or data['end'] != {'line': annotation['end_line'], 'col': annotation['end_col']}): + raise ValueError(f'Charon extracted a different source body for {rust}') + generics = function['generics'] + if (generics['const_generics'] or generics['trait_clauses'] + or [parameter['name'] for parameter in generics['types']] != annotation['generics']): + raise ValueError(f'Charon extracted different or unsupported generic parameters for {rust}') + + # Charon's deduplicated indices are reused across value domains. Collect + # definitions only by walking LLBC field-type positions, excluding array + # lengths and other value domains. Ambiguous type entries fail closed. + type_tags = {'Scalar', 'TypeVar', 'Adt', 'Array', 'Slice', + 'Ref', 'RawPtr', 'FnPtr', 'Arrow', 'TraitObject'} + type_values = {} + + def collect_type_values(value): + if not isinstance(value, dict) or len(value) != 1: + return + tag, body = next(iter(value.items())) + if tag == 'Value' and isinstance(body, list) and len(body) == 2: + if (isinstance(body[0], int) and isinstance(body[1], dict) + and len(body[1]) == 1 and next(iter(body[1])) in type_tags): + type_values.setdefault(body[0], []).append(body[1]) + collect_type_values(body[1]) + elif tag in {'Array', 'Slice'} and isinstance(body, list) and body: + collect_type_values(body[0]) + elif tag == 'Adt' and isinstance(body, dict): + for parameter in body.get('generics', {}).get('types', []): + collect_type_values(parameter) + + for declaration in translated.get('type_decls', []): + if not declaration or not isinstance(declaration.get('kind'), dict): + continue + kind = declaration['kind'] + fields = kind.get('Struct', []) + if 'Enum' in kind: + fields = [field for variant in kind['Enum'] for field in variant.get('fields', [])] + if isinstance(fields, list): + for field in fields: + if isinstance(field, dict): + collect_type_values(field.get('ty')) + + def safe_field_type(ty, parameter_count, seen=frozenset()): + if not isinstance(ty, dict) or len(ty) != 1: + return False + tag, body = next(iter(ty.items())) + if tag == 'Value': + return (isinstance(body, list) and len(body) == 2 + and isinstance(body[0], int) and safe_field_type(body[1], parameter_count, seen)) + if tag == 'Deduplicated': + if not isinstance(body, int) or body in seen: + return False + candidates = {json.dumps(v, sort_keys=True) for v in type_values.get(body, [])} + return (len(candidates) == 1 and safe_field_type( + json.loads(next(iter(candidates))), parameter_count, seen | {body})) + if tag == 'Scalar': + return isinstance(body, dict) + if tag == 'TypeVar': + return (isinstance(body, dict) and set(body) == {'Free'} + and isinstance(body['Free'], int) and 0 <= body['Free'] < parameter_count) + if tag == 'Array': + return (isinstance(body, list) and len(body) == 3 and body[2] is None + and safe_field_type(body[0], parameter_count, seen)) + if tag == 'Slice': + return (isinstance(body, list) and len(body) == 2 and body[1] is None + and safe_field_type(body[0], parameter_count, seen)) + if tag == 'Adt' and isinstance(body, dict): + generics = body.get('generics', {}) + return (body.get('builtin') is None and isinstance(generics, dict) + and generics.get('regions') == [] and generics.get('const_generics') == [] + and generics.get('trait_refs') == [] and isinstance(generics.get('types'), list) + and all(safe_field_type(ty, parameter_count, seen) for ty in generics['types'])) + # In particular, Ref, RawPtr, FnPtr and unrecognized future type forms + # cannot become structural carriers through a macro source span. + return False + + def macro_nominal_source(rust, declaration): + meta = declaration['item_meta'] + span = meta.get('span', {}).get('Untagged', {}) + data = span.get('data', {}) + file = files.get(data.get('file_id')) + if (meta.get('is_local') is not True or meta.get('started_from') is not False + or 'source_text' not in meta or meta['source_text'] is not None + or declaration.get('src') != 'Normal' + or span.get('generated_from_span') is not None + or not isinstance(file, dict) or not isinstance(file.get('name'), dict) + or set(file['name']) != {'Local'}): + return None + path = 'zerocopy/' + file['name']['Local'] + if not valid_file(path) or path not in annotations.source_snapshots: + return None + source = annotations.source_snapshots[path].decode('utf-8').replace('\r\n', '\n') + if file.get('contents', '').replace('\r\n', '\n') != source: + return None + + def position(point): + if not isinstance(point, dict): + return None + line, col = point.get('line'), point.get('col') + lines = source.split('\n') + if (not isinstance(line, int) or not isinstance(col, int) or line < 1 + or line > len(lines) or col < 0 or col > len(lines[line - 1])): + return None + return sum(len(part) + 1 for part in lines[:line - 1]) + col + + begin, end = position(data.get('beg')), position(data.get('end')) + if begin is None or end is None or begin >= end: + return None + name = rust.rsplit('::', 1)[-1] + macros = [macro for macro in annotations.macros if macro['file'] == path + and rust_identity(path, '::'.join([*macro['modules'], name])) == rust + and macro['start'] <= begin and end <= macro['end']] + if len(macros) != 1 or suspicious(source[macros[0]['start']:macros[0]['end']]): + return None + generics = declaration.get('generics', {}) + if (not isinstance(generics, dict) or generics.get('regions') != [] + or generics.get('const_generics') != [] or generics.get('trait_clauses') != [] + or generics.get('regions_outlive', []) != [] + or generics.get('types_outlive', []) != [] + or generics.get('trait_type_constraints', []) != [] + or not isinstance(generics.get('types'), list)): + return None + parameters = [parameter.get('name') for parameter in generics['types'] + if isinstance(parameter, dict)] + if (len(parameters) != len(generics['types']) or len(set(parameters)) != len(parameters) + or any(not isinstance(name, str) or not re.fullmatch(IDENTIFIER, name) + for name in parameters)): + return None + kind = declaration.get('kind') + if not isinstance(kind, dict) or len(kind) != 1: + return None + nominal_kind, variants = next(iter(kind.items())) + if nominal_kind == 'Struct' and isinstance(variants, list): + fields = variants + elif nominal_kind == 'Enum' and isinstance(variants, list): + if not all(isinstance(v, dict) and isinstance(v.get('fields'), list) for v in variants): + return None + fields = [field for variant in variants for field in variant['fields']] + else: + return None + if not all(isinstance(field, dict) and safe_field_type(field.get('ty'), len(parameters)) + for field in fields): + return None + return {'rust': rust, 'file': path, 'syntax': '::'.join([*macros[0]['modules'], name]), + 'kind': 'type', 'nominal_kind': nominal_kind.lower(), 'generics': parameters, + 'inputs': [], 'supported': True, 'fields': [], + 'open_line': data['beg']['line'], 'end_line': data['end']['line'], + 'end_col': data['end']['col'], 'macro_expanded': True} + + for rust, annotation in annotations.items(): + function = roots[rust] + verify_source(rust, annotation, function) + if annotation.get('kind') == 'type': + expected = 'Struct' if annotation['nominal_kind'] == 'struct' else 'Enum' + if not isinstance(function.get('kind'), dict) or set(function['kind']) != {expected}: + raise ValueError(f'Charon extracted a different or unsupported nominal type for {rust}') + continue + body = function.get('body') or {} + locals_ = body.get('Structured', {}).get('locals', {}) + count = locals_.get('arg_count') + arguments = locals_.get('locals', [])[1:1 + count] if isinstance(count, int) else [] + if (count != len(annotation['inputs']) + or len(function['signature']['inputs']) != count + or [argument['name'] for argument in arguments] != annotation['inputs']): + raise ValueError(f'Charon extracted different function arguments for {rust}') + + if isinstance(annotations, Annotations): + extracted_types = {} + for declaration in translated.get('type_decls', []): + if not declaration or declaration['item_meta'].get('is_local') is not True: + continue + meta = declaration['item_meta'] + kind = declaration.get('kind') + if not isinstance(kind, dict) or not (set(kind) <= {'Struct', 'Enum'}): + # Genuine aliases inherit their carrier; unsupported opaque and + # recursive carriers are diagnosed during structural generation. + continue + rust = identity(meta['name']) + candidates = [a for a in annotations.types if a['rust'] == rust] + matching = [] + for candidate in candidates: + try: + verify_source(rust, candidate, declaration) + except ValueError: + continue + matching.append(candidate) + if not candidates and rust not in annotations: + macro_source = macro_nominal_source(rust, declaration) + if macro_source is not None: + matching.append(macro_source) + if len(matching) != 1 or not matching[0]['supported']: + raise ValueError(f'Extracted nominal type lacks exact supported Rust source ownership: {rust}') + source = matching[0] + expected = 'Struct' if source['nominal_kind'] == 'struct' else 'Enum' + if set(kind) != {expected} or rust in extracted_types: + raise ValueError(f'Extracted nominal type kind or identity disagrees with Rust source: {rust}') + extracted_types[rust] = source + annotations.extracted_types = extracted_types + classes = {} + external_parameters = {} + for group, category in (('type_decls', None), ('trait_decls', 'trait-dictionary')): + for declaration in translated.get(group, []): + if not declaration: + continue + meta = declaration.get('item_meta', {}) + if category is None: + kind = declaration.get('kind') + if not isinstance(kind, dict) or set(kind) not in ({'Struct'}, {'Enum'}): + continue + role = ('local-nominal' if meta.get('is_local') is True else + 'external-nominal' if meta.get('is_local') is False else None) + else: + role = category + if role is None: + continue + try: + rust = identity(meta['name']) + except (KeyError, TypeError, ValueError): + continue + if rust in classes: + raise ValueError(f'Extracted raw type has conflicting declarations: {rust}') + classes[rust] = role + if role == 'external-nominal': + generics = declaration.get('generics', {}) + if not isinstance(generics, dict) or not isinstance(generics.get('types'), list): + raise ValueError(f'External raw type lacks extracted type parameters: {rust}') + external_parameters[rust] = len(generics['types']) + annotations.type_classes = classes + annotations.external_type_parameters = external_parameters + + +def update(root, live): + """Update complete goldens from live output without rewriting Rust fences.""" + golden.update(live, root / 'verification/aeneas/golden') + + +def main(): + """Route discovery, extraction checks, and assembly through their boundaries. + + Development assembly is explicitly weaker than fresh live binding; both + modes still preserve projection edits and bind to inspected snapshots. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', choices=['scan', 'scan-update', 'bindings', 'models', 'render', 'assemble', 'dev-assemble', 'update']) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--tool', type=Path, required=True) + parser.add_argument('--work', type=Path, required=True) + args = parser.parse_args() + annotations = discover(args.root, args.tool) + if args.command == 'bindings': + check_bindings(args.root, annotations, args.work / 'zerocopy.llbc') + elif args.command == 'models': + # The driver calls this only after bindings, on freshly generated live + # output. Keep the verified mapping separate from mutable spec text. + check_bindings(args.root, annotations, args.work / 'zerocopy.llbc') + save_bindings(args.root, annotations, args.work) + elif args.command == 'dev-assemble': + # Editor-only generation deliberately lacks the fresh extraction/body + # guarantee. CI uses models above and checks both independent builds. + workspace.protect_projections(args.work) + save_bindings(args.root, annotations, args.work, development=True) + assemble(args.root, annotations, args.work, development=True) + elif args.command == 'update': + update(args.root, args.work) + elif args.command == 'assemble': + assemble(args.root, annotations, args.work) + elif args.command == 'render': + render(args.root, args.work) + else: + check_goldens(args.root, allow_missing=args.command == 'scan-update') + write_scan(annotations, args.work) + + +if __name__ == '__main__': + main() diff --git a/verification/aeneas/lean/Check.lean b/verification/aeneas/lean/Check.lean new file mode 100644 index 0000000000..fd7a085c17 --- /dev/null +++ b/verification/aeneas/lean/Check.lean @@ -0,0 +1,370 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +import Required +open Lean Elab Command + +/-! +This is the final compiled audit of the connection from Rust annotations to +Lean proofs. It compares three sources of evidence: the source/extraction +binding manifest, the actual compiled raw types and specifications, and the +configured proof and required-check modules. Each comparison is closed: both +missing and unexpected owners are errors. Deleting an annotation and its proof +together must not erase a promised root from the audit. + +After checking ownership and arbitrary-outcome adequacy, the audit records the +proof dependency graph and checks the axioms used by every audited declaration. +The graph reports what the compiled proofs use; it does not prescribe their +structure. External ABI size and alignment inputs are permitted only with their +data signatures and explicit read interpretations, rather than as axioms about +useful layout properties. +-/ + +run_elab do + let env ← getEnv + let layoutInputs := #[`Zerocopy.RustLayout.size, `Zerocopy.RustLayout.align] + -- Generic alignment reads identify the ABI-capable model. Earlier models + -- may have only the pointer-width size read and require no generic ABI inputs. + let hasLayoutReads := env.contains `core.mem.align_of + let modelInputs := if hasLayoutReads then layoutInputs else #[] + -- ABI inputs are functions returning data, with no proposition asserting + -- alignment, size, or correctness. A changed type could smuggle a theorem + -- into the allowed axiom list, so check their exact compiled signatures. + let expected ← Term.elabType (← `(Type → Aeneas.Std.Usize)) + for input in modelInputs do + let some (.axiomInfo info) := env.find? input + | throwError "Missing external layout input {input}" + unless ← Meta.isDefEq info.type expected do + throwError "External layout input {input} changed its data-only signature" + if hasLayoutReads then + -- Independently state each primitive interpretation. Keeping the data + -- axioms while ignoring them must not silently shrink the modeled domain. + let alignRead ← Term.elabTerm (← `( + fun (T : Type) => Aeneas.Std.Result.ok (Zerocopy.RustLayout.align T))) none + let sizeRead ← Term.elabTerm (← `( + fun (T : Type) => by + classical + exact Aeneas.Std.Result.ok + (if T = Aeneas.Std.Usize then + ({ bv := BitVec.ofNat _ (System.Platform.numBits / 8) } : Aeneas.Std.Usize) + else Zerocopy.RustLayout.size T))) none + Term.synthesizeSyntheticMVarsNoPostponing + let alignRead ← instantiateMVars alignRead + let sizeRead ← instantiateMVars sizeRead + unless ← Meta.isDefEq (mkConst `core.mem.align_of) alignRead do + throwError "External alignment read changed its data-input interpretation" + unless env.contains `core.mem.size_of do + throwError "Missing external size read" + unless ← Meta.isDefEq (mkConst `core.mem.size_of) sizeRead do + throwError "External size read changed its data-input interpretation" + -- Module indices identify declarations by compiled ownership. A matching + -- namespace is insufficient: a generated or unrelated module could otherwise + -- install a theorem under a handwritten proof's expected name. + let mut proofModules : Array ModuleIdx := #[] + for moduleName in proofModuleNames do + let some index := env.getModuleIdx? moduleName + | throwError "Proof module {moduleName} was not imported" + proofModules := proofModules.push index + let mut auditModules : Array ModuleIdx := #[] + for moduleName in auditModuleNames do + let some index := env.getModuleIdx? moduleName + | throwError "Audit module {moduleName} was not imported" + auditModules := auditModules.push index + -- Compiled spec declarations determine coverage; there is no separately + -- generated theorem roster that can omit a specification and its checks. + let some specsModule := env.getModuleIdx? `Specs + | throwError "Compiled inline specifications must be imported" + let mut required : Array Name := #[] + let mut actualModels : Array Name := #[] + let mut actualFamilies : Array Name := #[] + let mut expectedFamilies : Array Name := #[] + for (declName, info) in env.constants.toList do + if env.getModuleIdxFor? declName != some specsModule || + declName.getPrefix != `Zerocopy.Specs then continue + let .defnInfo info := info + | throwError "Unexpected inline specification declaration {declName}" + -- The generated namespace contains specs and their outcome families only. + -- Collect untagged definitions for an exact family comparison below, rather + -- than treating every nearby declaration as an authored contract. + if !AeneasSpecs.specAttribute.hasTag env declName then + actualFamilies := actualFamilies.push declName + continue + unless info.type.consumeMData == mkSort Level.zero do + throwError "Inline specification {declName} must be a proposition" + let (modelName, _) ← AeneasSpecs.specBinding declName + AeneasSpecs.checkSpecContract declName + let familyName := AeneasSpecs.specContractName declName + unless env.getModuleIdxFor? familyName == some specsModule do + throwError "Abstract contract {familyName} must be declared with its inline specification" + expectedFamilies := expectedFamilies.push familyName + -- One Rust function owns one inline specification. Multiple specs could + -- let the canonical theorem prove a weaker sibling of the intended claim. + if actualModels.contains modelName then + throwError "Compiled inline specifications repeat model {modelName}" + actualModels := actualModels.push modelName + required := required.push ((`Zerocopy.Proofs).str declName.getString!) + let missingFamilies := expectedFamilies.filter (!actualFamilies.contains ·) + let extraFamilies := actualFamilies.filter (!expectedFamilies.contains ·) + unless missingFamilies.isEmpty && extraFamilies.isEmpty do + throwError "Compiled abstract contracts disagree with inline specifications: missing {missingFamilies}; unexpected {extraFamilies}" + if required.isEmpty then + throwError "No compiled inline specifications were found" + -- Reuse the model mapping already produced from Rust annotations/extraction. + -- Deriving both Specs and Required from a smaller subset cannot hide a root. + -- The manifest is an audited data input produced from source ownership and + -- extraction. Validate its schema and mode before using any names/counts; + -- a development manifest still must satisfy the same compiled binding checks. + let bindingsText ← IO.FS.readFile "bindings.json" + let .ok bindings := Json.parse bindingsText + | throwError "Model bindings are not valid JSON" + let .ok version := bindings.getObjValAs? Nat "version" + | throwError "Model bindings must declare version 3" + unless version == 3 do + throwError "Model bindings must declare version 3" + let .ok mode := bindings.getObjValAs? String "mode" + | throwError "Model bindings must declare their validation mode" + unless mode == "verified-live" || mode == "development" do + throwError "Model bindings have an unsupported validation mode {mode}" + let .ok entriesValue := bindings.getObjVal? "bindings" + | throwError "Model bindings must contain their source/extraction binding table" + let .ok entries := entriesValue.getObj? + | throwError "Model bindings must contain a binding table object" + let some typesModule := env.getModuleIdx? `Zerocopy.Types + | throwError "Raw extracted types must be imported" + let some shapesModule := env.getModuleIdx? `ModelShapes + | throwError "ModelShapes must be imported from their generated module" + let some modelsModule := env.getModuleIdx? `Models + | throwError "Models must be imported from their generated module" + let mut expectedTypes : Array Name := #[] + let mut expectedLocalRust : Array String := #[] + let mut expectedModels : Array Name := #[] + for (rustName, entry) in entries.toArray do + let .ok kind := entry.getObjValAs? String "kind" + | throwError "Binding for {rustName} must name its owner kind" + let .ok raw := entry.getObjValAs? String "raw" + | throwError "Binding for {rustName} must name its exact extracted carrier" + let .ok generics := entry.getObjValAs? (Array String) "generics" + | throwError "Binding for {rustName} must name its original type parameters" + let rawName := raw.toName + -- Function entries bind a source spec to the exact extracted constant and + -- its complete type/value prefix. Type entries bind nominal raw ownership + -- to shape and full-provider declarations in their generated modules. + if kind == "function" then + if expectedModels.contains rawName then + throwError "Model bindings repeat model {rawName}" + expectedModels := expectedModels.push rawName + let .ok sourceSpec := entry.getObjValAs? String "spec" + | throwError "Function binding for {rustName} must name its inline specification" + let .ok sourceInputs := entry.getObjValAs? (Array String) "inputs" + | throwError "Function binding for {rustName} must name its original value inputs" + let specName := (`Zerocopy.Specs).str sourceSpec + unless env.getModuleIdxFor? specName == some specsModule do + throwError "Function binding for {rustName} does not name a compiled inline specification" + let (boundModel, boundInputs) ← AeneasSpecs.specBinding specName + -- This also rejects a same-arity call on replaced or reordered inputs: + -- specBinding already checked each argument's original variable identity. + unless boundModel == rawName && boundInputs == generics.size + sourceInputs.size do + throwError "Compiled specification {specName} changed its source model or original input prefix" + else if kind == "type" then + if expectedTypes.contains rawName then + throwError "Type bindings repeat extracted carrier {rawName}" + expectedTypes := expectedTypes.push rawName + expectedLocalRust := expectedLocalRust.push rustName + let some (.inductInfo rawInfo) := env.find? rawName + | throwError "Type binding is not a raw nominal carrier: {rawName}" + unless env.getModuleIdxFor? rawName == some typesModule && + rawInfo.numParams == generics.size do + throwError "Raw nominal binding changed its ownership or type parameters: {rawName}" + let (fields, mathType, decoder, provider, count) ← AeneasSpecs.modelBinding rawName + unless count == generics.size do + throwError "Mathematical model changed its original generic parameters: {rawName}" + -- Compare independently emitted manifest names with the registry's actual + -- declarations. Derived fixed names still need this external comparison; + -- a correct provider for the wrong Rust owner is not an acceptable proof. + for (key, actual) in #[("fields", fields), ("model", mathType), + ("decoder", decoder), ("provider", provider)] do + let .ok expected := entry.getObjValAs? String key + | throwError "Type binding for {rustName} must name its {key}" + unless actual == expected.toName do + throwError "Compiled {key} {actual} disagrees with its Rust owner binding {expected}" + -- Shapes can depend on mathematical child types before any decoder is + -- built. Their ownership stays separate from full decoder/provider assembly. + unless env.getModuleIdxFor? fields == some shapesModule && + env.getModuleIdxFor? mathType == some shapesModule do + throwError "Mathematical fields and model must be owned by ModelShapes: {rawName}" + unless env.getModuleIdxFor? decoder == some modelsModule && + env.getModuleIdxFor? provider == some modelsModule do + throwError "Decoder and provider must be owned by Models: {rawName}" + let .ok authored := entry.getObjValAs? Bool "authored" + | throwError "Type binding must record its authored model disposition" + -- An unannotated owner must receive its structural carrier, not an + -- arbitrary smaller interpretation introduced by generation. + unless authored || mathType == fields do + throwError "Unannotated model must use its nominal Fields carrier: {rawName}" + else + throwError "Unsupported binding kind {kind} for {rustName}" + -- This image is classified from LLBC type and trait declarations in live + -- mode. Every compiled raw inductive must belong to exactly one class; + -- local nominals must also have the source-bound model/provider entry above. + let .ok image := bindings.getObjValAs? (Array Json) "type_image" + | throwError "Model bindings must contain the complete raw type image" + let mut imageTypes : Array Name := #[] + let mut imageLocalTypes : Array Name := #[] + let mut imageLocalRust : Array String := #[] + let mut imageRust : Array String := #[] + for row in image do + let .ok rust := row.getObjValAs? String "rust" + | throwError "Raw type image entry must name its Rust identity" + let .ok raw := row.getObjValAs? String "raw" + | throwError "Raw type image entry must name its generated carrier" + let .ok category := row.getObjValAs? String "category" + | throwError "Raw type image entry must name its extraction category" + let .ok parameterCount := row.getObjValAs? Nat "parameters" + | throwError "Raw type image entry must name its generated parameter count" + unless category == "local-nominal" || category == "trait-dictionary" || + category == "external-nominal" do + throwError "Unsupported raw type image category {category} for {rust}" + let rawName := raw.toName + if imageRust.contains rust || imageTypes.contains rawName then + throwError "Raw type image repeats Rust identity or generated carrier {rust}: {rawName}" + imageRust := imageRust.push rust + imageTypes := imageTypes.push rawName + let some (.inductInfo info) := env.find? rawName + | throwError "Raw type image entry is not an inductive carrier: {rawName}" + unless env.getModuleIdxFor? rawName == some typesModule && info.numParams == parameterCount do + throwError "Raw type image changed its compiled module or parameter count: {rawName}" + if category == "local-nominal" then + let some bound := entries.get? rust + | throwError "Local raw type image has no source binding: {rust}" + let .ok boundRaw := bound.getObjValAs? String "raw" + | throwError "Local raw type image has no bound generated carrier: {rust}" + unless boundRaw == raw do + throwError "Local raw type image disagrees with source-bound carrier: {rust}" + imageLocalTypes := imageLocalTypes.push rawName + imageLocalRust := imageLocalRust.push rust + let missingLocalTypes := expectedTypes.filter (!imageLocalTypes.contains ·) + let extraLocalTypes := imageLocalTypes.filter (!expectedTypes.contains ·) + let missingLocalRust := expectedLocalRust.filter (!imageLocalRust.contains ·) + let extraLocalRust := imageLocalRust.filter (!expectedLocalRust.contains ·) + unless missingLocalTypes.isEmpty && extraLocalTypes.isEmpty && + missingLocalRust.isEmpty && extraLocalRust.isEmpty do + throwError "Source-bound nominal models disagree with raw type image: missing {missingLocalTypes}; unexpected {extraLocalTypes}" + let mut actualTypes : Array Name := #[] + for (constName, constInfo) in env.constants.toList do + if env.getModuleIdxFor? constName == some typesModule then + if let .inductInfo _ := constInfo then + actualTypes := actualTypes.push constName + let missingTypes := actualTypes.filter (!imageTypes.contains ·) + let extraTypes := imageTypes.filter (!actualTypes.contains ·) + unless missingTypes.isEmpty && extraTypes.isEmpty do + throwError "Compiled raw types disagree with classified extraction image: missing {missingTypes}; unexpected {extraTypes}" + -- Compare registered providers too: a raw type can be present in extraction + -- and the manifest while its full mathematical provider is absent or extra. + let actualOwners := (← AeneasSpecs.modelOwnerNames).filter + (fun owner => env.getModuleIdxFor? (owner ++ `aeneasModel) == some modelsModule) + let missingOwners := expectedTypes.filter (!actualOwners.contains ·) + let extraOwners := actualOwners.filter (!expectedTypes.contains ·) + unless missingOwners.isEmpty && extraOwners.isEmpty do + throwError "Compiled mathematical models disagree with bindings: missing {missingOwners}; unexpected {extraOwners}" + let missing := expectedModels.filter (fun modelName => !actualModels.contains modelName) + let extra := actualModels.filter (fun modelName => !expectedModels.contains modelName) + unless missing.isEmpty && extra.isEmpty do + throwError "Compiled inline specification models disagree with bindings: missing {missing}; unexpected {extra}" + -- Sort only for reproducible diagnostics and dependency output. Coverage was + -- established from complete sets above, independent of declaration order. + required := required.qsort (fun a b => a.toString < b.toString) + let some checksModule := env.getModuleIdx? `Required + | throwError "Required checks must be imported from their own module" + for declName in required do + let some (.thmInfo info) := env.find? declName + | throwError "Missing required theorem {declName}" + let specName := (`Zerocopy.Specs).str declName.getString! + unless env.contains specName do + throwError "Missing inline specification {specName}" + -- Require the canonical theorem's type to name its exact spec. An arbitrary + -- term or independently reproved proposition could bypass the inline claim. + let actualSpec ← AeneasContracts.canonicalSpecName declName + unless actualSpec == specName do + throwError "{declName} must directly name its inline specification {specName}" + unless ← Meta.isDefEq info.type (mkConst specName) do + throwError "{declName} does not prove its inline specification {specName}" + AeneasSpecs.checkSpecContract specName + unless (env.getModuleIdxFor? declName).any proofModules.contains do + throwError "{declName} must be declared in a configured proof module" + let obligationName := (`Zerocopy.Obligations).str declName.getString! + let adequacyName := obligationName.appendAfter "_adequate" + let some (.thmInfo adequacy) := env.find? adequacyName + | throwError "Missing arbitrary-outcome required theorem {adequacyName}" + -- Reconstruct the arbitrary-outcome implication from compiled families. + -- A concrete checked theorem alone would not expose weak posts, accidental + -- divergence allowance, or a decoder/ghost that narrowed the required domain. + let adequacyType ← AeneasContracts.contractAdequacyType specName obligationName + unless ← Meta.isDefEq adequacy.type adequacyType do + throwError "{adequacyName} does not prove its arbitrary-outcome required implication" + unless env.getModuleIdxFor? adequacyName == some checksModule do + throwError "{adequacyName} must be declared in the required checks module" + -- The concrete witness is the separately named required proposition, and + -- must be owned by the check module along with the adequacy theorem. + let witnessName := obligationName.appendAfter "_checked" + let some (.thmInfo witness) := env.find? witnessName + | throwError "Missing independent required theorem {witnessName}" + unless ← Meta.isDefEq witness.type (mkConst obligationName) do + throwError "{witnessName} does not prove its independent required proposition" + unless env.getModuleIdxFor? witnessName == some checksModule do + throwError "{witnessName} must be declared in the required checks module" + let mut graph : Array Json := #[] + for declName in required do + -- Follow helpers in every audited handwritten and model module, including + -- private and generated declarations. Stop at registered callees, whose + -- own edges follow. Canonical proof ownership remains restricted above. + let mut pending := #[declName] + let mut visited : NameHashSet := {} + let mut actual : Array Name := #[] + while !pending.isEmpty do + let current := pending.back! + pending := pending.pop + if visited.contains current then continue + visited := visited.insert current + let some info := env.find? current + | throwError "Missing proof declaration {current}" + let references := info.type.getUsedConstants ++ + ((info.value? (allowOpaque := true)).map (·.getUsedConstants)).getD #[] + for used in references do + if used == declName then continue + if required.contains used then + unless actual.contains used do actual := actual.push used + else if (env.getModuleIdxFor? used).any auditModules.contains then + pending := pending.push used + actual := actual.qsort (fun a b => a.toString < b.toString) + graph := graph.push (Json.mkObj [ + ("theorem", Json.str declName.toString), + ("depends_on", Json.arr (actual.map (Json.str ∘ Name.toString)))]) + logInfo m!"Recorded proof dependencies of {required.size} theorems" + -- Audit generated specs/models and handwritten helpers, not only canonical + -- roots. A helper with an unapproved axiom must fail even if a current proof + -- happens not to use it. privateToUserName keeps private namespace helpers in + -- scope; module ownership catches auxiliaries with compiler-generated names. + let prefixes := #[`Zerocopy, `core.num, `AeneasContracts, `AeneasSpecs, + `ContractTests, `SpecsSyntaxTests, `SupportTests, `ContractAuditTests] + let mut audited := 0 + for (declName, _) in env.constants.toList do + if prefixes.any (·.isPrefixOf (privateToUserName declName)) || + (env.getModuleIdxFor? declName).any auditModules.contains || + env.getModuleIdxFor? declName == some checksModule then + let used ← collectAxioms declName + for ax in used do + -- These are Lean's permitted logical axioms. The only additional + -- allowances are the ABI data inputs whose exact signatures were + -- checked above. They supply no proposition; + -- sorryAx and user-defined proof axioms remain errors. + unless #[`propext, `Classical.choice, `Quot.sound].contains ax || + modelInputs.contains ax do + throwError "{declName} depends on unapproved axiom {ax}" + audited := audited + 1 + IO.FS.writeFile "proof-dependencies.json" (Json.arr graph).pretty + logInfo m!"Checked axiom dependencies of {audited} declarations" diff --git a/verification/aeneas/lean/ContractAuditTests.lean b/verification/aeneas/lean/ContractAuditTests.lean new file mode 100644 index 0000000000..cd2659ee31 --- /dev/null +++ b/verification/aeneas/lean/ContractAuditTests.lean @@ -0,0 +1,113 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import SpecsSyntax +public import RequiredContracts +@[expose] public section + +/-! +These adversarial families test the audits themselves. A family can agree with +a concrete specification yet ignore its outcome, add a premise, or hide the +extracted call in a helper's body or type. Each case below isolates one escape +route and checks its rejection. A wrong arbitrary return separately demonstrates +why narrowing the authored domain cannot imply a broader required promise. +-/ + +open Lean Aeneas Aeneas.Std AeneasSpecs +namespace ContractAuditTests + +def identity (n : Nat) : Result Nat := .ok n + +-- Concrete specialization can be correct even when a family ignores run. +@[aeneas_spec] abbrev ignored : Prop := + ∀ n : Nat, WP.spec (identity n) (fun output => output = n) +def ignored_contract (n : Nat) (_run : Result Nat) : Prop := + WP.spec (identity n) (fun output => output = n) +example : ∀ n : Nat, ignored_contract n (identity n) := by + intro n + exact WP.spec.ret rfl +/-- error: Abstract contract ContractAuditTests.ignored_contract does not use its execution outcome -/ +#guard_msgs in +run_elab AeneasSpecs.checkSpecContract `ContractAuditTests.ignored + +-- The family audit retains every premise of the authored contract. +@[aeneas_spec] abbrev narrowed : Prop := + ∀ n : Nat, WP.spec (identity n) (fun output => output = n) +def narrowed_contract (n : Nat) (run : Result Nat) : Prop := + n = 0 → WP.spec run (fun output => output = n) +/-- error: Abstract contract ContractAuditTests.narrowed_contract changed the specification's premises or postcondition -/ +#guard_msgs in +run_elab AeneasSpecs.checkSpecContract `ContractAuditTests.narrowed + +-- A narrowed authored domain does not imply a broader independent guarantee. +@[aeneas_spec] abbrev conditional : Prop := + ∀ n : Nat, n = 0 → WP.spec (identity n) (fun output => output = n) +def conditional_contract (n : Nat) (run : Result Nat) : Prop := + n = 0 → WP.spec run (fun output => output = n) +def broad_contract (n : Nat) (run : Result Nat) : Prop := + WP.spec run (fun output => output = n) +def broad : Prop := ∀ n : Nat, broad_contract n (identity n) +theorem conditional_proved : conditional := by + intro n _ + exact WP.spec.ret rfl +theorem broad_proved : broad := by + intro n + exact WP.spec.ret rfl +run_elab AeneasSpecs.checkSpecContract `ContractAuditTests.conditional +example : (contract_adequacy% conditional implies broad) = + (∀ n run, conditional_contract n run → broad_contract n run) := rfl +-- This is the exact type required by check_contract, with an explicit witness +-- showing why a concrete proof of identity cannot repair the missing domain. +theorem narrowing_fails_required_domain : + ¬ (contract_adequacy% conditional implies broad) := by + intro adequate + have rejected := adequate 1 (.ok 2) (by intro impossible; contradiction) + simp only [broad_contract, WP.spec_ok] at rejected + contradiction + +-- Independent expected families must also retain the abstract outcome. +def ignoredExpected_contract (n : Nat) (_run : Result Nat) : Prop := + WP.spec (identity n) (fun output => output = n) +def ignoredExpected : Prop := + ∀ n : Nat, ignoredExpected_contract n (identity n) +/-- error: Independent contract ContractAuditTests.ignoredExpected_contract must use its execution outcome -/ +#guard_msgs in +run_elab do + let _ ← AeneasContracts.contractAdequacyType + `ContractAuditTests.conditional `ContractAuditTests.ignoredExpected + +-- Outcome use alone does not protect against an embedded implementation proof. +-- Hide the call behind a helper to exercise the transitive dependency audit. +def hiddenCall (n : Nat) : Result Nat := identity n +def embeddedExpected_contract (n : Nat) (run : Result Nat) : Prop := + WP.spec run (fun output => output = n) ∧ + WP.spec (hiddenCall n) (fun output => output = n) +def embeddedExpected : Prop := + ∀ n : Nat, embeddedExpected_contract n (identity n) +/-- error: Independent contract ContractAuditTests.embeddedExpected_contract depends on its extracted operation -/ +#guard_msgs in +run_elab do + let _ ← AeneasContracts.contractAdequacyType + `ContractAuditTests.conditional `ContractAuditTests.embeddedExpected + +-- An opaque helper can retain the operation only in its unreduced type. Its +-- body is Nat.zero, so checking bodies alone misses this dependency. +opaque typeOnlyHelper : (fun _ : Result Nat => Nat) (identity 0) := Nat.zero + +def typeOnlyExpected_contract (n : Nat) (run : Result Nat) : Prop := + WP.spec run (fun output => output = Nat.add n typeOnlyHelper) +def typeOnlyExpected : Prop := + ∀ n : Nat, typeOnlyExpected_contract n (identity n) +/-- error: Independent contract ContractAuditTests.typeOnlyExpected_contract depends on its extracted operation -/ +#guard_msgs in +run_elab do + let _ ← AeneasContracts.contractAdequacyType + `ContractAuditTests.conditional `ContractAuditTests.typeOnlyExpected + +end ContractAuditTests diff --git a/verification/aeneas/lean/ContractSimps.lean b/verification/aeneas/lean/ContractSimps.lean new file mode 100644 index 0000000000..0fd9e459c2 --- /dev/null +++ b/verification/aeneas/lean/ContractSimps.lean @@ -0,0 +1,27 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Aeneas +public section + +/-! +Required-contract comparisons use a dedicated simplification set. Registering +it in this small module lets imported modules add normalizations before the +checker runs. The lemmas remain ordinary checked theorems; this attribute only +controls which rewrites the comparison's proof search attempts. +-/ + + +namespace AeneasContracts + +/-- Normalizations for independently authored required contracts. This +attribute is initialized before the importing checker registers its lemmas. -/ +register_simp_attr' contractSimpExt contractSimprocExt contract_simps + +end AeneasContracts diff --git a/verification/aeneas/lean/DeriveModels.lean b/verification/aeneas/lean/DeriveModels.lean new file mode 100644 index 0000000000..8a3e51c336 --- /dev/null +++ b/verification/aeneas/lean/DeriveModels.lean @@ -0,0 +1,507 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import ModelPrelude +public import ModelCompletion +public meta import Lean +public section + +/-! +Rust values have an extracted representation and a mathematical interpretation. +This module builds the connection in two stages. A shape declares the decoded +field types using only mathematical child types. A provider later supplies the +actual child decoders and combines their accepted values with the local decoder. +Separating those stages lets ordinary support lemmas mention shapes before full +providers exist. + +The full decoder must visit every field even when the local model discards its +information. For an enum it visits only the selected constructor's payload. The +same constructor traversal produces a kernel-checked decomposition theorem, +which exposes those child witnesses to operation proofs. The registry preserves +nominal ownership across module imports; it supplies explicit providers rather +than allowing later authored instances to select a different interpretation. +-/ + +open Lean Elab Term Meta Aeneas.Std +namespace AeneasSpecs + +/-- The registration records the Rust owner, its authored mathematical type, +and the original generic count. The generated declarations have fixed names +under that owner, so they are derived rather than stored as independent state. -/ +structure ModelBinding where + raw : Name + model : Name + generics : Nat + deriving Inhabited + +meta def ModelBinding.fields (binding : ModelBinding) : Name := binding.raw ++ `Fields +meta def ModelBinding.decoder (binding : ModelBinding) : Name := binding.raw ++ `decode +meta def ModelBinding.provider (binding : ModelBinding) : Name := binding.raw ++ `aeneasModel + +-- A persistent extension carries model registrations into importing modules. +-- Lean merges imported entries before this module's entries, so nominal child +-- shapes and providers can be resolved without relying on declaration text. +private meta initialize modelExtension : + SimplePersistentEnvExtension ModelBinding (NameMap ModelBinding) ← + registerSimplePersistentEnvExtension { + addEntryFn := fun s b => s.insert b.raw b + addImportedFn := fun entries => entries.foldl (fun s es => + es.foldl (fun s b => s.insert b.raw b) s) {} + } + +meta def modelOwnerNames : MetaM (Array Name) := do + return ((modelExtension.getState (← getEnv)).toList.map (·.1)).toArray + +meta def modelBinding (raw : Name) : MetaM (Name × Name × Name × Name × Nat) := do + let some b := (modelExtension.getState (← getEnv)).find? raw + | throwError "Missing mathematical model binding for {raw}" + unless (← getEnv).contains b.provider && (← getEnv).contains b.decoder do + throwError "Missing full decoder or bound provider for {raw}" + return (b.fields, b.model, b.decoder, b.provider, b.generics) + +/-- One dictionary for every original Rust generic, including unused parameters. -/ +meta def withModelDictionaries (params : Array Expr) + (k : Array Expr → TermElabM α) : TermElabM α := do + let rec loop (i : Nat) (dicts : Array Expr) : TermElabM α := do + if h : i < params.size then + let param := params[i] + unless (← whnf (← inferType param)).isSort do + throwError "Only Rust type parameters are supported in model generation" + let classType ← mkAppM ``RustModel #[param] + withLocalDecl (Name.mkSimple s!"_rustModel{i}") .instImplicit classType fun dict => + loop (i + 1) (dicts.push dict) + else k dicts + loop 0 #[] + +/-- These restrictions describe the supported extracted shape. Indexed types +and recursive groups would require additional carrier and decoding semantics; +accepting them through the simple constructor traversal would invent a model. -/ +private meta def nominal (raw : Name) (count : Nat) : TermElabM InductiveVal := do + let info ← getConstInfoInduct raw + unless info.numParams == count && info.numIndices == 0 && + !info.isRec && info.all == [raw] do + throwError "Models require a nonrecursive, nonindexed nominal Rust type: {raw}" + pure info + +/-- Fixed structural selection. No global instance inference selects automatic +model meanings, even when authored ghosts later introduce competing instances. -/ +meta partial def boundModelProvider (raw : Expr) (params dicts : Array Expr) : MetaM Expr := do + rejectEscapingFunctions raw + let raw ← whnf raw + -- A generic's original dictionary fixes its meaning even when no field uses + -- it. Resolve it before structural cases or any authored local instances. + for i in [:params.size] do + if raw == params[i]! then return dicts[i]! + let args := raw.getAppArgs + let head := raw.getAppFn + let app (n : Name) (xs : Array Expr) : MetaM Expr := do + mkAppOptM n (xs.map some) + if head.isConstOf ``UScalar then return ← app ``modelUScalar args + if head.isConstOf ``IScalar then return ← app ``modelIScalar args + if head.isConstOf ``Bool then return mkConst ``modelBool + if head.isConstOf ``Nat then return mkConst ``modelNat + if head.isConstOf ``Int then return mkConst ``modelInt + -- Reduction expands Unit to PUnit.{1}. Compare the carrier itself so that + -- ordinary Rust functions returning () keep their total unit model. + if ← isDefEq raw (mkConst ``Unit) then return mkConst ``modelUnit + if head.isConstOf ``Prod || head.isConstOf ``core.result.Result then + let ma ← boundModelProvider args[0]! params dicts + let mb ← boundModelProvider args[1]! params dicts + return ← app (if head.isConstOf ``Prod then ``modelProd else ``modelRustResult) + (args ++ #[ma, mb]) + if head.isConstOf ``Option || head.isConstOf ``List || head.isConstOf ``Slice then + let ma ← boundModelProvider args[0]! params dicts + let provider := if head.isConstOf ``Option then ``modelOption + else if head.isConstOf ``List then ``modelList else ``modelSlice + return ← app provider (args ++ #[ma]) + if head.isConstOf ``Aeneas.Std.Array then + let ma ← boundModelProvider args[0]! params dicts + return ← app ``modelArray (args ++ #[ma]) + if head.isConstOf ``core.num.nonzero.NonZero then + let word ← whnf args[0]! + if word.getAppFn.isConstOf ``UScalar then + return ← app ``modelNonZeroUScalar #[word.appArg!, args[1]!] + if word.getAppFn.isConstOf ``IScalar then + return ← app ``modelNonZeroIScalar #[word.appArg!, args[1]!] + if let .const n _ := head then + if let some b := (modelExtension.getState (← getEnv)).find? n then + unless (← getEnv).contains b.provider do + throwError "Full model provider is not available for {n}" + let childDicts ← args.mapM fun a => boundModelProvider a params dicts + return ← app b.provider (args ++ childDicts) + -- A universal fallback would admit values without a defined interpretation. + -- Unsupported carriers must fail at model construction, not weaken a proof. + throwError "Unsupported opaque carrier or missing bound model provider: {raw}" + +/-- Reduce associated mathematical types recursively, while leaving values +alone. Nested containers then elaborate against ordinary mathematical carriers, +without asking instance inference to reverse-map a model type to a raw type. -/ +private meta partial def reduceModelTypes (e : Expr) : MetaM Expr := do + let e ← withTransparency .all (whnf e) + if e.isApp then + let mut args := #[] + for arg in e.getAppArgs do + let reducedArg ← if (← isType arg) then reduceModelTypes arg else pure arg + args := args.push reducedArg + return mkAppN e.getAppFn args + return e + +meta def providerModel (raw provider : Expr) : MetaM Expr := do + reduceModelTypes (← mkAppOptM ``RustModel.Model #[some raw, some provider]) + +meta def providerDecode (raw provider value : Expr) : MetaM Expr := + mkAppOptM ``RustModel.decode #[some raw, some provider, some value] + +/-- Shape families depend only on mathematical carrier types. No decoder +provider is required for a nominal child whose shape has already been declared. -/ +private meta partial def shapeType (raw : Expr) (params models : Array Expr) : MetaM Expr := do + rejectEscapingFunctions raw + let raw ← whnf raw + for i in [:params.size] do + if raw == params[i]! then return models[i]! + let args := raw.getAppArgs + let head := raw.getAppFn + if let .const n _ := head then + if let some b := (modelExtension.getState (← getEnv)).find? n then + let children ← args.mapM fun a => shapeType a params models + return ← mkAppOptM b.model (children.map some) + if head.isConstOf ``Prod || head.isConstOf ``core.result.Result then + let children ← args.mapM fun a => shapeType a params models + return mkAppN (← mkConstWithFreshMVarLevels head.constName!) children + if head.isConstOf ``Option || head.isConstOf ``List then + return mkAppN (← mkConstWithFreshMVarLevels head.constName!) + #[← shapeType args[0]! params models] + if head.isConstOf ``Slice then + return ← mkAppM ``SliceValue #[← shapeType args[0]! params models] + if head.isConstOf ``Aeneas.Std.Array then + return ← mkAppM ``ArrayValue #[← shapeType args[0]! params models, args[1]!] + -- Scalar and nonzero carriers have fixed native mathematical types. + providerModel raw (← boundModelProvider raw #[] #[]) + +/-- Shape generation binds one mathematical type per original generic. These +are types rather than RustModel dictionaries: declaring a child shape need not +wait for its decoder or any proofs used to construct that decoder. -/ +private meta def withModelTypes (params : Array Expr) + (k : Array Expr → TermElabM α) : TermElabM α := do + let rec loop (i : Nat) (models : Array Expr) : TermElabM α := do + if h : i < params.size then + let p := params[i] + let pName := (← p.fvarId!.getDecl).userName + let modelName := Name.mkSimple s!"{pName}Model" + withLocalDeclD modelName (← inferType p) fun model => loop (i + 1) (models.push model) + else k models + loop 0 #[] + +private meta def expressionText (e : Expr) : TermElabM String := do + return (← withOptions (fun o => (o.setBool `pp.explicit true).setBool `pp.universes false) + (ppExpr e)).pretty 100000 + +private meta def parameterText (params dicts : Array Expr) : TermElabM String := do + let mut text := "" + for p in params do + text := text ++ s!" ({(← p.fvarId!.getDecl).userName} : {← expressionText (← inferType p)})" + for d in dicts do + text := text ++ s!" [{(← d.fvarId!.getDecl).userName} : {← expressionText (← inferType d)}]" + return text + +private meta def parseCommand (text : String) : Command.CommandElabM Syntax := do + match Parser.runParserCategory (← getEnv) `command text with + | .ok stx => pure stx + | .error error => throwError "Generated mathematical declaration failed to parse: {error}\n{text}" + +/-- Mirror the raw constructor structure with decoded field types. Parsing +ordinary Lean declarations lets Lean enforce the resulting type and constructor +rules rather than installing unchecked expression representations of a shape. -/ +private meta def generateShape (raw : Name) (count : Nat) : Command.CommandElabM Unit := do + let text ← Command.liftTermElabM do + let info ← nominal raw count + forallTelescope info.type fun params _ => + withModelTypes params fun models => do + let binders ← parameterText models #[] + let shapeName := "Fields" + if let some structureInfo := getStructureInfo? (← getEnv) raw then + let ctorTy := mkAppN (mkConst info.ctors.head! (info.levelParams.map Level.param)) params + forallTelescope (← inferType ctorTy) fun fields _ => do + let mut text := s!"structure {shapeName}{binders} where\n" + for i in [:fields.size] do + let ty ← shapeType (← inferType fields[i]!) params models + text := text ++ s!" {structureInfo.fieldNames[i]!.getString!} : {← expressionText ty}\n" + return text + else + let mut text := s!"inductive {shapeName}{binders} where\n" + for ctor in info.ctors do + let ctorTy := mkAppN (mkConst ctor (info.levelParams.map Level.param)) params + let fieldsText ← forallTelescope (← inferType ctorTy) fun fields _ => do + let mut fieldsText := "" + for field in fields do + let ty ← shapeType (← inferType field) params models + fieldsText := fieldsText ++ s!" → {← expressionText ty}" + return fieldsText + -- Print actual user names, never internal free-variable identifiers. + let application ← models.foldlM (fun s e => do + pure (s ++ " " ++ (← e.fvarId!.getDecl).userName.toString)) "" + let fieldPrefix := if fieldsText.isEmpty then "" else fieldsText.drop 3 |>.toString + let arrow := if fieldsText.isEmpty then "" else " → " + text := text ++ s!"| {ctor.getString!} : {fieldPrefix}{arrow}@{shapeName}{application}\n" + return text + Command.withScope (fun scope => {scope with currNamespace := raw}) do + Command.elabCommand (← parseCommand text) + +syntax (name := modelStructure) "model " ident " where" Parser.Command.structFields : command +syntax (name := modelAlias) "model " ident " := " term : command +syntax "aeneas_model_shape " ident " with " num " type " "parameters" " begin " + command " end" : command + +private meta def registerShape (raw mathName : Name) (count : Nat) : Command.CommandElabM Unit := do + -- One raw owner has one registered interpretation. A second shape must not + -- silently replace the one already used by imported contracts or providers. + if (modelExtension.getState (← getEnv)).contains raw then + throwError "Duplicate mathematical model shape for {raw}" + modifyEnv (modelExtension.addEntry · { + raw, «model» := mathName, generics := count }) + +elab "derive_model_shape " rawId:ident " with " count:num " type " "parameters" : command => do + let raw ← Command.liftTermElabM (realizeGlobalConstNoOverloadWithInfo rawId) + generateShape raw count.getNat + registerShape raw (raw ++ `Fields) count.getNat + +elab_rules : command + | `(aeneas_model_shape $rawId:ident with $count:num type parameters begin $declaration:command end) => do + let raw ← Command.liftTermElabM (realizeGlobalConstNoOverloadWithInfo rawId) + generateShape raw count.getNat + let id : TSyntax `ident := ⟨declaration.raw[1]⟩ + let suffix ← if declaration.raw.getKind == ``modelStructure then do + let fileMap ← getFileMap + let column := declaration.raw[3].getPos?.map (fun p => (fileMap.toPosition p).column) |>.getD 2 + pure (" where\n" ++ String.ofList (List.replicate column ' ') ++ declaration.raw[3].reprint.getD "") + else if declaration.raw.getKind == ``modelAlias then + pure (" := " ++ declaration.raw[3].reprint.getD "") + else throwError "A type fence must contain exactly one model shape declaration" + -- Fields belongs to the generated structural carrier. An authored model + -- may alias it, but cannot overwrite the name and change field admission. + if id.getId == `Fields then throwError "Fields is the generated nominal field carrier" + let (binders, application, levelNames) ← Command.liftTermElabM do + let info ← nominal raw count.getNat + forallTelescope info.type fun params _ => withModelTypes params fun models => do + let binders ← parameterText models #[] + let application ← models.foldlM (fun s e => do + pure (s ++ " " ++ (← e.fvarId!.getDecl).userName.toString)) "" + return (binders, application, info.levelParams) + let suffix := if declaration.raw.getKind == ``modelAlias && declaration.raw[3].isIdent && + declaration.raw[3].getId == `Fields then " := @Fields" ++ application else suffix + let authored := raw ++ id.getId + let keyword := if declaration.raw.getKind == ``modelStructure then "@[ext] structure" else "abbrev" + Command.withScope (fun scope => + {scope with + currNamespace := raw + levelNames := levelNames ++ scope.levelNames + opts := scope.opts.setBool `autoImplicit false}) do + Command.elabCommand (← parseCommand s!"{keyword} {id.getId}{binders}{suffix}") + registerShape raw authored count.getNat + +/-- Finish elaboration and install a kernel-checked definition. Its safe status +forbids unsafe dependencies; `forceExpose` lets importing modules reduce it. +Proof terms nested in the value also need exported names, even when the decoder +is noncomputable and used only to state mathematical contracts. -/ +private meta def addDefinition (declName : Name) (levels : List Name) (value : Expr) : TermElabM Unit := do + synthesizeSyntheticMVarsNoPostponing + let value ← instantiateMVars value + -- Match native declaration assembly: nested proof auxiliaries must remain + -- available when downstream modules unfold the exposed decoder. + let value ← withExporting do + withDeclNameForAuxNaming declName do + abstractNestedProofs value + addDecl (.defnDecl { + «name» := declName, levelParams := levels, «type» := (← inferType value), value, + hints := .abbrev, safety := .safe }) (forceExpose := true) + -- Authored decoders may be logical noncomputable terms. The safe declaration + -- is kernel checked and exposed for subsequent associated-type reduction; + -- contracts do not require executable code for mathematical decoding. + modifyEnv (addNoncomputable · declName) + enableRealizationsForConst declName + +/-- Assemble a case expression using the original nominal constructors. +A structure has one case whose fields are projections. An enum has one branch +per constructor, and that branch receives only the selected payload. Both the +full decoder and its decomposition theorem use this traversal so they cannot +silently disagree about field order or constructor correspondence. -/ +private meta def nominalCases (raw fieldsName : Name) (info : InductiveVal) + (params : Array Expr) (rawValue resultType : Expr) + (branch : Array Expr → Name → TermElabM Expr) : TermElabM Expr := do + let levels := info.levelParams.map Level.param + if let some structureInfo := getStructureInfo? (← getEnv) raw then + branch (structureInfo.fieldNames.mapIdx fun i _ => mkProj raw i rawValue) + ((← getConstInfoInduct fieldsName).ctors.head!) + else + let mut branches := #[] + for ctor in info.ctors do + let ctorType ← inferType (mkAppN (mkConst ctor levels) params) + let body ← forallTelescope ctorType fun fields _ => do + mkLambdaFVars fields (← branch fields (fieldsName ++ Name.mkSimple ctor.getString!)) + branches := branches.push body + -- casesOn needs a motive: the result type for each possible raw value. + -- These decoders are not indexed, so the motive is constant in that value. + let motive ← mkLambdaFVars #[rawValue] resultType + pure (mkAppN (mkConst (mkCasesOnName raw) ((← getLevel resultType) :: levels)) + (params ++ #[motive, rawValue] ++ branches)) + +/-- Build the local decoder, wrap it with recursive field admission, prove +that wrapper's decomposition, and finally register the complete RustModel. +An absent local decoder is structural identity. An infallible authored decoder +returns a model; a fallible one returns Option model and can restrict admission. -/ +private meta def deriveProvider (raw : Name) (count : Nat) + (localDecoder : Option (Name × Syntax × Bool)) : TermElabM Unit := do + let info ← nominal raw count + let some b := (modelExtension.getState (← getEnv)).find? raw + | throwError "Missing Fields/model shape for {raw}" + if (← getEnv).contains b.provider then throwError "Duplicate model provider for {raw}" + let levels := info.levelParams.map Level.param + forallTelescope info.type fun params _ => withModelDictionaries params fun dicts => do + let all := params ++ dicts + let rawTy := mkAppN (mkConst raw levels) params + let models ← params.zip dicts |>.mapM fun (p, d) => providerModel p d + let fieldsTy ← mkAppOptM b.fields (models.map some) + let modelTy ← mkAppOptM b.model (models.map some) + let outputTy ← mkAppM ``Option #[modelTy] + let localFn ← withLocalDeclD (localDecoder.map (·.1) |>.getD `self) fieldsTy fun fields => do + let body ← match localDecoder with + | none => mkAppM ``Option.some #[fields] + | some (_, term, fallible) => do + let completed ← withRef term `(model_value $(⟨term⟩)) + let value ← elabTermEnsuringType completed (if fallible then outputTy else modelTy) + if fallible then pure value else mkAppM ``Option.some #[value] + mkLambdaFVars #[fields] body + addDefinition (raw ++ `decodeFields) info.levelParams (← mkLambdaFVars all localFn) + let localConst := mkAppN (mkConst (raw ++ `decodeFields) levels) all + let full ← withLocalDeclD `raw rawTy fun rawValue => do + -- Bind each child decoder before constructing Fields. Option.bind stops + -- on rejection; successful traversal supplies all decoded values in the + -- original constructor order, including values the local decoder forgets. + let decodeBranch (fields : Array Expr) (constructor : Name) : TermElabM Expr := do + let rec loop (i : Nat) (decoded : Array Expr) : TermElabM Expr := do + if h : i < fields.size then + let field := fields[i] + let fieldTy ← inferType field + let provider ← boundModelProvider fieldTy params dicts + let valueTy ← providerModel fieldTy provider + let decodedRaw ← providerDecode fieldTy provider field + withLocalDeclD (Name.mkSimple s!"field{i}") valueTy fun value => do + let rest ← loop (i+1) (decoded.push value) + let continuation ← mkLambdaFVars #[value] rest + mkAppM ``Option.bind #[decodedRaw, continuation] + else pure (mkApp localConst (mkAppN (mkConst constructor levels) (models ++ decoded))) + loop 0 #[] + let body ← nominalCases raw b.fields info params rawValue outputTy decodeBranch + mkLambdaFVars #[rawValue] body + addDefinition b.decoder info.levelParams (← mkLambdaFVars all full) + -- Keep child witnesses even when decodeFields intentionally forgets them. + -- This proposition follows the actual fixed providers and constructor path. + let decomposition ← withLocalDeclD `raw rawTy fun rawValue => + withLocalDeclD `model modelTy fun modelValue => do + let someModel ← mkAppM ``Option.some #[modelValue] + let branch (fields : Array Expr) (constructor : Name) : TermElabM Expr := do + -- This is the proposition corresponding to the Option.bind chain: + -- each child has a witness and its decoder equation, followed by the + -- local decoder equation. Keeping witnesses prevents lossy models + -- from erasing the parent's recursive admission obligations. + let rec decomposeLoop (i : Nat) (decoded : Array Expr) : TermElabM Expr := do + if h : i < fields.size then + let field := fields[i] + let fieldTy ← inferType field + let provider ← boundModelProvider fieldTy params dicts + let valueTy ← providerModel fieldTy provider + let decodedRaw ← providerDecode fieldTy provider field + withLocalDeclD (Name.mkSimple s!"field{i}") valueTy fun value => do + let equation ← mkEq decodedRaw (← mkAppM ``Option.some #[value]) + let rest ← decomposeLoop (i + 1) (decoded.push value) + let body ← mkAppM ``And #[equation, rest] + mkAppM ``Exists #[← mkLambdaFVars #[value] body] + else + let fields ← mkAppOptM constructor ((models ++ decoded).map some) + mkEq (mkApp localConst fields) someModel + decomposeLoop 0 #[] + let rhs ← nominalCases raw b.fields info params rawValue (mkSort .zero) branch + let lhs ← mkEq (mkApp (mkAppN (mkConst b.decoder levels) all) rawValue) someModel + let proposition ← mkAppM ``Iff #[lhs, rhs] + -- The bridge is proved by ordinary case splitting and bind expansion. + -- It is not an axiom asserted merely because generation succeeded. + let proofSyntax ← match Parser.runParserCategory (← getEnv) `term + s!"by cases raw <;> simp only [{b.decoder}, Option.bind_eq_some_iff]" with + | .ok stx => pure stx + | .error error => throwError "Generated decoder decomposition failed to parse: {error}" + let proof ← withOptions (fun o => o.setBool `linter.unusedSimpArgs false) + (elabTermEnsuringType proofSyntax proposition) + mkLambdaFVars (all ++ #[rawValue, modelValue]) proof + synthesizeSyntheticMVarsNoPostponing + let decomposition ← instantiateMVars decomposition + addDecl (.thmDecl { + «name» := raw ++ `decode_decompose, levelParams := info.levelParams, + «type» := (← inferType decomposition), value := decomposition }) (forceExpose := true) + let provider ← mkAppOptM ``RustModel.mk #[some rawTy, some modelTy, + some (mkAppN (mkConst b.decoder levels) all)] + addDefinition b.provider info.levelParams (← mkLambdaFVars all provider) + setReducibilityStatus b.provider .reducible + addInstance b.provider .global 1000 + +elab "derive_rust_model " rawId:ident " with " count:num " type " "parameters" : command => + Command.liftTermElabM do + deriveProvider (← realizeGlobalConstNoOverloadWithInfo rawId) count.getNat none + +elab "derive_rust_model " rawId:ident " with " count:num " type " "parameters" + " decode " self:ident " => " body:term : command => Command.liftTermElabM do + deriveProvider (← realizeGlobalConstNoOverloadWithInfo rawId) count.getNat + (some (self.getId, body, false)) + +elab "derive_rust_model " rawId:ident " with " count:num " type " "parameters" + " decode? " self:ident " => " body:term : command => Command.liftTermElabM do + deriveProvider (← realizeGlobalConstNoOverloadWithInfo rawId) count.getNat + (some (self.getId, body, true)) + +/-- Audit the elaborated provider against its registered raw owner, mathematical +type, full decoder, and generic count. Definitional equality unfolds actual +expressions, so matching names alone cannot attach a provider to a wrong type. -/ +elab "check_model_binding " rawId:ident " with " count:num " type " "parameters" : command => + Command.liftTermElabM do + let raw ← realizeGlobalConstNoOverloadWithInfo rawId + let (fields, mathName, decoder, provider, generics) ← modelBinding raw + unless generics == count.getNat do throwError "Wrong generic count for {raw}" + let info ← nominal raw count.getNat + forallTelescope info.type fun params _ => withModelDictionaries params fun dicts => do + let all := params ++ dicts + let rawTy := mkAppN (mkConst raw (info.levelParams.map Level.param)) params + let providerExpr := mkAppN (mkConst provider (info.levelParams.map Level.param)) all + unless (← isDefEq (← inferType providerExpr) (← mkAppM ``RustModel #[rawTy])) do + throwError "Provider is attached to the wrong raw carrier: {raw}" + unless (← withTransparency .all (isDefEq (← providerModel rawTy providerExpr) + (← mkAppOptM mathName ((← params.zip dicts |>.mapM fun (p, d) => providerModel p d).map some)))) do + throwError "Provider model is not the bound mathematical type: {raw}" + withLocalDeclD `raw rawTy fun value => do + unless (← withTransparency .all (isDefEq (← providerDecode rawTy providerExpr value) + (mkAppN (mkConst decoder (info.levelParams.map Level.param)) (all.push value)))) do + throwError "Provider decoder is not the bound full decoder: {raw}" + unless (← getEnv).contains fields do throwError "Missing Fields carrier: {raw}" + +/-- Check every original input and the successful output before a function's +specification is authored. Unsupported borrowed continuations or missing models +must fail even if a later clause would ignore the affected value. -/ +elab "check_model_inputs " functionId:ident " with " count:num " type " "parameters" : command => + Command.liftTermElabM do + let function ← realizeGlobalConstNoOverloadWithInfo functionId + forallTelescope (← getConstInfo function).type fun inputs result => do + unless count.getNat ≤ inputs.size do throwError "Invalid Rust generic prefix count" + let params := inputs.extract 0 count.getNat + withModelDictionaries params fun dicts => do + for input in inputs.extract count.getNat inputs.size do + let _ ← boundModelProvider (← inferType input) params dicts + let result ← whnf result + unless result.isAppOfArity ``Aeneas.Std.Result 1 do + throwError "Unsupported result or escaping borrow in {function}" + let _ ← boundModelProvider result.appArg! params dicts + +end AeneasSpecs diff --git a/verification/aeneas/lean/InspectSpec.lean b/verification/aeneas/lean/InspectSpec.lean new file mode 100644 index 0000000000..5eca283256 --- /dev/null +++ b/verification/aeneas/lean/InspectSpec.lean @@ -0,0 +1,79 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +import Specs +open Lean Elab Command Meta AeneasSpecs Aeneas.Std + +/-! +`inspect_spec` explains a compiled specification: its original raw call, +selected providers, decoded input premises, result admission, and postconditions. +It reads the elaborated Lean expressions used by the checker. It does not +reconstruct a contract from annotation text or run a new verification build. +The shape checks below keep the report tied to the generated decoded-result +form, so inspection fails if that form no longer supports the reported meaning. +-/ + + +private meta def inspectionExpr (e : Expr) : MetaM Format := + withOptions (fun o => o.setBool `pp.proofs true) (ppExpr e) + +/-- Inspect compiled expressions, keeping the same original-call audit as Check. +No syntax expansion or independent specification interpretation is involved. -/ +elab "inspect_spec " candidate:ident : command => liftTermElabM do + let specName ← Elab.realizeGlobalConstNoOverloadWithInfo candidate + let (modelName, originalCount) ← specBinding specName + let info ← getConstInfo specName + let some proposition := info.value? | throwError "Missing specification value {specName}" + logInfo m!"Elaborated proposition {specName}:\n{← inspectionExpr proposition}" + forallTelescope proposition fun inputs body => do + let args := body.consumeMData.getAppArgs + let call := args[1]! + logInfo m!"Original target: {modelName}\nOriginal raw call: {← withOptions (fun o => (o.setBool `pp.explicit true).setBool `pp.fullNames true) (inspectionExpr call)}" + let mode := if body.consumeMData.getAppFn.isConstOf ``WP.spec then "total (WP.spec)" + else "partial (WP.dspec)" + logInfo m!"Execution: {mode}" + for i in [:inputs.size] do + let input := inputs[i]! + let decl ← input.fvarId!.getDecl + let ty := decl.type.consumeMData + -- Binder position identifies original raw/type arguments. Later shapes + -- are classified for reporting only; this is not a second domain checker. + if i < originalCount then + logInfo m!"Original binder {decl.userName}: {← inspectionExpr ty}" + else if ty.isAppOfArity ``RustModel 1 then + logInfo m!"Provider binder (original dictionary or ghost) {decl.userName}: {← inspectionExpr ty}" + else if ty.isAppOfArity ``Eq 3 && ty.getAppArgs[1]!.isAppOfArity ``RustModel.decode 3 then + let decoding := ty.getAppArgs[1]!.getAppArgs + logInfo m!"Input domain {decl.userName}: {← inspectionExpr ty}\nSelected provider: {← withOptions (fun o => (o.setBool `pp.explicit true).setBool `pp.fullNames true) (inspectionExpr decoding[1]!)}" + else if ← isProp ty then + logInfo m!"Premise or proposition-valued ghost {decl.userName}: {← inspectionExpr ty}" + else + logInfo m!"Mathematical witness or ghost {decl.userName}: {← inspectionExpr ty}" + let post := args[2]! + -- Unpack exactly one raw output, one existential model witness, and its + -- decoder equation. Check variable identities so a report cannot call a + -- different result's decoding the admission condition for this return. + lambdaTelescope post fun resultInputs resultBody => do + unless resultInputs.size == 1 && resultBody.isAppOfArity ``Exists 2 do + throwError "Specification {specName} lacks the expected decoded-result existential" + lambdaTelescope resultBody.getAppArgs[1]! fun witnesses conjunction => do + unless witnesses.size == 1 && conjunction.isAppOfArity ``And 2 do + throwError "Specification {specName} lacks the expected result decoding conjunction" + let equation := conjunction.getAppArgs[0]! + unless equation.isAppOfArity ``Eq 3 && + equation.getAppArgs[1]!.isAppOfArity ``RustModel.decode 3 do + throwError "Specification {specName} lacks an explicit result decoder equation" + let decoding := equation.getAppArgs[1]!.getAppArgs + let accepted := equation.getAppArgs[2]! + unless decoding[2]!.consumeMData == resultInputs[0]! && + accepted.isAppOfArity ``Option.some 2 && + accepted.getAppArgs[1]!.consumeMData == witnesses[0]! do + throwError "Specification {specName} does not require successful decoding of its original result" + logInfo m!"Result domain: {← inspectionExpr (← inferType witnesses[0]!)}\nSelected result provider: {← withOptions (fun o => (o.setBool `pp.explicit true).setBool `pp.fullNames true) (inspectionExpr decoding[1]!)}\nRequired result decoding: {← inspectionExpr equation}\nPostconditions: {← inspectionExpr conjunction.getAppArgs[1]!}" + let axioms ← collectAxioms specName + logInfo m!"Compiled axiom dependencies: {axioms}" diff --git a/verification/aeneas/lean/MathViews.lean b/verification/aeneas/lean/MathViews.lean new file mode 100644 index 0000000000..c8d6ed7771 --- /dev/null +++ b/verification/aeneas/lean/MathViews.lean @@ -0,0 +1,96 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Models +public import ModelLemmas +public import SpecPrelude +public import ContractSimps +@[expose] public section + +/-! +Contracts automatically require values to decode. Raw implementation proofs +instead use concrete conditions such as a nonzero alignment word. This module +proves that the actual decoders admit exactly those independently stated raw +domains, and supplies equations for normalizing retained provider +projections. + +The predicates below are observations used by proofs, not competing validity +instances. The selected RustModel decoder remains authoritative for +admission. The different theorem spellings match expressions produced at +different stages: isValid, a provider's decode projection, or the owner's +named decoder. Each is proved from the same decoder; none chooses an +interpretation from a model type. +-/ +open Aeneas Aeneas.Std AeneasSpecs +namespace Zerocopy.Proofs +set_option linter.unusedSimpArgs false + +-- Raw observations remain available independently of function contracts. +-- They record the full positive encoding domain, including arbitrary stored +-- words, and do not register a separately selectable validity interpretation. +@[contract_simps] theorem admission_eq {Raw : Type u} (provider : RustModel Raw) + (raw : Raw) : (∃ value, provider.decode raw = some value) = + @isValid Raw provider raw := rfl + +-- Eliminating an unused mathematical witness is ordinary existential logic. +-- These lemmas apply only when the authored predicate does not depend on it. +@[contract_simps] theorem option_decoded_pre_iff {M : Type u} (d : Option M) (P : Prop) : + (∀ m, d = some m → P) ↔ ((∃ m, d = some m) → P) := by + constructor + · rintro h ⟨m, hm⟩; exact h m hm + · intro h m hm; exact h ⟨m, hm⟩ + +@[contract_simps] theorem option_decoded_post_iff {M : Type u} (d : Option M) (P : Prop) : + (∃ m, d = some m ∧ P) ↔ (∃ m, d = some m) ∧ P := by + constructor + · rintro ⟨m, hm, hp⟩; exact ⟨⟨m, hm⟩, hp⟩ + · rintro ⟨⟨m, hm⟩, hp⟩; exact ⟨m, hm, hp⟩ + +@[simp, contract_simps] theorem scalar_valid_iff (x : UScalar ty) : isValid x ↔ True := by + simp [isValid, RustModel.decode, modelUScalar] + +@[simp, contract_simps] theorem nonzero_valid_iff (a : NonZeroUsize) : + isValid a ↔ 0 < a.val.val := by + unfold isValid + constructor + · rintro ⟨m, hm⟩ + have he := (decodeNonZeroUScalar_iff a m).mp hm + rw [he]; exact m.positive + · intro hp + exact ⟨⟨unsignedWord a.val, hp⟩, (decodeNonZeroUScalar_iff a ⟨unsignedWord a.val, hp⟩).mpr rfl⟩ + +@[simp, contract_simps] theorem bool_valid_iff (x : Bool) : isValid x ↔ True := by + simp [isValid, RustModel.decode, modelBool] + +@[contract_simps] theorem scalar_admitted_iff (x : UScalar ty) : + (∃ value, RustModel.decode x = some value) ↔ True := scalar_valid_iff x + +@[contract_simps] theorem nonzero_admitted_iff (x : NonZeroUsize) : + (∃ value, RustModel.decode x = some value) ↔ 0 < x.val.val := nonzero_valid_iff x + +@[contract_simps] theorem bool_admitted_iff (x : Bool) : + (∃ value, RustModel.decode x = some value) ↔ True := bool_valid_iff x + +@[contract_simps] theorem option_admitted_iff {α : Type} [RustModel α] (x : Option α) : + (∃ value : Option (ModelOf α), RustModel.decode x = some value) ↔ + ∀ v ∈ x, isValid v := option_valid_iff x + +@[contract_simps] theorem slice_admitted_iff {α : Type} [RustModel α] (x : Slice α) : + (∃ value, RustModel.decode x = some value) ↔ ∀ v ∈ x.val, isValid v := slice_valid_iff x + +-- A named decoder can also occur after a surrounding structural traversal +-- exposes the retained dictionary. These are the same admission equivalences. +@[contract_simps] theorem normalized_nat_pos_iff (n : Nat) : + Nat.le (Nat.succ 0) n ↔ 0 < n := Nat.succ_le_iff + +attribute [contract_simps] option_valid_iff prod_valid_iff result_valid_iff + +attribute [contract_simps] and_true true_and and_self true_implies forall_true_iff + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/ModelCompletion.lean b/verification/aeneas/lean/ModelCompletion.lean new file mode 100644 index 0000000000..b833266a2d --- /dev/null +++ b/verification/aeneas/lean/ModelCompletion.lean @@ -0,0 +1,86 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import ModelPrelude +public meta import Lean +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +/-! +Mathematical model records may contain proofs, such as a bound on a word or a +power-of-two constraint. `model_value` lets native record syntax omit those +proof fields and runs ordinary Lean proof search at the construction site. +Lean still checks the resulting proof terms. + +Completion cannot choose missing data or turn an unproved constraint into +decoder rejection. In particular, a failed proof inside `decode?` is a compiler +error; it is not another branch returning `none`. The search has a bounded +heartbeat budget so an omitted proof cannot consume unlimited elaboration work. +-/ + +open Lean Elab Tactic Aeneas.Std +namespace AeneasSpecs + +/-- An exposed theorem avoids requiring clients to unfold the predicate's +private defining module while constructing ordinary mathematical values. -/ +theorem model_power_of_two (n : Nat) : (2 ^ n).isPowerOfTwo := ⟨n, rfl⟩ + +/-- Standard logarithm bounds, in a form usable by native arithmetic automation. -/ +theorem model_log2_bounds (n : Nat) (positive : 0 < n) : + 2 ^ Nat.log2 n ≤ n ∧ n < 2 * 2 ^ Nat.log2 n := by + have lower := Nat.log2_self_le (n := n) (by omega) + have upper := Nat.lt_log2_self (n := n) + rw [Nat.pow_succ] at upper + omega + +grind_pattern model_log2_bounds => Nat.log2 n + +/-- Complete omitted proofs at a construction site. Data goals are never solved +by this tactic; ordinary elaboration and unification may already infer data. -/ +elab "complete_model_proofs" : tactic => withCurrHeartbeats do + let options ← getOptions + let configured := maxHeartbeats.get options + -- Lean uses zero for an unlimited budget. Treat it as the local ceiling + -- here; a smaller caller budget still wins, including deterministic tests. + let budget := if configured == 0 then 100000 else min configured 100000 + withOptions (fun o => maxHeartbeats.set o budget) do + -- A field whose type is Prop can be data (for example, `claim : Prop`). + -- isProp checks whether this goal asks for a proof, not whether its target + -- is a sort. Unresolved target types are therefore not guessed into proofs. + for goal in (← getGoals) do + goal.withContext do + unless (← Meta.isProp (← goal.getType)) do + Meta.throwTacticEx `complete_model_proofs goal + "Supply the omitted data value; automatic model completion only proves propositions" + -- Each branch constructs a proof term using assumptions or checked lemmas. + -- first tries the inexpensive cases before arithmetic and bounded grind. + try + evalTactic (← `(tactic| all_goals first + | assumption + | exact model_power_of_two _ + | exact ⟨_, rfl⟩ + | omega + | grind only [NonZeroUnsignedWord.positive, UnsignedWord.bound, + model_log2_bounds, UScalar.max, Usize.max, Usize.numBits, + UScalarTy.Usize_numBits_eq])) + catch error => + -- An automation failure is a compilation failure, including in decode?. + -- Preserve interruption/heartbeat errors and their original diagnostics. + if error.isInterrupt || error.isMaxHeartbeat then throw error + Meta.throwTacticEx `complete_model_proofs (← getMainGoal) + m!"Could not prove the omitted model constraint. Supply an explicit field proof. +{error.toMessageData}" + +/-- The explicit entry point outside a decoder; decoder bodies use the same rule. -/ +macro "model_value " value:term : term => + `(by + refine' $value + complete_model_proofs) + +end AeneasSpecs diff --git a/verification/aeneas/lean/ModelCompletionTests.lean b/verification/aeneas/lean/ModelCompletionTests.lean new file mode 100644 index 0000000000..f9b7a72cb4 --- /dev/null +++ b/verification/aeneas/lean/ModelCompletionTests.lean @@ -0,0 +1,135 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import DeriveModels +@[expose] public section + +/-! +These fixtures exercise native proof-field completion with explicit data, +inferred data, nested records, and fallible decoders. The negative cases check +that missing data, an impossible proof, an unresolved target type, and exhausted +proof search remain compilation failures. In particular, proof-search failure +must never become a convenient `none` that silently narrows decoder admission. +-/ + +open Lean Aeneas Aeneas.Std AeneasSpecs +namespace ModelCompletionTests + +structure Pair where + first : Nat + second : Nat + ordered : first ≤ second + +example (n : Nat) : Pair := model_value { first := n, second := n + 1, .. } +example (n : Nat) : Pair := + model_value (by exact { first := n, second := n, ordered := Nat.le_refl n }) +example (n : Nat) : Pair := + model_value { first := n, second := n, ordered := by omega } +example (n : Nat) : Option Pair := + model_value (if h : n < 4 then some { first := n, second := 4, .. } + else some { first := 4, second := n, .. }) + +structure Nested where + pair : Pair + positive : 0 < pair.second +example (n : Nat) : Nested := + model_value { pair := { first := n, second := n + 1, .. }, .. } + +structure Encoding where + word : core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner + +aeneas_model_shape Encoding with 0 type parameters begin + model Value where + align : Nat + phase : Nat + pow2 : align.isPowerOfTwo + phase_lt : phase < align + fits : align + phase ≤ Usize.max +end +derive_rust_model Encoding with 0 type parameters decode self => + let word := self.word.value + let align := 2 ^ Nat.log2 word + { align := align, phase := word - align, .. } +check_model_binding Encoding with 0 type parameters + +-- Completion does not admit invalid children of an infallible local decoder. +example : Encoding.decode ⟨⟨0#usize⟩⟩ = none := rfl +example : (Encoding.decode ⟨⟨13#usize⟩⟩).map (fun r => (r.align, r.phase)) = + some (8, 5) := by decide + +structure Fallible where + word : core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner + +aeneas_model_shape Fallible with 0 type parameters begin + model Value where + value : Nat + positive : 0 < value +end +derive_rust_model Fallible with 0 type parameters decode? self => + if h : 1 < self.word.value then some { value := self.word.value - 1, .. } + else none +check_model_binding Fallible with 0 type parameters +example : Fallible.decode ⟨⟨1#usize⟩⟩ = none := rfl +example : (Fallible.decode ⟨⟨3#usize⟩⟩).map (·.value) = some 2 := rfl + +structure Impossible where + value : Nat +aeneas_model_shape Impossible with 0 type parameters begin + model Value where + value : Nat + impossible : False +end + +structure PropositionData where + claim : Prop + +syntax (name := discardedCompletionFixture) "discard_completion_fixture " command : command +@[command_elab discardedCompletionFixture] meta def discardCompletionFixture : + Lean.Elab.Command.CommandElab := fun stx => + Lean.withoutModifyingEnv (Lean.Elab.Command.elabCommand stx[1]) + +-- These check the intended failure reason without fixing incidental pretty printing. +/-- Supply the omitted data value; automatic model completion only proves propositions -/ +#guard_msgs (substring := true) in +discard_completion_fixture example : Pair := model_value { second := 1, .. } + +/-- Supply the omitted data value; automatic model completion only proves propositions -/ +#guard_msgs (substring := true) in +discard_completion_fixture example : PropositionData := model_value { .. } + +-- A false constraint never silently narrows decode? by turning failure into none. +/-- Could not prove the omitted model constraint. Supply an explicit field proof. -/ +#guard_msgs (substring := true) in +discard_completion_fixture derive_rust_model Impossible with 0 type parameters decode? self => + some { value := self.value, .. } + +-- An unresolved target type must not be guessed to be a proposition. +elab "unresolved_completion_goal" : tactic => do + let unknownSort ← Lean.Meta.mkFreshLevelMVar + let unknownType ← Lean.Meta.mkFreshExprMVar (some (mkSort unknownSort)) + let unknownGoal ← Lean.Meta.mkFreshExprMVar (some unknownType) + Lean.Elab.Tactic.setGoals [unknownGoal.mvarId!] + +/-- Supply the omitted data value; automatic model completion only proves propositions -/ +#guard_msgs (substring := true) in +discard_completion_fixture example : True := by + unresolved_completion_goal + complete_model_proofs + +-- Exhaustion remains a native compilation failure, including for a valid model. +/-- deterministic timeout -/ +#guard_msgs (substring := true) in +discard_completion_fixture example (word : NonZeroUsizeValue) : Encoding.Value := + model_value (by + let align := 2 ^ Nat.log2 word.value + refine' { align := align, phase := word.value - align, .. } + set_option maxHeartbeats 1 in + complete_model_proofs) + +end ModelCompletionTests diff --git a/verification/aeneas/lean/ModelExportProducer.lean b/verification/aeneas/lean/ModelExportProducer.lean new file mode 100644 index 0000000000..279c2ec91f --- /dev/null +++ b/verification/aeneas/lean/ModelExportProducer.lean @@ -0,0 +1,65 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import DeriveModels +@[expose] public section + +/-! +This module produces decoded models whose records contain automatically proved +constraints. A separate consumer module unfolds these definitions and uses the +generated decomposition theorems. Splitting producer from consumer catches +export bugs that a same-module proof could miss, particularly private proof +auxiliaries embedded in an exposed decoder. +-/ + +open AeneasSpecs Aeneas.Std +namespace ModelExportRegression +structure Raw where + word : Usize +aeneas_model_shape Raw with 0 type parameters begin + model Value where + word : Nat + fits : word ≤ Usize.max +end +derive_rust_model Raw with 0 type parameters decode self => + let word := self.word.value + { word := word, .. } +check_model_binding Raw with 0 type parameters + +structure Encoding where + word : core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner +aeneas_model_shape Encoding with 0 type parameters begin + model Value where + align : Nat + phase : Nat + pow2 : align.isPowerOfTwo + phase_lt : phase < align + fits : align + phase ≤ Usize.max +end +derive_rust_model Encoding with 0 type parameters decode self => + let word := self.word.value + let align := 2 ^ Nat.log2 word + { align := align, phase := word - align, .. } +check_model_binding Encoding with 0 type parameters + +structure Fallible where + word : Usize +aeneas_model_shape Fallible with 0 type parameters begin + model Value where + word : Nat + positive : 0 < word + fits : word ≤ Usize.max +end +derive_rust_model Fallible with 0 type parameters decode? self => + if h : 1 < self.word.value then + some { word := self.word.value - 1, .. } + else none +check_model_binding Fallible with 0 type parameters + +end ModelExportRegression diff --git a/verification/aeneas/lean/ModelExportTests.lean b/verification/aeneas/lean/ModelExportTests.lean new file mode 100644 index 0000000000..7adaf85c5c --- /dev/null +++ b/verification/aeneas/lean/ModelExportTests.lean @@ -0,0 +1,43 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import ModelExportProducer +@[expose] public section + +/-! +These consumer checks import, unfold, and inspect models produced in another +module. Successful and rejected decodings pin both calculation and admission; +the decomposition example exposes a child witness across the import boundary. +The axiom audit checks the exported decoder and bridge declarations themselves, +so successful elaboration cannot conceal an unchecked proof dependency. +-/ + +open AeneasSpecs Aeneas.Std +namespace ModelExportRegression +example (raw : Raw) : ∃ value, Raw.decode raw = some value := by + simp [Raw.decode, Raw.decodeFields] +-- Consumer kernel checks must unfold proof-containing outputs across the import. +example : (Encoding.decode ⟨⟨13#usize⟩⟩).map (fun value => (value.align, value.phase)) = + some (8, 5) := rfl +example : Encoding.decode ⟨⟨0#usize⟩⟩ = none := rfl +example : (Fallible.decode ⟨13#usize⟩).map (·.word) = some 12 := rfl +example : Fallible.decode ⟨0#usize⟩ = none := rfl +example (raw : Encoding) (value : Encoding.Value) + (h : Encoding.decode raw = some value) : + ∃ word, modelNonZeroUsize.decode raw.word = some word ∧ + Encoding.decodeFields ⟨word⟩ = some value := Encoding.decode_decompose raw value |>.mp h + +run_meta do + for owner in #[``Raw, ``Encoding, ``Fallible] do + for suffix in #[`decodeFields, `decode, `aeneasModel, `decode_decompose] do + let declName := owner ++ suffix + for axiomName in ← Lean.collectAxioms declName do + unless #[`propext, `Classical.choice, `Quot.sound].contains axiomName do + throwError "Unexpected decoder axiom {axiomName} in {declName}" +end ModelExportRegression diff --git a/verification/aeneas/lean/ModelLemmas.lean b/verification/aeneas/lean/ModelLemmas.lean new file mode 100644 index 0000000000..617a6e00cb --- /dev/null +++ b/verification/aeneas/lean/ModelLemmas.lean @@ -0,0 +1,169 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import ModelPrelude +@[expose] public section + +/-! +Operation proofs often need to unpack decoder admission or relate an accepted +model to a raw observation. These lemmas expose container admission, element +witnesses, and length-preserving decoding without changing which values decode. +Where a mathematical carrier could have several interpretations, the provider +or decoder stays explicit: the carrier's type alone does not identify its +meaning. +-/ + +open Aeneas Aeneas.Std +namespace AeneasSpecs +set_option linter.unusedSimpArgs false + +/-- An accepting bind has an accepting first value and an accepting continuation. +This equivalence explains the existential/equation form of generated decoder +bridges without relying on how a parent model observes the child. -/ +theorem admitted_bind {α β : Type} (d : Option α) (f : α → Option β) : + (∃ result, d.bind f = some result) ↔ + ∃ value, d = some value ∧ ∃ result, f value = some result := by + cases d <;> simp + +@[simp] theorem admitted_some {α : Type} (value : α) : + (∃ result, some value = some result) ↔ True := by simp + + +theorem admitted_none {α : Type} : (∃ value : α, none = some value) ↔ False := by simp + +theorem admitted_dite {α : Type} (P : Prop) [Decidable P] (value : P → α) : + (∃ result, (if h : P then some (value h) else none) = some result) ↔ P := by + by_cases h : P <;> simp [h] + +-- Admission statements forget which mathematical witness was produced, but +-- retain exactly which children must decode. Option and Rust Result select a +-- payload; products and lists require all their represented children. +@[simp] theorem option_valid_iff {α : Type} [RustModel α] (raw : Option α) : + isValid raw ↔ ∀ x ∈ raw, isValid x := by + cases raw <;> simp [isValid, RustModel.decode, modelOption, Option.map_eq_some_iff] + +@[simp] theorem prod_valid_iff {α β : Type} [RustModel α] [RustModel β] + (raw : α × β) : isValid raw ↔ isValid raw.1 ∧ isValid raw.2 := by + rcases raw with ⟨a, b⟩ + simp only [isValid, RustModel.decode, modelProd] + cases ha : RustModel.decode a <;> cases hb : RustModel.decode b <;> simp [ha, hb] + +@[simp] theorem result_valid_iff {α β : Type} [RustModel α] [RustModel β] + (raw : core.result.Result α β) : + isValid raw ↔ match raw with | .Ok a => isValid a | .Err b => isValid b := by + cases raw <;> simp [isValid, RustModel.decode, modelRustResult, Option.map_eq_some_iff] + +theorem decodeList_admitted {α β : Type} (d : α → Option β) (raw : List α) : + (∃ values, decodeList d raw = some values) ↔ + ∀ x ∈ raw, ∃ value, d x = some value := by + induction raw with + | nil => simp [decodeList] + | cons x xs ih => + cases hx : d x <;> cases ht : decodeList d xs <;> + simp only [hx, ht, Option.some.injEq, reduceCtorEq, exists_eq_left, + not_exists, forall_const, List.mem_cons, forall_eq_or_imp] at ih ⊢ <;> + simp_all [decodeList] + +@[simp] theorem list_valid_iff {α : Type} [m : RustModel α] (raw : List α) : + isValid raw ↔ ∀ x ∈ raw, isValid x := decodeList_admitted m.decode raw + +@[simp] theorem slice_valid_iff {α : Type} [m : RustModel α] (raw : Slice α) : + isValid raw ↔ ∀ x ∈ raw.val, isValid x := by + unfold isValid + dsimp only [RustModel.decode, modelSlice] + split + · rename_i h + simp only [reduceCtorEq] + constructor + · rintro ⟨_, bad⟩; contradiction + intro hall + obtain ⟨values, hv⟩ := (decodeList_admitted m.decode raw.val).mpr hall + rw [h] at hv; contradiction + · rename_i values h + simp only [Option.some.injEq, exists_eq', true_iff] + exact (decodeList_admitted m.decode raw.val).mp ⟨values, h⟩ + +-- Exposing the child carrier and decoder as separate parameters lets ordinary +-- simplification match a normalized nested associated type without trying to +-- infer its raw carrier from its mathematical carrier. +theorem option_admitted_explicit_iff {Raw Math : Type} + (decoder : Raw → Option Math) (raw : Option Raw) : + (∃ value : Option Math, + (@modelOption Raw ⟨Math, decoder⟩).decode raw = some value) ↔ + ∀ x ∈ raw, ∃ value, decoder x = some value := by + cases raw <;> simp [RustModel.decode, modelOption, Option.map_eq_some_iff] + +/-- Structural traversal preserves the accepted mathematical element witnesses. -/ +theorem decodeList_eq_some_iff {α β : Type u} (d : α → Option β) + (raw : List α) (values : List β) : + decodeList d raw = some values ↔ + List.Forall₂ (fun x value => d x = some value) raw values := by + induction raw generalizing values with + | nil => cases values <;> simp [decodeList] + | cons x xs ih => + cases values with + | nil => cases hx : d x <;> cases ht : decodeList d xs <;> simp [decodeList, hx, ht] + | cons value values => + cases hx : d x <;> cases ht : decodeList d xs <;> + simp only [decodeList, hx, ht, Option.bind_some, Option.bind_none, + Option.some.injEq, reduceCtorEq, List.cons.injEq, + List.forall₂_cons] at ih ⊢ <;> simp_all + +/-- Bounded container decomposition exposes the traversed list, while proof +irrelevance identifies the length evidence already stored in its carrier. -/ +theorem decodeSlice_iff {α : Type u} (m : RustModel α) (raw : Slice α) + (value : SliceValue m.Model) : + (@modelSlice α m).decode raw = some value ↔ + decodeList m.decode raw.val = some value.val := by + change (match h : decodeList m.decode raw.val with + | none => none + | some values => some ⟨values, (decodeList_length m.decode h) ▸ raw.property⟩) = some value ↔ _ + split + · rename_i h; simp [h] + · rename_i values h + simp only [Option.some.injEq] + constructor + · intro he; cases he; exact h + · intro he + -- The lists agree by decoder equality. Subtype.ext discards differences + -- between proofs of the same length bound, not differences in elements. + apply Subtype.ext + exact Option.some.inj (h.symm.trans he) + +theorem decodeArray_iff {α : Type u} (m : RustModel α) (n : Usize) + (raw : Aeneas.Std.Array α n) (value : ArrayValue m.Model n) : + (@modelArray α n m).decode raw = some value ↔ + decodeList m.decode raw.val = some value.val := by + change (match h : decodeList m.decode raw.val with + | none => none + | some values => some ⟨values, (decodeList_length m.decode h) ▸ raw.property⟩) = some value ↔ _ + split + · rename_i h; simp [h] + · rename_i values h + simp only [Option.some.injEq] + constructor + · intro he; cases he; exact h + · intro he; apply Subtype.ext; exact Option.some.inj (h.symm.trans he) + +/-- Lift an admitted raw postcondition through an observation proved for the +retained provider. The provider stays explicit: a model carrier alone does not +identify its decoder. -/ +theorem decoded_view_post {Raw : Type u} {View : Type v} + (provider : RustModel Raw) (rawView : Raw → View) + (modelView : provider.Model → View) + (view_decode : ∀ raw value, provider.decode raw = some value → + modelView value = rawView raw) + (post : View → Prop) (raw : Raw) + (admitted : ∃ value, provider.decode raw = some value) + (facts : post (rawView raw)) : + ∃ value, provider.decode raw = some value ∧ post (modelView value) := by + obtain ⟨value, decoded⟩ := admitted + exact ⟨value, decoded, (view_decode raw value decoded).symm ▸ facts⟩ + +end AeneasSpecs diff --git a/verification/aeneas/lean/ModelPrelude.lean b/verification/aeneas/lean/ModelPrelude.lean new file mode 100644 index 0000000000..c3a7f110b9 --- /dev/null +++ b/verification/aeneas/lean/ModelPrelude.lean @@ -0,0 +1,122 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import RustModel +public import Zerocopy.TypesExternal +@[expose] public section + +/-! +Extracted integer words store finite bit patterns. Their mathematical models +expose ordinary `Nat` or `Int` values together with proofs of the corresponding +word bounds. A reader of a decoded word can use those bounds directly, without +reintroducing a separate validity premise. + +Raw nonzero wrappers need an additional admission check: their extracted shape +can contain zero even though the mathematical nonzero model cannot. These +decoders test that condition and preserve its proof in the accepted value. +-/ + +open Aeneas.Std +namespace AeneasSpecs + +-- The bounds are proofs stored beside the value. They contribute constraints +-- to the mathematical type without another runtime field or admission test. +structure UnsignedWord (ty : UScalarTy) where + value : Nat + bound : value ≤ UScalar.max ty + +structure SignedWord (ty : IScalarTy) where + value : Int + lower : IScalar.min ty ≤ value + upper : value ≤ IScalar.max ty + +structure NonZeroUnsignedWord (ty : UScalarTy) extends UnsignedWord ty where + positive : 0 < value + +structure NonZeroSignedWord (ty : IScalarTy) extends SignedWord ty where + nonzero : value ≠ 0 + +instance {ty : UScalarTy} : CoeOut (UnsignedWord ty) Nat := ⟨UnsignedWord.value⟩ +instance {ty : IScalarTy} : CoeOut (SignedWord ty) Int := ⟨SignedWord.value⟩ +instance {ty : UScalarTy} : CoeOut (NonZeroUnsignedWord ty) Nat := ⟨fun x => x.value⟩ +instance {ty : IScalarTy} : CoeOut (NonZeroSignedWord ty) Int := ⟨fun x => x.value⟩ + +-- Proof irrelevance makes equal numeric observations sufficient for record +-- equality: two proofs of the same bound do not create distinct word values. +@[ext] theorem UnsignedWord.ext {x y : UnsignedWord ty} (h : x.value = y.value) : x = y := by + cases x; cases y; simp_all +@[ext] theorem SignedWord.ext {x y : SignedWord ty} (h : x.value = y.value) : x = y := by + cases x; cases y; simp_all +@[ext] theorem NonZeroUnsignedWord.ext {x y : NonZeroUnsignedWord ty} + (h : x.value = y.value) : x = y := by + cases x; cases y; congr 1; exact UnsignedWord.ext h +@[ext] theorem NonZeroSignedWord.ext {x y : NonZeroSignedWord ty} + (h : x.value = y.value) : x = y := by + cases x; cases y; congr 1; exact SignedWord.ext h + +-- Aeneas scalar values already carry their machine bounds. Transport those +-- existing facts when decoding, instead of retesting valid bit patterns. +def unsignedWord (x : UScalar ty) : UnsignedWord ty := + ⟨x.val, by have := x.hBounds; simp only [UScalar.max]; omega⟩ +def signedWord (x : IScalar ty) : SignedWord ty := + ⟨x.val, by simpa only [IScalar.min] using x.hmin, + by have := x.hmax; simp only [IScalar.max]; omega⟩ + +@[reducible] instance modelUScalar (ty : UScalarTy) : RustModel (UScalar ty) := + ⟨UnsignedWord ty, fun x => some (unsignedWord x)⟩ +@[reducible] instance modelIScalar (ty : IScalarTy) : RustModel (IScalar ty) := + ⟨SignedWord ty, fun x => some (signedWord x)⟩ + +@[reducible] instance modelNonZeroUScalar (ty : UScalarTy) (Inner : Type) : + RustModel (core.num.nonzero.NonZero (UScalar ty) Inner) where + Model := NonZeroUnsignedWord ty + decode raw := if h : 0 < raw.val.val then some ⟨unsignedWord raw.val, h⟩ else none + +@[reducible] instance modelNonZeroIScalar (ty : IScalarTy) (Inner : Type) : + RustModel (core.num.nonzero.NonZero (IScalar ty) Inner) where + Model := NonZeroSignedWord ty + decode raw := if h : raw.val.val ≠ 0 then some ⟨signedWord raw.val, h⟩ else none + +abbrev NonZeroUsizeValue := NonZeroUnsignedWord .Usize +abbrev modelNonZeroUsize := modelNonZeroUScalar .Usize core.num.niche_types.NonZeroUsizeInner + +@[simp] theorem unsignedWord_value (x : UScalar ty) : (unsignedWord x).value = x.val := rfl +@[simp] theorem signedWord_value (x : IScalar ty) : (signedWord x).value = x.val := rfl +@[simp] theorem decodeUScalar (x : UScalar ty) : + (modelUScalar ty).decode x = some (unsignedWord x) := rfl +@[simp] theorem decodeIScalar (x : IScalar ty) : + (modelIScalar ty).decode x = some (signedWord x) := rfl + +/-- Reduce an explicit decoder equation to the numeric observation it preserves. +Extensionality handles the bound proofs, so operation proofs need only numbers. -/ +theorem decodeUScalar_iff (raw : UScalar ty) (value : UnsignedWord ty) : + (modelUScalar ty).decode raw = some value ↔ raw.val = value.value := by + change some (unsignedWord raw) = some value ↔ raw.val = value.value + simp only [Option.some.injEq] + exact ⟨fun h => congrArg UnsignedWord.value h, (fun h => UnsignedWord.ext (x := unsignedWord raw) h)⟩ + +theorem decodeNonZeroUScalar_iff (raw : core.num.nonzero.NonZero (UScalar ty) Inner) + (value : NonZeroUnsignedWord ty) : + (modelNonZeroUScalar ty Inner).decode raw = some value ↔ raw.val.val = value.value := by + change (if h : 0 < raw.val.val then some ⟨unsignedWord raw.val, h⟩ else none) = some value ↔ _ + constructor + · intro h + split at h + · cases Option.some.inj h; rfl + · contradiction + · intro h + -- In the reverse direction, the supplied mathematical value already proves + -- positivity. Numeric equality transfers it to the raw wrapper and selects + -- the accepting branch; there is no separate validity assumption. + have hp : 0 < raw.val.val := h ▸ value.positive + rw [dif_pos hp] + congr 1 + exact NonZeroUnsignedWord.ext h + +end AeneasSpecs diff --git a/verification/aeneas/lean/ModelTests.lean b/verification/aeneas/lean/ModelTests.lean new file mode 100644 index 0000000000..c568be7502 --- /dev/null +++ b/verification/aeneas/lean/ModelTests.lean @@ -0,0 +1,294 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import DeriveModels +@[expose] public section + +/-! +These fixtures challenge structural model generation independently of zerocopy's +layout models. They cover generic shapes and dictionaries, nominal identity, +containers, enum payloads, and lossy local decoders. The rejected-child cases +are essential: an infallible parent that forgets a field must still reject that +field's invalid raw representation. Decomposition checks also pin the accepted +witness order rather than merely checking one decoder result. +-/ + +open Lean Aeneas Aeneas.Std AeneasSpecs +namespace ModelTests +set_option linter.unusedVariables false + +structure Child where + value : Nat + +aeneas_model_shape Child with 0 type parameters begin + model Positive where + value : Nat + positive : 0 < value +end +derive_rust_model Child with 0 type parameters decode? self => + if h : 0 < self.value then some ⟨self.value, h⟩ else none +check_model_binding Child with 0 type parameters + +structure Parent where + child : Child + ignored : Nat +derive_model_shape Parent with 0 type parameters +derive_rust_model Parent with 0 type parameters +check_model_binding Parent with 0 type parameters +example : Parent.decode ⟨⟨0⟩, 1⟩ = none := rfl + +structure Box (α : Type u) where + value : Option (List α) +derive_model_shape Box with 1 type parameters +derive_rust_model Box with 1 type parameters +check_model_binding Box with 1 type parameters +example (α : Type u) [m : RustModel α] : + (@Box.aeneasModel α m).Model = Box.Fields m.Model := rfl +example (α : Type u) [m : RustModel α] : + @Box.decode α m ⟨none⟩ = some ⟨none⟩ := rfl + +inductive Choice (α : Type u) where + | one : α → Choice α + | empty : Choice α +derive_model_shape Choice with 1 type parameters +derive_rust_model Choice with 1 type parameters +check_model_binding Choice with 1 type parameters +example (α : Type u) [m : RustModel α] : + @Choice.decode α m .empty = some .empty := rfl + +structure SameShape where + child : Child + ignored : Nat +derive_model_shape SameShape with 0 type parameters +derive_rust_model SameShape with 0 type parameters +run_meta do + if ← Lean.Meta.isDefEq (Lean.mkConst ``Parent.Fields) (Lean.mkConst ``SameShape.Fields) then + throwError "Different owners lost nominal identity" + +-- Even unused Rust generics retain their dictionary and carrier parameter. +structure Phantom (T : Type u) where + value : Nat +aeneas_model_shape Phantom with 1 type parameters begin + model Value := Unit +end +derive_rust_model Phantom with 1 type parameters decode self => () +check_model_binding Phantom with 1 type parameters +example (T : Type u) [m : RustModel T] : + @Phantom.decode T m ⟨7⟩ = some () := rfl + +-- An authored Fields alias shares the one nominal carrier, including generics. +structure AliasBox (T : Type u) where + value : T +aeneas_model_shape AliasBox with 1 type parameters begin + model Value := Fields +end +derive_rust_model AliasBox with 1 type parameters +example (T : Type u) [m : RustModel T] : AliasBox.Value m.Model = AliasBox.Fields m.Model := rfl + +-- Even an infallible parent that discards all children first decodes them all. +structure Forgetful where + child : Child +aeneas_model_shape Forgetful with 0 type parameters begin + model Value := Unit +end +derive_rust_model Forgetful with 0 type parameters decode self => () +example : Forgetful.decode ⟨⟨0⟩⟩ = none := rfl + +structure ArrayBox (T : Type u) where + values : Aeneas.Std.Array T (2#usize) +derive_model_shape ArrayBox with 1 type parameters +derive_rust_model ArrayBox with 1 type parameters +check_model_binding ArrayBox with 1 type parameters +structure SliceBox (T : Type u) where + values : Slice T +derive_model_shape SliceBox with 1 type parameters +derive_rust_model SliceBox with 1 type parameters +check_model_binding SliceBox with 1 type parameters + +-- Nested nominal generic fields use the same original dictionary. +structure Nested (T : Type u) where + value : Box T +derive_model_shape Nested with 1 type parameters +derive_rust_model Nested with 1 type parameters +check_model_binding Nested with 1 type parameters + +structure Heterogeneous (T : Type u) where + value : T × Nat +derive_model_shape Heterogeneous with 1 type parameters +derive_rust_model Heterogeneous with 1 type parameters + +example : @RustModel.decode (Option Nat) (modelOption) none = some none := rfl +example : @RustModel.decode (core.result.Result Nat Bool) (modelRustResult) (.Err true) = + some (.Err true) := rfl + +abbrev ChildAlias := Child +example (x : ChildAlias) : @RustModel.decode ChildAlias Child.aeneasModel x = Child.decode x := rfl +example (x y : Child.Positive) (h : x.value = y.value) : x = y := Child.Positive.ext h + +syntax (name := discardedModelFixture) "discard_model_fixture " command : command +@[command_elab discardedModelFixture] meta def discardModelFixture : Lean.Elab.Command.CommandElab := + fun stx => Lean.withoutModifyingEnv (Lean.Elab.Command.elabCommand stx[1]) + +structure Opaque where + value : Nat +structure MissingParent where + child : Opaque +/-- error: Unsupported opaque carrier or missing bound model provider: Opaque -/ +#guard_msgs in +discard_model_fixture derive_model_shape MissingParent with 0 type parameters + +inductive Recursive where + | more : Recursive → Recursive +/-- error: Models require a nonrecursive, nonindexed nominal Rust type: ModelTests.Recursive -/ +#guard_msgs in +discard_model_fixture derive_model_shape Recursive with 0 type parameters + +inductive Indexed : Nat → Type where + | zero : Indexed 0 +/-- error: Models require a nonrecursive, nonindexed nominal Rust type: ModelTests.Indexed -/ +#guard_msgs in +discard_model_fixture derive_model_shape Indexed with 0 type parameters + +structure Escaping where + continuation : Nat → Nat +/-- error: Unsupported escaping function or backward reconstruction in model carrier -/ +#guard_msgs in +discard_model_fixture derive_model_shape Escaping with 0 type parameters + +/-- error: A type fence must contain exactly one model shape declaration -/ +#guard_msgs in +discard_model_fixture aeneas_model_shape Opaque with 0 type parameters begin + def injection : Nat := 0 +end + +-- Concrete generic shape arguments require only their mathematical shape. +-- The child decoder deliberately appears after the containing shape. +structure RoundingRaw where + word : core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner +aeneas_model_shape RoundingRaw with 0 type parameters begin + model RoundingValue where + value : Nat + positive : 0 < value +end +structure Wrapper (T : Type u) where + value : T +derive_model_shape Wrapper with 1 type parameters +structure BeforeDecoder where + child : Wrapper RoundingRaw + choices : Option (List (Wrapper RoundingRaw)) + slice : Slice (Wrapper RoundingRaw) + array : Aeneas.Std.Array (Wrapper RoundingRaw) (2#usize) +derive_model_shape BeforeDecoder with 0 type parameters +example : BeforeDecoder.Fields → Wrapper.Fields RoundingRaw.RoundingValue := fun x => x.child +example : BeforeDecoder.Fields → SliceValue (Wrapper.Fields RoundingRaw.RoundingValue) := + fun x => x.slice +example : BeforeDecoder.Fields → ArrayValue (Wrapper.Fields RoundingRaw.RoundingValue) (2#usize) := + fun x => x.array + +derive_rust_model RoundingRaw with 0 type parameters decode self => + ⟨self.word.value, self.word.positive⟩ +derive_rust_model Wrapper with 1 type parameters +derive_rust_model BeforeDecoder with 0 type parameters +check_model_binding BeforeDecoder with 0 type parameters + +-- Later dictionaries retain distinct meanings despite sharing one shape family. +@[reducible] def identityDictionary : RustModel Nat := ⟨Nat, some⟩ +@[reducible] def doubleDictionary : RustModel Nat := ⟨Nat, fun n => some (2 * n)⟩ +example : @Wrapper.decode Nat identityDictionary ⟨13⟩ = some ⟨13⟩ := rfl +example : @Wrapper.decode Nat doubleDictionary ⟨13⟩ = some ⟨26⟩ := rfl +example : (@Wrapper.aeneasModel Nat identityDictionary).Model = + (@Wrapper.aeneasModel Nat doubleDictionary).Model := rfl + +-- Authored generic shapes bind TModel; decoding still has the actual T dictionary. +structure Authored (T : Type u) where + value : T +aeneas_model_shape Authored with 1 type parameters begin + model Value where + value : TModel +end +derive_rust_model Authored with 1 type parameters decode self => + { value := (self.value : ModelOf T) } +check_model_binding Authored with 1 type parameters +example (TModel : Type u) : Authored.Value TModel → TModel := fun x => x.value + +-- An infallible lossy parent still rejects a malformed native nonzero child. +structure ForgetfulWord where + word : core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner +aeneas_model_shape ForgetfulWord with 0 type parameters begin + model Value := Unit +end +derive_rust_model ForgetfulWord with 0 type parameters decode self => () +example : ForgetfulWord.decode ⟨⟨0#usize⟩⟩ = none := rfl +example (raw : ForgetfulWord) : ForgetfulWord.decode raw = some () ↔ + ∃ word, (modelNonZeroUsize).decode raw.word = some word ∧ + ForgetfulWord.decodeFields ⟨word⟩ = some () := ForgetfulWord.decode_decompose raw () + +-- Separate universe parameters and container bounds remain present. +structure Mixed (A : Type u) (B : Type v) where + value : A × Option B +derive_model_shape Mixed with 2 type parameters +derive_rust_model Mixed with 2 type parameters +check_model_binding Mixed with 2 type parameters +example (A : Type u) (B : Type v) : Mixed.Fields A B → A × Option B := fun x => x.value +example (T : Type u) [m : RustModel T] (x : ArrayBox.Fields m.Model) : + x.values.val.length = (2#usize).val := x.values.property +example (T : Type u) [m : RustModel T] (x : SliceBox.Fields m.Model) : + x.values.val.length ≤ Usize.max := x.values.property + +-- Decomposition exposes every child's decoded witness, including forgotten data. +example (raw : Forgetful) : Forgetful.decode raw = some () ↔ + ∃ child, Child.decode raw.child = some child ∧ + Forgetful.decodeFields ⟨child⟩ = some () := Forgetful.decode_decompose raw () +example (raw : Parent) (value : Parent.Fields) : Parent.decode raw = some value ↔ + ∃ child, Child.decode raw.child = some child ∧ + ∃ ignored, (modelNat).decode raw.ignored = some ignored ∧ + Parent.decodeFields ⟨child, ignored⟩ = some value := Parent.decode_decompose raw value +example (T : Type u) [m : RustModel T] (x : T) (value : Choice.Fields m.Model) : + @Choice.decode T m (.one x) = some value ↔ + ∃ child, m.decode x = some child ∧ @Choice.decodeFields T m (.one child) = some value := + Choice.decode_decompose T (.one x) value +example (T : Type u) [m : RustModel T] (value : Choice.Fields m.Model) : + @Choice.decode T m .empty = some value ↔ @Choice.decodeFields T m .empty = some value := + Choice.decode_decompose T .empty value + +-- An enum traverses exactly its selected payload. The local decoder forgets +-- both fields, but the second field's rejection must still reject the pair. +-- Its empty case has no child admission obligation. +inductive ForgetfulChoice where + | pair : Nat → Child → ForgetfulChoice + | empty : ForgetfulChoice +aeneas_model_shape ForgetfulChoice with 0 type parameters begin + model Value := Unit +end +derive_rust_model ForgetfulChoice with 0 type parameters decode self => () +check_model_binding ForgetfulChoice with 0 type parameters +example : ForgetfulChoice.decode (.pair 7 ⟨0⟩) = none := rfl +example : ForgetfulChoice.decode (.pair 7 ⟨1⟩) = some () := rfl +example : ForgetfulChoice.decode .empty = some () := rfl +example (n : Nat) (raw : Child) : ForgetfulChoice.decode (.pair n raw) = some () ↔ + ∃ number, modelNat.decode n = some number ∧ + ∃ child, Child.decode raw = some child ∧ + ForgetfulChoice.decodeFields (.pair number child) = some () := + ForgetfulChoice.decode_decompose (.pair n raw) () +example : ForgetfulChoice.decode .empty = some () ↔ + ForgetfulChoice.decodeFields .empty = some () := + ForgetfulChoice.decode_decompose .empty () + +-- Audit the generated bridge terms as well as their successful elaboration. +run_meta do + for owner in ← modelOwnerNames do + if !(`ModelTests).isPrefixOf owner then continue + let theoremName := owner ++ `decode_decompose + let .thmInfo _ ← getConstInfo theoremName + | throwError "Missing structural decomposition theorem for {owner}" + for axiomName in ← Lean.collectAxioms theoremName do + unless #[`propext, `Classical.choice, `Quot.sound].contains axiomName do + throwError "Unexpected axiom in generated decomposition {theoremName}: {axiomName}" + +end ModelTests diff --git a/verification/aeneas/lean/Obligations.lean b/verification/aeneas/lean/Obligations.lean new file mode 100644 index 0000000000..c95854b591 --- /dev/null +++ b/verification/aeneas/lean/Obligations.lean @@ -0,0 +1,74 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Zerocopy.Funs +public import SpecPrelude +public import MathViews +@[expose] public section + +/-! +This module independently states the behavior and input domains we want to +retain. Its propositions are written by the integration's authors, rather +than generated from Rust comments. They protect against a comment's macro +expansion or a model migration expressing less than a reviewer intended. + +Each name ending in _contract describes an arbitrary execution outcome for +the original inputs. Its unsuffixed proposition specializes that outcome to +the actual extracted call. RequiredContracts first proves that the authored +spec implies the arbitrary-outcome expectation, then specializes the +implication. These declarations are expectations awaiting proofs, not axioms +or a second proof that the compiler implements Rust correctly. +-/ +open Aeneas Aeneas.Std AeneasSpecs +namespace Zerocopy.Obligations +open Zerocopy.Proofs +abbrev NonZeroUsize := + core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner + +def max_spec_contract (a b : NonZeroUsize) + (run : Result NonZeroUsize) : Prop := + 0 < a.val.val → 0 < b.val.val → + ∃ r, run = .ok r ∧ 0 < r.val.val ∧ + r.val.val = Nat.max a.val.val b.val.val + +def max_spec : Prop := + ∀ (a b : NonZeroUsize), + max_spec_contract a b (util.max a b) + +def min_spec_contract (a b : NonZeroUsize) + (run : Result NonZeroUsize) : Prop := + 0 < a.val.val → 0 < b.val.val → + ∃ r, run = .ok r ∧ 0 < r.val.val ∧ + r.val.val = Nat.min a.val.val b.val.val + +def min_spec : Prop := + ∀ (a b : NonZeroUsize), + min_spec_contract a b (util.min a b) + +def padding_lt_alignment_contract (_len : Usize) (align : NonZeroUsize) + (run : Result Usize) : Prop := + ∀ (_valid : 0 < align.val.val), + run ⦃ p => p.val < align.val.val ⦄ + +def padding_lt_alignment : Prop := + ∀ (len : Usize) (align : NonZeroUsize), + padding_lt_alignment_contract len align (util.padding_needed_for len align) + +def round_down_spec_contract (n : Usize) (align : NonZeroUsize) + (run : Result Usize) : Prop := + ∀ (_valid : 0 < align.val.val) (_h : align.val.val.isPowerOfTwo), + run + ⦃ m => m.val ≤ n.val ∧ m.val % align.val.val = 0 ⦄ + +def round_down_spec : Prop := + ∀ (n : Usize) (align : NonZeroUsize), + round_down_spec_contract n align (util.round_down_to_next_multiple_of_alignment n align) + + +end Zerocopy.Obligations diff --git a/verification/aeneas/lean/Proofs.lean b/verification/aeneas/lean/Proofs.lean new file mode 100644 index 0000000000..fc494fe6b0 --- /dev/null +++ b/verification/aeneas/lean/Proofs.lean @@ -0,0 +1,44 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Specs +public import Proofs.Util +public import Aeneas +import all Mathlib.Data.Nat.Log +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +/-! +This is the import root for contracts proved about actual Aeneas function +definitions. Proofs.Util supplies the arithmetic theorems: its Raw namespace +steps through the extracted computations, then canonical theorems prove the +generated Specs propositions, including input and result decoding. + +Aeneas Result describes execution success, panic, or divergence. Rust Option +and Rust Result values may themselves be successful payloads. These cases +have different meanings and must remain distinct in function contracts. +-/ +open Aeneas Aeneas.Std +namespace Zerocopy.Proofs.Raw + +set_option linter.unusedVariables false + + +end Zerocopy.Proofs.Raw + +namespace Zerocopy.Proofs +open AeneasSpecs + + +end Zerocopy.Proofs + +namespace Zerocopy.Proofs +open AeneasSpecs + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/Proofs/Util.lean b/verification/aeneas/lean/Proofs/Util.lean new file mode 100644 index 0000000000..61cb530625 --- /dev/null +++ b/verification/aeneas/lean/Proofs/Util.lean @@ -0,0 +1,148 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Specs +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +/-! +These four arithmetic helpers show the proof pattern used by the integration. +Raw lemmas unfold the actual extracted computation and prove precise numeric +facts. Canonical theorems then lift those facts to the generated inline +specs, including successful input and result decoding. + +The notation computation ⦃ result => property ⦄ means that execution +terminates successfully and its returned value satisfies property. Aeneas's +step tactic uses a registered callee specification, generating its +preconditions as proof goals. These proofs connect numeric facts to the +specific zerocopy implementations. +-/ +open Aeneas Aeneas.Std +namespace Zerocopy.Proofs.Raw + +set_option linter.unusedVariables false + +theorem padding_lt_alignment : + ∀ (len : Usize) (align : NonZeroUsize), ∀ (h : (0 < align.val.val : Prop)), + @Zerocopy.util.padding_needed_for len align ⦃ p => p.val < align.val.val ⦄ := by + intro len align h + unfold util.padding_needed_for + simp only [core.num.nonzero.NonZero.get, bind_ok] + step + simp only [lift, bind_ok, WP.spec_ok, UScalar.val_and] + have hbound : + (~~~(core.num.Usize.wrapping_sub len 1#usize)).val &&& mask.val ≤ mask.val := + Nat.and_le_right + omega + +theorem round_down_spec : + ∀ (n : Usize) (align : NonZeroUsize), ∀ (hpos : (0 < align.val.val : Prop)), ∀ (h : (align.val.val.isPowerOfTwo : Prop)), + @Zerocopy.util.round_down_to_next_multiple_of_alignment n align ⦃ m => m.val ≤ n.val ∧ m.val % align.val.val = 0 ⦄ := by + intro n align hpos h + unfold util.round_down_to_next_multiple_of_alignment + simp only [core.num.nonzero.NonZero.get, bind_ok] + step + step + step with UScalar.sub_bv_spec as ⟨mask, hval, hle, hbv⟩ + simp only [lift, bind_ok, WP.spec_ok] + constructor + · simp only [UScalar.val_and] + exact Nat.and_le_left + · have clear : (n &&& ~~~mask).val &&& mask.val = 0 := by + rw [← UScalar.val_and] + change ((n.bv &&& ~~~mask.bv) &&& mask.bv).toNat = 0 + simp [BitVec.and_assoc] + unfold Nat.isPowerOfTwo at h + rcases h with ⟨k, hk⟩ + rw [hval, hk, Nat.and_two_pow_sub_one_eq_mod] at clear + simpa only [hk] using clear + +theorem max_spec : + ∀ (a b : NonZeroUsize), + @Zerocopy.util.max a b ⦃ r => r.val.val = Nat.max a.val.val b.val.val ⦄ := by + intro a b + apply WP.exists_imp_spec + simp only [util.max, core.num.nonzero.NonZero.get, bind_ok, UScalar.lt_equiv] + split + · rename_i h + exact ⟨b, rfl, (Nat.max_eq_right (by omega)).symm⟩ + · rename_i h + exact ⟨a, rfl, (Nat.max_eq_left (by omega)).symm⟩ + +theorem min_spec : + ∀ (a b : NonZeroUsize), + @Zerocopy.util.min a b ⦃ r => r.val.val = Nat.min a.val.val b.val.val ⦄ := by + intro a b + apply WP.exists_imp_spec + simp only [util.min, core.num.nonzero.NonZero.get, bind_ok, UScalar.lt_equiv] + split + · rename_i h + exact ⟨b, rfl, (Nat.min_eq_right (by omega)).symm⟩ + · rename_i h + exact ⟨a, rfl, (Nat.min_eq_left (by omega)).symm⟩ + +end Zerocopy.Proofs.Raw + +namespace Zerocopy.Proofs +open AeneasSpecs + +theorem padding_lt_alignment : Zerocopy.Specs.padding_lt_alignment := by + intro len align _lenValue _hlen alignValue halign + have ha := (decodeNonZeroUScalar_iff align alignValue).mp halign + have positive : 0 < align.val.val := by rw [ha]; exact alignValue.positive + apply WP.spec_mono (Raw.padding_lt_alignment len align positive) + intro output facts + refine ⟨unsignedWord output, rfl, ?_⟩ + dsimp only + rw [← ha] + exact facts +register_spec_step padding_lt_alignment + +theorem round_down_spec : Zerocopy.Specs.round_down_spec := by + intro n align nValue hn alignValue halign pow2 + have hnv := (decodeUScalar_iff n nValue).mp hn + have ha := (decodeNonZeroUScalar_iff align alignValue).mp halign + have positive : 0 < align.val.val := by rw [ha]; exact alignValue.positive + apply WP.spec_mono (Raw.round_down_spec n align positive (by simpa only [ha] using pow2)) + intro output facts + refine ⟨unsignedWord output, rfl, ?_⟩ + dsimp only + rw [← hnv, ← ha] + exact facts +register_spec_step round_down_spec + +theorem max_spec : Zerocopy.Specs.max_spec := by + intro a b av ha bv hb + have hav := (decodeNonZeroUScalar_iff a av).mp ha + have hbv := (decodeNonZeroUScalar_iff b bv).mp hb + apply WP.spec_mono (Raw.max_spec a b) + intro output facts + have positive : 0 < output.val.val := by + rw [facts, hav, hbv] + exact lt_of_lt_of_le av.positive (Nat.le_max_left _ _) + let value : NonZeroUsizeValue := ⟨unsignedWord output.val, positive⟩ + refine ⟨value, (decodeNonZeroUScalar_iff output value).mpr rfl, ?_⟩ + simpa only [value, unsignedWord, hav, hbv] using facts +register_spec_step max_spec + +theorem min_spec : Zerocopy.Specs.min_spec := by + intro a b av ha bv hb + have hav := (decodeNonZeroUScalar_iff a av).mp ha + have hbv := (decodeNonZeroUScalar_iff b bv).mp hb + apply WP.spec_mono (Raw.min_spec a b) + intro output facts + have positive : 0 < output.val.val := by + rw [facts, hav, hbv] + exact lt_min av.positive bv.positive + let value : NonZeroUsizeValue := ⟨unsignedWord output.val, positive⟩ + refine ⟨value, (decodeNonZeroUScalar_iff output value).mpr rfl, ?_⟩ + simpa only [value, unsignedWord, hav, hbv] using facts +register_spec_step min_spec + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/RequiredContractTests.lean b/verification/aeneas/lean/RequiredContractTests.lean new file mode 100644 index 0000000000..e995a55bb2 --- /dev/null +++ b/verification/aeneas/lean/RequiredContractTests.lean @@ -0,0 +1,112 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import RequiredContracts +public import MathViews +@[expose] public section + +/-! +Adequacy must hold for arbitrary outcomes, even when both concrete propositions +are true. These tests supply wrong returns and divergence to distinguish weak +postconditions, partial correctness, and empty ghost domains from the promised +total behavior. A final polymorphic example checks that specialization preserves +original implicit type arguments and their model dictionaries. +-/ + +open Aeneas Aeneas.Std AeneasSpecs AeneasContracts +namespace RequiredContractTests +set_option linter.unusedSimpArgs false + +def identity (x : Nat) : Result Nat := .ok x + +-- The independent family uses only the supplied outcome. Its concrete alias +-- specializes to the operation, whose implementation satisfies this promise. +def expected_contract (x : Nat) (run : Result Nat) : Prop := + WP.spec run (fun output => output = x) +def expected : Prop := ∀ x : Nat, expected_contract x (identity x) +theorem identity_correct : expected := by + intro x + exact WP.spec.ret rfl + +spec strict for identity with 0 type parameters + ensures output => output = x +spec weak for identity with 0 type parameters + ensures output => True +theorem weak_correct : weak := by + simp [weak, weak_contract, identity, RustModel.decode, modelNat] + +-- Both concrete propositions are true, but the weak post permits a wrong +-- arbitrary outcome. A second proof of identity cannot supply adequacy. +theorem weak_post_rejected : ¬ (contract_adequacy% weak implies expected) := by + intro adequate + have accepted : weak_contract 0 (.ok 1) := by + simp [weak_contract, RustModel.decode, modelNat] + have wrong := adequate 0 (.ok 1) accepted + simp [expected_contract] at wrong + +partial spec partial_identity for identity with 0 type parameters + ensures output => output = x +theorem partial_total_rejected : + ¬ (contract_adequacy% partial_identity implies expected) := by + intro adequate + have accepted : partial_identity_contract 0 .div := by + intro math decoded + exact WP.dspec.div _ + have wrong := adequate 0 .div accepted + simp [expected_contract] at wrong + +spec false_ghost (impossible : False) for identity with 0 type parameters + ensures output => output = x +theorem false_ghost_rejected : + ¬ (contract_adequacy% false_ghost implies expected) := by + intro adequate + have accepted : false_ghost_contract 0 (.ok 1) := by + intro math decoded impossible + exact impossible.elim + have wrong := adequate 0 (.ok 1) accepted + simp [expected_contract] at wrong + +spec fin_zero_ghost (position : Fin 0) for identity with 0 type parameters + ensures output => output = x +theorem fin_zero_ghost_rejected : + ¬ (contract_adequacy% fin_zero_ghost implies expected) := by + intro adequate + have accepted : fin_zero_ghost_contract 0 (.ok 1) := by + intro math decoded position + exact Fin.elim0 position + have wrong := adequate 0 (.ok 1) accepted + simp [expected_contract] at wrong + +-- A true concrete alias must still specialize its own independent family. +def mismatched_contract (x : Nat) (run : Result Nat) : Prop := + WP.spec run (fun output => output = x + 1) +def mismatched : Prop := ∀ x : Nat, expected_contract x (identity x) +example : mismatched := identity_correct +/-- error: Independent proposition RequiredContractTests.mismatched does not specialize its arbitrary-outcome family -/ +#guard_msgs in +#check (contract_adequacy% strict implies mismatched) + +end RequiredContractTests + +namespace GenericUniverseTest +universe u +-- Preserve both universe polymorphism and implicit original type arguments. +def identity {T : Type u} (x : T) : Result T := .ok x +spec identity_spec for @identity with 1 type parameters + ensures(raw) r => r = x +theorem identity_proof : identity_spec := by + intro T x provider math decoded + exact ⟨math, decoded, rfl⟩ +def identity_required_contract (T : Type u) (x : T) (run : Result T) : Prop := + ∀ [_m : RustModel T], isValid x → + WP.spec run (fun r => isValid r ∧ r = x) +def identity_required : Prop := ∀ (T : Type u) (x : T), + identity_required_contract T x (@identity T x) +check_contract identity_required using identity_proof +end GenericUniverseTest diff --git a/verification/aeneas/lean/RequiredContracts.lean b/verification/aeneas/lean/RequiredContracts.lean new file mode 100644 index 0000000000..b5affbee7a --- /dev/null +++ b/verification/aeneas/lean/RequiredContracts.lean @@ -0,0 +1,198 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import SpecsSyntax +public import ContractSimps +public import Mathlib.Tactic.Convert +public section + +/-! +A proved specification can still state too little. For example, a postcondition +`True` can be proved for identity but also permits identity to return the wrong +value. This module checks adequacy by replacing the actual execution with an +arbitrary `Result` and proving that the authored contract implies a separately +authored expectation for that same outcome. + +Only after that implication is checked do we specialize it to the extracted +call and apply the canonical proof. The audits require the expected family to +use its outcome and forbid dependencies on the extracted operation, including +through helper declarations. Its concrete alias must also specialize that same +family. These checks prevent an adapter from bypassing the authored contract by +independently proving the implementation again. +-/ + +open Lean Lean.Meta Aeneas Aeneas.Std + +namespace AeneasContracts + +attribute [contract_simps] WP.spec_equiv_exists UScalar.eq_equiv + UScalar.coe_max UScalar.lt_equiv UScalar.le_equiv + +/-- A required check starts from a canonical proof of a named specification. +Accepting arbitrary terms here would let a check ignore the inline contract. -/ +meta def canonicalSpecName (candidate : Name) : MetaM Name := do + let .thmInfo proof ← getConstInfo candidate + | throwError "Required contract candidate must be a theorem: {candidate}" + let .const specName _ := proof.type.consumeMData + | throwError "Required contract candidate must directly name a specification: {candidate}" + unless AeneasSpecs.specAttribute.hasTag (← getEnv) specName do + throwError "Required contract candidate must directly name a specification: {candidate}" + pure specName + +/-- An independent family must describe the supplied outcome, rather than +smuggling the concrete call back into the expectation through a helper. -/ +private meta def checkIndependentFamily (familyName modelName : Name) + (count : Nat) : MetaM Unit := do + let info ← getConstInfo familyName + let some value := info.value? | throwError "Independent contract must be transparent: {familyName}" + -- Open the function type to get the actual input/outcome variables, then + -- reduce its body enough to reject a syntactically present but ignored run. + forallTelescope info.type fun inputs _ => do + unless inputs.size == count + 1 do + throwError "Independent contract {familyName} changed its input and outcome prefix" + let body ← whnf (mkAppN value inputs) + unless body.containsFVar inputs[count]!.fvarId! do + throwError "Independent contract {familyName} must use its execution outcome" + -- Walk types as well as bodies. An opaque helper can mention the extracted + -- operation only in its type; inspecting reduced bodies would miss that + -- dependency and admit a family with an implementation-specific premise. + let mut pending := info.type.getUsedConstants ++ value.getUsedConstants + let mut seen : NameHashSet := {} + while !pending.isEmpty do + let current := pending.back! + pending := pending.pop + if current == modelName then + throwError "Independent contract {familyName} depends on its extracted operation" + if seen.contains current then continue + seen := seen.insert current + let dependency ← getConstInfo current + pending := pending ++ dependency.type.getUsedConstants + if let some body := dependency.value? (allowOpaque := true) then + pending := pending ++ body.getUsedConstants + +/-- Compare the contract for every possible execution outcome. The independent +family must also specialize to its independently authored concrete proposition. +No extracted function body is available as a premise of this implication. -/ +meta def contractAdequacyType (specName expectedName : Name) : MetaM Expr := do + AeneasSpecs.checkSpecContract specName + let (modelName, count) ← AeneasSpecs.specBinding specName + checkIndependentFamily (expectedName.appendAfter "_contract") modelName count + let family ← mkConstWithFreshMVarLevels (AeneasSpecs.specContractName specName) + forallTelescope (← inferType family) fun inputs resultType => do + unless inputs.size == count + 1 && resultType.consumeMData == mkSort .zero do + throwError "Required comparison has an unexpected contract family type" + let expectedAt ← mkAppM (expectedName.appendAfter "_contract") inputs + let expectedFamily := expectedAt.getAppFn + unless ← isDefEq (← inferType expectedAt) (mkSort .zero) do + throwError "Independent contract family must have the same input and outcome types" + -- Quantify over arbitrary run, then form AuthoredAt -> RequiredAt. The + -- actual extracted call is introduced only in the specialization check + -- below, so it cannot prove away a weak post or an accidentally empty domain. + let implication ← mkArrow (mkAppN family inputs) expectedAt + let adequacy ← mkForallFVars inputs implication + -- The concrete expectation and its family must be two views of the same + -- promise. Otherwise a true alias could conceal a false expected family. + let .defnInfo source ← getConstInfo specName + | throwError "Expected a transparent specification" + forallTelescope (source.value.instantiateLevelParams source.levelParams family.constLevels!) fun sourceInputs body => do + let call := body.consumeMData.getAppArgs[1]! + let originalInputs := sourceInputs.extract 0 count + let concrete := mkAppN expectedFamily (originalInputs.push call) + let specialized ← mkForallFVars originalInputs concrete + unless ← isDefEq (← mkConstWithFreshMVarLevels expectedName) specialized do + throwError "Independent proposition {expectedName} does not specialize its arbitrary-outcome family" + instantiateMVars adequacy + +elab "contract_adequacy% " source:ident " implies " expected:ident : term => do + let specName ← Elab.realizeGlobalConstNoOverloadWithInfo source + let expectedName ← Elab.realizeGlobalConstNoOverloadWithInfo expected + contractAdequacyType specName expectedName + +-- Only after adequacy is proved may its abstract outcome be specialized to +-- the actual call and supplied canonical proof. +elab "required_contract_instance% " candidate:ident " with " adequate:ident : term => do + let proofName ← Elab.realizeGlobalConstNoOverloadWithInfo candidate + let adequateName ← Elab.realizeGlobalConstNoOverloadWithInfo adequate + let specName ← canonicalSpecName proofName + let (modelName, count) ← AeneasSpecs.specBinding specName + let family ← mkConstWithFreshMVarLevels (AeneasSpecs.specContractName specName) + forallTelescope (← inferType family) fun inputs _ => do + let originals := inputs.extract 0 count + -- The audited prefix includes implicit Rust type arguments. Apply it + -- literally; mkAppM would instead insert them and treat the first supplied + -- type as a value argument for an implicit-generic extracted operation. + let call ← mkAppOptM modelName (originals.map some) + let proof ← mkAppOptM proofName (originals.map some) + let value ← mkAppOptM adequateName ((originals ++ #[call, proof]).map some) + let _ ← inferType value + mkLambdaFVars originals (← instantiateMVars value) + +/-- Generate a kernel-checked implication about arbitrary outcomes, then apply +it to the proved implementation. Normalization can use a domain-specific lemma, +but it cannot substitute a second proof of the concrete implementation. -/ +elab "check_contract " expected:ident " using " candidate:term : command => do + let candidateId ← match candidate with + | `(@$id:ident) => pure id + | `($id:ident) => pure id + | _ => throwErrorAt candidate "Required contract candidate must be a bare canonical theorem" + let (specName, count, dictionaryCount) ← Elab.Command.liftTermElabM do + let proofName ← Elab.realizeGlobalConstNoOverloadWithInfo candidateId + let specName ← canonicalSpecName proofName + let (_, count) ← AeneasSpecs.specBinding specName + let .defnInfo source ← getConstInfo specName + | throwError "Expected a transparent specification" + let dictionaryCount ← forallTelescope source.value fun inputs _ => do + let mut dictionaries := 0 + for input in inputs[:count] do + if (← whnf (← inferType input)).isSort then + dictionaries := dictionaries + 1 + pure dictionaries + pure (specName, count, dictionaryCount) + let source := mkIdentFrom candidateId specName + let family := mkIdentFrom expected (expected.getId.appendAfter "_contract") + let sourceFamily := mkIdentFrom candidateId (AeneasSpecs.specContractName specName) + let adequate := mkIdentFrom expected (expected.getId.appendAfter "_adequate") + let witness := mkIdentFrom expected (expected.getId.appendAfter "_checked") + let arguments := (List.range (count + 1)).toArray.map + (fun i => mkIdent (Name.mkSimple s!"_contractInput{i}")) + let dictionaries := (List.range dictionaryCount).toArray.map + (fun i => mkIdent (Name.mkSimple s!"_contractDictionary{i}")) + -- Source type parameters contribute original model dictionaries after run. + -- Specialize the authored premise with those same dictionaries before proving + -- the independent implication; ghost instances cannot select new meanings. + let introduceDictionaries ← if dictionaries.isEmpty then `(tactic| skip) else + `(tactic| (intro $dictionaries:ident*; specialize @provided $dictionaries:ident*)) + -- These tactics produce an ordinary theorem, not a trusted comparison + -- result. They normalize known decoder/word facts and align forall/exists + -- witnesses. If the implication is false or needs a new lemma, elaboration + -- fails rather than falling back to a proof of the concrete operation. + Elab.Command.elabCommand (← `(theorem $adequate : + contract_adequacy% $source implies $expected := by + intro $arguments:ident* provided + $introduceDictionaries:tactic + first + | exact provided + | (simp (disch := assumption) only [contract_simps]; done) + | try unfold $family + try unfold $sourceFamily at provided + convert provided using 1 <;> + (simp only [WP.spec_equiv_exists] + all_goals simp (config := { index := false, contextual := true }) only [contract_simps]) + all_goals + (congr! 2 + all_goals first | rfl | (split <;> simp_all only [contract_simps]) | skip) + all_goals + (congr! 4 + all_goals first | rfl | (split <;> simp_all only [contract_simps]) | grind) + all_goals rfl)) + if ← MonadLog.hasErrors then return + Elab.Command.elabCommand (← `(theorem $witness : $expected := + required_contract_instance% $candidateId with $adequate)) + +end AeneasContracts diff --git a/verification/aeneas/lean/RequiredModelContracts.lean b/verification/aeneas/lean/RequiredModelContracts.lean new file mode 100644 index 0000000000..f845f2fb68 --- /dev/null +++ b/verification/aeneas/lean/RequiredModelContracts.lean @@ -0,0 +1,28 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import RequiredModelContracts.Util +@[expose] public section + +/-! +These adapters prove that mathematical inline promises retain independently +stated raw behavior and input coverage. Each receives provided, a proof about +an arbitrary run, and derives the matching Obligations proposition for that +same run. It never executes or independently proves the function to bypass a +weak authored spec. + +Constructing mathematical input witnesses from raw premises checks the +promised domain. Recovering raw facts from the decoded result checks the +promised observations. The contract_simps attribute makes these ordinary +proved implications available to check_contract's bounded normalization. +-/ +open Aeneas Aeneas.Std AeneasSpecs +namespace Zerocopy.Proofs + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/RequiredModelContracts/Util.lean b/verification/aeneas/lean/RequiredModelContracts/Util.lean new file mode 100644 index 0000000000..674014acc6 --- /dev/null +++ b/verification/aeneas/lean/RequiredModelContracts/Util.lean @@ -0,0 +1,81 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Obligations +public import Specs +@[expose] public section + +/-! +The arithmetic inline specs use bounded mathematical words; the independent +expectations retain raw machine-word observations. These adapters bridge +those descriptions for an arbitrary execution outcome, using only the +supplied spec. They must construct decoded inputs from the independent raw +domain and recover raw output facts from successful decoding. Proving the +implementation again would not establish that the inline promise itself is +strong enough. +-/ +open Aeneas Aeneas.Std AeneasSpecs +namespace Zerocopy.Proofs + +-- These bridges consume the supplied weak-stage model contract and recover +-- the unchanged independent c85 propositions. No raw function proof is used. +@[contract_simps] theorem required_padding (len : Usize) (align : NonZeroUsize) + (run : Result Usize) (provided : Specs.padding_lt_alignment_contract len align run) : + Obligations.padding_lt_alignment_contract len align run := by + intro positive + let av : NonZeroUsizeValue := ⟨unsignedWord align.val, positive⟩ + have admitted := (decodeNonZeroUScalar_iff align av).mpr rfl + apply WP.spec_mono (provided (unsignedWord len) rfl av admitted) + rintro output ⟨value, decoded, facts⟩ + have same := (decodeUScalar_iff output value).mp decoded + simpa only [unsignedWord, av, ← same] using facts + +@[contract_simps] theorem required_round_down (n : Usize) (align : NonZeroUsize) + (run : Result Usize) (provided : Specs.round_down_spec_contract n align run) : + Obligations.round_down_spec_contract n align run := by + intro positive pow2 + let av : NonZeroUsizeValue := ⟨unsignedWord align.val, positive⟩ + have admitted := (decodeNonZeroUScalar_iff align av).mpr rfl + apply WP.spec_mono (provided (unsignedWord n) rfl av admitted pow2) + rintro output ⟨value, decoded, facts⟩ + have same := (decodeUScalar_iff output value).mp decoded + simpa only [unsignedWord, av, ← same] using facts + +@[contract_simps] theorem required_max (a b : NonZeroUsize) (run : Result NonZeroUsize) + (provided : Specs.max_spec_contract a b run) : + Obligations.max_spec_contract a b run := by + intro pa pb + let av : NonZeroUsizeValue := ⟨unsignedWord a.val, pa⟩ + let bv : NonZeroUsizeValue := ⟨unsignedWord b.val, pb⟩ + have da := (decodeNonZeroUScalar_iff a av).mpr rfl + have db := (decodeNonZeroUScalar_iff b bv).mpr rfl + have result := provided av da bv db + simp only [WP.spec_equiv_exists] at result + obtain ⟨output, call, value, decoded, facts⟩ := result + have same := (decodeNonZeroUScalar_iff output value).mp decoded + refine ⟨output, call, ?_, ?_⟩ + · rw [same]; exact value.positive + · simpa only [av, bv, unsignedWord, ← same] using facts + +@[contract_simps] theorem required_min (a b : NonZeroUsize) (run : Result NonZeroUsize) + (provided : Specs.min_spec_contract a b run) : + Obligations.min_spec_contract a b run := by + intro pa pb + let av : NonZeroUsizeValue := ⟨unsignedWord a.val, pa⟩ + let bv : NonZeroUsizeValue := ⟨unsignedWord b.val, pb⟩ + have da := (decodeNonZeroUScalar_iff a av).mpr rfl + have db := (decodeNonZeroUScalar_iff b bv).mpr rfl + have result := provided av da bv db + simp only [WP.spec_equiv_exists] at result + obtain ⟨output, call, value, decoded, facts⟩ := result + have same := (decodeNonZeroUScalar_iff output value).mp decoded + refine ⟨output, call, ?_, ?_⟩ + · rw [same]; exact value.positive + · simpa only [av, bv, unsignedWord, ← same] using facts +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/RustModel.lean b/verification/aeneas/lean/RustModel.lean new file mode 100644 index 0000000000..8db51158bd --- /dev/null +++ b/verification/aeneas/lean/RustModel.lean @@ -0,0 +1,131 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Aeneas +public meta import Lean +@[expose] public section + +/-! +A `RustModel` interprets a raw Rust representation by either accepting it with a +mathematical value (`some`) or rejecting it (`none`). That `Option` is decoder +admission, separate from both a Rust `Option` payload and Aeneas execution +failure. There is no requirement to reconstruct a raw value from its model or +to preserve information that the operation contracts do not observe. + +The builtin container models recursively decode their contents. One rejected +child rejects its container. Slices and arrays retain their length constraints +in proof fields; list decoding preserves length, so those constraints can be +transferred from the raw representation without a second runtime check. +-/ + +open Lean Lean.Meta Aeneas.Std +namespace AeneasSpecs + +/-- The provider fixes both the mathematical type and the decoder. Two providers +may share a Model type while assigning different meanings or admission domains +to the same raw value, so contracts retain the actual provider explicitly. -/ +class RustModel (Raw : Type u) where + Model : Type u + decode : Raw → Option Model + +-- The associated type is transparent so ordinary rewriting and coercions can +-- identify a retained provider's Model with its named mathematical carrier. +attribute [reducible] RustModel.Model + +abbrev ModelOf (Raw : Type u) [m : RustModel Raw] : Type u := m.Model + +/-- Admission means that some mathematical witness exists. It does not require +a canonical witness, an encoder, or a separately authored validity predicate. -/ +def isValid {Raw : Type u} [m : RustModel Raw] (raw : Raw) : Prop := + ∃ value, m.decode raw = some value + +/-- Automatic contracts call this with their retained explicit provider. -/ +def decoded {Raw : Type u} (m : RustModel Raw) (raw : Raw) (value : m.Model) : Prop := + m.decode raw = some value + +@[reducible] instance modelBool : RustModel Bool := ⟨Bool, some⟩ +@[reducible] instance modelNat : RustModel Nat := ⟨Nat, some⟩ +@[reducible] instance modelInt : RustModel Int := ⟨Int, some⟩ +@[reducible] instance modelUnit : RustModel Unit := ⟨Unit, some⟩ + +@[reducible] instance modelProd {α : Type u} {β : Type v} [ma : RustModel α] [mb : RustModel β] : + RustModel (α × β) where + Model := ma.Model × mb.Model + decode raw := match ma.decode raw.1, mb.decode raw.2 with + | some a, some b => some (a, b) + | _, _ => none + +-- Raw none is an admitted mathematical none. A raw some needs child decoding; +-- a rejected child returns decoder none, not an admitted Option payload. +@[reducible] instance modelOption {α : Type u} [ma : RustModel α] : RustModel (Option α) where + Model := Option ma.Model + decode | none => some none | some raw => (ma.decode raw).map some + +def decodeList {α β : Type u} (decode : α → Option β) : List α → Option (List β) + | [] => some [] + | raw :: raws => do return (← decode raw) :: (← decodeList decode raws) + +/-- Each successful list step contributes exactly one output element. This +length equality transports the existing slice/array proof to the decoded list. -/ +theorem decodeList_length {α β : Type u} (decode : α → Option β) + {raws : List α} {values : List β} (h : decodeList decode raws = some values) : + values.length = raws.length := by + induction raws generalizing values with + | nil => simp [decodeList] at h; subst values; rfl + | cons raw raws ih => + simp only [decodeList] at h + cases hd : decode raw <;> simp [hd] at h + rename_i value + cases hs : decodeList decode raws <;> simp [hs] at h + rename_i tail + subst values + simp [ih hs] + +@[reducible] instance modelList {α : Type u} [ma : RustModel α] : RustModel (List α) := + ⟨List ma.Model, decodeList ma.decode⟩ + +/-- Mathematical slice shape; its length bound needs no child decoder. -/ +abbrev SliceValue (Element : Type u) := + { values : List Element // values.length ≤ Usize.max } + +/-- Mathematical array shape; its fixed length needs no child decoder. -/ +abbrev ArrayValue (Element : Type u) (n : Usize) := + { values : List Element // values.length = n.val } + +@[reducible] instance modelSlice {α : Type u} [ma : RustModel α] : RustModel (Slice α) where + Model := SliceValue ma.Model + decode raw := match h : decodeList ma.decode raw.val with + | none => none + | some values => some ⟨values, (decodeList_length ma.decode h) ▸ raw.property⟩ + +@[reducible] instance modelArray {α : Type u} (n : Usize) [ma : RustModel α] : + RustModel (Aeneas.Std.Array α n) where + Model := ArrayValue ma.Model n + decode raw := match h : decodeList ma.decode raw.val with + | none => none + | some values => some ⟨values, (decodeList_length ma.decode h) ▸ raw.property⟩ + +-- This is Rust's Ok/Err payload type. Either constructor is admitted exactly +-- when its selected child decodes, independently of Aeneas execution Result. +@[reducible] instance modelRustResult {α : Type u} {β : Type v} [ma : RustModel α] [mb : RustModel β] : + RustModel (core.result.Result α β) where + Model := core.result.Result ma.Model mb.Model + decode | .Ok raw => (ma.decode raw).map .Ok | .Err raw => (mb.decode raw).map .Err + +/-- Reject borrow continuations even when nested inside supported containers. -/ +meta partial def rejectEscapingFunctions (carrierType : Expr) : MetaM Unit := do + let carrierType ← whnf carrierType + if carrierType.isForall then + throwError "Unsupported escaping function or backward reconstruction in model carrier" + -- Only type-valued arguments can hide another carrier. Values such as an + -- array's fixed length are not recursively treated as modelable types. + for arg in carrierType.getAppArgs do + if (← whnf (← inferType arg)).isSort then rejectEscapingFunctions arg + +end AeneasSpecs diff --git a/verification/aeneas/lean/SpecPrelude.lean b/verification/aeneas/lean/SpecPrelude.lean new file mode 100644 index 0000000000..b79101dd16 --- /dev/null +++ b/verification/aeneas/lean/SpecPrelude.lean @@ -0,0 +1,25 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Rust.Bytes +public import Zerocopy.Types +@[expose] public section + +/-! +This small prelude gives handwritten proofs the extracted type declarations +and the exact native nonzero-usize carrier. It names that carrier without +introducing a decoder or a claim that every extracted wrapper is nonzero. +-/ + +open Aeneas Aeneas.Std +namespace Zerocopy.Proofs + +abbrev NonZeroUsize := core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/SpecsSyntax.lean b/verification/aeneas/lean/SpecsSyntax.lean new file mode 100644 index 0000000000..cd1a897abc --- /dev/null +++ b/verification/aeneas/lean/SpecsSyntax.lean @@ -0,0 +1,398 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import DeriveModels +public meta import Lean +public section + +/-! +An inline Rust `spec` becomes an ordinary Lean proposition about the extracted +function. This module elaborates that proposition in three stages: retain the +original raw arguments and their model providers, introduce decoded witnesses +and authored premises, then require a decoded successful result satisfying all +postconditions. A `(raw)` clause changes the names available to its author; it +never changes the arguments passed to the extracted function. + +The generated `_contract` family replaces only the execution outcome. Required +checks can therefore ask whether the authored contract implies an independent +promise for every outcome, including an incorrect return or divergence. The +binding audits below inspect Lean expressions rather than trusting printed +names or a second interpretation of the source syntax. +-/ + +open Lean Lean.Meta Aeneas Aeneas.Std +namespace AeneasSpecs + +meta initialize specAttribute : TagAttribute ← + registerTagAttribute `aeneas_spec "A transparent Aeneas specification" + +declare_syntax_cat specRequirement +syntax "requires " ident " : " term : specRequirement +syntax "requires(raw) " ident " : " term : specRequirement + +declare_syntax_cat specPostcondition +syntax "ensures " term+ " => " term : specPostcondition +syntax "ensures(raw) " term+ " => " term : specPostcondition + +syntax (name := totalSpec) "spec " ident bracketedBinder* " for " term + " with " num " type " "parameters" specRequirement* specPostcondition+ : command +syntax (name := partialSpec) "partial " "spec " ident bracketedBinder* " for " term + " with " num " type " "parameters" specRequirement* specPostcondition+ : command + +syntax (name := specBody) "aeneas_spec_body% " term " with " num " mode% " num + " ghosts% " bracketedBinder* " requires% " specRequirement* + " posts% " specPostcondition+ : term + +/-- Prevent a ghost or requirement from hiding an original input or another +premise. The user-facing name is checked after removing macro hygiene scopes; +two visually identical binders must not denote different contract inputs. -/ +private meta def checkFreshNames (previous : Array Expr) (newInputs : Array Expr) + : Elab.Term.TermElabM Unit := do + let mut names ← previous.mapM fun e => return (← e.fvarId!.getDecl).userName.eraseMacroScopes + for input in newInputs do + let binderName := (← input.fvarId!.getDecl).userName.eraseMacroScopes + if names.contains binderName then + throwError "Specification binder {binderName} shadows an original input or an earlier binder" + names := names.push binderName + +/-- Open the extracted function's forall type one binder at a time. The local +variables are the exact raw/type arguments later passed back to that function; +no decoded witness, ghost, or requirement can enter this argument prefix. -/ +private meta partial def withOriginalInputs (carrier : Expr) (inputs : Array Expr) + (k : Array Expr → Expr → Elab.Term.TermElabM Expr) : Elab.Term.TermElabM Expr := do + match carrier with + | .forallE binderName domain body _ => + Meta.withLocalDeclD binderName.eraseMacroScopes domain fun input => do + checkFreshNames inputs #[input] + withOriginalInputs (body.instantiate1 input) (inputs.push input) k + | _ => k inputs carrier + +/-- Clause scopes change only local declaration names. Authored terms elaborate +against exact free variables; no textual identifier replacement is performed. -/ +private meta def withClauseScope (raws maths : Array Expr) (useRaw : Bool) + (k : Elab.Term.TermElabM α) : Elab.Term.TermElabM α := withFreshMacroScope do + let mut lctx ← getLCtx + -- A free variable's identity stays fixed when its display name changes. + -- Hide the other interpretation with fresh macro scopes so ordinary clause + -- lookup sees only the requested raw or mathematical input names. + for i in [:raws.size] do + let userName := (← raws[i]!.fvarId!.getDecl).userName + let hiddenRaw ← MonadQuotation.addMacroScope (Name.mkSimple s!"_rawInput{i}") + let hiddenMath ← MonadQuotation.addMacroScope (Name.mkSimple s!"_mathInput{i}") + lctx := lctx.setUserName raws[i]!.fvarId! (if useRaw then userName else hiddenRaw) + lctx := lctx.setUserName maths[i]!.fvarId! (if useRaw then hiddenMath else userName) + Meta.withLCtx' lctx k + +/-- Introduce a mathematical witness immediately followed by its decoding +premise for each raw value. These are universally quantified contract inputs: +the operation promise applies whenever that original raw value decodes to the +supplied witness, using the provider selected before authored binders exist. -/ +private meta partial def withDecodedInputs (raws providers : Array Expr) (i : Nat) + (maths equations : Array Expr) + (k : Array Expr → Array Expr → Elab.Term.TermElabM Expr) : Elab.Term.TermElabM Expr := do + if h : i < raws.size then + let raw := raws[i] + let rawTy ← Meta.inferType raw + let provider := providers[i]! + let mathTy ← providerModel rawTy provider + Meta.withLocalDeclD (Name.mkSimple s!"_mathInput{i}") mathTy fun math => do + let equation ← Meta.mkEq (← providerDecode rawTy provider raw) + (← Meta.mkAppM ``Option.some #[math]) + Meta.withLocalDeclD (Name.mkSimple s!"_decodedInput{i}") equation fun hDecoded => + withDecodedInputs raws providers (i+1) (maths.push math) (equations.push hDecoded) k + else k maths equations + +/-- Elaborate requirements in source order. Each named premise is available +to later requirements and posts, while its clause's raw/math scope affects only +original input names. The freshness check preserves all earlier binder names. -/ +private meta partial def withRequirements (requirements : Array Syntax) (i : Nat) + (raws maths previous : Array Expr) + (k : Array Expr → Elab.Term.TermElabM Expr) : Elab.Term.TermElabM Expr := do + if h : i < requirements.size then + let (id, proposition, useRaw) ← match requirements[i] with + | `(specRequirement| requires $id:ident : $pre:term) => pure (id, pre, false) + | `(specRequirement| requires(raw) $id:ident : $pre:term) => pure (id, pre, true) + | _ => throwError "Unexpected requirement: {repr requirements[i]}" + let carrierType ← withClauseScope raws maths useRaw + (Elab.Term.elabTermEnsuringType proposition (mkSort .zero)) + Meta.withLocalDeclD id.getId carrierType fun hypothesis => do + checkFreshNames previous #[hypothesis] + withRequirements requirements (i+1) raws maths (previous.push hypothesis) k + else k previous + +/-- Use upstream result-pattern notation, retaining ordinary Lean ascriptions +and tuple patterns in either raw or mathematical result scope. -/ +private meta def patternPredicate (patterns : Array (TSyntax `term)) + (body : TSyntax `term) : Elab.Term.TermElabM (TSyntax `term) := do + let contract ← match patterns.toList with + | [binder] => `(Result.ok () ⦃ $binder => $body ⦄) + | binder :: rest => do + let tail := rest.toArray + `(Result.ok () ⦃ $binder $tail:term* => $body ⦄) + | [] => throwError "A result pattern is required" + let some expanded ← Elab.liftMacroM (expandMacro? contract.raw) | throwError "Unsupported result pattern" + match expanded with + | `(Aeneas.Std.WP.spec $_call $post) => pure post + | _ => throwError "Unsupported upstream result-pattern expansion" + +/-- All posts share one successful raw result and one decoded witness. The +existential's decoder equation is required even for a raw-only or True post; +otherwise such clauses would accidentally permit rejected successful outputs. -/ +private meta def postcondition (posts : Array Syntax) (raws maths : Array Expr) + (rawType provider : Expr) : Elab.Term.TermElabM Expr := do + Meta.withLocalDeclD `_rawOutput rawType fun raw => do + let mathType ← providerModel rawType provider + Meta.withLocalDeclD `_mathOutput mathType fun math => do + let mut propositions := #[] + for post in posts do + unless post.getNumArgs == 4 && #["ensures", "ensures(raw)"].contains post[0].getAtomVal do + throwError "Malformed specification postcondition" + let patterns : Array (TSyntax `term) := post[1].getArgs.map (fun stx => ⟨stx⟩) + let body : TSyntax `term := ⟨post[3]⟩ + let useRaw := post[0].getAtomVal == "ensures(raw)" + let predicateSyntax ← patternPredicate patterns body + let carrierType ← mkArrow (if useRaw then rawType else mathType) (mkSort .zero) + let predicate ← withClauseScope raws maths useRaw + (Elab.Term.elabTermEnsuringType predicateSyntax carrierType) + propositions := propositions.push (mkApp predicate (if useRaw then raw else math)) + -- Preserve clause order in a right-associated conjunction. The grammar + -- requires at least one post, so back! cannot read an empty array here. + let mut conjunction := propositions.back! + for p in (propositions.extract 0 (propositions.size-1)).reverse do + conjunction ← Meta.mkAppM ``And #[p, conjunction] + let equation ← Meta.mkEq (← providerDecode rawType provider raw) + (← Meta.mkAppM ``Option.some #[math]) + let body ← Meta.mkAppM ``And #[equation, conjunction] + let existsExpr ← Meta.mkAppM ``Exists #[← Meta.mkLambdaFVars #[math] body] + Meta.mkLambdaFVars #[raw] existsExpr + +@[term_elab specBody] meta def elabSpecBody : Elab.Term.TermElab := fun stx _ => do + match stx with + | `(aeneas_spec_body% $function:term with $count:num mode% $mode:num + ghosts% $ghosts:bracketedBinder* requires% $requirements:specRequirement* + posts% $posts:specPostcondition*) => + let id ← match function with + | `(@$id:ident) => pure id + | `($id:ident) => pure id + | _ => throwErrorAt function "A specification must name a bare extracted function constant" + let functionName ← Elab.realizeGlobalConstNoOverloadWithInfo id + let functionExpr ← Meta.mkConstWithFreshMVarLevels functionName + withOriginalInputs (← Meta.inferType functionExpr) #[] fun originals returnType => do + -- The generated count identifies the source generic prefix. Check both + -- sides of the boundary: counted parameters must be Type-valued, and + -- later value arguments must not contain an omitted type parameter. + unless count.getNat ≤ originals.size do throwErrorAt count "Invalid Rust type-parameter prefix" + for input in originals[:count.getNat] do + let carrierType ← Meta.whnf (← Meta.inferType input) + unless carrierType.isSort && carrierType != mkSort .zero do + throwErrorAt count "Rust generic parameters must be the original Type-valued prefix" + for input in originals[count.getNat:] do + if (← Meta.whnf (← Meta.inferType input)).isSort then + throwErrorAt count "Rust type-parameter count omits an original type parameter" + let returnType ← Meta.whnf returnType + -- This Result represents execution (return, panic, divergence), not a + -- Rust Result payload. Its successful payload gets the output decoder. + unless returnType.isAppOfArity ``Result 1 do + throwErrorAt function "Extracted function must return an Aeneas execution Result" + let rawOutputType := returnType.appArg! + let params := originals.extract 0 count.getNat + let raws := originals.extract count.getNat originals.size + let inputNames ← raws.mapM fun raw => return (← raw.fvarId!.getDecl).userName + withModelDictionaries params fun dictionaries => do + -- Fix all automatic providers before introducing decoded witnesses, + -- dependent ghosts, requirements, or their local instances. + let providers ← raws.mapM fun raw => do + boundModelProvider (← Meta.inferType raw) params dictionaries + let outputProvider ← boundModelProvider rawOutputType params dictionaries + withDecodedInputs raws providers 0 #[] #[] fun maths equations => do + let decodedPrefix := (maths.zip equations).foldl + (fun inputs pair => inputs ++ #[pair.1, pair.2]) #[] + let boundPrefix := originals ++ dictionaries ++ decodedPrefix + withClauseScope raws maths false <| Elab.Term.elabBinders ghosts fun ghostInputs => do + -- Restore raw names as the baseline for each clause-specific scope. + let mut lctx ← getLCtx + for i in [:raws.size] do + lctx := lctx.setUserName raws[i]!.fvarId! inputNames[i]! + lctx := lctx.setUserName maths[i]!.fvarId! (Name.mkSimple s!"_mathInput{i}") + Meta.withLCtx' lctx do + checkFreshNames boundPrefix ghostInputs + withRequirements requirements 0 raws maths (boundPrefix ++ ghostInputs) fun inputs => do + let post ← postcondition posts raws maths rawOutputType outputProvider + -- Execute on the untouched raw prefix. Total WP.spec excludes + -- panic and divergence; partial WP.dspec allows divergence but + -- still checks every successful return and excludes panic. + let call := mkAppN functionExpr originals + let wp := if mode.getNat == 0 then ``WP.spec else ``WP.dspec + let body ← Meta.mkAppM wp #[call, post] + Elab.Term.synthesizeSyntheticMVarsNoPostponing + Elab.Term.levelMVarToParam (← Meta.mkForallFVars inputs (← instantiateMVars body)) + | _ => throwError "Unexpected specification syntax: {repr stx}" + +syntax "aeneas_spec_decl " ident " := " term : command + +private meta def expandSpec (id : TSyntax `ident) + (ghosts : Array (TSyntax ``Lean.Parser.Term.bracketedBinder)) + (function : TSyntax `term) (count : TSyntax `num) (isPartial : Bool) + (requirements : Array (TSyntax `specRequirement)) + (posts : Array (TSyntax `specPostcondition)) : MacroM Syntax := do + let mode := Syntax.mkNumLit (if isPartial then "1" else "0") + let body ← `(aeneas_spec_body% $function with $count mode% $mode:num + ghosts% $ghosts:bracketedBinder* requires% $requirements:specRequirement* posts% $posts:specPostcondition*) + `(set_option linter.unusedVariables false in + aeneas_spec_decl $id := $body) + +macro_rules + | `(spec $id:ident $ghosts:bracketedBinder* for $function:term with $count:num type parameters + $requirements:specRequirement* $posts:specPostcondition*) => + expandSpec id ghosts function count false requirements posts + | `(partial spec $id:ident $ghosts:bracketedBinder* for $function:term with $count:num type parameters + $requirements:specRequirement* $posts:specPostcondition*) => + expandSpec id ghosts function count true requirements posts + +-- A fence owns one specification declaration. Restricting it here prevents a +-- source annotation from injecting unrelated declarations into generated Specs. +syntax "aeneas_spec_begin " command " aeneas_spec_end" : command +macro_rules + | `(aeneas_spec_begin $declaration:command aeneas_spec_end) => do + unless #[``totalSpec, ``partialSpec].contains declaration.raw.getKind do + Macro.throwErrorAt declaration "An Aeneas fence must contain exactly one spec declaration" + pure declaration.raw + +/-- Read a specification's actual model call without reducing the application. +Each argument must be its original prefix input variable; ghost inputs and +requirements may follow that prefix. Both the generated command and the final +coverage audit use this same elaborated-expression check. -/ +meta def specBinding (specName : Name) : MetaM (Name × Nat) := do + let info ← getConstInfo specName + let .defnInfo declaration := info + | throwError "Expected a specification declaration, got {specName}" + unless specAttribute.hasTag (← getEnv) specName do + throwError "Expected a specification declaration, got {specName}" + Meta.forallTelescope declaration.value fun inputs body => do + let body := body.consumeMData + unless body.getAppFn.isConstOf ``WP.spec || body.getAppFn.isConstOf ``WP.dspec do + throwError "Specification {specName} does not directly state a total or partial contract" + -- WP's arguments are the payload type, execution, and result predicate. + -- Inspect this application without unfolding the extracted function: its + -- constant and each original free-variable argument must remain visible. + let contractArgs := body.getAppArgs + unless contractArgs.size == 3 do + throwError "Specification {specName} has an unexpected contract application" + let call := contractArgs[1]!.consumeMData + let .const modelName _ := call.getAppFn + | throwError "Specification {specName} does not directly call a model declaration" + let arguments := call.getAppArgs + unless inputs.size ≥ arguments.size do + throwError "Specification {specName} has more model arguments than bound inputs" + -- Exact expression identity rejects reordered, replaced, or reconstructed + -- arguments, even when a replacement might compute the same value. + for index in [:arguments.size] do + unless arguments[index]!.consumeMData == inputs[index]! do + throwError "Specification {specName} model input {index + 1} is not its original bound variable" + pure (modelName, arguments.size) + +/-- The contract family abstracts only the audited execution node. Original +inputs stay first; one outcome then precedes dictionaries and all premises. +It cannot select a different execution for different ghosts. -/ +meta def specContractName (specName : Name) : Name := specName.appendAfter "_contract" + +meta def abstractSpecContract (specName : Name) : MetaM Expr := do + let (_, count) ← specBinding specName + let .defnInfo info ← getConstInfo specName + | throwError "Expected a transparent specification" + forallTelescope info.value fun inputs body => do + let body := body.consumeMData + let args := body.getAppArgs + let call := args[1]! + -- Keep original arguments outside the outcome binder. All dictionaries, + -- witnesses, ghosts, and premises belong to the one outcome inside it. + withLocalDeclD `_executionOutcome (← inferType call) fun outcome => do + let predicate := mkAppN body.getAppFn #[args[0]!, outcome, args[2]!] + let predicate ← mkForallFVars (inputs.extract count inputs.size) predicate + mkLambdaFVars ((inputs.extract 0 count).push outcome) predicate + +/-- Check the abstract family itself as well as its concrete specialization. +A constant family could specialize correctly while ignoring its outcome. -/ +meta def checkSpecContract (specName : Name) : MetaM Unit := do + let (_, count) ← specBinding specName + let familyName := specContractName specName + let .defnInfo info ← getConstInfo familyName + | throwError "Missing abstract specification contract {familyName}" + lambdaTelescope info.value fun inputs body => do + unless inputs.size == count + 1 do + throwError "Abstract contract {familyName} changed its original input prefix" + let outcome := inputs[count]! + forallTelescope body fun _ predicate => do + let predicate := predicate.consumeMData + unless predicate.getAppFn.isConstOf ``WP.spec || predicate.getAppFn.isConstOf ``WP.dspec do + throwError "Abstract contract {familyName} does not directly state a WP contract" + let args := predicate.getAppArgs + unless args.size == 3 && args[1]!.consumeMData == outcome do + throwError "Abstract contract {familyName} does not use its execution outcome" + -- Outcome use alone is insufficient: an added premise could make the family + -- vacuous. Compare the full abstraction to preserve every original domain + -- premise, provider, mode, and postcondition as well. + unless ← isDefEq info.value (← abstractSpecContract specName) do + throwError "Abstract contract {familyName} changed the specification's premises or postcondition" + +elab_rules : command + | `(aeneas_spec_decl $id:ident := $body:term) => do + Elab.Command.elabCommand (← `(@[aeneas_spec] abbrev $id : Prop := $body)) + -- A failed authored body already has its diagnostic. Do not derive a + -- contract from Lean's error-recovery placeholder or add a second error. + if ← MonadLog.hasErrors then return + Elab.Command.liftTermElabM do + let specName ← Elab.realizeGlobalConstNoOverloadWithInfo id + let .defnInfo source ← getConstInfo specName + | throwError "Expected a transparent specification" + let value ← abstractSpecContract specName + let familyName := specContractName specName + addDecl (.defnDecl { + «name» := familyName + levelParams := source.levelParams + «type» := (← inferType value) + value := value + hints := .abbrev + safety := .safe }) (forceExpose := true) + modifyEnv (addNoncomputable · familyName) + enableRealizationsForConst familyName + checkSpecContract specName + +/-- Check the actual call against the independently verified model declaration +and Rust type/value parameter count supplied by the generated command. -/ +elab "check_spec_binding " candidate:ident " for " extractedId:ident " with " count:num : command => + Elab.Command.liftTermElabM do + let specName ← Elab.realizeGlobalConstNoOverloadWithInfo candidate + let modelName ← Elab.realizeGlobalConstNoOverloadWithInfo extractedId + let (actualModel, inputCount) ← withRef candidate (specBinding specName) + unless actualModel == modelName do + throwErrorAt candidate "Specification {specName} does not call the expected model {modelName}" + unless inputCount == count.getNat do + throwErrorAt candidate "Specification {specName} does not bind exactly {count.getNat} model inputs" + checkSpecContract specName + +/-- Register an ordinary theorem whose type names a `spec` proposition with +Aeneas's existing step database. This pinned upstream registry inspects theorem +types syntactically. The generated alias expands only specifications, and Lean +checks its original theorem value against the expanded type. -/ +elab "register_spec_step " candidate:ident : command => Elab.Command.liftCoreM do + let thmName ← Elab.realizeGlobalConstNoOverloadWithInfo candidate + let info ← getConstInfo thmName + let .thmInfo _ := info + | throwError "Expected a theorem, got {thmName}" + let env ← getEnv + let expanded ← Meta.deltaExpand info.type (specAttribute.hasTag env) + let adapterName := thmName.appendAfter "_step" + let declaration : TheoremVal := ⟨⟨adapterName, info.levelParams, expanded⟩, + mkConst thmName (info.levelParams.map Level.param), [adapterName]⟩ + addDecl (.thmDecl declaration) + Elab.addDeclarationRangesFromSyntax adapterName candidate + Attribute.add adapterName `step .missing .global + +end AeneasSpecs diff --git a/verification/aeneas/lean/SpecsSyntaxTests.lean b/verification/aeneas/lean/SpecsSyntaxTests.lean new file mode 100644 index 0000000000..b0ad71d9f4 --- /dev/null +++ b/verification/aeneas/lean/SpecsSyntaxTests.lean @@ -0,0 +1,251 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import ModelTests +public import SpecsSyntax +@[expose] public section + +/-! +These independent expansions pin what the authored clause syntax means after +elaboration: original raw execution, dictionary placement, interleaved decoded +witnesses and equations, clause scopes, shared result admission, and total versus +partial correctness. Several examples use competing ghost instances or raw-only +postconditions to ensure those conveniences cannot change provider selection or +remove the obligation to decode a successful return. +-/ + +open Lean Aeneas Aeneas.Std AeneasSpecs +namespace SpecsSyntaxTests +set_option linter.unusedVariables false +set_option linter.unusedSimpArgs false + +-- Ordinary Rust assertion harnesses return Unit. The provider lookup reduces +-- that abbreviation to PUnit.{1}; this regression checks the actual function +-- signature, macro expansion, and returned-value decoding together. +def unitAssertionRoot (x : Usize) : Result Unit := do + massert (x = x) + .ok () +check_model_inputs unitAssertionRoot with 0 type parameters +spec unit_assertion_root_spec for unitAssertionRoot with 0 type parameters + ensures(raw) _ => True + +example : unit_assertion_root_spec := by + intro x decoded hdecoded + simp [unitAssertionRoot, massert, WP.spec_ok, RustModel.decode, modelUnit] + +example : ¬ (Result.fail .panic : Result Unit) ⦃ _ => True ⦄ := by + simp + +def identity (α : Type u) (x : α) : Result α := .ok x +spec identity_spec for @identity with 1 type parameters + ensures y => y = x + ensures(raw) y => y = x +check_spec_binding identity_spec for identity with 2 +theorem identity_proof : identity_spec := by + intro α x m mx hx + simp only [identity, Result.ok] + exact ⟨mx, hx, rfl, rfl⟩ + +def sequenceIdentity (xs : List Nat) : Result (List Nat) := .ok xs +spec dependent_spec (position : Fin xs.length) for sequenceIdentity with 0 type parameters + requires h : position.val < xs.length + requires(raw) hr : position.val < xs.length + ensures ys => ys = xs ∧ h = h + ensures(raw) ys => ys = xs ∧ hr = hr +check_spec_binding dependent_spec for sequenceIdentity with 1 + +-- The output interpretation is fixed before the competing ghost exists. +def nativeIdentity (x : Nat) : Result Nat := .ok x +spec fixed_provider_spec [evil : RustModel Nat] for nativeIdentity with 0 type parameters + ensures(raw) y => y = x +example : fixed_provider_spec := by + intro x mx hx evil + change some x = some mx at hx + cases hx + exact ⟨x, rfl, rfl⟩ + +-- Independently authored expansions pin the scopes and upstream WP modes. +def wordIdentity (x : Usize) : Result Usize := .ok x +spec word_math for wordIdentity with 0 type parameters + requires h : x.value < 5 + ensures y => y.value = x.value +spec word_raw for wordIdentity with 0 type parameters + requires(raw) h : x.val < 5 + ensures(raw) y => y.val = x.val +partial spec word_partial for wordIdentity with 0 type parameters + ensures y => y.value = x.value +spec word_mixed for wordIdentity with 0 type parameters + requires h : x.value < 5 + requires(raw) hr : x.val < 5 + ensures y => y.value = x.value ∧ h = h + ensures(raw) y => y.val = x.val ∧ hr = hr + +example : word_math = + (∀ raw : Usize, ∀ math : UnsignedWord .Usize, + (modelUScalar .Usize).decode raw = some math → + (h : math.value < 5) → + WP.spec (wordIdentity raw) (fun rawOutput => + ∃ output : UnsignedWord .Usize, + (modelUScalar .Usize).decode rawOutput = some output ∧ output.value = math.value)) := rfl +example : word_raw = + (∀ raw : Usize, ∀ math : UnsignedWord .Usize, + (modelUScalar .Usize).decode raw = some math → + (h : raw.val < 5) → + WP.spec (wordIdentity raw) (fun rawOutput => + ∃ output : UnsignedWord .Usize, + (modelUScalar .Usize).decode rawOutput = some output ∧ rawOutput.val = raw.val)) := rfl +example : word_partial = + (∀ raw : Usize, ∀ math : UnsignedWord .Usize, + (modelUScalar .Usize).decode raw = some math → + WP.dspec (wordIdentity raw) (fun rawOutput => + ∃ output : UnsignedWord .Usize, + (modelUScalar .Usize).decode rawOutput = some output ∧ output.value = math.value)) := rfl +example : word_mixed = + (∀ raw : Usize, ∀ math : UnsignedWord .Usize, + (modelUScalar .Usize).decode raw = some math → + (h : math.value < 5) → (hr : raw.val < 5) → + WP.spec (wordIdentity raw) (fun rawOutput => + ∃ output : UnsignedWord .Usize, + (modelUScalar .Usize).decode rawOutput = some output ∧ + (output.value = math.value ∧ h = h) ∧ (rawOutput.val = raw.val ∧ hr = hr))) := rfl + +-- A generic ghost cannot change the original generated dictionary either. +spec generic_fixed [evil : RustModel α] for @identity with 1 type parameters + ensures(raw) y => y = x +example : generic_fixed := by + intro α x original math decoded evil + exact ⟨math, decoded, rfl⟩ + +-- Every source generic receives a dictionary, even if unused or result-only. +def unused (α : Type) (x : Nat) : Result Nat := .ok x +spec unused_spec for @unused with 1 type parameters + ensures y => y = x +example : unused_spec = + (∀ α : Type, ∀ raw : Nat, ∀ _dictionary : RustModel α, + ∀ math : Nat, some raw = some math → + WP.spec (unused α raw) (fun rawOutput => ∃ output : Nat, + some rawOutput = some output ∧ output = math)) := rfl + +def resultOnly (T : Type u) : Result (Option T) := .ok none +spec result_only for @resultOnly with 1 type parameters + ensures result => result = none +example : result_only := by + intro T m + exact ⟨none, rfl, rfl⟩ + +-- A restrictive supplied interpretation controls both sides of identity. +abbrev positiveOnly : RustModel Nat := ⟨Unit, fun x => if 0 < x then some () else none⟩ +example : positiveOnly.decode 0 = none := rfl +example : positiveOnly.decode 1 = some () := rfl +example : WP.spec (identity Nat 1) (fun raw => ∃ value : Unit, + positiveOnly.decode raw = some value ∧ value = () ∧ raw = 1) := + @identity_proof Nat 1 positiveOnly () rfl + +-- Concrete function instantiations are supported after Models has supplied +-- every nominal provider. This is distinct from applying a generic Fields +-- family to an undecoded concrete child during ModelShapes. +def concreteBoxIdentity (x : ModelTests.Box ModelTests.Child) : + Result (ModelTests.Box ModelTests.Child) := .ok x +check_model_inputs concreteBoxIdentity with 0 type parameters +spec concrete_box_identity for concreteBoxIdentity with 0 type parameters + ensures result => result = x + ensures(raw) result => result = x +example : concrete_box_identity := by + intro raw math decoded + exact ⟨math, decoded, rfl, rfl⟩ + +def childIdentity (x : ModelTests.Child) : Result ModelTests.Child := .ok x +spec model_dependent_ghost (position : Fin x.value) (constraint : x.positive = x.positive) + for childIdentity with 0 type parameters + ensures y => y.value = x.value + +-- Native pair projections retain bounded word carriers and Nat coercions. +def pairWords (x : Usize) : Result (Usize × Usize) := .ok (x, x) +spec pair_words for pairWords with 0 type parameters + ensures (a, b) => (a : Nat) = (x : Nat) ∧ (b : Nat) = (x : Nat) + +-- A function-valued payload is rejected before any ghost or clause elaborates. +def escapingOutput (x : Nat) : Result (Nat → Nat) := .ok (fun y => y) +/-- error: Unsupported escaping function or backward reconstruction in model carrier -/ +#guard_msgs in +discard_model_fixture spec escaping for escapingOutput with 0 type parameters ensures y => True + +/-- error: An Aeneas fence must contain exactly one spec declaration -/ +#guard_msgs in +discard_model_fixture aeneas_spec_begin + def injected : Nat := 0 +aeneas_spec_end + +def rejectedReturn (x : Nat) : Result + (core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner) := .ok ⟨0#usize⟩ +spec rejected_total for rejectedReturn with 0 type parameters ensures result => True +partial spec rejected_partial for rejectedReturn with 0 type parameters ensures result => True +example : ¬rejected_total := by + intro contract + have h := contract 0 0 rfl + simp [rejectedReturn, WP.spec, WP.dspec, RustModel.decode, modelNonZeroUScalar] at h +example : ¬rejected_partial := by + intro contract + have h := contract 0 0 rfl + simp [rejectedReturn, WP.spec, WP.dspec, RustModel.decode, modelNonZeroUScalar] at h + +-- Five generic raw arguments exercise arbitrary-arity witness elimination. +-- Only the original raw telescope is passed to the extracted function. +def fiveInputs (T : Type u) (a b c d e : T) : Result T := .ok a +spec five_inputs_raw for @fiveInputs with 1 type parameters + requires(raw) accepted : True + ensures(raw) result => result = a +check_spec_binding five_inputs_raw for fiveInputs with 6 +example : five_inputs_raw.{u} = + (∀ T : Type u, ∀ a b c d e : T, ∀ [m : RustModel T], + ∀ ma : m.Model, m.decode a = some ma → + ∀ mb : m.Model, m.decode b = some mb → + ∀ mc : m.Model, m.decode c = some mc → + ∀ md : m.Model, m.decode d = some md → + ∀ me : m.Model, m.decode e = some me → + (accepted : True) → + WP.spec (fiveInputs T a b c d e) (fun rawOutput => + ∃ output : m.Model, m.decode rawOutput = some output ∧ rawOutput = a)) := rfl +example : five_inputs_raw.{u} = + (∀ T : Type u, ∀ a b c d e : T, ∀ [m : RustModel T], + isValid a → isValid b → isValid c → isValid d → isValid e → + (accepted : True) → + WP.spec (fiveInputs T a b c d e) (fun rawOutput => isValid rawOutput ∧ rawOutput = a)) := by + apply propext + simp only [five_inputs_raw, isValid, forall_exists_index, WP.spec_equiv_exists, exists_and_left, exists_and_right] + +-- Two words also pin alternating witness/equation order independently of the +-- generic dictionary case above. +def pairFunction (a b : Nat) : Result (Nat × Nat) := .ok (a, b) +spec pair_words_interleaved for pairFunction with 0 type parameters + ensures(raw) result => result = (a, b) +example : pair_words_interleaved = + (∀ a b : Nat, ∀ ma : Nat, some a = some ma → + ∀ mb : Nat, some b = some mb → + WP.spec (pairFunction a b) (fun rawOutput => ∃ output : Nat × Nat, + (modelProd : RustModel (Nat × Nat)).decode rawOutput = some output ∧ rawOutput = (a, b))) := rfl +@[aeneas_spec] abbrev swapped : Prop := ∀ a b : Nat, + WP.spec (pairFunction b a) (fun _ => True) +/-- error: Specification SpecsSyntaxTests.swapped model input 1 is not its original bound variable -/ +#guard_msgs in +check_spec_binding swapped for pairFunction with 2 + +/-- error: Specification binder x shadows an original input or an earlier binder -/ +#guard_msgs in +discard_model_fixture spec shadows (x : Nat) for nativeIdentity with 0 type parameters + ensures y => True + +/-- error: Specification binder h shadows an original input or an earlier binder -/ +#guard_msgs in +discard_model_fixture spec duplicate_requires for nativeIdentity with 0 type parameters + requires h : True + requires(raw) h : True + ensures y => True + +end SpecsSyntaxTests diff --git a/verification/aeneas/lean/Zerocopy.lean b/verification/aeneas/lean/Zerocopy.lean new file mode 100644 index 0000000000..b1d17e74f3 --- /dev/null +++ b/verification/aeneas/lean/Zerocopy.lean @@ -0,0 +1,19 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Zerocopy.Funs + +/-! +This is the import root for the extracted program and its handwritten +external models. Aeneas supplies generated Types and Funs; the external +modules state the explicitly selected correspondence for operations outside +that translation. Specs and proofs import this root so their computation +names refer to the selected complete extraction, not to inline mathematical +definitions. +-/ diff --git a/verification/aeneas/lean/Zerocopy/FunsExternal.lean b/verification/aeneas/lean/Zerocopy/FunsExternal.lean new file mode 100644 index 0000000000..c820466380 --- /dev/null +++ b/verification/aeneas/lean/Zerocopy/FunsExternal.lean @@ -0,0 +1,24 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Zerocopy.Types +@[expose] public section +open Aeneas Aeneas.Std +open Zerocopy + +-- External semantic models: Clone copies the value; NonZero::get returns its +-- stored integer without failure. Their correspondence to Rust is part of the +-- trusted boundary described in ../../README.md, not a theorem of this package. +def core.num.niche_types.NonZeroUsizeInner.Insts.CoreCloneClone.clone + (x : core.num.niche_types.NonZeroUsizeInner) : + Result core.num.niche_types.NonZeroUsizeInner := .ok x + +@[simp] def core.num.nonzero.NonZero.get + {T Inner : Type} (_inst : core.num.nonzero.ZeroablePrimitive T Inner) + (x : core.num.nonzero.NonZero T Inner) : Result T := .ok x.val diff --git a/verification/aeneas/lean/Zerocopy/TypesExternal.lean b/verification/aeneas/lean/Zerocopy/TypesExternal.lean new file mode 100644 index 0000000000..10c6e45a5b --- /dev/null +++ b/verification/aeneas/lean/Zerocopy/TypesExternal.lean @@ -0,0 +1,20 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +module +public import Aeneas +@[expose] public section +open Aeneas.Std + +-- These types model stored values, not Rust's niche encoding or memory layout. +-- The generic wrapper admits zero; the alignment theorems explicitly require +-- positivity. This over-approximation includes every valid NonZeroUsize. +abbrev core.num.niche_types.NonZeroUsizeInner := Usize + +structure core.num.nonzero.NonZero (T : Type) (_Inner : Type) where + val : T diff --git a/verification/aeneas/negative-controls.sh b/verification/aeneas/negative-controls.sh new file mode 100755 index 0000000000..91ad355337 --- /dev/null +++ b/verification/aeneas/negative-controls.sh @@ -0,0 +1,1286 @@ +#!/usr/bin/env bash +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# Run after run.sh. Mutate generated scratch files, never checked-in sources. +# Capabilities select control families; a missing selected fixture is an error. +set -euo pipefail +cd "$(dirname "$0")/../.." +repo=$PWD +tools_dir=${AENEAS_TOOLCHAIN_DIR:-"$repo/target/aeneas/toolchain"} +export PATH="$tools_dir/lean/bin:$PATH" +# Golden and live projects have separate generated sources, caches, and +# backups. Each subshell restores its own project even when a control fails. +check_workspace() ( +cd "$repo/target/aeneas/$1" +echo "Testing failure controls in $1" +# Every temporary source has one lifecycle: it must be absent before the +# suite starts and is removed on exit. Use the same roster for both decisions +# so cleanup cannot delete a source that belonged to an earlier editor session. +control_files=( + NegativeControl.lean WeakOutcome.lean + RoundingConstructorControl.lean RoundingDomainControl.lean + Zerocopy/ScopeControl.lean Zerocopy/ShapeControl.lean Zerocopy/ModelControl.lean + Proofs/WitnessOwnerControl.lean Proofs/PaddingDependencyControl.lean +) +for fixture in "${control_files[@]}"; do + # A dangling symlink also belongs to somebody else; -e alone misses it. + [[ ! -e "$fixture" && ! -L "$fixture" ]] || { + echo "Negative control fixture already exists: $fixture" >&2; exit 1; + } +done +backup=$(mktemp -d) +python3 - "$backup" <<'PYBACKUP' +from pathlib import Path +import shutil, sys +root = Path(sys.argv[1]) +files = [Path("bindings.json"), Path("Required.lean"), Path("Specs.lean"), Path("ModelShapes.lean"), Path("Models.lean"), + Path("SpecPrelude.lean"), Path("Obligations.lean"), + Path("Zerocopy/Funs.lean"), Path("Zerocopy/FunsExternal.lean"), + *Path(".").glob("Proofs*.lean"), + *Path("Proofs").rglob("*.lean")] +for name in ("ModelSupport", "LayoutModel", "MathViews", "SupportTests", "Corollaries", + "LayoutModelDomainTests", "OutcomeTests"): + if Path(name + ".lean").exists(): + files.append(Path(name + ".lean")) +for source in files: + destination = root / source + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) +PYBACKUP +# Restore baseline source bytes between control stages. Lake then +# rebuilds from those sources; restoring an old .olean could conceal a mutation. +restore_files() { + python3 - "$backup" <<'PYRESTORE' +from pathlib import Path +import shutil, sys +root = Path(sys.argv[1]) +for source in [*root.rglob("*.lean"), root / "bindings.json"]: + shutil.copyfile(source, source.relative_to(root)) +PYRESTORE +} +cleanup() { + restore_files + rm -f "${control_files[@]}" + rm -rf "$backup" +} +trap cleanup EXIT + +# This helper checks command failure, not its semantic cause. Each control +# must also establish its valid baseline and inspect the expected diagnostic. +expect_failure() { + local description=$1 log=$2 + shift 2 + if "$@" > "$backup/$log" 2>&1; then + echo "Negative control accepted $description" >&2; exit 1 + fi + if ! grep -q 'error:' "$backup/$log"; then + cat "$backup/$log" >&2; exit 1 + fi + echo "Confirmed: rejected $description" +} +build_required() { + lake build Required +} +audit() { + lake env lean -DwarningAsError=true Check.lean +} +# The positive module proves TModel authoring and concrete forward shapes first. +# Reject raw-type/dictionary shape dependencies in a separate source environment. +lake build ModelTests > "$backup/type-only-shapes-positive.log" 2>&1 || { + cat "$backup/type-only-shapes-positive.log" >&2; exit 1; +} +shape_fixture="$repo/verification/aeneas/tests/type-only-shapes/reject-raw-parameter.lean" +expect_failure "raw generic parameter in a type-only model shape" type-only-shapes-negative.log \ + lake env lean --root="$(dirname "$shape_fixture")" -DwarningAsError=true "$shape_fixture" +if ! grep -Fq 'Unknown identifier `T`' "$backup/type-only-shapes-negative.log"; then + echo "Type-only shape fixture failed outside its intended authoring boundary" >&2 + cat "$backup/type-only-shapes-negative.log" >&2; exit 1 +fi +# A semantic mutation must remain a well-formed generated program. Only its +# dependent proofs should fail; a parser/type error cannot establish sensitivity. +check_model_mutant() { + local description=$1 target=${2:-Zerocopy.Funs} + lake build "+$target" > "$backup/model-well-formed.log" 2>&1 || { + echo "Malformed model negative control: $description" >&2 + cat "$backup/model-well-formed.log" >&2; exit 1; + } +} +# Reuse the verified root mapping instead of parsing model declaration syntax +# a second time. Missing/invalid manifests are errors, never absent capabilities. +has_binding() { + local present + present=$(python3 - "$@" <<'PYCONTROL' +from pathlib import Path +import json, sys +manifest = json.loads(Path("bindings.json").read_text()) +models = manifest["bindings"] +if (manifest.get("version") != 3 or manifest.get("mode") != "verified-live" + or not isinstance(models, dict) or not all( + isinstance(key, str) and isinstance(value, dict) and value.get("kind") in ("function", "type") and isinstance(value.get("raw"), str) for key, value in models.items())): + raise SystemExit("Invalid verified model bindings for failure controls") +kind = sys.argv[1] +raw = sys.argv[2] if len(sys.argv) == 3 else None +if kind not in ("function", "type") or len(sys.argv) not in (2, 3): + raise SystemExit("Invalid binding capability request") +print("yes" if any(entry["kind"] == kind and (raw is None or entry["raw"] == raw) + for entry in models.values()) else "no") +PYCONTROL +) || { echo "Cannot determine model capability" >&2; exit 1; } + [[ $present == yes ]] +} +has_model() { + has_binding function "Zerocopy.$1" +} +# A constrained mathematical value can still break an operation promise or its +# required raw input domain. Probe the unchanged constructor source fence in an +# isolated module so reorganizing helper proofs does not change these controls. +rounding_control="$repo/verification/aeneas/tests/rounding_model_controls.py" +rounding_capability=$(python3 "$rounding_control" capability) || exit 1 +if [[ $rounding_capability == yes ]]; then + lake build +Models +SpecsSyntax +Zerocopy.Funs > "$backup/rounding-operation-baseline.log" 2>&1 || { + cat "$backup/rounding-operation-baseline.log" >&2; exit 1; + } + python3 "$rounding_control" write-probes + for fixture in RoundingConstructorControl RoundingDomainControl; do + lake env lean -DwarningAsError=true "$fixture.lean" > "$backup/$fixture-baseline.log" 2>&1 || { + echo "Malformed or false rounding behavior baseline: $fixture" >&2 + cat "$backup/$fixture-baseline.log" >&2; exit 1; + } + done + reject_rounding_decoder() { + local mutation=$1 description=$2 target=$3 + python3 "$rounding_control" mutate "$mutation" + # Proof fields must be correct; a malformed decoder cannot establish + # sensitivity of its operation promise or independently fixed domain. + check_model_mutant "$description" Models + if [[ $mutation == constant ]]; then + lake env lean -DwarningAsError=true RoundingDomainControl.lean \ + > "$backup/rounding-constant-domain.log" 2>&1 || { + echo "Constant rounding mutant did not preserve the required input" >&2 + cat "$backup/rounding-constant-domain.log" >&2; exit 1; + } + fi + expect_failure "$description" "rounding-$mutation-behavior.log" \ + lake env lean -DwarningAsError=true "$target.lean" + python3 "$rounding_control" check-failure "$mutation" "$backup/rounding-$mutation-behavior.log" + cp "$backup/Models.lean" Models.lean + lake build +Models > "$backup/rounding-$mutation-restore.log" 2>&1 || { + cat "$backup/rounding-$mutation-restore.log" >&2; exit 1; + } + } + reject_rounding_decoder constant "a valid constant rounding pair breaking new(2,1)'s output promise" RoundingConstructorControl + reject_rounding_decoder none "an always-reject rounding decoder excluding a required positive getter input" RoundingDomainControl +fi +restore_build() { + restore_files + lake build Required > "$backup/restore-build.log" 2>&1 || { + cat "$backup/restore-build.log" >&2; exit 1; + } +} + +# Establish a valid baseline so a missing artifact cannot satisfy a control. +lake build > "$backup/baseline-build.log" 2>&1 || { + cat "$backup/baseline-build.log" >&2; exit 1; +} +audit > "$backup/baseline-audit.log" 2>&1 || { + cat "$backup/baseline-audit.log" >&2; exit 1; +} + +# Harmless extracted-function imports are ordinary acyclic dependencies. +# Their presence cannot replace the independently authored outcome expectation. +if [[ -f ModelSupport.lean ]]; then + for transitive in false true; do + python3 - "$transitive" <<'PYCONTROL' +from pathlib import Path +import sys +support = Path("ModelSupport.lean") +source = support.read_text() +marker = '@[expose] public section' +if source.count(marker) != 1: + raise SystemExit("Compiled support import control requires the exact support header boundary") +if sys.argv[1] == 'true': + helper = Path("SpecPrelude.lean") + text = helper.read_text() + if text.count(marker) != 1: + raise SystemExit("Compiled support import control requires the exact helper header boundary") + helper.write_text(text.replace(marker, 'public import «Zerocopy».Funs\n' + marker)) + imported = '«SpecPrelude»' +else: + imported = '«Zerocopy».Funs' +support.write_text(source.replace(marker, 'public import ' + imported + '\n' + marker)) +PYCONTROL + lake build Required > "$backup/support-import-build.log" 2>&1 || { + echo "Malformed compiled support import control" >&2 + cat "$backup/support-import-build.log" >&2; exit 1; + } + audit > "$backup/support-import-audit.log" 2>&1 || { + echo "Harmless support function import rejected (transitive=$transitive)" >&2 + cat "$backup/support-import-audit.log" >&2; exit 1; + } + echo "Confirmed: harmless support function import accepted (transitive=$transitive)" + restore_build + done +fi + +# Every independent conversion must exist in the generated checking module. +# These are well-formed modules with the canonical proof and Specs check intact. +reject_witness() { + local mutation=$1 description=$2 diagnostic=$3 + python3 - "$mutation" <<'PYCONTROL' +from pathlib import Path +import sys +p = Path("Required.lean") +s = p.read_text() +command = 'check_contract Zerocopy.Obligations.min_spec using @Zerocopy.Proofs.min_spec\n' +if s.count(command) != 1: + raise SystemExit("Independent witness coverage control no longer matches") +mutation = sys.argv[1] +replacement = '' +# Preserve the independently typed adequacy theorem when testing the later +# concrete witness audit, so a missing earlier check cannot mask the failure. +checked_context = ('namespace IndependentWitnessControl\n' + command + + 'end IndependentWitnessControl\n' + 'theorem Zerocopy.Obligations.min_spec_adequate :\n' + ' contract_adequacy% Zerocopy.Specs.min_spec implies Zerocopy.Obligations.min_spec :=\n' + ' IndependentWitnessControl.Zerocopy.Obligations.min_spec_adequate\n') +if mutation == 'wrong-kind': + replacement = (checked_context + + 'def Zerocopy.Obligations.min_spec_checked : Zerocopy.Obligations.min_spec :=\n' + ' IndependentWitnessControl.Zerocopy.Obligations.min_spec_checked\n') +elif mutation == 'wrong-type': + replacement = checked_context + 'theorem Zerocopy.Obligations.min_spec_checked : True := by trivial\n' +elif mutation == 'missing-rows': + example = 'example : Zerocopy.Specs.min_spec := @Zerocopy.Proofs.min_spec\n' + if s.count(example) != 1 or 'def requiredTheorems ' in s: + raise SystemExit("Compiled specification scope control no longer matches") + s = s.replace(example, '') + '\ndef requiredTheorems : Array Name := #[]\n' +elif mutation == 'wrong-owner': + proof = Path("Proofs/WitnessOwnerControl.lean") + imported = 'import Proofs\n' + if proof.exists() or s.count(imported) != 1: + raise SystemExit("Independent witness ownership fixture no longer matches") + # Keep the successful checking context in a separate owning module. + # Importing normalization infrastructure into Proofs would change the + # elaboration context of its existing raw and mathematical proofs. + imports = ''.join(line + '\n' for line in s.splitlines() + if line.startswith('import ')) + normalizers = ''.join(line + '\n' for line in s.splitlines() + if line.startswith('attribute [local contract_simps] ')) + proof.parent.mkdir(exist_ok=True) + proof.write_text(imports + 'open Lean\n\n' + normalizers + command) + s = s.replace(imported, imported + 'import Proofs.WitnessOwnerControl\n') +elif mutation != 'missing': + raise SystemExit("Unknown independent witness coverage mutation") +p.write_text(s.replace(command, replacement)) +PYCONTROL + lake build Required > "$backup/witness-build.log" 2>&1 || { + echo "Malformed independent witness control: $description" >&2 + cat "$backup/witness-build.log" >&2; exit 1; + } + expect_failure "$description" witness-audit.log audit + if ! grep -Fq "$diagnostic" "$backup/witness-audit.log"; then + cat "$backup/witness-audit.log" >&2; exit 1; + fi + rm -f Proofs/WitnessOwnerControl.lean + restore_build +} +reject_witness missing "an omitted independent conversion" \ + "Missing arbitrary-outcome required theorem Zerocopy.Obligations.min_spec_adequate" +reject_witness wrong-kind "a definition replacing the independent theorem" \ + "Missing independent required theorem Zerocopy.Obligations.min_spec_checked" +reject_witness wrong-type "an independent theorem with an unrelated type" \ + "Zerocopy.Obligations.min_spec_checked does not prove its independent required proposition" +reject_witness wrong-owner "an independent witness declared in a proof module" \ + "Zerocopy.Obligations.min_spec_adequate must be declared in the required checks module" +reject_witness missing-rows "omitted check rows hidden by an empty legacy roster" \ + "Missing arbitrary-outcome required theorem Zerocopy.Obligations.min_spec_adequate" + +# Imported lookalikes, nested compiler helpers and mutable tags do not choose +# coverage. Only immediate proposition definitions owned by Specs are roots. +python3 - <<'PYCONTROL' +from pathlib import Path +required = Path("Required.lean") +required.write_text(required.read_text() + ''' +namespace Zerocopy.Specs +def importedScopeLookalike : Prop := True +end Zerocopy.Specs +''') +specs = Path("Specs.lean") +specs.write_text(specs.read_text() + ''' +namespace Zerocopy.Specs.min_spec +def nestedScopeHelper : Prop := True +end Zerocopy.Specs.min_spec +''') +PYCONTROL +lake build Required > "$backup/scope-helper-build.log" 2>&1 || { + cat "$backup/scope-helper-build.log" >&2; exit 1; +} +audit > "$backup/scope-helper-audit.log" 2>&1 || { + cat "$backup/scope-helper-audit.log" >&2; exit 1; +} +echo "Confirmed: scope excludes imported and nested helpers" +restore_build + +# Move valid propositions to an imported model module. Everything still builds, +# but an empty generated Specs module must not manufacture a zero-proof audit. +python3 - <<'PYCONTROL' +from pathlib import Path +other = Path("Zerocopy/ScopeControl.lean") +if other.exists(): + raise SystemExit("Compiled scope control fixture already exists") +specs = Path("Specs.lean") +other.write_bytes(specs.read_bytes()) +specs.write_text('module\npublic import Zerocopy.ScopeControl\n') +PYCONTROL +lake build Required > "$backup/empty-scope-build.log" 2>&1 || { + echo "Malformed empty compiled scope control" >&2 + cat "$backup/empty-scope-build.log" >&2; exit 1; +} +expect_failure "an empty owned specification scope" empty-scope-audit.log audit +if ! grep -Fq 'No compiled inline specifications were found' "$backup/empty-scope-audit.log"; then + cat "$backup/empty-scope-audit.log" >&2; exit 1; +fi +restore_files +rm Zerocopy/ScopeControl.lean +restore_build + +# Every present source specification must equal its owned compiled call in +# both models. Preserve valid canonical proofs/callers so omitted roots reach +# the audit; removing a generated row cannot erase a present source fence. +reject_binding_image() { + local mutation=$1 description=$2 diagnostic=$3 + python3 - "$mutation" <<'PYCONTROL' +from pathlib import Path +import json, re, sys +mutation = sys.argv[1] +manifest_path = Path("bindings.json") +manifest = json.loads(manifest_path.read_text()) +models = manifest["bindings"] +if (manifest.get("version") != 3 or manifest.get("mode") != "verified-live" + or not isinstance(models, dict) or not all( + isinstance(key, str) and isinstance(value, dict) and value.get("kind") in ("function", "type") and isinstance(value.get("raw"), str) for key, value in models.items())): + raise SystemExit("Invalid verified model bindings for binding-image controls") +keys = [key for key, value in models.items() if value["kind"] == "function" and value["raw"] == "Zerocopy.util.min"] +if len(keys) != 1: + raise SystemExit("Binding-image control requires a unique min mapping") +if mutation == "missing-map": + del models[keys[0]] + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') +elif mutation == "duplicate-map": + if [entry["raw"] for entry in models.values()].count("Zerocopy.util.max") != 1: + raise SystemExit("Binding-image duplicate control requires a unique max mapping") + models[keys[0]]["raw"] = "Zerocopy.util.max" + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') +elif mutation in ("missing-spec", "duplicate-spec"): + p = Path("Specs.lean") + s = p.read_text() + fence = re.compile(r'(?m)^check_model_inputs Zerocopy\.util\.min with 0 type parameters\n' + r'aeneas_spec_begin\n' + r'spec min_spec\b[^\n]*\n(?:(?!^aeneas_spec_(?:begin|end)\b).)*?' + r'^aeneas_spec_end\n' + r'check_spec_binding Zerocopy\.Specs\.min_spec for Zerocopy\.util\.min with 2\n', + re.S | re.M) + matches = list(fence.finditer(s)) + if len(matches) != 1 or 'for @Zerocopy.util.min ' not in matches[0].group(): + raise SystemExit("Binding-image control requires the unique complete min fence/command") + match = matches[0] + block = match.group() + if mutation == "missing-spec": + other = Path("Zerocopy/ScopeControl.lean") + marker = '@[expose] public section' + start = s.find('aeneas_spec_begin\n') + if (other.exists() or start < 0 or s.count(marker) != 1 + or s[:start].count('namespace Zerocopy.Specs\n') != 1 + or s.count('end Zerocopy.Specs\n') != 1): + raise SystemExit("Binding-image carrier module boundary no longer matches") + # Reuse this stage's module/import/open preamble without importing Specs. + other.write_text(s[:start] + block + '\nend Zerocopy.Specs\n') + s = s[:match.start()] + s[match.end():] + s = s.replace(marker, 'public import Zerocopy.ScopeControl\n' + marker) + required = Path("Required.lean") + rows = required.read_text() + for row in ('example : Zerocopy.Specs.min_spec := @Zerocopy.Proofs.min_spec\n', + 'check_contract Zerocopy.Obligations.min_spec using @Zerocopy.Proofs.min_spec\n'): + if rows.count(row) != 1: + raise SystemExit("Binding-image min checking row no longer matches") + rows = rows.replace(row, '') + required.write_text(rows) + else: + name = 'bindingImageDuplicate' + if name in s or block.count('spec min_spec') != 1: + raise SystemExit("Binding-image duplicate specification fixture already exists") + duplicate = block.replace('spec min_spec', 'spec ' + name).replace( + 'check_spec_binding Zerocopy.Specs.min_spec ', + 'check_spec_binding Zerocopy.Specs.' + name + ' ') + s = s[:match.end()] + '\n' + duplicate + s[match.end():] + p.write_text(s) +else: + raise SystemExit("Unknown binding-image control mutation") +PYCONTROL + lake build Required > "$backup/binding-image-build.log" 2>&1 || { + echo "Malformed binding-image control: $description" >&2 + cat "$backup/binding-image-build.log" >&2; exit 1; + } + expect_failure "$description" binding-image-audit.log audit + if ! grep -Eq "$diagnostic" "$backup/binding-image-audit.log"; then + cat "$backup/binding-image-audit.log" >&2; exit 1; + fi + restore_files + rm -f Zerocopy/ScopeControl.lean + restore_build + audit > "$backup/binding-image-restore-audit.log" 2>&1 || { + cat "$backup/binding-image-restore-audit.log" >&2; exit 1; + } +} +reject_binding_image missing-spec "a Rust root moved out of the owned Specs scope" \ + 'Function binding for zerocopy::util::min does not name a compiled inline specification' +reject_binding_image missing-map "a compiled root missing from the binding image" \ + 'Compiled inline specification models disagree with bindings: missing .*; unexpected .*Zerocopy[.]util[.]min' +reject_binding_image duplicate-map "two Rust bindings assigned the same model" \ + 'Model bindings repeat model Zerocopy[.]util[.]max' +reject_binding_image duplicate-spec "two owned specifications targeting the same model" \ + 'Compiled inline specifications repeat model Zerocopy[.]util[.]min' + +# Type coverage is audited by the production Check, independently of function +# checks. Keep every model and proof well formed before accepting a rejection. +reject_model_image() { + local mutation=$1 description=$2 diagnostic=$3 + python3 - "$mutation" <<'PYCONTROL' +from pathlib import Path +import json, sys +manifest_path = Path("bindings.json") +manifest = json.loads(manifest_path.read_text()) +entries = manifest.get("bindings") +if (manifest.get("version") != 3 or manifest.get("mode") != "verified-live" + or not isinstance(entries, dict)): + raise SystemExit("Invalid verified model binding table for failure controls") +mutation = sys.argv[1] +rounding = "zerocopy::layout::RoundingAlignAndPhase" +error = "zerocopy::layout::MetadataCastError" +if mutation in ("missing-authored-image", "wrong-authored-model"): + entry = entries.get(rounding) + if not entry or entry["kind"] != "type" or not entry["authored"]: + raise SystemExit("Authored model omission fixture no longer matches") + if mutation == "missing-authored-image": + del entries[rounding] + else: + if entry["model"] == entry["fields"]: + raise SystemExit("Authored model control requires a distinct mathematical model") + entry["model"] = entry["fields"] + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') +elif mutation == "missing-default-image": + entry = entries.get(error) + if not entry or entry["kind"] != "type" or entry["authored"]: + raise SystemExit("Default model omission fixture no longer matches") + del entries[error] + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') +elif mutation in ("moved-shapes", "moved-models"): + module, target = (("ModelShapes", "ShapeControl") if mutation == "moved-shapes" + else ("Models", "ModelControl")) + path = Path(module + ".lean") + other = Path("Zerocopy/" + target + ".lean") + if other.exists(): + raise SystemExit("Model ownership control fixture already exists") + other.write_bytes(path.read_bytes()) + path.write_text('module\npublic import Zerocopy.' + target + '\n') +else: + raise SystemExit("Unknown model-image control mutation") +PYCONTROL + lake build Required > "$backup/model-image-build.log" 2>&1 || { + echo "Malformed model-image control: $description" >&2 + cat "$backup/model-image-build.log" >&2; exit 1; + } + expect_failure "$description" model-image-audit.log audit + if ! grep -Eq "$diagnostic" "$backup/model-image-audit.log"; then + cat "$backup/model-image-audit.log" >&2; exit 1; + fi + restore_files + rm -f Zerocopy/ShapeControl.lean Zerocopy/ModelControl.lean + restore_build + audit > "$backup/model-image-restore-audit.log" 2>&1 || { + cat "$backup/model-image-restore-audit.log" >&2; exit 1; + } +} +# Capability selection uses the compiled source carrier, independently of the +# manifest rows whose deletion is under test. Each mutant must build first. +if grep -Eq '^(structure|inductive)[[:blank:]]+layout[.]RoundingAlignAndPhase([[:blank:]]|$)' Zerocopy/Types.lean; then + reject_model_image wrong-authored-model "an authored model associated with the wrong shape" \ + 'Compiled model .* disagrees with its Rust owner binding' + reject_model_image missing-authored-image "an authored model omitted from the binding table" \ + 'Local raw type image has no source binding: zerocopy::layout::RoundingAlignAndPhase' +fi +if grep -Eq '^(structure|inductive)[[:blank:]]+layout[.]MetadataCastError([[:blank:]]|$)' Zerocopy/Types.lean; then + reject_model_image missing-default-image "an unannotated carrier omitted from the binding table" \ + 'Local raw type image has no source binding: zerocopy::layout::MetadataCastError' +fi +# Without extracted nominal types these modules own no declarations, so moving +# their empty contents cannot violate declaration ownership. The baseline audit +# above checks that the binding table covers every extracted nominal type. +if has_binding type; then + reject_model_image moved-shapes "model shapes moved out of their generated module" \ + 'Mathematical fields and model must be owned by ModelShapes' + reject_model_image moved-models "decoders and providers moved out of their generated module" \ + 'Decoder and provider must be owned by Models' +fi + +# Generic alignment reads select the ABI family. No-layout and pointer-only +# models intentionally have no such declaration and retain their earlier scope. +if grep -Fq 'def core.mem.align_of' Zerocopy/FunsExternal.lean; then + # Run the actual ABI-checking source in an isolated environment. Required + # proofs need not compile for a wrong pointer-width model, and removing an + # input must reach the audit rather than merely break one of its consumers. + python3 - <<'PYCONTROL' +from pathlib import Path +s = Path("Check.lean").read_text() +begin = ' let env ← getEnv\n' +end = ' let mut proofModules : Array ModuleIdx := #[]' +if s.count(begin) != 1 or s.count(end) != 1: + raise SystemExit("External-input audit control boundaries no longer match") +body = s[s.index(begin):s.index(end)] +Path("NegativeControl.lean").write_text( + 'import Lean\nimport Zerocopy.FunsExternal\nopen Lean Elab Command\nrun_elab do\n' + body) +PYCONTROL + lake env lean -DwarningAsError=true NegativeControl.lean \ + > "$backup/abi-baseline.log" 2>&1 || { + cat "$backup/abi-baseline.log" >&2; exit 1; + } + reject_abi() { + local description=$1 mutation=$2 diagnostic=$3 + python3 - "$mutation" <<'PYCONTROL' +from pathlib import Path +import sys +p = Path("Zerocopy/FunsExternal.lean") +s = p.read_text() +def replace(old, new): + global s + if s.count(old) != 1: + raise SystemExit("External-input negative control no longer matches: " + old) + s = s.replace(old, new) +size = 'axiom Zerocopy.RustLayout.size (T : Type) : Usize' +align = 'axiom Zerocopy.RustLayout.align (T : Type) : Usize' +mutation = sys.argv[1] +if mutation in ('missing-size', 'missing-align', 'missing-both'): + if mutation != 'missing-align': + replace(size, '') + replace('Zerocopy.RustLayout.size T', '0#usize') + if mutation != 'missing-size': + replace(align, '') + replace('Zerocopy.RustLayout.align T', '1#usize') + replace('core.mem.align_of (T : Type)', 'core.mem.align_of (_T : Type)') +elif mutation == 'wrong-kind': + replace(size, 'def Zerocopy.RustLayout.size (_T : Type) : Usize := 0#usize') +elif mutation == 'wrong-signature': + replace(size, 'axiom Zerocopy.RustLayout.size (T : Type) : Nat') + replace('Zerocopy.RustLayout.size T', '0#usize') +elif mutation == 'ignored-align': + replace('Zerocopy.RustLayout.align T', '1#usize') + replace('core.mem.align_of (T : Type)', 'core.mem.align_of (_T : Type)') +elif mutation == 'ignored-size': + replace('Zerocopy.RustLayout.size T', '0#usize') +elif mutation == 'wrong-pointer-width': + replace('System.Platform.numBits / 8', 'System.Platform.numBits / 8 + 1') +else: + raise SystemExit("Unknown external-input control mutation") +p.write_text(s) +PYCONTROL + check_model_mutant "$description" Zerocopy.FunsExternal + expect_failure "$description" abi-audit.log \ + lake env lean -DwarningAsError=true NegativeControl.lean + if ! grep -Fq "$diagnostic" "$backup/abi-audit.log"; then + cat "$backup/abi-audit.log" >&2; exit 1; + fi + restore_files + } + reject_abi "a missing generic ABI size input" missing-size \ + "Missing external layout input Zerocopy.RustLayout.size" + reject_abi "a missing generic ABI alignment input" missing-align \ + "Missing external layout input Zerocopy.RustLayout.align" + reject_abi "both generic ABI inputs missing" missing-both \ + "Missing external layout input Zerocopy.RustLayout.size" + reject_abi "a definition replacing an arbitrary ABI data input" wrong-kind \ + "Missing external layout input Zerocopy.RustLayout.size" + reject_abi "an ABI data input with the wrong signature" wrong-signature \ + "External layout input Zerocopy.RustLayout.size changed its data-only signature" + reject_abi "an alignment read ignoring its ABI input" ignored-align \ + "External alignment read changed its data-input interpretation" + reject_abi "a generic size read ignoring its ABI input" ignored-size \ + "External size read changed its data-input interpretation" + reject_abi "an incorrect Usize pointer-width read" wrong-pointer-width \ + "External size read changed its data-input interpretation" + rm NegativeControl.lean + restore_build + audit +fi + +if [[ $1 == verification ]]; then + echo '-- Harmless generated-comment difference' >> Zerocopy/Funs.lean + python3 "$repo/verification/aeneas/golden.py" compare \ + "$PWD/Zerocopy" "$repo/verification/aeneas/golden" + lake build Required > "$backup/comment-build.log" 2>&1 || { + cat "$backup/comment-build.log" >&2; exit 1; + } + audit + echo "Confirmed: comment drift passes comparison and live proofs" + restore_build +fi + +# Ordinary theorems must establish the proposition expanded by the spec command. +# Native proofs use the same total/partial semantics as the inline specs. +reject_spec() { + local description=$1 + cat > NegativeControl.lean <<'LEAN' +import SpecsSyntax +import ModelPrelude +open Aeneas Aeneas.Std +LEAN + cat >> NegativeControl.lean + expect_failure "$description" spec-build.log lake env lean -DwarningAsError=true NegativeControl.lean + if ! grep -q 'unsolved goals' "$backup/spec-build.log" || + ! grep -q '⊢ False' "$backup/spec-build.log"; then + cat "$backup/spec-build.log" >&2; exit 1 + fi + rm NegativeControl.lean +} +reject_spec "panic under a total spec" <<'LEAN' +namespace NegativeControl +def panicking : Result Nat := .fail Error.panic +spec panic + for @panicking with 0 type parameters + ensures _ => True +theorem attempted : panic := by simp [panic, panicking] +end NegativeControl +LEAN +reject_spec "divergence under a total spec" <<'LEAN' +namespace NegativeControl +def divergent : Result Nat := .div +spec diverging + for @divergent with 0 type parameters + ensures _ => True +theorem attempted : diverging := by simp [diverging, divergent] +end NegativeControl +LEAN +reject_spec "panic under a partial spec" <<'LEAN' +namespace NegativeControl +def panicking : Result Nat := .fail Error.panic +partial spec panic + for @panicking with 0 type parameters + ensures _ => True +theorem attempted : panic := by simp [panic, panicking] +end NegativeControl +LEAN +reject_spec "an incorrect successful return under a partial spec" <<'LEAN' +namespace NegativeControl +def zero : Result Nat := .ok 0 +partial spec wrong_return + for @zero with 0 type parameters + ensures out => out = 1 +theorem attempted : wrong_return := by simp [wrong_return, zero, AeneasSpecs.RustModel.decode] +end NegativeControl +LEAN +reject_spec "divergence under a total mathematical-view spec" <<'LEAN' +namespace NegativeControl +def divergent : Result Nat := .div +spec diverging + for @divergent with 0 type parameters + ensures out => out = 0 +theorem attempted : diverging := by simp [diverging, divergent] +end NegativeControl +LEAN +reject_spec "an incorrect mathematical view" <<'LEAN' +namespace NegativeControl +def zero : Result Nat := .ok 0 +spec wrong_view + for @zero with 0 type parameters + ensures out => Nat.succ out = 0 + ensures(raw) out => out = 0 +theorem attempted : wrong_view := by simp [wrong_view, zero] +end NegativeControl +LEAN + + +if [[ -f Loops.lean ]]; then + [[ -f SupportTests.lean ]] || { + echo "Indexed-loop capability requires SupportTests.lean" >&2; exit 1; + } + reject_loop() { + local description=$1 + python3 - "$2" "$3" <<'PYCONTROL' +from pathlib import Path +import sys +p = Path("SupportTests.lean") +s = p.read_text() +old, new = sys.argv[1:] +if s.count(old) != 1: + raise SystemExit("Indexed-loop negative control no longer matches") +p.write_text(s.replace(old, new)) +PYCONTROL + expect_failure "$description" loop-build.log lake build SupportTests + cp "$backup/SupportTests.lean" SupportTests.lean + } + reject_loop "a stationary loop index" 's.2 + 1#usize' 's.2 + 0#usize' + reject_loop "an incorrect loop prefix step" 's.1 + 1, j' 's.1 + 2, j' + reject_loop "continuing a loop past its end" 'if s.2 < n then' 'if s.2 ≤ n then' + lake build SupportTests > "$backup/loop-restore-build.log" 2>&1 || { + cat "$backup/loop-restore-build.log" >&2; exit 1; + } +fi + +# Registered proofs are theorems of their corresponding Specs proposition. +min_module=$(python3 - <<'PYCONTROL' +from pathlib import Path +import re +paths = [Path("Proofs.lean"), *Path("Proofs").rglob("*.lean")] +old = 'theorem min_spec : Zerocopy.Specs.min_spec := by' +matched = [p for p in paths if old in p.read_text()] +if len(matched) != 1: + raise SystemExit("Specification type control no longer matches") +p = matched[0] +s = p.read_text() +registration = 'register_spec_step min_spec\n' +if s.count(old) != 1 or s.count(registration) != 1: + raise SystemExit("Specification type control requires the exact unique min theorem/registration") +start = s.index(old) +body = start + len(old) +# Native proof declarations start in column zero. Stop before the next +# declaration or registration, preserving padding/round-down in early stages. +boundary = re.search(r'(?m)^(?:(?:(?:private|noncomputable) +)?' + r'(?:theorem|def|abbrev)|attribute|register_spec_step|end)\b|^@\[', + s[body:]) +if boundary is None: + raise SystemExit("Cannot isolate the min theorem declaration") +end = body + boundary.start() +mutated = s[:start] + 'theorem min_spec : True := by trivial\n\n' + s[end:] +p.write_text(mutated.replace(registration, '')) +module = str(p.with_suffix('')).replace('/', '.') +Path('NegativeControl.lean').write_text('import ' + module + '\nimport Specs\n' + + 'example : Zerocopy.Specs.min_spec := @Zerocopy.Proofs.min_spec\n') +print(module) +PYCONTROL +) +# Compile just its owning module: callers of min may otherwise fail before +# the independent type check is reached. This is the same example as Required. +lake build "+$min_module" > "$backup/min-module-build.log" 2>&1 || { + cat "$backup/min-module-build.log" >&2; exit 1; +} +expect_failure "an unrelated registered theorem in place of the inline min spec" wrong-type-build.log lake env lean NegativeControl.lean +if ! grep -qi 'type mismatch' "$backup/wrong-type-build.log" || + ! grep -q 'min_spec' "$backup/wrong-type-build.log"; then + cat "$backup/wrong-type-build.log" >&2; exit 1 +fi +rm NegativeControl.lean +restore_build + +min_module=$(python3 - <<'PYCONTROL' +from pathlib import Path +paths = [Path("Proofs.lean"), *Path("Proofs").rglob("*.lean")] +old = 'theorem min_spec : Zerocopy.Specs.min_spec := by' +matched = [p for p in paths if old in p.read_text()] +if len(matched) != 1: + raise SystemExit("Proof declaration-kind control no longer matches") +p = matched[0] +s = p.read_text() +registration = 'register_spec_step min_spec\n' +if s.count(old) != 1 or s.count(registration) != 1: + raise SystemExit("Proof declaration-kind control requires the exact unique min theorem/registration") +p.write_text(s.replace(old, 'def min_spec : Zerocopy.Specs.min_spec := by').replace(registration, '')) +print(str(p.with_suffix('')).replace('/', '.')) +PYCONTROL +) +# Keep the definition well formed while testing the earlier theorem-kind guard. +lake build "+$min_module" > "$backup/definition-module-build.log" 2>&1 || { + cat "$backup/definition-module-build.log" >&2; exit 1; +} +expect_failure "a definition in place of a registered theorem" definition-build.log build_required +if ! grep -Fq 'Required contract candidate must be a theorem: Zerocopy.Proofs.min_spec' "$backup/definition-build.log"; then + cat "$backup/definition-build.log" >&2; exit 1 +fi +restore_build + +# Exercise dependency feedback without prescribing production proof structure. +# Select by the arithmetic extraction binding, independently of how caller +# proofs are organized. A selected missing or changed fixture must fail. +if has_model util.checks.check_arithmetic; then + audit > "$backup/dependency-baseline-audit.log" 2>&1 || { + cat "$backup/dependency-baseline-audit.log" >&2; exit 1; + } + cp proof-dependencies.json "$backup/padding-baseline.json" + padding_dependency_fixture() { + python3 - "$1" "$backup" <<'PYCONTROL' +from pathlib import Path +import sys +mode, backup = sys.argv[1], Path(sys.argv[2]) +if mode not in ('direct', 'unavailable', 'auxiliary', 'independent'): + raise SystemExit("Unknown padding dependency fixture") +caller = Path("Proofs/ArithmeticChecks.lean") +callee = Path("Proofs/Util.lean") +helper = Path("Proofs/PaddingDependencyControl.lean") +for fixture in (caller, callee, Path("Required.lean")): + if not (backup / fixture).is_file(): + raise SystemExit("Selected padding dependency fixture is missing: " + str(fixture)) +header = 'theorem arithmetic_checks_spec : Specs.arithmetic_checks_spec := by' +body = """ + intro len align _ _ _ _ + apply WP.spec_mono (Raw.arithmetic_checks len align) + intro result _ + exact ⟨(), rfl, trivial⟩ +""" +source = (backup / caller).read_text() +original = header + body +if source.count(original) != 1 or source.count(header) != 1: + raise SystemExit("Selected arithmetic dependency fixture is missing or changed") +callee_source = (backup / callee).read_text() +callee_header = 'theorem padding_lt_alignment : Zerocopy.Specs.padding_lt_alignment := by' +registration = 'register_spec_step padding_lt_alignment\n' +if callee_source.count(callee_header) != 1 or callee_source.count(registration) != 1: + raise SystemExit("Selected canonical padding fixture is missing or changed") +callee_body, separator, _ = callee_source.partition(callee_header)[2].partition('\n' + registration) +if not separator or callee_body.count('Raw.padding_lt_alignment') != 1: + raise SystemExit("Independent padding transport requires its exact Raw reference") +reference = 'Zerocopy.Proofs.padding_lt_alignment' +required = (backup / 'Required.lean').read_text() +if mode in ('auxiliary', 'independent'): + alias = 'private def padding_alias := Zerocopy.Proofs.padding_lt_alignment\n' + if mode == 'independent': + alias = ('private theorem padding_alias : Zerocopy.Specs.padding_lt_alignment := by' + + callee_body.replace('Raw.padding_lt_alignment', + 'Zerocopy.Proofs.Raw.padding_lt_alignment') + '\n') + helper.write_text("""module +public import Proofs.Util +@[expose] public section +open Aeneas Aeneas.Std AeneasSpecs Zerocopy.Proofs +namespace PaddingDependencyControl +""" + alias + """theorem padding_proxy : Zerocopy.Specs.padding_lt_alignment := by exact padding_alias +end PaddingDependencyControl +""") + import_at = '@[expose] public section' + if source.count(import_at) != 1: + raise SystemExit("Selected arithmetic module framing is missing or changed") + source = source.replace(import_at, 'public import Proofs.PaddingDependencyControl\n' + import_at) + # Check traverses configured native modules. Include this scratch helper + # in that same boundary; its namespace alone must not grant admission. + audit_header = 'def auditModuleNames : Array Name := #[' + if required.count(audit_header) != 1: + raise SystemExit("Selected audit module fixture is missing or changed") + required = required.replace(audit_header, audit_header + '`Proofs.PaddingDependencyControl, ') + reference = 'PaddingDependencyControl.padding_proxy' +wrapped = (header + '\n exact And.left (show Specs.arithmetic_checks_spec ∧ ' + 'Specs.padding_lt_alignment from\n ⟨(by\n' + + ''.join(' ' + line + '\n' for line in body.splitlines()[1:]) + + ' ), ' + reference + '⟩)\n') +caller.write_text(source.replace(original, wrapped)) +Path('Required.lean').write_text(required) +if mode == 'unavailable': + callee_source = callee_source.replace(callee_header, + 'theorem unavailable_padding_lt_alignment : Zerocopy.Specs.padding_lt_alignment := by') + callee_source = callee_source.replace(registration, '') +callee.write_text(callee_source) +PYCONTROL + } + check_padding_dependency() { + python3 - "$1" "$backup/padding-baseline.json" <<'PYCONTROL' +from pathlib import Path +import json, sys +mode = sys.argv[1] +caller = 'Zerocopy.Proofs.arithmetic_checks_spec' +callee = 'Zerocopy.Proofs.padding_lt_alignment' +def dependencies(path): + entries = [entry for entry in json.loads(Path(path).read_text()) if entry['theorem'] == caller] + if len(entries) != 1: + raise SystemExit("Dependency graph lacks its exact arithmetic caller") + return set(entries[0]['depends_on']) +baseline = dependencies(sys.argv[2]) +if callee in baseline: + raise SystemExit("Original arithmetic fixture already references the canonical padding root") +expected = baseline | {callee} if mode == 'present' else baseline +if mode not in ('present', 'absent') or dependencies('proof-dependencies.json') != expected: + raise SystemExit("Dependency graph does not match the controlled canonical padding edge") +PYCONTROL + } + padding_dependency_fixture direct + lake build Required > "$backup/dependency-direct-build.log" 2>&1 || { + cat "$backup/dependency-direct-build.log" >&2; exit 1; + } + audit > "$backup/dependency-direct-audit.log" 2>&1 || { + cat "$backup/dependency-direct-audit.log" >&2; exit 1; + } + check_padding_dependency present + echo "Confirmed: dependency reporting retains the direct canonical callee" + padding_dependency_fixture unavailable + lake build +Proofs.Util > "$backup/callee-owner-build.log" 2>&1 || { + echo "Malformed callee control: renamed owning Util module did not compile" >&2 + cat "$backup/callee-owner-build.log" >&2; exit 1; + } + expect_failure "an unavailable canonical callee theorem" callee-build.log lake build +Proofs.ArithmeticChecks + if ! grep -q 'Unknown identifier.*padding_lt_alignment' "$backup/callee-build.log"; then + cat "$backup/callee-build.log" >&2; exit 1 + fi + restore_build + padding_dependency_fixture auxiliary + lake build Required > "$backup/helper-build.log" 2>&1 || { + cat "$backup/helper-build.log" >&2; exit 1; + } + audit > "$backup/helper-audit.log" 2>&1 || { + cat "$backup/helper-audit.log" >&2; exit 1; + } + check_padding_dependency present + echo "Confirmed: dependency reporting follows a private helper in an auxiliary native module" + padding_dependency_fixture independent + lake build Required > "$backup/independent-helper-build.log" 2>&1 || { + cat "$backup/independent-helper-build.log" >&2; exit 1; + } + audit > "$backup/independent-helper-audit.log" 2>&1 || { + cat "$backup/independent-helper-audit.log" >&2; exit 1; + } + check_padding_dependency absent + echo "Confirmed: dependency reporting omits a callee replaced by an independent auxiliary proof" + rm -f Proofs/PaddingDependencyControl.lean + restore_build + audit > "$backup/dependency-restored-audit.log" 2>&1 || { + cat "$backup/dependency-restored-audit.log" >&2; exit 1; + } + check_padding_dependency absent +fi + +# Data-only Rust layout inputs are the only additional admitted axioms. Even an +# unused private helper in another configured proof module must be audited. +helper=Proofs.lean +if [[ -f Proofs/Util.lean ]]; then helper=Proofs/Util.lean; fi +cat >> "$helper" <<'LEAN' +namespace UnrelatedControl +set_option warningAsError false in +private theorem unused_helper : True := by sorry +end UnrelatedControl +LEAN +lake build Required > "$backup/private-helper-build.log" 2>&1 || { + cat "$backup/private-helper-build.log" >&2; exit 1; +} +expect_failure "an admitted unused private proof helper" private-helper-audit.log audit +if ! grep -q 'unused_helper.*unapproved axiom.*sorryAx' "$backup/private-helper-audit.log"; then + cat "$backup/private-helper-audit.log" >&2; exit 1 +fi +restore_build + +# The native-module roster covers auxiliary files too, even when neither their +# declarations nor their namespace occur in a registered proof. +cat >> SpecPrelude.lean <<'LEAN' +namespace UnrelatedControl +axiom unused_auxiliary_fact : True +end UnrelatedControl +LEAN +lake build Required > "$backup/auxiliary-axiom-build.log" 2>&1 || { + cat "$backup/auxiliary-axiom-build.log" >&2; exit 1; +} +expect_failure "an unused unapproved axiom in a native auxiliary module" auxiliary-axiom-audit.log audit +if ! grep -q 'unapproved axiom.*UnrelatedControl.unused_auxiliary_fact' "$backup/auxiliary-axiom-audit.log"; then + cat "$backup/auxiliary-axiom-audit.log" >&2; exit 1 +fi +restore_build + +cat >> Proofs.lean <<'LEAN' +namespace UnrelatedControl +axiom unused_fact : True +end UnrelatedControl +LEAN +lake build Required > "$backup/unused-axiom-build.log" 2>&1 || { + cat "$backup/unused-axiom-build.log" >&2; exit 1; +} +expect_failure "an unused unapproved axiom outside the proof namespace" unused-axiom-audit.log audit +if ! grep -q 'unapproved axiom.*UnrelatedControl.unused_fact' "$backup/unused-axiom-audit.log"; then + cat "$backup/unused-axiom-audit.log" >&2; exit 1 +fi +restore_build + +cat >> Proofs.lean <<'LEAN' +namespace UnrelatedControl +set_option warningAsError false in +theorem admitted : True := by sorry +end UnrelatedControl +LEAN +lake build Required > "$backup/sorry-build.log" 2>&1 || { + cat "$backup/sorry-build.log" >&2; exit 1; +} +expect_failure "an admitted proof" sorry-audit.log audit +if ! grep -q 'unapproved axiom.*sorryAx' "$backup/sorry-audit.log"; then + cat "$backup/sorry-audit.log" >&2; exit 1 +fi +restore_build + +# Generated conversion witnesses receive the same admission audit. +python3 - <<'PYCONTROL' +from pathlib import Path +p = Path("Required.lean") +s = p.read_text() +old = 'using @Zerocopy.Proofs.min_spec' +if s.count(old) != 1: + raise SystemExit("Required witness admission control no longer matches") +s = s.replace('check_contract Zerocopy.Obligations.min_spec ' + old, + 'set_option warningAsError false in\n' + + 'theorem Zerocopy.Obligations.min_spec_adequate :\n' + + ' contract_adequacy% Zerocopy.Specs.min_spec implies Zerocopy.Obligations.min_spec := by sorry\n' + + 'theorem Zerocopy.Obligations.min_spec_checked : Zerocopy.Obligations.min_spec :=\n' + + ' required_contract_instance% Zerocopy.Proofs.min_spec with Zerocopy.Obligations.min_spec_adequate') +p.write_text(s) +PYCONTROL +lake build Required > "$backup/required-sorry-build.log" 2>&1 || { + cat "$backup/required-sorry-build.log" >&2; exit 1; +} +expect_failure "an admitted required-contract witness" required-sorry-audit.log audit +if ! grep -q 'unapproved axiom.*sorryAx' "$backup/required-sorry-audit.log"; then + cat "$backup/required-sorry-audit.log" >&2; exit 1 +fi +restore_build + +cat > NegativeControl.lean <<'LEAN' +import Proofs +import Obligations +import RequiredContracts +namespace UnrelatedControl +theorem min_spec : True := by trivial +end UnrelatedControl +check_contract Zerocopy.Obligations.min_spec using UnrelatedControl.min_spec +LEAN +expect_failure "an unrelated True theorem as an independent required contract" unrelated-build.log lake env lean NegativeControl.lean +rm NegativeControl.lean + +# Once arithmetic postconditions are strengthened, a valid earlier weak +# round-down contract must fail the independently written required proposition. +if [[ -f Arithmetic.lean ]]; then + cat > NegativeControl.lean <<'LEAN' +import Proofs +import Obligations +import RequiredContracts +open Aeneas Aeneas.Std Zerocopy Zerocopy.Proofs +namespace WeakerControl +theorem round_down_spec (n : Usize) (align : NonZeroUsize) + (h : (align.val : Nat).isPowerOfTwo) : + util.round_down_to_next_multiple_of_alignment n align + ⦃ m => m ≤ n ∧ m.val % align.val.val = 0 ⦄ := by + apply WP.spec_mono (Zerocopy.Proofs.Raw.round_down_spec n align h) + intro m hm + exact ⟨hm.1, hm.2.2.1⟩ +end WeakerControl +LEAN + lake env lean -DwarningAsError=true NegativeControl.lean > "$backup/weaker-build.log" 2>&1 || { + cat "$backup/weaker-build.log" >&2; exit 1; + } + cat >> NegativeControl.lean <<'LEAN' +check_contract Zerocopy.Obligations.round_down_spec using WeakerControl.round_down_spec +LEAN + expect_failure "a valid weaker round-down contract as the independent required contract" weaker-required.log lake env lean NegativeControl.lean + rm NegativeControl.lean +fi + +reject_model() { + local description=$1 old=$2 new=$3 target=${4:-} + python3 - "$old" "$new" "$target" <<'PYCONTROL' +from pathlib import Path +import sys +p = Path("Zerocopy/Funs.lean") +s = p.read_text() +old, new, target = sys.argv[1:] +start, end = 0, len(s) +if target: + start = s.index('def ' + target) + end = s.find('/-- ', start) + if end < 0: + end = len(s) +part = s[start:end] +if part.count(old) != 1: + raise SystemExit("Model negative control no longer matches") +p.write_text(s[:start] + part.replace(old, new) + s[end:]) +PYCONTROL + check_model_mutant "$description" + expect_failure "$description" model-build.log build_required + cp "$backup/Zerocopy/Funs.lean" Zerocopy/Funs.lean +} +if [[ -f Arithmetic.lean ]]; then + reject_model "always-zero padding under the exact arithmetic spec" 'ok (i2 &&& mask)' 'ok 0#usize' + reject_model "always-zero round-down under the exact arithmetic spec" 'ok (n &&& mask)' 'ok 0#usize' +fi +if has_model layout.DstLayout.pad_to_align; then + # Both the earlier sized-only contract and normalized layout contract check + # this flag. Generated scalar variable names are not a capability boundary. + shallow_flag=$(python3 - <<'PYCONTROL' +from pathlib import Path +import re +s = Path("Zerocopy/Funs.lean").read_text() +start = s.index('def layout.DstLayout.pad_to_align') +end = s.find('/-- ', start) +if end < 0: + end = len(s) +matches = re.findall(r'statically_shallow_unpadded := \([A-Za-z_][A-Za-z0-9_]* = 0#usize\)', s[start:end]) +if len(matches) != 1: + raise SystemExit("Shallow-padding flag negative control no longer matches") +print(matches[0]) +PYCONTROL +) + reject_model "an incorrect shallow-padding flag" "$shallow_flag" \ + 'statically_shallow_unpadded := true' 'layout.DstLayout.pad_to_align' +fi +if has_model layout.DstLayout.extend; then + # Field extension identifies full normalized composition. Earlier stages + # contain only a sized-padding contract, without the recursive DST rule. + reject_model "conflating physical offset with the size base" \ + 'Usize.checked_add offset tsl.offset' \ + 'Usize.checked_add offset tsl.size_base' 'layout.DstLayout.extend' + reject_model "dropping inner rounding under outer packing" \ + 'util.padding_needed_for trailing.size_base size_align' \ + 'util.padding_needed_for trailing.size_base self.align' 'layout.DstLayout.pad_to_align' +fi + +restore_build + +# Independent expectations must reject a genuinely proven weaker contract. +reject_weak_outcome() { + local description=$1 + cat > NegativeControl.lean + # Check the candidate proof alone first; malformed Lean is no witness. + sed '/^check_contract /d' NegativeControl.lean > WeakOutcome.lean + lake env lean -DwarningAsError=true WeakOutcome.lean > "$backup/weak-outcome-valid.log" 2>&1 || { + cat "$backup/weak-outcome-valid.log" >&2; exit 1; + } + rm WeakOutcome.lean + expect_failure "$description" weak-outcome-check.log \ + lake env lean -DwarningAsError=true NegativeControl.lean + rm NegativeControl.lean +} +reject_predicate() { + local name=$1 replacement=$2 module=$3 + python3 - "$name" "$replacement" <<'PYCONTROL' +from pathlib import Path +import re, sys +p = Path("LayoutModel.lean") +s = p.read_text() +pattern = r'(?ms)^(def ' + re.escape(sys.argv[1]) + r'\b.*?: Prop :=).*?(?=^def )' +s, count = re.subn(pattern, lambda m: 'set_option linter.unusedVariables false in\n' + m[1] + ' ' + sys.argv[2] + '\n\n', s) +if count != 1: + raise SystemExit("Outcome predicate negative control no longer matches") +p.write_text(s) +PYCONTROL + check_model_mutant "a changed mathematical predicate $name" LayoutModel + expect_failure "a changed mathematical predicate $name" outcome-test.log \ + lake build "+$module" + if ! grep -Fq "error: $module.lean:" "$backup/outcome-test.log"; then + cat "$backup/outcome-test.log" >&2; exit 1; + fi + restore_files +} +if has_model layout.DstLayout.validate_cast_and_convert_metadata; then + [[ -f OutcomeTests.lean ]] || { + echo "Outcome capability requires OutcomeTests.lean" >&2; exit 1; + } + reject_weak_outcome "a termination-only cast contract" <<'LEAN' +import Proofs +import Obligations +import RequiredContracts +open Aeneas Aeneas.Std Zerocopy Zerocopy.Proofs AeneasContracts + +namespace CastWeakContractControls +def terminationOnly : Prop := + ∀ (self : layout.DstLayout) (addr length : Usize) (side : layout.CastType), + 0 < self.align.val.val → addr.val + length.val ≤ Usize.max → + (match self.size_info with + | .Sized _ => True + | .SliceDst t => 0 < t.size_rounding_align_and_phase._0.val.val ∧ 0 < t.elem_size.val) → + ∃ r, layout.DstLayout.validate_cast_and_convert_metadata self addr length side = .ok r + +theorem terminationOnly_proof : terminationOnly := by + intro self addr length side ha hroom ht + obtain ⟨r, hr, _⟩ := WP.spec_imp_exists + (Zerocopy.Proofs.Raw.validate_cast_spec self addr length side ha hroom ht) + exact ⟨r, hr⟩ +end CastWeakContractControls + +-- Expected rejection; delete only this line for the positive proof-validity stage. +check_contract Zerocopy.Obligations.validate_cast_spec using CastWeakContractControls.terminationOnly_proof +LEAN + reject_predicate castSpec True OutcomeTests + restore_build +fi + +if has_model layout.DstLayout.metadata_for_exact_size; then + [[ -f OutcomeTests.lean ]] || { + echo "Outcome capability requires OutcomeTests.lean" >&2; exit 1; + } + reject_weak_outcome "a termination-only metadata contract" <<'LEAN' +import Proofs +import Obligations +import RequiredContracts +open Aeneas Aeneas.Std Zerocopy Zerocopy.Proofs AeneasContracts + +namespace MetadataWeakContractControls +def terminationOnly : Prop := + ∀ (self : layout.DstLayout) (size : Usize), 0 < self.align.val.val → + (match self.size_info with + | .Sized _ => True + | .SliceDst t => t.elem_size.val ≠ 0 → 0 < t.size_rounding_align_and_phase._0.val.val) → + ∃ r, layout.DstLayout.metadata_for_exact_size self size = .ok r + +theorem terminationOnly_proof : terminationOnly := by + intro self size ha ht + obtain ⟨r, hr, _⟩ := WP.spec_imp_exists + (Zerocopy.Proofs.Raw.metadata_exact_spec self size ha ht) + exact ⟨r, hr⟩ +end MetadataWeakContractControls + +-- Expected rejection; delete only this line for the positive proof-validity stage. +check_contract Zerocopy.Obligations.metadata_exact_spec using MetadataWeakContractControls.terminationOnly_proof +LEAN + reject_predicate metadataSpec True OutcomeTests + restore_build +fi + +# Positive examples keep supported constructor preconditions inhabited. +if has_model layout.DstLayout.for_repr_c_struct; then + [[ -f LayoutModelDomainTests.lean ]] || { + echo "Constructor capability requires LayoutModelDomainTests.lean" >&2; exit 1; + } + for predicate in alignmentDomain canonicalLayout constructionDomain; do + reject_predicate "$predicate" False LayoutModelDomainTests + done + restore_build +fi + +python3 - <<'PYCONTROL' +from pathlib import Path +p = Path("Zerocopy/Funs.lean") +s = p.read_text() +if s.count("if i > i1") != 1: + raise SystemExit("Min negative control no longer matches generated output") +p.write_text(s.replace("if i > i1", "if i < i1")) +PYCONTROL +check_model_mutant "a min implementation with its comparison reversed" +if [[ $1 == verification ]]; then + if python3 "$repo/verification/aeneas/golden.py" compare \ + "$PWD/Zerocopy" "$repo/verification/aeneas/golden" \ + > "$backup/mutated-compare.log" 2>&1; then + echo "Fuzzy comparison accepted an incorrect translated min" >&2; exit 1 + fi + if ! grep -q 'Aeneas goldens differ' "$backup/mutated-compare.log"; then + cat "$backup/mutated-compare.log" >&2; exit 1 + fi + echo "Confirmed: fuzzy comparison rejects an incorrect translated min" +fi +expect_failure "a min implementation with its comparison reversed" min-build.log build_required +restore_build +lake build +audit +) +case "${1:-}" in + "") + [[ $# == 0 ]] || { echo "Usage: $0 [golden-verification|verification]" >&2; exit 1; } + check_workspace golden-verification + check_workspace verification + ;; + golden-verification|verification) + [[ $# == 1 ]] || { echo "Usage: $0 [golden-verification|verification]" >&2; exit 1; } + check_workspace "$1" + ;; + *) echo "Usage: $0 [golden-verification|verification]" >&2; exit 1 ;; +esac diff --git a/verification/aeneas/patches/use-tuple-structs.patch b/verification/aeneas/patches/use-tuple-structs.patch new file mode 100644 index 0000000000..7cdd111104 --- /dev/null +++ b/verification/aeneas/patches/use-tuple-structs.patch @@ -0,0 +1,13 @@ +--- a/src/Main.ml ++++ b/src/Main.ml +@@ -105,6 +105,10 @@ + Arg.String set_subdir, + " Extract the files in a sub-folder; this option has an impact on the \ + import paths of the generated files" ); ++ ( "-use-tuple-structs", ++ Arg.Bool (fun b -> use_tuple_structs := b), ++ " Extract structures with unnamed fields as tuples (default: true). \ ++ Pass `-use-tuple-structs false` to preserve their nominal identity." ); + ( "-use-lean-modules", + Arg.Bool (fun b -> use_lean_modules := b), + " Emit Lean files using the module system (default: true). Pass \ diff --git a/verification/aeneas/prepare.py b/verification/aeneas/prepare.py new file mode 100644 index 0000000000..9ec0974aec --- /dev/null +++ b/verification/aeneas/prepare.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Prepare the pinned extraction without changing its translated behavior. + +Reject error-bearing LLBC even when Charon exits successfully. Repair only the +known duplicate dictionary name, then point the Lean project at already-fetched +backend dependencies. Native imports remain responsible for module ordering. +""" + +import argparse +import json +import re +from pathlib import Path + + +def check_llbc(path): + """Require Charon's explicit success flag and an actual translated crate.""" + data = json.loads(path.read_text()) + # Charon can exit successfully even when extraction was incomplete. + if data.get("has_errors") is not False: + raise ValueError("Charon LLBC is missing has_errors: false") + if not isinstance(data.get("translated"), dict): + raise ValueError("Charon LLBC is missing the translated crate") + + +def rename_copy_fields(types, funs): + """Apply the exact naming repair only after every expected match is unique.""" + # At this exact pin ZeroablePrimitive inherits Copy for both Self and its + # associated NonZeroInner. Aeneas gives both projections the same name. + # Rename ONLY the second projection and its initializer; retain both types + # and both dictionaries. Never change the translated helper bodies. + contents = {'types': types, 'funs': funs} + edits = [ + ('types', '"markerCopyInst", "markerCopyInst"', + '"markerCopyInst", "innerCopyInst"'), + ('types', "markerCopyInst : core.marker.Copy Self_NonZeroInner", + "innerCopyInst : core.marker.Copy Self_NonZeroInner"), + ('funs', "markerCopyInst := core.num.niche_types.NonZeroUsizeInner.Insts.CoreMarkerCopy", + "innerCopyInst := core.num.niche_types.NonZeroUsizeInner.Insts.CoreMarkerCopy"), + ] + for name, old, _ in edits: + if contents[name].count(old) != 1: + raise ValueError(f"Pinned Aeneas naming workaround no longer matches: {old}") + for name, old, new in edits: + contents[name] = contents[name].replace(old, new) + return contents['types'], contents['funs'] + + +def share_manifest(workspace, backend, rust_model): + """Reuse pinned packages and discover library roots from assembled modules. + + Each root is a default build target except Check, which the driver also runs + explicitly to refresh audit reports even when Lake has cached its imports. + """ + # Read the upstream lockfile and reuse the dependencies fetched into the + # backend. A separate `lake update` here would clone and cache Mathlib twice. + upstream = json.loads((backend / "lake-manifest.json").read_text()) + packages = [{ + "type": "path", "scope": "", "name": "aeneas", "inherited": False, + "dir": str(backend), "manifestFile": "lake-manifest.json", + "configFile": "lakefile.lean", + }] + if not (rust_model / "lakefile.lean").is_file(): + raise ValueError(f"Missing shared Rust Lean package: {rust_model}") + packages.append({ + "type": "path", "scope": "", "name": "rust_model", "inherited": False, + "dir": str(rust_model), "manifestFile": "lake-manifest.json", + "configFile": "lakefile.lean", + }) + for dep in upstream["packages"]: + directory = backend / ".lake/packages" / dep["name"] + if not directory.is_dir(): + raise ValueError(f"Missing pinned Lean dependency: {directory}") + packages.append({ + "type": "path", "scope": dep["scope"], "name": dep["name"], + "inherited": True, "dir": str(directory), + "manifestFile": dep["manifestFile"], "configFile": dep["configFile"], + }) + manifest = { + "version": upstream["version"], "packagesDir": ".lake/packages", + "packages": packages, "name": "zerocopyVerification", "lakeDir": ".lake", + "fixedToolchain": False, + } + (workspace / "lake-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + # Native proof modules use Lake's import graph. Discover their library roots + # rather than maintaining a second assembly order in this script. + libraries = {path.stem for path in workspace.glob('*.lean') + if path.name != 'lakefile.lean'} + declarations = ''.join( + ('' if name == 'Check' else '@[default_target] ') + f'lean_lib {name}\n' + for name in sorted(libraries)) + (workspace / "lakefile.lean").write_text( + 'import Lake\nopen Lake DSL\n' + f'require aeneas from {json.dumps(str(backend))}\n' + f'require rust_model from {json.dumps(str(rust_model))}\n' + 'package zerocopyVerification where\n' + ' moreLeanArgs := #["-DwarningAsError=true"]\n' + declarations) + + +def mathlib_imports(backend, proof_sources=None): + """Collect native Mathlib imports while excluding fetched dependency trees. + + Include handwritten proof submodules when supplied. Their imports determine + which cache artifacts the workflow needs; an unrelated .lake checkout must + not enlarge that request. + """ + pattern = re.compile(r"^(?:(?:public|private|protected|meta)\s+)*import\s+(.+)$") + modules = set() + roots = [backend] if proof_sources is None else [backend, proof_sources] + for root in roots: + for path in root.rglob('*.lean'): + if ".lake" in path.relative_to(root).parts: + continue + for line in path.read_text().splitlines(): + match = pattern.match(line.strip()) + if match: + modules.update(m for m in match[1].split() if m.startswith("Mathlib.")) + if not modules: + raise ValueError("No Mathlib imports found in the pinned Aeneas backend") + return sorted(modules) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["llbc", "prepare", "workspace", "mathlib-imports"]) + parser.add_argument("path", type=Path) + parser.add_argument("--backend", type=Path) + parser.add_argument("--proof-sources", type=Path) + args = parser.parse_args() + if args.command == "llbc": + check_llbc(args.path) + elif args.command == "mathlib-imports": + print("\n".join(mathlib_imports(args.path, args.proof_sources))) + elif args.command == "prepare": + types = args.path / "Zerocopy/Types.lean" + funs = args.path / "Zerocopy/Funs.lean" + types_text, funs_text = rename_copy_fields(types.read_text(), funs.read_text()) + types.write_text(types_text) + funs.write_text(funs_text) + if args.command == "workspace": + if args.backend is None: + parser.error(f"{args.command} requires --backend") + share_manifest(args.path, args.backend.resolve(), + Path(__file__).resolve().parents[2] / "anneal/lean") + + +if __name__ == "__main__": + main() diff --git a/verification/aeneas/run.sh b/verification/aeneas/run.sh new file mode 100755 index 0000000000..f3476c6c54 --- /dev/null +++ b/verification/aeneas/run.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# Produce a fresh Rust extraction, compare its generated modules, then check +# the authored proofs against golden and live definitions independently. A text +# match does not establish a proof, and a proof does not establish a text match. +set -euo pipefail +update_goldens=false +extract_only=false +case "${1:-}" in + "") [[ $# == 0 ]] ;; + --update-goldens) [[ $# == 1 ]]; update_goldens=true ;; + --extract-only) [[ $# == 1 ]]; extract_only=true ;; + *) echo "Usage: $0 [--update-goldens|--extract-only]" >&2; exit 1 ;; +esac +cd "$(dirname "$0")/../.." +repo=$PWD +source verification/aeneas/toolchain.sh +tools_dir=${AENEAS_TOOLCHAIN_DIR:-"$repo/target/aeneas/toolchain"} +backend="$tools_dir/backends/lean" +[[ $("$tools_dir/aeneas" -version) == "aeneas $AENEAS_VERSION" ]] +[[ $("$tools_dir/charon" toolchain-version) == "$AENEAS_RUST_TOOLCHAIN" ]] +[[ $("$tools_dir/charon" version) == "$CHARON_VERSION ($CHARON_REV)" ]] +[[ $(cat "$backend/lean-toolchain") == "$AENEAS_LEAN_TOOLCHAIN" ]] +export PATH="$tools_dir/lean/bin:$PATH" +export RUSTUP_TOOLCHAIN="$AENEAS_RUST_TOOLCHAIN" + +workspace_cmd=(python3 -B verification/aeneas/workspace.py) +initialize_workspace() { + "${workspace_cmd[@]}" initialize "$1" --root "$repo" --backend "$backend" +} +check_proofs() { + "${workspace_cmd[@]}" check "$1" --root "$repo" +} +work="$repo/target/aeneas/verification" +golden_work="$repo/target/aeneas/golden-verification" +initialize_workspace "$work" +if ! "$extract_only"; then initialize_workspace "$golden_work"; fi + +# Use a Rust parser for body ownership and a lexer for comments/literals. +CARGO_TARGET_DIR="$repo/target/aeneas/annotation-tool" \ + cargo +"$AENEAS_RUST_TOOLCHAIN" build --locked --jobs 2 \ + --manifest-path tools/Cargo.toml -p aeneas-inline +inline_tool="$repo/target/aeneas/annotation-tool/debug/aeneas-inline" +inline_cmd=(python3 -B verification/aeneas/inline.py) +inline_args=(--root "$repo" --tool "$inline_tool") +scan=scan +if "$update_goldens"; then scan=scan-update; fi +"${inline_cmd[@]}" "$scan" "${inline_args[@]}" --work "$work" +roots=$(cat "$work/roots.txt") + +# This compiles the actual zerocopy library with its normal build script and +# default features. Start from these private helpers and their dependencies; +# whole-crate unsafe verification is outside the current Aeneas safe subset. +( + cd zerocopy + CARGO_TARGET_DIR="$repo/target/aeneas/rust" \ + "$tools_dir/charon" cargo --preset=aeneas --sysroot default \ + --start-from "$roots" \ + --dest-file "$work/zerocopy.llbc" --abort-on-error --error-on-warnings \ + -- --lib --locked --offline +) +# Charon produces LLBC; the inline tool binds Rust owners to that inspected +# extraction. Aeneas then produces the function bodies used by the live proofs. +python3 verification/aeneas/prepare.py llbc "$work/zerocopy.llbc" +"${inline_cmd[@]}" bindings "${inline_args[@]}" --work "$work" +"$tools_dir/aeneas" -backend lean -namespace Zerocopy -dest "$work/Zerocopy" \ + -split-files -abort-on-error -warnings-as-errors -no-progress-bar \ + -use-tuple-structs false \ + "$work/zerocopy.llbc" +python3 verification/aeneas/prepare.py prepare "$work" +"${inline_cmd[@]}" models "${inline_args[@]}" --work "$work" +"${inline_cmd[@]}" assemble "${inline_args[@]}" --work "$work" +python3 verification/aeneas/prepare.py workspace "$work" --backend "$backend" +if "$extract_only"; then + echo "Development extraction: $work" + echo "Golden comparison and proof checks have not run." + exit 0 +fi +if "$update_goldens"; then + # Update explicitly, and only from a fresh extraction that passes proofs. + echo "Checking live model before updating goldens" + check_proofs "$work" + "${inline_cmd[@]}" update "${inline_args[@]}" --work "$work/Zerocopy" +fi +python3 verification/aeneas/golden.py compare "$work/Zerocopy" verification/aeneas/golden +"${inline_cmd[@]}" render "${inline_args[@]}" --work "$golden_work/Zerocopy" +# Reuse the audited Rust-to-Lean owner mapping, but keep the golden function +# definitions. This fixes which function each spec targets without substituting +# live bodies into the golden proof build. Fuzzy documentation cannot select a +# different function; both projects must pass their own proofs and axiom audit. +cp "$work/bindings.json" "$golden_work/bindings.json" +"${inline_cmd[@]}" assemble "${inline_args[@]}" --work "$golden_work" +python3 verification/aeneas/prepare.py workspace "$golden_work" --backend "$backend" +echo "Checking proofs and axioms against the checked-in golden model" +check_proofs "$golden_work" +echo "Checking proofs and axioms against the live Aeneas model" +check_proofs "$work" diff --git a/verification/aeneas/setup.sh b/verification/aeneas/setup.sh new file mode 100755 index 0000000000..685a92e648 --- /dev/null +++ b/verification/aeneas/setup.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +set -euo pipefail +cd "$(dirname "$0")/../.." +source verification/aeneas/toolchain.sh +mkdir -p target/aeneas + +# Downloads, patched compilation, version checks, and Mathlib cache fetching +# must all succeed before this candidate replaces the installed bundle. Only +# this invocation's temporary directory is disposable during a failed setup. +staging=$(mktemp -d "$PWD/target/aeneas/setup.XXXXXX") +trap 'rm -rf "$staging"' EXIT + +fetch() { + curl --fail --location --silent --show-error --retry 5 "$1" -o "$2" + python3 - "$2" "$3" <<'PY' +import hashlib +import sys +from pathlib import Path +digest = hashlib.sha256() +with Path(sys.argv[1]).open("rb") as archive: + for block in iter(lambda: archive.read(1024 * 1024), b""): + digest.update(block) +actual = digest.hexdigest() +if actual != sys.argv[2]: + raise SystemExit(f"SHA-256 mismatch for {sys.argv[1]}: {actual}") +PY +} + +fetch "https://github.com/AeneasVerif/aeneas/releases/download/$AENEAS_RELEASE/aeneas-$AENEAS_PLATFORM.tar.gz" \ + "$staging/aeneas.tar.gz" "$AENEAS_SHA256" +mkdir "$staging/toolchain" +tar -xzf "$staging/aeneas.tar.gz" -C "$staging/toolchain" +fetch "https://github.com/leanprover/lean4/releases/download/v$AENEAS_LEAN_VERSION/lean-$AENEAS_LEAN_VERSION-$AENEAS_LEAN_PLATFORM.tar.zst" \ + "$staging/lean.tar.zst" "$AENEAS_LEAN_SHA256" +mkdir "$staging/toolchain/lean" +tar --zstd -xf "$staging/lean.tar.zst" -C "$staging/toolchain/lean" --strip-components=1 + +[[ $("$staging/toolchain/aeneas" -version) == "aeneas $AENEAS_RELEASE" ]] +bash verification/aeneas/build-aeneas.sh "$staging/toolchain" +[[ $("$staging/toolchain/aeneas" -version) == "aeneas $AENEAS_VERSION" ]] +[[ $("$staging/toolchain/charon" toolchain-version) == "$AENEAS_RUST_TOOLCHAIN" ]] +[[ $("$staging/toolchain/charon" version) == "$CHARON_VERSION ($CHARON_REV)" ]] +[[ $(cat "$staging/toolchain/backends/lean/lean-toolchain") == "$AENEAS_LEAN_TOOLCHAIN" ]] +rustup toolchain install "$AENEAS_RUST_TOOLCHAIN" --profile minimal \ + --component rustc-dev --component rust-src + +# Fetch Mathlib imports of the backend and our proof sources, with dependencies. +# The bundle contains a manifest with pinned dependency revisions. Lake loads +# it directly; do not run `lake update` and float the dependencies. +export PATH="$staging/toolchain/lean/bin:$PATH" +python3 verification/aeneas/prepare.py mathlib-imports \ + "$staging/toolchain/backends/lean" --proof-sources verification/aeneas/lean \ + > "$staging/mathlib-imports.txt" +mathlib_modules=() +while IFS= read -r module; do + mathlib_modules+=("$module") +done < "$staging/mathlib-imports.txt" +( + cd "$staging/toolchain/backends/lean" + lake exe cache get "${mathlib_modules[@]}" + # The release includes compiled backend modules, but may omit some compiled + # dependencies. Direct Lean invocations cannot build a missing import. Let + # Lake complete the backend before installing it so extraction fixtures work + # on a fresh CI runner as well as with an existing local build cache. + lake build +Aeneas +) +rm -rf target/aeneas/toolchain +mv "$staging/toolchain" target/aeneas/toolchain diff --git a/verification/aeneas/tests/check_nominal_tuples.py b/verification/aeneas/tests/check_nominal_tuples.py new file mode 100644 index 0000000000..a6d3d3f8d0 --- /dev/null +++ b/verification/aeneas/tests/check_nominal_tuples.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Check declaration identity and unchanged ordinary tuples in fresh extraction.""" +from pathlib import Path +import re +import sys + +work = Path(sys.argv[1]) +nominal = (work / 'nominal/Nominal.lean').read_text() +default = (work / 'default/Nominal.lean').read_text() +# Nominal mode must retain each Rust owner as a structure, even when owners +# share fields. Default mode intentionally retains upstream tuple aliases. +for name in ('First', 'Second', 'Empty', 'OtherEmpty', 'Generic', 'OtherGeneric', 'Pair', 'OtherPair', 'Nested', 'OtherNested'): + assert re.search(rf'^structure {name}(?:\s|$)', nominal, re.M), name + assert re.search(rf'^def {name}(?:\s|$)', default, re.M), name +for name in ('ordinary', 'ordinary_pattern', 'ordinary_unit', 'ordinary_single'): + pattern = rf'^def {name}\b.*?(?=\n/--|\nend NominalTuples)' + actual = re.search(pattern, nominal, re.M | re.S) + expected = re.search(pattern, default, re.M | re.S) + assert actual and expected and actual.group() == expected.group(), name +print('Nominal identities and ordinary tuple compatibility checked') diff --git a/verification/aeneas/tests/nominal-tuples.sh b/verification/aeneas/tests/nominal-tuples.sh new file mode 100755 index 0000000000..a3145c8dfa --- /dev/null +++ b/verification/aeneas/tests/nominal-tuples.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. +set -euo pipefail +cd "$(dirname "$0")/../../.." +source verification/aeneas/toolchain.sh +repo=$PWD +tools_dir=${AENEAS_TOOLCHAIN_DIR:-"$repo/target/aeneas/toolchain"} +upstream=${AENEAS_UPSTREAM_EXE:-"$tools_dir/aeneas-upstream"} +work="$repo/target/aeneas/nominal-tuples" +mkdir -p "$work"/{nominal,default,explicit} +export RUSTUP_TOOLCHAIN="$AENEAS_RUST_TOOLCHAIN" +( + cd verification/aeneas/tests/nominal-tuples + CARGO_TARGET_DIR="$work/rust" "$tools_dir/charon" cargo --preset=aeneas --sysroot default \ + --dest-file "$work/nominal.llbc" --abort-on-error --error-on-warnings \ + -- --lib --locked --offline +) +extract() { + local executable=$1 destination=$2 + shift 2 + "$executable" -backend lean -namespace NominalTuples -dest "$destination" \ + -abort-on-error -warnings-as-errors -no-progress-bar "$@" "$work/nominal.llbc" +} +# Only false changes the representation of tuple structs. Explicit true, +# the patched default, and the unpatched release must agree byte for byte. +# Ordinary Rust tuples must agree across both representation modes as well. +extract "$tools_dir/aeneas" "$work/nominal" -use-tuple-structs false +extract "$tools_dir/aeneas" "$work/default" +extract "$tools_dir/aeneas" "$work/explicit" -use-tuple-structs true +diff -u "$work/default/Nominal.lean" "$work/explicit/Nominal.lean" +[[ -x "$upstream" ]] || { echo "Missing upstream comparison binary: $upstream" >&2; exit 1; } +mkdir -p "$work/upstream" +extract "$upstream" "$work/upstream" +diff -u "$work/upstream/Nominal.lean" "$work/default/Nominal.lean" +python3 verification/aeneas/tests/check_nominal_tuples.py "$work" +# Read only the installed backend's compiled imports; all test artifacts live here. +export LEAN_PATH +LEAN_PATH=$(python3 - "$tools_dir/backends/lean" "$work/nominal" <<'PYLEAN' +from pathlib import Path +import os, sys +backend = Path(sys.argv[1]) +paths = [str(backend / '.lake/build/lib/lean')] +paths += [str(p) for p in sorted((backend / '.lake/packages').glob('*/.lake/build/lib/lean'))] +print(os.pathsep.join([sys.argv[2], *paths])) +PYLEAN +) +# Text checks establish names and unchanged output. These compilations also +# establish that the generated declarations elaborate and support the intended +# distinct nominal providers, including empty and generic tuple structs. +"$tools_dir/lean/bin/lean" -j1 -DwarningAsError=true -o "$work/nominal/Nominal.olean" "$work/nominal/Nominal.lean" +"$tools_dir/lean/bin/lean" -j1 -DwarningAsError=true verification/aeneas/tests/nominal-tuples/Check.lean +LEAN_PATH="$work/default:${LEAN_PATH#*:}" +"$tools_dir/lean/bin/lean" -j1 -DwarningAsError=true -o "$work/default/Nominal.olean" "$work/default/Nominal.lean" +"$tools_dir/lean/bin/lean" -j1 -DwarningAsError=true verification/aeneas/tests/nominal-tuples/Default.lean +echo "Nominal tuple extraction fixtures: $work" diff --git a/verification/aeneas/tests/nominal-tuples/Cargo.lock b/verification/aeneas/tests/nominal-tuples/Cargo.lock new file mode 100644 index 0000000000..c7ba6b168d --- /dev/null +++ b/verification/aeneas/tests/nominal-tuples/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "nominal-tuples" +version = "0.0.0" diff --git a/verification/aeneas/tests/nominal-tuples/Cargo.toml b/verification/aeneas/tests/nominal-tuples/Cargo.toml new file mode 100644 index 0000000000..8cbcd5caf0 --- /dev/null +++ b/verification/aeneas/tests/nominal-tuples/Cargo.toml @@ -0,0 +1,15 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +[package] +name = "nominal-tuples" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] diff --git a/verification/aeneas/tests/nominal-tuples/Check.lean b/verification/aeneas/tests/nominal-tuples/Check.lean new file mode 100644 index 0000000000..b3155f24ce --- /dev/null +++ b/verification/aeneas/tests/nominal-tuples/Check.lean @@ -0,0 +1,50 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +import Nominal + +open Aeneas Aeneas.Std Result NominalTuples + +example : True := by + fail_if_success have : First = Second := rfl + fail_if_success have : NominalTuples.Empty = OtherEmpty := rfl + fail_if_success have : Generic Nat = OtherGeneric Nat := rfl + fail_if_success have : Pair Nat = OtherPair Nat := rfl + fail_if_success have : Nested Nat = OtherNested Nat := rfl + trivial + +example (x : First) : x = x := by + fail_if_success have : Second := x + fail_if_success have : Std.U32 := x + rfl + +example (x : Std.U32) : construct x = ok (First.mk x, Second.mk x) := rfl +example (x y : Std.U32) : project (First.mk x) (Second.mk y) = ok (x, y) := rfl +example (x y : Std.U32) : pattern (First.mk x) (Second.mk y) = ok (x, y) := rfl +example : empty = ok (Empty.mk, OtherEmpty.mk) := rfl +example : empty_pattern Empty.mk OtherEmpty.mk = ok () := rfl +example {T : Type} (x : T) : generic x = ok (Generic.mk x) := rfl +example {T : Type} (x : T) : other_generic x = ok (OtherGeneric.mk x) := rfl +example {T : Type} (x : T) : generic_pattern (Generic.mk x) = ok x := rfl +example {T : Type} (x : T) (y : Std.U32) : pair x y = ok (Pair.mk x y) := rfl +example {T : Type} (x : T) (y : Std.U32) : pair_pattern (Pair.mk x y) = ok (x, y) := rfl +example {T : Type} (x : T) (y z : Std.U32) : + update (Pair.mk x y) z = ok (Pair.mk x z) := rfl +example {T : Type} (x : T) (y : Std.U32) : + nested x y = ok (Nested.mk (First.mk y) (Second.mk y) (Generic.mk x)) := rfl +example {T : Type} (x : T) (a b : Std.U32) : + nested_pattern (Nested.mk (First.mk a) (Second.mk b) (Generic.mk x)) = ok (a, b, x) := rfl +example (x : Std.U32) (y : Std.U64) : ordinary x y = ok (x, y) := rfl +example (x : Std.U32) (y : Std.U64) : ordinary_pattern (x, y) = ok (y, x) := rfl +example : ordinary_unit () = ok () := rfl +example {T : Type} (x : T) (y : Std.U32) : other_pair x y = ok (OtherPair.mk x y) := rfl +example {T : Type} (x : T) (y : Std.U32) : other_pair_pattern (OtherPair.mk x y) = ok (x, y) := rfl +example {T : Type} (x : T) (y : Std.U32) : + other_nested x y = ok (OtherNested.mk (First.mk y) (Second.mk y) (Generic.mk x)) := rfl +example {T : Type} (x : T) (a b : Std.U32) : + other_nested_pattern (OtherNested.mk (First.mk a) (Second.mk b) (Generic.mk x)) = ok (a, b, x) := rfl diff --git a/verification/aeneas/tests/nominal-tuples/Default.lean b/verification/aeneas/tests/nominal-tuples/Default.lean new file mode 100644 index 0000000000..39984f927c --- /dev/null +++ b/verification/aeneas/tests/nominal-tuples/Default.lean @@ -0,0 +1,19 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +import Nominal + +open Aeneas Aeneas.Std NominalTuples + +example : First = Second := rfl +example : NominalTuples.Empty = OtherEmpty := rfl +example : Generic Nat = OtherGeneric Nat := rfl +example : Pair Nat = OtherPair Nat := rfl +example : Nested Nat = OtherNested Nat := rfl +example (x : First) : Second := x +example (x : Std.U32) : First := x diff --git a/verification/aeneas/tests/nominal-tuples/src/lib.rs b/verification/aeneas/tests/nominal-tuples/src/lib.rs new file mode 100644 index 0000000000..2dc3ef6205 --- /dev/null +++ b/verification/aeneas/tests/nominal-tuples/src/lib.rs @@ -0,0 +1,95 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +#![no_std] + +#[repr(transparent)] +pub struct First(pub u32); +pub struct Second(pub u32); +pub struct Empty(); +pub struct OtherEmpty(); +pub struct Generic(pub T); +pub struct OtherGeneric(pub T); +pub struct Pair(pub T, pub u32); +pub struct OtherPair(pub T, pub u32); +pub struct Nested(pub First, pub Second, pub Generic); +pub struct OtherNested(pub First, pub Second, pub Generic); + +pub fn construct(x: u32) -> (First, Second) { + (First(x), Second(x)) +} +pub fn project(x: First, y: Second) -> (u32, u32) { + (x.0, y.0) +} +pub fn pattern(x: First, y: Second) -> (u32, u32) { + let First(a) = x; + let Second(b) = y; + (a, b) +} +pub fn empty() -> (Empty, OtherEmpty) { + (Empty(), OtherEmpty()) +} +pub fn empty_pattern(x: Empty, y: OtherEmpty) { + let Empty() = x; + let OtherEmpty() = y; +} +pub fn generic(x: T) -> Generic { + Generic(x) +} +pub fn other_generic(x: T) -> OtherGeneric { + OtherGeneric(x) +} +pub fn generic_pattern(x: Generic) -> T { + let Generic(a) = x; + a +} +pub fn pair(x: T, y: u32) -> Pair { + Pair(x, y) +} +pub fn other_pair(x: T, y: u32) -> OtherPair { + OtherPair(x, y) +} +pub fn other_pair_pattern(x: OtherPair) -> (T, u32) { + let OtherPair(a, b) = x; + (a, b) +} +pub fn pair_pattern(x: Pair) -> (T, u32) { + let Pair(a, b) = x; + (a, b) +} +pub fn update(mut x: Pair, y: u32) -> Pair { + x.1 = y; + x +} +pub fn nested(x: T, y: u32) -> Nested { + Nested(First(y), Second(y), Generic(x)) +} +pub fn nested_pattern(x: Nested) -> (u32, u32, T) { + let Nested(First(a), Second(b), Generic(c)) = x; + (a, b, c) +} +pub fn ordinary(x: u32, y: u64) -> (u32, u64) { + (x, y) +} +pub fn ordinary_pattern(x: (u32, u64)) -> (u64, u32) { + let (a, b) = x; + (b, a) +} +pub fn ordinary_unit(x: ()) { + let () = x; +} +pub fn other_nested(x: T, y: u32) -> OtherNested { + OtherNested(First(y), Second(y), Generic(x)) +} +pub fn other_nested_pattern(x: OtherNested) -> (u32, u32, T) { + let OtherNested(First(a), Second(b), Generic(c)) = x; + (a, b, c) +} +pub fn ordinary_single(x: T) -> (T,) { + (x,) +} diff --git a/verification/aeneas/tests/rounding_model_controls.py b/verification/aeneas/tests/rounding_model_controls.py new file mode 100644 index 0000000000..d8718c0a03 --- /dev/null +++ b/verification/aeneas/tests/rounding_model_controls.py @@ -0,0 +1,223 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Challenge rounding operation promises and input coverage with valid decoders. + +The constant decoder still constructs a constrained mathematical value but loses +the constructor's promised pair. The rejecting decoder excludes an independently +fixed valid getter input. Both probes first pass with the production decoder; +negative-controls.sh also compiles each mutated decoder before checking failure. +Only a diagnostic inside the intended theorem counts as semantic rejection. +""" +import argparse +import json +from pathlib import Path +import re + +OWNER = 'Zerocopy.layout.RoundingAlignAndPhase' +PREFIX = f'derive_rust_model {OWNER} with 0 type parameters' +CONSTANT = PREFIX + """ decode self => + { align := 1, phase := 0, align_pow2 := AeneasSpecs.model_power_of_two 0, + phase_lt := by decide, + fits := by + have positive := self._0.positive + have bound : self._0.value ≤ Usize.max := by + simpa only [UScalar.max, Usize.max, Usize.numBits, UScalarTy.Usize_numBits_eq] + using self._0.bound + omega } +""" +REJECT = PREFIX + ' decode? self =>\n none\n' +PROBES = {'constant': ('RoundingConstructorControl', 'constructor_promise'), + 'none': ('RoundingDomainControl', 'positive_input_domain')} + + +def constructor_spec(): + """Reuse the source-owned fence verbatim, without its helper import closure.""" + source = Path('Specs.lean').read_text() + fences = re.findall(r'^aeneas_spec_begin\n(.*?)^aeneas_spec_end$', + source, re.MULTILINE | re.DOTALL) + matches = [body for body in fences if re.match(r'spec encoding_new_spec\b', body)] + if len(matches) != 1 or not re.search( + r'for @?' + re.escape(OWNER) + r'[.]new with 0 type parameters\b', matches[0]): + raise ValueError('Missing unique source-owned rounding constructor promise') + return matches[0] + + +def write_probes(): + """Write isolated Lean consumers of the real constructor fence and decoder. + + The fixed raw input and successful raw result prevent changed admission from + making the promises vacuous. Imports supply ordinary decoder dependencies; + representation helper lemmas cannot replace the operation being challenged. + """ + header = '''module +public import Zerocopy.Funs +public import Models +public import SpecsSyntax +@[expose] public section +open Aeneas Aeneas.Std AeneasSpecs +set_option linter.unusedSimpArgs false +''' + constructor = header + '''namespace RoundingConstructorControl +-- This is the unchanged generated constructor fence, in an isolated namespace. +''' + constructor_spec() + ''' +def align : core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner := ⟨2#usize⟩ +def phase : Usize := 1#usize +def code : Zerocopy.layout.RoundingAlignAndPhase := ⟨⟨3#usize⟩⟩ +-- Pin the actual successful result independently of the decoder. +theorem constructor_result : Zerocopy.layout.RoundingAlignAndPhase.new align phase = .ok code := by + have pow : (2 : Nat).isPowerOfTwo := by simpa using model_power_of_two 1 + rcases System.Platform.numBits_eq with bits | bits <;> + simp [Zerocopy.layout.RoundingAlignAndPhase.new, align, phase, code, + core.num.nonzero.NonZero.get, core.num.nonzero.NonZero.new, + core.num.Usize.is_power_of_two, UScalar.is_power_of_two, + UScalar.lt_equiv, UScalar.eq_equiv, UScalar.val_or, massert, lift, pow, bits] +-- The arbitrary-outcome contract checks both output admission and pair meaning. +theorem constructor_promise : encoding_new_spec_contract align phase (.ok code) := by + simp [encoding_new_spec_contract, WP.spec, WP.dspec, RustModel.decode, + Zerocopy.layout.RoundingAlignAndPhase.aeneasModel, + Zerocopy.layout.RoundingAlignAndPhase.decode, + Zerocopy.layout.RoundingAlignAndPhase.decodeFields, + modelNonZeroUScalar, modelUScalar, unsignedWord, align, phase, code] +end RoundingConstructorControl +''' + domain = header + '''namespace RoundingDomainControl +def code : Zerocopy.layout.RoundingAlignAndPhase := ⟨⟨3#usize⟩⟩ +-- Independently fix a positive input required by the getter domain. A shared +-- rejecting decoder cannot turn this promised raw input into a vacuous premise. +theorem positive_input_domain : isValid code := by + simp [isValid, RustModel.decode, + Zerocopy.layout.RoundingAlignAndPhase.aeneasModel, + Zerocopy.layout.RoundingAlignAndPhase.decode, + Zerocopy.layout.RoundingAlignAndPhase.decodeFields, + modelNonZeroUScalar, modelUScalar, unsignedWord, code] +end RoundingDomainControl +''' + for module, source in [('RoundingConstructorControl', constructor), + ('RoundingDomainControl', domain)]: + path = Path(module + '.lean') + if path.exists(): + raise ValueError(f'Rounding operation control fixture already exists: {module}') + Path('RoundingConstructorControl.lean').write_text(constructor) + Path('RoundingDomainControl.lean').write_text(domain) + + +def capability(): + """Select controls from the audited owner binding, not from helper names. + + Earlier stack stages may lack this model. Once its owner or decoder is + present, missing or changed binding evidence is an error, never a skip. + """ + manifest = json.loads(Path('bindings.json').read_text()) + entries = manifest.get('bindings') + if manifest.get('version') != 3 or manifest.get('mode') != 'verified-live' or not isinstance(entries, dict): + raise ValueError('Invalid verified binding table for rounding model controls') + owner = entries.get('zerocopy::layout::RoundingAlignAndPhase') + if owner is not None and not isinstance(owner, dict): + raise ValueError('Invalid production rounding owner binding') + selected = bool(owner and owner.get('kind') == 'type' and owner.get('raw') == OWNER + and owner.get('model') == OWNER + '.RoundingValue') + if not selected and (owner is not None or (Path('Models.lean').exists() + and PREFIX in Path('Models.lean').read_text())): + raise ValueError('Rounding operation controls lost their production model binding') + if selected: + for name in ('Models', 'ModelShapes', 'Specs', 'SpecsSyntax', 'Zerocopy/Funs'): + if not Path(name + '.lean').is_file(): + raise ValueError(f'Missing selected rounding model control prerequisite: {name}') + return selected + + +def mutate(kind): + """Replace only the unique owner's decoder, preserving sibling providers.""" + if kind not in ('constant', 'none'): + raise ValueError('Unknown rounding decoder mutation') + path = Path('Models.lean') + text = path.read_text() + end_marker = f'check_model_binding {OWNER} with 0 type parameters' + if text.count(PREFIX) != 1 or text.count(end_marker) != 1: + raise ValueError('Rounding decoder control boundaries no longer match') + start = text.index(PREFIX) + end = text.index(end_marker) + if end <= start or re.search(r'^(?:derive_rust_model|check_model_binding|namespace|end|' + r'def|abbrev|instance|theorem|structure|inductive)\b', + text[start + len(PREFIX):end], re.MULTILINE): + raise ValueError('Rounding decoder control boundaries contain another declaration') + # Keep every generated sibling, raw owner, and child provider untouched. + path.write_text(text[:start] + (CONSTANT if kind == 'constant' else REJECT) + text[end:]) + + +def check_failure(kind, log_path): + """Accept only recognized proof failures within the selected probe theorem. + + Inspect all errors, not merely the first expected one. An unrelated import, + parser, or kernel failure invalidates the evidence even when the semantic + probe also fails. Accept both native Lean and Lake diagnostic locations. + """ + if kind not in ('constant', 'none'): + raise ValueError('Unknown rounding decoder mutation') + module, theorem = PROBES[kind] + lines = Path(module + '.lean').read_text().splitlines() + starts = [i + 1 for i, line in enumerate(lines) if re.search(r'\btheorem\s+' + theorem + r'\b', line)] + if len(starts) != 1: + raise ValueError(f'Missing rounding behavior probe: {module}.{theorem}') + start = starts[0] + end = next((i + 1 for i, line in enumerate(lines) if i + 1 > start + and re.match(r'^(?:@\[[^]]*\]\s*)?(?:theorem|def|abbrev)\s', line)), len(lines) + 1) + text = re.sub(r'\x1b\[[0-9;]*m', '', Path(log_path).read_text()) + # A parser, import, missing dependency, or unknown kernel constant is never + # evidence that the independent mathematical proposition rejects a decoder. + invalid = (r'unknown (?:identifier|constant|module)', r'(?:invalid|failed to) import', + r'object file .*does not exist', r'unexpected token', + r'environment already contains', r'failed to synthesize', + r'kernel (?:exception|error)', r'could not resolve import') + if any(re.search(marker, text, re.IGNORECASE) for marker in invalid): + raise ValueError('Rounding mutant failed outside its operation promise or domain') + # Lean's native renderer puts the severity after the location; Lake puts it + # before the location. Parse every error, including unlocated errors, so a + # semantic failure cannot hide a broken import or kernel failure. + severity = r'error(?:\([^)]*\))?' + native = re.compile(r'^(.+[.]lean):(\d+):\d+: ' + severity + r': (.*)$') + lake = re.compile(r'^' + severity + r': (.+[.]lean):(\d+):\d+: (.*)$') + summaries = ('error: Lean exited with code 1', 'error: build failed') + errors = [] + for line in text.splitlines(): + match = native.match(line) or lake.match(line) + if match: + file, location, diagnostic = match.groups() + errors.append((Path(file).name, int(location), diagnostic)) + elif re.search(r'\berror(?:\([^)]*\))?:', line) and line not in summaries: + raise ValueError('Unrecognized error in rounding semantic rejection log') + expected = ('unsolved goals', 'Application type mismatch', 'Type mismatch', 'Tactic') + if not errors or any(file != module + '.lean' or not start <= line < end + or not diagnostic.startswith(expected) + for file, line, diagnostic in errors): + raise ValueError('Rounding mutant failed outside its operation promise or domain') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('operation', choices=('capability', 'write-probes', 'mutate', 'check-failure')) + parser.add_argument('kind', nargs='?', choices=('constant', 'none')) + parser.add_argument('log', nargs='?') + args = parser.parse_args() + if args.operation == 'capability': + print('yes' if capability() else 'no') + elif args.operation == 'write-probes': + write_probes() + elif args.operation == 'mutate': + if args.kind is None: + parser.error('mutate requires a decoder mutation') + mutate(args.kind) + else: + if args.kind is None or args.log is None: + parser.error('check-failure requires a mutation and log') + check_failure(args.kind, args.log) + + +if __name__ == '__main__': + main() diff --git a/verification/aeneas/tests/test_copy_back.py b/verification/aeneas/tests/test_copy_back.py new file mode 100644 index 0000000000..1ff4070329 --- /dev/null +++ b/verification/aeneas/tests/test_copy_back.py @@ -0,0 +1,393 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import contextlib +import copy +import io +import json +import shutil +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import copy_back +import inline +import workspace +from test_inline import BLOCK, ENTRY, GENERATED, ROOT, RUST, fixture + +TYPE = 'zerocopy::util::Box' +MODEL = '/// ```aeneas\n/// model BoxValue := Fields\n/// decode self => self\n/// ```\n' +TYPES = ('module\nnamespace Zerocopy\n/-- [zerocopy::util::Box]\n Source: x -/\n' + 'structure util.Box (T : Type) where\n val : T\n\nend Zerocopy\n') + + +class CopyBackTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.work = self.root / 'work' + self.source = self.root / ENTRY['file'] + + def generate(self, source=None, second=False, model=False): + if source is None: + source = (MODEL + 'struct Box(T);\n' if model else '') + BLOCK + 'fn f(x: usize) {}\n' + if second: + source += BLOCK.replace('f_spec', 'g_spec') + 'fn g(x: usize) {}\n' + annotations = fixture(self.root, source) + (self.work / 'Zerocopy').mkdir(parents=True, exist_ok=True) + generated = GENERATED + if second: + generated = generated.replace('\nend Zerocopy', '\n' + GENERATED.split('namespace Zerocopy\n')[1].replace( + 'zerocopy::util::f', 'zerocopy::util::g').replace('util.f', 'util.g').split('\nend Zerocopy')[0] + '\nend Zerocopy') + (self.work / 'Zerocopy/Funs.lean').write_text(generated) + if model: + (self.work / 'Zerocopy/Types.lean').write_text(TYPES) + lean = self.root / 'verification/aeneas/lean' + lean.mkdir(parents=True, exist_ok=True) + (lean / 'RequiredContracts.lean').write_text('') + inline.save_bindings(self.root, annotations, self.work, development=True) + inline.assemble(self.root, annotations, self.work, development=True) + return annotations + + def edit(self, module, old, new): + path = self.work / f'{module}.lean' + text = path.read_text() + self.assertIn(old, text) + path.write_text(text.replace(old, new, 1)) + + def files(self): + return {str(p.relative_to(self.root)): p.read_bytes() for p in self.root.rglob('*') if p.is_file()} + + def run_copy(self, owner=RUST, apply=False, **kwargs): + with contextlib.redirect_stdout(io.StringIO()) as output: + diff = copy_back.copy_back(self.root, self.work, owner, apply=apply, **kwargs) + return diff, output.getvalue() + + def fails_without_writes(self, owner=RUST, message=None): + before = self.files() + with self.assertRaisesRegex(ValueError, message or '.'): + self.run_copy(owner, apply=True) + self.assertEqual(before, self.files()) + + def test_spec_line_count_preview_apply_and_native_regeneration_round_trip(self): + self.generate() + self.edit('Specs', ' ensures r => r = x', ' requires positive : x > 0\n ensures r =>\n r = x + 1') + edited = (self.work / 'Specs.lean').read_bytes() + before = self.files() + diff, printed = self.run_copy() + self.assertEqual(diff, printed) + self.assertIn('+/// requires positive : x > 0', diff) + self.assertEqual(before, self.files()) + self.run_copy(apply=True) + self.assertIn(b'/// r = x + 1\n', self.source.read_bytes()) + workspace.protect_projections(self.work) + self.generate(self.source.read_bytes().decode()) + self.assertEqual(edited, (self.work / 'Specs.lean').read_bytes()) + + def test_shape_and_decoder_are_one_annotation_and_leave_other_owner_dirty(self): + self.generate(model=True) + self.edit('ModelShapes', 'model BoxValue := Fields', 'model BoxValue where\n val : T\n extra : Nat') + self.edit('Models', 'decode self =>\n self', 'decode? self =>\n some { val := self.val, extra := 0 }') + self.edit('Specs', 'r = x', 'r ≠ x') + specs = (self.work / 'Specs.lean').read_bytes() + before = self.source.read_bytes() + self.run_copy(TYPE, apply=True) + source = self.source.read_bytes() + self.assertTrue(source.endswith(before[before.index(BLOCK.encode()):])) + self.assertIn(b'/// extra : Nat\n/// decode? self =>', source) + self.assertEqual(specs, (self.work / 'Specs.lean').read_bytes()) + with self.assertRaisesRegex(ValueError, 'Refusing to overwrite'): + workspace.protect_projections(self.work) + self.run_copy(apply=True) + workspace.protect_projections(self.work) + edited = {m: (self.work / f'{m}.lean').read_bytes() for m in workspace.PROJECTIONS} + self.generate(self.source.read_bytes().decode(), model=True) + self.assertEqual(edited, {m: (self.work / f'{m}.lean').read_bytes() for m in workspace.PROJECTIONS}) + + def test_unicode_crlf_indented_docs_and_mode_preserve_every_other_byte(self): + source = '// 雪 documentation\n' + ''.join(' ' + line for line in BLOCK.splitlines(keepends=True)) + ' fn f(x: usize) {}\n' + source = source.replace('\n', '\r\n') + annotations = self.generate(source) + span = annotations[RUST]['copy_span'] + self.source.chmod(0o640) + before = self.source.read_bytes() + self.edit('Specs', 'r = x', 'r ≠ x\n -- 雪') + self.run_copy(apply=True) + after = self.source.read_bytes() + self.assertEqual(after[:span['start']], before[:span['start']]) + self.assertTrue(after.endswith(before[span['end']:])) + self.assertIn(' /// -- 雪\r\n'.encode(), after) + self.assertNotIn(b'\n', after.replace(b'\r\n', b'')) + self.assertEqual(stat.S_IMODE(self.source.stat().st_mode), 0o640) + + def test_two_function_owners_shift_spans_without_adopting_other_edits(self): + self.generate(second=True) + self.edit('Specs', 'r = x', 'r ≠ x\n ∧ True') + self.edit('Specs', 'spec g_spec\n for @Zerocopy.util.g with 0 type parameters\n ensures r => r = x', + 'spec g_spec\n for @Zerocopy.util.g with 0 type parameters\n ensures r => r = x + 2') + lean = (self.work / 'Specs.lean').read_bytes() + self.run_copy(apply=True) + self.assertIn(b'/// ensures r => r = x\n', self.source.read_bytes()) + self.assertEqual(lean, (self.work / 'Specs.lean').read_bytes()) + with self.assertRaisesRegex(ValueError, 'Refusing to overwrite'): + workspace.protect_projections(self.work) + self.run_copy('zerocopy::util::g', apply=True) + workspace.protect_projections(self.work) + self.assertIn(b'r = x + 2', self.source.read_bytes()) + + def test_generated_application_import_check_and_derive_edits_rejected(self): + changes = [('Specs', ' for @Zerocopy.util.f', ' for @Zerocopy.util.g'), + ('Specs', 'public import Models', 'public import Other'), + ('Specs', 'check_model_inputs', 'check_other_inputs'), + ('Specs', 'check_spec_binding', 'check_other_binding'), + ('ModelShapes', 'type parameters begin', 'type parameters begin\n-- scratch'), + ('Models', 'derive_rust_model Zerocopy.util.Box', 'derive_rust_model Zerocopy.util.Other'), + ('Models', 'check_model_binding', 'check_other_binding')] + self.generate(model=True) + baseline = self.files() + for module, old, new in changes: + with self.subTest(module=module, old=old): + self.edit(module, old, new) + self.fails_without_writes(TYPE if module != 'Specs' else RUST) + for relative, contents in baseline.items(): + (self.root / relative).write_bytes(contents) + + def test_moving_generated_for_target_is_rejected_without_writes(self): + self.generate() + application = ' for @Zerocopy.util.f with 0 type parameters' + self.edit('Specs', application + '\n ensures r => r = x', + ' ensures r => r = x\n' + application) + self.fails_without_writes(message='immediately before the first specification clause') + + def test_added_binder_lines_move_canonical_for_target_and_round_trip(self): + self.generate() + application = ' for @Zerocopy.util.f with 0 type parameters' + self.edit('Specs', 'spec f_spec\n' + application, + 'spec f_spec\n (ghost : Nat)\n' + application) + projected = (self.work / 'Specs.lean').read_bytes() + self.run_copy(apply=True) + self.assertIn(b'/// spec f_spec\n/// (ghost : Nat)\n/// ensures', self.source.read_bytes()) + workspace.protect_projections(self.work) + self.generate(self.source.read_bytes().decode()) + self.assertEqual(projected, (self.work / 'Specs.lean').read_bytes()) + + def test_specification_trailing_blank_edit_is_rejected_without_poisoning_regeneration(self): + self.generate() + original = (self.work / 'Specs.lean').read_bytes() + self.edit('Specs', ' ensures r => r = x\n-- aeneas-copy-end', + ' ensures r => r = x\n\n-- aeneas-copy-end') + self.fails_without_writes(message='remove trailing blank lines') + (self.work / 'Specs.lean').write_bytes(original) + self.generate(self.source.read_bytes().decode()) + self.assertEqual(original, (self.work / 'Specs.lean').read_bytes()) + + def test_inline_decoder_edit_is_rejected_with_normalization_advice(self): + self.generate(model=True) + self.edit('Models', 'decode self =>\n self', 'decode self => self') + self.fails_without_writes(TYPE, message='body on separate lines indented by two spaces') + + def test_original_rust_spec_trailing_blanks_remain_readable_and_noop_preserved(self): + source = BLOCK.replace('/// ```\n', '/// \n/// \n/// ```\n') + 'fn f(x: usize) {}\n' + self.generate(source) + before = self.files() + self.run_copy(apply=True) + self.assertEqual(before, self.files()) + self.generate(source) + self.assertEqual(self.source.read_bytes(), source.encode()) + workspace.protect_projections(self.work) + + def test_model_trailing_blank_edits_are_rejected_before_writes(self): + self.generate(model=True) + for module in ('ModelShapes', 'Models'): + with self.subTest(module=module): + original = (self.work / f'{module}.lean').read_bytes() + self.edit(module, '\n-- aeneas-copy-end', '\n\n-- aeneas-copy-end') + self.fails_without_writes(TYPE, message='remove trailing blank lines') + (self.work / f'{module}.lean').write_bytes(original) + + def test_nested_fake_duplicate_and_missing_markers_rejected(self): + self.generate() + original = (self.work / 'Specs.lean').read_bytes() + begin = f'-- aeneas-copy-begin {RUST} spec\n' + end = f'-- aeneas-copy-end {RUST} spec\n' + for change in (begin + begin, '-- aeneas-copy-begin fake spec\n', '-- aeneas-copy-begin ' + RUST + ' shape\n', ''): + with self.subTest(change=change): + (self.work / 'Specs.lean').write_bytes(original.replace(begin.encode(), change.encode())) + self.fails_without_writes() + (self.work / 'Specs.lean').write_bytes(original + begin.encode() + end.encode()) + self.fails_without_writes() + + def test_unsupported_doc_forms_keep_read_support_and_fail_copy(self): + for source in ('/**\n```aeneas\nspec f_spec\n ensures r => r = x\n```\n*/\nfn f(x: usize) {}\n', + '#[doc = "```aeneas\\nspec f_spec\\n ensures r => r = x\\n```"]\nfn f(x: usize) {}\n'): + with self.subTest(source=source): + self.generate(source) + self.fails_without_writes(message='other doc forms are read-only') + + def test_stale_rust_and_malformed_metadata_have_no_writes(self): + self.generate() + self.edit('Specs', 'r = x', 'r ≠ x') + original = self.source.read_bytes() + self.source.write_bytes(original + b'// independent Rust edit\n') + self.fails_without_writes(message='Rust source changed') + self.source.write_bytes(original) + path = self.work / workspace.PROJECTION_STATE + state = json.loads(path.read_bytes()) + variants = [[], {'version': 1}, {**state, 'version': 3}, {**state, 'projections': {}}, {**state, 'sources': {}}] + wrong_span = copy.deepcopy(state) + wrong_span['owners'][RUST]['copy_span']['start'] += 1 + variants.append(wrong_span) + missing_owner = copy.deepcopy(state) + missing_owner['owners'][RUST]['file'] = '../escape.rs' + variants.append(missing_owner) + for variant in variants: + with self.subTest(variant=variant): + path.write_text(json.dumps(variant)) + self.fails_without_writes() + path.write_text('{') + self.fails_without_writes(message='Malformed') + + def test_interruption_after_rust_write_recovers_then_retries_idempotently(self): + self.generate() + self.edit('Specs', 'r = x', 'r ≠ x\n ∧ True') + control = (self.work / workspace.PROJECTION_STATE).read_bytes() + projections = {m: (self.work / f'{m}.lean').read_bytes() for m in workspace.PROJECTIONS} + with self.assertRaisesRegex(RuntimeError, 'interrupted'): + self.run_copy(apply=True, after_source_write=lambda: (_ for _ in ()).throw(RuntimeError('interrupted'))) + self.assertIn('r ≠ x'.encode(), self.source.read_bytes()) + self.assertEqual(control, (self.work / workspace.PROJECTION_STATE).read_bytes()) + self.assertEqual(projections, {m: (self.work / f'{m}.lean').read_bytes() for m in workspace.PROJECTIONS}) + self.run_copy(apply=True) + workspace.protect_projections(self.work) + before = self.files() + self.run_copy(apply=True) + self.assertEqual(before, self.files()) + + def test_symlink_files_and_parent_directories_rejected(self): + self.generate() + self.edit('Specs', 'r = x', 'r ≠ x') + for target in (self.source, self.work / 'Specs.lean', self.work / workspace.PROJECTION_STATE): + with self.subTest(target=target): + moved = target.with_name(target.name + '.real') + target.rename(moved) + target.symlink_to(moved) + self.fails_without_writes(message='symlink') + target.unlink() + moved.rename(target) + parent = self.source.parent + moved = parent.with_name('real-util') + parent.rename(moved) + parent.symlink_to(moved, target_is_directory=True) + self.fails_without_writes(message='symlink') + + def test_source_maps_are_diagnostic_only_and_cannot_redirect_an_edit(self): + self.generate() + self.edit('Specs', 'r = x', 'r ≠ x') + unrelated = self.root / 'zerocopy/src/unrelated.rs' + unrelated.write_text('unique unrelated Rust source\n') + for module in workspace.PROJECTIONS: + (self.work / f'{module}.source-map.json').write_text(json.dumps({ + '1': {'file': 'zerocopy/src/unrelated.rs', 'line': 1}})) + self.run_copy(apply=True) + self.assertIn('r ≠ x'.encode(), self.source.read_bytes()) + self.assertEqual(unrelated.read_text(), 'unique unrelated Rust source\n') + + def test_malformed_authored_payload_is_rejected_before_writes(self): + self.generate(model=True) + originals = {module: (self.work / f'{module}.lean').read_bytes() for module in workspace.PROJECTIONS} + for module, old, new, owner in [ + ('Specs', ' ensures r => r = x', ' theorem bad : True := by trivial', RUST), + ('Specs', ' ensures r => r = x', ' ensures r = x', RUST), + ('Models', 'decode self =>\n self', 'decode self =>\n self\ndecode other => other', TYPE), + ('ModelShapes', 'model BoxValue := Fields', 'model Fields := Fields', TYPE)]: + with self.subTest(module=module, new=new): + self.edit(module, old, new) + self.fails_without_writes(owner) + (self.work / f'{module}.lean').write_bytes(originals[module]) + + def test_noop_has_no_writes_even_for_inline_decoder_spelling(self): + self.generate(model=True) + before = self.files() + self.run_copy(TYPE, apply=True) + self.run_copy(apply=True) + self.assertEqual(before, self.files()) + + def test_input_changes_immediately_before_replace_abort_without_rust_write(self): + self.generate() + self.edit('Specs', 'r = x', 'r ≠ x') + source = self.source.read_bytes() + original_replace = copy_back.replace + + def raced(record, contents, guard): + (self.work / 'Specs.lean').write_text('changed during planning') + return original_replace(record, contents, guard) + + with mock.patch.object(copy_back, 'replace', side_effect=raced), self.assertRaisesRegex(ValueError, 'input changed'): + self.run_copy(apply=True) + self.assertEqual(source, self.source.read_bytes()) + self.assertFalse(list(self.source.parent.glob('.aeneas-copy-*'))) + + def test_v1_protection_and_clean_regeneration_upgrade(self): + self.generate() + workspace.record_projections(self.work) + workspace.protect_projections(self.work) + self.fails_without_writes(message='version 2') + self.generate(self.source.read_bytes().decode()) + self.assertEqual(json.loads((self.work / workspace.PROJECTION_STATE).read_bytes())['version'], 2) + self.run_copy(apply=True) + + def test_real_dev_script_previews_and_applies_with_system_bash(self): + self.generate() + self.edit('Specs', 'r = x', 'r ≠ x') + project = self.root / 'verification/aeneas/lean' + shutil.copytree(self.work, project, dirs_exist_ok=True) + scripts = project.parent + for name in ('dev.sh', 'workspace.py', 'copy_back.py', 'inline.py', 'golden.py'): + shutil.copyfile(ROOT / 'verification/aeneas' / name, scripts / name) + # No toolchain or extraction driver is supplied: both routes must read + # this existing project directly, even with macOS's system Bash 3.2. + command = ['/bin/bash', str(scripts / 'dev.sh'), '--copy-back', RUST] + before = self.files() + preview = subprocess.run([*command, '--dry-run'], capture_output=True, text=True) + self.assertEqual(preview.returncode, 0, preview.stderr) + self.assertIn('+/// ensures r => r ≠ x', preview.stdout) + self.assertEqual(before, self.files()) + applied = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(applied.returncode, 0, applied.stderr) + self.assertEqual(preview.stdout, applied.stdout) + after = self.files() + self.assertEqual({name for name in before if before[name] != after[name]}, + {ENTRY['file'], 'verification/aeneas/lean/' + workspace.PROJECTION_STATE}) + self.assertIn('r ≠ x'.encode(), self.source.read_bytes()) + workspace.protect_projections(project) + + def test_cli_preview_and_incompatible_dev_flags(self): + self.generate() + self.edit('Specs', 'r = x', 'r ≠ x') + before = self.files() + result = subprocess.run([sys.executable, '-B', str(ROOT / 'verification/aeneas/workspace.py'), + 'copy-back', str(self.work), '--root', str(self.root), '--owner', RUST], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('+/// ensures r => r ≠ x', result.stdout) + self.assertEqual(before, self.files()) + for extra in ('--live', '--check'): + result = subprocess.run(['bash', str(ROOT / 'verification/aeneas/dev.sh'), '--copy-back', RUST, extra], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('cannot be combined', result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/test_golden.py b/verification/aeneas/tests/test_golden.py new file mode 100644 index 0000000000..ef79960b67 --- /dev/null +++ b/verification/aeneas/tests/test_golden.py @@ -0,0 +1,97 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Check the comparator's admission boundary independently of Lean builds. + +These fixtures distinguish permitted presentation changes from code, literal, +and module-roster changes. A passing comparator test cannot replace compilation +of the golden and live proof projects. +""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "golden", Path(__file__).parents[1] / "golden.py") +golden = importlib.util.module_from_spec(spec) +spec.loader.exec_module(golden) + + +class GoldenTests(unittest.TestCase): + def test_accepts_comments_blank_lines_and_trailing_whitespace(self): + before = 'def min := do\n if x > y\n then ok y\n else ok x\n' + after = ('-- generated source moved\n\n' + '/-- source location /- nested comment -/ -/\n' + 'def min := do \t\n if x > y -- explanation\n\n' + ' then ok y\n else ok x\n') + self.assertEqual(golden.normalize(before), golden.normalize(after)) + self.assertEqual(golden.normalize('x/- short -/y'), + golden.normalize('x/- much longer -/y')) + self.assertNotEqual(golden.normalize('x/- comment -/y'), + golden.normalize('xy')) + + def test_preserves_code_structure_and_literal_contents(self): + before = 'def min := do\n if x > y\n then ok y\n else ok x\n' + for after in [before.replace('>', '<'), before.replace('ok y', 'ok x'), + before.replace(' then', ' then'), + before.replace('if x', 'if x'), + before.replace('then ok y\n else', 'then ok y else')]: + self.assertNotEqual(golden.normalize(before), golden.normalize(after)) + for literal in ['"-- /- spaces -/"', '"a\\\"--b"', "'\"'", + '«-- /- spaces -/»', '"a\n\n \nb"']: + self.assertEqual(golden.normalize('def s := ' + literal), + ['def s := ' + part if i == 0 else part + for i, part in enumerate(literal.split('\n'))]) + self.assertNotEqual(golden.normalize('def s := "a "'), + golden.normalize('def s := "a "')) + self.assertNotEqual(golden.normalize('def s := "a\n\nb"'), + golden.normalize('def s := "a\nb"')) + + def test_rejects_malformed_or_unsupported_literals(self): + for source in ['/- unclosed', '"unclosed', '«unclosed', '"escape\\', + 'r#"raw"#', 's!"interpolated"']: + with self.assertRaises(ValueError): + golden.normalize(source) + + def test_full_file_set_and_template_signatures_are_checked(self): + with tempfile.TemporaryDirectory() as tmp: + live, snapshot = Path(tmp) / 'live', Path(tmp) / 'golden' + live.mkdir() + for name in golden.FILES: + (live / name).write_text('def x := 1\n') + for name in golden.HANDWRITTEN: + (live / name).write_text('def external := 2\n') + golden.update(live, snapshot) + self.assertEqual(golden.compare(live, snapshot), []) + self.assertTrue((snapshot / 'Types.lean').read_text().startswith('/- Copyright')) + original = {name: (snapshot / name).read_bytes() for name in golden.FILES} + (live / 'Funs.lean').write_text('def x := "unclosed\n') + with self.assertRaises(ValueError): + golden.update(live, snapshot) + self.assertEqual(original, + {name: (snapshot / name).read_bytes() for name in golden.FILES}) + (live / 'Funs.lean').write_text('def x := 1\n') + (live / 'TypesExternal_Template.lean').write_text('def x := 2\n') + self.assertTrue(golden.compare(live, snapshot)) + (live / 'Types.lean').unlink() + with self.assertRaises(ValueError): + golden.compare(live, snapshot) + (live / 'Types.lean').write_text('def x := 1\n') + (live / 'New.lean').write_text('def extra := 1\n') + with self.assertRaises(ValueError): + golden.compare(live, snapshot) + (live / 'New.lean').unlink() + (snapshot / 'Unexpected.lean').write_text('def extra := 1\n') + with self.assertRaises(ValueError): + golden.compare(live, snapshot) + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/test_import_graph.py b/verification/aeneas/tests/test_import_graph.py new file mode 100644 index 0000000000..bb5e1f5060 --- /dev/null +++ b/verification/aeneas/tests/test_import_graph.py @@ -0,0 +1,60 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Compile harmless direct and transitive imports with the installed Lean. + +Import reachability supplies dependency evidence, not semantic independence. +The arbitrary-outcome contract checks carry the latter obligation separately. +""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] +TOOLCHAIN = Path(os.environ.get('AENEAS_TOOLCHAIN_DIR', str(ROOT / 'target/aeneas/toolchain'))) +LEAN = TOOLCHAIN / 'lean/bin/lean' + + +@unittest.skipUnless(LEAN.is_file(), 'requires installed pinned Lean') +class CompiledImportGraphTests(unittest.TestCase): + def test_harmless_function_imports_are_ordinary_acyclic_dependencies(self): + # Importing a function does not establish an operation expectation. The + # arbitrary-outcome adequacy checks provide that semantic boundary. + check = (ROOT / 'verification/aeneas/lean/Check.lean').read_text() + self.assertNotIn('ModelSupport cannot depend', check) + with tempfile.TemporaryDirectory(prefix='aeneas-import-graph-') as directory: + work = Path(directory) + env = dict(os.environ, LEAN_PATH=str(work)) + + def compile_module(name, imports, body=''): + path = work / (name.replace('.', '/') + '.lean') + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('module\n' + ''.join('public import ' + imported + '\n' for imported in imports) + body) + result = subprocess.run([str(LEAN), '-o', str(path.with_suffix('.olean')), str(path)], + cwd=work, env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + compile_module('ModelShapes', []) + compile_module('Zerocopy.Funs', [], '@[expose] public def harmless (x : Nat) := x\n') + compile_module('Zerocopy.FunsExternal', [], '@[expose] public def externalData := 3\n') + for imported in ('Zerocopy.Funs', '«Zerocopy».Funs', '«Zerocopy».FunsExternal'): + for transitive in (False, True): + with self.subTest(imported=imported, transitive=transitive): + compile_module('SafeMath', [imported] if transitive else ['ModelShapes']) + compile_module('ModelSupport', ['SafeMath'] if transitive else [imported]) + compile_module('ModelSupport.Nested', [imported]) + compile_module('Consumer', ['ModelSupport', 'ModelSupport.Nested'], + 'example : harmless 2 = 2 := rfl\n' if 'FunsExternal' not in imported else + 'example : externalData = 3 := rfl\n') + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/test_inline.py b/verification/aeneas/tests/test_inline.py new file mode 100644 index 0000000000..8b201b5956 --- /dev/null +++ b/verification/aeneas/tests/test_inline.py @@ -0,0 +1,933 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import copy +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / 'verification/aeneas')) +import inline + +TOOL = Path(os.environ.get('AENEAS_INLINE_TOOL', ROOT / 'target/aeneas/annotation-tool/debug/aeneas-inline')) +BLOCK = ('/// ```aeneas\n' + '/// spec f_spec\n' + '/// ensures r => r = x\n' + '/// ```\n') +ENTRY = {'file': 'zerocopy/src/util/mod.rs', 'syntax': 'f'} +RUST = 'zerocopy::util::f' +GENERATED = ('module\nnamespace Zerocopy\n' + '/-- [zerocopy::util::f]:\n Source: location -/\n' + 'def util.f (x : Nat) :=\n x + 1\n\nend Zerocopy\n') + + +def syntax(source): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'source.rs' + path.write_bytes(source.encode()) + result = subprocess.run([str(TOOL)], input=json.dumps([str(path)]), + text=True, capture_output=True) + if result.returncode: + raise ValueError(result.stderr) + return json.loads(result.stdout)[str(path)] + + +def fixture(root, source=None): + source = BLOCK + 'fn f(x: usize) {}\n' if source is None else source + path = root / ENTRY['file'] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) + return inline.discover(root, TOOL) + + +def llbc(annotation, source): + return {'translated': { + 'files': [{'id': 1, 'name': {'Local': 'src/util/mod.rs'}, 'contents': source}], + 'fun_decls': [{'generics': {'types': [], 'const_generics': [], 'trait_clauses': []}, + 'signature': {'inputs': ['type']}, + 'body': {'Structured': {'locals': {'arg_count': 1, 'locals': [ + {'name': None}, {'name': 'x'}]}}}, + 'item_meta': { + 'name': [{'Ident': [part, 0]} for part in RUST.split('::')], + 'started_from': True, 'is_local': True, + 'span': {'Untagged': {'generated_from_span': None, 'data': { + 'file_id': 1, 'beg': {'line': 1, 'col': 0}, + 'end': {'line': annotation['end_line'], 'col': annotation['end_col']}}}}}}]}} + + +class InlineTests(unittest.TestCase): + def parse(self, source): + return inline.parse_file(source, syntax(source)) + + def test_doc_ownership_crlf_unicode_attributes_and_source_map(self): + for eol in ('\n', '\r\n'): + source = ('const 雪: u8 = 0;\n/// Normal documentation.\n' + BLOCK + + '#[cfg(any())]\n#[allow(unused)]\nfn f(x: usize) {}').replace('\n', eol) + found = self.parse(source) + self.assertEqual(len(found), 1) + self.assertEqual(found[0]['syntax'], 'f') + self.assertEqual(found[0]['kind'], 'function') + self.assertEqual(found[0]['spec_text'], 'spec f_spec\n ensures r => r = x\n') + self.assertEqual(found[0]['inputs'], ['x']) + self.assertEqual(found[0]['ghosts'], []) + self.assertEqual(found[0]['source_lines'], [4, 5]) + self.assertEqual(found[0]['newline'], eol) + + def test_source_snapshot_cannot_change_between_inspection_and_parsing(self): + original = BLOCK + 'fn f(x: usize) {}' + inspected = syntax(original) + changed = original.replace('r = x', 'r ≠ x') + with self.assertRaisesRegex(ValueError, 'Rust source changed during annotation inspection'): + inline.parse_file(changed, inspected) + del inspected['source'] + with self.assertRaisesRegex(ValueError, 'Rust source changed during annotation inspection'): + inline.parse_file(original, inspected) + + def test_rejects_body_fences_orphans_unsupported_owners_and_duplicates(self): + for source in ('fn f(x: usize) {\n' + BLOCK.replace('///', '//') + '}', + 'fn outer() { ' + BLOCK + 'fn f(x: usize) {} }', + 'impl Trait for S { ' + BLOCK + 'fn f(x: usize) {} }', + BLOCK + 'const X: usize = 1;', + BLOCK + 'type S = usize;', + BLOCK + 'mod m {}', + BLOCK + BLOCK + 'fn f(x: usize) {}'): + with self.subTest(source=source), self.assertRaises(ValueError): + self.parse(source) + + def test_direct_include_str_is_rejected_in_owner_doc_expressions(self): + for owner in ('fn f(x: usize) {}', 'struct Box(usize);', 'enum Box { Empty }'): + for attr in ('#[doc = include_str!("contract.md")]', + '#[doc = std::include_str!("contract.md")]', + '#[doc = concat!("ordinary ", include_str!("contract.md"))]', + '#[doc = condVersionLink!(std::include_str!("contract.md"))]', + '#[cfg_attr(any(), doc = include_str!("contract.md"))]', + '#[cfg_attr(any(), cfg_attr(all(), doc = wrapper!(::std::include_str!("contract.md"))))]'): + with self.subTest(owner=owner, attr=attr), self.assertRaisesRegex( + ValueError, 'Direct include_str!'): + self.parse(attr + '\n' + owner) + # Visibility and cfg documentation flags do not supply computed strings. + self.assertEqual(self.parse('#[doc(hidden)]\n#[cfg_attr(any(), doc(hidden))]\nfn f() {}'), []) + + def test_unannotated_computed_docs_are_outside_the_annotation_language(self): + for owner in ('fn f(x: usize) {}', 'struct Box(usize);', 'enum Box { Empty }'): + for attr in ('#[doc = concat!("ordinary ", "documentation")]', + '#[doc = condVersionLink!("version", "link")]', + '#[cfg_attr(any(), doc = condVersionLink!("version", "link"))]', + '#[cfg_attr(any(), cfg_attr(all(), doc = other_macro!("ordinary")))]', + '#[doc = codegen_header!("h5", "f")]', + '#[doc = codegen_section!(f, mutable = true)]', + '#[doc = concat!("include_str!(external text)")]'): + with self.subTest(owner=owner, attr=attr): + self.assertEqual(self.parse(attr + '\n' + owner), []) + + def test_computed_docs_cannot_mix_with_literal_function_or_type_annotations(self): + type_block = '/// ```aeneas\n/// model BoxValue := Fields\\ndecode self => self\n/// ```\n' + for owner, block in (('fn f(x: usize) {}', BLOCK), + ('struct Box(usize);', type_block), + ('enum Box { Empty }', type_block)): + for attr in ('#[doc = concat!("ordinary", " documentation")]', + '#[doc = condVersionLink!("version", "link")]', + '#[cfg_attr(any(), doc = condVersionLink!("version", "link"))]'): + for docs in (attr + '\n' + block, block + attr + '\n'): + with self.subTest(owner=owner, docs=docs), self.assertRaisesRegex( + ValueError, 'Computed doc strings'): + self.parse(docs + owner) + # Macro output supplies no source annotation; an empty extraction fails. + with tempfile.TemporaryDirectory() as tmp, self.assertRaisesRegex( + ValueError, 'No Aeneas specifications discovered'): + fixture(Path(tmp), '#[doc = contract_docs!()]\nfn f(x: usize) {}') + + def test_literal_docs_are_owned_and_conditional_computed_and_inner_docs_rejected(self): + source = '#[doc = "```aeneas\\nspec f_spec\\n ensures r => r = x\\n```"]\nfn f(x: usize) {}' + self.assertEqual(self.parse(source)[0]['spec_text'], 'spec f_spec\n ensures r => r = x\n') + for owner in ('struct Box(usize);', 'enum Box { Empty }'): + source = '#[doc = "```aeneas\\nmodel BoxValue := Fields\\ndecode self => self\\n```"]\n' + owner + self.assertEqual(self.parse(source)[0]['theorem'], 'BoxValue') + for attr in ('#[cfg_attr(any(), doc = "```aeneas")]', + '#[doc = concat!("```", "aeneas")]', + '#[doc = concat!("```ae", "neas")]', + '#[cfg_attr(any(), doc = concat!("```ae", "neas"))]', + '#[doc = condVersionLink!("```ae", "neas")]', + '#[cfg_attr(any(), doc = condVersionLink!("```", "ae", "neas"))]', + '#[doc = include_str!("```aeneas")]', + '#![doc = "```aeneas"]'): + for owner in ('fn f(x: usize) {}', 'struct Box(usize);', 'enum Box { Empty }'): + with self.subTest(attr=attr, owner=owner), self.assertRaises(ValueError): + self.parse(attr + '\n' + owner) + + def test_block_doc_fences_and_non_doc_literal_lookalikes(self): + for comment in ('/**\n```aeneas\nspec f_spec\n ensures r => r = x\n```\n*/', + '/**\n * ```aeneas\n * spec f_spec\n * ensures r => r = x\n * ```\n */'): + self.assertEqual(self.parse(comment + '\nfn f(x: usize) {}')[0]['inputs'], ['x']) + self.assertEqual(self.parse('#[cfg_attr(any(), arbitrary = "```aeneas")] fn f() {}'), []) + + def test_inherent_and_generic_ownership_automatically_introduces_rust_names(self): + found = self.parse('mod m { impl S { ' + BLOCK + 'fn f(&self, x: usize) {} } }') + self.assertEqual(found[0]['syntax'], 'm::S::f') + self.assertEqual(found[0]['generics'], ['E']) + for implementation in ('impl S', 'impl Trait for S', 'impl m::S'): + with self.assertRaises(ValueError): + self.parse(implementation + ' { ' + BLOCK + 'fn f(x: usize) {} }') + for signature in ('fn f((x, y): (usize, usize)) {}', 'fn f(x: &mut usize) {}', + "fn f() -> &'static usize {}", 'fn f() {}'): + with self.assertRaises(ValueError): + self.parse(BLOCK + signature) + with self.assertRaises(ValueError): + self.parse('impl S { ' + BLOCK + 'fn f(&mut self, x: usize) {} }') + + def test_ghosts_and_requirements_cannot_shadow_original_parameters(self): + for header in ('(x : Nat)', '(T : Type)', '(ghost ghost : Nat)'): + block = BLOCK.replace('spec f_spec', 'spec f_spec ' + header) + with self.subTest(header=header), self.assertRaises(ValueError): + self.parse(block + 'fn f(x: T) {}') + block = BLOCK.replace('spec f_spec', 'spec f_spec (ghost : Nat)') + found = self.parse(block + 'fn f(x: T) {}')[0] + self.assertEqual(found['generics'], ['T']) + self.assertEqual(found['ghosts'], ['ghost']) + for requirements in ('/// requires x : True\n', '/// requires T : True\n', + '/// requires h : True\n/// requires h : True\n', + '/// aeneas_spec_end\n', '/// aeneas_spec_begin\n'): + block = BLOCK.replace('/// ensures', requirements + '/// ensures') + with self.subTest(requirements=requirements), self.assertRaises(ValueError): + self.parse(block + 'fn f(x: T) {}') + + def test_nominal_type_models_and_wrong_declaration_kinds(self): + block = ('/// ```aeneas\n/// model BoxValue := Fields\n' + '/// decode self => self\n/// ```\n') + for declaration, kind in (('struct Box(T);', 'struct'), ('enum Box { Some(T), None }', 'enum')): + found = self.parse(block + declaration)[0] + self.assertEqual(found['kind'], 'type') + self.assertEqual(found['nominal_kind'], kind) + self.assertEqual(found['generics'], ['T']) + self.assertEqual(found['decoder_text'], 'self') + self.assertEqual(found['value_binder'], 'self') + for source in (BLOCK + 'struct Box(T);', block + 'fn f(x: usize) {}', + block + 'type Box = usize;', block + 'union Box { x: usize }', + block.replace('decode self', 'decode T') + 'struct Box(T);'): + with self.subTest(source=source), self.assertRaises(ValueError): + self.parse(source) + + def test_plain_raw_clauses_multiple_posts_and_dependent_ghosts(self): + text = ('spec f_spec (position : Fin self.length)\n' + ' requires valid : position.val < self.length\n' + ' requires(raw) bounded : self.val < 10\n' + ' ensures out => out = self\n' + ' ensures(raw) out => out.val = self.val\n') + self.assertEqual(inline.parse_spec(text, [], ['self']), ('f_spec', 1)) + for bad in (text.replace('requires valid', 'requires(raw) self'), + text + ' requires late : True\n', + text.replace('ensures(raw)', 'ensures(other)'), + text.replace('requires(raw)', 'requires(other)'), + text.replace(' ensures out => out = self\n', '' ).replace(' ensures(raw) out => out.val = self.val\n', ''), + text + ' refines id to 0\n'): + with self.subTest(text=bad), self.assertRaises(ValueError): + inline.parse_spec(bad, [], ['self']) + + def test_model_shapes_decoders_and_extra_command_rejection(self): + for shape in ('model BoxValue := Fields', + 'model BoxValue where\n value : TModel\n positive : True'): + for mode in ('decode', 'decode?'): + text = shape + '\n' + mode + ' self =>\n self\n' + parsed = inline.parse_model(text, ['T']) + self.assertEqual(parsed['shape_text'], shape) + self.assertEqual(parsed['decoder_mode'], mode) + self.assertEqual(parsed['decoder_text'], 'self') + for command in ('axiom extra : False', 'def extra := 1', 'end', + 'namespace Extra', 'open Classical', 'set_option maxRecDepth 0', + 'model Extra := Nat', 'decode self => self', 'decode? self => none'): + with self.subTest(command=command), self.assertRaises(ValueError): + inline.parse_model(text + ' ' + command + '\n', ['T']) + nested = inline.parse_model('model BoxValue := Nat\ndecode self =>\n let word := self._0\n let align := word + 1\n align\n', []) + self.assertEqual(nested['decoder_text'], 'let word := self._0\nlet align := word + 1\nalign') + self.assertEqual(nested['decoder_line'], 2) + for bad in ('model BoxValue := Fields\n', + 'model BoxValue := Fields\ndecode self =>\n', + 'model BoxValue := Fields\ndecode T => T\n', + 'model T := Fields\ndecode self => self\n', + 'model Fields := Fields\ndecode self => self\n', + 'invariant old for self => True\n', + 'model BoxValue := Fields\ndecode self => self\nextra command\n'): + with self.subTest(text=bad), self.assertRaises(ValueError): + inline.parse_model(bad, ['T']) + + def test_unannotated_functions_impls_and_macros_are_permitted(self): + extras = ('fn missing() {}', '#[cfg(any())] fn inactive() {}', + 'impl S { fn missing() {} methods!(); }', + '#[cfg(any())] impl S { #[cfg(any())] methods!(); }', + 'impl S { fn missing() {} }', 'impl crate::S {}', + 'impl Trait for S { methods!(); }', + '#[cfg(any())] mod m { impl S {} }', 'global_methods!();') + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for extra in extras: + with self.subTest(extra=extra): + annotations = fixture(root, BLOCK + 'fn f(x: usize) {}\n' + extra) + self.assertEqual(set(annotations), {RUST}) + + def test_discovery_snapshots_unannotated_sources_and_roots_only_annotations(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root, 'struct S; impl S { ' + BLOCK + 'fn f(self) {} }') + other = root / 'zerocopy/src/other.rs' + other.write_text('#[cfg(any())] impl S { fn missed() {} }\nimpl Other { methods!(); }') + ordinary = root / 'ordinary.rs' + ordinary.write_text('fn unannotated() {}') + annotations = inline.discover(root, TOOL) + self.assertEqual(set(annotations), {'zerocopy::util::S::f'}) + self.assertEqual(set(annotations.source_snapshots), {ENTRY['file'], 'zerocopy/src/other.rs'}) + self.assertEqual([a['rust'] for a in annotations.types], ['zerocopy::util::S']) + work = root / 'work' + work.mkdir() + inline.write_scan(annotations, work) + self.assertEqual((work / 'roots.txt').read_text(), 'zerocopy::util::S::f\n') + self.assertEqual(json.loads((work / 'source-types.json').read_text()), annotations.types) + # Literal lookalikes outside the crate are inspected without becoming + # roots; a real reserved fence there has no supported source owner. + ordinary.write_text('const TEXT: &str = "```aeneas";') + annotations = inline.discover(root, TOOL) + self.assertIn('ordinary.rs', annotations.source_snapshots) + ordinary.write_text(BLOCK + 'fn outside(x: usize) {}') + with self.assertRaisesRegex(ValueError, 'Unsupported annotated source location'): + inline.discover(root, TOOL) + + def test_present_nominal_model_adds_only_its_exact_type_root(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + model = '/// ```aeneas\n/// model SValue := Fields\n/// decode self => self\n/// ```\n' + source = model + 'struct S;\n' + BLOCK + 'fn f(x: usize) {}' + annotations = fixture(root, source) + self.assertEqual(set(annotations), {'zerocopy::util::S', RUST}) + work = root / 'work' + work.mkdir() + inline.write_scan(annotations, work) + self.assertEqual((work / 'roots.txt').read_text(), RUST + ',zerocopy::util::S\n') + # The nominal owner remains available to structural model binding + # after its authored model is removed, but is no longer a root. + annotations = fixture(root, source.removeprefix(model)) + self.assertEqual(set(annotations), {RUST}) + self.assertEqual([a['rust'] for a in annotations.types], ['zerocopy::util::S']) + + def test_nested_annotations_select_exact_owner_without_type_wildcards(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = 'mod m { struct S; impl S { ' + BLOCK + 'fn f(self) {} } }' + annotations = fixture(root, source) + self.assertEqual(set(annotations), {'zerocopy::util::m::S::f'}) + work = root / 'work' + work.mkdir() + inline.write_scan(annotations, work) + self.assertEqual((work / 'roots.txt').read_text(), 'zerocopy::util::m::S::f\n') + + def test_removing_an_annotation_visibly_removes_its_root(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + extra = BLOCK.replace('f_spec', 'g_spec') + 'fn g(x: usize) {}\n' + annotations = fixture(root, BLOCK + 'fn f(x: usize) {}\n' + extra) + self.assertEqual(set(annotations), {RUST, 'zerocopy::util::g'}) + annotations = fixture(root, 'fn f(x: usize) {}\n' + extra) + self.assertEqual(set(annotations), {'zerocopy::util::g'}) + work = root / 'work' + work.mkdir() + inline.write_scan(annotations, work) + self.assertEqual((work / 'roots.txt').read_text(), 'zerocopy::util::g\n') + with self.assertRaisesRegex(ValueError, 'No Aeneas specifications discovered'): + fixture(root, 'fn f(x: usize) {}\nfn g(x: usize) {}') + + def test_rejects_near_miss_guards_legacy_sections_and_extra_commands(self): + variants = [BLOCK.replace('```aeneas', guard) for guard in + ('```Aeneas', '```aeneos', '``aeneas', '```aeneas v2')] + variants += [BLOCK.replace('/// ```\n', ''), + BLOCK.replace('/// ```\n', '/// ``` trailing\n'), + BLOCK.replace('/// ensures', '/// ensures'), + BLOCK.replace('/// ensures', '/// for util.other x\n/// ensures'), + BLOCK.replace('/// ensures', '/// proof:\n/// ensures'), + BLOCK.replace('/// spec', '/// model:\n/// spec'), + BLOCK.replace('/// ```\n', '/// def extra := 1\n/// ```\n')] + for block in variants: + with self.subTest(block=block), self.assertRaises(ValueError): + self.parse(block + 'fn f(x: usize) {}') + + def test_fake_fences_in_literals_are_not_annotations(self): + self.assertEqual(self.parse('fn f() { let x = r###"\n' + BLOCK + '"###; }'), []) + with self.assertRaises(ValueError): + self.parse('fn f() { /* ```aeneas\nspec f_spec\n``` */ }') + + def test_discovery_derives_ids_and_rejects_duplicates_or_unsupported_files(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root) + self.assertEqual(set(annotations), {RUST}) + self.assertNotIn('golden', annotations[RUST]) + self.assertNotIn('depends_on', annotations[RUST]) + path = root / ENTRY['file'] + path.write_text('fn f(x: usize) {}') + with self.assertRaisesRegex(ValueError, 'No Aeneas specifications discovered'): + inline.discover(root, TOOL) + fixture(root) + path.write_text(path.read_text() * 2) + with self.assertRaisesRegex(ValueError, 'Duplicate annotation'): + inline.discover(root, TOOL) + fixture(root) + (root / 'unsupported.rs').write_text(BLOCK + 'fn f(x: usize) {}') + with self.assertRaisesRegex(ValueError, 'Unsupported annotated'): + inline.discover(root, TOOL) + + def test_models_derive_root_names_and_verify_parameter_order(self): + annotations = {RUST: {'generics': [], 'inputs': ['x']}} + self.assertEqual(inline.generated_bindings(GENERATED, annotations), {RUST: 'util.f'}) + for bad in (GENERATED.replace('(x : Nat)', '(other : Nat)'), + GENERATED.replace('[zerocopy::util::f]', '[zerocopy::util::other]'), + GENERATED.replace('\nend Zerocopy', GENERATED + '\nend Zerocopy')): + with self.assertRaises(ValueError): + inline.generated_bindings(bad, annotations) + two = GENERATED.replace('(x : Nat)', '(a b : Nat)').replace('x + 1', 'a + b') + original = {RUST: {'generics': [], 'inputs': ['a', 'b']}} + self.assertEqual(inline.generated_bindings(two, original), {RUST: 'util.f'}) + with self.assertRaisesRegex(ValueError, 'parameters disagree'): + inline.generated_bindings(two.replace('(a b : Nat)', '(b a : Nat)'), original) + generic = GENERATED.replace('[zerocopy::util::f]', '[zerocopy::util::{zerocopy::util::S}::f]') + generic = generic.replace('def util.f (x : Nat)', 'def util.S.f {E : Type} (self : S E)') + name = 'zerocopy::util::S::f' + self.assertEqual(inline.generated_bindings(generic, {name: {'generics': ['E'], 'inputs': ['self']}}), {name: 'util.S.f'}) + with self.assertRaises(ValueError): + inline.generated_bindings(generic.replace('{zerocopy::util::S}', '{zerocopy::other::S}'), {name: {'generics': ['E'], 'inputs': ['self']}}) + + def test_loop_helpers_dependencies_and_constants_do_not_replace_roots(self): + helpers = ('/-- [zerocopy::util::f]: loop body 0:\n Source: x -/\n' + '@[rust_loop_body]\ndef util.f_loop.body := 1\n\n' + '/-- [zerocopy::util::dependency]:\n Source: x -/\n' + 'def util.dependency := 4\n\n') + self.assertEqual(inline.generated_bindings(GENERATED.replace('/-- ', helpers + '/-- ', 1), + {RUST: {'generics': [], 'inputs': ['x']}}), {RUST: 'util.f'}) + + def test_trait_implementation_dependencies_do_not_replace_roots(self): + annotations = {RUST: {'generics': [], 'inputs': ['x']}} + trait = 'zerocopy::{impl zerocopy::PointerMetadata for ()}::from_elem_count' + dependency = (f'/-- [{trait}]:\n Source: location -/\n' + 'def Tuple.Insts.ZerocopyPointerMetadata.from_elem_count ' + '(elems : Nat) := ()\n\n') + source = GENERATED.replace('/-- ', dependency + '/-- ', 1) + self.assertEqual(inline.generated_bindings(source, annotations), {RUST: 'util.f'}) + # Neither a dependency alone nor an unsupported trait owner can stand + # in for the mandatory source-to-model binding of an annotated item. + missing_root = 'module\nnamespace Zerocopy\n' + dependency + 'end Zerocopy\n' + with self.assertRaisesRegex(ValueError, 'do not match discovered specifications'): + inline.generated_bindings(missing_root, annotations) + with self.assertRaisesRegex(ValueError, 'do not match discovered specifications'): + inline.generated_bindings(source, {trait: {'generics': [], 'inputs': ['elems']}}) + + def test_assembly_requires_independent_checker_before_generation(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + work = root / 'work' + work.mkdir() + with self.assertRaisesRegex(ValueError, 'RequiredContracts.lean is required'): + inline.assemble(root, {}, work) + self.assertFalse((work / 'Specs.lean').exists()) + self.assertFalse((work / 'Required.lean').exists()) + + def test_development_assembly_preserves_edited_projection_and_all_outputs(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root) + work = root / 'work' + (work / 'Zerocopy').mkdir(parents=True) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED) + lean = root / 'verification/aeneas/lean' + lean.mkdir(parents=True) + (lean / 'RequiredContracts.lean').write_text('') + inline.save_bindings(root, annotations, work, development=True) + inline.assemble(root, annotations, work, development=True) + self.assertTrue((work / inline.workspace.PROJECTION_STATE).exists()) + inline.assemble(root, annotations, work, development=True) + for module in inline.workspace.PROJECTIONS: + path = work / f'{module}.lean' + original = path.read_bytes() + path.write_bytes(original + b'\n-- local scratch edit\n') + before = {str(p.relative_to(work)): p.read_bytes() for p in work.rglob('*') if p.is_file()} + with self.subTest(module=module), self.assertRaisesRegex(ValueError, 'Refusing to overwrite'): + inline.assemble(root, annotations, work, development=True) + self.assertEqual(before, {str(p.relative_to(work)): p.read_bytes() for p in work.rglob('*') if p.is_file()}) + path.write_bytes(original) + + def test_assembly_injects_verified_application_and_maps_lines_and_modules(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root) + work = root / 'work' + (work / 'Zerocopy').mkdir(parents=True) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED) + proofs = root / 'verification/aeneas/lean/Proofs' + proofs.mkdir(parents=True) + (proofs / 'Util.lean').write_text('import Specs\n') + (root / 'verification/aeneas/lean/RequiredContracts.lean').write_text('') + (root / 'verification/aeneas/lean/ExtraFacts.lean').write_text( + 'namespace Other\naxiom unused : True\nend Other\n') + with self.assertRaisesRegex(ValueError, 'Verified live'): + inline.assemble(root, annotations, work) + inline.save_bindings(root, annotations, work) + inline.assemble(root, annotations, work) + specs = (work / 'Specs.lean').read_text() + self.assertIn('aeneas_spec_begin\nspec f_spec', specs) + self.assertIn('ensures r => r = x\naeneas_spec_end', specs) + self.assertIn('check_spec_binding Zerocopy.Specs.f_spec for Zerocopy.util.f with 1', specs) + self.assertIn('spec f_spec\n for @Zerocopy.util.f with 0 type parameters\n ensures', specs) + required = (work / 'Required.lean').read_text() + self.assertIn('example : Zerocopy.Specs.f_spec := @Zerocopy.Proofs.f_spec', required) + self.assertIn('check_contract Zerocopy.Obligations.f_spec using @Zerocopy.Proofs.f_spec', required) + self.assertNotIn('def requiredTheorems ', required) + self.assertLess(required.index('example : Zerocopy.Specs.f_spec'), + required.index('check_contract Zerocopy.Obligations.f_spec')) + self.assertIn('proofModuleNames : Array Name := #[`Proofs, `Proofs.Util]', required) + self.assertIn('import ExtraFacts', required) + self.assertIn('auditModuleNames : Array Name := #[`ExtraFacts', required) + mapping = json.loads((work / 'Specs.source-map.json').read_text()) + for index, line in enumerate(specs.splitlines(), 1): + if line.startswith('spec '): + self.assertEqual(mapping[str(index)], {'file': ENTRY['file'], 'line': 2}) + if line.startswith(' for '): + self.assertEqual(mapping[str(index)], {'file': ENTRY['file'], 'line': 2}) + if line.startswith(' ensures '): + self.assertEqual(mapping[str(index)], {'file': ENTRY['file'], 'line': 3}) + # Comment-only golden variation is allowed, but code and source + # changes invalidate the mapping before any proof is compiled. + (work / 'Zerocopy/Funs.lean').write_text('-- moved\n' + GENERATED) + inline.assemble(root, annotations, work) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED.replace('x + 1', 'x + 2')) + with self.assertRaisesRegex(ValueError, 'stale'): + inline.assemble(root, annotations, work) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED) + (root / ENTRY['file']).write_text((root / ENTRY['file']).read_text() + '// changed\n') + with self.assertRaisesRegex(ValueError, 'changed after annotation discovery'): + inline.assemble(root, annotations, work) + + def test_fixed_source_snapshot_rejects_discovery_digest_binding_and_assembly_edits(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root) + original = (root / ENTRY['file']).read_bytes() + digest = inline.source_digest(root, annotations) + self.assertEqual(annotations.source_snapshots[ENTRY['file']], original) + work = root / 'work' + (work / 'Zerocopy').mkdir(parents=True) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED) + (root / 'verification/aeneas/lean').mkdir(parents=True) + (root / 'verification/aeneas/lean/RequiredContracts.lean').write_text('') + inline.save_bindings(root, annotations, work) + extracted = root / 'crate.llbc' + extracted.write_text(json.dumps(llbc(annotations[RUST], original.decode()))) + changed = original.replace(b'r = x', b'r != x') + (root / ENTRY['file']).write_bytes(changed) + for operation in (lambda: inline.source_digest(root, annotations), + lambda: inline.save_bindings(root, annotations, work), + lambda: inline.assemble(root, annotations, work), + lambda: inline.check_bindings(root, annotations, extracted)): + with self.assertRaisesRegex(ValueError, 'changed after annotation discovery'): + operation() + self.assertEqual(annotations[RUST]['spec_text'], 'spec f_spec\n ensures r => r = x\n') + self.assertEqual(json.loads((work / 'bindings.json').read_text())['sources'], digest) + (root / ENTRY['file']).write_bytes(original) + self.assertEqual(inline.source_digest(root, annotations), digest) + + def test_unannotated_dependency_files_are_frozen_and_checked_against_charon(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + fixture(root) + dependency = root / 'zerocopy/src/util/macros.rs' + dependency.write_text('fn dependency() -> usize { 1 }\n') + annotations = inline.discover(root, TOOL) + digest = inline.source_digest(root, annotations) + source = (root / ENTRY['file']).read_text() + data = llbc(annotations[RUST], source) + data['translated']['files'].extend([ + {'id': 2, 'name': {'Local': 'src/util/macros.rs'}, 'contents': dependency.read_text()}, + {'id': 3, 'name': {'Local': '/rustc/library/core/src/lib.rs'}, 'contents': None}]) + path = root / 'crate.llbc' + path.write_text(json.dumps(data)) + inline.check_bindings(root, annotations, path) + dependency.write_text('fn dependency() -> usize { 2 }\n') + with self.assertRaisesRegex(ValueError, 'changed after annotation discovery'): + inline.source_digest(root, annotations) + with self.assertRaisesRegex(ValueError, 'changed after annotation discovery'): + inline.check_bindings(root, annotations, path) + dependency.write_bytes(annotations.source_snapshots['zerocopy/src/util/macros.rs']) + self.assertEqual(inline.source_digest(root, annotations), digest) + for change, expected in [('stale', 'different source body for local dependency'), + ('missing', 'missing source contents'), + ('unscanned', 'lacks an inspected source snapshot')]: + bad = copy.deepcopy(data) + file = bad['translated']['files'][1] + if change == 'stale': + file['contents'] = file['contents'].replace('{ 1 }', '{ 2 }') + elif change == 'missing': + file['contents'] = None + else: + file['name'] = {'Local': 'src/unscanned.rs'} + path.write_text(json.dumps(bad)) + with self.subTest(change=change), self.assertRaisesRegex(ValueError, expected): + inline.check_bindings(root, annotations, path) + + def test_type_binding_order_structural_coverage_and_model_assembly(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + block = '/// ```aeneas\n/// model BoxValue := Fields\n/// decode self => self\n/// ```\n' + source = block + 'struct Box(T);\nenum E { A }\n' + BLOCK + 'fn f(x: usize) {}\n' + annotations = fixture(root, source) + self.assertEqual(len(annotations.types), 2) + data = llbc(annotations[RUST], source) + for index, owner in enumerate(annotations.types): + data['translated'].setdefault('type_decls', []).append({ + 'def_id': index, 'generics': {'types': [{'name': name} for name in owner['generics']], + 'const_generics': [], 'trait_clauses': []}, + 'kind': {'Struct' if owner['nominal_kind'] == 'struct' else 'Enum': []}, + 'item_meta': {'name': [{'Ident': [part, 0]} for part in owner['rust'].split('::')], + 'is_local': True, 'started_from': owner['rust'] in annotations, + 'span': {'Untagged': {'generated_from_span': None, 'data': { + 'file_id': 1, 'beg': {'line': owner['open_line'], 'col': 0}, + 'end': {'line': owner['end_line'], 'col': owner['end_col']}}}}}}) + path = root / 'crate.llbc' + path.write_text(json.dumps(data)) + inline.check_bindings(root, annotations, path) + work = root / 'work' + (work / 'Zerocopy').mkdir(parents=True) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED) + types = ('module\nnamespace Zerocopy\n' + '/-- [zerocopy::util::E]\n Source: x -/\n@[discriminant isize]\n' + 'inductive util.E where\n| A : util.E\n' + '/-- [zerocopy::util::Box]\n Source: x -/\n' + 'structure util.Box (T : Type) where\n val : T\n\nend Zerocopy\n') + (work / 'Zerocopy/Types.lean').write_text(types) + inline.save_bindings(root, annotations, work) + manifest = json.loads((work / 'bindings.json').read_text()) + self.assertEqual(manifest['version'], 3) + self.assertEqual([row['category'] for row in manifest['type_image']], + ['local-nominal', 'local-nominal']) + self.assertEqual([rust for rust, entry in manifest['bindings'].items() if entry['kind'] == 'type'], + ['zerocopy::util::E', 'zerocopy::util::Box']) + self.assertEqual(manifest['bindings']['zerocopy::util::Box']['model'], 'Zerocopy.util.Box.BoxValue') + (root / 'verification/aeneas/lean').mkdir(parents=True) + (root / 'verification/aeneas/lean/RequiredContracts.lean').write_text('') + inline.assemble(root, annotations, work) + shapes = (work / 'ModelShapes.lean').read_text() + models = (work / 'Models.lean').read_text() + self.assertIn('aeneas_model_shape Zerocopy.util.Box with 1 type parameters begin\nmodel BoxValue := Fields\nend', shapes) + self.assertIn('derive_rust_model Zerocopy.util.Box with 1 type parameters decode self =>\n self', models) + default = manifest['bindings']['zerocopy::util::E'] + self.assertEqual(default['model'], default['fields']) + self.assertEqual(default['provider'], 'Zerocopy.util.E.aeneasModel') + for module, generated in [('ModelShapes', shapes), ('Models', models)]: + mapping = json.loads((work / f'{module}.source-map.json').read_text()) + for line, location in mapping.items(): + self.assertLessEqual(int(line), len(generated.splitlines())) + self.assertEqual(location['file'], ENTRY['file']) + required = (work / 'Required.lean').read_text() + self.assertNotIn('Zerocopy.Proofs.BoxValue', required) + self.assertIn('import Models', required) + (work / 'Zerocopy/Types.lean').write_text(types.replace('val : T', 'val : Option T')) + with self.assertRaisesRegex(ValueError, 'stale'): + inline.assemble(root, annotations, work) + # An unannotated nominal carrier cannot vanish into an alias. + (work / 'Zerocopy/Types.lean').write_text(types.replace('inductive util.E where\n| A : util.E', 'def util.E := Unit')) + with self.assertRaisesRegex(ValueError, 'nominal type declarations'): + inline.save_bindings(root, annotations, work) + (work / 'Zerocopy/Types.lean').write_text(types) + for change in ('missing', 'kind', 'span', 'generic'): + bad = copy.deepcopy(data) + if change == 'missing': + bad['translated']['type_decls'].pop(0) + elif change == 'kind': + bad['translated']['type_decls'][1]['kind'] = {'Struct': []} + elif change == 'span': + bad['translated']['type_decls'][1]['item_meta']['span']['Untagged']['data']['end']['line'] += 1 + else: + bad['translated']['type_decls'][0]['generics']['types'] = [] + path.write_text(json.dumps(bad)) + with self.subTest(change=change), self.assertRaises(ValueError): + inline.check_bindings(root, annotations, path) + + def test_macro_expanded_nominal_dependencies_require_exact_safe_source(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = ('macro_rules! make { ($name:ident) => { struct $name(O); }; }\n' + + BLOCK + 'fn f(x: usize) {}\n') + annotations = fixture(root, source) + self.assertEqual(len(annotations.macros), 1) + self.assertFalse(any(t['rust'] == 'zerocopy::util::U16' for t in annotations.types)) + data = llbc(annotations[RUST], source) + line = source.splitlines()[0] + start, end = line.index('struct $name'), line.index('struct $name') + len('struct $name(O);') + declaration = { + 'def_id': 10, 'src': 'Normal', + 'generics': {'regions': [], 'types': [{'name': 'O'}], 'const_generics': [], + 'trait_clauses': []}, + 'kind': {'Struct': [{'ty': {'Value': [17, {'TypeVar': {'Free': 0}}]}}]}, + 'item_meta': { + 'name': [{'Ident': [part, 0]} for part in 'zerocopy::util::U16'.split('::')], + 'is_local': True, 'started_from': False, 'source_text': None, + 'span': {'Untagged': {'generated_from_span': None, 'data': { + 'file_id': 1, 'beg': {'line': 1, 'col': start}, + 'end': {'line': 1, 'col': end}}}}} + } + data['translated']['type_decls'] = [declaration] + path = root / 'crate.llbc' + path.write_text(json.dumps(data)) + inline.check_bindings(root, annotations, path) + candidate = annotations.extracted_types['zerocopy::util::U16'] + self.assertTrue(candidate['macro_expanded']) + self.assertEqual(candidate['syntax'], 'U16') + + work = root / 'work' + (work / 'Zerocopy').mkdir(parents=True) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED) + (work / 'Zerocopy/Types.lean').write_text( + 'module\nnamespace Zerocopy\n' + '/-- [zerocopy::util::U16]\n Source: x -/\n' + 'structure util.U16 (O : Type) where\n val : O\n\nend Zerocopy\n') + inline.save_bindings(root, annotations, work) + binding = json.loads((work / 'bindings.json').read_text())['bindings']['zerocopy::util::U16'] + self.assertTrue(binding['macro_expanded']) + self.assertEqual(binding['file'], ENTRY['file']) + + for change in ('span', 'source', 'generated', 'text', 'const', 'trait', + 'reference', 'function_pointer', 'deduplicated_reference', 'ordinary_owner'): + bad = copy.deepcopy(data) + item = bad['translated']['type_decls'][0] + if change == 'span': + item['item_meta']['span']['Untagged']['data']['end']['col'] = len(line) + elif change == 'source': + bad['translated']['files'][0]['contents'] += '// stale\n' + elif change == 'generated': + item['item_meta']['span']['Untagged']['generated_from_span'] = {} + elif change == 'text': + item['item_meta']['source_text'] = 'struct U16;' + elif change == 'const': + item['generics']['const_generics'] = [{'name': 'N'}] + elif change == 'trait': + item['generics']['trait_clauses'] = [{}] + elif change == 'reference': + item['kind']['Struct'][0]['ty'] = {'Ref': []} + elif change == 'function_pointer': + item['kind']['Struct'][0]['ty'] = {'FnPtr': []} + elif change == 'deduplicated_reference': + item['kind']['Struct'][0]['ty'] = {'Deduplicated': 17} + bad['translated']['type_decls'].append({ + 'def_id': 11, 'item_meta': {'is_local': False, 'name': []}, + 'kind': {'Struct': [{'ty': {'Value': [17, {'Ref': []}]}}]}, + }) + else: + ordinary = 'struct U16;\n' + source + (root / ENTRY['file']).write_text(ordinary) + other_annotations = fixture(root, ordinary) + bad['translated']['files'][0]['contents'] = ordinary + bad['translated']['fun_decls'][0]['item_meta']['span']['Untagged']['data']['end']['line'] += 1 + item['item_meta']['span']['Untagged']['data']['beg']['line'] += 1 + item['item_meta']['span']['Untagged']['data']['end']['line'] += 1 + path.write_text(json.dumps(bad)) + with self.subTest(change=change), self.assertRaisesRegex(ValueError, 'exact supported Rust source ownership'): + inline.check_bindings(root, other_annotations, path) + (root / ENTRY['file']).write_text(source) + continue + path.write_text(json.dumps(bad)) + with self.subTest(change=change), self.assertRaises(ValueError): + inline.check_bindings(root, annotations, path) + + decorated = source.replace('struct $name(O);', '#[doc = "```aeneas"] struct $name(O);') + decorated_annotations = fixture(root, decorated) + decorated_data = llbc(decorated_annotations[RUST], decorated) + item = copy.deepcopy(declaration) + decorated_line = decorated.splitlines()[0] + item['item_meta']['span']['Untagged']['data']['beg']['col'] = decorated_line.index('struct $name') + item['item_meta']['span']['Untagged']['data']['end']['col'] = ( + decorated_line.index('struct $name') + len('struct $name(O);')) + decorated_data['translated']['type_decls'] = [item] + path.write_text(json.dumps(decorated_data)) + with self.assertRaisesRegex(ValueError, 'exact supported Rust source ownership'): + inline.check_bindings(root, decorated_annotations, path) + + def test_raw_type_image_classifies_traits_and_external_nominals(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root) + source = (root / ENTRY['file']).read_text() + data = llbc(annotations[RUST], source) + def meta(rust, local): + return {'name': [{'Ident': [part, 0]} for part in rust.split('::')], + 'is_local': local} + data['translated']['trait_decls'] = [ + {'def_id': 10, 'item_meta': meta('zerocopy::util::Dictionary', True)}] + data['translated']['type_decls'] = [ + {'def_id': 11, 'item_meta': meta('core::marker::PhantomData', False), + 'kind': {'Struct': []}, 'generics': {'types': [{'name': 'T'}]}}] + llbc_path = root / 'crate.llbc' + llbc_path.write_text(json.dumps(data)) + inline.check_bindings(root, annotations, llbc_path) + work = root / 'work' + (work / 'Zerocopy').mkdir(parents=True) + (work / 'Zerocopy/Funs.lean').write_text(GENERATED) + types = ('module\nnamespace Zerocopy\n' + '/-- [core::marker::PhantomData]\n Source: x -/\n' + '@[rust_type "core::marker::PhantomData"]\n' + 'structure core.marker.PhantomData (T : Type) where\n\n' + '/-- Trait declaration: [zerocopy::util::Dictionary]\n Source: x -/\n' + 'structure util.Dictionary (Self : Type) (Assoc : Type) where\n\n' + 'end Zerocopy\n') + path = work / 'Zerocopy/Types.lean' + path.write_text(types) + inline.save_bindings(root, annotations, work) + rows = json.loads((work / 'bindings.json').read_text())['type_image'] + self.assertEqual([(row['category'], row['parameters']) for row in rows], + [('external-nominal', 1), ('trait-dictionary', 2)]) + classes = annotations.type_classes + annotations.type_classes = None + with self.assertRaisesRegex(ValueError, 'checked LLBC classification'): + inline.save_bindings(root, annotations, work) + annotations.type_classes = classes + for changed in (types.replace('Trait declaration: ', ''), + types.replace('@[rust_type "core::marker::PhantomData"]\n', ''), + types.replace('PhantomData (T : Type)', 'PhantomData (T : Type) (U : Type)'), + types.replace('core.marker.PhantomData', 'core.marker.Other', 1)): + path.write_text(changed) + with self.assertRaises(ValueError): + inline.save_bindings(root, annotations, work) + path.write_text(types) + annotations.type_classes.pop('zerocopy::util::Dictionary') + with self.assertRaisesRegex(ValueError, 'classified extraction ownership'): + inline.save_bindings(root, annotations, work) + + def test_two_type_owners_can_choose_the_same_short_model_name(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + model = '/// ```aeneas\n/// model SharedName := Fields\n/// decode self => self\n/// ```\n' + source = model + 'struct Left(usize);\n' + model + 'struct Right(usize);\n' + BLOCK + 'fn f(x: usize) {}\n' + annotations = fixture(root, source) + self.assertEqual(annotations['zerocopy::util::Left']['model_name'], 'SharedName') + self.assertEqual(annotations['zerocopy::util::Right']['model_name'], 'SharedName') + self.assertEqual(len(annotations), 3) + + def test_whole_module_goldens_update_without_rewriting_rust(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + fixture(root) + original = (root / ENTRY['file']).read_bytes() + live = root / 'live' + live.mkdir() + for name in inline.golden.FILES: + (live / name).write_text(GENERATED if name == 'Funs.lean' else 'module\n') + inline.update(root, live) + self.assertEqual((root / ENTRY['file']).read_bytes(), original) + inline.check_goldens(root) + rendered = root / 'rendered' + inline.render(root, rendered) + self.assertEqual(inline.golden.compare(live, rendered), []) + (root / 'verification/aeneas/golden/obsolete.lean.in').write_text('placeholder') + with self.assertRaises(ValueError): + inline.check_goldens(root) + + def test_llbc_binding_rejects_cfg_alternatives_stale_sources_and_parameter_changes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root) + source = (root / ENTRY['file']).read_text() + data = llbc(annotations[RUST], source) + path = root / 'crate.llbc' + path.write_text(json.dumps(data)) + inline.check_bindings(root, annotations, path) + for kind in ('alternative', 'stale', 'missing', 'duplicate', 'wrong_file', 'generic', 'arg_name', 'arg_count'): + bad = copy.deepcopy(data) + translated = bad['translated'] + function = translated['fun_decls'][0] + if kind == 'alternative': + function['item_meta']['span']['Untagged']['data']['end']['line'] += 1 + elif kind == 'stale': + translated['files'][0]['contents'] += '// stale\n' + elif kind == 'missing': + translated['fun_decls'] = [] + elif kind == 'duplicate': + translated['fun_decls'] *= 2 + elif kind == 'wrong_file': + translated['files'][0]['name']['Local'] = 'src/other.rs' + elif kind == 'generic': + function['generics']['types'] = [{'name': 'T'}] + elif kind == 'arg_name': + function['body']['Structured']['locals']['locals'][1]['name'] = 'other' + else: + function['body']['Structured']['locals']['arg_count'] = 2 + path.write_text(json.dumps(bad)) + with self.subTest(kind=kind), self.assertRaises(ValueError): + inline.check_bindings(root, annotations, path) + + def test_extraction_rejects_extra_roots_and_permits_unannotated_dependencies(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + annotations = fixture(root) + source = (root / ENTRY['file']).read_text() + data = llbc(annotations[RUST], source) + initializer = copy.deepcopy(data['translated']['fun_decls'][0]) + initializer['src'] = {'GlobalInitializer': {'id': 0, 'generics': {}}} + initializer['item_meta']['name'][-1]['Ident'][0] = 'CONSTANT' + data['translated']['fun_decls'].append(initializer) + path = root / 'crate.llbc' + path.write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, 'Extracted roots do not match'): + inline.check_bindings(root, annotations, path) + initializer['item_meta']['started_from'] = False + path.write_text(json.dumps(data)) + inline.check_bindings(root, annotations, path) + initializer['item_meta']['started_from'] = True + initializer['src'] = 'Normal' + initializer['item_meta']['name'][-1]['Ident'][0] = 'generated_method' + path.write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, 'Extracted roots do not match'): + inline.check_bindings(root, annotations, path) + + def test_inherent_method_llbc_identity_resolves_named_self(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + block = BLOCK.replace('(x : Nat)', '(self : S)') + source = 'struct S; impl S { ' + block + 'fn f(self) {} }\n' + annotations = fixture(root, source) + annotation = annotations['zerocopy::util::S::f'] + data = llbc(annotation, source) + translated = data['translated'] + type_name = [{'Ident': [part, 0]} for part in ['zerocopy', 'util', 'S']] + body = {'Adt': {'id': 1, 'builtin': None, + 'generics': {'regions': [], 'types': [], 'const_generics': [], 'trait_refs': []}}} + implementation = {'Ty': {'kind': 'InherentImplBlock', 'params': {'types': [], 'regions': []}, + 'skip_binder': {'Deduplicated': 3}}} + translated['item_names'] = [{'value': {'Value': [3, body]}}] + translated['type_decls'] = [{'def_id': 1, 'item_meta': {'name': type_name}}] + translated['fun_decls'][0]['item_meta']['name'] = [*type_name[:2], {'Impl': implementation}, {'Ident': ['f', 0]}] + translated['fun_decls'][0]['body']['Structured']['locals']['locals'][1]['name'] = 'self' + path = root / 'crate.llbc' + path.write_text(json.dumps(data)) + inline.check_bindings(root, annotations, path) + for kind in ('wrong_self', 'missing_self', 'trait', 'cross_module'): + bad = copy.deepcopy(data) + translated = bad['translated'] + impl = translated['fun_decls'][0]['item_meta']['name'][2]['Impl'] + if kind == 'wrong_self': + translated['type_decls'][0]['item_meta']['name'][-1]['Ident'][0] = 'T' + elif kind == 'missing_self': + impl['Ty']['skip_binder']['Deduplicated'] = 99 + elif kind == 'trait': + impl.clear() + impl['Trait'] = 0 + else: + translated['type_decls'][0]['item_meta']['name'][1]['Ident'][0] = 'other' + path.write_text(json.dumps(bad)) + with self.subTest(kind=kind), self.assertRaises(ValueError): + inline.check_bindings(root, annotations, path) + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/test_inspect.py b/verification/aeneas/tests/test_inspect.py new file mode 100644 index 0000000000..c515447a79 --- /dev/null +++ b/verification/aeneas/tests/test_inspect.py @@ -0,0 +1,79 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Read-only inspection regression against an explicitly selected compiled project. + +AENEAS_INSPECTION_PROJECT=/path/to/compiled/project python3 -B -m unittest + discover -s verification/aeneas/tests -p test_inspect.py +""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] +PROJECT = os.environ.get('AENEAS_INSPECTION_PROJECT') + + +@unittest.skipUnless(PROJECT, 'Set AENEAS_INSPECTION_PROJECT to reuse compiled prerequisites') +class InspectTests(unittest.TestCase): + def test_compiled_provider_mode_ghosts_and_rejection(self): + source = (ROOT / 'verification/aeneas/lean/InspectSpec.lean').read_text() + source = source.replace('import Specs\n', 'import Specs\nimport SpecsSyntaxTests\n') + # Positive prerequisites are imported compiled modules. Guarded failures + # must identify the intended call/decoding audit, rather than imports. + source += ''' +inspect_spec SpecsSyntaxTests.word_partial +/-- error: Specification SpecsSyntaxTests.word_partial does not call the expected model SpecsSyntaxTests.nativeIdentity -/ +#guard_msgs (error, drop info) in +check_spec_binding SpecsSyntaxTests.word_partial for SpecsSyntaxTests.nativeIdentity with 1 +inspect_spec SpecsSyntaxTests.fixed_provider_spec +inspect_spec SpecsSyntaxTests.generic_fixed +inspect_spec SpecsSyntaxTests.model_dependent_ghost +namespace InspectionRegression +open Aeneas.Std AeneasSpecs +def original (x : Nat) : Result Nat := .ok x +@[aeneas_spec] abbrev wrongCall : Prop := + ∀ x : Nat, WP.spec (original (x + 1)) (fun _ => True) +/-- error: Specification InspectionRegression.wrongCall model input 1 is not its original bound variable -/ +#guard_msgs (error, drop info) in +inspect_spec wrongCall +@[aeneas_spec] abbrev missingDecode : Prop := + ∀ x : Nat, WP.spec (original x) (fun _ => True) +/-- error: Specification InspectionRegression.missingDecode lacks the expected decoded-result existential -/ +#guard_msgs (error, drop info) in +inspect_spec missingDecode +@[aeneas_spec] abbrev rejectedDecode : Prop := + ∀ x : Nat, WP.spec (original x) (fun raw => ∃ math : Nat, + @RustModel.decode Nat modelNat raw = none ∧ math = math) +/-- error: Specification InspectionRegression.rejectedDecode does not require successful decoding of its original result -/ +#guard_msgs (error, drop info) in +inspect_spec rejectedDecode +end InspectionRegression +''' + with tempfile.TemporaryDirectory(prefix='aeneas-inspection-test-') as directory: + path = Path(directory) / 'Regression.lean' + path.write_text(source) + result = subprocess.run(['lake', 'env', 'lean', '-DwarningAsError=true', str(path)], + cwd=PROJECT, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('Execution: partial (WP.dspec)', result.stdout) + self.assertIn('Execution: total (WP.spec)', result.stdout) + self.assertIn('modelNat', result.stdout) + self.assertIn('position', result.stdout) + self.assertIn('constraint', result.stdout) + self.assertIn('Required result decoding:', result.stdout) + self.assertIn('Original raw call:', result.stdout) + # The competing ghost must not be reported as the selected input provider. + self.assertNotIn('Selected provider: evil', result.stdout) + self.assertNotIn('Selected result provider: evil', result.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/test_prepare.py b/verification/aeneas/tests/test_prepare.py new file mode 100644 index 0000000000..408405c070 --- /dev/null +++ b/verification/aeneas/tests/test_prepare.py @@ -0,0 +1,102 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location("prepare", Path(__file__).parents[1] / "prepare.py") +prepare = importlib.util.module_from_spec(spec) +spec.loader.exec_module(prepare) + + +class PrepareTests(unittest.TestCase): + def test_rejects_error_bearing_or_missing_llbc_status(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "crate.llbc" + for status in [True, None, 0, "false"]: + path.write_text(json.dumps({"has_errors": status, "translated": {}})) + with self.assertRaises(ValueError): + prepare.check_llbc(path) + path.write_text(json.dumps({"has_errors": False, "translated": {}})) + prepare.check_llbc(path) + + def test_renames_only_second_dictionary(self): + types = ('"markerCopyInst", "markerCopyInst"\n' + 'markerCopyInst : core.marker.Copy Self\n' + 'markerCopyInst : core.marker.Copy Self_NonZeroInner\n') + funs = ('markerCopyInst := core.marker.CopyUsize\n' + 'markerCopyInst := core.num.niche_types.NonZeroUsizeInner.Insts.CoreMarkerCopy\n' + 'def util.max := body\n') + new_types, new_funs = prepare.rename_copy_fields(types, funs) + self.assertIn('"markerCopyInst", "innerCopyInst"', new_types) + self.assertIn('markerCopyInst : core.marker.Copy Self\n', new_types) + self.assertIn('innerCopyInst : core.marker.Copy Self_NonZeroInner\n', new_types) + self.assertIn('markerCopyInst := core.marker.CopyUsize\n', new_funs) + self.assertTrue(new_funs.endswith('def util.max := body\n')) + # Upgrades, duplicate matches, and accidental repeated application fail. + for bad_types, bad_funs in [(types + types, funs), (types, ''), (new_types, new_funs)]: + with self.assertRaises(ValueError): + prepare.rename_copy_fields(bad_types, bad_funs) + + def test_imports_include_public_meta_and_exclude_dependency_cache(self): + with tempfile.TemporaryDirectory() as tmp: + backend = Path(tmp) + (backend / 'Aeneas.lean').write_text( + 'public import Mathlib.Data.BitVec\npublic meta import Mathlib.Tactic\n' + 'import Aeneas.Std\n') + (backend / '.lake').mkdir() + (backend / '.lake/Other.lean').write_text('import Mathlib\n') + self.assertEqual(prepare.mathlib_imports(backend), + ['Mathlib.Data.BitVec', 'Mathlib.Tactic']) + + def test_imports_include_native_proof_submodules(self): + with tempfile.TemporaryDirectory() as tmp: + backend, proofs = Path(tmp) / 'backend', Path(tmp) / 'proofs' + backend.mkdir() + proofs.mkdir() + (backend / 'Aeneas.lean').write_text('import Mathlib.Tactic.Basic\n') + (proofs / 'RequiredContracts.lean').write_text( + 'public import Mathlib.Tactic.Convert\n') + (proofs / 'Proofs').mkdir() + (proofs / 'Proofs/Helpers.lean').write_text('import all Mathlib.Data.Nat.Log\n') + self.assertEqual(prepare.mathlib_imports(backend, proofs), + ['Mathlib.Data.Nat.Log', 'Mathlib.Tactic.Basic', + 'Mathlib.Tactic.Convert']) + + def test_workspace_discovers_all_assembled_modules(self): + with tempfile.TemporaryDirectory() as tmp: + backend, work = Path(tmp) / 'backend', Path(tmp) / 'work' + backend.mkdir() + work.mkdir() + (backend / 'lake-manifest.json').write_text(json.dumps({ + 'version': '1.2.0', 'packages': [], + })) + for module in ('Specs', 'Invariants', 'Required', 'Zerocopy', + 'ExtraGenerated', 'Check'): + (work / (module + '.lean')).write_text('') + rust_model = Path(tmp) / "rust-model" + rust_model.mkdir() + (rust_model / "lakefile.lean").write_text("") + prepare.share_manifest(work, backend, rust_model) + manifest = json.loads((work / 'lake-manifest.json').read_text()) + self.assertEqual(manifest['packages'][1]['name'], 'rust_model') + self.assertEqual(manifest['packages'][1]['dir'], str(rust_model)) + config = (work / 'lakefile.lean').read_text() + self.assertIn('require rust_model from ', config) + for module in ('Specs', 'Invariants', 'Required', 'Zerocopy', + 'ExtraGenerated'): + self.assertIn('@[default_target] lean_lib ' + module + '\n', config) + self.assertIn('\nlean_lib Check\n', config) + self.assertNotIn('@[default_target] lean_lib Check', config) + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/test_rounding_model_controls.py b/verification/aeneas/tests/test_rounding_model_controls.py new file mode 100644 index 0000000000..d9715a7404 --- /dev/null +++ b/verification/aeneas/tests/test_rounding_model_controls.py @@ -0,0 +1,176 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Bounded fixtures for production rounding control selection and diagnostics.""" +import json +import os +from pathlib import Path +import tempfile +import unittest + +import rounding_model_controls as controls + + +class RoundingControlTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.old_cwd = Path.cwd() + os.chdir(self.temp.name) + self.addCleanup(self.restore_directory) + for module, theorem in controls.PROBES.values(): + Path(module + '.lean').write_text( + 'namespace Zerocopy\n\ntheorem ' + theorem + ' : True := by\n' + ' trivial\n\ntheorem next_law : True := by trivial\n') + Path('ModelShapes.lean').write_text('') + Path('SpecsSyntax.lean').write_text('') + Path('Zerocopy').mkdir() + Path('Zerocopy/Funs.lean').write_text('') + self.spec = ('spec encoding_new_spec\n for @' + controls.OWNER + '.new with 0 type parameters\n' + ' ensures encoded => encoded.align = (align : Nat) ∧ encoded.phase = (phase : Nat)\n') + Path('Specs.lean').write_text('aeneas_spec_begin\n' + self.spec + 'aeneas_spec_end\n') + self.models = ('-- preceding sibling\n' + controls.PREFIX + ' decode self =>\n' + ' { align := 2, phase := 1, .. }\n' + 'check_model_binding ' + controls.OWNER + ' with 0 type parameters\n' + '-- following sibling and local tuple provider\n') + Path('Models.lean').write_text(self.models) + self.manifest = {'version': 3, 'mode': 'verified-live', 'bindings': { + 'zerocopy::layout::RoundingAlignAndPhase': { + 'kind': 'type', 'raw': controls.OWNER, + 'model': controls.OWNER + '.RoundingValue'}}} + self.write_manifest() + + def restore_directory(self): + os.chdir(self.old_cwd) + self.temp.cleanup() + + def write_manifest(self): + Path('bindings.json').write_text(json.dumps(self.manifest)) + + def check_log(self, text, kind='none'): + Path('failure.log').write_text(text) + controls.check_failure(kind, 'failure.log') + + def test_native_renderer_and_lake_renderer(self): + # These are the real renderer prefixes; locations are adjusted to this + # compact fixture's operation-promise or domain span. + fixtures = [ + ('none', '/probe/generated/RoundingDomainControl.lean:3:41: error: unsolved goals\n' + 'case pos\nhp : 0 < code._0.val.val\n⊢ False\n'), + ('none', '✖ [2/2] Building RoundingDomainControl\n' + 'error: generated/RoundingDomainControl.lean:3:41: unsolved goals\n' + 'case pos\nhp : 0 < code._0.val.val\n⊢ False\n' + 'error: Lean exited with code 1\nerror: build failed\n'), + ('constant', '/probe/generated/RoundingConstructorControl.lean:3:14: error: ' + 'Application type mismatch: The argument\n rfl\n'), + ('constant', 'error: generated/RoundingConstructorControl.lean:3:14: ' + 'Application type mismatch: The argument\n rfl\n' + 'error: Lean exited with code 1\nerror: build failed\n'), + ] + for kind, text in fixtures: + with self.subTest(kind=kind, text=text): + self.check_log(text, kind) + + def test_mixed_semantic_and_unrelated_errors_fail_closed(self): + semantic = 'error: RoundingDomainControl.lean:3:41: unsolved goals\n⊢ False\n' + unrelated = [ + "error: RoundingDomainControl.lean:9:1: (kernel) unknown constant '_private.Models.0._proof_3'\n", + "Other.lean:1:1: error: Unknown identifier `missing`\n", + "error: Other.lean:1:1: object file 'Missing.olean' of module Missing does not exist\n", + 'error: cannot locate module Missing\n', + 'wrapper error: unrecognized kernel failure\n', + 'Other.lean:3:1: error: unsolved goals\n', + 'RoundingDomainControl.lean:1:1: error: unsolved goals\n', + ] + for suffix in unrelated: + with self.subTest(suffix=suffix), self.assertRaises(ValueError): + self.check_log(semantic + suffix) + + def test_wrong_probe_or_missing_diagnostic_does_not_count(self): + for text in ['error: Other.lean:3:41: unsolved goals\n', + 'error: RoundingDomainControl.lean:6:41: unsolved goals\n', + 'error: Lean exited with code 1\nerror: build failed\n']: + with self.subTest(text=text), self.assertRaises(ValueError): + self.check_log(text) + + def test_selected_capability_and_lost_owner(self): + self.assertTrue(controls.capability()) + for field, value in [('kind', 'function'), ('model', controls.OWNER + '.Forgetful'), + ('raw', 'OtherRaw')]: + owner = self.manifest['bindings']['zerocopy::layout::RoundingAlignAndPhase'] + old = owner[field] + owner[field] = value + self.write_manifest() + with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'lost'): + controls.capability() + owner[field] = old + self.manifest['bindings'].clear() + self.write_manifest() + with self.assertRaisesRegex(ValueError, 'lost'): + controls.capability() + Path('Models.lean').write_text('') + self.assertFalse(controls.capability()) + + def test_selected_capability_requires_operation_modules(self): + Path('Specs.lean').unlink() + with self.assertRaisesRegex(ValueError, 'prerequisite: Specs'): + controls.capability() + + def test_probes_reuse_constructor_fence_and_independent_input(self): + for module, _ in controls.PROBES.values(): + Path(module + '.lean').unlink() + controls.write_probes() + constructor = Path('RoundingConstructorControl.lean').read_text() + domain = Path('RoundingDomainControl.lean').read_text() + self.assertIn(self.spec, constructor) + self.assertIn('encoding_new_spec_contract align phase (.ok code)', constructor) + self.assertIn('constructor_result', constructor) + self.assertIn('isValid code', domain) + for source in (constructor, domain): + self.assertNotIn('import MathViews', source) + self.assertNotIn('import RepresentationLaws', source) + self.assertNotIn('derive_rust_model', source) + with self.assertRaisesRegex(ValueError, 'already exists'): + controls.write_probes() + + def test_constructor_fence_selection_fails_closed(self): + original = Path('Specs.lean').read_text() + for source in (original + original, original.replace('encoding_new_spec', 'other_spec'), + original.replace(controls.OWNER + '.new', 'Other.new')): + Path('Specs.lean').write_text(source) + with self.subTest(source=source), self.assertRaises(ValueError): + controls.constructor_spec() + + def test_mutations_touch_only_selected_decoder(self): + marker = 'check_model_binding ' + controls.OWNER + prefix, suffix = self.models.split(controls.PREFIX)[0], self.models[self.models.index(marker):] + for kind, body in [('constant', controls.CONSTANT), ('none', controls.REJECT)]: + Path('Models.lean').write_text(self.models) + controls.mutate(kind) + self.assertEqual(Path('Models.lean').read_text(), prefix + body + suffix) + + def test_bad_mutation_boundaries_leave_source_unchanged(self): + marker = 'check_model_binding ' + controls.OWNER + ' with 0 type parameters\n' + fixtures = [self.models + controls.PREFIX, + self.models.replace(marker, ''), + marker + self.models.replace(marker, ''), + self.models.replace(marker, 'derive_rust_model Another with 0 type parameters\n' + marker), + self.models.replace(marker, 'check_model_binding Another with 0 type parameters\n' + marker), + self.models.replace(marker, 'def unrelated := 0\n' + marker)] + for source in fixtures: + Path('Models.lean').write_text(source) + with self.subTest(source=source), self.assertRaises(ValueError): + controls.mutate('none') + self.assertEqual(Path('Models.lean').read_text(), source) + Path('Models.lean').write_text(self.models) + with self.assertRaises(ValueError): + controls.mutate('invalid') + self.assertEqual(Path('Models.lean').read_text(), self.models) + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/test_workspace.py b/verification/aeneas/tests/test_workspace.py new file mode 100644 index 0000000000..984b741b3b --- /dev/null +++ b/verification/aeneas/tests/test_workspace.py @@ -0,0 +1,154 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +import contextlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import workspace + + +class WorkspaceTests(unittest.TestCase): + def test_projection_edits_block_regeneration_with_copy_back_instructions(self): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + workspace.protect_projections(work) + for module in workspace.PROJECTIONS: + (work / f'{module}.lean').write_text(f'{module} original') + workspace.record_projections(work) + workspace.protect_projections(work) + for module in workspace.PROJECTIONS: + path = work / f'{module}.lean' + original = path.read_bytes() + path.write_text('manual construction or clause edit') + before = {p.name: p.read_bytes() for p in work.iterdir()} + with self.subTest(module=module), self.assertRaisesRegex(ValueError, 'Copy each authored fragment back') as error: + workspace.protect_projections(work) + self.assertIn(f'{module}.source-map.json', str(error.exception)) + self.assertEqual(before, {p.name: p.read_bytes() for p in work.iterdir()}) + path.write_bytes(original) + # Explicitly preserving and removing an edited projection permits regeneration. + (work / 'Models.lean').unlink() + workspace.protect_projections(work) + + def test_unbaselined_existing_projection_is_never_silently_adopted(self): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + (work / 'Specs.lean').write_text('unrecorded local changes') + with self.assertRaisesRegex(ValueError, 'unbaselined'): + workspace.protect_projections(work) + self.assertFalse((work / workspace.PROJECTION_STATE).exists()) + (work / workspace.PROJECTION_STATE).write_text('{"version": 0}') + with self.assertRaisesRegex(ValueError, 'Invalid projection baseline'): + workspace.protect_projections(work) + + def test_inspection_rejects_wrong_binding_and_command_injection_before_running_lean(self): + with tempfile.TemporaryDirectory() as directory, mock.patch.object(workspace, 'run_checked') as run: + work = Path(directory) + (work / 'bindings.json').write_text('{"bindings": {}}') + for candidate in ['missing', 'x\n#eval 1', 'Zerocopy.Proofs.foo']: + with self.subTest(candidate=candidate), self.assertRaises(ValueError): + workspace.inspect_spec(work, work, candidate) + run.assert_not_called() + + def test_fresh_projects_copy_proof_submodules_and_remove_stale_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sources = root / 'verification/aeneas/lean' + for relative in ('Proofs.lean', 'Proofs/Util.lean', 'Specs.lean', + 'Required.lean', 'lakefile.lean', 'Zerocopy/Funs.lean', + '.lake/packages/Dependency.lean'): + path = sources / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(relative) + backend = root / 'backend' + backend.mkdir() + (backend / 'lean-toolchain').write_text('leanprover/lean4:v4.31.0') + work = root / 'target/aeneas/verification' + work.mkdir(parents=True) + (work / 'RemovedProof.lean').write_text('stale') + workspace.initialize(root, work, backend) + self.assertEqual({str(path.relative_to(work)) + for path in work.rglob('*') if path.is_file()}, + {'Proofs.lean', 'Proofs/Util.lean', 'lean-toolchain'}) + with self.assertRaises(ValueError): + workspace.initialize(root, sources, backend) + self.assertTrue((sources / 'Proofs/Util.lean').exists()) + + def test_retires_only_recognized_legacy_generated_artifacts(self): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + old = work / 'Invariants.lean' + mapping = work / 'Invariants.source-map.json' + old.write_text(workspace.golden.HEADER + 'module\npublic import DeriveValidity\nnamespace Zerocopy.Invariants\nend Zerocopy.Invariants\n') + mapping.write_text('{"17": {"file": "zerocopy/src/layout/mod.rs", "line": 42}}') + workspace.retire_generated(work) + self.assertFalse(old.exists()) + self.assertFalse(mapping.exists()) + old.write_text('handwritten source') + with self.assertRaisesRegex(ValueError, 'Refusing to retire unrecognized'): + workspace.retire_generated(work) + self.assertEqual(old.read_text(), 'handwritten source') + + def test_model_support_discovers_all_root_and_nested_modules(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sources = root / 'verification/aeneas/lean' + (sources / 'ModelSupport').mkdir(parents=True) + (sources / 'ModelSupport.lean').write_text('public import ModelSupport.Facts\n') + (sources / 'ModelSupport/Facts.lean').write_text('public import SafeMath\n') + (sources / 'SafeMath.lean').write_text('public import ModelShapes\n') + self.assertEqual(workspace.model_support(root), ['ModelSupport', 'ModelSupport.Facts']) + # Retired and current generated modules never become handwritten. + for generated in ('Invariants.lean', 'ModelShapes.lean', 'Models.lean'): + (sources / generated).write_text('obsolete') + self.assertFalse(set(workspace.handwritten(root)) & {Path('Invariants.lean'), Path('Models.lean'), Path('ModelShapes.lean')}) + + def test_handwritten_module_paths_do_not_conflate_literal_dotted_segments(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sources = root / 'verification/aeneas/lean' + (sources / 'Helper').mkdir(parents=True) + (sources / 'Helper/Name.lean').write_text('module\n') + self.assertEqual(workspace.handwritten(root), [Path('Helper/Name.lean')]) + for filename in ('Helper.Name.lean', 'Helper Name.lean', '«Helper».lean'): + path = sources / filename + path.write_text('module\n') + with self.subTest(filename=filename), self.assertRaisesRegex(ValueError, 'simple identifier components'): + workspace.handwritten(root) + path.unlink() + + def test_diagnostics_preserve_lean_output_and_add_rust_location_on_failure(self): + for module, kind in [('Specs', 'specification'), ('ModelShapes', 'model'), ('Models', 'model')]: + with self.subTest(module=module), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + work = root / 'project' + work.mkdir() + (work / f'{module}.source-map.json').write_text(json.dumps({ + '17': {'file': 'zerocopy/src/layout/mod.rs', 'line': 42}})) + output = io.StringIO() + message = f'{module}.lean:17:9: error: unknown identifier' + command = [sys.executable, '-c', + f'print({message!r}); raise SystemExit(1)'] + with contextlib.redirect_stdout(output), self.assertRaises( + subprocess.CalledProcessError): + workspace.run_checked(command, root, work) + self.assertIn(message, output.getvalue()) + self.assertIn(f'Inline {kind}: {root}/zerocopy/src/layout/mod.rs:42', + output.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/verification/aeneas/tests/type-only-shapes/reject-raw-parameter.lean b/verification/aeneas/tests/type-only-shapes/reject-raw-parameter.lean new file mode 100644 index 0000000000..c833883a48 --- /dev/null +++ b/verification/aeneas/tests/type-only-shapes/reject-raw-parameter.lean @@ -0,0 +1,20 @@ +/- Copyright 2026 The Fuchsia Authors + +Licensed under a BSD-style license , Apache License, Version 2.0 +, or the MIT +license , at your option. +This file may not be copied, modified, or distributed except according to +those terms. -/ + +-- The corresponding TModel shape succeeds in ModelTests. This fixture must +-- fail at the authored shape because raw T and its dictionary are unavailable. +import DeriveModels +open AeneasSpecs +namespace TypeOnlyShapeNegative +structure RawDependent (T : Type) where + value : T +aeneas_model_shape RawDependent with 1 type parameters begin + model Value where + value : T +end +end TypeOnlyShapeNegative diff --git a/verification/aeneas/toolchain.sh b/verification/aeneas/toolchain.sh new file mode 100644 index 0000000000..6860b72259 --- /dev/null +++ b/verification/aeneas/toolchain.sh @@ -0,0 +1,40 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# One pin identifies the translation chain: release archives, patched Aeneas +# source, Charon, Rust, and Lean. Upgrade them together; a version string alone +# does not identify the checked-in patch or the downloaded archive bytes. +# Charon is selected by Aeneas's charon-pin. +AENEAS_RELEASE=nightly-2026.10.01-ca282ec +AENEAS_REV=ca282ec2312a96f6a985a4d23a663690816d3f3e +# The release supplies Charon and the backend. Aeneas itself is built from +# this exact source plus the checked-in CLI-only patch. +AENEAS_VERSION="$AENEAS_RELEASE+nominal-tuple-structs.1" +AENEAS_SOURCE_SHA256=c909a1ada228d8ea6b995eef5de10dce6af011f28430834d7c27f0ebb46a8189 +AENEAS_PATCH_SHA256=a61422f5483f4f753164e0e9e209cc0c872f8072c3318728746665f878bf6b19 +CHARON_VERSION=0.1.276 +CHARON_REV=cf02e3f228f97ee7be3a415be56989053063326d +AENEAS_RUST_TOOLCHAIN=nightly-2026-09-17 +AENEAS_LEAN_TOOLCHAIN=leanprover/lean4:v4.31.0 +AENEAS_LEAN_VERSION=4.31.0 + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) + AENEAS_PLATFORM=linux-x86_64 + AENEAS_SHA256=473df2e5bab0439bfd1a68503ae02c7f88a8de4182e98e4e281590285ab01dee + AENEAS_LEAN_PLATFORM=linux + AENEAS_LEAN_SHA256=07a633cc8d9151cbc08825ea4cdda50d4b02a2c9cb852c0131b13046f49cad7f + ;; + Darwin-arm64) + AENEAS_PLATFORM=macos-aarch64 + AENEAS_SHA256=fac88099e9d687eb321499e43aef1418a4d06f7ae72ecba0b5fd7d38cf013ddd + AENEAS_LEAN_PLATFORM=darwin_aarch64 + AENEAS_LEAN_SHA256=264105500c8abdf37b68ffe03390a783ed259807807222698da8dd92d6ce0a27 + ;; + *) echo "Unsupported Aeneas CI toolchain platform" >&2; return 1 ;; +esac diff --git a/verification/aeneas/workspace.py b/verification/aeneas/workspace.py new file mode 100644 index 0000000000..f5edddfb96 --- /dev/null +++ b/verification/aeneas/workspace.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Manage disposable CI projects and protect persistent development edits. + +Native Lean imports determine compilation order. This module copies handwritten +sources into fresh CI projects, preserves development projections unless their +baseline matches, and adds Rust locations to Lean diagnostics. Inspection reads +the existing compiled subject; it never implies that edited sources were rebuilt. +""" + +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +import golden +from copy_back import MODULES as PROJECTIONS, STATE as PROJECTION_STATE + +GENERATED = {'Specs.lean', 'ModelShapes.lean', 'Models.lean', 'Invariants.lean', 'Required.lean', 'lakefile.lean'} | { + 'Zerocopy/' + name for name in golden.FILES +} + + +def protect_projections(work): + """Fail before development regeneration can destroy an edited projection.""" + state_path = work / PROJECTION_STATE + state = json.loads(state_path.read_text()) if state_path.exists() else None + if state is not None and (not isinstance(state, dict) or state.get('version') not in (1, 2) or + set(state.get('sha256', {})) != set(PROJECTIONS)): + raise ValueError(f'Invalid projection baseline: {state_path}; preserve projections before removing it') + if state is not None and state['version'] == 2: + import copy_back + copy_back.validate(state) + changed = [] + for module in PROJECTIONS: + path = work / f'{module}.lean' + if not path.exists(): + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if state is None or state['sha256'][module] != digest: + changed.append(module) + if changed: + details = '\n'.join(f' {work / (name + ".lean")} -> ' + f'{work / (name + ".source-map.json")}' for name in changed) + raise ValueError('Refusing to overwrite edited or unbaselined inline projections:\n' + details + + '\nUse dev.sh --copy-back FULL_RUST_OWNER --dry-run to preview a version 2 baseline, ' + 'then omit --dry-run to apply. Copy each authored fragment back to the Rust aeneas doc fence at its source-map ' + 'file/line (shape fields from ModelShapes, decode/decode? body from Models, ' + 'spec clauses from Specs). Do not copy generated imports, for targets, or derive/check ' + 'commands. Preserve the edited projections and baseline until copying back is complete. ' + 'Unannotated structural projections have no authored fragment; change the Rust type ' + 'or generator instead. Rust comments remain authoritative.') + + +def record_projections(work, annotations=None): + """Record full editing authority when available, or a legacy hash baseline. + + A legacy baseline can protect files from regeneration but cannot authorize + reverse edits, because it contains no owned source spans or saved payloads. + """ + if annotations is not None: + import copy_back + copy_back.record(work, annotations) + return + state = {'version': 1, 'sha256': {module: hashlib.sha256( + (work / f'{module}.lean').read_bytes()).hexdigest() for module in PROJECTIONS}} + (work / PROJECTION_STATE).write_text(json.dumps(state, indent=2) + '\n') + + +def module_name(relative): + """Keep file components identical to their resolved Lean Name segments.""" + components = (*relative.parts[:-1], relative.stem) + if any(not re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*', part) for part in components): + raise ValueError(f'Handwritten Lean module paths require simple identifier components: {relative}') + return '.'.join(components) + + +def handwritten(root): + """Discover native modules while excluding generated files and Lake caches. + + Validate each path before returning it: a filename containing a literal dot + must not silently become the same Lean name as a nested directory path. + """ + sources = root / 'verification/aeneas/lean' + paths = sorted(path.relative_to(sources) for path in sources.rglob('*.lean') + if '.lake' not in path.relative_to(sources).parts + and str(path.relative_to(sources)) not in GENERATED) + for path in paths: + module_name(path) + return paths + + +def retire_generated(work): + """Remove only recognized artifacts of the retired invariant generator.""" + old = work / 'Invariants.lean' + mapping = work / 'Invariants.source-map.json' + if old.exists(): + text = old.read_text() + if not (text.startswith(golden.HEADER) and 'public import DeriveValidity\n' in text + and 'namespace Zerocopy.Invariants\n' in text): + raise ValueError('Refusing to retire unrecognized Invariants.lean; preserve or relocate this file before regeneration') + if mapping.exists(): + locations = json.loads(mapping.read_text()) + if not isinstance(locations, dict) or any( + not isinstance(key, str) or not key.isdecimal() + or not isinstance(value, dict) or set(value) != {'file', 'line'} + or not isinstance(value['file'], str) or not isinstance(value['line'], int) + for key, value in locations.items()): + raise ValueError('Refusing to retire unrecognized Invariants.source-map.json') + if old.exists(): + old.unlink() + if mapping.exists(): + mapping.unlink() + + +def model_support(root): + """Discover support modules; Check audits their compiled import closure.""" + return sorted(name for path in handwritten(root) + if (name := module_name(path)) == 'ModelSupport' or name.startswith('ModelSupport.')) + + +def initialize(root, work, backend): + """Replace a named disposable CI project with fresh handwritten sources. + + Check the destination before deletion. Development sources and their editing + baseline never belong to this replacement path, even if they look generated. + """ + # Only the two disposable CI projects may be replaced. The developer's + # project contains checked-in proof sources and must never be removed. + expected = root / 'target/aeneas' + if work.parent.resolve() != expected.resolve() or work.name not in { + 'verification', 'golden-verification'}: + raise ValueError('Fresh CI workspaces must be the disposable Aeneas projects') + if work.exists(): + shutil.rmtree(work) + work.mkdir(parents=True) + for relative in handwritten(root): + destination = work / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(root / 'verification/aeneas/lean' / relative, destination) + shutil.copyfile(backend / 'lean-toolchain', work / 'lean-toolchain') + + +def run_checked(command, root, work): + """Stream Lean output unchanged and append any mapped Rust diagnostic. + + Source maps explain generated locations; they do not grant editing authority. + Preserve the command's failing status even when a diagnostic was mapped. + """ + locations = {} + for module in ('Specs', 'ModelShapes', 'Models'): + mapping = work / f'{module}.source-map.json' + locations[module] = json.loads(mapping.read_text()) if mapping.exists() else {} + with subprocess.Popen(command, cwd=work, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True) as process: + for line in process.stdout: + print(line, end='', flush=True) + match = re.search(r'(?:^|[ /])(Specs|ModelShapes|Models)\.lean:(\d+):\d+:', line) + if match and (location := locations[match[1]].get(match[2])): + kind = 'specification' if match[1] == 'Specs' else 'model' + print(f'Inline {kind}: {root / location["file"]}:{location["line"]}', + flush=True) + status = process.wait() + if status: + raise subprocess.CalledProcessError(status, command) + + +def check(root, work): + """Build native modules, then refresh the audit even when Lake caches imports.""" + run_checked(['lake', 'build'], root, work) + # Lake checks every native module with package-wide warnings as errors. + # Fresh CI initialization removes local artifacts; development keeps its + # own dependency cache and lets Lake invalidate changed modules. + # The audit also writes the proof graph; run it even when Lake caches imports. + run_checked(['lake', 'env', 'lean', '-DwarningAsError=true', 'Check.lean'], root, work) + + +def inspect_spec(root, work, spec): + """Report one compiled contract and its existing binding without rebuilding. + + Validate command identifiers before constructing Lean source. Metadata and + fingerprints describe the available project, so report their limitations + explicitly rather than treating inspection as a fresh verification result. + """ + name = spec if spec.startswith('Zerocopy.Specs.') else 'Zerocopy.Specs.' + spec + if not re.fullmatch(r'Zerocopy\.Specs\.[A-Za-z_][A-Za-z_0-9]*', name): + raise ValueError('Inspection requires a simple compiled specification name') + bindings = json.loads((work / 'bindings.json').read_text()) + entries = [entry for entry in bindings['bindings'].values() + if entry.get('kind') == 'function' and entry.get('spec') == name.rsplit('.', 1)[1]] + if len(entries) != 1: + raise ValueError('Inspection requires exactly one existing Rust/spec binding') + entry = entries[0] + if not re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*(?:\.[A-Za-z_][A-Za-z_0-9]*)*', entry['raw']): + raise ValueError('Invalid extracted declaration in existing binding') + print(f'Project: {work} ({bindings["mode"]}); Rust owner: {entry["file"]}:{entry["open_line"]}', flush=True) + print(f'Binding source/model fingerprints: {bindings["sources"]} / {bindings["model"]}', flush=True) + print(f'Pinned toolchain/configuration: {root / "verification/aeneas/toolchain.sh"}; ' + f'extraction driver: {root / "verification/aeneas/run.sh"}', flush=True) + toolchain = work / 'lean-toolchain' + print('Project toolchain: ' + (toolchain.read_text().strip() if toolchain.exists() else 'unavailable'), flush=True) + llbc = work / 'zerocopy.llbc' + if llbc.exists(): + print(f'Available extraction file: {llbc}', flush=True) + translated = json.loads(llbc.read_text())['translated'] + targets = [{'target': item['key'], **{key: value for key, value in item['value'].items() + if key != 'primitive_alignments'}} for item in translated['target_information']] + options = {key: value for key, value in translated['options'].items() + if key not in {'start_from', 'dest_file'}} + print('Available extraction target metadata: ' + json.dumps(targets), flush=True) + print('Available extraction options: ' + json.dumps(options), flush=True) + print('LLBC metadata describes this file; legacy bindings do not certify its identity with compiled imports.', flush=True) + else: + print('Extraction target/configuration metadata unavailable in this project; no target is inferred from the host.', flush=True) + premises = root / 'verification/aeneas/SEMANTICS.md' + if premises.is_file(): + print(f'External Rust/ABI premises: {premises} (Explicit Rust premise); ' + 'compiler/target layout correspondence and Rust safety remain assumptions.', flush=True) + else: + print('Detailed Rust/ABI premise document unavailable in this stage. ' + f'Available scope and translation trust boundary: {root / "verification/aeneas/README.md"}', flush=True) + graph = work / 'proof-dependencies.json' + if graph.exists(): + theorem = 'Zerocopy.Proofs.' + name.rsplit('.', 1)[1] + print('Existing audited proof graph: ' + json.dumps([item for item in json.loads(graph.read_text()) + if item['theorem'] == theorem]), flush=True) + # Reuse the native imported .olean files. A temporary command file outside + # the project cannot overwrite its sources or trigger a second Lake build. + source = (root / 'verification/aeneas/lean/InspectSpec.lean').read_text() + with tempfile.TemporaryDirectory(prefix='aeneas-inspect-') as directory: + path = Path(directory) / 'Inspect.lean' + count = len(entry['generics']) + len(entry['inputs']) + path.write_text(source + f'\ncheck_spec_binding {name} for {entry["raw"]} with {count}\n' + + 'inspect_spec ' + name + '\n') + run_checked(['lake', 'env', 'lean', '-DwarningAsError=true', str(path)], root, work) + print('Inspection reads compiled declarations; run dev.sh --check to refresh and audit edited sources.', flush=True) + + +def main(): + """Keep preview/apply editing, regeneration protection, and inspection explicit.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', choices=['initialize', 'check', 'protect', 'inspect', 'copy-back']) + parser.add_argument('work', type=Path) + parser.add_argument('--root', required=True, type=Path) + parser.add_argument('--backend', type=Path) + parser.add_argument('--spec', help='Short or qualified compiled specification name') + parser.add_argument('--owner', help='Full Rust annotation owner for copy-back') + parser.add_argument('--apply', action='store_true', help='Apply the previewed copy-back plan') + args = parser.parse_args() + if args.command == 'copy-back': + if not args.owner: + parser.error('copy-back requires --owner') + import copy_back + try: + copy_back.copy_back(args.root, args.work, args.owner, apply=args.apply) + except (ValueError, OSError, UnicodeError) as error: + parser.exit(1, f'Copy-back failed: {error}\n') + return + if args.owner or args.apply: + parser.error('--owner and --apply require copy-back') + root, work = args.root.resolve(), args.work.resolve() + if args.command == 'initialize': + if args.backend is None: + parser.error('initialize requires --backend') + initialize(root, work, args.backend.resolve()) + elif args.command == 'protect': + protect_projections(work) + elif args.command == 'inspect': + if not args.spec: + parser.error('inspect requires --spec') + inspect_spec(root, work, args.spec) + else: + check(root, work) + + +if __name__ == '__main__': + main() diff --git a/zerocopy/src/util/mod.rs b/zerocopy/src/util/mod.rs index a54980170a..a493f48f70 100644 --- a/zerocopy/src/util/mod.rs +++ b/zerocopy/src/util/mod.rs @@ -153,6 +153,11 @@ pub(crate) fn validate_aligned_to(t: T) -> Result<(), Alignment )] #[cfg_attr(not(zerocopy_inline_always), inline)] #[cfg_attr(zerocopy_inline_always, inline(always))] +/// +/// ```aeneas +/// spec padding_lt_alignment +/// ensures p => (p : Nat) < (align : Nat) +/// ``` pub(crate) const fn padding_needed_for(len: usize, align: NonZeroUsize) -> usize { #[cfg(kani)] #[kani::proof_for_contract(padding_needed_for)] @@ -234,6 +239,12 @@ pub(crate) const fn padding_needed_for(len: usize, align: NonZeroUsize) -> usize m.checked_add(align.get()).map(|next_mul| next_mul > n).unwrap_or(true) }) )] +/// +/// ```aeneas +/// spec round_down_spec +/// requires h : (align : Nat).isPowerOfTwo +/// ensures m => (m : Nat) ≤ (n : Nat) ∧ (m : Nat) % (align : Nat) = 0 +/// ``` pub(crate) const fn round_down_to_next_multiple_of_alignment( n: usize, align: NonZeroUsize, @@ -256,6 +267,11 @@ pub(crate) const fn round_down_to_next_multiple_of_alignment( #[cfg_attr(not(zerocopy_inline_always), inline)] #[cfg_attr(zerocopy_inline_always, inline(always))] +/// +/// ```aeneas +/// spec max_spec +/// ensures r => (r : Nat) = Nat.max (a : Nat) (b : Nat) +/// ``` pub(crate) const fn max(a: NonZeroUsize, b: NonZeroUsize) -> NonZeroUsize { if a.get() < b.get() { b @@ -266,6 +282,11 @@ pub(crate) const fn max(a: NonZeroUsize, b: NonZeroUsize) -> NonZeroUsize { #[cfg_attr(not(zerocopy_inline_always), inline)] #[cfg_attr(zerocopy_inline_always, inline(always))] +/// +/// ```aeneas +/// spec min_spec +/// ensures r => (r : Nat) = Nat.min (a : Nat) (b : Nat) +/// ``` pub(crate) const fn min(a: NonZeroUsize, b: NonZeroUsize) -> NonZeroUsize { if a.get() > b.get() { b