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