From 7c15a27564bd18bc1a139181e07d165fe405513d Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Mon, 5 Oct 2026 11:02:42 -0400 Subject: [PATCH 01/12] Move pointer cast implementation into its own source file Move the inline cast_from module to layout/cast_from.rs without changing its implementation. Use layout/mod.rs as the root of the resulting multi-file module, and update existing source-path references. Agent-authored-by: AI agent acting on joshlf's behalf gherrit-pr-id: Gvvdmej5q7tk5o76yu4c26esuzqpvpdca --- evals/unsafe-rust/source-catalog.md | 2 +- zerocopy/src/layout/cast_from.rs | 348 ++++++++++++++++++++++ zerocopy/src/{layout.rs => layout/mod.rs} | 340 +-------------------- 3 files changed, 350 insertions(+), 340 deletions(-) create mode 100644 zerocopy/src/layout/cast_from.rs rename zerocopy/src/{layout.rs => layout/mod.rs} (84%) diff --git a/evals/unsafe-rust/source-catalog.md b/evals/unsafe-rust/source-catalog.md index b111a540d5..685ce9448d 100644 --- a/evals/unsafe-rust/source-catalog.md +++ b/evals/unsafe-rust/source-catalog.md @@ -355,7 +355,7 @@ Use separate fresh agents for these audit units: 7. primitive, atomic, function-pointer, SIMD, float, and validity implementations in `src/impls.rs` and `src/byteorder.rs`; 8. layout/allocation arithmetic, ZSTs, DST metadata, and allocation failure in - `src/layout.rs`, `src/util/mod.rs`, and alloc branches; + `src/layout/mod.rs`, `src/util/mod.rs`, and alloc branches; 9. declarative macros and hidden support in `src/macros.rs` and `src/util/`; 10. the entire proc-macro generator as a theorem over every accepted token stream and interaction with other macros/attributes; diff --git a/zerocopy/src/layout/cast_from.rs b/zerocopy/src/layout/cast_from.rs new file mode 100644 index 0000000000..479446fae2 --- /dev/null +++ b/zerocopy/src/layout/cast_from.rs @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: BSD-2-Clause OR Apache-2.0 OR MIT +// +// Copyright 2026 The Fuchsia Authors +// +// Licensed under the 2-Clause BSD 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 crate::*; + +pub(crate) struct CastFrom { + _never: core::convert::Infallible, + _marker: PhantomData, +} + +// SAFETY: The implementation of `Project::project` preserves the address +// of the referent – it only modifies pointer metadata. +unsafe impl crate::pointer::cast::Cast for CastFrom +where + Src: KnownLayout + ?Sized, + Dst: KnownLayout + ?Sized, +{ +} + +// SAFETY: The implementation of `Project::project` preserves the size of +// the referent (see inline comments for a more detailed proof of this). +unsafe impl crate::pointer::cast::CastExact for CastFrom +where + Src: KnownLayout + ?Sized, + Dst: KnownLayout + ?Sized, +{ +} + +// SAFETY: `project` produces a pointer which refers to the same referent +// bytes as its input, or to a subset of them (see inline comments for a +// more detailed proof of this). It does this using provenance-preserving +// operations. +unsafe impl crate::pointer::cast::Project for CastFrom +where + Src: KnownLayout + ?Sized, + Dst: KnownLayout + ?Sized, +{ + /// # PME + /// + /// Generates a post-monomorphization error if it is not possible to + /// implement soundly. + // + // FIXME(#1817): Support Sized->Unsized and Unsized->Sized casts + fn project(src: PtrInner<'_, Src>) -> *mut Dst { + /// The parameters required in order to perform a pointer cast from + /// `Src` to `Dst`. + /// + /// These are a compile-time function of the layouts of `Src` + /// and `Dst`. + /// + /// # Safety + /// + /// `Src`'s alignment must not be smaller than `Dst`'s alignment. + struct CastParams { + inner: CastParamsInner, + _src: PhantomData, + _dst: PhantomData, + } + + #[derive(Copy, Clone)] + enum CastParamsInner { + // At compile time (specifically, post-monomorphization time), + // we need to compute two things: + // - Whether, given *any* `*Src`, it is possible to construct a + // `*Dst` which addresses the same number of bytes (ie, + // whether, for any `Src` pointer metadata, there exists `Dst` + // pointer metadata that addresses the same number of bytes) + // - If this is possible, any information necessary to perform + // the `Src`->`Dst` metadata conversion at runtime. + // + // Assume that `Src` and `Dst` are slice DSTs, and define: + // - `S_OFF = Src::LAYOUT.size_info.offset` + // - `S_ELEM = Src::LAYOUT.size_info.elem_size` + // - `D_OFF = Dst::LAYOUT.size_info.offset` + // - `D_ELEM = Dst::LAYOUT.size_info.elem_size` + // + // We are trying to solve the following equation: + // + // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // + // At runtime, we will be attempting to compute `d_meta`, given + // `s_meta` (a runtime value) and all other parameters (which + // are compile-time values). We can solve like so: + // + // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // + // d_meta * D_ELEM = S_OFF - D_OFF + s_meta * S_ELEM + // + // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM + // + // Since `d_meta` will be a `usize`, we need the right-hand side + // to be an integer, and this needs to hold for *any* value of + // `s_meta` (in order for our conversion to be infallible - ie, + // to not have to reject certain values of `s_meta` at runtime). + // This means that: + // + // - `s_meta * S_ELEM` must be a multiple of `D_ELEM` + // - Since this must hold for any value of `s_meta`, `S_ELEM` + // must be a multiple of `D_ELEM` + // - `S_OFF - D_OFF` must be a multiple of `D_ELEM` + // + // Thus, let `OFFSET_DELTA_ELEMS = (S_OFF - D_OFF)/D_ELEM` and + // `ELEM_MULTIPLE = S_ELEM/D_ELEM`. We can rewrite the above + // expression as: + // + // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM + // + // d_meta = OFFSET_DELTA_ELEMS + s_meta * ELEM_MULTIPLE + // + // Thus, we just need to compute the following and confirm that + // they have integer solutions in order to both a) determine + // whether infallible `Src` -> `Dst` casts are possible and, b) + // pre-compute the parameters necessary to perform those casts + // at runtime. These parameters are encapsulated in + // `CastParams`, which acts as a witness that such infallible + // casts are possible. + /// The parameters required in order to perform an + /// unsized-to-unsized pointer cast from `Src` to `Dst` as + /// described above. + /// + /// # Safety + /// + /// `Src` and `Dst` must both be slice DSTs. + /// + /// `offset_delta_elems` and `elem_multiple` must be valid as + /// described above. + UnsizedToUnsized { offset_delta_elems: usize, elem_multiple: usize }, + + /// The metadata of a `Dst` which has the same size as `Src: + /// Sized`. + /// + /// # Safety + /// + /// `Src: Sized` and `Dst` must be a slice DST. + /// + /// A raw `Dst` pointer with metadata `dst_meta` must address + /// `size_of::()` bytes. + SizedToUnsized { dst_meta: usize }, + + /// The metadata of a `Dst` which has the same size as `Src: + /// Sized`. + /// + /// # Safety + /// + /// `Src` and `Dst` must both be `Sized` and `size_of::() + /// == size_of::()`. + SizedToSized, + } + + impl Copy for CastParams {} + impl Clone for CastParams { + fn clone(&self) -> Self { + *self + } + } + + impl CastParams { + const fn try_compute( + src: &DstLayout, + dst: &DstLayout, + ) -> Option> { + if src.align.get() < dst.align.get() { + return None; + } + + let inner = match (src.size_info, dst.size_info) { + ( + SizeInfo::Sized { size: src_size }, + SizeInfo::Sized { size: dst_size }, + ) => { + if src_size != dst_size { + return None; + } + + // SAFETY: We checked above that `src_size == + // dst_size`. + CastParamsInner::SizedToSized + } + (SizeInfo::Sized { size: src_size }, SizeInfo::SliceDst(dst)) => { + let offset_delta = if let Some(od) = src_size.checked_sub(dst.offset) { + od + } else { + return None; + }; + + let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { + e + } else { + return None; + }; + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let delta_mod_other_elem = offset_delta % dst_elem_size.get(); + + if delta_mod_other_elem != 0 { + return None; + } + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let dst_meta = offset_delta / dst_elem_size.get(); + + // SAFETY: The preceding math ensures that a `Dst` + // with `dst_meta` addresses `src_size` bytes. + CastParamsInner::SizedToUnsized { dst_meta } + } + (SizeInfo::SliceDst(src), SizeInfo::SliceDst(dst)) => { + let offset_delta = if let Some(od) = src.offset.checked_sub(dst.offset) + { + od + } else { + return None; + }; + + let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { + e + } else { + return None; + }; + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let delta_mod_other_elem = offset_delta % dst_elem_size.get(); + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let elem_remainder = src.elem_size % dst_elem_size.get(); + + if delta_mod_other_elem != 0 + || src.elem_size < dst.elem_size + || elem_remainder != 0 + { + return None; + } + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let offset_delta_elems = offset_delta / dst_elem_size.get(); + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let elem_multiple = src.elem_size / dst_elem_size.get(); + + CastParamsInner::UnsizedToUnsized { + // SAFETY: We checked above that this is an exact ratio. + offset_delta_elems, + // SAFETY: We checked above that this is an exact ratio. + elem_multiple, + } + } + _ => return None, + }; + + // SAFETY: We checked above that `src.align >= dst.align`. + Some(CastParams { inner, _src: PhantomData, _dst: PhantomData }) + } + } + + impl CastParams { + /// # Safety + /// + /// `src_meta` describes a `Src` whose size is no larger than + /// `isize::MAX`. + /// + /// The returned metadata describes a `Dst` of the same size as + /// the original `Src`. + #[inline(always)] + unsafe fn cast_metadata( + self, + src_meta: Src::PointerMetadata, + ) -> Dst::PointerMetadata { + #[allow(unused)] + use crate::util::polyfills::*; + + let dst_meta = match self.inner { + CastParamsInner::UnsizedToUnsized { offset_delta_elems, elem_multiple } => { + let src_meta = src_meta.to_elem_count(); + #[allow( + unstable_name_collisions, + clippy::multiple_unsafe_ops_per_block + )] + // SAFETY: `self` is a witness that the following + // equation holds: + // + // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // + // Since the caller promises that `src_meta` is + // valid `Src` metadata, this math will not + // overflow, and the returned value will describe a + // `Dst` of the same size. + unsafe { + offset_delta_elems + .unchecked_add(src_meta.unchecked_mul(elem_multiple)) + } + } + CastParamsInner::SizedToUnsized { dst_meta } => dst_meta, + CastParamsInner::SizedToSized => 0, + }; + Dst::PointerMetadata::from_elem_count(dst_meta) + } + } + + trait Params { + const CAST_PARAMS: CastParams; + } + + impl Params for Dst + where + Src: KnownLayout + ?Sized, + Dst: KnownLayout + ?Sized, + { + const CAST_PARAMS: CastParams = + match CastParams::try_compute(&Src::LAYOUT, &Dst::LAYOUT) { + Some(params) => params, + None => const_panic!( + "cannot `transmute_ref!` or `transmute_mut!` between incompatible types" + ), + }; + } + + let src_meta = ::pointer_to_metadata(src.as_ptr()); + let params = >::CAST_PARAMS; + + // SAFETY: `src: PtrInner` guarantees that `src`'s referent is zero + // bytes or lives in a single allocation, which means that it is no + // larger than `isize::MAX` bytes [1]. + // + // [1] https://doc.rust-lang.org/1.92.0/std/ptr/index.html#allocation + let dst_meta = unsafe { params.cast_metadata(src_meta) }; + + ::raw_from_ptr_len(src.as_non_null().cast(), dst_meta).as_ptr() + } +} diff --git a/zerocopy/src/layout.rs b/zerocopy/src/layout/mod.rs similarity index 84% rename from zerocopy/src/layout.rs rename to zerocopy/src/layout/mod.rs index d58786d637..5f14bd424e 100644 --- a/zerocopy/src/layout.rs +++ b/zerocopy/src/layout/mod.rs @@ -755,345 +755,7 @@ impl DstLayout { } pub(crate) use cast_from::CastFrom; -mod cast_from { - use crate::*; - - pub(crate) struct CastFrom { - _never: core::convert::Infallible, - _marker: PhantomData, - } - - // SAFETY: The implementation of `Project::project` preserves the address - // of the referent – it only modifies pointer metadata. - unsafe impl crate::pointer::cast::Cast for CastFrom - where - Src: KnownLayout + ?Sized, - Dst: KnownLayout + ?Sized, - { - } - - // SAFETY: The implementation of `Project::project` preserves the size of - // the referent (see inline comments for a more detailed proof of this). - unsafe impl crate::pointer::cast::CastExact for CastFrom - where - Src: KnownLayout + ?Sized, - Dst: KnownLayout + ?Sized, - { - } - - // SAFETY: `project` produces a pointer which refers to the same referent - // bytes as its input, or to a subset of them (see inline comments for a - // more detailed proof of this). It does this using provenance-preserving - // operations. - unsafe impl crate::pointer::cast::Project for CastFrom - where - Src: KnownLayout + ?Sized, - Dst: KnownLayout + ?Sized, - { - /// # PME - /// - /// Generates a post-monomorphization error if it is not possible to - /// implement soundly. - // - // FIXME(#1817): Support Sized->Unsized and Unsized->Sized casts - fn project(src: PtrInner<'_, Src>) -> *mut Dst { - /// The parameters required in order to perform a pointer cast from - /// `Src` to `Dst`. - /// - /// These are a compile-time function of the layouts of `Src` - /// and `Dst`. - /// - /// # Safety - /// - /// `Src`'s alignment must not be smaller than `Dst`'s alignment. - struct CastParams { - inner: CastParamsInner, - _src: PhantomData, - _dst: PhantomData, - } - - #[derive(Copy, Clone)] - enum CastParamsInner { - // At compile time (specifically, post-monomorphization time), - // we need to compute two things: - // - Whether, given *any* `*Src`, it is possible to construct a - // `*Dst` which addresses the same number of bytes (ie, - // whether, for any `Src` pointer metadata, there exists `Dst` - // pointer metadata that addresses the same number of bytes) - // - If this is possible, any information necessary to perform - // the `Src`->`Dst` metadata conversion at runtime. - // - // Assume that `Src` and `Dst` are slice DSTs, and define: - // - `S_OFF = Src::LAYOUT.size_info.offset` - // - `S_ELEM = Src::LAYOUT.size_info.elem_size` - // - `D_OFF = Dst::LAYOUT.size_info.offset` - // - `D_ELEM = Dst::LAYOUT.size_info.elem_size` - // - // We are trying to solve the following equation: - // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM - // - // At runtime, we will be attempting to compute `d_meta`, given - // `s_meta` (a runtime value) and all other parameters (which - // are compile-time values). We can solve like so: - // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM - // - // d_meta * D_ELEM = S_OFF - D_OFF + s_meta * S_ELEM - // - // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM - // - // Since `d_meta` will be a `usize`, we need the right-hand side - // to be an integer, and this needs to hold for *any* value of - // `s_meta` (in order for our conversion to be infallible - ie, - // to not have to reject certain values of `s_meta` at runtime). - // This means that: - // - // - `s_meta * S_ELEM` must be a multiple of `D_ELEM` - // - Since this must hold for any value of `s_meta`, `S_ELEM` - // must be a multiple of `D_ELEM` - // - `S_OFF - D_OFF` must be a multiple of `D_ELEM` - // - // Thus, let `OFFSET_DELTA_ELEMS = (S_OFF - D_OFF)/D_ELEM` and - // `ELEM_MULTIPLE = S_ELEM/D_ELEM`. We can rewrite the above - // expression as: - // - // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM - // - // d_meta = OFFSET_DELTA_ELEMS + s_meta * ELEM_MULTIPLE - // - // Thus, we just need to compute the following and confirm that - // they have integer solutions in order to both a) determine - // whether infallible `Src` -> `Dst` casts are possible and, b) - // pre-compute the parameters necessary to perform those casts - // at runtime. These parameters are encapsulated in - // `CastParams`, which acts as a witness that such infallible - // casts are possible. - /// The parameters required in order to perform an - /// unsized-to-unsized pointer cast from `Src` to `Dst` as - /// described above. - /// - /// # Safety - /// - /// `Src` and `Dst` must both be slice DSTs. - /// - /// `offset_delta_elems` and `elem_multiple` must be valid as - /// described above. - UnsizedToUnsized { offset_delta_elems: usize, elem_multiple: usize }, - - /// The metadata of a `Dst` which has the same size as `Src: - /// Sized`. - /// - /// # Safety - /// - /// `Src: Sized` and `Dst` must be a slice DST. - /// - /// A raw `Dst` pointer with metadata `dst_meta` must address - /// `size_of::()` bytes. - SizedToUnsized { dst_meta: usize }, - - /// The metadata of a `Dst` which has the same size as `Src: - /// Sized`. - /// - /// # Safety - /// - /// `Src` and `Dst` must both be `Sized` and `size_of::() - /// == size_of::()`. - SizedToSized, - } - - impl Copy for CastParams {} - impl Clone for CastParams { - fn clone(&self) -> Self { - *self - } - } - - impl CastParams { - const fn try_compute( - src: &DstLayout, - dst: &DstLayout, - ) -> Option> { - if src.align.get() < dst.align.get() { - return None; - } - - let inner = match (src.size_info, dst.size_info) { - ( - SizeInfo::Sized { size: src_size }, - SizeInfo::Sized { size: dst_size }, - ) => { - if src_size != dst_size { - return None; - } - - // SAFETY: We checked above that `src_size == - // dst_size`. - CastParamsInner::SizedToSized - } - (SizeInfo::Sized { size: src_size }, SizeInfo::SliceDst(dst)) => { - let offset_delta = if let Some(od) = src_size.checked_sub(dst.offset) { - od - } else { - return None; - }; - - let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { - e - } else { - return None; - }; - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let delta_mod_other_elem = offset_delta % dst_elem_size.get(); - - if delta_mod_other_elem != 0 { - return None; - } - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let dst_meta = offset_delta / dst_elem_size.get(); - - // SAFETY: The preceding math ensures that a `Dst` - // with `dst_meta` addresses `src_size` bytes. - CastParamsInner::SizedToUnsized { dst_meta } - } - (SizeInfo::SliceDst(src), SizeInfo::SliceDst(dst)) => { - let offset_delta = if let Some(od) = src.offset.checked_sub(dst.offset) - { - od - } else { - return None; - }; - - let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { - e - } else { - return None; - }; - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let delta_mod_other_elem = offset_delta % dst_elem_size.get(); - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let elem_remainder = src.elem_size % dst_elem_size.get(); - - if delta_mod_other_elem != 0 - || src.elem_size < dst.elem_size - || elem_remainder != 0 - { - return None; - } - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let offset_delta_elems = offset_delta / dst_elem_size.get(); - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let elem_multiple = src.elem_size / dst_elem_size.get(); - - CastParamsInner::UnsizedToUnsized { - // SAFETY: We checked above that this is an exact ratio. - offset_delta_elems, - // SAFETY: We checked above that this is an exact ratio. - elem_multiple, - } - } - _ => return None, - }; - - // SAFETY: We checked above that `src.align >= dst.align`. - Some(CastParams { inner, _src: PhantomData, _dst: PhantomData }) - } - } - - impl CastParams { - /// # Safety - /// - /// `src_meta` describes a `Src` whose size is no larger than - /// `isize::MAX`. - /// - /// The returned metadata describes a `Dst` of the same size as - /// the original `Src`. - #[inline(always)] - unsafe fn cast_metadata( - self, - src_meta: Src::PointerMetadata, - ) -> Dst::PointerMetadata { - #[allow(unused)] - use crate::util::polyfills::*; - - let dst_meta = match self.inner { - CastParamsInner::UnsizedToUnsized { offset_delta_elems, elem_multiple } => { - let src_meta = src_meta.to_elem_count(); - #[allow( - unstable_name_collisions, - clippy::multiple_unsafe_ops_per_block - )] - // SAFETY: `self` is a witness that the following - // equation holds: - // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM - // - // Since the caller promises that `src_meta` is - // valid `Src` metadata, this math will not - // overflow, and the returned value will describe a - // `Dst` of the same size. - unsafe { - offset_delta_elems - .unchecked_add(src_meta.unchecked_mul(elem_multiple)) - } - } - CastParamsInner::SizedToUnsized { dst_meta } => dst_meta, - CastParamsInner::SizedToSized => 0, - }; - Dst::PointerMetadata::from_elem_count(dst_meta) - } - } - - trait Params { - const CAST_PARAMS: CastParams; - } - - impl Params for Dst - where - Src: KnownLayout + ?Sized, - Dst: KnownLayout + ?Sized, - { - const CAST_PARAMS: CastParams = - match CastParams::try_compute(&Src::LAYOUT, &Dst::LAYOUT) { - Some(params) => params, - None => const_panic!( - "cannot `transmute_ref!` or `transmute_mut!` between incompatible types" - ), - }; - } - - let src_meta = ::pointer_to_metadata(src.as_ptr()); - let params = >::CAST_PARAMS; - - // SAFETY: `src: PtrInner` guarantees that `src`'s referent is zero - // bytes or lives in a single allocation, which means that it is no - // larger than `isize::MAX` bytes [1]. - // - // [1] https://doc.rust-lang.org/1.92.0/std/ptr/index.html#allocation - let dst_meta = unsafe { params.cast_metadata(src_meta) }; - - ::raw_from_ptr_len(src.as_non_null().cast(), dst_meta).as_ptr() - } - } -} +mod cast_from; // FIXME(#67): For some reason, on our MSRV toolchain, this `allow` isn't // enforced despite having `#![allow(unknown_lints)]` at the crate root, but From 44c82cefb62a06ea4d618399b952977f80c1a956 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Mon, 5 Oct 2026 11:05:56 -0400 Subject: [PATCH 02/12] Separate numerical cast planning from typed pointer projection Hoist the numerical CastPlan out of the generic pointer method. Keep CastParams as the private connection to the selected KnownLayout pair, and select its plan through an associated constant instead of the local Params trait. Project then reads metadata, applies the selected plan, and reconstructs the destination pointer. Preserve the existing offset/stride selection and unchecked arithmetic. The nested-padding correction and Aeneas proofs remain later changes. Agent-authored-by: AI agent acting on joshlf's behalf gherrit-pr-id: Gysrrwrr4oyfrqso6dw7gdmpjocmskwtx --- zerocopy/src/layout/cast_from.rs | 539 +++++++++++++++---------------- 1 file changed, 255 insertions(+), 284 deletions(-) diff --git a/zerocopy/src/layout/cast_from.rs b/zerocopy/src/layout/cast_from.rs index 479446fae2..579c7f37c3 100644 --- a/zerocopy/src/layout/cast_from.rs +++ b/zerocopy/src/layout/cast_from.rs @@ -9,8 +9,262 @@ // This file may not be copied, modified, or distributed except according to // those terms. +//! Compile-time cast planning and runtime pointer projection. +//! +//! `CastPlan` selects and applies numerical metadata transformations from two +//! layouts. `CastParams` associates a selected plan with a `KnownLayout` type +//! pair and converts their typed metadata to and from element counts. Its +//! associated constant performs selection at compile time; `CastFrom::project` +//! uses that constant to transform metadata and construct the destination +//! pointer at runtime. + use crate::*; +/// A numerical metadata transformation selected for a pair of layouts. +/// +/// A raw plan contains no type or layout identity. Its parameters describe the +/// offset/element-size equation below only in relation to the layouts used by +/// `try_compute`; an arbitrary plan is not a witness for a `KnownLayout` pair. +/// `CastParams` carries that association for pointer projection. +#[derive(Copy, Clone)] +enum CastPlan { + // Assume that `Src` and `Dst` are slice DSTs, and define: + // - `S_OFF = Src::LAYOUT.size_info.offset` + // - `S_ELEM = Src::LAYOUT.size_info.elem_size` + // - `D_OFF = Dst::LAYOUT.size_info.offset` + // - `D_ELEM = Dst::LAYOUT.size_info.elem_size` + // + // We are trying to solve the following equation: + // + // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // + // At runtime, we will be attempting to compute `d_meta`, given + // `s_meta` (a runtime value) and all other parameters (which + // are compile-time values). We can solve like so: + // + // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // + // d_meta * D_ELEM = S_OFF - D_OFF + s_meta * S_ELEM + // + // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM + // + // Since `d_meta` will be a `usize`, we need the right-hand side + // to be an integer, and this needs to hold for *any* value of + // `s_meta` (in order for our conversion to be infallible - ie, + // to not have to reject certain values of `s_meta` at runtime). + // This means that: + // + // - `s_meta * S_ELEM` must be a multiple of `D_ELEM` + // - Since this must hold for any value of `s_meta`, `S_ELEM` + // must be a multiple of `D_ELEM` + // - `S_OFF - D_OFF` must be a multiple of `D_ELEM` + // + // Thus, let `OFFSET_DELTA_ELEMS = (S_OFF - D_OFF)/D_ELEM` and + // `ELEM_MULTIPLE = S_ELEM/D_ELEM`. We can rewrite the above + // expression as: + // + // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM + // + // d_meta = OFFSET_DELTA_ELEMS + s_meta * ELEM_MULTIPLE + // + // Thus, plan selection checks these ratios and stores the parameters + // needed to compute `d_meta` at runtime. `CastParams` associates the selected + // plan with the `KnownLayout` types whose layouts were used to select it. + /// Converts between slice DSTs using the ratios described above. + UnsizedToUnsized { offset_delta_elems: usize, elem_multiple: usize }, + + /// Uses the metadata selected for a sized source and a slice DST. + SizedToUnsized { dst_meta: usize }, + + /// Converts between sized layouts with equal sizes. + SizedToSized, +} + +impl CastPlan { + /// Selects a plan from layout values without converting typed metadata. + const fn try_compute(src: &DstLayout, dst: &DstLayout) -> Option { + if src.align.get() < dst.align.get() { + return None; + } + + let plan = match (src.size_info, dst.size_info) { + (SizeInfo::Sized { size: src_size }, SizeInfo::Sized { size: dst_size }) => { + if src_size != dst_size { + return None; + } + + // We checked above that `src_size == dst_size`. + CastPlan::SizedToSized + } + (SizeInfo::Sized { size: src_size }, SizeInfo::SliceDst(dst)) => { + let offset_delta = if let Some(od) = src_size.checked_sub(dst.offset) { + od + } else { + return None; + }; + + let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { + e + } else { + return None; + }; + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let delta_mod_other_elem = offset_delta % dst_elem_size.get(); + + if delta_mod_other_elem != 0 { + return None; + } + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let dst_meta = offset_delta / dst_elem_size.get(); + + // The preceding math ensures that + // `dst.offset + dst_meta * dst.elem_size == src_size`. + CastPlan::SizedToUnsized { dst_meta } + } + (SizeInfo::SliceDst(src), SizeInfo::SliceDst(dst)) => { + let offset_delta = if let Some(od) = src.offset.checked_sub(dst.offset) { + od + } else { + return None; + }; + + let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { + e + } else { + return None; + }; + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let delta_mod_other_elem = offset_delta % dst_elem_size.get(); + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let elem_remainder = src.elem_size % dst_elem_size.get(); + + if delta_mod_other_elem != 0 || src.elem_size < dst.elem_size || elem_remainder != 0 + { + return None; + } + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let offset_delta_elems = offset_delta / dst_elem_size.get(); + + // PANICS: `dst_elem_size: NonZeroUsize`, so this won't + // divide by zero. + #[allow(clippy::arithmetic_side_effects)] + let elem_multiple = src.elem_size / dst_elem_size.get(); + + CastPlan::UnsizedToUnsized { + // We checked above that this is an exact ratio. + offset_delta_elems, + // We checked above that this is an exact ratio. + elem_multiple, + } + } + _ => return None, + }; + + Some(plan) + } + + /// Applies the numerical metadata transformation to an element count. + /// + /// # Safety + /// + /// For `UnsizedToUnsized`, neither `src_meta * elem_multiple` nor + /// `offset_delta_elems + src_meta * elem_multiple` may overflow `usize`. + /// The other variants impose no condition on `src_meta` and ignore it. + #[inline(always)] + unsafe fn cast_metadata(self, src_meta: usize) -> usize { + #[allow(unused)] + use crate::util::polyfills::*; + + match self { + CastPlan::UnsizedToUnsized { offset_delta_elems, elem_multiple } => { + #[allow(unstable_name_collisions, clippy::multiple_unsafe_ops_per_block)] + // SAFETY: The caller promises that neither the multiplication + // nor the addition overflows `usize`. + unsafe { + offset_delta_elems.unchecked_add(src_meta.unchecked_mul(elem_multiple)) + } + } + CastPlan::SizedToUnsized { dst_meta } => dst_meta, + CastPlan::SizedToSized => 0, + } + } +} + +/// A selected plan associated with the layouts of `Src` and `Dst`. +/// +/// The private fields are initialized only by `CAST_PARAMS`, which selects the +/// plan using `Src::LAYOUT` and `Dst::LAYOUT`. The markers retain this type-pair +/// association when the plan is applied to typed pointer metadata at runtime. +/// +/// # Safety +/// +/// `Src`'s alignment must not be smaller than `Dst`'s alignment. +struct CastParams { + plan: CastPlan, + _src: PhantomData, + _dst: PhantomData, +} + +impl Copy for CastParams {} +impl Clone for CastParams { + fn clone(&self) -> Self { + *self + } +} + +impl CastParams { + /// Computes the parameters at compile time, rejecting incompatible layouts + /// with a post-monomorphization error. + const CAST_PARAMS: Self = match CastPlan::try_compute(&Src::LAYOUT, &Dst::LAYOUT) { + // SAFETY: `try_compute` checked that `Src::LAYOUT.align >= + // Dst::LAYOUT.align`. + Some(plan) => Self { plan, _src: PhantomData, _dst: PhantomData }, + None => { + const_panic!("cannot `transmute_ref!` or `transmute_mut!` between incompatible types") + } + }; + + /// # Safety + /// + /// `src_meta` describes a `Src` whose size is no larger than `isize::MAX`. + /// + /// The returned metadata describes a `Dst` of the same size as the original + /// `Src`. + #[inline(always)] + unsafe fn cast_metadata(self, src_meta: Src::PointerMetadata) -> Dst::PointerMetadata { + let src_meta = match self.plan { + CastPlan::UnsizedToUnsized { .. } => src_meta.to_elem_count(), + _ => 0, + }; + + // SAFETY: `self.plan` was selected for `Src::LAYOUT` and `Dst::LAYOUT`. + // For `UnsizedToUnsized`, its parameters satisfy the equation: + // + // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // + // Since the caller promises that `src_meta` is valid `Src` metadata, + // this math will not overflow, and the returned value will describe a + // `Dst` of the same size. The other variants do no unchecked arithmetic. + let dst_meta = unsafe { self.plan.cast_metadata(src_meta) }; + Dst::PointerMetadata::from_elem_count(dst_meta) + } +} + pub(crate) struct CastFrom { _never: core::convert::Infallible, _marker: PhantomData, @@ -50,291 +304,8 @@ where // // FIXME(#1817): Support Sized->Unsized and Unsized->Sized casts fn project(src: PtrInner<'_, Src>) -> *mut Dst { - /// The parameters required in order to perform a pointer cast from - /// `Src` to `Dst`. - /// - /// These are a compile-time function of the layouts of `Src` - /// and `Dst`. - /// - /// # Safety - /// - /// `Src`'s alignment must not be smaller than `Dst`'s alignment. - struct CastParams { - inner: CastParamsInner, - _src: PhantomData, - _dst: PhantomData, - } - - #[derive(Copy, Clone)] - enum CastParamsInner { - // At compile time (specifically, post-monomorphization time), - // we need to compute two things: - // - Whether, given *any* `*Src`, it is possible to construct a - // `*Dst` which addresses the same number of bytes (ie, - // whether, for any `Src` pointer metadata, there exists `Dst` - // pointer metadata that addresses the same number of bytes) - // - If this is possible, any information necessary to perform - // the `Src`->`Dst` metadata conversion at runtime. - // - // Assume that `Src` and `Dst` are slice DSTs, and define: - // - `S_OFF = Src::LAYOUT.size_info.offset` - // - `S_ELEM = Src::LAYOUT.size_info.elem_size` - // - `D_OFF = Dst::LAYOUT.size_info.offset` - // - `D_ELEM = Dst::LAYOUT.size_info.elem_size` - // - // We are trying to solve the following equation: - // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM - // - // At runtime, we will be attempting to compute `d_meta`, given - // `s_meta` (a runtime value) and all other parameters (which - // are compile-time values). We can solve like so: - // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM - // - // d_meta * D_ELEM = S_OFF - D_OFF + s_meta * S_ELEM - // - // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM - // - // Since `d_meta` will be a `usize`, we need the right-hand side - // to be an integer, and this needs to hold for *any* value of - // `s_meta` (in order for our conversion to be infallible - ie, - // to not have to reject certain values of `s_meta` at runtime). - // This means that: - // - // - `s_meta * S_ELEM` must be a multiple of `D_ELEM` - // - Since this must hold for any value of `s_meta`, `S_ELEM` - // must be a multiple of `D_ELEM` - // - `S_OFF - D_OFF` must be a multiple of `D_ELEM` - // - // Thus, let `OFFSET_DELTA_ELEMS = (S_OFF - D_OFF)/D_ELEM` and - // `ELEM_MULTIPLE = S_ELEM/D_ELEM`. We can rewrite the above - // expression as: - // - // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM - // - // d_meta = OFFSET_DELTA_ELEMS + s_meta * ELEM_MULTIPLE - // - // Thus, we just need to compute the following and confirm that - // they have integer solutions in order to both a) determine - // whether infallible `Src` -> `Dst` casts are possible and, b) - // pre-compute the parameters necessary to perform those casts - // at runtime. These parameters are encapsulated in - // `CastParams`, which acts as a witness that such infallible - // casts are possible. - /// The parameters required in order to perform an - /// unsized-to-unsized pointer cast from `Src` to `Dst` as - /// described above. - /// - /// # Safety - /// - /// `Src` and `Dst` must both be slice DSTs. - /// - /// `offset_delta_elems` and `elem_multiple` must be valid as - /// described above. - UnsizedToUnsized { offset_delta_elems: usize, elem_multiple: usize }, - - /// The metadata of a `Dst` which has the same size as `Src: - /// Sized`. - /// - /// # Safety - /// - /// `Src: Sized` and `Dst` must be a slice DST. - /// - /// A raw `Dst` pointer with metadata `dst_meta` must address - /// `size_of::()` bytes. - SizedToUnsized { dst_meta: usize }, - - /// The metadata of a `Dst` which has the same size as `Src: - /// Sized`. - /// - /// # Safety - /// - /// `Src` and `Dst` must both be `Sized` and `size_of::() - /// == size_of::()`. - SizedToSized, - } - - impl Copy for CastParams {} - impl Clone for CastParams { - fn clone(&self) -> Self { - *self - } - } - - impl CastParams { - const fn try_compute( - src: &DstLayout, - dst: &DstLayout, - ) -> Option> { - if src.align.get() < dst.align.get() { - return None; - } - - let inner = match (src.size_info, dst.size_info) { - ( - SizeInfo::Sized { size: src_size }, - SizeInfo::Sized { size: dst_size }, - ) => { - if src_size != dst_size { - return None; - } - - // SAFETY: We checked above that `src_size == - // dst_size`. - CastParamsInner::SizedToSized - } - (SizeInfo::Sized { size: src_size }, SizeInfo::SliceDst(dst)) => { - let offset_delta = if let Some(od) = src_size.checked_sub(dst.offset) { - od - } else { - return None; - }; - - let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { - e - } else { - return None; - }; - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let delta_mod_other_elem = offset_delta % dst_elem_size.get(); - - if delta_mod_other_elem != 0 { - return None; - } - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let dst_meta = offset_delta / dst_elem_size.get(); - - // SAFETY: The preceding math ensures that a `Dst` - // with `dst_meta` addresses `src_size` bytes. - CastParamsInner::SizedToUnsized { dst_meta } - } - (SizeInfo::SliceDst(src), SizeInfo::SliceDst(dst)) => { - let offset_delta = if let Some(od) = src.offset.checked_sub(dst.offset) - { - od - } else { - return None; - }; - - let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { - e - } else { - return None; - }; - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let delta_mod_other_elem = offset_delta % dst_elem_size.get(); - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let elem_remainder = src.elem_size % dst_elem_size.get(); - - if delta_mod_other_elem != 0 - || src.elem_size < dst.elem_size - || elem_remainder != 0 - { - return None; - } - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let offset_delta_elems = offset_delta / dst_elem_size.get(); - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let elem_multiple = src.elem_size / dst_elem_size.get(); - - CastParamsInner::UnsizedToUnsized { - // SAFETY: We checked above that this is an exact ratio. - offset_delta_elems, - // SAFETY: We checked above that this is an exact ratio. - elem_multiple, - } - } - _ => return None, - }; - - // SAFETY: We checked above that `src.align >= dst.align`. - Some(CastParams { inner, _src: PhantomData, _dst: PhantomData }) - } - } - - impl CastParams { - /// # Safety - /// - /// `src_meta` describes a `Src` whose size is no larger than - /// `isize::MAX`. - /// - /// The returned metadata describes a `Dst` of the same size as - /// the original `Src`. - #[inline(always)] - unsafe fn cast_metadata( - self, - src_meta: Src::PointerMetadata, - ) -> Dst::PointerMetadata { - #[allow(unused)] - use crate::util::polyfills::*; - - let dst_meta = match self.inner { - CastParamsInner::UnsizedToUnsized { offset_delta_elems, elem_multiple } => { - let src_meta = src_meta.to_elem_count(); - #[allow( - unstable_name_collisions, - clippy::multiple_unsafe_ops_per_block - )] - // SAFETY: `self` is a witness that the following - // equation holds: - // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM - // - // Since the caller promises that `src_meta` is - // valid `Src` metadata, this math will not - // overflow, and the returned value will describe a - // `Dst` of the same size. - unsafe { - offset_delta_elems - .unchecked_add(src_meta.unchecked_mul(elem_multiple)) - } - } - CastParamsInner::SizedToUnsized { dst_meta } => dst_meta, - CastParamsInner::SizedToSized => 0, - }; - Dst::PointerMetadata::from_elem_count(dst_meta) - } - } - - trait Params { - const CAST_PARAMS: CastParams; - } - - impl Params for Dst - where - Src: KnownLayout + ?Sized, - Dst: KnownLayout + ?Sized, - { - const CAST_PARAMS: CastParams = - match CastParams::try_compute(&Src::LAYOUT, &Dst::LAYOUT) { - Some(params) => params, - None => const_panic!( - "cannot `transmute_ref!` or `transmute_mut!` between incompatible types" - ), - }; - } - let src_meta = ::pointer_to_metadata(src.as_ptr()); - let params = >::CAST_PARAMS; + let params = CastParams::::CAST_PARAMS; // SAFETY: `src: PtrInner` guarantees that `src`'s referent is zero // bytes or lives in a single allocation, which means that it is no From e7c8ab5efb8761749e3d7b70d622e416e6349b43 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Thu, 1 Oct 2026 18:43:13 -0400 Subject: [PATCH 03/12] Verify doc-owned specifications over recursive mathematical models Associate spec-only Rust doc fences with their syntax owners and derive original binders from verified signatures. Recursively decode fields into nominal mathematical carriers; inline type models may refine those fields with a fallible decoder. Plain clauses and dependent ghosts use decoded values, while explicit raw clauses retain extracted representations. Freeze native providers and thread every original type parameter's dictionary explicitly. Successful execution must produce a decodable result shared by all postconditions. Check ordinary canonical proofs and independent expectations against both complete models. The initial scope covers four arithmetic helpers; broad Raw lemmas retain their representation domains. Preserve nominal tuple structs using a reproducible patch exposing the existing upstream option. Audit source snapshots, exact annotation ownership and axioms; retain source maps and a stable Lean development project. Define generic shapes solely over mathematical carrier types. Generate structural decoder decomposition with retained child witnesses. Complete only omitted proposition proofs at decoder construction sites, bounded by the caller heartbeat budget, and preserve native module exports. Protect edited Lean projections and inspect compiled effective specs. Add native Lean edit regions and guarded copy-back into owned Rust doc fences. Preserve unrelated edits and bytes, reject stale source or generated scaffolding changes, and recover interrupted source/baseline replacements. Validate native editor/copy/regeneration round trips at the first and final stack stages and preserve byte-identical CI assembly throughout the stack. Compare every inline contract with its independent expectation for arbitrary execution outcomes, then specialize to the canonical implementation proof. Record the complete design and deferred decisions in DESIGN.md. Derive fixed registration names from their owner and share nominal case assembly. Normalize source ownership once, retain generated lines with their locations, and reuse checked editing regions. Share file rosters while keeping independent source ownership and contract adequacy checks separate. Explain the pipeline and the purpose of its guards for readers new to the integration. Derive extraction roots solely from present annotations. Every discovered specification is mandatory in both builds; removing a specification and its proof deliberately removes that claim. No method roster or root marker is required. Preserve Unit decoding through reduction so total assertion harnesses use the same contract path as ordinary value-returning functions. Validation: fresh pinned extraction, complete golden comparison, separate golden/live builds, independent required contracts and axiom audits. gherrit-pr-id: Gf75ukj4vvtufg6dwjpfa3bx5a457sw74 Agent-authored-by: AI agent acting on joshlf's behalf --- .gitattributes | 4 + .github/workflows/ci.yml | 67 +- .gitignore | 22 + skills/zerocopy-review/SKILL.md | 38 + tools/Cargo.lock | 25 + tools/Cargo.toml | 1 + tools/aeneas-inline/Cargo.toml | 20 + tools/aeneas-inline/src/main.rs | 654 +++++++++ verification/aeneas/DESIGN.md | 318 ++++ verification/aeneas/README.md | 678 +++++++++ verification/aeneas/build-aeneas.nix | 34 + verification/aeneas/build-aeneas.sh | 63 + verification/aeneas/copy_back.py | 373 +++++ verification/aeneas/dev.sh | 86 ++ verification/aeneas/golden.py | 188 +++ verification/aeneas/golden/Funs.lean | 140 ++ .../aeneas/golden/FunsExternal_Template.lean | 52 + verification/aeneas/golden/Types.lean | 42 + .../aeneas/golden/TypesExternal_Template.lean | 42 + verification/aeneas/inline.py | 1059 ++++++++++++++ verification/aeneas/lean/Check.lean | 329 +++++ .../aeneas/lean/ContractAuditTests.lean | 113 ++ verification/aeneas/lean/ContractSimps.lean | 27 + verification/aeneas/lean/DeriveModels.lean | 507 +++++++ verification/aeneas/lean/InspectSpec.lean | 79 + verification/aeneas/lean/MathViews.lean | 96 ++ verification/aeneas/lean/ModelCompletion.lean | 86 ++ .../aeneas/lean/ModelCompletionTests.lean | 135 ++ .../aeneas/lean/ModelExportProducer.lean | 65 + .../aeneas/lean/ModelExportTests.lean | 43 + verification/aeneas/lean/ModelLemmas.lean | 169 +++ verification/aeneas/lean/ModelPrelude.lean | 122 ++ verification/aeneas/lean/ModelTests.lean | 294 ++++ verification/aeneas/lean/Obligations.lean | 74 + verification/aeneas/lean/Proofs.lean | 44 + verification/aeneas/lean/Proofs/Util.lean | 148 ++ .../aeneas/lean/RequiredContractTests.lean | 112 ++ .../aeneas/lean/RequiredContracts.lean | 198 +++ .../aeneas/lean/RequiredModelContracts.lean | 28 + .../lean/RequiredModelContracts/Util.lean | 81 ++ verification/aeneas/lean/RustModel.lean | 131 ++ verification/aeneas/lean/SpecPrelude.lean | 24 + verification/aeneas/lean/SpecsSyntax.lean | 398 +++++ .../aeneas/lean/SpecsSyntaxTests.lean | 251 ++++ verification/aeneas/lean/Zerocopy.lean | 19 + .../aeneas/lean/Zerocopy/FunsExternal.lean | 24 + .../aeneas/lean/Zerocopy/TypesExternal.lean | 20 + verification/aeneas/negative-controls.sh | 1286 +++++++++++++++++ .../aeneas/patches/use-tuple-structs.patch | 13 + verification/aeneas/prepare.py | 146 ++ verification/aeneas/run.sh | 104 ++ verification/aeneas/setup.sh | 76 + .../aeneas/tests/check_nominal_tuples.py | 28 + verification/aeneas/tests/nominal-tuples.sh | 61 + .../aeneas/tests/nominal-tuples/Cargo.lock | 7 + .../aeneas/tests/nominal-tuples/Cargo.toml | 15 + .../aeneas/tests/nominal-tuples/Check.lean | 50 + .../aeneas/tests/nominal-tuples/Default.lean | 19 + .../aeneas/tests/nominal-tuples/src/lib.rs | 95 ++ .../aeneas/tests/rounding_model_controls.py | 223 +++ verification/aeneas/tests/test_copy_back.py | 393 +++++ verification/aeneas/tests/test_golden.py | 97 ++ .../aeneas/tests/test_import_graph.py | 60 + verification/aeneas/tests/test_inline.py | 766 ++++++++++ verification/aeneas/tests/test_inspect.py | 79 + verification/aeneas/tests/test_prepare.py | 95 ++ .../tests/test_rounding_model_controls.py | 176 +++ verification/aeneas/tests/test_workspace.py | 154 ++ .../reject-raw-parameter.lean | 20 + verification/aeneas/toolchain.sh | 40 + verification/aeneas/workspace.py | 288 ++++ zerocopy/src/util/mod.rs | 21 + 72 files changed, 11834 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 tools/aeneas-inline/Cargo.toml create mode 100644 tools/aeneas-inline/src/main.rs create mode 100644 verification/aeneas/DESIGN.md create mode 100644 verification/aeneas/README.md create mode 100644 verification/aeneas/build-aeneas.nix create mode 100755 verification/aeneas/build-aeneas.sh create mode 100644 verification/aeneas/copy_back.py create mode 100644 verification/aeneas/dev.sh create mode 100644 verification/aeneas/golden.py create mode 100644 verification/aeneas/golden/Funs.lean create mode 100644 verification/aeneas/golden/FunsExternal_Template.lean create mode 100644 verification/aeneas/golden/Types.lean create mode 100644 verification/aeneas/golden/TypesExternal_Template.lean create mode 100644 verification/aeneas/inline.py create mode 100644 verification/aeneas/lean/Check.lean create mode 100644 verification/aeneas/lean/ContractAuditTests.lean create mode 100644 verification/aeneas/lean/ContractSimps.lean create mode 100644 verification/aeneas/lean/DeriveModels.lean create mode 100644 verification/aeneas/lean/InspectSpec.lean create mode 100644 verification/aeneas/lean/MathViews.lean create mode 100644 verification/aeneas/lean/ModelCompletion.lean create mode 100644 verification/aeneas/lean/ModelCompletionTests.lean create mode 100644 verification/aeneas/lean/ModelExportProducer.lean create mode 100644 verification/aeneas/lean/ModelExportTests.lean create mode 100644 verification/aeneas/lean/ModelLemmas.lean create mode 100644 verification/aeneas/lean/ModelPrelude.lean create mode 100644 verification/aeneas/lean/ModelTests.lean create mode 100644 verification/aeneas/lean/Obligations.lean create mode 100644 verification/aeneas/lean/Proofs.lean create mode 100644 verification/aeneas/lean/Proofs/Util.lean create mode 100644 verification/aeneas/lean/RequiredContractTests.lean create mode 100644 verification/aeneas/lean/RequiredContracts.lean create mode 100644 verification/aeneas/lean/RequiredModelContracts.lean create mode 100644 verification/aeneas/lean/RequiredModelContracts/Util.lean create mode 100644 verification/aeneas/lean/RustModel.lean create mode 100644 verification/aeneas/lean/SpecPrelude.lean create mode 100644 verification/aeneas/lean/SpecsSyntax.lean create mode 100644 verification/aeneas/lean/SpecsSyntaxTests.lean create mode 100644 verification/aeneas/lean/Zerocopy.lean create mode 100644 verification/aeneas/lean/Zerocopy/FunsExternal.lean create mode 100644 verification/aeneas/lean/Zerocopy/TypesExternal.lean create mode 100755 verification/aeneas/negative-controls.sh create mode 100644 verification/aeneas/patches/use-tuple-structs.patch create mode 100644 verification/aeneas/prepare.py create mode 100755 verification/aeneas/run.sh create mode 100755 verification/aeneas/setup.sh create mode 100644 verification/aeneas/tests/check_nominal_tuples.py create mode 100755 verification/aeneas/tests/nominal-tuples.sh create mode 100644 verification/aeneas/tests/nominal-tuples/Cargo.lock create mode 100644 verification/aeneas/tests/nominal-tuples/Cargo.toml create mode 100644 verification/aeneas/tests/nominal-tuples/Check.lean create mode 100644 verification/aeneas/tests/nominal-tuples/Default.lean create mode 100644 verification/aeneas/tests/nominal-tuples/src/lib.rs create mode 100644 verification/aeneas/tests/rounding_model_controls.py create mode 100644 verification/aeneas/tests/test_copy_back.py create mode 100644 verification/aeneas/tests/test_golden.py create mode 100644 verification/aeneas/tests/test_import_graph.py create mode 100644 verification/aeneas/tests/test_inline.py create mode 100644 verification/aeneas/tests/test_inspect.py create mode 100644 verification/aeneas/tests/test_prepare.py create mode 100644 verification/aeneas/tests/test_rounding_model_controls.py create mode 100644 verification/aeneas/tests/test_workspace.py create mode 100644 verification/aeneas/tests/type-only-shapes/reject-raw-parameter.lean create mode 100644 verification/aeneas/toolchain.sh create mode 100644 verification/aeneas/workspace.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..aebbd2f623 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Preserve complete generated Lean snapshots, including literal bytes. Aeneas's +# pretty printer can emit trailing code spaces; the conservative comparison and +# independent proof builds check these files instead of hand-formatting them. +verification/aeneas/golden/*.lean whitespace=-blank-at-eol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed995dd69b..3e623db968 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1043,6 +1043,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 @@ -1053,7 +1118,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..7c563ccf7e --- /dev/null +++ b/tools/aeneas-inline/src/main.rs @@ -0,0 +1,654 @@ +// 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, + 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_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(), + attributes: Vec::new(), + }; + visitor.visit_file(&syntax); + Ok(json!({"source": source, "comments": comments, "functions": visitor.functions, + "implementations": visitor.implementations, "types": visitor.types, + "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 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..458c5e0753 --- /dev/null +++ b/verification/aeneas/DESIGN.md @@ -0,0 +1,318 @@ + + +# 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. + +## 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..14539a6d16 --- /dev/null +++ b/verification/aeneas/README.md @@ -0,0 +1,678 @@ + + +# 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. +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. + +## 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..c7e70d50fd --- /dev/null +++ b/verification/aeneas/inline.py @@ -0,0 +1,1059 @@ +#!/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.extracted_types = 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 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_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] + '::': + raise ValueError('Unsupported generated inherent method identity') + 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'), + 'file': source['file'], 'syntax': source['syntax'], + 'open_line': source['open_line'], 'end_line': source['end_line'], 'end_col': source['end_col']} + manifest = {'version': 3, 'bindings': bindings, + '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. + """ + 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}') + + 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 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 + + +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..7693b7ea04 --- /dev/null +++ b/verification/aeneas/lean/Check.lean @@ -0,0 +1,329 @@ +/- 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 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 + 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}" + -- Independently inspect the complete compiled raw type image. Removing both + -- a derivation and its manifest entry cannot hide an extracted nominal owner. + -- Aeneas also emits trait dictionaries as inductives. Their own extraction + -- metadata identifies them; excluding by a guessed name pattern could hide + -- an ordinary Rust type that still needs a model binding. + let traitCarriers := (Aeneas.Extract.rustTraitDecls.ext.getState env).filterMap + fun (_, _, info) => info.extract.map String.toName + let mut actualTypes : Array Name := #[] + for (constName, constInfo) in env.constants.toList do + if (`Zerocopy).isPrefixOf constName && env.getModuleIdxFor? constName == some typesModule && + !traitCarriers.any (fun carrier => constName == carrier || constName == (`Zerocopy).append carrier) then + if let .inductInfo _ := constInfo then + actualTypes := actualTypes.push constName + let missingTypes := actualTypes.filter (!expectedTypes.contains ·) + let extraTypes := expectedTypes.filter (!actualTypes.contains ·) + unless missingTypes.isEmpty && extraTypes.isEmpty do + throwError "Raw nominal types disagree with source bindings: 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 types/read meanings were + -- checked above; 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..9d3f5c928b --- /dev/null +++ b/verification/aeneas/lean/SpecPrelude.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 + +/-! +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..f3569660f9 --- /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" \ + 'Raw nominal types disagree with source bindings: missing .*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" \ + 'Raw nominal types disagree with source bindings: missing .*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..acccde8731 --- /dev/null +++ b/verification/aeneas/prepare.py @@ -0,0 +1,146 @@ +#!/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): + """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", + }] + 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' + '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()) + + +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..86e1c8bb25 --- /dev/null +++ b/verification/aeneas/tests/test_inline.py @@ -0,0 +1,766 @@ +# 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_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([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_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..9be43f15d8 --- /dev/null +++ b/verification/aeneas/tests/test_prepare.py @@ -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. + +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('') + prepare.share_manifest(work, backend) + config = (work / 'lakefile.lean').read_text() + 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 a8a56a3b0e..e2e62da7a9 100644 --- a/zerocopy/src/util/mod.rs +++ b/zerocopy/src/util/mod.rs @@ -152,6 +152,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)] @@ -233,6 +238,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, @@ -255,6 +266,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 @@ -265,6 +281,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 From 5a84ff2ab471e9f6b3e1f0020412d3f4f6c7c8df Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 09:40:40 -0400 Subject: [PATCH 04/12] Check native total and partial Aeneas specifications Keep independent native examples for successful returns, named assumptions and tuple results. Total contracts reject panic and divergence; partial contracts admit divergence while rejecting panic and incorrect successful returns. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. gherrit-pr-id: Gmasez6fvx2c6chogoybuzpgyoykiaupc Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/lean/ContractTests.lean | 111 ++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 verification/aeneas/lean/ContractTests.lean diff --git a/verification/aeneas/lean/ContractTests.lean b/verification/aeneas/lean/ContractTests.lean new file mode 100644 index 0000000000..83fc943d03 --- /dev/null +++ b/verification/aeneas/lean/ContractTests.lean @@ -0,0 +1,111 @@ +/- 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 + +/-! +These kernel-checked examples explain Aeneas's native total and partial +contracts independently of our spec macro. They pin successful results, tuple +postconditions, panic rejection, and divergence behavior. The independently +written theorem types prevent a change in syntax from silently changing the +claim. Total contracts require successful execution even for a True +postcondition. Partial contracts may diverge, but still reject panic. +-/ +open Aeneas Aeneas.Std AeneasSpecs +namespace ContractTests + +-- Omitted requirements mean True; a successful result is still required. +theorem identity_spec (x : Nat) : + Result.ok x + ⦃ ret => ret = x ⦄ := by + exact WP.spec.ret rfl + +-- Named requirements are available without introductions in the proof body. +theorem transitive_spec (x y z : Nat) + (hxy : x = y) + (hyz : y = z) : + Result.ok x + ⦃ ret => ret = z ⦄ := by + exact WP.spec.ret (hxy.trans hyz) + +-- Reuse Aeneas's tuple-aware binders for returns and mutable post-states. +theorem tuple_spec (x : Nat) : + Result.ok (x, x + 1) + ⦃ ret x' => ret = x ∧ x' = x + 1 ⦄ := by + simp + +theorem diverging_spec : + (Result.div : Result Nat) + ⦃ _ => False ⦄div := by + exact WP.dspec.div _ + +theorem partial_identity_spec (x : Nat) : + Result.ok x + ⦃ ret => ret = x ⦄div := by + exact WP.dspec.ret rfl + +-- These independently written types pin the exported theorem types and outcomes. +example : ∀ x : Nat, WP.spec (Result.ok x) (fun ret => ret = x) := identity_spec +example : ∀ x y z : Nat, x = y → y = z → + WP.spec (Result.ok x) (fun ret => ret = z) := transitive_spec +example : ∀ x : Nat, WP.spec (Result.ok (x, x + 1)) + (fun (ret, x') => ret = x ∧ x' = x + 1) := tuple_spec +example : WP.dspec (Result.div : Result Nat) (fun _ => False) := diverging_spec +example : ∀ x : Nat, WP.dspec (Result.ok x) (fun ret => ret = x) := partial_identity_spec + +theorem panic_rejected : + ¬ WP.spec (Result.fail Error.panic : Result Nat) (fun _ => True) := by simp + +theorem divergence_rejected : + ¬ WP.spec (Result.div : Result Nat) (fun _ => True) := by simp + +-- Partial correctness must reject panic and incorrect successful returns too. +theorem partial_panic_rejected : + ¬ WP.dspec (Result.fail Error.panic : Result Nat) (fun _ => True) := by simp + +theorem partial_wrong_return_rejected : + ¬ WP.dspec (Result.ok 0) (fun ret : Nat => ret = 1) := by simp + +-- A generic identity shares one explicit dictionary and the same witness on +-- input and output, even when the dictionary rejects some representations. +theorem modeled_identity {Raw : Type} (provider : RustModel Raw) (raw : Raw) + (value : provider.Model) (h : provider.decode raw = some value) : + WP.spec (Result.ok raw) (fun out => + ∃ decoded, provider.decode out = some decoded ∧ decoded = value) := + WP.spec.ret ⟨value, h, rfl⟩ + +-- A dependent ghost retains its exact domain in mathematical input scope. +theorem modeled_position {Raw : Type} (provider : RustModel Raw) + (length : provider.Model → Nat) (raw : Raw) (value : provider.Model) + (h : provider.decode raw = some value) (position : Fin (length value)) : + WP.spec (Result.ok raw) (fun out => + ∃ decoded, provider.decode out = some decoded ∧ position.val < length decoded) := + WP.spec.ret ⟨value, h, position.isLt⟩ + +-- An accepted execution cannot return a decoder-rejected representation, even +-- when its authored postcondition contributes no information. +theorem rejected_output_rejected {Raw : Type} (provider : RustModel Raw) + (raw : Raw) (h : provider.decode raw = none) : + ¬ WP.spec (Result.ok raw) (fun out => + ∃ decoded, provider.decode out = some decoded ∧ True) := by + simp [WP.spec_ok, h] + +theorem partial_rejected_output_rejected {Raw : Type} (provider : RustModel Raw) + (raw : Raw) (h : provider.decode raw = none) : + ¬ WP.dspec (Result.ok raw) (fun out => + ∃ decoded, provider.decode out = some decoded ∧ True) := by + simp [h] + +-- Rust absence and Rust errors remain successful decoded constructor cases. +example : (@modelOption Usize (modelUScalar .Usize)).decode none = some none := rfl +example : (@modelRustResult Usize Unit (modelUScalar .Usize) modelUnit).decode + (.Err ()) = some (.Err ()) := rfl + +end ContractTests From bc7fdcca888a3a87021714debc2c005e71cd27c2 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Thu, 1 Oct 2026 22:49:50 -0400 Subject: [PATCH 05/12] Strengthen arithmetic specifications and prove arithmetic corollaries Prove exact extrema, least padding and the greatest aligned predecessor. Preserve broad Raw arithmetic results and derive recursive mathematical models contracts; compose extrema and round-down corollaries. Layout padding is introduced with its normalized implementation in the later padding proof layer. Add total Rust assertion harnesses comparing padding and round-down with independent division/remainder calculations, including usize boundary values. Keep the comparisons and their power-of-two guard visible in Rust. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. gherrit-pr-id: Gxi6wprpgshggdrhvt64jphmjwhociog5 Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/README.md | 16 +- verification/aeneas/golden/Funs.lean | 52 +++-- verification/aeneas/lean/Arithmetic.lean | 178 ++++++++++++++++++ verification/aeneas/lean/Corollaries.lean | 85 +++++++++ verification/aeneas/lean/Obligations.lean | 44 +++-- .../aeneas/lean/Proofs/ArithmeticChecks.lean | 54 ++++++ verification/aeneas/lean/Proofs/Util.lean | 154 +++++++++------ .../lean/RequiredModelContracts/Util.lean | 64 +++++-- zerocopy/src/util/checks.rs | 61 ++++++ zerocopy/src/util/mod.rs | 23 ++- 10 files changed, 621 insertions(+), 110 deletions(-) create mode 100644 verification/aeneas/lean/Arithmetic.lean create mode 100644 verification/aeneas/lean/Corollaries.lean create mode 100644 verification/aeneas/lean/Proofs/ArithmeticChecks.lean create mode 100644 zerocopy/src/util/checks.rs diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md index 14539a6d16..8bbe739070 100644 --- a/verification/aeneas/README.md +++ b/verification/aeneas/README.md @@ -112,6 +112,8 @@ zerocopy's unsafe pointer operations are sound. ## Scope and proofs +Rust review entry points: [arithmetic assertions](../../zerocopy/src/util/checks.rs). + 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 @@ -120,10 +122,10 @@ 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. | +| `max` | Returns the mathematical maximum, selects an input, and bounds both inputs from above. | +| `min` | Returns the mathematical minimum, selects an input, and bounds both inputs from below. | +| `padding_needed_for` | For power-of-two alignment, returns padding below it and exactly `(align - len % align) % align`, the least padding making the sum aligned, with zero padding exactly when the input is aligned. | +| `round_down_to_next_multiple_of_alignment` | For power-of-two alignment, returns exactly `n - n % align`, the greatest aligned value at most `n`; the next multiple exceeds `n`. | Every registered function uses total `spec`: accepted raw representations, supplied mathematical ghosts, and explicit requirements imply successful @@ -131,9 +133,9 @@ 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. +`Corollaries.lean` composes the arithmetic proofs: extrema preserve a shared +predicate, and round-down is monotone, aligned and idempotent under its stated +conditions. Plain arithmetic clauses use mathematical word values carrying machine bounds and NonZero positivity. Their Nat/Int arithmetic does not wrap; explicit raw diff --git a/verification/aeneas/golden/Funs.lean b/verification/aeneas/golden/Funs.lean index 66e3008882..ded6b8df2a 100644 --- a/verification/aeneas/golden/Funs.lean +++ b/verification/aeneas/golden/Funs.lean @@ -64,8 +64,25 @@ def Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner : innerCopyInst := core.num.niche_types.NonZeroUsizeInner.Insts.CoreMarkerCopy } +/-- [zerocopy::util::round_down_to_next_multiple_of_alignment]: + Source: 'src/util/mod.rs', lines 260:0-278: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::padding_needed_for]: - Source: 'src/util/mod.rs', lines 160:0-220:1 -/ + Source: 'src/util/mod.rs', lines 168:0-228:1 -/ def util.padding_needed_for (len : Std.Usize) (align : core.num.nonzero.NonZero Std.Usize @@ -80,25 +97,32 @@ def util.padding_needed_for 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) +/-- [zerocopy::util::checks::check_arithmetic]: + Source: 'src/util/checks.rs', lines 38:0-46:1 -/ +def util.checks.check_arithmetic + (len : Std.Usize) (align : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner) : - Result Std.Usize + Result Unit := do - let align1 ← + let i ← 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) + let b ← core.num.Usize.is_power_of_two i + if b + then + let remainder ← len % i + let i1 ← i - remainder + let padding ← i1 % i + let i2 ← util.padding_needed_for len align + massert (i2 = padding) + let i3 ← util.round_down_to_next_multiple_of_alignment len align + let i4 ← len - remainder + massert (i3 = i4) + else ok () /-- [zerocopy::util::max]: - Source: 'src/util/mod.rs', lines 274:0-280:1 -/ + Source: 'src/util/mod.rs', lines 288:0-294:1 -/ def util.max (a : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner) @@ -118,7 +142,7 @@ def util.max else ok a /-- [zerocopy::util::min]: - Source: 'src/util/mod.rs', lines 289:0-295:1 -/ + Source: 'src/util/mod.rs', lines 304:0-310:1 -/ def util.min (a : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner) diff --git a/verification/aeneas/lean/Arithmetic.lean b/verification/aeneas/lean/Arithmetic.lean new file mode 100644 index 0000000000..3abb9ac72f --- /dev/null +++ b/verification/aeneas/lean/Arithmetic.lean @@ -0,0 +1,178 @@ +/- 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 +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +/-! +Rust implements alignment operations with bounded words and bit masks. The +contracts describe those results with unbounded natural numbers. This module +proves the arithmetic facts needed to connect the two descriptions: why +clearing low bits rounds down, why the padding mask yields the least aligned +increment, and why wrapping at zero does not invalidate that increment. It +contains no claims about the control flow of a zerocopy function; Proofs.Util +applies these facts while stepping through the extracted implementations. +-/ +open Aeneas Aeneas.Std +namespace Zerocopy.Arithmetic + +/-- Scalar minimum agrees with mathematical minimum. -/ +theorem coe_min (a b : Usize) : + ((min a b : Usize) : Nat) = Nat.min (a : Nat) (b : Nat) := by + by_cases h : a ≤ b + · rw [min_eq_left h] + exact (Nat.min_eq_left ((UScalar.le_equiv _ _).mp h)).symm + · have hba : b ≤ a := le_of_not_ge h + rw [min_eq_right hba] + exact (Nat.min_eq_right ((UScalar.le_equiv _ _).mp hba)).symm + +/-- The unique bounded padding that makes the sum divisible by alignment. -/ +theorem padding_unique (len align p : Nat) (hpos : 0 < align) + (hlt : p < align) (haligned : (len + p) % align = 0) : + p = (align - len % align) % align := by + have hr := Nat.mod_lt len hpos + have hsum : (len % align + p) % align = 0 := by + rw [Nat.add_mod, Nat.mod_eq_of_lt hlt] at haligned + exact haligned + by_cases hzero : len % align = 0 + · simpa only [hzero, Nat.zero_add, Nat.mod_eq_of_lt hlt, + Nat.sub_zero, Nat.mod_self] using hsum + · have hge : align ≤ len % align + p := by + by_contra h + rw [Nat.mod_eq_of_lt (by omega)] at hsum + omega + rw [Nat.mod_eq_sub_mod hge, Nat.mod_eq_of_lt (by omega)] at hsum + rw [Nat.mod_eq_of_lt (by omega)] + omega + +/-- Exact padding also gives minimality and the zero-padding criterion. -/ +theorem padding_properties (len align p : Nat) (hpos : 0 < align) + (hlt : p < align) (haligned : (len + p) % align = 0) : + p = (align - len % align) % align ∧ + (∀ q : Nat, (len + q) % align = 0 → p ≤ q) ∧ + (p = 0 ↔ len % align = 0) := by + refine ⟨padding_unique len align p hpos hlt haligned, ?_, ?_⟩ + · intro q hq + have hqm : (len + q % align) % align = 0 := by + rw [Nat.add_mod] at hq + rw [Nat.add_mod, Nat.mod_mod] + exact hq + have heq := padding_unique len align (q % align) hpos + (Nat.mod_lt q hpos) hqm + have hp := padding_unique len align p hpos hlt haligned + have := Nat.mod_le q align + omega + · constructor + · intro hp + simpa only [hp, Nat.add_zero] using haligned + · intro hl + have hp := padding_unique len align p hpos hlt haligned + simpa only [hl, Nat.sub_zero, Nat.mod_self] using hp + +/-- Clearing and retaining a bit mask partition the original word exactly. -/ +theorem mask_partition (n mask : Usize) : + (n &&& ~~~mask).val + (n &&& mask).val = n.val := by + have hdisjoint : (n.bv &&& ~~~mask.bv) &&& (n.bv &&& mask.bv) = 0 := by + ext i + simp only [BitVec.getElem_and, BitVec.getElem_not] + cases n.bv[i] <;> cases mask.bv[i] <;> simp + have hjoin : (n.bv &&& ~~~mask.bv) ||| (n.bv &&& mask.bv) = n.bv := by + ext i + simp only [BitVec.getElem_or, BitVec.getElem_and, BitVec.getElem_not] + cases n.bv[i] <;> cases mask.bv[i] <;> decide + have hsum := BitVec.add_eq_or_of_and_eq_zero _ _ hdisjoint + rw [hjoin] at hsum + have := congrArg BitVec.toNat hsum + rw [BitVec.toNat_add_of_and_eq_zero hdisjoint] at this + exact this + +/-- A power-of-two alignment divides the word modulus. -/ +theorem alignment_dvd_size (align : Usize) (h : align.val.isPowerOfTwo) : + align.val ∣ UScalar.size .Usize := by + obtain ⟨k, hk⟩ := h + have hsize := UScalar.hSize align + rw [UScalar.size, hk] at hsize ⊢ + exact Nat.pow_dvd_pow 2 ((Nat.pow_le_pow_iff_right (a := 2) (by decide)).mp (by omega)) + +/-- Complementing the wrapping predecessor produces modular negation. -/ +theorem complement_predecessor (len : Usize) : + (~~~(core.num.Usize.wrapping_sub len 1#usize)).val = + if len.val = 0 then 0 else UScalar.size .Usize - len.val := by + change (~~~(len.bv - (1#usize).bv)).toNat = _ + rw [BitVec.toNat_not] + have hsize := UScalar.hSize len + have hone : (1#usize).val = 1 := by simp + by_cases hz : len.val = 0 + · simp only [hz, if_true] + rw [BitVec.toNat_sub, UScalar.bv_toNat, UScalar.bv_toNat] + change 2 ^ System.Platform.numBits - 1 - + ((2 ^ System.Platform.numBits - (1#usize).val) + len.val) % 2 ^ System.Platform.numBits = 0 + rw [hz, hone, Nat.add_zero, Nat.mod_eq_of_lt (by have := Nat.two_pow_pos System.Platform.numBits; omega)] + omega + · simp only [hz, if_false] + rw [BitVec.toNat_sub_of_le (by simpa only [BitVec.le_def, UScalar.bv_toNat, hone] using (show 1 ≤ len.val by omega)), UScalar.bv_toNat, UScalar.bv_toNat, hone] + change 2 ^ System.Platform.numBits - 1 - (len.val - 1) = UScalar.size .Usize - len.val + rw [UScalar.size] at hsize ⊢ + change len.val < 2 ^ System.Platform.numBits at hsize + change 2 ^ System.Platform.numBits - 1 - (len.val - 1) = + 2 ^ System.Platform.numBits - len.val + omega + +/-- The padding bit mask aligns the unbounded sum, including wrapping at zero. -/ +theorem padding_mask_aligned (len align mask : Usize) + (h : align.val.isPowerOfTwo) (hm : mask.val = align.val - 1) : + (len.val + (~~~(core.num.Usize.wrapping_sub len 1#usize) &&& mask).val) + % align.val = 0 := by + have hmod := Nat.mod_eq_zero_of_dvd (alignment_dvd_size align h) + have hsum : (len.val + (~~~(core.num.Usize.wrapping_sub len 1#usize)).val) + % align.val = 0 := by + rw [complement_predecessor] + split + · rename_i hz + simp only [hz, Nat.zero_add, Nat.zero_mod] + · have hsize := UScalar.hSize len + rw [Nat.add_sub_of_le (by omega)] + exact hmod + obtain ⟨k, hk⟩ := h + rw [UScalar.val_and, hm, hk, Nat.and_two_pow_sub_one_eq_mod] + rw [hk, Nat.add_mod] at hsum + rw [Nat.add_mod, Nat.mod_mod] + exact hsum + +/-- The exact arithmetic result of clearing the low alignment bits. -/ +theorem round_down_exact (n align mask : Usize) + (h : align.val.isPowerOfTwo) (hm : mask.val = align.val - 1) : + (n &&& ~~~mask).val = n.val - n.val % align.val := by + have hp := mask_partition n mask + have hlow : (n &&& mask).val = n.val % align.val := by + obtain ⟨k, hk⟩ := h + rw [UScalar.val_and, hm, hk, Nat.and_two_pow_sub_one_eq_mod] + rw [hlow] at hp + omega + +/-- An exact round-down result is aligned and the greatest such lower bound. -/ +theorem round_down_properties (n align m : Nat) (hpos : 0 < align) + (hm : m = n - n % align) : + m ≤ n ∧ m % align = 0 ∧ n < m + align ∧ + (∀ q : Nat, q ≤ n → q % align = 0 → q ≤ m) := by + have hr := Nat.mod_lt n hpos + have hrle := Nat.mod_le n align + have hmul : m = (n / align) * align := by + rw [Nat.div_mul_self_eq_mod_sub_self, hm] + refine ⟨by omega, ?_, by omega, ?_⟩ + · rw [hmul, Nat.mul_mod_left] + · intro q hqn hqa + have hdiv := Nat.div_le_div_right (c := align) hqn + have hle := Nat.mul_le_mul_right align hdiv + rw [Nat.div_mul_self_eq_mod_sub_self, Nat.div_mul_self_eq_mod_sub_self, + hqa, Nat.sub_zero, ← hm] at hle + exact hle + +end Zerocopy.Arithmetic diff --git a/verification/aeneas/lean/Corollaries.lean b/verification/aeneas/lean/Corollaries.lean new file mode 100644 index 0000000000..edab313551 --- /dev/null +++ b/verification/aeneas/lean/Corollaries.lean @@ -0,0 +1,85 @@ +/- 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 Proofs +public import Aeneas +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +/-! +A function contract is most useful when other proofs can consume it. These +corollaries compose the checked arithmetic operation lemmas into further +numeric consequences. They reuse the proved computation and discharge its +stated requirements, rather than proving a different calculation in its +place. + +The results remain conditional functional guarantees. They do not establish +pointer safety or prove that every Rust caller meets the contract's +requirements. +-/ +open Aeneas Aeneas.Std Aeneas.Std.Result +namespace Zerocopy.Corollaries +open Proofs Proofs.Raw + + +theorem max_preserves (a b : NonZeroUsize) (P : NonZeroUsize → Prop) + (ha : P a) + (hb : P b) : + util.max a b + ⦃ r => P r ⦄ := by + step with Proofs.Raw.max_spec a b as ⟨r, _, hchoice, _, _⟩ + rcases hchoice with h | h + · simpa only [h] using ha + · simpa only [h] using hb + +theorem min_preserves (a b : NonZeroUsize) (P : NonZeroUsize → Prop) + (ha : P a) + (hb : P b) : + util.min a b + ⦃ r => P r ⦄ := by + step with Proofs.Raw.min_spec a b as ⟨r, _, hchoice, _, _⟩ + rcases hchoice with h | h + · simpa only [h] using ha + · simpa only [h] using hb + +theorem round_down_aligned (n : Usize) (align : NonZeroUsize) + (hpow : (align.val : Nat).isPowerOfTwo) + (haligned : (n : Nat) % (align.val : Nat) = 0) : + util.round_down_to_next_multiple_of_alignment n align = .ok n := by + obtain ⟨m, hm, _, hexact, _, _, _⟩ := + WP.spec_imp_exists (Proofs.Raw.round_down_spec n align hpow) + have heq : m = n := UScalar.eq_of_val_eq (by + simpa only [haligned, Nat.sub_zero] using hexact) + simpa only [heq] using hm + +theorem round_down_idempotent (n : Usize) (align : NonZeroUsize) + (hpow : (align.val : Nat).isPowerOfTwo) : + (do + let m ← util.round_down_to_next_multiple_of_alignment n align + let m' ← util.round_down_to_next_multiple_of_alignment m align + ok (m, m')) + ⦃ m m' => m' = m ⦄ := by + step with Proofs.Raw.round_down_spec n align hpow as ⟨m, _, _, haligned, _, _⟩ + rw [round_down_aligned m align hpow haligned] + simp only [bind_ok, WP.spec_ok, WP.uncurry'_pair] + +theorem round_down_monotone (a b : Usize) (align : NonZeroUsize) + (hab : a ≤ b) + (hpow : (align.val : Nat).isPowerOfTwo) : + (do + let m ← util.round_down_to_next_multiple_of_alignment a align + let m' ← util.round_down_to_next_multiple_of_alignment b align + ok (m, m')) + ⦃ m m' => m ≤ m' ⦄ := by + step with Proofs.Raw.round_down_spec a align hpow as ⟨m, hbound, _, haligned, _, _⟩ + step with Proofs.Raw.round_down_spec b align hpow as ⟨m', _, _, _, _, hgreatest⟩ + apply (UScalar.le_equiv _ _).mpr + exact hgreatest m.val ((UScalar.le_equiv _ _).mp (le_trans hbound hab)) haligned + +end Zerocopy.Corollaries diff --git a/verification/aeneas/lean/Obligations.lean b/verification/aeneas/lean/Obligations.lean index c95854b591..e4d32f4ce8 100644 --- a/verification/aeneas/lean/Obligations.lean +++ b/verification/aeneas/lean/Obligations.lean @@ -10,6 +10,7 @@ module public import Zerocopy.Funs public import SpecPrelude public import MathViews +public import Arithmetic @[expose] public section /-! @@ -33,9 +34,9 @@ abbrev NonZeroUsize := 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 + 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 ∧ (r = a ∨ r = b) ∧ + a.val.val ≤ r.val.val ∧ b.val.val ≤ r.val.val def max_spec : Prop := ∀ (a b : NonZeroUsize), @@ -43,18 +44,22 @@ def max_spec : Prop := 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 + 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 ∧ (r = a ∨ r = b) ∧ + r.val.val ≤ a.val.val ∧ r.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) +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 ⦄ + ∀ (_valid : 0 < align.val.val) (_h : align.val.val.isPowerOfTwo), + run ⦃ p => p.val < align.val.val ∧ + p.val = (align.val.val - len.val % align.val.val) % align.val.val ∧ + (len.val + p.val) % align.val.val = 0 ∧ + (∀ q : Nat, (len.val + q) % align.val.val = 0 → p.val ≤ q) ∧ + (p.val = 0 ↔ len.val % align.val.val = 0) ⦄ def padding_lt_alignment : Prop := ∀ (len : Usize) (align : NonZeroUsize), @@ -63,8 +68,10 @@ def padding_lt_alignment : Prop := 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 ⦄ + run ⦃ m => + m.val ≤ n.val ∧ m.val = n.val - n.val % align.val.val ∧ + m.val % align.val.val = 0 ∧ n.val < m.val + align.val.val ∧ + (∀ q : Nat, q ≤ n.val → q % align.val.val = 0 → q ≤ m.val) ⦄ def round_down_spec : Prop := ∀ (n : Usize) (align : NonZeroUsize), @@ -72,3 +79,18 @@ def round_down_spec : Prop := end Zerocopy.Obligations + +namespace Zerocopy.Obligations + +-- The source harness promises successful execution, including its assertions. +-- The positive premise records the Rust NonZeroUsize input domain independently +-- of the generated specification's decoding expansion. +def arithmetic_checks_spec_contract (_len : Usize) (align : NonZeroUsize) + (run : Result Unit) : Prop := + 0 < align.val.val → run ⦃ _ => True ⦄ + +def arithmetic_checks_spec : Prop := + ∀ (len : Usize) (align : NonZeroUsize), + arithmetic_checks_spec_contract len align (util.checks.check_arithmetic len align) + +end Zerocopy.Obligations diff --git a/verification/aeneas/lean/Proofs/ArithmeticChecks.lean b/verification/aeneas/lean/Proofs/ArithmeticChecks.lean new file mode 100644 index 0000000000..596f49d20e --- /dev/null +++ b/verification/aeneas/lean/Proofs/ArithmeticChecks.lean @@ -0,0 +1,54 @@ +/- 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 Proofs.Util +@[expose] public section + +/-! +The Rust assertions are the application promise. These lemmas connect the +extracted remainder calculations to the already proved bitwise helpers. The +canonical theorem establishes successful execution of the entire harness, +including both assertions, for every accepted input. +-/ +open Aeneas Aeneas.Std +namespace Zerocopy.Proofs.Raw + +theorem arithmetic_checks (len : Usize) (align : NonZeroUsize) : + util.checks.check_arithmetic len align ⦃ _ => True ⦄ := by + unfold util.checks.check_arithmetic + simp only [core.num.nonzero.NonZero.get, bind_ok] + step as ⟨pow2, hp⟩ + split + · rename_i enabled + have power : align.val.val.isPowerOfTwo := by + exact Eq.mp hp enabled + have positive := Nat.pos_of_isPowerOfTwo power + step as ⟨remainder, hr⟩ + step as ⟨gap, hg⟩ + step as ⟨padding, hpadding⟩ + step with padding_lt_alignment len align power as ⟨actual, _, exactPadding, _⟩ + step + step with round_down_spec len align power as ⟨rounded, _, exactRounded, _⟩ + step as ⟨expected, hexpected⟩ + · rw [hr] + exact Nat.mod_le _ _ + · step + · simp only [WP.spec_ok] + +end Zerocopy.Proofs.Raw + +namespace Zerocopy.Proofs + +theorem arithmetic_checks_spec : Specs.arithmetic_checks_spec := by + intro len align _ _ _ _ + apply WP.spec_mono (Raw.arithmetic_checks len align) + intro result _ + exact ⟨(), rfl, trivial⟩ + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/Proofs/Util.lean b/verification/aeneas/lean/Proofs/Util.lean index 61cb530625..b4556233db 100644 --- a/verification/aeneas/lean/Proofs/Util.lean +++ b/verification/aeneas/lean/Proofs/Util.lean @@ -20,101 +20,117 @@ 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. +preconditions as proof goals. Pure scalar facts belong in Arithmetic; these +proofs connect those facts to the specific zerocopy implementations. -/ open Aeneas Aeneas.Std namespace Zerocopy.Proofs.Raw +-- Raw contract requirements intentionally retain their descriptive proof names. 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 + ∀ (len : Usize) (align : NonZeroUsize), ∀ (h : ((align.val : Nat).isPowerOfTwo : Prop)), + @Zerocopy.util.padding_needed_for len align ⦃ p => p < align.val ∧ + let L : Nat := len + let A : Nat := align.val + let P : Nat := p + P = (A - L % A) % A ∧ (L + P) % A = 0 ∧ + (∀ q : Nat, (L + q) % A = 0 → P ≤ q) ∧ (P = 0 ↔ L % A = 0) ⦄ := by intro len align h + have hpos := Nat.pos_of_isPowerOfTwo 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 + simp only [lift, bind_ok, WP.spec_ok] + have hbound : (~~~(core.num.Usize.wrapping_sub len 1#usize) &&& mask).val + < align.val.val := by + have := Nat.and_le_right (n := (~~~(core.num.Usize.wrapping_sub len 1#usize)).val) + (m := mask.val) + rw [UScalar.val_and] + omega + have haligned := Arithmetic.padding_mask_aligned len align.val mask h (by omega) + obtain ⟨hexact, hminimal, hzero⟩ := + Arithmetic.padding_properties _ _ _ hpos hbound haligned + exact ⟨(UScalar.lt_equiv _ _).mpr hbound, hexact, haligned, hminimal, hzero⟩ 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 + ∀ (n : Usize) (align : NonZeroUsize), ∀ (h : ((align.val : Nat).isPowerOfTwo : Prop)), + @Zerocopy.util.round_down_to_next_multiple_of_alignment n align ⦃ m => m ≤ n ∧ + let N : Nat := n + let A : Nat := align.val + let M : Nat := m + M = N - N % A ∧ M % A = 0 ∧ N < M + A ∧ + (∀ q : Nat, q ≤ N → q % A = 0 → q ≤ M) ⦄ := by + intro n align h + have hpos := Nat.pos_of_isPowerOfTwo 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 + have hexact := Arithmetic.round_down_exact n align.val mask h hval + obtain ⟨hbound, haligned, hnext, hgreatest⟩ := + Arithmetic.round_down_properties _ _ _ hpos hexact + exact ⟨(UScalar.le_equiv _ _).mpr hbound, hexact, haligned, hnext, hgreatest⟩ theorem max_spec : ∀ (a b : NonZeroUsize), - @Zerocopy.util.max a b ⦃ r => r.val.val = Nat.max a.val.val b.val.val ⦄ := by + @Zerocopy.util.max a b ⦃ r => r.val = max a.val b.val ∧ (r = a ∨ r = b) ∧ + a.val ≤ r.val ∧ b.val ≤ r.val ⦄ := by intro a b - apply WP.exists_imp_spec - simp only [util.max, core.num.nonzero.NonZero.get, bind_ok, UScalar.lt_equiv] + simp only [util.max, core.num.nonzero.NonZero.get, bind_ok] split · rename_i h - exact ⟨b, rfl, (Nat.max_eq_right (by omega)).symm⟩ + exact WP.spec.ret ⟨(max_eq_right (le_of_lt h)).symm, Or.inr rfl, + le_of_lt h, le_rfl⟩ · rename_i h - exact ⟨a, rfl, (Nat.max_eq_left (by omega)).symm⟩ + exact WP.spec.ret ⟨(max_eq_left (le_of_not_gt h)).symm, Or.inl rfl, + le_rfl, le_of_not_gt h⟩ theorem min_spec : ∀ (a b : NonZeroUsize), - @Zerocopy.util.min a b ⦃ r => r.val.val = Nat.min a.val.val b.val.val ⦄ := by + @Zerocopy.util.min a b ⦃ r => r.val = min a.val b.val ∧ (r = a ∨ r = b) ∧ + r.val ≤ a.val ∧ r.val ≤ b.val ⦄ := by intro a b - apply WP.exists_imp_spec - simp only [util.min, core.num.nonzero.NonZero.get, bind_ok, UScalar.lt_equiv] + simp only [util.min, core.num.nonzero.NonZero.get, bind_ok] split · rename_i h - exact ⟨b, rfl, (Nat.min_eq_right (by omega)).symm⟩ + exact WP.spec.ret ⟨(min_eq_right (le_of_lt h)).symm, Or.inr rfl, + le_of_lt h, le_rfl⟩ · rename_i h - exact ⟨a, rfl, (Nat.min_eq_left (by omega)).symm⟩ + exact WP.spec.ret ⟨(min_eq_left (le_of_not_gt h)).symm, Or.inl rfl, + le_rfl, le_of_not_gt h⟩ 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 + intro len align lenValue hlen alignValue halign h + have hl := (decodeUScalar_iff len lenValue).mp hlen 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, ?_⟩ + apply WP.spec_mono (Raw.padding_lt_alignment len align (by simpa only [ha] using h)) + intro p facts + refine ⟨unsignedWord p, rfl, ?_⟩ dsimp only - rw [← ha] - exact facts + rw [← hl, ← ha] + exact ⟨(UScalar.lt_equiv _ _).mp facts.1, facts.2⟩ register_spec_step padding_lt_alignment theorem round_down_spec : Zerocopy.Specs.round_down_spec := by - intro n align nValue hn alignValue halign pow2 + intro n align nValue hn alignValue halign h 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, ?_⟩ + apply WP.spec_mono (Raw.round_down_spec n align (by simpa only [ha] using h)) + intro m facts + refine ⟨unsignedWord m, rfl, ?_⟩ dsimp only rw [← hnv, ← ha] - exact facts + exact ⟨(UScalar.le_equiv _ _).mp facts.1, facts.2⟩ register_spec_step round_down_spec theorem max_spec : Zerocopy.Specs.max_spec := by @@ -122,13 +138,22 @@ theorem max_spec : Zerocopy.Specs.max_spec := by 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 + intro r facts + rcases facts.2.1 with hr | hr + · subst r + refine ⟨av, ha, ?_⟩ + dsimp only + rw [← hav, ← hbv] + exact ⟨by simpa only [UScalar.coe_max] using congrArg UScalar.val facts.1, + Or.inl rfl, (UScalar.le_equiv _ _).mp facts.2.2.1, + (UScalar.le_equiv _ _).mp facts.2.2.2⟩ + · subst r + refine ⟨bv, hb, ?_⟩ + dsimp only + rw [← hav, ← hbv] + exact ⟨by simpa only [UScalar.coe_max] using congrArg UScalar.val facts.1, + Or.inr rfl, (UScalar.le_equiv _ _).mp facts.2.2.1, + (UScalar.le_equiv _ _).mp facts.2.2.2⟩ register_spec_step max_spec theorem min_spec : Zerocopy.Specs.min_spec := by @@ -136,13 +161,22 @@ theorem min_spec : Zerocopy.Specs.min_spec := by 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 + intro r facts + rcases facts.2.1 with hr | hr + · subst r + refine ⟨av, ha, ?_⟩ + dsimp only + rw [← hav, ← hbv] + exact ⟨(Nat.min_eq_left ((UScalar.le_equiv _ _).mp facts.2.2.2)).symm, + Or.inl rfl, (UScalar.le_equiv _ _).mp facts.2.2.1, + (UScalar.le_equiv _ _).mp facts.2.2.2⟩ + · subst r + refine ⟨bv, hb, ?_⟩ + dsimp only + rw [← hav, ← hbv] + exact ⟨(Nat.min_eq_right ((UScalar.le_equiv _ _).mp facts.2.2.1)).symm, + Or.inr rfl, (UScalar.le_equiv _ _).mp facts.2.2.1, + (UScalar.le_equiv _ _).mp facts.2.2.2⟩ register_spec_step min_spec end Zerocopy.Proofs diff --git a/verification/aeneas/lean/RequiredModelContracts/Util.lean b/verification/aeneas/lean/RequiredModelContracts/Util.lean index 674014acc6..ee8f971efc 100644 --- a/verification/aeneas/lean/RequiredModelContracts/Util.lean +++ b/verification/aeneas/lean/RequiredModelContracts/Util.lean @@ -9,6 +9,7 @@ those terms. -/ module public import Obligations public import Specs +public import MathViews @[expose] public section /-! @@ -23,17 +24,21 @@ 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. +attribute [contract_simps] option_admitted_explicit_iff admitted_some admitted_none + admitted_dite + +-- These implications consume a supplied model contract and recover the +-- separately authored representation observations. They do not prove or +-- select a function contract themselves. @[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 + intro positive pow2 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 + apply WP.spec_mono (provided (unsignedWord len) rfl av admitted pow2) + rintro p ⟨value, decoded, facts⟩ + have same := (decodeUScalar_iff p value).mp decoded simpa only [unsignedWord, av, ← same] using facts @[contract_simps] theorem required_round_down (n : Usize) (align : NonZeroUsize) @@ -47,6 +52,12 @@ namespace Zerocopy.Proofs have same := (decodeUScalar_iff output value).mp decoded simpa only [unsignedWord, av, ← same] using facts +theorem nonzero_raw_eq_of_value_eq (a b : NonZeroUsize) + (same : a.val.val = b.val.val) : a = b := by + have hs := UScalar.eq_of_val_eq same + cases a; cases b + congr + @[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 @@ -57,11 +68,16 @@ namespace Zerocopy.Proofs 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, ?_, ?_⟩ + obtain ⟨r, call, value, decoded, facts⟩ := result + have same := (decodeNonZeroUScalar_iff r value).mp decoded + refine ⟨r, call, ?_, ?_, ?_, ?_, ?_⟩ · rw [same]; exact value.positive - · simpa only [av, bv, unsignedWord, ← same] using facts + · simpa only [av, bv, unsignedWord, ← same] using facts.1 + · rcases facts.2.1 with h | h + · left; apply nonzero_raw_eq_of_value_eq; simp only [same, h, av, unsignedWord] + · right; apply nonzero_raw_eq_of_value_eq; simp only [same, h, bv, unsignedWord] + · simpa only [av, unsignedWord, ← same] using facts.2.2.1 + · simpa only [bv, unsignedWord, ← same] using facts.2.2.2 @[contract_simps] theorem required_min (a b : NonZeroUsize) (run : Result NonZeroUsize) (provided : Specs.min_spec_contract a b run) : @@ -73,9 +89,29 @@ namespace Zerocopy.Proofs 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, ?_, ?_⟩ + obtain ⟨r, call, value, decoded, facts⟩ := result + have same := (decodeNonZeroUScalar_iff r value).mp decoded + refine ⟨r, call, ?_, ?_, ?_, ?_, ?_⟩ · rw [same]; exact value.positive - · simpa only [av, bv, unsignedWord, ← same] using facts + · simpa only [av, bv, unsignedWord, ← same] using facts.1 + · rcases facts.2.1 with h | h + · left; apply nonzero_raw_eq_of_value_eq; simp only [same, h, av, unsignedWord] + · right; apply nonzero_raw_eq_of_value_eq; simp only [same, h, bv, unsignedWord] + · simpa only [av, unsignedWord, ← same] using facts.2.2.1 + · simpa only [bv, unsignedWord, ← same] using facts.2.2.2 + +end Zerocopy.Proofs + +namespace Zerocopy.Proofs + +@[contract_simps] theorem required_arithmetic_checks (len : Usize) (align : NonZeroUsize) + (run : Result Unit) (provided : Specs.arithmetic_checks_spec_contract len align run) : + Obligations.arithmetic_checks_spec_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) + intro _ _ + trivial + end Zerocopy.Proofs diff --git a/zerocopy/src/util/checks.rs b/zerocopy/src/util/checks.rs new file mode 100644 index 0000000000..38147e3db4 --- /dev/null +++ b/zerocopy/src/util/checks.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BSD-2-Clause OR Apache-2.0 OR MIT +// +// Copyright 2026 The Fuchsia Authors +// +// Licensed under the 2-Clause BSD 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. + +//! Rust assertions describe the arithmetic promises checked by Aeneas. +//! +//! A total specification with postcondition `True` still requires successful +//! termination: every assertion must pass. The independent calculations below +//! use division and remainder, rather than the production bitwise algorithms. +//! The module is part of the ordinary library so extraction sees exactly the +//! source reviewed here. Its private functions have no runtime callers outside +//! tests; optimized builds can discard them. + +// Read the integer explicitly so extraction uses the ordinary scalar remainder +// operation. The proof checks that these guarded arithmetic operations are safe. +#![allow(dead_code, clippy::needless_nonzero_get)] + +use core::num::NonZeroUsize; + +/// Checks padding and rounding down for every length and supported alignment. +/// +/// The explicit guard describes the domain: these helpers require a +/// power-of-two alignment. There is no bound on `len` beyond its Rust type. +/// Neither comparison adds padding to `len`, so `usize::MAX` remains covered. +/// +/// ```aeneas +/// spec arithmetic_checks_spec +/// ensures _ => True +/// ``` +#[allow(clippy::arithmetic_side_effects)] +fn check_arithmetic(len: usize, align: NonZeroUsize) { + if !align.get().is_power_of_two() { + return; + } + let remainder = len % align.get(); + let padding = (align.get() - remainder) % align.get(); + assert!(super::padding_needed_for(len, align) == padding); + assert!(super::round_down_to_next_multiple_of_alignment(len, align) == len - remainder); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arithmetic_boundaries() { + for align in [1, 2, 4, 8, 1 << (usize::BITS - 1), 3] { + let align = NonZeroUsize::new(align).unwrap(); + for len in [0, 1, 7, 8, 9, usize::MAX - 1, usize::MAX] { + check_arithmetic(len, align); + } + } + } +} diff --git a/zerocopy/src/util/mod.rs b/zerocopy/src/util/mod.rs index e2e62da7a9..28ccd53bbe 100644 --- a/zerocopy/src/util/mod.rs +++ b/zerocopy/src/util/mod.rs @@ -14,6 +14,8 @@ pub(crate) mod macros; #[doc(hidden)] pub mod macro_util; +mod checks; + use core::{ marker::PhantomData, mem::{self, ManuallyDrop}, @@ -155,7 +157,13 @@ pub(crate) fn validate_aligned_to(t: T) -> Result<(), Alignment /// /// ```aeneas /// spec padding_lt_alignment -/// ensures p => (p : Nat) < (align : Nat) +/// requires h : (align : Nat).isPowerOfTwo +/// ensures p => (p : Nat) < (align : Nat) ∧ +/// let L : Nat := len +/// let A : Nat := align +/// let P : Nat := p +/// P = (A - L % A) % A ∧ (L + P) % A = 0 ∧ +/// (∀ q : Nat, (L + q) % A = 0 → P ≤ q) ∧ (P = 0 ↔ L % A = 0) /// ``` pub(crate) const fn padding_needed_for(len: usize, align: NonZeroUsize) -> usize { #[cfg(kani)] @@ -242,7 +250,12 @@ pub(crate) const fn padding_needed_for(len: usize, align: NonZeroUsize) -> usize /// ```aeneas /// spec round_down_spec /// requires h : (align : Nat).isPowerOfTwo -/// ensures m => (m : Nat) ≤ (n : Nat) ∧ (m : Nat) % (align : Nat) = 0 +/// ensures m => (m : Nat) ≤ (n : Nat) ∧ +/// let N : Nat := n +/// let A : Nat := align +/// let M : Nat := m +/// M = N - N % A ∧ M % A = 0 ∧ N < M + A ∧ +/// (∀ q : Nat, q ≤ N → q % A = 0 → q ≤ M) /// ``` pub(crate) const fn round_down_to_next_multiple_of_alignment( n: usize, @@ -269,7 +282,8 @@ pub(crate) const fn round_down_to_next_multiple_of_alignment( /// /// ```aeneas /// spec max_spec -/// ensures r => (r : Nat) = Nat.max (a : Nat) (b : Nat) +/// ensures r => (r : Nat) = max (a : Nat) (b : Nat) ∧ (r = a ∨ r = b) ∧ +/// (a : Nat) ≤ (r : Nat) ∧ (b : Nat) ≤ (r : Nat) /// ``` pub(crate) const fn max(a: NonZeroUsize, b: NonZeroUsize) -> NonZeroUsize { if a.get() < b.get() { @@ -284,7 +298,8 @@ pub(crate) const fn max(a: NonZeroUsize, b: NonZeroUsize) -> NonZeroUsize { /// /// ```aeneas /// spec min_spec -/// ensures r => (r : Nat) = Nat.min (a : Nat) (b : Nat) +/// ensures r => (r : Nat) = min (a : Nat) (b : Nat) ∧ (r = a ∨ r = b) ∧ +/// (r : Nat) ≤ (a : Nat) ∧ (r : Nat) ≤ (b : Nat) /// ``` pub(crate) const fn min(a: NonZeroUsize, b: NonZeroUsize) -> NonZeroUsize { if a.get() > b.get() { From 345e102a18fb825d9bbc8e8f86070f83539c9eba Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 08:30:22 -0400 Subject: [PATCH 06/12] Reuse Aeneas specifications for views and indexed loops Add mathematical-view composition and an indexed-loop adapter using upstream specifications. Independent native examples check successful outcomes, overflow and the loop progress requirements. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. gherrit-pr-id: Gjr7bhllf5hkdhoipwvk4k5zbqa5q7qs7 Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/README.md | 38 ++++++ verification/aeneas/lean/Loops.lean | 57 +++++++++ verification/aeneas/lean/Proofs.lean | 1 + verification/aeneas/lean/SupportTests.lean | 141 +++++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 verification/aeneas/lean/Loops.lean create mode 100644 verification/aeneas/lean/SupportTests.lean diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md index 8bbe739070..c6cbce44bc 100644 --- a/verification/aeneas/README.md +++ b/verification/aeneas/README.md @@ -554,6 +554,44 @@ 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. +## Mathematical views and indexed loops + +### Arithmetic, mathematical views, and indexed loops + +Checked addition, subtraction, and multiplication already have upstream +`step_pure` specifications. Use `step as ⟨result, facts⟩` and split the `Option` +result to obtain both the exact successful value and the overflow condition. +`SupportTests.lean` exercises all inputs, including overflow. + +A contract may express equality through a pure mathematical view: + +```lean +theorem operation_spec (input : Input) (h : valid input) : + operation input ⦃ result => + view result = mathematicalOperation (view input) ∧ canonical result ⦄ := by + ... +``` + +This ordinary theorem states the existing total WP postcondition directly. +A view-only theorem states just the view equality. Partial correctness uses +`⦄div` in place of `⦄`: it explicitly permits divergence and still excludes +failure. These are Aeneas's existing specification operators. Use `WP.spec_mono` +to adapt an existing result specification to a view, and register useful caller +specifications with `attribute [step] operation_spec`. The existing Aeneas +registry then lets callers use `step` without specifying the theorem manually. + +`AeneasContracts.indexed_loop_spec` specializes Aeneas's `loop.spec_decr_nat` +to a state and `Usize` index. Supply a view, the mathematical value of each +prefix, and a representation invariant. Each continuing body step must advance +the index by exactly one and establish the next prefix; a completed step must +be at the length. The adapter supplies bounds and the decreasing `length - index` +termination measure. `SupportTests.lean` contains an independent example. + +The specification syntax, examples, and required-contract proof terms +participate in the axiom audit. Failure controls challenge an incorrect view, +divergence under a total view contract, a stationary loop index, an incorrect +prefix step, and continuing past the loop bound. + ## Updating models and fuzzy comparison `golden/` stores all four complete generated Aeneas modules, including types, diff --git a/verification/aeneas/lean/Loops.lean b/verification/aeneas/lean/Loops.lean new file mode 100644 index 0000000000..300d1bc5a0 --- /dev/null +++ b/verification/aeneas/lean/Loops.lean @@ -0,0 +1,57 @@ +/- 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 + +/-! +Aeneas already proves loop reasoning rules. This adapter specializes its +decreasing-natural rule to the indexed loops used by layout construction. The +caller supplies a prefix model and proves one body step. The adapter uses +length minus index as the termination measure and carries both the model +relation and the caller's representation invariant across every step. This +avoids reproving termination separately for each indexed operation. +-/ +open Aeneas Aeneas.Std + +namespace AeneasContracts + +/-- An indexed Rust loop refines a mathematical prefix computation. The +caller proves one body step: continue advances the index by one, while done +occurs at the length. This rule supplies the index bounds and termination +measure, and carries an arbitrary representation invariant. -/ +theorem indexed_loop_spec {State Model : Type} + (length : Nat) (view : State → Model) (prefixValue : Nat → Model) + (valid : State → Prop) + (body : State × Usize → Result (ControlFlow (State × Usize) State)) + (hbody : ∀ state i, i.val ≤ length → view state = prefixValue i.val → valid state → + body (state, i) ⦃ r => match r with + | .cont (next, j) => i.val < length ∧ j.val = i.val + 1 ∧ + view next = prefixValue j.val ∧ valid next + | .done out => i.val = length ∧ view out = prefixValue i.val ∧ valid out ⦄) + (state : State) (i : Usize) + (hi : i.val ≤ length) (hv : view state = prefixValue i.val) (hc : valid state) : + loop body (state, i) ⦃ out => view out = prefixValue length ∧ valid out ⦄ := by + apply loop.spec_decr_nat + (fun s : State × Usize => length - s.2.val) + (fun s => s.2.val ≤ length ∧ view s.1 = prefixValue s.2.val ∧ valid s.1) + · rintro ⟨state, i⟩ ⟨hi, hv, hc⟩ + apply WP.spec_mono (hbody state i hi hv hc) + intro r hr + cases r with + | done out => + obtain ⟨hend, hv, hc⟩ := hr + exact ⟨hend ▸ hv, hc⟩ + | cont s => + rcases s with ⟨next, j⟩ + obtain ⟨hlt, hj, hv, hc⟩ := hr + exact ⟨⟨by scalar_tac, hv, hc⟩, by scalar_tac⟩ + · exact ⟨hi, hv, hc⟩ + +end AeneasContracts diff --git a/verification/aeneas/lean/Proofs.lean b/verification/aeneas/lean/Proofs.lean index fc494fe6b0..1dbd3c3835 100644 --- a/verification/aeneas/lean/Proofs.lean +++ b/verification/aeneas/lean/Proofs.lean @@ -10,6 +10,7 @@ module public import Specs public import Proofs.Util public import Aeneas +public import Loops import all Mathlib.Data.Nat.Log import all Init.Data.Nat.Power2.Basic @[expose] public section diff --git a/verification/aeneas/lean/SupportTests.lean b/verification/aeneas/lean/SupportTests.lean new file mode 100644 index 0000000000..3d00b1ac35 --- /dev/null +++ b/verification/aeneas/lean/SupportTests.lean @@ -0,0 +1,141 @@ +/- 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 import Loops +public import RequiredContracts +@[expose] public section + +/-! +These examples exercise arithmetic, mathematical-view composition, and +indexed-loop reasoning as ordinary consumers of the shared proof support. +They test how the abstractions are used together, rather than merely +repeating their definitions. The independently written expectations keep +termination, returned values, and loop-state preservation observable. +-/ +open Aeneas Aeneas.Std AeneasContracts AeneasSpecs +namespace SupportTests + +-- Use upstream checked-operation registrations, including overflow branches. +theorem add_spec (x y : Usize) : + lift (x.checked_add y) + ⦃ out => out.map UScalar.val = + if x.val + y.val ≤ Usize.max then some (x.val + y.val) else none ⦄ := by + step as ⟨out, hout⟩ + cases out <;> simp_all only [Option.map_none, Option.map_some] + all_goals scalar_tac +split + +theorem sub_spec (x y : Usize) : + lift (x.checked_sub y) + ⦃ out => out.map UScalar.val = + if y ≤ x then some (x.val - y.val) else none ⦄ := by + step as ⟨out, hout⟩ + cases out <;> simp_all only [Option.map_none, Option.map_some] + all_goals scalar_tac +split + +theorem mul_spec (x y : Usize) : + lift (x.checked_mul y) + ⦃ out => out.map UScalar.val = + if x.val * y.val ≤ Usize.max then some (x.val * y.val) else none ⦄ := by + step as ⟨out, hout⟩ + cases out <;> simp_all only [Option.map_none, Option.map_some] + all_goals scalar_tac +split + +def pair (n : Nat) : Result (Nat × Nat) := .ok (n, n + 1) + +theorem pair_view_spec (n : Nat) : + pair n + ⦃ r => Prod.fst r = n ∧ r.2 = n + 1 ⦄ := by + exact WP.spec.ret ⟨rfl, rfl⟩ + +attribute [step] pair_view_spec + +-- A caller uses the view theorem through Aeneas's existing spec registry. +theorem pair_caller_spec (n : Nat) : + (do let r ← pair n; Result.ok r.1) + ⦃ r => id r = n ⦄ := by + step* + simp_all + +theorem diverging_view_spec : + (Result.div : Result Nat) + ⦃ r => Nat.succ r = 0 ∧ r = 9 ⦄div := by + exact WP.dspec.div _ + +theorem partial_view_spec (n : Nat) : + Result.ok n + ⦃ r => Nat.succ r = n + 1 ⦄div := by + exact WP.dspec.ret rfl + +-- The independent family compares every outcome, even though `pair` itself +-- happens to return the expected pair for every input. +spec pair_inline for pair with 0 type parameters + ensures (first, second) => first = n ∧ second = n + 1 + +theorem pair_inline_proof : pair_inline := by + intro n math decoded + change some n = some math at decoded + cases decoded + exact ⟨(n, n + 1), rfl, rfl, rfl⟩ + +def pair_required_contract (n : Nat) (run : Result (Nat × Nat)) : Prop := + ∃ r, run = .ok r ∧ r.1 = n ∧ r.2 = n + 1 + +def pair_required : Prop := ∀ n : Nat, pair_required_contract n (pair n) + +@[contract_simps] theorem pair_required_bridge (n : Nat) (run : Result (Nat × Nat)) + (provided : pair_inline_contract n run) : pair_required_contract n run := by + obtain ⟨out, success, math, decoded, first, second⟩ := + (WP.spec_equiv_exists _ _).mp (provided n rfl) + have same : out = math := by + simpa [RustModel.decode, modelProd] using decoded + cases same + exact ⟨out, success, first, second⟩ + +check_contract pair_required using pair_inline_proof + +-- An alias still names its own independent family and canonical inline proof. +def pair_required_again_contract := pair_required_contract +def pair_required_again : Prop := pair_required +@[contract_simps] theorem pair_required_again_bridge + (n : Nat) (run : Result (Nat × Nat)) (provided : pair_inline_contract n run) : + pair_required_again_contract n run := pair_required_bridge n run provided +check_contract pair_required_again using pair_inline_proof + +example : ∀ n : Nat, WP.spec (do let r ← pair n; Result.ok r.1) + (fun r => r = n) := pair_caller_spec +example : WP.dspec (Result.div : Result Nat) + (fun r => Nat.succ r = 0 ∧ r = 9) := diverging_view_spec +example : ∀ n : Nat, WP.dspec (Result.ok n) + (fun r => Nat.succ r = n + 1) := partial_view_spec + +def counterBody (n : Usize) (s : Nat × Usize) : + Result (ControlFlow (Nat × Usize) Nat) := do + if s.2 < n then + let j ← s.2 + 1#usize + .ok (.cont (s.1 + 1, j)) + else .ok (.done s.1) + +theorem counter_spec (n i : Usize) (hi : i ≤ n) : + loop (counterBody n) (i.val, i) ⦃ out => out = n.val ∧ True ⦄ := by + apply indexed_loop_spec n.val id (fun i => i) (fun _ => True) + · intro state idx hidx hv _ + unfold counterBody + simp only [UScalar.lt_equiv] + split + · rename_i hlt + step as ⟨j, hj⟩ + simp_all [id] + · rename_i hdone + exact WP.spec.ret ⟨by scalar_tac, hv, trivial⟩ + · exact (UScalar.le_equiv _ _).mp hi + · rfl + · trivial + +end SupportTests From a3a994cbfb0689b333261c14f677b21f52b5a2f0 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 03:18:31 -0400 Subject: [PATCH 07/12] Prove trailing-layout normalization and capacity arithmetic Develop unbounded size, alignment and capacity formulas and prove their normalization laws. Keep arithmetic assumptions explicit and independent of generated function proofs. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. gherrit-pr-id: Geshtqcmx24d4schhoccr2bfxlmhdz4yi Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/README.md | 2 + verification/aeneas/lean/Arithmetic.lean | 11 + verification/aeneas/lean/LayoutMath.lean | 328 +++++++++++++++++++++++ verification/aeneas/lean/Proofs.lean | 1 + 4 files changed, 342 insertions(+) create mode 100644 verification/aeneas/lean/LayoutMath.lean diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md index c6cbce44bc..ed97477493 100644 --- a/verification/aeneas/README.md +++ b/verification/aeneas/README.md @@ -137,6 +137,8 @@ lemmas retain useful representation-level domains. predicate, and round-down is monotone, aligned and idempotent under its stated conditions. +`LayoutMath.lean` supplies independent unbounded size and capacity formulas. + 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 diff --git a/verification/aeneas/lean/Arithmetic.lean b/verification/aeneas/lean/Arithmetic.lean index 3abb9ac72f..64a7f5c38c 100644 --- a/verification/aeneas/lean/Arithmetic.lean +++ b/verification/aeneas/lean/Arithmetic.lean @@ -33,6 +33,17 @@ theorem coe_min (a b : Usize) : rw [min_eq_right hba] exact (Nat.min_eq_right ((UScalar.le_equiv _ _).mp hba)).symm + +/-- Scalar maximum agrees with mathematical maximum. -/ +theorem coe_max (a b : Usize) : + ((max a b : Usize) : Nat) = Nat.max (a : Nat) (b : Nat) := by + by_cases h : a ≤ b + · rw [max_eq_right h] + exact (Nat.max_eq_right ((UScalar.le_equiv _ _).mp h)).symm + · have hba : b ≤ a := le_of_not_ge h + rw [max_eq_left hba] + exact (Nat.max_eq_left ((UScalar.le_equiv _ _).mp hba)).symm + /-- The unique bounded padding that makes the sum divisible by alignment. -/ theorem padding_unique (len align p : Nat) (hpos : 0 < align) (hlt : p < align) (haligned : (len + p) % align = 0) : diff --git a/verification/aeneas/lean/LayoutMath.lean b/verification/aeneas/lean/LayoutMath.lean new file mode 100644 index 0000000000..053e65e585 --- /dev/null +++ b/verification/aeneas/lean/LayoutMath.lean @@ -0,0 +1,328 @@ +/- 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 Arithmetic +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +/-! +This module proves unbounded layout mathematics independently of extracted +Rust bodies. Formula describes a normalized complete-size calculation, and +its lemmas characterize padding and the greatest fitting trailing-byte count. +Machine words and the operation proofs use those facts at a separate +boundary, where the relevant positivity and overflow conditions must be +established. This algebra alone does not establish correspondence with a Rust +compiler. +-/ +namespace Zerocopy.LayoutMath + +/-- Mathematical ceiling to an alignment, without machine overflow. -/ +def roundUp (n a : Nat) : Nat := n + (a - n % a) % a + +theorem roundUp_properties (n a : Nat) (ha : 0 < a) : + n ≤ roundUp n a ∧ roundUp n a < n + a ∧ roundUp n a % a = 0 := by + have hr := Nat.mod_lt n ha + have hp := Nat.mod_lt (a - n % a) ha + refine ⟨by unfold roundUp; omega, by unfold roundUp; omega, ?_⟩ + unfold roundUp + by_cases hz : n % a = 0 + · simp [hz] + · have hp' : (a - n % a) % a = a - n % a := + Nat.mod_eq_of_lt (by omega) + rw [Nat.add_mod, Nat.mod_mod, hp'] + rw [show n % a + (a - n % a) = a by omega, Nat.mod_self] + +theorem roundUp_le (n a q : Nat) (ha : 0 < a) + (hn : n ≤ q) (hq : q % a = 0) : roundUp n a ≤ q := by + have hp := (roundUp_properties n a ha).2.2 + have hbound := Nat.mod_lt (a - n % a) ha + have hq' : (n + (q - n)) % a = 0 := by + simpa only [Nat.add_sub_of_le hn] using hq + have h := (Arithmetic.padding_properties n a _ ha hbound hp).2.1 _ hq' + unfold roundUp + omega + +theorem roundUp_eq (n a : Nat) (h : n % a = 0) : roundUp n a = n := by + simp [roundUp, h] + +theorem roundUp_mono {n m a : Nat} (ha : 0 < a) (h : n ≤ m) : + roundUp n a ≤ roundUp m a := by + have hm := roundUp_properties m a ha + exact roundUp_le n a _ ha (by omega) hm.2.2 + +theorem roundUp_shift (n k a : Nat) (hk : k % a = 0) : + roundUp (k + n) a = k + roundUp n a := by + simp only [roundUp, Nat.add_mod, hk, Nat.zero_add, Nat.mod_mod] + omega + +theorem roundUp_compose (n a b : Nat) (ha : 0 < a) (hb : 0 < b) + (hab : a ∣ b) : roundUp (roundUp n a) b = roundUp n b := by + have hn := roundUp_properties n a ha + have hnb := roundUp_properties n b hb + have hba : roundUp n b % a = 0 := + Nat.mod_eq_zero_of_dvd (dvd_trans hab (Nat.dvd_of_mod_eq_zero hnb.2.2)) + have hle := roundUp_le n a _ ha hnb.1 hba + apply Nat.le_antisymm + · exact roundUp_le _ b _ hb hle hnb.2.2 + · exact roundUp_mono hb hn.1 + +/-- Move a smaller inner ceiling across an addition before a larger ceiling. -/ +theorem roundUp_swap (c x a b : Nat) (ha : 0 < a) (hb : 0 < b) + (hab : a ∣ b) : + roundUp (c + roundUp x a) b = roundUp (roundUp c a + x) b := by + have hc := (roundUp_properties c a ha).2.2 + have hx := (roundUp_properties x a ha).2.2 + calc + roundUp (c + roundUp x a) b = roundUp (roundUp (c + roundUp x a) a) b := + (roundUp_compose _ a b ha hb hab).symm + _ = roundUp (roundUp c a + roundUp x a) b := by + rw [Nat.add_comm c, roundUp_shift c _ a hx, Nat.add_comm (roundUp x a)] + _ = roundUp (roundUp (roundUp c a + x) a) b := by + rw [roundUp_shift x _ a hc] + _ = roundUp (roundUp c a + x) b := roundUp_compose _ a b ha hb hab + +theorem roundUp_inner (c x a b : Nat) (ha : 0 < a) (hba : b ∣ a) : + roundUp (c + roundUp x a) b = roundUp c b + roundUp x a := by + have hx : roundUp x a % b = 0 := Nat.mod_eq_zero_of_dvd + (dvd_trans hba (Nat.dvd_of_mod_eq_zero (roundUp_properties x a ha).2.2)) + rw [Nat.add_comm c, roundUp_shift c _ b hx, Nat.add_comm] + +theorem normalize_phase (c x a : Nat) : + (c - c % a) + roundUp (c % a + x) a = roundUp (c + x) a := by + have hm := Nat.mod_le c a + have hk : (c - c % a) % a = 0 := by + rw [← Nat.div_mul_self_eq_mod_sub_self, Nat.mul_mod_left] + rw [← roundUp_shift _ _ a hk] + congr 1 + omega + +theorem power_alignments (i j : Nat) : + if i ≤ j then 2 ^ i ∣ 2 ^ j else 2 ^ j ∣ 2 ^ i := by + split + · exact Nat.pow_dvd_pow 2 ‹i ≤ j› + · exact Nat.pow_dvd_pow 2 (by omega) + +/-- Normal form used only as the target of the refinement proof. -/ +/- A normalized complete-size calculation: base + roundUp (phase + n * elem) +align. Physical offset is retained separately, because it need not equal the +normalized base. Normalization replaces recursive rounding only after proving +equivalence. +-/ +structure Formula where + base : Nat + phase : Nat + align : Nat + elem : Nat + offset : Nat +deriving DecidableEq + +/- Require a positive rounding alignment and a phase below it. This condition +describes the normalized formula; it does not assert physical realizability +of a completed Rust type. +-/ +def Formula.valid (f : Formula) : Prop := 0 < f.align ∧ f.phase < f.align +/- Evaluate complete size for an arbitrary number of trailing bytes, retaining +the inner alignment rounding. Capacity is first computed in this byte domain. +-/ +def Formula.bytes (f : Formula) (b : Nat) := f.base + roundUp (f.phase + b) f.align +/- Convert metadata (an element count) to trailing bytes before evaluating the +complete size. All arithmetic here is unbounded. +-/ +def Formula.size (f : Formula) (n : Nat) := f.bytes (n * f.elem) + +theorem Formula.size_mono (f : Formula) (ha : 0 < f.align) {n m : Nat} (h : n ≤ m) : + f.size n ≤ f.size m := by + unfold Formula.size Formula.bytes + exact Nat.add_le_add_left (roundUp_mono ha + (Nat.add_le_add_left (Nat.mul_le_mul_right f.elem h) f.phase)) f.base + +/- For positive alignments where the smaller divides the larger, normalize an +additional outer rounding step. When the outer alignment is larger, move +completed inner rounding into base and keep the residual phase; otherwise +preserve inner rounding and align base. pad_size proves this conditional +rule. +-/ +def Formula.pad (f : Formula) (outer : Nat) : Formula := + if f.align < outer then + let fixed := roundUp f.base f.align + f.phase + { f with base := fixed - fixed % outer, phase := fixed % outer, align := outer } + else { f with base := roundUp f.base outer } + +theorem pad_size (f : Formula) (n outer : Nat) (hf : 0 < f.align) + (ho : 0 < outer) + (hd : if f.align < outer then f.align ∣ outer else outer ∣ f.align) : + (f.pad outer).size n = roundUp (f.size n) outer := by + unfold Formula.pad Formula.size Formula.bytes + split <;> rename_i h + · simp only [h, if_true] at hd + dsimp only + rw [normalize_phase] + rw [roundUp_swap _ _ _ _ hf ho hd] + congr 1 + omega + · simp only [h, if_false] at hd + dsimp only + exact (roundUp_inner _ _ _ _ hf hd).symm + +theorem pad_valid (f : Formula) (outer : Nat) (hf : f.valid) (ho : 0 < outer) : + (f.pad outer).valid := by + unfold Formula.pad Formula.valid + split + · exact ⟨ho, Nat.mod_lt _ ho⟩ + · exact hf + +theorem pad_completed (f : Formula) (outer : Nat) (ho : 0 < outer) : + outer ≤ (f.pad outer).align ∧ (f.pad outer).base % outer = 0 := by + unfold Formula.pad + split <;> rename_i h + · dsimp only + refine ⟨by omega, ?_⟩ + rw [← Nat.div_mul_self_eq_mod_sub_self, Nat.mul_mod_left] + · dsimp only + exact ⟨by omega, (roundUp_properties _ _ ho).2.2⟩ + +/- Move the logical beginning by b bytes and replace element stride with e. +Carry the aligned portion into base and retain the remainder as phase, so the +complete-size calculation survives the change of coordinates. +-/ +def Formula.advance (f : Formula) (b e : Nat) : Formula := + let shifted := f.phase + b + { f with base := f.base + shifted - shifted % f.align, + phase := shifted % f.align, elem := e } + +theorem advance_size (f : Formula) (b e n : Nat) : + (f.advance b e).size n = f.base + roundUp (f.phase + b + n * e) f.align := by + have hm := Nat.mod_le (f.phase + b) f.align + unfold Formula.advance Formula.size Formula.bytes + dsimp only + rw [show f.base + (f.phase + b) - (f.phase + b) % f.align = + f.base + ((f.phase + b) - (f.phase + b) % f.align) by omega] + rw [Nat.add_assoc, normalize_phase] + +theorem aligned_element_size (f : Formula) (n : Nat) (he : f.elem % f.align = 0) : + f.size n = f.size 0 + n * f.elem := by + have hm : (n * f.elem) % f.align = 0 := by simp [Nat.mul_mod, he] + unfold Formula.size Formula.bytes + simp only [Nat.zero_mul, Nat.add_zero] + rw [Nat.add_comm f.phase, roundUp_shift _ _ _ hm] + omega + +theorem roundUp_le_budget (n a q : Nat) (ha : 0 < a) : + roundUp n a ≤ q ↔ n ≤ q - q % a := by + have hfloor := Arithmetic.round_down_properties q a (q - q % a) ha rfl + have hceil := roundUp_properties n a ha + constructor + · intro h + have := hfloor.2.2.2 _ h hceil.2.2 + omega + · intro h + have := roundUp_le n a (q - q % a) ha h hfloor.2.1 + omega + +/-- Largest fitting byte count, including rounding plateaus. -/ +def Formula.capacity (f : Formula) (available : Nat) : Option Nat := + if f.bytes 0 ≤ available then + some ((available - f.base) - (available - f.base) % f.align - f.phase) + else none + +theorem capacity_spec (f : Formula) (available : Nat) (ha : 0 < f.align) : + match f.capacity available with + | none => ∀ b, available < f.bytes b + | some cap => ∀ b, f.bytes b ≤ available ↔ b ≤ cap := by + unfold Formula.capacity + by_cases h : f.bytes 0 ≤ available + · simp only [h, if_true] + have hbase : f.base ≤ available := by unfold Formula.bytes at h; omega + have hphase : f.phase ≤ (available - f.base) - (available - f.base) % f.align := by + apply (roundUp_le_budget _ _ _ ha).mp + unfold Formula.bytes at h + simp only [Nat.add_zero] at h + omega + intro b + have hb := roundUp_le_budget (f.phase + b) f.align (available - f.base) ha + unfold Formula.bytes + omega + · simp only [h, if_false] + intro b + have hmono := roundUp_mono ha (show f.phase + 0 ≤ f.phase + b by omega) + unfold Formula.bytes at * + omega + +theorem roundUp_phase (p a : Nat) (_ha : 0 < a) (hp : p < a) : + roundUp p a = if p = 0 then 0 else a := by + by_cases h : p = 0 + · simp [roundUp, h] + · unfold roundUp + rw [Nat.mod_eq_of_lt hp, Nat.mod_eq_of_lt (show a - p < a by omega)] + simp only [h, if_false] + omega + +theorem floor_shift (k x a : Nat) (hk : k % a = 0) : + (k + x) - (k + x) % a = k + (x - x % a) := by + have := Nat.mod_le x a + rw [Nat.add_mod, hk, Nat.zero_add, Nat.mod_mod] + omega + +theorem aligned_or (n p a : Nat) (ha : a.isPowerOfTwo) + (hn : n % a = 0) (hp : p < a) : n ||| p = n + p := by + obtain ⟨k, hk⟩ := ha + rw [hk] at hn hp + have h : n = 2 ^ k * (n / 2 ^ k) := by + rw [Nat.mul_comm, Nat.div_mul_self_eq_mod_sub_self, hn, Nat.sub_zero] + rw [h] + exact (Nat.two_pow_add_eq_or_of_lt hp _).symm + +/- A sufficient, deliberately conservative test for equal size sequences. The +alternatives cover linear aligned strides or identical rounding phases. +Failure of this test does not prove that the sequences differ. +-/ +def Formula.sameSequence (f g : Formula) : Prop := + f.elem = g.elem ∧ f.size 0 = g.size 0 ∧ + ((f.elem % f.align = 0 ∧ g.elem % g.align = 0) ∨ + (f.align = g.align ∧ f.phase = g.phase)) + +theorem same_sequence_sound (f g : Formula) (h : f.sameSequence g) (n : Nat) : + f.size n = g.size n := by + obtain ⟨he, hzero, hcases⟩ := h + rcases hcases with ⟨hf, hg⟩ | ⟨ha, hp⟩ + · rw [aligned_element_size f n hf, aligned_element_size g n hg, he, hzero] + · have hbase : f.base = g.base := by + unfold Formula.size Formula.bytes at hzero + simp only [Nat.zero_mul, Nat.add_zero] at hzero + rw [ha, hp] at hzero + omega + simp only [Formula.size, Formula.bytes, hbase, hp, he, ha] + +theorem no_dynamic_padding (f : Formula) (hzero : f.size 0 = f.offset) + (he : f.elem % f.align = 0) (n : Nat) : f.size n = f.offset + n * f.elem := by + rw [aligned_element_size f n he, hzero] + +theorem maximal_metadata (f : Formula) (available cap : Nat) (he : 0 < f.elem) + (h : f.capacity available = some cap) (ha : 0 < f.align) : + ∀ n, f.size n ≤ available ↔ n ≤ cap / f.elem := by + have hc := capacity_spec f available ha + rw [h] at hc + intro n + unfold Formula.size + rw [hc] + exact Nat.le_div_iff_mul_le he |>.symm + +/- Model a checked machine-size result by bounding the unbounded formula. None +means numerical overflow, not execution failure or decoder rejection. +-/ +def Formula.checkedSize (f : Formula) (limit n : Nat) : Option Nat := + if f.size n ≤ limit then some (f.size n) else none + +theorem checked_size_spec (f : Formula) (limit n : Nat) : + (f.checkedSize limit n = some (f.size n) ↔ f.size n ≤ limit) ∧ + (f.checkedSize limit n = none ↔ limit < f.size n) := by + unfold Formula.checkedSize + split <;> simp_all + +end Zerocopy.LayoutMath diff --git a/verification/aeneas/lean/Proofs.lean b/verification/aeneas/lean/Proofs.lean index 1dbd3c3835..73b1676b10 100644 --- a/verification/aeneas/lean/Proofs.lean +++ b/verification/aeneas/lean/Proofs.lean @@ -11,6 +11,7 @@ public import Specs public import Proofs.Util public import Aeneas public import Loops +public import LayoutMath import all Mathlib.Data.Nat.Log import all Init.Data.Nat.Power2.Basic @[expose] public section From 5bc26a228aadce81754094742cc7a3e8bc987ddc Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 03:19:01 -0400 Subject: [PATCH 08/12] Refine recursive record semantics into normalized layouts Define recursive record semantics and prove normalization across nested fields and trailing metadata. Preserve complete inner sizes when composing layouts. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. gherrit-pr-id: Gorv7wy3hyn76t7fgbuh776b3o6yz5teg Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/README.md | 4 + verification/aeneas/lean/LayoutMath.lean | 397 ++++++++++++++++++++++- 2 files changed, 394 insertions(+), 7 deletions(-) diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md index ed97477493..b120b275eb 100644 --- a/verification/aeneas/README.md +++ b/verification/aeneas/README.md @@ -139,6 +139,10 @@ conditions. `LayoutMath.lean` supplies independent unbounded size and capacity formulas. +The mathematical layout semantics proves normalization across arbitrary +nesting and metadata values. These algebraic laws do not themselves verify +a Rust layout method. + 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 diff --git a/verification/aeneas/lean/LayoutMath.lean b/verification/aeneas/lean/LayoutMath.lean index 053e65e585..bbf2deb872 100644 --- a/verification/aeneas/lean/LayoutMath.lean +++ b/verification/aeneas/lean/LayoutMath.lean @@ -12,13 +12,19 @@ import all Init.Data.Nat.Power2.Basic @[expose] public section /-! -This module proves unbounded layout mathematics independently of extracted -Rust bodies. Formula describes a normalized complete-size calculation, and -its lemmas characterize padding and the greatest fitting trailing-byte count. -Machine words and the operation proofs use those facts at a separate -boundary, where the relevant positivity and overflow conditions must be -established. This algebra alone does not establish correspondence with a Rust -compiler. +This module states layout mathematics independently of extracted Rust bodies. +Description retains recursive record nesting and each field's complete size. +Formula normalizes that description into a compact size calculation, and the +compile theorems prove that normalization preserves every metadata value. +LayoutValue then describes fragment operations used by the runtime layout +API. + +All sizes here are natural numbers: arithmetic cannot overflow. Explicit fit +predicates identify when a fragment operation can be implemented with a +machine word. A fragment need not describe a completed Rust type. +SEMANTICS.md states the separate premise connecting completed recursive +descriptions to Rust; the algebra in this module alone does not establish +compiler correspondence. -/ namespace Zerocopy.LayoutMath @@ -325,4 +331,381 @@ theorem checked_size_spec (f : Formula) (limit n : Nat) : unfold Formula.checkedSize split <;> simp_all +/-- Recursive record semantics, retaining each inner field's complete size. -/ +/- A recursive layout description retains every inner field's complete size. +Alignment exponents encode powers of two. A record contributes a fixed +leading region and places its trailing description under optional packing. +-/ +inductive Description where + | slice (elem alignExponent : Nat) + | record (leading minAlignExponent : Nat) (packedExponent : Option Nat) + (tail : Description) +deriving DecidableEq + +/- Compute the record alignment from its declared minimum and packed field +alignment. Packing limits field placement; it does not remove inner padding. +-/ +def Description.alignExponent : Description → Nat + | .slice _ a => a + | .record _ minimum packed tail => max minimum + (match packed with | none => tail.alignExponent | some p => min p tail.alignExponent) + +/- Cap the field placement alignment by the packing bound, when present. +-/ +def Description.fieldAlignExponent (packed : Option Nat) (tail : Description) : Nat := + match packed with | none => tail.alignExponent | some p => min p tail.alignExponent + +/- Place the field, add its full recursive size, then round the containing +record. The order matters: packing the outer record cannot flatten an inner +field's own final rounding. +-/ +def Description.size : Description → Nat → Nat + | .slice elem _, n => n * elem + | d@(.record leading _ packed tail), n => + roundUp (roundUp leading (2 ^ fieldAlignExponent packed tail) + tail.size n) + (2 ^ d.alignExponent) + +/- Track the physical start of the ultimate slice through every containing +record. This observation is independent of normalized formula base and phase. +-/ +def Description.offset : Description → Nat + | .slice _ _ => 0 + | .record leading _ packed tail => + roundUp leading (2 ^ fieldAlignExponent packed tail) + tail.offset + +def Description.elem : Description → Nat + | .slice e _ => e + | .record _ _ _ tail => tail.elem + +/- Require the slice element stride to be divisible by its alignment. Every +record retains that same obligation through its trailing description. +-/ +def Description.valid : Description → Prop + | .slice elem a => elem % 2 ^ a = 0 + | .record _ _ _ tail => tail.valid + +/- Normalize recursively without inspecting generated Rust code. compile_size, +compile_offset, and compile_elem prove that all retained observations agree. +-/ +def Description.compile : Description → Formula + | .slice elem a => ⟨0, 0, 2 ^ a, elem, 0⟩ + | d@(.record leading _ packed tail) => + let placement := roundUp leading (2 ^ fieldAlignExponent packed tail) + let inner := tail.compile + ({ inner with base := placement + inner.base, + offset := placement + inner.offset }).pad (2 ^ d.alignExponent) + +theorem compile_power (d : Description) : ∃ k, d.compile.align = 2 ^ k := by + induction d with + | slice e a => exact ⟨a, rfl⟩ + | record leading minimum packed tail ih => + obtain ⟨k, hk⟩ := ih + unfold Description.compile Formula.pad + dsimp only + split + · exact ⟨_, rfl⟩ + · exact ⟨k, hk⟩ + +theorem compile_size (d : Description) (h : d.valid) (n : Nat) : + d.compile.size n = d.size n := by + induction d with + | slice e a => + simp only [Description.compile, Formula.size, Formula.bytes, + Description.size, Nat.zero_add] + apply roundUp_eq + simp only [Description.valid] at h + simp [Nat.mul_mod, h] + | record leading minimum packed tail ih => + obtain ⟨k, hk⟩ := compile_power tail + let placement := roundUp leading (2 ^ Description.fieldAlignExponent packed tail) + let f : Formula := { tail.compile with + base := placement + tail.compile.base + offset := placement + tail.compile.offset } + change (f.pad (2 ^ (Description.record leading minimum packed tail).alignExponent)).size n = _ + have hf : 0 < f.align := by dsimp [f]; rw [hk]; exact Nat.two_pow_pos k + have hd : if f.align < 2 ^ (Description.record leading minimum packed tail).alignExponent + then f.align ∣ 2 ^ (Description.record leading minimum packed tail).alignExponent + else 2 ^ (Description.record leading minimum packed tail).alignExponent ∣ f.align := by + dsimp [f] + rw [hk] + split <;> rename_i hh + · exact Nat.pow_dvd_pow 2 ((Nat.pow_lt_pow_iff_right (by decide : 1 < 2)).mp hh).le + · exact Nat.pow_dvd_pow 2 ((Nat.pow_le_pow_iff_right (by decide : 1 < 2)).mp (by omega)) + rw [pad_size f n _ hf (Nat.two_pow_pos _) hd] + simp only [Formula.size, Formula.bytes, f, Nat.add_assoc] + change roundUp (placement + tail.compile.size n) _ = _ + rw [ih h] + rfl + +theorem compile_offset (d : Description) : d.compile.offset = d.offset := by + induction d with + | slice e a => rfl + | record leading minimum packed tail ih => + unfold Description.compile Formula.pad + dsimp only + split <;> simp only [Description.offset, ih] + +theorem compile_elem (d : Description) : d.compile.elem = d.elem := by + induction d with + | slice e a => rfl + | record leading minimum packed tail ih => + unfold Description.compile Formula.pad + dsimp only + split <;> simp only [Description.elem, ih] + +theorem compile_valid (d : Description) : d.compile.valid := by + induction d with + | slice e a => exact ⟨Nat.two_pow_pos a, Nat.two_pow_pos a⟩ + | record leading minimum packed tail ih => + exact pad_valid _ _ ih (Nat.two_pow_pos _) + +/-- A complete recursive layout always contains the physical slice bytes. -/ +theorem description_contains_tail (d : Description) (n : Nat) : + d.offset + n * d.elem ≤ d.size n := by + induction d with + | slice e a => simp only [Description.offset, Description.elem, Description.size, Nat.zero_add, Nat.le_refl] + | record leading minimum packed tail ih => + have h := (roundUp_properties + (roundUp leading (2 ^ Description.fieldAlignExponent packed tail) + tail.size n) + (2 ^ (Description.record leading minimum packed tail).alignExponent) + (Nat.two_pow_pos _)).1 + simp only [Description.offset, Description.elem, Description.size] + omega + +theorem compiled_contains_tail (d : Description) (h : d.valid) (n : Nat) : + d.compile.offset + n * d.compile.elem ≤ d.compile.size n := by + rw [compile_offset, compile_elem, compile_size d h] + exact description_contains_tail d n + +/-- The old flattened formula loses padding inside a packed field. -/ +theorem packed_regression : + (Description.record 2 0 (some 1) (.record 5 2 none (.slice 1 0))).size 0 = 10 ∧ + roundUp 7 2 = 8 := by decide + +theorem aligned_below_next (x k a : Nat) (hx : x % a = 0) (hk : k % a = 0) + (h : x < k + a) : x ≤ k := by + by_contra hnot + have heq : x = k + (x - k) := by omega + have hd : (x - k) % a = 0 := by + rw [heq, Nat.add_mod, hk, Nat.zero_add, Nat.mod_mod] at hx + exact hx + rw [Nat.mod_eq_of_lt (by omega)] at hd + omega + +theorem floor_capacity (n cap a : Nat) (ha : 0 < a) : + n - n % a ≤ cap ↔ n ≤ cap - cap % a + (a - 1) := by + have hn := Arithmetic.round_down_properties n a (n - n % a) ha rfl + have hc := Arithmetic.round_down_properties cap a (cap - cap % a) ha rfl + constructor + · intro h + have hbound := hc.2.2.2 _ h hn.2.1 + omega + · intro h + have hbound := aligned_below_next _ _ _ hn.2.1 hc.2.1 (by omega) + omega + +theorem roundUp_from_aligned_budget (budget unused a : Nat) (ha : 0 < a) + (hb : budget % a = 0) (hu : unused ≤ budget) : + roundUp (budget - unused) a = budget - (unused - unused % a) := by + have hm := Nat.mod_le unused a + have hr := Nat.mod_lt unused ha + let floor := unused - unused % a + have hf : floor % a = 0 := by + dsimp only [floor] + rw [← Nat.div_mul_self_eq_mod_sub_self, Nat.mul_mod_left] + have hq : (budget - floor) % a = 0 := by + have hsplit : budget = (budget - floor) + floor := by dsimp only [floor]; omega + rw [hsplit, Nat.add_mod, hf, Nat.add_zero, Nat.mod_mod] at hb + exact hb + have hceil := roundUp_properties (budget - unused) a ha + have hupper := roundUp_le (budget - unused) a (budget - floor) ha (by dsimp only [floor]; omega) hq + have hlower := aligned_below_next (budget - floor) (roundUp (budget - unused) a) a + hq hceil.2.2 (by dsimp only [floor]; omega) + dsimp only [floor] at hupper hlower + omega + +theorem capacity_object_size (f : Formula) (available cap : Nat) (ha : 0 < f.align) + (hcap : f.capacity available = some cap) : + f.size (cap / f.elem) = f.base + + ((available - f.base) - (available - f.base) % f.align) - + (cap % f.elem - cap % f.elem % f.align) := by + have hspec := capacity_spec f available ha + rw [hcap] at hspec + have hzero := (hspec 0).mpr (by omega) + have hbase : f.base ≤ available := by unfold Formula.bytes at hzero; omega + have hphase : f.phase ≤ (available - f.base) - (available - f.base) % f.align := by + apply (roundUp_le_budget f.phase f.align (available - f.base) ha).mp + unfold Formula.bytes at hzero + simp only [Nat.add_zero] at hzero + omega + have hc : cap = (available - f.base) - (available - f.base) % f.align - f.phase := by + unfold Formula.capacity at hcap + simp only [hzero, if_true, Option.some.injEq] at hcap + exact hcap.symm + let budget := (available - f.base) - (available - f.base) % f.align + have hb : budget % f.align = 0 := by + dsimp only [budget] + rw [← Nat.div_mul_self_eq_mod_sub_self, Nat.mul_mod_left] + have hm := Nat.mod_le cap f.elem + have hdiv := Nat.mod_add_div cap f.elem + have hinput : f.phase + (cap / f.elem) * f.elem = budget - cap % f.elem := by + dsimp only [budget] + rw [Nat.mul_comm] at hdiv + omega + unfold Formula.size Formula.bytes + rw [hinput, roundUp_from_aligned_budget budget _ f.align ha hb (by dsimp only [budget]; omega)] + have hfloor := Nat.mod_le (cap % f.elem) f.align + dsimp only [budget] + omega + +/-- An independent description of a layout fragment, with unbounded sizes. -/ +inductive Payload where + | fixed (bytes : Nat) + | trailing (formula : Formula) +deriving DecidableEq + +/- The mathematical state of a layout fragment, including alignment and the +unpadded flag. Fragment operations can produce intermediate states, so this +type intentionally has no blanket completed-Rust-type invariant. +-/ +structure LayoutValue where + align : Nat + payload : Payload + unpadded : Bool +deriving DecidableEq + +/- Observe fixed byte size or evaluate the trailing formula at this metadata. +-/ +def LayoutValue.size (v : LayoutValue) (n : Nat) : Nat := + match v.payload with + | .fixed bytes => bytes + | .trailing f => f.size n + +/- Begin record construction with no fields and the requested minimum alignment. +-/ +def LayoutValue.initial (align : Nat) : LayoutValue := ⟨align, .fixed 0, true⟩ + +/-- Outside the `extendFits` domain the placeholder result has no contract. -/ +def LayoutValue.extend (v field : LayoutValue) (packing : Nat) : LayoutValue := + match v.payload with + | .trailing _ => v + | .fixed bytes => + let fieldAlign := min field.align packing + let offset := roundUp bytes fieldAlign + { align := max v.align fieldAlign, + payload := match field.payload with + | .fixed size => .fixed (offset + size) + | .trailing f => .trailing { f with base := offset + f.base, offset := offset + f.offset }, + unpadded := v.unpadded && field.unpadded && decide (bytes % fieldAlign = 0) } + +/- State the exact machine bounds needed to append a field. A trailing fragment +cannot receive another field; this rules out extend's placeholder case. For a +trailing field, physical offset and normalized base each need their own +bound. +-/ +def LayoutValue.extendFits (v field : LayoutValue) (packing limit : Nat) : Prop := + match v.payload with + | .trailing _ => False + | .fixed bytes => + let offset := roundUp bytes (min field.align packing) + match field.payload with + | .fixed size => offset + size ≤ limit + | .trailing f => offset + f.offset ≤ limit ∧ offset + f.base ≤ limit + +/- Complete a fragment at its own alignment, retaining inner rounding in a +trailing payload. Fixed payload padding also updates the unpadded +observation. +-/ +def LayoutValue.pad (v : LayoutValue) : LayoutValue := + { v with + payload := match v.payload with + | .fixed bytes => .fixed (roundUp bytes v.align) + | .trailing f => .trailing (f.pad v.align) + unpadded := match v.payload with + | .fixed bytes => v.unpadded && decide (bytes % v.align = 0) + | .trailing _ => v.unpadded } + +/- Bound the stored fields changed by padding. This is a machine-representation +condition, not a bound on every possible metadata value of a trailing +formula. +-/ +def LayoutValue.padFits (v : LayoutValue) (limit : Nat) : Prop := + match v.payload with + | .fixed bytes => roundUp bytes v.align ≤ limit + | .trailing f => + if f.align < v.align then roundUp f.base f.align + f.phase ≤ limit + else roundUp f.base v.align ≤ limit + +theorem LayoutValue.extend_size (v field : LayoutValue) (packing bytes n : Nat) + (h : v.payload = .fixed bytes) : + (v.extend field packing).size n = + roundUp bytes (min field.align packing) + field.size n := by + simp only [LayoutValue.extend, h] + cases hf : field.payload <;> simp [LayoutValue.size, hf, Formula.size, Formula.bytes, Nat.add_assoc] + +/- Fold extension over the first i fields. The constructor loop invariant uses +this prefix calculation to identify its current mathematical state. +-/ +def LayoutValue.prefixValue (fields : List LayoutValue) (initial : LayoutValue) + (packing i : Nat) : LayoutValue := + (fields.take i).foldl (fun v field => v.extend field packing) initial + +theorem LayoutValue.prefixValue_step (fields : List LayoutValue) (initial : LayoutValue) + (packing i : Nat) (hi : i < fields.length) : + prefixValue fields initial packing (i + 1) = + (prefixValue fields initial packing i).extend fields[i] packing := by + unfold prefixValue + rw [List.take_succ_eq_append_getElem hi, List.foldl_append] + rfl + +/-- Direct field placement, evaluated separately for each metadata value. +This rule never manipulates the normalization's base, phase, or size alignment. -/ +def recordState (fields : List LayoutValue) (minimum packing metadata count : Nat) : Nat × Nat := + (fields.take count).foldl + (fun state field => + (max state.1 (min field.align packing), + roundUp state.2 (min field.align packing) + field.size metadata)) + (minimum, 0) + +theorem recordState_step (fields : List LayoutValue) (minimum packing metadata i : Nat) + (hi : i < fields.length) : + recordState fields minimum packing metadata (i + 1) = + (max (recordState fields minimum packing metadata i).1 (min fields[i].align packing), + roundUp (recordState fields minimum packing metadata i).2 (min fields[i].align packing) + + fields[i].size metadata) := by + unfold recordState + rw [List.take_succ_eq_append_getElem hi, List.foldl_append] + rfl + +theorem recordState_refinement (fields : List LayoutValue) (minimum packing metadata limit : Nat) + (hfit : ∀ i (hi : i < fields.length), + (LayoutValue.prefixValue fields (LayoutValue.initial minimum) packing i).extendFits + fields[i] packing limit) : + ∀ i ≤ fields.length, recordState fields minimum packing metadata i = + ((LayoutValue.prefixValue fields (LayoutValue.initial minimum) packing i).align, + (LayoutValue.prefixValue fields (LayoutValue.initial minimum) packing i).size metadata) := by + intro i + induction i with + | zero => + intro hi + simp only [recordState, LayoutValue.prefixValue, List.take_zero, List.foldl_nil, + LayoutValue.initial, LayoutValue.size] + | succ i ih => + intro hi + have hil : i < fields.length := by omega + have hstep := hfit i hil + let current := LayoutValue.prefixValue fields (LayoutValue.initial minimum) packing i + cases hc : current.payload with + | trailing f => + dsimp only [current] at hc + simp only [LayoutValue.extendFits, hc] at hstep + | fixed bytes => + dsimp only [current] at hc + rw [recordState_step fields minimum packing metadata i hil, ih (by omega), + LayoutValue.prefixValue_step fields (LayoutValue.initial minimum) packing i hil] + change _ = ((current.extend fields[i] packing).align, + (current.extend fields[i] packing).size metadata) + rw [LayoutValue.extend_size current fields[i] packing bytes metadata hc] + simp only [current, LayoutValue.extend, hc, LayoutValue.size] + end Zerocopy.LayoutMath From 371fbd1eb5d42463aa2bdc4545f9c570f02a0b28 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 10:26:14 -0400 Subject: [PATCH 09/12] Model alignment and phase encoding as a bounded mathematical pair Retain the nominal Rust wrapper and define its mathematical model inline. Decode its already-positive NonZero field into alignment and phase with power-of-two, phase-bound and representability proofs. Prove the accepted domain, representation relation and round trips independently of method proofs. Encoding and decoding specifications use the mathematical pair; broad Raw lemmas preserve the original representation guarantees. Construct the rounding model using only its data fields; decoder-scoped completion proves its unchanged constraints in the construction context. Independent meaning, admission and round-trip guarantees remain explicit. Validation: fresh pinned extraction, complete golden comparison, separate golden/live builds, independent required contracts and axiom audits. gherrit-pr-id: Gng7s74f35a4f6jjd5xjjmvxbwqqnnx7q Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/README.md | 32 ++- verification/aeneas/golden/Funs.lean | 66 +++++ .../aeneas/golden/FunsExternal_Template.lean | 18 ++ verification/aeneas/golden/Types.lean | 6 + verification/aeneas/lean/MathViews.lean | 22 ++ verification/aeneas/lean/ModelSupport.lean | 56 ++++ verification/aeneas/lean/Obligations.lean | 29 +++ verification/aeneas/lean/Proofs.lean | 243 +++++++++++++++++- .../aeneas/lean/RepresentationLaws.lean | 71 +++++ .../aeneas/lean/RequiredModelContracts.lean | 56 ++++ .../aeneas/lean/Zerocopy/FunsExternal.lean | 17 ++ zerocopy/src/layout/mod.rs | 198 ++++++++++++++ 12 files changed, 798 insertions(+), 16 deletions(-) create mode 100644 verification/aeneas/lean/ModelSupport.lean create mode 100644 verification/aeneas/lean/RepresentationLaws.lean diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md index b120b275eb..039fc48590 100644 --- a/verification/aeneas/README.md +++ b/verification/aeneas/README.md @@ -61,8 +61,9 @@ each tool answers a different question: 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)` +the vocabulary in which we state those properties. For example, Rust stores +rounding alignment and phase in one nonzero word, while its mathematical model +exposes two natural numbers. 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. @@ -126,6 +127,9 @@ added without extending the specification set. | `min` | Returns the mathematical minimum, selects an input, and bounds both inputs from below. | | `padding_needed_for` | For power-of-two alignment, returns padding below it and exactly `(align - len % align) % align`, the least padding making the sum aligned, with zero padding exactly when the input is aligned. | | `round_down_to_next_multiple_of_alignment` | For power-of-two alignment, returns exactly `n - n % align`, the greatest aligned value at most `n`; the next multiple exceeds `n`. | +| `RoundingAlignAndPhase::new` | For power-of-two alignment `A` and phase `P < A`, successfully encodes exactly `A + P`. | +| `RoundingAlignAndPhase::components` | For any nonzero encoded word, returns its highest set bit as a power-of-two alignment and the remaining lower bits as a phase below that alignment; their sum is the original word. | +| `RoundingAlignAndPhase::align` | For any nonzero encoded word, returns exactly its highest set bit. | Every registered function uses total `spec`: accepted raw representations, supplied mathematical ghosts, and explicit requirements imply successful @@ -143,6 +147,11 @@ The mathematical layout semantics proves normalization across arbitrary nesting and metadata values. These algebraic laws do not themselves verify a Rust layout method. +The nominal rounding wrapper decodes to `RoundingValue`, a power-of-two +alignment and bounded phase with machine-fit proofs. Ordinary representation +laws establish acceptance of every positive stored word and exact reconstruction +from that pair. Zero is rejected by the native NonZero child decoder. + 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 @@ -315,7 +324,7 @@ comparison is part of the automatic-decoding audit. Generated modules follow the proof-construction dependency: ```text -raw Types + ModelPrelude → ModelShapes → Models → Specs + Proofs +raw Types + ModelPrelude → ModelShapes → ModelSupport → Models → Specs + Proofs ``` `ModelShapes` declares nominal mathematical fields and inline model types in @@ -451,14 +460,17 @@ modules must not create import cycles through generated `Required` or `Check`. 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 +model RoundingValue where + align : Nat + phase : Nat + align_pow2 : align.isPowerOfTwo + phase_lt : phase < align + fits : align + phase ≤ Usize.max decode self => - { value := self.word.value + 1, .. } + let word := self._0.value + let align := 2 ^ Nat.log2 word + { align := align, phase := word - align, .. } ``` Both `decode` and `decode?` implicitly use `model_value` completion on the whole @@ -539,7 +551,7 @@ 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 + --root "$PWD" --spec encoding_components_spec ``` This reads compiled declarations without running another build. It displays the diff --git a/verification/aeneas/golden/Funs.lean b/verification/aeneas/golden/Funs.lean index ded6b8df2a..414d5e1fac 100644 --- a/verification/aeneas/golden/Funs.lean +++ b/verification/aeneas/golden/Funs.lean @@ -64,6 +64,72 @@ def Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner : innerCopyInst := core.num.niche_types.NonZeroUsizeInner.Insts.CoreMarkerCopy } +/-- [zerocopy::layout::POINTER_WIDTH_BITS] + Source: 'src/layout/mod.rs', lines 17:0-17:62 -/ +@[global_simps, irreducible] +def layout.POINTER_WIDTH_BITS : Result Std.Usize := do + let i ← core.mem.size_of Std.Usize + i * 8#usize + +/-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::new]: + Source: 'src/layout/mod.rs', lines 98:4-117:5 -/ +def layout.RoundingAlignAndPhase.new + (align : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) (phase : Std.Usize) : + Result layout.RoundingAlignAndPhase + := do + let i ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner align + let b ← core.num.Usize.is_power_of_two i + massert b + massert (phase < i) + let encoded ← lift (i ||| phase) + let o ← + core.num.nonzero.NonZero.new + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner encoded + match o with + | none => fail panic + | some encoded1 => ok { _0 := encoded1 } + +/-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::components]: + Source: 'src/layout/mod.rs', lines 131:4-158:5 -/ +def layout.RoundingAlignAndPhase.components + (self : layout.RoundingAlignAndPhase) : + Result ((core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) × Std.Usize) + := do + let i ← layout.POINTER_WIDTH_BITS + let i1 ← i - 1#usize + let i2 ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner self._0 + let i3 ← lift (core.num.Usize.leading_zeros i2) + let i4 ← lift (UScalar.cast .Usize i3) + let shift ← i1 - i4 + let align ← 1#usize <<< shift + let o ← + core.num.nonzero.NonZero.new + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner align + match o with + | none => fail panic + | some align1 => + let i5 ← + core.num.nonzero.NonZero.get + Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner align1 + let phase ← lift (i2 ^^^ i5) + ok (align1, phase) + +/-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::align]: + Source: 'src/layout/mod.rs', lines 171:4-180:5 -/ +def layout.RoundingAlignAndPhase.align + (self : layout.RoundingAlignAndPhase) : + Result (core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner) + := do + let (nz, _) ← layout.RoundingAlignAndPhase.components self + ok nz + /-- [zerocopy::util::round_down_to_next_multiple_of_alignment]: Source: 'src/util/mod.rs', lines 260:0-278:1 -/ def util.round_down_to_next_multiple_of_alignment diff --git a/verification/aeneas/golden/FunsExternal_Template.lean b/verification/aeneas/golden/FunsExternal_Template.lean index ccb87a43d6..03f8eb3085 100644 --- a/verification/aeneas/golden/FunsExternal_Template.lean +++ b/verification/aeneas/golden/FunsExternal_Template.lean @@ -28,6 +28,13 @@ set_option maxHeartbeats 1000000 set_option maxRecDepth 2048 open Zerocopy +/-- [core::mem::size_of]: + Source: '/rustc/library/core/src/mem/mod.rs', lines 375:0-375:34 + Name pattern: [core::mem::size_of] + Visibility: public -/ +@[rust_fun "core::mem::size_of"] +axiom core.mem.size_of (T : Type) : Result Std.Usize + /-- [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] @@ -39,6 +46,17 @@ 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}::new]: + Source: '/rustc/library/core/src/num/nonzero.rs', lines 388:4-388:42 + Name pattern: [core::num::nonzero::{core::num::nonzero::NonZero<@T, @Clause0_NonZeroInner>}::new] + Visibility: public -/ +@[rust_fun + "core::num::nonzero::{core::num::nonzero::NonZero<@T, @Clause0_NonZeroInner>}::new"] +axiom core.num.nonzero.NonZero.new + {T : Type} {Clause0_NonZeroInner : Type} (ZeroablePrimitiveInst : + core.num.nonzero.ZeroablePrimitive T Clause0_NonZeroInner) : + T → Result (Option (core.num.nonzero.NonZero T Clause0_NonZeroInner)) + /-- [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] diff --git a/verification/aeneas/golden/Types.lean b/verification/aeneas/golden/Types.lean index 687cf0b1f8..7d69d1fcc5 100644 --- a/verification/aeneas/golden/Types.lean +++ b/verification/aeneas/golden/Types.lean @@ -39,4 +39,10 @@ structure core.num.nonzero.ZeroablePrimitive (Self : Type) (Self_NonZeroInner : markerCopyInst : core.marker.Copy Self innerCopyInst : core.marker.Copy Self_NonZeroInner +/-- [zerocopy::layout::RoundingAlignAndPhase] + Source: 'src/layout/mod.rs', lines 77:0-77:54 -/ +structure layout.RoundingAlignAndPhase where + _0 : core.num.nonzero.NonZero Std.Usize + core.num.niche_types.NonZeroUsizeInner + end Zerocopy diff --git a/verification/aeneas/lean/MathViews.lean b/verification/aeneas/lean/MathViews.lean index c8d6ed7771..81ad558122 100644 --- a/verification/aeneas/lean/MathViews.lean +++ b/verification/aeneas/lean/MathViews.lean @@ -34,6 +34,9 @@ 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. +def encodingValid (code : layout.RoundingAlignAndPhase) : Prop := + 0 < code._0.val.val + @[contract_simps] theorem admission_eq {Raw : Type u} (provider : RustModel Raw) (raw : Raw) : (∃ value, provider.decode raw = some value) = @isValid Raw provider raw := rfl @@ -68,12 +71,22 @@ set_option linter.unusedSimpArgs false @[simp, contract_simps] theorem bool_valid_iff (x : Bool) : isValid x ↔ True := by simp [isValid, RustModel.decode, modelBool] +@[simp, contract_simps] theorem encoding_valid_iff (code : layout.RoundingAlignAndPhase) : + isValid code ↔ encodingValid code := by + by_cases hp : 0 < code._0.val.val <;> + simp [isValid, RustModel.decode, layout.RoundingAlignAndPhase.aeneasModel, + layout.RoundingAlignAndPhase.decode, layout.RoundingAlignAndPhase.decodeFields, + modelNonZeroUScalar, hp, encodingValid] + @[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 encoding_admitted_iff (x : layout.RoundingAlignAndPhase) : + (∃ value, RustModel.decode x = some value) ↔ encodingValid x := encoding_valid_iff x + @[contract_simps] theorem bool_admitted_iff (x : Bool) : (∃ value, RustModel.decode x = some value) ↔ True := bool_valid_iff x @@ -86,11 +99,20 @@ set_option linter.unusedSimpArgs false -- A named decoder can also occur after a surrounding structural traversal -- exposes the retained dictionary. These are the same admission equivalences. +@[contract_simps] theorem encoding_decoder_admitted_iff (raw : layout.RoundingAlignAndPhase) : + (∃ value, layout.RoundingAlignAndPhase.decode raw = some value) ↔ + encodingValid raw := encoding_valid_iff raw + @[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 +attribute [contract_simps] encodingValid + +@[simp, contract_simps] theorem rounding_model_decode_eq + (raw : layout.RoundingAlignAndPhase) : + RustModel.decode raw = layout.RoundingAlignAndPhase.decode raw := rfl end Zerocopy.Proofs diff --git a/verification/aeneas/lean/ModelSupport.lean b/verification/aeneas/lean/ModelSupport.lean new file mode 100644 index 0000000000..8359dac865 --- /dev/null +++ b/verification/aeneas/lean/ModelSupport.lean @@ -0,0 +1,56 @@ +/- 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 ModelShapes +import all Mathlib.Data.Nat.Log +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +/-! +The inline rounding decoder constructs a constrained alignment/phase pair. +These ordinary arithmetic lemmas explain why a positive word has the required +components, and why those components are unique. This module imports model +shapes rather than full providers, so it can be available while decoders are +being elaborated. It supplies reusable facts, not a separate certification +duty that every mathematical model must satisfy. +-/ +open Aeneas Aeneas.Std AeneasSpecs + +namespace Zerocopy.RoundingFacts + +/-- A positive word has a unique highest power-of-two bit. -/ +theorem bounds (word : Nat) (positive : 0 < word) : + 2 ^ Nat.log2 word ≤ word ∧ word < 2 ^ (Nat.log2 word + 1) := + (Nat.log2_eq_iff (by omega)).mp rfl + +theorem reconstruction (word : Nat) (positive : 0 < word) : + 2 ^ Nat.log2 word + (word - 2 ^ Nat.log2 word) = word := by + have := bounds word positive + omega + +/-- These are the exact components of every positive encoding, not a restricted +constructor-only subset of the representation domain. -/ +theorem components_unique (word align phase : Nat) + (pow2 : align.isPowerOfTwo) (phase_lt : phase < align) + (encoding : word = align + phase) : + align = 2 ^ Nat.log2 word ∧ phase = word - 2 ^ Nat.log2 word := by + obtain ⟨k, rfl⟩ := pow2 + have positive : 0 < word := by have := Nat.two_pow_pos k; omega + have hk : Nat.log2 word = k := + (Nat.log2_eq_iff (by omega)).mpr (by rw [Nat.pow_succ]; omega) + rw [hk] + omega + +/-- Proof irrelevance makes equality depend only on the observed pair. -/ +@[ext] theorem value_ext + {a b : layout.RoundingAlignAndPhase.RoundingValue} + (ha : a.align = b.align) (hp : a.phase = b.phase) : a = b := by + cases a; cases b; simp_all + +end Zerocopy.RoundingFacts diff --git a/verification/aeneas/lean/Obligations.lean b/verification/aeneas/lean/Obligations.lean index e4d32f4ce8..d04b45d63e 100644 --- a/verification/aeneas/lean/Obligations.lean +++ b/verification/aeneas/lean/Obligations.lean @@ -77,6 +77,35 @@ 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) +def encoding_new_spec_contract (a : NonZeroUsize) (p : Usize) + (run : Result layout.RoundingAlignAndPhase) : Prop := + 0 < a.val.val → a.val.val.isPowerOfTwo → p.val < a.val.val → + ∃ code, run = .ok code ∧ encodingValid code ∧ code._0.val.val = a.val.val + p.val + +def encoding_new_spec : Prop := + ∀ (a : NonZeroUsize) (p : Usize), + encoding_new_spec_contract a p (layout.RoundingAlignAndPhase.new a p) + +def encoding_components_spec_contract (code : layout.RoundingAlignAndPhase) + (run : Result (NonZeroUsize × Usize)) : Prop := + 0 < code._0.val.val → + run ⦃ (a, p) => + 0 < a.val.val ∧ a.val.val.isPowerOfTwo ∧ p.val < a.val.val ∧ a.val.val + p.val = code._0.val.val ∧ + a.val.val = 2 ^ code._0.val.val.log2 ∧ p.val = code._0.val.val - 2 ^ code._0.val.val.log2 ⦄ + +def encoding_components_spec : Prop := + ∀ (code : layout.RoundingAlignAndPhase), + encoding_components_spec_contract code (layout.RoundingAlignAndPhase.components code) + +def encoding_align_spec_contract (code : layout.RoundingAlignAndPhase) + (run : Result NonZeroUsize) : Prop := + 0 < code._0.val.val → + ∃ a, run = .ok a ∧ 0 < a.val.val ∧ a.val.val = 2 ^ code._0.val.val.log2 + +def encoding_align_spec : Prop := + ∀ (code : layout.RoundingAlignAndPhase), + encoding_align_spec_contract code (layout.RoundingAlignAndPhase.align code) + end Zerocopy.Obligations diff --git a/verification/aeneas/lean/Proofs.lean b/verification/aeneas/lean/Proofs.lean index 73b1676b10..89ff7b9e67 100644 --- a/verification/aeneas/lean/Proofs.lean +++ b/verification/aeneas/lean/Proofs.lean @@ -11,32 +11,263 @@ public import Specs public import Proofs.Util public import Aeneas public import Loops +public import RepresentationLaws public import LayoutMath 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. +This module proves contracts about the actual Aeneas function definitions. +The Raw namespace steps through extracted control flow and machine +arithmetic. Those lemmas deliberately retain useful representation domains, +even when an inline specification admits a smaller domain through automatic +decoding. + +The canonical theorems outside Raw prove the generated Specs propositions. +They use the raw lemmas, decode accepted inputs, and establish that the +actual returned representation decodes and has the requested mathematical +properties. WP.spec_mono transfers a proved computation to a new +postcondition; it cannot replace that computation. Registered specifications +let later proofs reuse a callee's theorem instead of unfolding its +implementation each time. 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. +and Rust Result values may themselves be successful payloads. The proof must +keep these cases distinct. -/ open Aeneas Aeneas.Std namespace Zerocopy.Proofs.Raw set_option linter.unusedVariables false +/- Read the pinned platform's pointer width through its modeled primitive. This +establishes the runtime constant used by encoding and packing arithmetic. +-/ +theorem pointer_width_spec : + layout.POINTER_WIDTH_BITS + ⦃ w => w.val = System.Platform.numBits ⦄ := by + unfold layout.POINTER_WIDTH_BITS + simp only [core.mem.size_of, ite_true, bind_ok] + have hv : ({ bv := BitVec.ofNat _ (System.Platform.numBits / 8) } : Usize).val = + System.Platform.numBits / 8 := by + change (System.Platform.numBits / 8) % 2 ^ System.Platform.numBits = _ + rcases System.Platform.numBits_eq with h | h <;> simp only [h] <;> decide + step as ⟨w, hw⟩ + · rw [hv] + rcases System.Platform.numBits_eq with h | h <;> + simp only [Usize.max, Usize.numBits, UScalarTy.Usize_numBits_eq, h, + UScalar.val] <;> decide + · rw [hv] at hw + rcases System.Platform.numBits_eq with h | h <;> + simp only [h] at hw ⊢ <;> omega + +/- Connect the constructor's power-of-two and phase checks to exact encoding. +The canonical form also proves decoding of the actual returned word. +-/ +theorem encoding_new_spec : + ∀ (align : NonZeroUsize) (phase : Usize), ∀ (ha : (align.val.val.isPowerOfTwo : Prop)), ∀ (hp : (phase.val < align.val.val : Prop)), + @Zerocopy.layout.RoundingAlignAndPhase.new align phase ⦃ encoded => encoded._0.val.val = align.val.val + phase.val ⦄ := by + intro align phase ha hp + unfold layout.RoundingAlignAndPhase.new + simp only [core.num.nonzero.NonZero.get, bind_ok] + step as ⟨b, hb⟩ + have hbt : b = true := by + have hb' : (b = true) = True := by simpa only [ha] using hb + exact Eq.mpr hb' trivial + simp only [massert, hbt, if_true, bind_ok, UScalar.lt_equiv, hp, lift] + obtain ⟨k, hk⟩ := ha + have hor : (align.val ||| phase).val = align.val.val + phase.val := by + rw [UScalar.val_or, hk] + have h := Nat.two_pow_add_eq_or_of_lt (show phase.val < 2 ^ k by omega) 1 + simpa only [Nat.mul_one] using h.symm + have hnz : align.val ||| phase ≠ 0#usize := by + intro hz + have hz' := congrArg UScalar.val hz + rw [hor] at hz' + have hpos := Nat.two_pow_pos k + change align.val.val + phase.val = 0 at hz' + omega + simp only [core.num.nonzero.NonZero.new, cast_eq, hnz, ↓reduceDIte, ↓reduceIte, + bind_ok, WP.spec_ok] + exact hor + +/- Remove the highest bit from align + phase when phase uses only lower bits. +The decoder implementation uses XOR; the mathematical model uses subtraction. +-/ +theorem highest_bit_xor (k p : Nat) (hp : p < 2 ^ k) : (2 ^ k + p) ^^^ 2 ^ k = p := by + have hjoin : 2 ^ k + p = 2 ^ k ||| p := by + simpa only [Nat.mul_one] using Nat.two_pow_add_eq_or_of_lt hp 1 + rw [hjoin] + apply Nat.eq_of_testBit_eq + intro i + rw [Nat.testBit_xor, Nat.testBit_or] + by_cases hi : i = k + · subst i + rw [Nat.testBit_two_pow_self, Nat.testBit_lt_two_pow hp] + rfl + · rw [Nat.testBit_two_pow_of_ne (Ne.symm hi)] + simp + +/- Prove the implementation's bit scan recovers the alignment and phase of every +positive stored word, not only words built by the constructor. +-/ +theorem encoding_components_spec : + ∀ (self : layout.RoundingAlignAndPhase), ∀ (hn : (0 < self._0.val.val : Prop)), + @Zerocopy.layout.RoundingAlignAndPhase.components self ⦃ (align, phase) => + align.val.val.isPowerOfTwo ∧ phase.val < align.val.val ∧ + align.val.val + phase.val = self._0.val.val ∧ + align.val.val = 2 ^ Nat.log2 self._0.val.val ∧ + phase.val = self._0.val.val - 2 ^ Nat.log2 self._0.val.val ⦄ := by + intro self hn + let k := Nat.log2 self._0.val.val + have hbound := (Nat.log2_eq_iff (show self._0.val.val ≠ 0 by omega)).mp (show Nat.log2 self._0.val.val = k from rfl) + have hsize := UScalar.hSize self._0.val + rw [UScalar.size, UScalarTy.Usize_numBits_eq] at hsize + have hk : k < System.Platform.numBits := by + dsimp [k] + rw [Nat.log2_eq_log_two] + apply Nat.log_lt_of_lt_pow (by omega) + exact hsize + have hbits : 32 ≤ System.Platform.numBits := by + rcases System.Platform.numBits_eq with h | h <;> omega + have hnzbv : self._0.val.bv ≠ 0 := by + intro h + have := congrArg BitVec.toNat h + change self._0.val.val = 0 at this + omega + have hlz : (core.num.Usize.leading_zeros self._0.val).val = System.Platform.numBits - k - 1 := by + change (BitVec.leadingZeros self._0.val.bv) % 2 ^ 32 = _ + unfold BitVec.leadingZeros + rw [if_neg hnzbv] + change (System.Platform.numBits - Nat.log 2 self._0.val.val - 1) % 2 ^ 32 = _ + rw [← Nat.log2_eq_log_two] + apply Nat.mod_eq_of_lt + rcases System.Platform.numBits_eq with h | h <;> rw [h] <;> omega + have hcast : (UScalar.cast .Usize (core.num.Usize.leading_zeros self._0.val)).val = + System.Platform.numBits - k - 1 := by + rw [UScalar.cast_val_eq, hlz, UScalarTy.Usize_numBits_eq] + apply Nat.mod_eq_of_lt + rcases System.Platform.numBits_eq with h | h <;> rw [h] <;> omega + unfold layout.RoundingAlignAndPhase.components + step with pointer_width_spec as ⟨w, hw⟩ + have hone : (1#usize).val = 1 := by simp + have hwpos : (1#usize).val ≤ w.val := by rw [hone, hw]; omega + step with Usize.sub_spec hwpos as ⟨w1, hw1, _⟩ + simp only [core.num.nonzero.NonZero.get, bind_ok, lift] + have hsub : (UScalar.cast .Usize (core.num.Usize.leading_zeros self._0.val)).val ≤ w1.val := by + rw [hcast, hw1, hw] + omega + step with Usize.sub_spec hsub as ⟨shift, hshift, _⟩ + have hshift' : shift.val = k := by rw [hcast, hw1, hw] at hshift; omega + step with Usize.ShiftLeft_spec 1#usize shift (by rw [hshift']; exact hk) as ⟨a, ha, _⟩ + have ha' : a.val = 2 ^ k := by + rw [hshift'] at ha + simp only [Nat.shiftLeft_eq, Nat.one_mul] at ha + have hp : 2 ^ k < Usize.size := by + rw [Usize.size, Usize.numBits, UScalarTy.Usize_numBits_eq] + omega + simpa only [Nat.mod_eq_of_lt hp] using ha + have haz : a ≠ 0#usize := by + intro hz + have hz' := congrArg UScalar.val hz + rw [ha'] at hz' + change 2 ^ k = 0 at hz' + have := Nat.two_pow_pos k + omega + simp only [core.num.nonzero.NonZero.new, cast_eq, haz, ↓reduceDIte, + ↓reduceIte, bind_ok, WP.spec_ok] + have hp : self._0.val.val - 2 ^ k < 2 ^ k := by + rw [Nat.pow_succ] at hbound + omega + have hx : (self._0.val ^^^ a).val = self._0.val.val - 2 ^ k := by + rw [UScalar.val_xor, ha'] + have heq : self._0.val.val = 2 ^ k + (self._0.val.val - 2 ^ k) := by omega + rw [heq] + simpa only [Nat.add_sub_cancel_left] using highest_bit_xor k _ hp + refine ⟨?_, ?_, ?_, ha', ?_⟩ + · exact ⟨k, ha'⟩ + · rw [hx, ha'] + exact hp + · rw [hx, ha'] + omega + · exact hx + +/- Reuse the components theorem for the getter that discards phase. +-/ +theorem encoding_align_spec : + ∀ (self : layout.RoundingAlignAndPhase), ∀ (hn : (0 < self._0.val.val : Prop)), + @Zerocopy.layout.RoundingAlignAndPhase.align self ⦃ align => align.val.val = 2 ^ Nat.log2 self._0.val.val ⦄ := by + intro self hn + unfold layout.RoundingAlignAndPhase.align + step with encoding_components_spec self hn as ⟨a, p, _, _, _, ha, _⟩ + simpa only [WP.spec_ok] using ha end Zerocopy.Proofs.Raw namespace Zerocopy.Proofs open AeneasSpecs +-- Unfold only representation domains. The model calls and authored outcome +-- predicates remain the independently verified raw contracts below. +macro "representation_simps" : tactic => + `(tactic| + (simp only [WP.spec_equiv_exists] + simp (config := { index := false }) only [contract_simps] + simp only [← WP.spec_equiv_exists])) + +/- Connect the constructor's power-of-two and phase checks to exact encoding. +The canonical form also proves decoding of the actual returned word. +-/ +theorem encoding_new_spec : Zerocopy.Specs.encoding_new_spec := by + intro align phase av ha pv hp hpow hphase + have hav := (decodeNonZeroUScalar_iff align av).mp ha + have hpv := (decodeUScalar_iff phase pv).mp hp + apply WP.spec_mono (Raw.encoding_new_spec align phase + (by simpa only [hav] using hpow) (by simpa only [hav, hpv] using hphase)) + intro encoded he + have hfit : av.value + pv.value ≤ Usize.max := by + rw [← hav, ← hpv, ← he] + scalar_tac + let value : layout.RoundingAlignAndPhase.RoundingValue := + ⟨av.value, pv.value, hpow, hphase, hfit⟩ + refine ⟨value, (rounding_decode_iff encoded value).mpr ?_, rfl, rfl⟩ + simpa only [roundingRepresents, value, hav, hpv] using he +register_spec_step encoding_new_spec + +/- Prove the implementation's bit scan recovers the alignment and phase of every +positive stored word, not only words built by the constructor. +-/ +theorem encoding_components_spec : Zerocopy.Specs.encoding_components_spec := by + intro self value decoded + have hn := (encoding_valid_iff self).mp ⟨value, decoded⟩ + have hvalue := rounding_decode_components self value decoded + apply WP.spec_mono (Raw.encoding_components_spec self hn) + rintro ⟨align, phase⟩ facts + let av : NonZeroUsizeValue := + ⟨unsignedWord align.val, Nat.pos_of_isPowerOfTwo facts.1⟩ + have ha : modelNonZeroUsize.decode align = some av := + (decodeNonZeroUScalar_iff align av).mpr rfl + refine ⟨(av, unsignedWord phase), ?_, ?_⟩ + · simp only [RustModel.decode, + dif_pos (Nat.pos_of_isPowerOfTwo facts.1)] + rfl + · exact ⟨facts.2.2.2.1.trans hvalue.1.symm, + facts.2.2.2.2.trans hvalue.2.symm⟩ +register_spec_step encoding_components_spec + +/- Reuse the components theorem for the getter that discards phase. +-/ +theorem encoding_align_spec : Zerocopy.Specs.encoding_align_spec := by + intro self value decoded + have hn := (encoding_valid_iff self).mp ⟨value, decoded⟩ + have hvalue := rounding_decode_components self value decoded + apply WP.spec_mono (Raw.encoding_align_spec self hn) + intro align he + have hp : 0 < align.val.val := by rw [he]; exact Nat.two_pow_pos _ + let av : NonZeroUsizeValue := ⟨unsignedWord align.val, hp⟩ + exact ⟨av, (decodeNonZeroUScalar_iff align av).mpr rfl, he.trans hvalue.1.symm⟩ +register_spec_step encoding_align_spec end Zerocopy.Proofs diff --git a/verification/aeneas/lean/RepresentationLaws.lean b/verification/aeneas/lean/RepresentationLaws.lean new file mode 100644 index 0000000000..3c110a6e11 --- /dev/null +++ b/verification/aeneas/lean/RepresentationLaws.lean @@ -0,0 +1,71 @@ +/- 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 MathViews +public import ModelSupport +@[expose] public section + +/-! +Rounding operation proofs need to relate an observed pair to its stored word. +The lemmas here expose that particular decoder's equations and positive +domain. They are useful because this encoding is reversible; the integration +does not require every model to be reversible or to retain every +representation detail. A coarse model can be appropriate when its operation +contracts need no more. Decoder construction proves the model's constraints, +while these lemmas describe what this decoder actually computes. +-/ +open Aeneas Aeneas.Std AeneasSpecs +namespace Zerocopy.Proofs + +/-- The stored word represents exactly the sum of the observed pair. -/ +def roundingRepresents (raw : layout.RoundingAlignAndPhase) + (value : layout.RoundingAlignAndPhase.RoundingValue) : Prop := + raw._0.val.val = value.align + value.phase + +theorem rounding_decode_positive (raw : layout.RoundingAlignAndPhase) + (positive : 0 < raw._0.val.val) : + ∃ value, layout.RoundingAlignAndPhase.decode raw = some value := + (encoding_valid_iff raw).mpr positive + +theorem rounding_decode_components (raw : layout.RoundingAlignAndPhase) + (value : layout.RoundingAlignAndPhase.RoundingValue) + (h : layout.RoundingAlignAndPhase.decode raw = some value) : + value.align = 2 ^ Nat.log2 raw._0.val.val ∧ + value.phase = raw._0.val.val - 2 ^ Nat.log2 raw._0.val.val := by + have positive : 0 < raw._0.val.val := + (encoding_valid_iff raw).mp ⟨value, h⟩ + simp [layout.RoundingAlignAndPhase.decode, layout.RoundingAlignAndPhase.decodeFields, + RustModel.decode, positive, unsignedWord] at h + cases h + exact ⟨rfl, rfl⟩ + +theorem rounding_decode_iff (raw : layout.RoundingAlignAndPhase) + (value : layout.RoundingAlignAndPhase.RoundingValue) : + layout.RoundingAlignAndPhase.decode raw = some value ↔ roundingRepresents raw value := by + constructor + · intro h + obtain ⟨ha, hp⟩ := rounding_decode_components raw value h + have positive := (encoding_valid_iff raw).mp ⟨value, h⟩ + unfold roundingRepresents + rw [ha, hp] + exact (RoundingFacts.reconstruction _ positive).symm + · intro h + have positive : 0 < raw._0.val.val := by + have := Nat.pos_of_isPowerOfTwo value.align_pow2 + unfold roundingRepresents at h + omega + obtain ⟨actual, decoded⟩ := rounding_decode_positive raw positive + obtain ⟨ha, hp⟩ := rounding_decode_components raw actual decoded + have unique := RoundingFacts.components_unique raw._0.val.val value.align value.phase + value.align_pow2 value.phase_lt h + have equal : actual = value := RoundingFacts.value_ext + (ha.trans unique.1.symm) (hp.trans unique.2.symm) + simpa only [equal] using decoded + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/RequiredModelContracts.lean b/verification/aeneas/lean/RequiredModelContracts.lean index f845f2fb68..a3051dee3c 100644 --- a/verification/aeneas/lean/RequiredModelContracts.lean +++ b/verification/aeneas/lean/RequiredModelContracts.lean @@ -8,6 +8,7 @@ those terms. -/ module public import RequiredModelContracts.Util +public import RepresentationLaws @[expose] public section /-! @@ -25,4 +26,59 @@ proved implications available to check_contract's bounded normalization. open Aeneas Aeneas.Std AeneasSpecs namespace Zerocopy.Proofs +attribute [contract_simps] encoding_decoder_admitted_iff + +@[contract_simps] theorem required_encoding_new (align : NonZeroUsize) (phase : Usize) + (run : Result layout.RoundingAlignAndPhase) + (provided : Specs.encoding_new_spec_contract align phase run) : + Obligations.encoding_new_spec_contract align phase run := by + intro positive pow2 below + let av : NonZeroUsizeValue := ⟨unsignedWord align.val, positive⟩ + have admitted := (decodeNonZeroUScalar_iff align av).mpr rfl + have result := provided av admitted (unsignedWord phase) rfl pow2 below + simp only [WP.spec_equiv_exists] at result + obtain ⟨raw, call, value, decoded, ha, hp⟩ := result + refine ⟨raw, call, ?_, ?_⟩ + · exact (encoding_valid_iff raw).mp ⟨value, decoded⟩ + · have represents := (rounding_decode_iff raw value).mp decoded + simpa only [roundingRepresents, ha, hp, av, unsignedWord] using represents + +@[contract_simps] theorem required_encoding_align (raw : layout.RoundingAlignAndPhase) + (run : Result NonZeroUsize) + (provided : Specs.encoding_align_spec_contract raw run) : + Obligations.encoding_align_spec_contract raw run := by + intro positive + obtain ⟨value, decoded⟩ := rounding_decode_positive raw positive + have result := provided value decoded + simp only [WP.spec_equiv_exists] at result + obtain ⟨align, call, av, admitted, facts⟩ := result + have same := (decodeNonZeroUScalar_iff align av).mp admitted + exact ⟨align, call, by rw [same]; exact av.positive, + same.trans (facts.trans (rounding_decode_components raw value decoded).1)⟩ + +@[contract_simps] theorem required_encoding_components (raw : layout.RoundingAlignAndPhase) + (run : Result (NonZeroUsize × Usize)) + (provided : Specs.encoding_components_spec_contract raw run) : + Obligations.encoding_components_spec_contract raw run := by + intro positive + obtain ⟨value, decoded⟩ := rounding_decode_positive raw positive + apply WP.spec_mono (provided value decoded) + rintro ⟨align, phase⟩ ⟨pair, admitted, ha, hp⟩ + have positiveAlign : 0 < align.val.val := + (nonzero_valid_iff align).mp ((prod_valid_iff (align, phase)).mp ⟨pair, admitted⟩).1 + have pairEq : ((⟨unsignedWord align.val, positiveAlign⟩ : NonZeroUsizeValue), + unsignedWord phase) = pair := by + simpa only [RustModel.decode, dif_pos positiveAlign, Option.some.injEq] using admitted + cases pairEq + dsimp only [unsignedWord] at ha hp + have hraw := (rounding_decode_iff raw value).mp decoded + have hcanonical := rounding_decode_components raw value decoded + change 0 < align.val.val ∧ align.val.val.isPowerOfTwo ∧ + phase.val < align.val.val ∧ align.val.val + phase.val = raw._0.val.val ∧ + align.val.val = 2 ^ raw._0.val.val.log2 ∧ + phase.val = raw._0.val.val - 2 ^ raw._0.val.val.log2 + rw [ha, hp] + exact ⟨Nat.pos_of_isPowerOfTwo value.align_pow2, value.align_pow2, value.phase_lt, + hraw.symm, hcanonical.1, hcanonical.2⟩ + end Zerocopy.Proofs diff --git a/verification/aeneas/lean/Zerocopy/FunsExternal.lean b/verification/aeneas/lean/Zerocopy/FunsExternal.lean index c820466380..52612ebaa5 100644 --- a/verification/aeneas/lean/Zerocopy/FunsExternal.lean +++ b/verification/aeneas/lean/Zerocopy/FunsExternal.lean @@ -22,3 +22,20 @@ def core.num.niche_types.NonZeroUsizeInner.Insts.CoreCloneClone.clone @[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 + +-- Extraction uses size_of only for usize to calculate its bit width. Other +-- types remain unsupported rather than receiving arbitrary layout values. +@[simp] noncomputable def core.mem.size_of (T : Type) : Result Usize := by + classical + exact if T = Usize then + .ok { bv := BitVec.ofNat _ (System.Platform.numBits / 8) } + else .fail .panic + +-- The extracted call sites instantiate this primitive only at Usize. +@[simp] noncomputable def core.num.nonzero.NonZero.new + {T Inner : Type} (_inst : core.num.nonzero.ZeroablePrimitive T Inner) + (x : T) : Result (Option (core.num.nonzero.NonZero T Inner)) := by + classical + exact if h : T = Usize then + if (cast h x : Usize) = 0#usize then .ok none else .ok (some ⟨x⟩) + else .fail .panic diff --git a/zerocopy/src/layout/mod.rs b/zerocopy/src/layout/mod.rs index 5f14bd424e..70692d6e5b 100644 --- a/zerocopy/src/layout/mod.rs +++ b/zerocopy/src/layout/mod.rs @@ -47,6 +47,139 @@ pub(crate) enum SizeInfo { SliceDst(TrailingSliceLayout), } +/// The alignment and phase of a rounded size calculation. +/// +/// If `encoded` is the stored non-zero integer, then its highest set bit is a +/// power-of-two alignment `align`, and its remaining lower bits are a `phase` +/// in `0..align`. Thus every non-zero bit pattern is a valid encoding, and: +/// +/// ```text +/// encoded = align + phase +/// ``` +/// +/// ```aeneas +/// model RoundingValue where +/// align : Nat +/// phase : Nat +/// align_pow2 : align.isPowerOfTwo +/// phase_lt : phase < align +/// fits : align + phase ≤ Usize.max +/// decode self => +/// let word := self._0.value +/// let align := 2 ^ Nat.log2 word +/// { align := align, phase := word - align, .. } +/// ``` +#[cfg_attr(any(kani, test), derive(Debug, PartialEq, Eq))] +#[repr(transparent)] +#[derive(Copy, Clone)] +#[cfg_attr(kani, derive(kani::Arbitrary))] +#[allow(dead_code)] +pub(crate) struct RoundingAlignAndPhase(NonZeroUsize); + +// The following layout migration will consume this representation. +#[allow(dead_code)] +impl RoundingAlignAndPhase { + /// Encodes `align` and `phase`. + /// + /// # Panics + /// + /// Panics if `align` is not a power of two or `phase` is not less than + /// `align`. + #[inline(always)] + #[cfg_attr(kani, kani::requires(align.is_power_of_two() && phase < align.get()))] + #[cfg_attr(kani, kani::ensures(|result| result.0.get() == align.get() + phase))] + /// + /// ```aeneas + /// spec encoding_new_spec + /// requires ha : (align : Nat).isPowerOfTwo + /// requires hp : (phase : Nat) < (align : Nat) + /// ensures encoded => encoded.align = (align : Nat) ∧ encoded.phase = (phase : Nat) + /// ``` + pub(crate) const fn new(align: NonZeroUsize, phase: usize) -> Self { + #[cfg(kani)] + #[kani::proof_for_contract(RoundingAlignAndPhase::new)] + #[kani::solver(kissat)] + fn proof() { + RoundingAlignAndPhase::new(kani::any(), kani::any()); + } + + const_assert!(align.get().is_power_of_two()); + const_assert!(phase < align.get()); + + // Since `align` is a power of two and `phase < align`, their set bits + // are disjoint. The result is therefore non-zero and losslessly stores + // both components. + let encoded = align.get() | phase; + match NonZeroUsize::new(encoded) { + Some(encoded) => Self(encoded), + None => const_unreachable!(), + } + } + + /// Decodes the alignment and phase. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&(align, phase)| { + align.is_power_of_two() && phase < align.get() + && align.get().checked_add(phase) == Some(self.0.get()) + }))] + /// + /// ```aeneas + /// spec encoding_components_spec + /// ensures (align, phase) => + /// align.value = self.align ∧ phase.value = self.phase + /// ``` + pub(crate) const fn components(self) -> (NonZeroUsize, usize) { + #[cfg(kani)] + #[kani::proof_for_contract(RoundingAlignAndPhase::components)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().components(); + } + // `leading_zeros <= POINTER_WIDTH_BITS - 1` because the encoded value + // is non-zero. Converting `leading_zeros` to `usize` cannot truncate + // because it is at most the number of bits in a `usize`. + // Keep the primitive usize operation visible to Aeneas. + #[allow( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::needless_nonzero_get + )] + let shift = (POINTER_WIDTH_BITS - 1) - (self.0.get().leading_zeros() as usize); + #[allow(clippy::arithmetic_side_effects)] + let align = 1usize << shift; + let align = match NonZeroUsize::new(align) { + Some(align) => align, + None => const_unreachable!(), + }; + // `align` is the highest set bit, so XOR removes exactly that bit and + // leaves a value in `0..align`. + let phase = self.0.get() ^ align.get(); + (align, phase) + } + + /// Decodes the alignment, which is the highest set bit of `self`. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|align| { + align.is_power_of_two() && align.get() <= self.0.get() + && self.0.get() - align.get() < align.get() + }))] + /// + /// ```aeneas + /// spec encoding_align_spec + /// ensures align => (align : Nat) = self.align + /// ``` + pub(crate) const fn align(self) -> NonZeroUsize { + #[cfg(kani)] + #[kani::proof_for_contract(RoundingAlignAndPhase::align)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().align(); + } + + self.components().0 + } +} + #[cfg_attr(any(kani, test), derive(Debug, PartialEq, Eq))] #[derive(Copy, Clone)] pub(crate) struct TrailingSliceLayout { @@ -766,6 +899,46 @@ mod cast_from; mod tests { use super::*; + const TEST_SIZE_ROUNDING_ALIGN_AND_PHASE_ALIGN: NonZeroUsize = match NonZeroUsize::new(8) { + Some(align) => align, + None => const_unreachable!(), + }; + const TEST_SIZE_ROUNDING_ALIGN_AND_PHASE: RoundingAlignAndPhase = + RoundingAlignAndPhase::new(TEST_SIZE_ROUNDING_ALIGN_AND_PHASE_ALIGN, 3); + const TEST_SIZE_ROUNDING_ALIGN_AND_PHASE_DECODED_ALIGN: usize = + TEST_SIZE_ROUNDING_ALIGN_AND_PHASE.align().get(); + const TEST_SIZE_ROUNDING_ALIGN_AND_PHASE_DECODED_PHASE: usize = + TEST_SIZE_ROUNDING_ALIGN_AND_PHASE.components().1; + + #[test] + #[allow(clippy::arithmetic_side_effects)] + fn test_size_rounding_align_and_phase_encoding() { + // These constants ensure that encoding and decoding remain evaluable + // on every supported compiler, including the MSRV. + assert_eq!(TEST_SIZE_ROUNDING_ALIGN_AND_PHASE_DECODED_ALIGN, 8); + assert_eq!(TEST_SIZE_ROUNDING_ALIGN_AND_PHASE_DECODED_PHASE, 3); + + for shift in 0..POINTER_WIDTH_BITS { + #[allow(clippy::arithmetic_side_effects)] + let align = NonZeroUsize::new(1usize << shift).unwrap(); + for phase in [0, align.get() / 2, align.get() - 1] { + let rounding = RoundingAlignAndPhase::new(align, phase); + assert_eq!(rounding.align(), align); + assert_eq!(rounding.components().1, phase); + assert_eq!(rounding.0.get(), align.get() | phase); + } + } + + let max_align = DstLayout::THEORETICAL_MAX_ALIGN; + let max_phase = max_align.get() - 1; + let max = RoundingAlignAndPhase::new(max_align, max_phase); + assert_eq!(max.0.get(), usize::MAX); + assert_eq!(max.align(), max_align); + assert_eq!(max.components().1, max_phase); + + assert_eq!(mem::size_of::(), mem::size_of::()); + } + #[test] fn test_dst_layout_for_slice() { let layout = DstLayout::for_slice::(); @@ -1674,6 +1847,31 @@ mod proofs { } } + #[kani::proof] + fn prove_size_rounding_align_and_phase_encoding() { + let encoded: usize = kani::any(); + kani::assume(encoded != 0); + let encoded = NonZeroUsize::new(encoded).unwrap(); + let rounding = RoundingAlignAndPhase(encoded); + + let align = rounding.align(); + let phase = rounding.components().1; + assert!(align.is_power_of_two()); + assert!(phase < align.get()); + assert_eq!(RoundingAlignAndPhase::new(align, phase), rounding); + } + + #[kani::proof] + fn prove_size_rounding_align_and_phase_decoding() { + let align: NonZeroUsize = kani::any(); + let phase: usize = kani::any(); + kani::assume(align.is_power_of_two()); + kani::assume(phase < align.get()); + + let rounding = RoundingAlignAndPhase::new(align, phase); + assert_eq!(rounding.components(), (align, phase)); + } + #[kani::proof] fn prove_requires_dynamic_padding() { let layout: DstLayout = kani::any(); From a71947f2507ecfdc32e9dabef0ba70d1595ee139 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 10:36:20 -0400 Subject: [PATCH 10/12] Preserve nested DST padding throughout runtime layout operations Represent trailing layouts with their rounding alignment and phase so runtime normalization preserves complete nested sizes. Keep this implementation change separate from the later primitive, trailing and padding proof layers. The packed regression has size 10 where the former calculation produced 8. Preserve compiler-layout regressions, Kani harnesses and generated expectations with the runtime change; the next change adds the independent recursive arithmetic oracle. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. Makes progress on #3617 gherrit-pr-id: Gcrebpateb5cosfnz4zvqhnx2xldzt6vv Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/README.md | 4 +- verification/aeneas/golden/Funs.lean | 14 +- verification/aeneas/golden/Types.lean | 2 +- verification/aeneas/lean/LayoutModel.lean | 29 + verification/aeneas/lean/Obligations.lean | 1 + .../ref_from_bytes_dynamic_padding.x86-64 | 31 +- .../ref_from_bytes_dynamic_padding.x86-64.mca | 70 +- .../ref_from_bytes_dynamic_size.x86-64 | 21 +- .../ref_from_bytes_dynamic_size.x86-64.mca | 58 +- .../ref_from_prefix_dynamic_padding.x86-64 | 14 +- ...ref_from_prefix_dynamic_padding.x86-64.mca | 48 +- .../ref_from_suffix_dynamic_padding.x86-64 | 29 +- ...ref_from_suffix_dynamic_padding.x86-64.mca | 68 +- ...t_via_runtime_check_dynamic_padding.x86-64 | 5 +- ...a_runtime_check_dynamic_padding.x86-64.mca | 30 +- .../try_ref_from_bytes_dynamic_padding.x86-64 | 34 +- ..._ref_from_bytes_dynamic_padding.x86-64.mca | 76 +- .../try_ref_from_bytes_dynamic_size.x86-64 | 31 +- ...try_ref_from_bytes_dynamic_size.x86-64.mca | 70 +- ...try_ref_from_prefix_dynamic_padding.x86-64 | 14 +- ...ref_from_prefix_dynamic_padding.x86-64.mca | 58 +- ...try_ref_from_suffix_dynamic_padding.x86-64 | 29 +- ...ref_from_suffix_dynamic_padding.x86-64.mca | 74 +- zerocopy/src/layout/cast_from.rs | 299 +- zerocopy/src/layout/mod.rs | 2918 +++++++++++++++-- zerocopy/src/lib.rs | 195 +- zerocopy/src/macros.rs | 32 + zerocopy/src/pointer/inner.rs | 35 +- zerocopy/src/pointer/mod.rs | 28 +- zerocopy/src/split_at.rs | 23 +- zerocopy/src/util/mod.rs | 30 +- zerocopy/tests/codegen.rs | 15 +- 32 files changed, 3506 insertions(+), 879 deletions(-) create mode 100644 verification/aeneas/lean/LayoutModel.lean diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md index 039fc48590..77a2d614bc 100644 --- a/verification/aeneas/README.md +++ b/verification/aeneas/README.md @@ -127,9 +127,7 @@ added without extending the specification set. | `min` | Returns the mathematical minimum, selects an input, and bounds both inputs from below. | | `padding_needed_for` | For power-of-two alignment, returns padding below it and exactly `(align - len % align) % align`, the least padding making the sum aligned, with zero padding exactly when the input is aligned. | | `round_down_to_next_multiple_of_alignment` | For power-of-two alignment, returns exactly `n - n % align`, the greatest aligned value at most `n`; the next multiple exceeds `n`. | -| `RoundingAlignAndPhase::new` | For power-of-two alignment `A` and phase `P < A`, successfully encodes exactly `A + P`. | -| `RoundingAlignAndPhase::components` | For any nonzero encoded word, returns its highest set bit as a power-of-two alignment and the remaining lower bits as a phase below that alignment; their sum is the original word. | -| `RoundingAlignAndPhase::align` | For any nonzero encoded word, returns exactly its highest set bit. | +| Alignment/phase encoder and decoders | Power-of-two alignment, bounded phase, and exact encoding round-trip. | Every registered function uses total `spec`: accepted raw representations, supplied mathematical ghosts, and explicit requirements imply successful diff --git a/verification/aeneas/golden/Funs.lean b/verification/aeneas/golden/Funs.lean index 414d5e1fac..daf298db34 100644 --- a/verification/aeneas/golden/Funs.lean +++ b/verification/aeneas/golden/Funs.lean @@ -72,7 +72,7 @@ def layout.POINTER_WIDTH_BITS : Result Std.Usize := do i * 8#usize /-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::new]: - Source: 'src/layout/mod.rs', lines 98:4-117:5 -/ + Source: 'src/layout/mod.rs', lines 97:4-116:5 -/ def layout.RoundingAlignAndPhase.new (align : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner) (phase : Std.Usize) : @@ -93,7 +93,7 @@ def layout.RoundingAlignAndPhase.new | some encoded1 => ok { _0 := encoded1 } /-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::components]: - Source: 'src/layout/mod.rs', lines 131:4-158:5 -/ + Source: 'src/layout/mod.rs', lines 130:4-157:5 -/ def layout.RoundingAlignAndPhase.components (self : layout.RoundingAlignAndPhase) : Result ((core.num.nonzero.NonZero Std.Usize @@ -121,7 +121,7 @@ def layout.RoundingAlignAndPhase.components ok (align1, phase) /-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::align]: - Source: 'src/layout/mod.rs', lines 171:4-180:5 -/ + Source: 'src/layout/mod.rs', lines 170:4-179:5 -/ def layout.RoundingAlignAndPhase.align (self : layout.RoundingAlignAndPhase) : Result (core.num.nonzero.NonZero Std.Usize @@ -131,7 +131,7 @@ def layout.RoundingAlignAndPhase.align ok nz /-- [zerocopy::util::round_down_to_next_multiple_of_alignment]: - Source: 'src/util/mod.rs', lines 260:0-278:1 -/ + Source: 'src/util/mod.rs', lines 261:0-279:1 -/ def util.round_down_to_next_multiple_of_alignment (n : Std.Usize) (align : core.num.nonzero.NonZero Std.Usize @@ -148,7 +148,7 @@ def util.round_down_to_next_multiple_of_alignment ok (n &&& mask) /-- [zerocopy::util::padding_needed_for]: - Source: 'src/util/mod.rs', lines 168:0-228:1 -/ + Source: 'src/util/mod.rs', lines 169:0-229:1 -/ def util.padding_needed_for (len : Std.Usize) (align : core.num.nonzero.NonZero Std.Usize @@ -188,7 +188,7 @@ def util.checks.check_arithmetic else ok () /-- [zerocopy::util::max]: - Source: 'src/util/mod.rs', lines 288:0-294:1 -/ + Source: 'src/util/mod.rs', lines 289:0-295:1 -/ def util.max (a : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner) @@ -208,7 +208,7 @@ def util.max else ok a /-- [zerocopy::util::min]: - Source: 'src/util/mod.rs', lines 304:0-310:1 -/ + Source: 'src/util/mod.rs', lines 305:0-311:1 -/ def util.min (a : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner) diff --git a/verification/aeneas/golden/Types.lean b/verification/aeneas/golden/Types.lean index 7d69d1fcc5..d081d8f6b7 100644 --- a/verification/aeneas/golden/Types.lean +++ b/verification/aeneas/golden/Types.lean @@ -40,7 +40,7 @@ structure core.num.nonzero.ZeroablePrimitive (Self : Type) (Self_NonZeroInner : innerCopyInst : core.marker.Copy Self_NonZeroInner /-- [zerocopy::layout::RoundingAlignAndPhase] - Source: 'src/layout/mod.rs', lines 77:0-77:54 -/ + Source: 'src/layout/mod.rs', lines 78:0-78:54 -/ structure layout.RoundingAlignAndPhase where _0 : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner diff --git a/verification/aeneas/lean/LayoutModel.lean b/verification/aeneas/lean/LayoutModel.lean new file mode 100644 index 0000000000..d2859cdab2 --- /dev/null +++ b/verification/aeneas/lean/LayoutModel.lean @@ -0,0 +1,29 @@ +/- 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 LayoutMath +@[expose] public section + +/-! +Extracted trailing records contain machine words and compressed rounding +encodings. This module interprets those records as an unbounded complete-size +formula and a separately observed physical slice offset. Its definitions do +not call an extracted operation or assume that an operation is correct. + +Keeping physical offset separate from normalized base and phase is essential: +inner padding can make them differ. Operation proofs use these observations +to connect extracted arithmetic with the independent layout mathematics. +-/ +open Aeneas Aeneas.Std +namespace Zerocopy.Proofs + + +end Zerocopy.Proofs diff --git a/verification/aeneas/lean/Obligations.lean b/verification/aeneas/lean/Obligations.lean index d04b45d63e..19453e6e6c 100644 --- a/verification/aeneas/lean/Obligations.lean +++ b/verification/aeneas/lean/Obligations.lean @@ -11,6 +11,7 @@ public import Zerocopy.Funs public import SpecPrelude public import MathViews public import Arithmetic +public import LayoutModel @[expose] public section /-! diff --git a/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64 b/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64 index e844a4608f..7f695f942e 100644 --- a/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64 +++ b/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64 @@ -1,22 +1,21 @@ bench_ref_from_bytes_dynamic_padding: - test dil, 3 - jne .LBB5_3 - movabs rax, 9223372036854775804 - and rax, rsi - cmp rax, 9 - jb .LBB5_3 - add rax, -9 + cmp rsi, 12 + setae al + mov ecx, esi + or ecx, edi + test cl, 3 + sete cl + and cl, al + cmp cl, 1 + je .LBB5_2 + xor edi, edi + mov rax, rdi + ret +.LBB5_2: + add rsi, -9 movabs rcx, -6148914691236517205 + mov rax, rsi mul rcx shr rdx - lea rax, [rdx + 2*rdx] - or rax, 3 - add rax, 9 - cmp rsi, rax - je .LBB5_4 -.LBB5_3: - xor edi, edi - mov rdx, rsi -.LBB5_4: mov rax, rdi ret diff --git a/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64.mca b/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64.mca index 423ed38ba2..5eb4fd1a11 100644 --- a/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64.mca +++ b/zerocopy/benches/ref_from_bytes_dynamic_padding.x86-64.mca @@ -1,11 +1,11 @@ Iterations: 100 Instructions: 1900 -Total Cycles: 645 +Total Cycles: 641 Total uOps: 2000 Dispatch Width: 4 -uOps Per Cycle: 3.10 -IPC: 2.95 +uOps Per Cycle: 3.12 +IPC: 2.96 Block RThroughput: 5.0 @@ -18,23 +18,23 @@ Instruction Info: [6]: HasSideEffects (U) [1] [2] [3] [4] [5] [6] Instructions: - 1 1 0.33 test dil, 3 - 1 1 1.00 jne .LBB5_3 - 1 1 0.33 movabs rax, 9223372036854775804 - 1 1 0.33 and rax, rsi - 1 1 0.33 cmp rax, 9 - 1 1 1.00 jb .LBB5_3 - 1 1 0.33 add rax, -9 + 1 1 0.33 cmp rsi, 12 + 1 1 0.50 setae al + 1 1 0.33 mov ecx, esi + 1 1 0.33 or ecx, edi + 1 1 0.33 test cl, 3 + 1 1 0.50 sete cl + 1 1 0.33 and cl, al + 1 1 0.33 cmp cl, 1 + 1 1 1.00 je .LBB5_2 + 1 0 0.25 xor edi, edi + 1 1 0.33 mov rax, rdi + 1 1 1.00 U ret + 1 1 0.33 add rsi, -9 1 1 0.33 movabs rcx, -6148914691236517205 + 1 1 0.33 mov rax, rsi 2 4 1.00 mul rcx 1 1 0.50 shr rdx - 1 1 0.50 lea rax, [rdx + 2*rdx] - 1 1 0.33 or rax, 3 - 1 1 0.33 add rax, 9 - 1 1 0.33 cmp rsi, rax - 1 1 1.00 je .LBB5_4 - 1 0 0.25 xor edi, edi - 1 1 0.33 mov rdx, rsi 1 1 0.33 mov rax, rdi 1 1 1.00 U ret @@ -52,26 +52,26 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 6.32 6.33 - 6.35 - - + - - 6.33 6.33 - 6.34 - - Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.64 0.35 - 0.01 - - test dil, 3 - - - - - - 1.00 - - jne .LBB5_3 - - - 0.34 0.65 - 0.01 - - movabs rax, 9223372036854775804 - - - 0.35 0.65 - - - - and rax, rsi - - - 0.33 0.34 - 0.33 - - cmp rax, 9 - - - - - - 1.00 - - jb .LBB5_3 - - - 0.35 - - 0.65 - - add rax, -9 - - - 0.97 0.01 - 0.02 - - movabs rcx, -6148914691236517205 - - - 1.00 1.00 - - - - mul rcx - - - 0.99 - - 0.01 - - shr rdx - - - 0.33 0.67 - - - - lea rax, [rdx + 2*rdx] - - - 0.34 0.66 - - - - or rax, 3 - - - 0.33 0.66 - 0.01 - - add rax, 9 - - - 0.01 0.99 - - - - cmp rsi, rax - - - - - - 1.00 - - je .LBB5_4 + - - 0.99 - - 0.01 - - cmp rsi, 12 + - - 1.00 - - - - - setae al + - - - 0.34 - 0.66 - - mov ecx, esi + - - - 0.99 - 0.01 - - or ecx, edi + - - - 1.00 - - - - test cl, 3 + - - 0.67 - - 0.33 - - sete cl + - - - 1.00 - - - - and cl, al + - - - 1.00 - - - - cmp cl, 1 + - - - - - 1.00 - - je .LBB5_2 - - - - - - - - xor edi, edi - - - 0.32 0.01 - 0.67 - - mov rdx, rsi - - - 0.02 0.34 - 0.64 - - mov rax, rdi + - - 0.66 - - 0.34 - - mov rax, rdi + - - - - - 1.00 - - ret + - - 0.35 0.01 - 0.64 - - add rsi, -9 + - - 0.65 0.33 - 0.02 - - movabs rcx, -6148914691236517205 + - - 0.01 0.33 - 0.66 - - mov rax, rsi + - - 1.00 1.00 - - - - mul rcx + - - 1.00 - - - - - shr rdx + - - - 0.33 - 0.67 - - mov rax, rdi - - - - - 1.00 - - ret diff --git a/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64 b/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64 index cc905b76c0..43cadf3527 100644 --- a/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64 +++ b/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64 @@ -1,20 +1,11 @@ bench_ref_from_bytes_dynamic_size: mov rdx, rsi - cmp rsi, 4 - setb al - or al, dil - test al, 1 - je .LBB5_2 + mov ecx, edx + or ecx, edi xor eax, eax - ret -.LBB5_2: - lea rcx, [rdx - 4] - mov rsi, rcx - and rsi, -2 - add rsi, 4 - shr rcx - xor eax, eax - cmp rdx, rsi - cmove rdx, rcx + sub rdx, 4 + cmovb rdi, rax + shr rdx + test cl, 1 cmove rax, rdi ret diff --git a/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64.mca b/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64.mca index 68aea583e4..444f4fad4f 100644 --- a/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64.mca +++ b/zerocopy/benches/ref_from_bytes_dynamic_size.x86-64.mca @@ -1,12 +1,12 @@ Iterations: 100 -Instructions: 1800 -Total Cycles: 704 -Total uOps: 2000 +Instructions: 1000 +Total Cycles: 373 +Total uOps: 1200 Dispatch Width: 4 -uOps Per Cycle: 2.84 -IPC: 2.56 -Block RThroughput: 5.0 +uOps Per Cycle: 3.22 +IPC: 2.68 +Block RThroughput: 3.0 Instruction Info: @@ -19,21 +19,13 @@ Instruction Info: [1] [2] [3] [4] [5] [6] Instructions: 1 1 0.33 mov rdx, rsi - 1 1 0.33 cmp rsi, 4 - 1 1 0.50 setb al - 1 1 0.33 or al, dil - 1 1 0.33 test al, 1 - 1 1 1.00 je .LBB5_2 + 1 1 0.33 mov ecx, edx + 1 1 0.33 or ecx, edi 1 0 0.25 xor eax, eax - 1 1 1.00 U ret - 1 1 0.50 lea rcx, [rdx - 4] - 1 1 0.33 mov rsi, rcx - 1 1 0.33 and rsi, -2 - 1 1 0.33 add rsi, 4 - 1 1 0.50 shr rcx - 1 0 0.25 xor eax, eax - 1 1 0.33 cmp rdx, rsi - 2 2 0.67 cmove rdx, rcx + 1 1 0.33 sub rdx, 4 + 2 2 0.67 cmovb rdi, rax + 1 1 0.50 shr rdx + 1 1 0.33 test cl, 1 2 2 0.67 cmove rax, rdi 1 1 1.00 U ret @@ -51,25 +43,17 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 5.97 5.98 - 6.05 - - + - - 3.66 3.67 - 3.67 - - Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.97 0.01 - 0.02 - - mov rdx, rsi - - - 0.01 0.02 - 0.97 - - cmp rsi, 4 - - - 0.03 - - 0.97 - - setb al - - - 0.01 0.02 - 0.97 - - or al, dil - - - - 0.98 - 0.02 - - test al, 1 - - - - - - 1.00 - - je .LBB5_2 - - - - - - - - - xor eax, eax - - - - - - 1.00 - - ret - - - 0.98 0.02 - - - - lea rcx, [rdx - 4] - - - 0.01 0.99 - - - - mov rsi, rcx - - - - 0.98 - 0.02 - - and rsi, -2 - - - 0.98 0.01 - 0.01 - - add rsi, 4 - - - 0.99 - - 0.01 - - shr rcx + - - 0.32 0.01 - 0.67 - - mov rdx, rsi + - - 0.33 0.35 - 0.32 - - mov ecx, edx + - - 0.01 0.34 - 0.65 - - or ecx, edi - - - - - - - - xor eax, eax - - - 0.02 0.97 - 0.01 - - cmp rdx, rsi - - - 0.99 0.99 - 0.02 - - cmove rdx, rcx - - - 0.98 0.99 - 0.03 - - cmove rax, rdi + - - 0.67 0.33 - - - - sub rdx, 4 + - - 0.99 0.98 - 0.03 - - cmovb rdi, rax + - - 0.03 - - 0.97 - - shr rdx + - - 0.32 0.67 - 0.01 - - test cl, 1 + - - 0.99 0.99 - 0.02 - - cmove rax, rdi - - - - - 1.00 - - ret diff --git a/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64 b/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64 index f380fee6c2..e7f7820a69 100644 --- a/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64 +++ b/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64 @@ -1,19 +1,19 @@ bench_ref_from_prefix_dynamic_padding: test dil, 3 + sete al + cmp rsi, 12 + setae cl + test al, cl jne .LBB5_2 - movabs rax, 9223372036854775804 - and rsi, rax - cmp rsi, 8 - ja .LBB5_3 -.LBB5_2: xor edx, edx test dil, 3 sete dl xor edi, edi mov rax, rdi ret -.LBB5_3: - add rsi, -9 +.LBB5_2: + or rsi, 3 + add rsi, -12 movabs rcx, -6148914691236517205 mov rax, rsi mul rcx diff --git a/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64.mca b/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64.mca index 499c58d94b..a3d68f37a8 100644 --- a/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64.mca +++ b/zerocopy/benches/ref_from_prefix_dynamic_padding.x86-64.mca @@ -1,12 +1,12 @@ Iterations: 100 -Instructions: 1900 -Total Cycles: 607 -Total uOps: 2000 +Instructions: 2000 +Total Cycles: 640 +Total uOps: 2100 Dispatch Width: 4 -uOps Per Cycle: 3.29 +uOps Per Cycle: 3.28 IPC: 3.13 -Block RThroughput: 5.0 +Block RThroughput: 5.3 Instruction Info: @@ -19,18 +19,19 @@ Instruction Info: [1] [2] [3] [4] [5] [6] Instructions: 1 1 0.33 test dil, 3 + 1 1 0.50 sete al + 1 1 0.33 cmp rsi, 12 + 1 1 0.50 setae cl + 1 1 0.33 test al, cl 1 1 1.00 jne .LBB5_2 - 1 1 0.33 movabs rax, 9223372036854775804 - 1 1 0.33 and rsi, rax - 1 1 0.33 cmp rsi, 8 - 1 1 1.00 ja .LBB5_3 1 0 0.25 xor edx, edx 1 1 0.33 test dil, 3 1 1 0.50 sete dl 1 0 0.25 xor edi, edi 1 1 0.33 mov rax, rdi 1 1 1.00 U ret - 1 1 0.33 add rsi, -9 + 1 1 0.33 or rsi, 3 + 1 1 0.33 add rsi, -12 1 1 0.33 movabs rcx, -6148914691236517205 1 1 0.33 mov rax, rsi 2 4 1.00 mul rcx @@ -52,26 +53,27 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 6.00 5.99 - 6.01 - - + - - 6.33 6.33 - 6.34 - - Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.01 0.98 - 0.01 - - test dil, 3 + - - 0.65 0.34 - 0.01 - - test dil, 3 + - - 0.99 - - 0.01 - - sete al + - - 0.01 0.33 - 0.66 - - cmp rsi, 12 + - - 0.34 - - 0.66 - - setae cl + - - 1.00 - - - - - test al, cl - - - - - 1.00 - - jne .LBB5_2 - - - 0.98 0.01 - 0.01 - - movabs rax, 9223372036854775804 - - - 0.01 0.99 - - - - and rsi, rax - - - 0.01 0.99 - - - - cmp rsi, 8 - - - - - - 1.00 - - ja .LBB5_3 - - - - - - - - xor edx, edx - - - 0.99 0.01 - - - - test dil, 3 - - - 0.99 - - 0.01 - - sete dl + - - - 0.99 - 0.01 - - test dil, 3 + - - - - - 1.00 - - sete dl - - - - - - - - xor edi, edi - - - - 0.01 - 0.99 - - mov rax, rdi + - - - 1.00 - - - - mov rax, rdi - - - - - 1.00 - - ret - - - 0.01 0.99 - - - - add rsi, -9 - - - - 1.00 - - - - movabs rcx, -6148914691236517205 - - - 1.00 - - - - - mov rax, rsi + - - 0.01 0.99 - - - - or rsi, 3 + - - 0.33 0.67 - - - - add rsi, -12 + - - 0.66 0.34 - - - - movabs rcx, -6148914691236517205 + - - 0.34 0.33 - 0.33 - - mov rax, rsi - - 1.00 1.00 - - - - mul rcx - - 1.00 - - - - - shr rdx - - - - 0.01 - 0.99 - - mov rax, rdi + - - - 0.34 - 0.66 - - mov rax, rdi - - - - - 1.00 - - ret diff --git a/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64 b/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64 index 3e05f6023f..68f21fe801 100644 --- a/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64 +++ b/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64 @@ -1,23 +1,22 @@ bench_ref_from_suffix_dynamic_padding: lea eax, [rsi + rdi] test al, 3 - jne .LBB5_1 - movabs rax, 9223372036854775804 - and rax, rsi - cmp rax, 9 - jae .LBB5_3 -.LBB5_1: - xor eax, eax + setne al + cmp rsi, 12 + setb cl + or cl, al + je .LBB5_2 + xor edi, edi + mov rax, rdi ret -.LBB5_3: - add rax, -9 +.LBB5_2: + mov rax, rsi + or rax, 3 + add rax, -12 movabs rcx, -6148914691236517205 mul rcx shr rdx - lea rax, [rdx + 2*rdx] - sub rsi, rax - or rax, -4 - add rsi, rdi - add rax, rsi - add rax, -8 + and esi, 3 + add rdi, rsi + mov rax, rdi ret diff --git a/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64.mca b/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64.mca index 73599d5b6a..c31e098aed 100644 --- a/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64.mca +++ b/zerocopy/benches/ref_from_suffix_dynamic_padding.x86-64.mca @@ -1,11 +1,11 @@ Iterations: 100 Instructions: 2000 -Total Cycles: 682 +Total Cycles: 673 Total uOps: 2100 Dispatch Width: 4 -uOps Per Cycle: 3.08 -IPC: 2.93 +uOps Per Cycle: 3.12 +IPC: 2.97 Block RThroughput: 5.3 @@ -20,23 +20,23 @@ Instruction Info: [1] [2] [3] [4] [5] [6] Instructions: 1 1 0.50 lea eax, [rsi + rdi] 1 1 0.33 test al, 3 - 1 1 1.00 jne .LBB5_1 - 1 1 0.33 movabs rax, 9223372036854775804 - 1 1 0.33 and rax, rsi - 1 1 0.33 cmp rax, 9 - 1 1 1.00 jae .LBB5_3 - 1 0 0.25 xor eax, eax + 1 1 0.50 setne al + 1 1 0.33 cmp rsi, 12 + 1 1 0.50 setb cl + 1 1 0.33 or cl, al + 1 1 1.00 je .LBB5_2 + 1 0 0.25 xor edi, edi + 1 1 0.33 mov rax, rdi 1 1 1.00 U ret - 1 1 0.33 add rax, -9 + 1 1 0.33 mov rax, rsi + 1 1 0.33 or rax, 3 + 1 1 0.33 add rax, -12 1 1 0.33 movabs rcx, -6148914691236517205 2 4 1.00 mul rcx 1 1 0.50 shr rdx - 1 1 0.50 lea rax, [rdx + 2*rdx] - 1 1 0.33 sub rsi, rax - 1 1 0.33 or rax, -4 - 1 1 0.33 add rsi, rdi - 1 1 0.33 add rax, rsi - 1 1 0.33 add rax, -8 + 1 1 0.33 and esi, 3 + 1 1 0.33 add rdi, rsi + 1 1 0.33 mov rax, rdi 1 1 1.00 U ret @@ -53,27 +53,27 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 6.65 6.67 - 6.68 - - + - - 6.67 6.66 - 6.67 - - Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.90 0.10 - - - - lea eax, [rsi + rdi] - - - 0.93 - - 0.07 - - test al, 3 - - - - - - 1.00 - - jne .LBB5_1 - - - 0.51 0.47 - 0.02 - - movabs rax, 9223372036854775804 - - - - - - 1.00 - - and rax, rsi - - - - 0.09 - 0.91 - - cmp rax, 9 - - - - - - 1.00 - - jae .LBB5_3 - - - - - - - - - xor eax, eax + - - 0.35 0.65 - - - - lea eax, [rsi + rdi] + - - 0.01 0.99 - - - - test al, 3 + - - 1.00 - - - - - setne al + - - 0.97 - - 0.03 - - cmp rsi, 12 + - - 0.66 - - 0.34 - - setb cl + - - - 1.00 - - - - or cl, al + - - - - - 1.00 - - je .LBB5_2 + - - - - - - - - xor edi, edi + - - 0.01 0.01 - 0.98 - - mov rax, rdi - - - - - 1.00 - - ret - - - 0.43 0.47 - 0.10 - - add rax, -9 - - - 0.42 0.39 - 0.19 - - movabs rcx, -6148914691236517205 + - - 0.01 0.99 - - - - mov rax, rsi + - - - 0.34 - 0.66 - - or rax, 3 + - - - 1.00 - - - - add rax, -12 + - - 0.66 0.31 - 0.03 - - movabs rcx, -6148914691236517205 - - 1.00 1.00 - - - - mul rcx - - - 0.69 - - 0.31 - - shr rdx - - - 0.54 0.46 - - - - lea rax, [rdx + 2*rdx] - - - 0.07 0.91 - 0.02 - - sub rsi, rax - - - 0.91 0.05 - 0.04 - - or rax, -4 - - - 0.08 0.90 - 0.02 - - add rsi, rdi - - - 0.09 0.91 - - - - add rax, rsi - - - 0.08 0.92 - - - - add rax, -8 + - - 1.00 - - - - - shr rdx + - - 0.67 0.01 - 0.32 - - and esi, 3 + - - 0.32 0.01 - 0.67 - - add rdi, rsi + - - 0.01 0.35 - 0.64 - - mov rax, rdi - - - - - 1.00 - - ret diff --git a/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64 b/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64 index 03684cb09f..0a8a4f501a 100644 --- a/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64 +++ b/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64 @@ -1,9 +1,8 @@ bench_split_via_runtime_check_dynamic_padding: mov rax, rdi mov rdx, qword ptr [rsi + 16] - mov ecx, edx - and ecx, 3 - cmp ecx, 1 + lea ecx, [rdx - 1] + test cl, 3 jne .LBB5_1 mov rcx, qword ptr [rsi] mov rsi, qword ptr [rsi + 8] diff --git a/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64.mca b/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64.mca index 5034ab0583..7140b06e88 100644 --- a/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64.mca +++ b/zerocopy/benches/split_via_runtime_check_dynamic_padding.x86-64.mca @@ -1,11 +1,11 @@ Iterations: 100 -Instructions: 2000 +Instructions: 1900 Total Cycles: 708 -Total uOps: 2000 +Total uOps: 1900 Dispatch Width: 4 -uOps Per Cycle: 2.82 -IPC: 2.82 +uOps Per Cycle: 2.68 +IPC: 2.68 Block RThroughput: 5.0 @@ -20,9 +20,8 @@ Instruction Info: [1] [2] [3] [4] [5] [6] Instructions: 1 1 0.33 mov rax, rdi 1 5 0.50 * mov rdx, qword ptr [rsi + 16] - 1 1 0.33 mov ecx, edx - 1 1 0.33 and ecx, 3 - 1 1 0.33 cmp ecx, 1 + 1 1 0.50 lea ecx, [rdx - 1] + 1 1 0.33 test cl, 3 1 1 1.00 jne .LBB5_1 1 5 0.50 * mov rcx, qword ptr [rsi] 1 5 0.50 * mov rsi, qword ptr [rsi + 8] @@ -53,22 +52,21 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 3.00 3.02 5.00 4.98 4.00 4.00 + - - 2.99 3.00 5.00 4.01 4.00 4.00 Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - - 0.99 - 0.01 - - mov rax, rdi + - - 0.99 - - 0.01 - - mov rax, rdi - - - - - - - 1.00 mov rdx, qword ptr [rsi + 16] - - - 0.99 0.01 - - - - mov ecx, edx - - - 0.99 0.01 - - - - and ecx, 3 - - - 0.97 0.03 - - - - cmp ecx, 1 + - - - 1.00 - - - - lea ecx, [rdx - 1] + - - - 0.98 - 0.02 - - test cl, 3 - - - - - 1.00 - - jne .LBB5_1 - - - - - - 1.00 - mov rcx, qword ptr [rsi] - - - - - - 0.99 0.01 mov rsi, qword ptr [rsi + 8] - - - 0.01 0.99 - - - - lea rdi, [rdx + 2*rdx] - - - - 0.96 - 0.04 - - add rdi, rcx - - - 0.03 - - 0.97 - - add rdi, 9 - - - 0.01 0.03 - 0.96 - - sub rsi, rdx + - - 1.00 - - - - - lea rdi, [rdx + 2*rdx] + - - 0.01 0.01 - 0.98 - - add rdi, rcx + - - - 1.00 - - - - add rdi, 9 + - - 0.99 0.01 - - - - sub rsi, rdx - - - - 1.00 - 1.00 - mov qword ptr [rax + 8], rdx - - - - 1.00 - - 1.00 mov qword ptr [rax + 16], rdi - - - - 1.00 - 0.01 0.99 mov qword ptr [rax + 24], rsi diff --git a/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64 b/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64 index 217c5fc617..5584e90541 100644 --- a/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64 +++ b/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64 @@ -1,24 +1,22 @@ bench_try_ref_from_bytes_dynamic_padding: - test dil, 3 - jne .LBB5_4 - movabs rax, 9223372036854775804 - and rax, rsi - cmp rax, 9 - jb .LBB5_4 - add rax, -9 + cmp rsi, 12 + setae al + mov ecx, esi + or ecx, edi + test cl, 3 + sete cl + and cl, al + cmp cl, 1 + je .LBB5_2 + xor eax, eax + ret +.LBB5_2: + add rsi, -9 movabs rcx, -6148914691236517205 + mov rax, rsi mul rcx shr rdx - lea rax, [rdx + 2*rdx] - or rax, 3 - add rax, 9 - cmp rsi, rax - jne .LBB5_4 + xor eax, eax cmp word ptr [rdi], -16192 - je .LBB5_5 -.LBB5_4: - xor edi, edi - mov rdx, rsi -.LBB5_5: - mov rax, rdi + cmove rax, rdi ret diff --git a/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64.mca b/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64.mca index 95b993c7e0..ccd610c7ef 100644 --- a/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64.mca +++ b/zerocopy/benches/try_ref_from_bytes_dynamic_padding.x86-64.mca @@ -1,10 +1,10 @@ Iterations: 100 -Instructions: 2100 -Total Cycles: 709 +Instructions: 2000 +Total Cycles: 675 Total uOps: 2300 Dispatch Width: 4 -uOps Per Cycle: 3.24 +uOps Per Cycle: 3.41 IPC: 2.96 Block RThroughput: 5.8 @@ -18,26 +18,25 @@ Instruction Info: [6]: HasSideEffects (U) [1] [2] [3] [4] [5] [6] Instructions: - 1 1 0.33 test dil, 3 - 1 1 1.00 jne .LBB5_4 - 1 1 0.33 movabs rax, 9223372036854775804 - 1 1 0.33 and rax, rsi - 1 1 0.33 cmp rax, 9 - 1 1 1.00 jb .LBB5_4 - 1 1 0.33 add rax, -9 + 1 1 0.33 cmp rsi, 12 + 1 1 0.50 setae al + 1 1 0.33 mov ecx, esi + 1 1 0.33 or ecx, edi + 1 1 0.33 test cl, 3 + 1 1 0.50 sete cl + 1 1 0.33 and cl, al + 1 1 0.33 cmp cl, 1 + 1 1 1.00 je .LBB5_2 + 1 0 0.25 xor eax, eax + 1 1 1.00 U ret + 1 1 0.33 add rsi, -9 1 1 0.33 movabs rcx, -6148914691236517205 + 1 1 0.33 mov rax, rsi 2 4 1.00 mul rcx 1 1 0.50 shr rdx - 1 1 0.50 lea rax, [rdx + 2*rdx] - 1 1 0.33 or rax, 3 - 1 1 0.33 add rax, 9 - 1 1 0.33 cmp rsi, rax - 1 1 1.00 jne .LBB5_4 + 1 0 0.25 xor eax, eax 2 6 0.50 * cmp word ptr [rdi], -16192 - 1 1 1.00 je .LBB5_5 - 1 0 0.25 xor edi, edi - 1 1 0.33 mov rdx, rsi - 1 1 0.33 mov rax, rdi + 2 2 0.67 cmove rax, rdi 1 1 1.00 U ret @@ -54,28 +53,27 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 6.98 6.99 - 7.03 0.50 0.50 + - - 6.67 6.66 - 6.67 0.50 0.50 Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.48 0.51 - 0.01 - - test dil, 3 - - - - - - 1.00 - - jne .LBB5_4 - - - 0.51 0.49 - - - - movabs rax, 9223372036854775804 - - - 0.01 0.99 - - - - and rax, rsi - - - 0.51 0.49 - - - - cmp rax, 9 - - - - - - 1.00 - - jb .LBB5_4 - - - 0.98 - - 0.02 - - add rax, -9 - - - 0.98 0.02 - - - - movabs rcx, -6148914691236517205 + - - 0.33 0.66 - 0.01 - - cmp rsi, 12 + - - 1.00 - - - - - setae al + - - 0.66 0.34 - - - - mov ecx, esi + - - - 0.99 - 0.01 - - or ecx, edi + - - - 0.67 - 0.33 - - test cl, 3 + - - 0.67 - - 0.33 - - sete cl + - - 0.33 0.34 - 0.33 - - and cl, al + - - 0.33 - - 0.67 - - cmp cl, 1 + - - - - - 1.00 - - je .LBB5_2 + - - - - - - - - xor eax, eax + - - - - - 1.00 - - ret + - - 0.67 0.33 - - - - add rsi, -9 + - - 0.99 0.01 - - - - movabs rcx, -6148914691236517205 + - - - 0.99 - 0.01 - - mov rax, rsi - - 1.00 1.00 - - - - mul rcx - - - 0.99 - - 0.01 - - shr rdx - - - - 1.00 - - - - lea rax, [rdx + 2*rdx] - - - - 0.51 - 0.49 - - or rax, 3 - - - 0.01 0.49 - 0.50 - - add rax, 9 - - - - 0.02 - 0.98 - - cmp rsi, rax - - - - - - 1.00 - - jne .LBB5_4 - - - 0.51 0.49 - - 0.50 0.50 cmp word ptr [rdi], -16192 - - - - - - 1.00 - - je .LBB5_5 - - - - - - - - - xor edi, edi - - - 0.50 0.50 - - - - mov rdx, rsi - - - 0.50 0.48 - 0.02 - - mov rax, rdi + - - 0.01 - - 0.99 - - shr rdx + - - - - - - - - xor eax, eax + - - 0.67 0.33 - - 0.50 0.50 cmp word ptr [rdi], -16192 + - - 0.01 1.00 - 0.99 - - cmove rax, rdi - - - - - 1.00 - - ret diff --git a/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64 b/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64 index cf67afd31c..39b12e14ee 100644 --- a/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64 +++ b/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64 @@ -1,22 +1,21 @@ bench_try_ref_from_bytes_dynamic_size: - mov rdx, rsi - mov rax, rdi cmp rsi, 4 - setb cl - or cl, al + setae al + mov ecx, esi + or ecx, edi test cl, 1 - jne .LBB5_4 - lea rcx, [rdx - 4] - mov rsi, rcx - and rsi, -2 - add rsi, 4 - cmp rdx, rsi - jne .LBB5_4 - cmp word ptr [rax], -16192 - jne .LBB5_4 - shr rcx - mov rdx, rcx + sete cl + and cl, al + cmp cl, 1 + je .LBB5_2 + xor eax, eax ret -.LBB5_4: +.LBB5_2: + lea rdx, [rsi - 4] + shr rdx + movzx ecx, word ptr [rdi] xor eax, eax + cmp ecx, 49344 + cmovne rdx, rsi + cmove rax, rdi ret diff --git a/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64.mca b/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64.mca index ecd7a18f6d..eecf3a1554 100644 --- a/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64.mca +++ b/zerocopy/benches/try_ref_from_bytes_dynamic_size.x86-64.mca @@ -1,10 +1,10 @@ Iterations: 100 -Instructions: 2000 -Total Cycles: 639 +Instructions: 1900 +Total Cycles: 607 Total uOps: 2100 Dispatch Width: 4 -uOps Per Cycle: 3.29 +uOps Per Cycle: 3.46 IPC: 3.13 Block RThroughput: 5.3 @@ -18,25 +18,24 @@ Instruction Info: [6]: HasSideEffects (U) [1] [2] [3] [4] [5] [6] Instructions: - 1 1 0.33 mov rdx, rsi - 1 1 0.33 mov rax, rdi 1 1 0.33 cmp rsi, 4 - 1 1 0.50 setb cl - 1 1 0.33 or cl, al + 1 1 0.50 setae al + 1 1 0.33 mov ecx, esi + 1 1 0.33 or ecx, edi 1 1 0.33 test cl, 1 - 1 1 1.00 jne .LBB5_4 - 1 1 0.50 lea rcx, [rdx - 4] - 1 1 0.33 mov rsi, rcx - 1 1 0.33 and rsi, -2 - 1 1 0.33 add rsi, 4 - 1 1 0.33 cmp rdx, rsi - 1 1 1.00 jne .LBB5_4 - 2 6 0.50 * cmp word ptr [rax], -16192 - 1 1 1.00 jne .LBB5_4 - 1 1 0.50 shr rcx - 1 1 0.33 mov rdx, rcx + 1 1 0.50 sete cl + 1 1 0.33 and cl, al + 1 1 0.33 cmp cl, 1 + 1 1 1.00 je .LBB5_2 + 1 0 0.25 xor eax, eax 1 1 1.00 U ret + 1 1 0.50 lea rdx, [rsi - 4] + 1 1 0.50 shr rdx + 1 5 0.50 * movzx ecx, word ptr [rdi] 1 0 0.25 xor eax, eax + 1 1 0.33 cmp ecx, 49344 + 2 2 0.67 cmovne rdx, rsi + 2 2 0.67 cmove rax, rdi 1 1 1.00 U ret @@ -53,27 +52,26 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 6.32 6.32 - 6.36 0.50 0.50 + - - 5.99 5.99 - 6.02 0.50 0.50 Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.33 0.66 - 0.01 - - mov rdx, rsi - - - 0.66 0.34 - - - - mov rax, rdi - - - 0.34 0.66 - - - - cmp rsi, 4 - - - 0.99 - - 0.01 - - setb cl - - - 0.01 0.99 - - - - or cl, al - - - - 1.00 - - - - test cl, 1 - - - - - - 1.00 - - jne .LBB5_4 - - - 0.66 0.34 - - - - lea rcx, [rdx - 4] - - - 0.33 0.66 - 0.01 - - mov rsi, rcx - - - 1.00 - - - - - and rsi, -2 - - - 0.66 0.34 - - - - add rsi, 4 - - - - 1.00 - - - - cmp rdx, rsi - - - - - - 1.00 - - jne .LBB5_4 - - - - - - 1.00 0.50 0.50 cmp word ptr [rax], -16192 - - - - - - 1.00 - - jne .LBB5_4 - - - 0.67 - - 0.33 - - shr rcx - - - 0.67 0.33 - - - - mov rdx, rcx + - - 0.02 0.97 - 0.01 - - cmp rsi, 4 + - - 1.00 - - - - - setae al + - - - 1.00 - - - - mov ecx, esi + - - 0.93 0.05 - 0.02 - - or ecx, edi + - - 0.94 0.06 - - - - test cl, 1 + - - 0.99 - - 0.01 - - sete cl + - - 0.98 0.02 - - - - and cl, al + - - 0.98 0.02 - - - - cmp cl, 1 + - - - - - 1.00 - - je .LBB5_2 + - - - - - - - - xor eax, eax - - - - - 1.00 - - ret + - - 0.01 0.99 - - - - lea rdx, [rsi - 4] + - - - - - 1.00 - - shr rdx + - - - - - - 0.50 0.50 movzx ecx, word ptr [rdi] - - - - - - - - xor eax, eax + - - 0.02 0.96 - 0.02 - - cmp ecx, 49344 + - - 0.05 0.97 - 0.98 - - cmovne rdx, rsi + - - 0.07 0.95 - 0.98 - - cmove rax, rdi - - - - - 1.00 - - ret diff --git a/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64 b/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64 index aa4ddaaca7..ff64bb6595 100644 --- a/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64 +++ b/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64 @@ -1,18 +1,18 @@ bench_try_ref_from_prefix_dynamic_padding: test dil, 3 + sete al + cmp rsi, 12 + setae cl + test al, cl jne .LBB5_2 - movabs rax, 9223372036854775804 - and rsi, rax - cmp rsi, 8 - ja .LBB5_3 -.LBB5_2: xor edx, edx test dil, 3 sete dl xor eax, eax ret -.LBB5_3: - add rsi, -9 +.LBB5_2: + or rsi, 3 + add rsi, -12 movabs rcx, -6148914691236517205 mov rax, rsi mul rcx diff --git a/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64.mca b/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64.mca index f595d499cf..29225404a2 100644 --- a/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64.mca +++ b/zerocopy/benches/try_ref_from_prefix_dynamic_padding.x86-64.mca @@ -1,12 +1,12 @@ Iterations: 100 -Instructions: 2500 -Total Cycles: 808 -Total uOps: 2800 +Instructions: 2600 +Total Cycles: 843 +Total uOps: 2900 Dispatch Width: 4 -uOps Per Cycle: 3.47 -IPC: 3.09 -Block RThroughput: 7.0 +uOps Per Cycle: 3.44 +IPC: 3.08 +Block RThroughput: 7.3 Instruction Info: @@ -19,17 +19,18 @@ Instruction Info: [1] [2] [3] [4] [5] [6] Instructions: 1 1 0.33 test dil, 3 + 1 1 0.50 sete al + 1 1 0.33 cmp rsi, 12 + 1 1 0.50 setae cl + 1 1 0.33 test al, cl 1 1 1.00 jne .LBB5_2 - 1 1 0.33 movabs rax, 9223372036854775804 - 1 1 0.33 and rsi, rax - 1 1 0.33 cmp rsi, 8 - 1 1 1.00 ja .LBB5_3 1 0 0.25 xor edx, edx 1 1 0.33 test dil, 3 1 1 0.50 sete dl 1 0 0.25 xor eax, eax 1 1 1.00 U ret - 1 1 0.33 add rsi, -9 + 1 1 0.33 or rsi, 3 + 1 1 0.33 add rsi, -12 1 1 0.33 movabs rcx, -6148914691236517205 1 1 0.33 mov rax, rsi 2 4 1.00 mul rcx @@ -58,32 +59,33 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 8.00 8.00 - 8.00 0.50 0.50 + - - 8.33 8.33 - 8.34 0.50 0.50 Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.97 0.02 - 0.01 - - test dil, 3 + - - 0.01 0.98 - 0.01 - - test dil, 3 + - - 0.98 - - 0.02 - - sete al + - - - 1.00 - - - - cmp rsi, 12 + - - - - - 1.00 - - setae cl + - - 0.01 0.03 - 0.96 - - test al, cl - - - - - 1.00 - - jne .LBB5_2 - - - 0.99 0.01 - - - - movabs rax, 9223372036854775804 - - - 0.02 0.98 - - - - and rsi, rax - - - 0.03 0.97 - - - - cmp rsi, 8 - - - - - - 1.00 - - ja .LBB5_3 - - - - - - - - xor edx, edx - - - 0.98 0.02 - - - - test dil, 3 + - - - 1.00 - - - - test dil, 3 - - 0.99 - - 0.01 - - sete dl - - - - - - - - xor eax, eax - - - - - 1.00 - - ret - - - 0.98 0.02 - - - - add rsi, -9 - - - 0.02 0.98 - - - - movabs rcx, -6148914691236517205 - - - 0.98 0.02 - - - - mov rax, rsi + - - - 1.00 - - - - or rsi, 3 + - - 0.01 0.99 - - - - add rsi, -12 + - - 0.01 0.99 - - - - movabs rcx, -6148914691236517205 + - - 0.34 0.66 - - - - mov rax, rsi - - 1.00 1.00 - - - - mul rcx - - - - 0.01 - 0.99 - - mov rax, rdx - - - 0.01 - - 0.99 - - shr rax + - - 0.99 - - 0.01 - - mov rax, rdx + - - 1.00 - - - - - shr rax - - - - - - 0.50 0.50 movzx ecx, word ptr [rdi] - - - - 0.99 - 0.01 - - cmp cx, -16192 - - - 0.99 0.01 - - - - mov edx, 2 - - - 0.01 1.00 - 0.99 - - cmove rdx, rax + - - 0.34 0.32 - 0.34 - - cmp cx, -16192 + - - 0.33 0.01 - 0.66 - - mov edx, 2 + - - 0.99 0.01 - 1.00 - - cmove rdx, rax - - - - - - - - xor eax, eax - - - 0.02 0.98 - - - - cmp ecx, 49344 - - - 0.01 0.99 - 1.00 - - cmove rax, rdi + - - 0.33 0.33 - 0.34 - - cmp ecx, 49344 + - - 1.00 0.01 - 0.99 - - cmove rax, rdi - - - - - 1.00 - - ret diff --git a/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64 b/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64 index b3e9244428..cee9a31790 100644 --- a/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64 +++ b/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64 @@ -1,26 +1,23 @@ bench_try_ref_from_suffix_dynamic_padding: lea eax, [rsi + rdi] test al, 3 - jne .LBB5_1 - movabs rax, 9223372036854775804 - and rax, rsi - cmp rax, 9 - jae .LBB5_3 -.LBB5_1: + setne al + cmp rsi, 12 + setb cl + or cl, al + je .LBB5_2 xor eax, eax ret -.LBB5_3: - add rax, -9 +.LBB5_2: + mov rax, rsi + or rax, 3 + add rax, -12 movabs rcx, -6148914691236517205 mul rcx shr rdx - lea rcx, [rdx + 2*rdx] - sub rsi, rcx - or rcx, -4 - add rsi, rdi - lea rdi, [rcx + rsi] - add rdi, -8 + and esi, 3 + lea rcx, [rdi + rsi] xor eax, eax - cmp word ptr [rcx + rsi - 8], -16192 - cmove rax, rdi + cmp word ptr [rdi + rsi], -16192 + cmove rax, rcx ret diff --git a/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64.mca b/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64.mca index d56ae56d85..92df4484b8 100644 --- a/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64.mca +++ b/zerocopy/benches/try_ref_from_suffix_dynamic_padding.x86-64.mca @@ -1,12 +1,12 @@ Iterations: 100 -Instructions: 2300 -Total Cycles: 791 -Total uOps: 2600 +Instructions: 2100 +Total Cycles: 710 +Total uOps: 2400 Dispatch Width: 4 -uOps Per Cycle: 3.29 -IPC: 2.91 -Block RThroughput: 6.5 +uOps Per Cycle: 3.38 +IPC: 2.96 +Block RThroughput: 6.0 Instruction Info: @@ -20,26 +20,24 @@ Instruction Info: [1] [2] [3] [4] [5] [6] Instructions: 1 1 0.50 lea eax, [rsi + rdi] 1 1 0.33 test al, 3 - 1 1 1.00 jne .LBB5_1 - 1 1 0.33 movabs rax, 9223372036854775804 - 1 1 0.33 and rax, rsi - 1 1 0.33 cmp rax, 9 - 1 1 1.00 jae .LBB5_3 + 1 1 0.50 setne al + 1 1 0.33 cmp rsi, 12 + 1 1 0.50 setb cl + 1 1 0.33 or cl, al + 1 1 1.00 je .LBB5_2 1 0 0.25 xor eax, eax 1 1 1.00 U ret - 1 1 0.33 add rax, -9 + 1 1 0.33 mov rax, rsi + 1 1 0.33 or rax, 3 + 1 1 0.33 add rax, -12 1 1 0.33 movabs rcx, -6148914691236517205 2 4 1.00 mul rcx 1 1 0.50 shr rdx - 1 1 0.50 lea rcx, [rdx + 2*rdx] - 1 1 0.33 sub rsi, rcx - 1 1 0.33 or rcx, -4 - 1 1 0.33 add rsi, rdi - 1 1 0.50 lea rdi, [rcx + rsi] - 1 1 0.33 add rdi, -8 + 1 1 0.33 and esi, 3 + 1 1 0.50 lea rcx, [rdi + rsi] 1 0 0.25 xor eax, eax - 2 6 0.50 * cmp word ptr [rcx + rsi - 8], -16192 - 2 2 0.67 cmove rax, rdi + 2 6 0.50 * cmp word ptr [rdi + rsi], -16192 + 2 2 0.67 cmove rax, rcx 1 1 1.00 U ret @@ -56,30 +54,28 @@ Resources: Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6.0] [6.1] - - - 7.70 7.58 - 7.72 0.50 0.50 + - - 7.00 7.00 - 7.00 0.50 0.50 Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6.0] [6.1] Instructions: - - - 0.26 0.74 - - - - lea eax, [rsi + rdi] - - - 0.19 0.28 - 0.53 - - test al, 3 - - - - - - 1.00 - - jne .LBB5_1 - - - 0.93 0.06 - 0.01 - - movabs rax, 9223372036854775804 - - - 0.81 0.14 - 0.05 - - and rax, rsi - - - 0.55 0.43 - 0.02 - - cmp rax, 9 - - - - - - 1.00 - - jae .LBB5_3 + - - 0.99 0.01 - - - - lea eax, [rsi + rdi] + - - 0.01 0.99 - - - - test al, 3 + - - 0.98 - - 0.02 - - setne al + - - 0.99 - - 0.01 - - cmp rsi, 12 + - - 0.02 - - 0.98 - - setb cl + - - - 0.03 - 0.97 - - or cl, al + - - - - - 1.00 - - je .LBB5_2 - - - - - - - - xor eax, eax - - - - - 1.00 - - ret - - - 0.42 0.56 - 0.02 - - add rax, -9 - - - 0.67 0.30 - 0.03 - - movabs rcx, -6148914691236517205 + - - - 1.00 - - - - mov rax, rsi + - - 0.03 0.97 - - - - or rax, 3 + - - - 1.00 - - - - add rax, -12 + - - 0.99 - - 0.01 - - movabs rcx, -6148914691236517205 - - 1.00 1.00 - - - - mul rcx - - - 0.71 - - 0.29 - - shr rdx - - - 0.32 0.68 - - - - lea rcx, [rdx + 2*rdx] - - - 0.57 0.04 - 0.39 - - sub rsi, rcx - - - 0.28 0.67 - 0.05 - - or rcx, -4 - - - 0.29 0.29 - 0.42 - - add rsi, rdi - - - 0.02 0.98 - - - - lea rdi, [rcx + rsi] - - - 0.02 0.41 - 0.57 - - add rdi, -8 + - - 0.01 - - 0.99 - - shr rdx + - - 1.00 - - - - - and esi, 3 + - - 0.97 0.03 - - - - lea rcx, [rdi + rsi] - - - - - - - - xor eax, eax - - - 0.57 0.01 - 0.42 0.50 0.50 cmp word ptr [rcx + rsi - 8], -16192 - - - 0.09 0.99 - 0.92 - - cmove rax, rdi + - - - 0.97 - 0.03 0.50 0.50 cmp word ptr [rdi + rsi], -16192 + - - 0.01 1.00 - 0.99 - - cmove rax, rcx - - - - - 1.00 - - ret diff --git a/zerocopy/src/layout/cast_from.rs b/zerocopy/src/layout/cast_from.rs index 579c7f37c3..eea5a6812c 100644 --- a/zerocopy/src/layout/cast_from.rs +++ b/zerocopy/src/layout/cast_from.rs @@ -22,55 +22,34 @@ use crate::*; /// A numerical metadata transformation selected for a pair of layouts. /// -/// A raw plan contains no type or layout identity. Its parameters describe the -/// offset/element-size equation below only in relation to the layouts used by -/// `try_compute`; an arbitrary plan is not a witness for a `KnownLayout` pair. -/// `CastParams` carries that association for pointer projection. +/// A raw plan contains no type or layout identity. Its parameters preserve size +/// only in relation to the layouts used by `try_compute`. `CastParams` retains +/// that connection to the `KnownLayout` pair used for pointer projection. #[derive(Copy, Clone)] enum CastPlan { - // Assume that `Src` and `Dst` are slice DSTs, and define: - // - `S_OFF = Src::LAYOUT.size_info.offset` - // - `S_ELEM = Src::LAYOUT.size_info.elem_size` - // - `D_OFF = Dst::LAYOUT.size_info.offset` - // - `D_ELEM = Dst::LAYOUT.size_info.elem_size` + // At compile time (specifically, post-monomorphization time), + // we need to compute two things: + // - Whether, given *any* `*Src`, it is possible to construct a + // `*Dst` which addresses the same number of bytes (ie, + // whether, for any `Src` pointer metadata, there exists `Dst` + // pointer metadata that addresses the same number of bytes) + // - If this is possible, any information necessary to perform + // the `Src`->`Dst` metadata conversion at runtime. // - // We are trying to solve the following equation: + // For slice DSTs, destination metadata is an affine function + // of source metadata: // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // dst_meta = offset_delta_elems + src_meta * elem_multiple // - // At runtime, we will be attempting to compute `d_meta`, given - // `s_meta` (a runtime value) and all other parameters (which - // are compile-time values). We can solve like so: - // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM - // - // d_meta * D_ELEM = S_OFF - D_OFF + s_meta * S_ELEM - // - // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM - // - // Since `d_meta` will be a `usize`, we need the right-hand side - // to be an integer, and this needs to hold for *any* value of - // `s_meta` (in order for our conversion to be infallible - ie, - // to not have to reject certain values of `s_meta` at runtime). - // This means that: - // - // - `s_meta * S_ELEM` must be a multiple of `D_ELEM` - // - Since this must hold for any value of `s_meta`, `S_ELEM` - // must be a multiple of `D_ELEM` - // - `S_OFF - D_OFF` must be a multiple of `D_ELEM` - // - // Thus, let `OFFSET_DELTA_ELEMS = (S_OFF - D_OFF)/D_ELEM` and - // `ELEM_MULTIPLE = S_ELEM/D_ELEM`. We can rewrite the above - // expression as: - // - // d_meta = (S_OFF - D_OFF + s_meta * S_ELEM)/D_ELEM - // - // d_meta = OFFSET_DELTA_ELEMS + s_meta * ELEM_MULTIPLE - // - // Thus, plan selection checks these ratios and stores the parameters - // needed to compute `d_meta` at runtime. `CastParams` associates the selected - // plan with the `KnownLayout` types whose layouts were used to select it. - /// Converts between slice DSTs using the ratios described above. + // `elem_multiple` scales the destination's trailing element + // size to the source's. `offset_delta_elems` selects a + // destination size equal to the source's zero-element size. + // We then prove that the two complete rounded size formulas + // produce the same sequence for all metadata. This is + // necessary because a packed outer DST can retain a nested + // field's rounding operation; comparing only physical + // trailing-slice offsets is not sufficient. + /// Converts between slice DSTs using a size-preserving affine map. UnsizedToUnsized { offset_delta_elems: usize, elem_multiple: usize }, /// Uses the metadata selected for a sized source and a slice DST. @@ -81,100 +60,145 @@ enum CastPlan { } impl CastPlan { - /// Selects a plan from layout values without converting typed metadata. - const fn try_compute(src: &DstLayout, dst: &DstLayout) -> Option { - if src.align.get() < dst.align.get() { + /// Given a nonzero `dst.elem_size` that exactly divides + /// `src.elem_size`, produces `true` only when the following + /// metadata map preserves the complete object size for every + /// source element count `src_meta`: + /// + /// ```text + /// dst_meta = dst_base + src_meta * (src.elem_size / dst.elem_size) + /// ``` + /// + /// This recognizes sufficient conditions for equality. A + /// `false` result also includes equivalent size sequences + /// that this test cannot recognize, or an adjustment whose + /// normalized components cannot be represented in `usize`. + const fn size_sequences_match( + src: TrailingSliceLayout, + dst: TrailingSliceLayout, + dst_base: usize, + ) -> bool { + let base_bytes = match dst_base.checked_mul(dst.elem_size) { + Some(bytes) => bytes, + None => return false, + }; + let shifted_dst = match dst.advance(base_bytes, src.elem_size) { + Some(layout) => layout, + None => return false, + }; + src.has_same_size_sequence(shifted_dst) + } + + /// Given the complete layouts of `Src` and `Dst`, produces + /// parameters for a cast that preserves object size. + /// + /// A `Some` result establishes that `Src`'s alignment is at + /// least `Dst`'s and that the metadata map preserves size for + /// every valid source metadata value. + /// + /// Supports casts between sized types of equal size, from a + /// sized type to a slice DST, and between slice DSTs. A slice + /// DST destination must have nonzero-sized elements; between + /// slice DSTs, its element size must exactly divide the + /// source's nonzero element size. + /// + /// Produces `None` for unsupported casts or when this method + /// cannot establish a size-preserving metadata map. Rejection + /// does not establish that no such map exists. + const fn try_compute(src_layout: &DstLayout, dst_layout: &DstLayout) -> Option { + if src_layout.align.get() < dst_layout.align.get() { return None; } - let plan = match (src.size_info, dst.size_info) { + let plan = match (src_layout.size_info, dst_layout.size_info) { (SizeInfo::Sized { size: src_size }, SizeInfo::Sized { size: dst_size }) => { if src_size != dst_size { return None; } - // We checked above that `src_size == dst_size`. + // SAFETY: We checked above that `src_size == + // dst_size`. CastPlan::SizedToSized } - (SizeInfo::Sized { size: src_size }, SizeInfo::SliceDst(dst)) => { - let offset_delta = if let Some(od) = src_size.checked_sub(dst.offset) { - od - } else { - return None; - }; - - let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { - e - } else { - return None; + (SizeInfo::Sized { size: src_size }, SizeInfo::SliceDst(_)) => { + let dst_meta = match dst_layout.metadata_for_exact_size(src_size) { + Some(meta) => meta, + None => return None, }; - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let delta_mod_other_elem = offset_delta % dst_elem_size.get(); - - if delta_mod_other_elem != 0 { - return None; - } - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let dst_meta = offset_delta / dst_elem_size.get(); - - // The preceding math ensures that - // `dst.offset + dst_meta * dst.elem_size == src_size`. + // SAFETY: The preceding math ensures that a `Dst` + // with `dst_meta` addresses `src_size` bytes. CastPlan::SizedToUnsized { dst_meta } } (SizeInfo::SliceDst(src), SizeInfo::SliceDst(dst)) => { - let offset_delta = if let Some(od) = src.offset.checked_sub(dst.offset) { - od - } else { - return None; - }; - let dst_elem_size = if let Some(e) = NonZeroUsize::new(dst.elem_size) { e } else { return None; }; - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let delta_mod_other_elem = offset_delta % dst_elem_size.get(); - - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let elem_remainder = src.elem_size % dst_elem_size.get(); - - if delta_mod_other_elem != 0 || src.elem_size < dst.elem_size || elem_remainder != 0 - { + if src.elem_size < dst.elem_size { return None; } - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let offset_delta_elems = offset_delta / dst_elem_size.get(); + let (elem_multiple, described_src_elem_size) = + super::max_elems_for_bytes(src.elem_size, dst_elem_size); + if described_src_elem_size != src.elem_size { + return None; + } - // PANICS: `dst_elem_size: NonZeroUsize`, so this won't - // divide by zero. - #[allow(clippy::arithmetic_side_effects)] - let elem_multiple = src.elem_size / dst_elem_size.get(); + // Prefer an exact destination-element adjustment + // between `src.size_offset()` and + // `dst.size_offset()`. This preserves offset-based + // cast compatibility and metadata selection when + // several lengths have the same rounded size. It + // also avoids choosing a length whose subsequent + // padding sequence differs from the source's. + let exact_offset_delta_elems = + match src.size_offset().checked_sub(dst.size_offset()) { + Some(delta) => { + let (elems, described_delta) = + super::max_elems_for_bytes(delta, dst_elem_size); + if described_delta == delta { + Some(elems) + } else { + None + } + } + None => None, + }; + + let offset_delta_elems = match exact_offset_delta_elems { + Some(elems) if Self::size_sequences_match(src, dst, elems) => elems, + _ => { + let src_zero_size = match src.size_for_elems(0) { + Some(size) => size, + None => return None, + }; + let elems = match dst_layout.metadata_for_exact_size(src_zero_size) { + Some(elems) => elems, + None => return None, + }; + if !Self::size_sequences_match(src, dst, elems) { + return None; + } + elems + } + }; CastPlan::UnsizedToUnsized { - // We checked above that this is an exact ratio. + // SAFETY: `size_sequences_match` proves that + // this affine metadata map preserves size. offset_delta_elems, - // We checked above that this is an exact ratio. + // SAFETY: We checked above that this is an exact + // ratio of source to destination element size. elem_multiple, } } _ => return None, }; + // SAFETY: We checked above that `src.align >= dst.align`. Some(plan) } @@ -187,17 +211,11 @@ impl CastPlan { /// The other variants impose no condition on `src_meta` and ignore it. #[inline(always)] unsafe fn cast_metadata(self, src_meta: usize) -> usize { - #[allow(unused)] - use crate::util::polyfills::*; - match self { CastPlan::UnsizedToUnsized { offset_delta_elems, elem_multiple } => { - #[allow(unstable_name_collisions, clippy::multiple_unsafe_ops_per_block)] - // SAFETY: The caller promises that neither the multiplication - // nor the addition overflows `usize`. - unsafe { - offset_delta_elems.unchecked_add(src_meta.unchecked_mul(elem_multiple)) - } + // SAFETY: The caller provides the helper's complete + // no-overflow precondition. + unsafe { super::add_scaled_metadata(offset_delta_elems, src_meta, elem_multiple) } } CastPlan::SizedToUnsized { dst_meta } => dst_meta, CastPlan::SizedToSized => 0, @@ -252,14 +270,57 @@ impl CastParams _ => 0, }; - // SAFETY: `self.plan` was selected for `Src::LAYOUT` and `Dst::LAYOUT`. - // For `UnsizedToUnsized`, its parameters satisfy the equation: + // SAFETY: The sized variants perform no unchecked arithmetic. + // For `UnsizedToUnsized`, `self.plan` was selected for the layouts and + // witnesses that this affine map makes `Src` and `Dst`'s complete + // rounded size formulas equal for every source metadata value. + // Interpret the arithmetic in this proof over the + // mathematical nonnegative integers. Since the + // caller promises that `src_meta` is valid `Src` + // metadata, the source object size `src_size` it + // describes is at most `isize::MAX`. Let + // `src_elem_size` and `dst_elem_size` be the + // respective trailing-slice element sizes. Then: + // + // src_meta * src_elem_size + // <= src_size <= isize::MAX. + // + // Since `elem_multiple` is the exact ratio + // `src_elem_size / dst_elem_size` and + // `dst_elem_size >= 1`, + // + // src_meta * elem_multiple + // <= src_meta * src_elem_size + // <= isize::MAX <= usize::MAX. + // + // Thus `scaled_elems = src_meta * elem_multiple` + // is representable, and its byte contribution is + // `scaled_elems * dst_elem_size = src_meta * + // src_elem_size`. Let `base_bytes = + // offset_delta_elems * dst_elem_size`. + // `size_sequences_match` compares the source + // formula with the destination formula advanced by + // `base_bytes`. The latter's unrounded term + // contains `base_bytes + src_meta * src_elem_size`, + // and its complete size is at least that large + // (formally checked by Kani in + // `prove_size_formula_bounds_size_offset`). + // Sequence equality therefore gives, over the + // mathematical nonnegative integers, // - // D_OFF + d_meta * D_ELEM = S_OFF + s_meta * S_ELEM + // base_bytes + src_meta * src_elem_size + // = (offset_delta_elems + scaled_elems) + // * dst_elem_size + // <= src_size. // - // Since the caller promises that `src_meta` is valid `Src` metadata, - // this math will not overflow, and the returned value will describe a - // `Dst` of the same size. The other variants do no unchecked arithmetic. + // Since `dst_elem_size >= 1`, the metadata sum + // `offset_delta_elems + scaled_elems` is also at + // most `src_size`, and thus at most `isize::MAX <= + // usize::MAX`. The sum is therefore representable, + // and the returned metadata describes a `Dst` of + // the same size. + // SAFETY: The bounds above establish the helper's + // complete no-overflow precondition. let dst_meta = unsafe { self.plan.cast_metadata(src_meta) }; Dst::PointerMetadata::from_elem_count(dst_meta) } diff --git a/zerocopy/src/layout/mod.rs b/zerocopy/src/layout/mod.rs index 70692d6e5b..0eb2e82a39 100644 --- a/zerocopy/src/layout/mod.rs +++ b/zerocopy/src/layout/mod.rs @@ -30,6 +30,7 @@ const POINTER_WIDTH_BITS: usize = mem::size_of::() * 8; #[allow(missing_debug_implementations, missing_copy_implementations)] #[cfg_attr(any(kani, test), derive(Debug, PartialEq, Eq))] #[derive(Copy, Clone)] +#[cfg_attr(kani, derive(kani::Arbitrary))] pub struct DstLayout { pub(crate) align: NonZeroUsize, pub(crate) size_info: SizeInfo, @@ -42,6 +43,7 @@ pub struct DstLayout { #[cfg_attr(any(kani, test), derive(Debug, PartialEq, Eq))] #[derive(Copy, Clone)] +#[cfg_attr(kani, derive(kani::Arbitrary))] pub(crate) enum SizeInfo { Sized { size: usize }, SliceDst(TrailingSliceLayout), @@ -73,11 +75,8 @@ pub(crate) enum SizeInfo { #[repr(transparent)] #[derive(Copy, Clone)] #[cfg_attr(kani, derive(kani::Arbitrary))] -#[allow(dead_code)] pub(crate) struct RoundingAlignAndPhase(NonZeroUsize); -// The following layout migration will consume this representation. -#[allow(dead_code)] impl RoundingAlignAndPhase { /// Encodes `align` and `phase`. /// @@ -182,22 +181,574 @@ impl RoundingAlignAndPhase { #[cfg_attr(any(kani, test), derive(Debug, PartialEq, Eq))] #[derive(Copy, Clone)] +#[cfg_attr(kani, derive(kani::Arbitrary))] pub(crate) struct TrailingSliceLayout { - // The offset of the first byte of the trailing slice field. Note that this - // is NOT the same as the minimum size of the type. For example, consider - // the following type: - // - // struct Foo { - // a: u16, - // b: u8, - // c: [u8], - // } - // - // In `Foo`, `c` is at byte offset 3. When `c.len() == 0`, `c` is followed - // by a padding byte. + /// The offset of the first byte of the trailing slice field. Note that this + /// is NOT the same as the minimum size of the type. For example, consider + /// the following type: + /// + /// ```ignore + /// #[repr(C, align(2))] + /// struct Foo { + /// header: u16, + /// tag: u8, + /// tail: [u8], + /// } + /// ``` + /// + /// In `Foo`, `tail` is at byte offset 3. When `tail.len() == 0`, `tail` is + /// followed by a padding byte. pub(crate) offset: usize, - // The size of the element type of the trailing slice field. + /// The size of the element type of the trailing slice field. pub(crate) elem_size: E, + /// The trailing slice's byte offset plus fixed trailing padding, minus the + /// rounding phase. + /// + /// Fixed trailing padding excludes padding added by the final alignment + /// rounding. The rounding alignment and phase are stored in + /// `size_rounding_align_and_phase`. This value is independent of the slice + /// length and can be smaller than, equal to, or larger than `offset`. + /// + /// For a completed layout, this value is a multiple of `DstLayout::align`. + /// The minimum object size equals this value when the phase is zero; + /// otherwise, it exceeds this value by one rounding-alignment unit. + pub(crate) size_base: usize, + /// The alignment and byte phase of the rounding applied to the trailing + /// slice's contribution to object size. + /// + /// The alignment `size_align` is a nonzero power of two. The phase + /// `size_phase` is the fixed byte contribution inside that rounding, in + /// `0..size_align`. Both are independent of the trailing slice's length. + /// For `elems` trailing elements, the enclosing object's size is: + /// + /// ```text + /// size_base + round_up(size_phase + elems * elem_size, size_align) + /// ``` + /// + /// For a completed layout, `size_align` is at least `DstLayout::align`. + /// Packing can reduce the enclosing type's alignment and the alignment + /// used to place its trailing field without removing padding required by + /// a nested DST's own layout. For example, a `repr(packed(2))` wrapper + /// around a DST with alignment 4 can have an object alignment of 2 and a + /// rounding alignment of 4. + /// + /// The rounding parameters account for padding preserved within nested + /// fields. Their rounding origin can differ from the physical start of + /// the trailing slice, so the phase need not equal `offset % size_align`. + pub(crate) size_rounding_align_and_phase: RoundingAlignAndPhase, +} + +impl TrailingSliceLayout { + /// Produces `self.size_base` rounded down to a multiple of `size_align`, + /// plus `size_phase`, where both components come from + /// `self.size_rounding_align_and_phase`. + /// + /// This value need not equal `self.offset`, the trailing slice's byte + /// offset. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&offset| { + let (align, phase) = self.size_rounding_align_and_phase.components(); + offset == util::round_down_to_next_multiple_of_alignment(self.size_base, align) + phase + }))] + const fn size_offset(&self) -> usize { + #[cfg(kani)] + #[kani::proof_for_contract(TrailingSliceLayout::::size_offset)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().size_offset(); + } + + let (size_align, size_phase) = self.size_rounding_align_and_phase.components(); + let aligned_base = + util::round_down_to_next_multiple_of_alignment(self.size_base, size_align); + // The aligned base and phase have disjoint set bits. + aligned_base | size_phase + } + + /// Produces the largest trailing-slice byte capacity whose complete, padded + /// object fits in `available_bytes`, or `None` if the object does not fit + /// even with an empty trailing slice. + /// + /// This capacity is independent of `elem_size`: it need not represent a + /// whole number of elements, even for a valid Rust layout. For example, a + /// slice of `[u8; 3]` elements has three-byte elements and alignment 1. + /// With `available_bytes = 4`, this method produces `Some(4)`, although + /// only one whole element, occupying three bytes, fits. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&result| { + let (align, phase) = self.size_rounding_align_and_phase.components(); + result == available_bytes.checked_sub(self.size_base) + .map(|bytes| util::round_down_to_next_multiple_of_alignment(bytes, align)) + .and_then(|rounded| rounded.checked_sub(phase)) + }))] + const fn max_trailing_bytes(&self, available_bytes: usize) -> Option { + #[cfg(kani)] + #[kani::proof_for_contract(TrailingSliceLayout::::max_trailing_bytes)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().max_trailing_bytes(kani::any()); + } + + #[cfg(kani)] + #[kani::proof_for_contract(TrailingSliceLayout::::max_trailing_bytes)] + #[kani::solver(kissat)] + fn proof_nonzero() { + kani::any::>().max_trailing_bytes(kani::any()); + } + + let (size_align, size_phase) = self.size_rounding_align_and_phase.components(); + + // Interpret the normalized size formula over the nonnegative integers: + // + // object_size(trailing_bytes) + // = self.size_base + // + round_up(size_phase + trailing_bytes, size_align). + // + // We seek the largest byte count with + // `object_size(trailing_bytes) <= available_bytes`. + // + // At zero trailing bytes, the input to rounding is just `size_phase`. + // Since `0 <= size_phase < size_align`, the least alignment multiple at + // or above it is zero for a zero phase, and `size_align` otherwise. + // Thus `rounded_phase = round_up(size_phase, size_align)` is exactly + // the rounded contribution of an empty tail. + let rounded_phase = if size_phase == 0 { 0 } else { size_align.get() }; + + // Adding the fixed base gives `minimum_size = object_size(0)`. The size + // formula is nondecreasing in the trailing byte count, so this is the + // minimum object size. If this sum exceeds `usize::MAX`, it also + // exceeds `available_bytes`, and no tail can fit. + let minimum_size = match self.size_base.checked_add(rounded_phase) { + Some(size) => size, + None => return None, + }; + + // Subtracting the minimum size gives the budget for increasing the + // rounded contribution. Failure means even the minimum size exceeds the + // available space. Success establishes `minimum_size + extra_bytes = + // available_bytes`. + let extra_bytes = match available_bytes.checked_sub(minimum_size) { + Some(bytes) => bytes, + None => return None, + }; + + // The rounded contribution can increase only by multiples of + // `size_align`. Its largest permitted increase is therefore + // `aligned_bytes = round_down(extra_bytes, size_align)`, with: + // + // aligned_bytes <= extra_bytes < aligned_bytes + size_align. + // + // Hence `rounded_phase + aligned_bytes` is the largest alignment + // multiple that fits after the base: adding the base gives at most + // `minimum_size + extra_bytes = available_bytes`, while the next + // alignment multiple would exceed that budget. + let aligned_bytes = util::round_down_to_next_multiple_of_alignment(extra_bytes, size_align); + + // Rounding an empty tail adds `rounded_phase - size_phase` bytes. This + // is `initial_padding`: the tail can use these bytes without increasing + // the rounded contribution. The two cases for `rounded_phase` give `0 + // <= initial_padding < size_align`, so the subtraction is + // representable. + #[allow(clippy::arithmetic_side_effects)] + let initial_padding = rounded_phase - size_phase; + + // For any nonnegative candidate byte count, the aligned limit above + // gives the following equivalences: + // + // object_size(candidate_bytes) <= available_bytes + // iff round_up(size_phase + candidate_bytes, size_align) + // <= rounded_phase + aligned_bytes + // iff size_phase + candidate_bytes + // <= rounded_phase + aligned_bytes + // iff candidate_bytes <= aligned_bytes + initial_padding. + // + // Removing `round_up` is valid because its upper bound is an alignment + // multiple. Thus `aligned_bytes + initial_padding` is exactly the + // largest tail that fits. Since `aligned_bytes` is a multiple of the + // power-of-two `size_align` and `initial_padding < size_align`, their + // set bits are disjoint. Bitwise OR therefore equals their sum, with no + // carry or overflow. + let trailing_bytes = aligned_bytes | initial_padding; + Some(trailing_bytes) + } +} + +impl TrailingSliceLayout { + /// Returns the difference between the complete object size and the + /// trailing-slice end for `elems` elements, modulo `usize::MAX + 1`. + /// + /// If the object size fits in `usize` and contains the trailing slice, + /// this is the exact trailing padding. Other inputs need not describe a + /// Rust type; their result is still the modular difference. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&padding| { + let (align, phase) = self.size_rounding_align_and_phase.components(); + let trailing_bytes = elems.wrapping_mul(self.elem_size); + let mask = align.get() - 1; + let rounded = phase.wrapping_add(trailing_bytes).wrapping_add(mask) & !mask; + let object_size = self.size_base.wrapping_add(rounded); + padding == object_size.wrapping_sub(self.offset.wrapping_add(trailing_bytes)) + }))] + pub(crate) const fn padding_for_elems(self, elems: usize) -> usize { + #[cfg(kani)] + #[kani::proof_for_contract(TrailingSliceLayout::padding_for_elems)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().padding_for_elems(kani::any()); + } + + let (size_align, size_phase) = self.size_rounding_align_and_phase.components(); + + // Since `size_align` is a nonzero power of two, subtracting one gives + // a mask selecting precisely the remainder modulo `size_align`. + #[allow(clippy::arithmetic_side_effects)] + let size_mask = size_align.get() - 1; + + // Removing whole alignment multiples from each element's size + // preserves the trailing-byte count modulo `size_align`. If elements + // occupy whole alignment units, this is zero, making the padding's + // independence from the element count explicit to the optimizer. + let elem_remainder = self.elem_size & size_mask; + + // The product is congruent to `elems * self.elem_size` modulo + // `size_align`. Wrapping preserves that congruence because every + // power-of-two `size_align` divides `usize::MAX + 1`. Masking then + // gives the exact trailing-byte remainder in `0..size_align`. + let trailing_remainder = elems.wrapping_mul(elem_remainder) & size_mask; + + // Adding `size_phase` gives the same remainder as the mathematical + // `size_phase + elems * self.elem_size`. Reducing before adding the + // phase also exposes this periodicity to the optimizer. + let rounding_input = size_phase.wrapping_add(trailing_remainder); + + // `rounding_padding` is the unique value in `0..size_align` that + // rounds `rounding_input` up to an alignment multiple. Since only + // the remainder determines this value, it is also the padding added + // by `round_up(size_phase + elems * self.elem_size, size_align)`. + let rounding_padding = util::padding_needed_for(rounding_input, size_align); + + // Write `trailing_bytes = elems * self.elem_size` over mathematical + // integers. Subtracting the trailing-slice end from the complete + // object size cancels `trailing_bytes`: + // + // size_base + round_up(size_phase + trailing_bytes, size_align) + // - (offset + trailing_bytes) + // = size_base + size_phase - offset + rounding_padding. + // + // Wrapping computes this difference modulo `usize::MAX + 1`, even + // if an intermediate value or the difference is unrepresentable. + // If the object size fits in `usize` and contains the trailing slice, + // the difference is in `0..=usize::MAX`; its residue is then the + // exact number of padding bytes after the trailing slice. + self.size_base + .wrapping_add(size_phase) + .wrapping_sub(self.offset) + .wrapping_add(rounding_padding) + } + + /// Computes the size described by this layout for `elems` trailing slice + /// elements. + /// + /// Returns `None` if the element-byte count or complete rounded size + /// cannot be represented as a `usize`. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&size| { + size == elems.checked_mul(self.elem_size) + .and_then(|bytes| proofs::slice_dst_size_for_trailing_bytes(self, bytes)) + }))] + pub(crate) const fn size_for_elems(self, elems: usize) -> Option { + #[cfg(kani)] + #[kani::proof_for_contract(TrailingSliceLayout::size_for_elems)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().size_for_elems(kani::any()); + } + + // Let `size_align` and `size_phase` denote the components stored in + // `size_rounding_align_and_phase`. The normalized layout formula, + // evaluated over the nonnegative integers, is: + // + // object_size(trailing_bytes) + // = self.size_base + // + round_up(size_phase + trailing_bytes, size_align) + // + // Here, `round_up` gives the least multiple of `size_align` greater + // than or equal to its input. Thus `object_size` is nondecreasing. + // By the capacity helper's contract, `max_trailing_bytes` is the + // largest byte count for which `object_size` fits in a `usize`. + // Monotonicity then gives the equivalence: + // + // object_size(trailing_bytes) <= usize::MAX + // iff trailing_bytes <= max_trailing_bytes. + // + // If the helper returns `None`, even `object_size(0)` exceeds the + // limit, so no element count can produce a representable size. + let max_trailing_bytes = match self.max_trailing_bytes(usize::MAX) { + Some(bytes) => bytes, + None => return None, + }; + + // Each of the `elems` elements contributes `self.elem_size` bytes, + // so substitution into the formula requires their product. Checked + // multiplication yields that exact product, including zero for + // zero-sized elements. If it overflows, the complete size cannot fit + // either: `object_size(trailing_bytes) >= trailing_bytes` because + // the base, phase, and rounding increment are all nonnegative. + let trailing_size = match self.elem_size.checked_mul(elems) { + Some(bytes) => bytes, + None => return None, + }; + + // The equivalence above rejects exactly the remaining cases whose + // complete size exceeds `usize::MAX`. After this check we have + // `object_size(trailing_size) <= usize::MAX`. + if trailing_size > max_trailing_bytes { + return None; + } + + // The trailing slice starts at `self.offset` and occupies + // `trailing_size` bytes. Thus `trailing_end` is its end offset modulo + // `usize::MAX + 1`. This sum need not fit: a raw layout need not + // describe a Rust type, so its trailing slice need not lie within + // the complete object size bounded above. + let trailing_end = self.offset.wrapping_add(trailing_size); + + // `padding_for_elems` returns `object_size(trailing_size)` minus the + // trailing-slice end, modulo `usize::MAX + 1`. Adding the end back + // therefore gives `object_size(trailing_size)` modulo the same + // modulus. The bound above establishes that this size fits in + // `usize`, so its residue is the exact size specified by the layout. + let size = trailing_end.wrapping_add(self.padding_for_elems(elems)); + Some(size) + } + + /// Returns `true` only when `self` and `other` describe the same size for + /// every trailing slice length. + /// + /// This recognizes sufficient conditions for equality. A `false` result + /// also includes equivalent size sequences that this test cannot recognize. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&same| { + let (self_align, self_phase) = self.size_rounding_align_and_phase.components(); + let (other_align, other_phase) = other.size_rounding_align_and_phase.components(); + let initial_size = proofs::slice_dst_size_for_trailing_bytes(self, 0); + same == (self.elem_size == other.elem_size + && initial_size.is_some() + && initial_size == proofs::slice_dst_size_for_trailing_bytes(other, 0) + && ((self.elem_size % self_align.get() == 0 + && self.elem_size % other_align.get() == 0) + || (self_align == other_align && self_phase == other_phase))) + }))] + pub(crate) const fn has_same_size_sequence(self, other: Self) -> bool { + #[cfg(kani)] + #[kani::proof_for_contract(TrailingSliceLayout::has_same_size_sequence)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().has_same_size_sequence(kani::any()); + } + + // For either layout, interpret its normalized size formula over the + // nonnegative integers: + // + // object_size(elems) + // = size_base + // + round_up(size_phase + elems * elem_size, size_align). + // + // In two cases below, we establish equality of these mathematical sizes + // for every `elems`. Equal sizes also have the same `usize` + // representability bound. + // + // Both cases require a common byte contribution at each element count. + // Equal element sizes give that shared term, `elems * elem_size`. + if self.elem_size != other.elem_size { + return false; + } + + // At `elems == 0`, the element contribution vanishes. Require equal, + // representable initial sizes; each case below then shows that + // increasing `elems` adds the same amount to both initial sizes. + match (self.size_for_elems(0), other.size_for_elems(0)) { + (Some(self_size), Some(other_size)) if self_size == other_size => {} + _ => return false, + } + + let (self_align, self_phase) = self.size_rounding_align_and_phase.components(); + let (other_align, other_phase) = other.size_rounding_align_and_phase.components(); + let self_align = self_align.get(); + let other_align = other_align.get(); + + // Case 1: The element size is a multiple of both alignments. + // + // For two power-of-two alignments, the larger is divisible by the + // smaller. Thus `max_align` is their least common multiple, and a byte + // count is divisible by both alignments exactly when it is divisible by + // `max_align`. + let max_align = if self_align > other_align { self_align } else { other_align }; + + // If the element size is divisible by both alignments, so is `elems * + // elem_size`. Adding an alignment multiple commutes with rounding, + // giving the following identity for each layout: + // + // round_up(size_phase + elems * elem_size, size_align) + // = round_up(size_phase, size_align) + elems * elem_size. + // + // Consequently, `object_size(elems) = object_size(0) + elems * + // elem_size`. The initial sizes and element sizes already agree, so the + // complete size sequences agree. This also covers zero-sized elements, + // whose contribution is zero for every element count. + #[allow(clippy::arithmetic_side_effects)] + if self.elem_size % max_align == 0 { + return true; + } + + // Case 2: The rounding alignments and phases match. + // + // Require a common rounding alignment before comparing phases. If the + // alignments differ, neither case establishes equality. + if self_align != other_align { + return false; + } + + // If the phases also agree, both `round_up` operations have the same + // input and alignment at every element count. In particular, their + // rounded terms agree at zero. Subtracting that common term from the + // equal initial sizes proves `self.size_base == other.size_base`. Both + // the fixed and rounded contributions therefore agree for every + // `elems`, which establishes equality of the complete size formulas. + self_phase == other_phase + } + + /// Advances the size formula by `bytes` while replacing its element size. + /// + /// Let `(size_align, size_phase)` be decoded from + /// `self.size_rounding_align_and_phase`. For a trailing slice length + /// `elems`, the returned layout describes: + /// + /// ```text + /// self.size_base + /// + round_up(size_phase + bytes + elems * elem_size, size_align) + /// ``` + /// + /// Here, `elem_size` is the replacement element size passed to this + /// method. The returned layout preserves the physical slice offset + /// `self.offset` and the rounding alignment; advancing changes the size + /// calculation. + /// + /// Returns `None` if advancing cannot produce representable normalized + /// components. Even when those components fit, evaluating the rounded + /// object size may overflow for some or all trailing slice lengths. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|result| { + let expected_offset = self.size_offset().checked_add(bytes); + match result { + Some(advanced) => { + let align = self.size_rounding_align_and_phase.align(); + expected_offset == Some(advanced.size_offset()) + && advanced.offset == self.offset + && advanced.elem_size == elem_size + && advanced.size_rounding_align_and_phase.align() == align + && (advanced.size_base & (align.get() - 1)) == (self.size_base & (align.get() - 1)) + } + None => expected_offset.is_none(), + } + }))] + const fn advance(self, bytes: usize, elem_size: usize) -> Option { + #[cfg(kani)] + #[kani::proof_for_contract(TrailingSliceLayout::advance)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().advance(kani::any(), kani::any()); + } + + let (size_align, size_phase) = self.size_rounding_align_and_phase.components(); + + // Advancing replaces the phase inside `round_up` with `size_phase + + // bytes`. To normalize this sum, separate it into whole alignment units + // and a remainder. The whole units can move outside `round_up` into the + // base; the remainder becomes the new phase. + // + // Since `size_align` is a nonzero power of two, `align_mask` selects + // exactly the bits representing a remainder modulo `size_align`. + // Nonzero `size_align` also makes this subtraction representable. + #[allow(clippy::arithmetic_side_effects)] + let align_mask = size_align.get() - 1; + + // The base can increase by at most `usize::MAX - self.size_base`. Since + // only whole alignment units move into the base, the largest + // transferable contribution is: + // + // whole_capacity + // = round_down(usize::MAX - self.size_base, size_align). + // + // A remainder up to `align_mask` stays in the phase and consumes no + // base capacity. Thus the largest advanced phase that can be split into + // representable normalized components is `phase_capacity = + // whole_capacity + align_mask`. Setting the low bits of the remaining + // base capacity computes exactly that sum. Its two terms have disjoint + // set bits, so it cannot overflow. Any larger sum would transfer more + // whole units than the base can hold. + #[allow(clippy::arithmetic_side_effects)] + let phase_capacity = (usize::MAX - self.size_base) | align_mask; + + // The existing phase already occupies `size_phase` bytes of this + // capacity. Subtracting it gives the greatest permitted increment: + // + // bytes <= max_advance + // iff size_phase + bytes <= phase_capacity. + // + // Since `phase_capacity >= align_mask >= size_phase`, the subtraction + // cannot underflow. + #[allow(clippy::arithmetic_side_effects)] + let max_advance = phase_capacity - size_phase; + if bytes > max_advance { + return None; + } + + // This is the fixed term inside rounding after the requested + // advancement, before normalization. The check above proves `size_phase + // + bytes <= phase_capacity <= usize::MAX`, so the addition is + // representable. + #[allow(clippy::arithmetic_side_effects)] + let advanced_phase = size_phase + bytes; + + // Taking the remainder retains precisely the part that cannot move + // outside rounding as whole alignment units. In particular, `0 <= + // normalized_phase < size_align`, as required for the new rounding + // parameters. + let normalized_phase = advanced_phase & align_mask; + + // Rounding down removes precisely the remainder retained in the + // phase, so `whole_bytes = advanced_phase - normalized_phase`. + let whole_bytes = + util::round_down_to_next_multiple_of_alignment(advanced_phase, size_align); + + // Because `advanced_phase <= phase_capacity`, we have + // `whole_bytes <= whole_capacity <= usize::MAX - self.size_base`. + // Adding this transferred contribution to the base therefore fits. + #[allow(clippy::arithmetic_side_effects)] + let size_base = self.size_base + whole_bytes; + + // To verify the returned formula, let `trailing_size = elems * + // elem_size` and evaluate the following over the nonnegative integers. + // Since `whole_bytes` is a multiple of `size_align`, it can move back + // inside `round_up` without changing the result: + // + // size_base + round_up(normalized_phase + trailing_size, size_align) + // = self.size_base + // + round_up(whole_bytes + normalized_phase + trailing_size, size_align) + // = self.size_base + // + round_up(size_phase + bytes + trailing_size, size_align). + // + // This is the requested advanced formula. The replacement element size + // supplies its new coefficient of `elems`; `offset` retains the + // caller's field-placement description. + Some(Self { + offset: self.offset, + elem_size, + size_base, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(size_align, normalized_phase), + }) + } } impl SizeInfo { @@ -206,12 +757,42 @@ impl SizeInfo { #[allow(unused)] #[cfg_attr(not(zerocopy_inline_always), inline)] #[cfg_attr(zerocopy_inline_always, inline(always))] + #[cfg_attr(kani, kani::ensures(|result| match (*self, *result) { + (SizeInfo::Sized { size }, Some(SizeInfo::Sized { size: converted })) => { + size == converted + } + (SizeInfo::SliceDst(original), Some(SizeInfo::SliceDst(converted))) => { + converted.elem_size.get() == original.elem_size + && converted.offset == original.offset + && converted.size_base == original.size_base + && converted.size_rounding_align_and_phase == original.size_rounding_align_and_phase + } + (SizeInfo::SliceDst(original), None) => original.elem_size == 0, + _ => false, + }))] const fn try_to_nonzero_elem_size(&self) -> Option> { + #[cfg(kani)] + #[kani::proof_for_contract(SizeInfo::try_to_nonzero_elem_size)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().try_to_nonzero_elem_size(); + } + Some(match *self { SizeInfo::Sized { size } => SizeInfo::Sized { size }, - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) => { + SizeInfo::SliceDst(TrailingSliceLayout { + offset, + elem_size, + size_base, + size_rounding_align_and_phase, + }) => { if let Some(elem_size) = NonZeroUsize::new(elem_size) { - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) + SizeInfo::SliceDst(TrailingSliceLayout { + offset, + elem_size, + size_base, + size_rounding_align_and_phase, + }) } else { return None; } @@ -220,6 +801,40 @@ impl SizeInfo { } } +/// Returns the largest number of `elem_size`-byte elements which fit in +/// `bytes`, along with the number of bytes those elements occupy. +#[cfg_attr( + kani, + kani::ensures(|&(elems, used)| { + (match elems.checked_mul(elem_size.get()) { + Some(product) => { + product == used && used <= bytes && bytes - used < elem_size.get() + } + None => false, + }) && used + .checked_add(elem_size.get()) + .map(|next_used| next_used > bytes) + .unwrap_or(true) + }) +)] +#[inline(always)] +const fn max_elems_for_bytes(bytes: usize, elem_size: NonZeroUsize) -> (usize, usize) { + #[cfg(kani)] + #[kani::proof_for_contract(max_elems_for_bytes)] + #[kani::solver(kissat)] + fn proof() { + max_elems_for_bytes(kani::any(), kani::any()); + } + + #[allow(clippy::arithmetic_side_effects)] + let elems = bytes / elem_size.get(); + let used = match elems.checked_mul(elem_size.get()) { + Some(used) => used, + None => const_unreachable!(), + }; + (elems, used) +} + #[doc(hidden)] #[derive(Copy, Clone)] #[cfg_attr(test, derive(Debug))] @@ -230,6 +845,7 @@ pub enum CastType { } #[cfg_attr(test, derive(Debug))] +#[cfg_attr(kani, derive(kani::Arbitrary))] pub(crate) enum MetadataCastError { Alignment, Size, @@ -261,7 +877,7 @@ impl DstLayout { /// 229. #[cfg(not(kani))] #[cfg(not(target_pointer_width = "16"))] - pub(crate) const CURRENT_MAX_ALIGN: NonZeroUsize = match NonZeroUsize::new(1 << 28) { + pub(crate) const CURRENT_MAX_ALIGN: NonZeroUsize = match NonZeroUsize::new(1 << 29) { Some(max_align) => max_align, None => const_unreachable!(), }; @@ -297,7 +913,18 @@ impl DstLayout { /// shallow padding, unsafe code may assume that the result of this method /// accurately reflects the size, alignment, and lack of static shallow /// padding of that type. + #[cfg_attr(kani, kani::ensures(|result| { + result.align == self.align && result.size_info == self.size_info + && result.statically_shallow_unpadded + }))] const fn assume_shallow_unpadded(self) -> Self { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::assume_shallow_unpadded)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().assume_shallow_unpadded(); + } + Self { statically_shallow_unpadded: true, ..self } } @@ -315,7 +942,20 @@ impl DstLayout { #[doc(hidden)] #[must_use] #[inline] + #[cfg_attr(kani, kani::requires(repr_align.map_or(true, |align| align.is_power_of_two())))] + #[cfg_attr(kani, kani::ensures(|result| { + result.align == repr_align.unwrap_or(Self::MIN_ALIGN) + && result.size_info == SizeInfo::Sized { size: 0 } + && result.statically_shallow_unpadded + }))] pub const fn new_zst(repr_align: Option) -> DstLayout { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::new_zst)] + #[kani::solver(kissat)] + fn proof() { + let _ = DstLayout::new_zst(kani::any()); + } + let align = match repr_align { Some(align) => align, None => Self::MIN_ALIGN, @@ -339,7 +979,24 @@ impl DstLayout { #[doc(hidden)] #[must_use] #[inline] + #[cfg_attr(kani, kani::ensures(|result| { + result.align.get() == mem::align_of::() + && result.size_info == SizeInfo::Sized { size: mem::size_of::() } + && result.statically_shallow_unpadded == false + }))] pub const fn for_type() -> DstLayout { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::for_type::<()>)] + fn proof_zst() { + let _ = DstLayout::for_type::<()>(); + } + + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::for_type::<(u8, u64)>)] + fn proof_padded() { + let _ = DstLayout::for_type::<(u8, u64)>(); + } + // SAFETY: `align` is correct by construction. `T: Sized`, and so it is // sound to initialize `size_info` to `SizeInfo::Sized { size }`; the // `size` field is also correct by construction. `unpadded` can safely @@ -354,16 +1011,39 @@ impl DstLayout { } } - /// Constructs a `DstLayout` which describes a `T` that does not contain - /// padding. + /// Produces `T`'s size and alignment with static shallow padding assumed + /// absent. + /// + /// This method records the assumption without checking `T` for padding. + /// It can be used to ignore padding within a field when computing padding + /// introduced by its enclosing struct. /// /// # Safety /// - /// Unsafe code may assume that `DstLayout` is the correct layout for `T`. + /// Unsafe code may rely on the result's size and alignment being correct + /// for `T`. Relying on its recorded absence of static shallow padding + /// requires an independent justification that `T` lacks such padding. #[doc(hidden)] #[must_use] #[inline] + #[cfg_attr(kani, kani::ensures(|result| { + result.align.get() == mem::align_of::() + && result.size_info == SizeInfo::Sized { size: mem::size_of::() } + && result.statically_shallow_unpadded == true + }))] pub const fn for_unpadded_type() -> DstLayout { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::for_unpadded_type::<()>)] + fn proof_zst() { + let _ = DstLayout::for_unpadded_type::<()>(); + } + + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::for_unpadded_type::<(u8, u64)>)] + fn proof_padded() { + let _ = DstLayout::for_unpadded_type::<(u8, u64)>(); + } + Self::for_type::().assume_shallow_unpadded() } @@ -372,7 +1052,31 @@ impl DstLayout { /// # Safety /// /// Unsafe code may assume that `DstLayout` is the correct layout for `[T]`. + #[cfg_attr(kani, kani::ensures(|result| { + result.align.get() == mem::align_of::() && result.statically_shallow_unpadded + && matches!(result.size_info, SizeInfo::SliceDst(trailing) + if trailing.offset == 0 && trailing.size_base == 0 + && trailing.elem_size == mem::size_of::() + && trailing.size_rounding_align_and_phase.components() == (result.align, 0)) + }))] pub(crate) const fn for_slice() -> DstLayout { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::for_slice::<()>)] + fn proof_zst() { + let _ = DstLayout::for_slice::<()>(); + } + + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::for_slice::<(u8, u64)>)] + fn proof_padded() { + let _ = DstLayout::for_slice::<(u8, u64)>(); + } + + let align = match NonZeroUsize::new(mem::align_of::()) { + Some(align) => align, + None => const_unreachable!(), + }; + // SAFETY: The alignment of a slice is equal to the alignment of its // element type, and so `align` is initialized correctly. // @@ -382,13 +1086,12 @@ impl DstLayout { // construction. Since `[T]` is a (degenerate case of a) slice DST, it // is correct to initialize `size_info` to `SizeInfo::SliceDst`. DstLayout { - align: match NonZeroUsize::new(mem::align_of::()) { - Some(align) => align, - None => const_unreachable!(), - }, + align, size_info: SizeInfo::SliceDst(TrailingSliceLayout { offset: 0, elem_size: mem::size_of::(), + size_base: 0, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(align, 0), }), statically_shallow_unpadded: true, } @@ -406,31 +1109,58 @@ impl DstLayout { /// alignment modifiers and fields corresponding to that definition, the /// resulting `DstLayout` will correctly encode the layout of that struct. /// + /// This guarantee uses one explicit compatibility premise for generic + /// indirection: while Rust accepts an instantiation which transitively + /// places a `repr(align)` type inside a `repr(packed)` type, that + /// instantiation follows the otherwise-applicable `repr(C)`, + /// `repr(packed)`, and inner-type layout rules. The Reference prohibits the + /// direct form, and Rust's continued acceptance and layout behavior are + /// outside the algebra proved by this implementation. Re-audit if the + /// compiler rejects such an instantiation or assigns it a different + /// layout. + /// /// We make no guarantees to the behavior of this method when it is invoked /// with arguments that cannot correspond to a valid `repr(C)` struct. #[must_use] #[inline] + #[cfg_attr(kani, kani::requires(proofs::repr_c_layout(repr_align, repr_packed, fields).is_some()))] + #[cfg_attr(kani, kani::ensures(|&result| { + Some(result) == proofs::repr_c_layout(repr_align, repr_packed, fields) + }))] pub const fn for_repr_c_struct( repr_align: Option, repr_packed: Option, fields: &[DstLayout], ) -> DstLayout { - let mut layout = DstLayout::new_zst(repr_align); + // This harness covers up to three fields, including an optional final + // DST. Arbitrary-length composition is not established by this bound. + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::for_repr_c_struct)] + #[kani::solver(kissat)] + #[kani::unwind(4)] + fn proof() { + let fields: [DstLayout; 3] = kani::any(); + let len: usize = kani::any(); + kani::assume(len <= fields.len()); + let _ = DstLayout::for_repr_c_struct(kani::any(), kani::any(), &fields[..len]); + } + + let mut result = DstLayout::new_zst(repr_align); let mut i = 0; #[allow(clippy::arithmetic_side_effects)] while i < fields.len() { #[allow(clippy::indexing_slicing)] let field = fields[i]; - layout = layout.extend(field, repr_packed); + result = result.extend(field, repr_packed); i += 1; } - layout = layout.pad_to_align(); + result = result.pad_to_align(); - // SAFETY: `layout` accurately describes the layout of a `repr(C)` + // SAFETY: `result` accurately describes the result of a `repr(C)` // struct with `repr_align` or `repr_packed` alignment modifications and - // the given `fields`. The `layout` is constructed using a sequence of + // the given `fields`. The `result` is constructed using a sequence of // invocations of `DstLayout::{new_zst,extend,pad_to_align}`. The // documentation of these items vows that invocations in this manner // will accurately describe a type, so long as: @@ -440,7 +1170,7 @@ impl DstLayout { // - the presence of `repr_align` and `repr_packed` are correctly accounted for. // // We respect all three of these preconditions above. - layout + result } /// Like `Layout::extend`, this creates a layout that describes a record @@ -471,7 +1201,18 @@ impl DstLayout { #[doc(hidden)] #[must_use] #[inline] + #[cfg_attr(kani, kani::requires(proofs::extended_layout(self, field, repr_packed).is_some()))] + #[cfg_attr(kani, kani::ensures(|&result| { + Some(result) == proofs::extended_layout(self, field, repr_packed) + }))] pub const fn extend(self, field: DstLayout, repr_packed: Option) -> Self { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::extend)] + #[kani::solver(kissat)] + fn proof() { + let _ = kani::any::().extend(kani::any(), kani::any()); + } + use util::{max, min, padding_needed_for}; // If `repr_packed` is `None`, there are no alignment constraints, and @@ -560,6 +1301,8 @@ impl DstLayout { SizeInfo::SliceDst(TrailingSliceLayout { offset: trailing_offset, elem_size, + size_base, + size_rounding_align_and_phase, }) => { // If the trailing field is dynamically sized, so too // will the resulting layout. The offset of the trailing @@ -573,11 +1316,30 @@ impl DstLayout { // panic otherwise (e.g., combining or aligning the // components would create a size exceeding // `usize::MAX`). - let offset = match offset.checked_add(trailing_offset) { + let trailing_offset = match offset.checked_add(trailing_offset) { Some(offset) => offset, None => const_panic!("`field` cannot be appended without the total size overflowing `usize`"), }; - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) + + // Before normalization, the composite's size is: + // + // offset + size_base + // + round_up(size_phase + elems * elem_size, size_align) + // + // `offset` does not participate in the rounding, + // so it can be added directly to the normalized + // base. + let size_base = match offset.checked_add(size_base) { + Some(base) => base, + None => const_panic!("`field` cannot be appended without the total size overflowing `usize`"), + }; + + SizeInfo::SliceDst(TrailingSliceLayout { + offset: trailing_offset, + elem_size, + size_base, + size_rounding_align_and_phase, + }) } }, ) @@ -592,18 +1354,11 @@ impl DstLayout { } /// Like `Layout::pad_to_align`, this routine rounds the size of this layout - /// up to the nearest multiple of this type's alignment or `repr_packed` - /// (whichever is less). This method leaves DST layouts unchanged, since the - /// trailing padding of DSTs is computed at runtime. - /// - /// The accompanying boolean is `true` if the resulting composition of - /// fields necessitated static (as opposed to dynamic) padding; otherwise - /// `false`. + /// up to the nearest multiple of this type's alignment. For DST layouts, + /// this updates the runtime size formula to account for trailing padding. /// /// In order to match the layout of a `#[repr(C)]` struct, this method - /// should be invoked after the invocations of [`DstLayout::extend`]. If - /// `self` corresponds to a type marked with `repr(packed(N))`, then - /// `repr_packed` should be set to `Some(N)`, otherwise `None`. + /// should be invoked after the invocations of [`DstLayout::extend`]. /// /// This method cannot be used to match the layout of a record with the /// default representation, as that representation is mostly unspecified. @@ -613,7 +1368,7 @@ impl DstLayout { /// If a (potentially hypothetical) valid `repr(C)` type begins with fields /// whose layout are `self` followed only by zero or more bytes of trailing /// padding (not included in `self`), then unsafe code may rely on - /// `self.pad_to_align(repr_packed)` producing a layout that correctly + /// `self.pad_to_align()` producing a layout that correctly /// encapsulates the layout of that type. /// /// We make no guarantees to the behavior of this method if `self` cannot @@ -622,7 +1377,16 @@ impl DstLayout { #[doc(hidden)] #[must_use] #[inline] + #[cfg_attr(kani, kani::requires(proofs::padded_layout(self).is_some()))] + #[cfg_attr(kani, kani::ensures(|&result| Some(result) == proofs::padded_layout(self)))] pub const fn pad_to_align(self) -> Self { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::pad_to_align)] + #[kani::solver(kissat)] + fn proof() { + let _ = kani::any::().pad_to_align(); + } + use util::padding_needed_for; let (static_padding, size_info) = match self.size_info { @@ -637,10 +1401,69 @@ impl DstLayout { (padding, SizeInfo::Sized { size }) } // For DST layouts, trailing padding depends on the length of the - // trailing DST and is computed at runtime. This does not alter the - // offset or element size of the layout, so we leave `size_info` - // unchanged. - size_info @ SizeInfo::SliceDst(_) => (0, size_info), + // trailing DST and is computed at runtime. Normalize the composed + // rounding operation back into the representation documented on + // `TrailingSliceLayout`. + SizeInfo::SliceDst(mut trailing) => { + let (size_align, size_phase) = trailing.size_rounding_align_and_phase.components(); + if size_align.get() < self.align.get() { + // For a trailing-slice byte count `trailing_size`, the + // input layout describes: + // + // trailing.size_base + // + round_up(size_phase + trailing_size, size_align) + // + // Here, `size_align < self.align`. Both alignments are + // powers of two, so rounding the complete input size to + // `self.align` is equivalent to: + // + // rounded_base = round_up(trailing.size_base, size_align) + // fixed_contribution = rounded_base + size_phase + // new_phase = fixed_contribution % self.align + // new_base = fixed_contribution - new_phase + // new_base + round_up(new_phase + trailing_size, self.align) + // + // `prove_dst_layout_pad_to_align_dst_outer_alignment_transition` + // checks the exact field transition and preserved + // invariants. Its `_formula` companion proves this + // identity for every representable trailing byte count. + let base_padding = padding_needed_for(trailing.size_base, size_align); + let rounded_base = match trailing.size_base.checked_add(base_padding) { + Some(base) => base, + None => const_panic!("Adding padding caused size to overflow `usize`."), + }; + let fixed_contribution = match rounded_base.checked_add(size_phase) { + Some(bytes) => bytes, + None => const_panic!("Adding padding caused size to overflow `usize`."), + }; + #[allow(clippy::arithmetic_side_effects)] + let phase = fixed_contribution & (self.align.get() - 1); + // Rounding down removes `phase`, the fixed contribution + // modulo `self.align`, leaving the new base. + let size_base = util::round_down_to_next_multiple_of_alignment( + fixed_contribution, + self.align, + ); + trailing.size_base = size_base; + trailing.size_rounding_align_and_phase = + RoundingAlignAndPhase::new(self.align, phase); + } else { + // `size_align` is a multiple of `self.align`, so + // the inner rounded size is already aligned to + // `self.align`. Only `size_base` needs to be rounded. + // `prove_dst_layout_pad_to_align_dst_inner_rounding_transition` + // checks the exact field transition and preserved + // invariants. Its `_formula` companion proves size + // equivalence for every representable trailing byte count. + let padding = padding_needed_for(trailing.size_base, self.align); + trailing.size_base = match trailing.size_base.checked_add(padding) { + Some(base) => base, + None => const_panic!("Adding padding caused size to overflow `usize`."), + }; + } + + (0, SizeInfo::SliceDst(trailing)) + } }; let statically_shallow_unpadded = self.statically_shallow_unpadded && static_padding == 0; @@ -648,48 +1471,127 @@ impl DstLayout { DstLayout { align: self.align, size_info, statically_shallow_unpadded } } - /// Produces `true` if `self` requires static padding; otherwise `false`. + /// Produces `false` when this layout is marked as having no static shallow + /// padding, and `true` otherwise. + /// + /// A `true` result is conservative: it does not establish that the type + /// contains padding. For example, this method produces `true` for + /// `DstLayout::for_type::()`, since `for_type` does not record whether + /// static shallow padding is absent. #[must_use] #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&padding| padding == !self.statically_shallow_unpadded))] pub const fn requires_static_padding(self) -> bool { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::requires_static_padding)] + #[kani::solver(kissat)] + fn proof() { + let _ = kani::any::().requires_static_padding(); + } + !self.statically_shallow_unpadded } - /// Produces `true` if there exists any metadata for which a type of layout - /// `self` would require dynamic trailing padding; otherwise `false`. + /// Produces `false` only if every valid metadata describes an instance + /// which requires no dynamic trailing padding. + /// + /// A `true` result conservatively selects the dynamic-padding path; it does + /// not guarantee that any valid metadata actually requires padding. #[must_use] #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&padding| match self.size_info { + SizeInfo::Sized { .. } => !padding, + SizeInfo::SliceDst(trailing) => padding == ( + proofs::slice_dst_size_for_trailing_bytes(trailing, 0) != Some(trailing.offset) + || trailing.elem_size % trailing.size_rounding_align_and_phase.align().get() != 0 + ), + }))] pub const fn requires_dynamic_padding(self) -> bool { - // A `% self.align.get()` cannot panic, since `align` is non-zero. - #[allow(clippy::arithmetic_side_effects)] + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::requires_dynamic_padding)] + #[kani::solver(kissat)] + fn proof() { + let _ = kani::any::().requires_dynamic_padding(); + } + match self.size_info { SizeInfo::Sized { .. } => false, SizeInfo::SliceDst(trailing_slice_layout) => { - // SAFETY: This predicate is formally proved sound by - // `proofs::prove_requires_dynamic_padding`. - trailing_slice_layout.offset % self.align.get() != 0 - || trailing_slice_layout.elem_size % self.align.get() != 0 + // SAFETY: `proofs::prove_requires_dynamic_padding` formally + // proves the safety-critical direction: when this predicate + // returns false, no valid metadata requires dynamic padding. + match trailing_slice_layout.size_for_elems(0) { + Some(initial_size) => { + #[allow(clippy::arithmetic_side_effects)] + let has_static_stride = trailing_slice_layout.elem_size + % trailing_slice_layout.size_rounding_align_and_phase.align().get() + == 0; + initial_size != trailing_slice_layout.offset || !has_static_stride + } + None => true, + } + } + } + } + + /// Returns the largest trailing slice length whose object size is exactly + /// `size`. + /// + /// Returns `None` if there is no such length, if this describes a sized + /// type, or if the trailing slice element is zero-sized. + #[inline(always)] + #[cfg_attr(kani, kani::ensures(|&result| { + result == match self.size_info { + SizeInfo::SliceDst(trailing) if trailing.elem_size != 0 => { + proofs::max_trailing_bytes(trailing, size) + .map(|bytes| bytes / trailing.elem_size) + .filter(|&elems| trailing.size_for_elems(elems) == Some(size)) + } + _ => None, + } + }))] + const fn metadata_for_exact_size(&self, size: usize) -> Option { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::metadata_for_exact_size)] + #[kani::solver(kissat)] + fn proof() { + kani::any::().metadata_for_exact_size(kani::any()); + } + + match self.size_info { + SizeInfo::Sized { .. } + | SizeInfo::SliceDst(TrailingSliceLayout { elem_size: 0, .. }) => None, + SizeInfo::SliceDst(_) => { + // Address zero is aligned to every supported alignment, and + // `0 + size` cannot overflow. The validator returns the + // largest metadata whose object fits in `size`; accepting it + // only when the resulting object consumes all `size` bytes + // turns that bounded query into an exact-size query. + match self.validate_cast_and_convert_metadata(0, size, CastType::Prefix) { + Ok((elems, object_size)) if object_size == size => Some(elems), + _ => None, + } } } } - /// Validates that a cast is sound from a layout perspective. + /// Given a completed type layout, produces metadata and a split index for + /// a prefix or suffix cast that satisfies the type's size and alignment + /// requirements. /// - /// Validates that the size and alignment requirements of a type with the - /// layout described in `self` would not be violated by performing a - /// `cast_type` cast from a pointer with address `addr` which refers to a - /// memory region of size `bytes_len`. + /// `addr` and `bytes_len` describe the address and size of the source + /// memory region, and `cast_type` selects its prefix or suffix. /// /// If the cast is valid, `validate_cast_and_convert_metadata` returns - /// `(elems, split_at)`. If `self` describes a dynamically-sized type, then - /// `elems` is the maximum number of trailing slice elements for which a - /// cast would be valid (for sized types, `elem` is meaningless and should - /// be ignored). `split_at` is the index at which to split the memory region - /// in order for the prefix (suffix) to contain the result of the cast, and - /// in order for the remaining suffix (prefix) to contain the leftover - /// bytes. + /// `Ok((elems, split_at))`. For a dynamically-sized type, `elems` is the + /// maximum number of trailing slice elements for which the cast is valid. + /// For sized types, `elems` is meaningless and should be ignored. + /// `split_at` is the index at which to split the memory region so that the + /// prefix (suffix) contains the result of the cast and the remaining + /// suffix (prefix) contains the leftover bytes. /// /// There are three conditions under which a cast can fail: + /// /// - The smallest possible value for the type is larger than the provided /// memory region /// - A prefix cast is requested, and `addr` does not satisfy `self`'s @@ -701,10 +1603,10 @@ impl DstLayout { /// /// # Safety /// - /// The caller may assume that this implementation is correct, and may rely - /// on that assumption for the soundness of their code. In particular, the - /// caller may assume that, if `validate_cast_and_convert_metadata` returns - /// `Some((elems, split_at))`, then: + /// When `self` accurately describes a completed type layout, including + /// trailing padding, and `addr + bytes_len` fits in `usize`, callers may + /// rely on the following guarantees for `Ok((elems, split_at))`: + /// /// - A pointer to the type (for dynamically sized types, this includes /// `elems` as its pointer metadata) describes an object of size `size <= /// bytes_len` @@ -715,6 +1617,11 @@ impl DstLayout { /// - `split_at == bytes_len - size` /// - `addr + split_at` satisfies `self`'s alignment /// + /// These guarantees require the completed-layout premise; `DstLayout` + /// also permits unfinished layout fragments and arbitrary field values. + /// In particular, suffix alignment relies on every object size being a + /// multiple of the type's alignment. + /// /// Note that this method does *not* ensure that a pointer constructed from /// its return values will be a valid pointer. In particular, this method /// does not reason about `isize` overflow, which is a requirement of many @@ -739,12 +1646,57 @@ impl DstLayout { /// condition, even if `debug_assertions` are enabled. #[allow(unused)] #[inline(always)] + #[cfg_attr(kani, kani::requires(addr.checked_add(bytes_len).is_some()))] + #[cfg_attr(kani, kani::requires(!matches!(self.size_info, + SizeInfo::SliceDst(TrailingSliceLayout { elem_size: 0, .. }))))] + #[cfg_attr(kani, kani::ensures(|result| { + let endpoint = match cast_type { + CastType::Prefix => addr, + CastType::Suffix => addr + bytes_len, + }; + if endpoint % self.align.get() != 0 { + return matches!(result, Err(MetadataCastError::Alignment)); + } + let candidate = match self.size_info { + SizeInfo::Sized { size } => { + if size <= bytes_len { Some((0, size)) } else { None } + } + SizeInfo::SliceDst(trailing) => { + proofs::max_trailing_bytes(trailing, bytes_len).and_then(|bytes| { + let elems = bytes / trailing.elem_size; + trailing.size_for_elems(elems).map(|size| (elems, size)) + }) + } + }; + match (result, candidate) { + (Ok((elems, split_at)), Some((expected_elems, size))) => { + *elems == expected_elems && *split_at == match cast_type { + CastType::Prefix => size, + CastType::Suffix => bytes_len - size, + } + } + (Err(MetadataCastError::Size), None) => true, + _ => false, + } + }))] pub(crate) const fn validate_cast_and_convert_metadata( &self, addr: usize, bytes_len: usize, cast_type: CastType, ) -> Result<(usize, usize), MetadataCastError> { + #[cfg(kani)] + #[kani::proof_for_contract(DstLayout::validate_cast_and_convert_metadata)] + #[kani::solver(kissat)] + fn proof() { + let cast_type = if kani::any() { CastType::Prefix } else { CastType::Suffix }; + let _ = kani::any::().validate_cast_and_convert_metadata( + kani::any(), + kani::any(), + cast_type, + ); + } + // `debug_assert!`, but with `#[allow(clippy::arithmetic_side_effects)]`. macro_rules! __const_debug_assert { ($e:expr $(, $msg:expr)?) => { @@ -784,9 +1736,8 @@ impl DstLayout { // of this check is trivial - `addr` is the address the object will // live at. // - // For a suffix cast, we know that all valid sizes for the type are - // a multiple of the alignment (and by safety precondition, we know - // `DstLayout` may only describe valid Rust types). Thus, a + // Under the documented completed-layout premise, all valid sizes + // for the type are multiples of its alignment. Thus, a // validly-sized instance which lives at a validly-aligned address // must also end at a validly-aligned address. Thus, if the end // address for a suffix cast (`addr + bytes_len`) is not aligned, @@ -799,7 +1750,7 @@ impl DstLayout { // Addition is guaranteed not to overflow because `offset <= // bytes_len`, and `addr + bytes_len <= usize::MAX` is a // precondition of this method. Modulus is guaranteed not to divide - // by 0 because `align` is non-zero. + // by zero because `self.align` is non-zero. #[allow(clippy::arithmetic_side_effects)] if (addr + offset) % self.align.get() != 0 { return Err(MetadataCastError::Alignment); @@ -813,59 +1764,50 @@ impl DstLayout { } (0, size) } - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) => { - // Calculate the maximum number of bytes that could be consumed - // - any number of bytes larger than this will either not be a - // multiple of the alignment, or will be larger than - // `bytes_len`. - let max_total_bytes = - util::round_down_to_next_multiple_of_alignment(bytes_len, self.align); - // Calculate the maximum number of bytes that could be consumed - // by the trailing slice. - // - // FIXME(#67): Once our MSRV is 1.65, use let-else: - // https://blog.rust-lang.org/2022/11/03/Rust-1.65.0.html#let-else-statements - let max_slice_and_padding_bytes = match max_total_bytes.checked_sub(offset) { - Some(max) => max, - // `bytes_len` too small even for 0 trailing slice elements. + SizeInfo::SliceDst(trailing) => { + let max_slice_bytes = match trailing.max_trailing_bytes(bytes_len) { + Some(bytes) => bytes, None => return Err(MetadataCastError::Size), }; - // Calculate the number of elements that fit in - // `max_slice_and_padding_bytes`; any remaining bytes will be - // considered padding. + // `max_slice_bytes`. Any remaining input bytes stay outside + // the selected object unless the object's own rounding + // operation consumes them as padding. // // Guaranteed not to divide by zero: `elem_size` is non-zero. - #[allow(clippy::arithmetic_side_effects)] - let elems = max_slice_and_padding_bytes / elem_size.get(); - // Guaranteed not to overflow on multiplication: `usize::MAX >= - // max_slice_and_padding_bytes >= (max_slice_and_padding_bytes / - // elem_size) * elem_size`. + let (elems, _) = max_elems_for_bytes(max_slice_bytes, trailing.elem_size); + // The remainder is `max_slice_bytes - elems * elem_size`. + // Writing it as a remainder exposes its bound to the + // optimizer: when `elem_size <= size_align`, rounding the + // remainder down to `size_align` always gives zero. + let size_align = trailing.size_rounding_align_and_phase.align(); + // `size_phase + max_slice_bytes == max_rounded_bytes`, an + // alignment multiple. The selected tail removes `unused_bytes` + // from this boundary; rounding back up removes only whole + // alignment units. Thus its normalized rounded size is: // - // Guaranteed not to overflow on addition: - // - max_slice_and_padding_bytes == max_total_bytes - offset - // - elems * elem_size <= max_slice_and_padding_bytes == max_total_bytes - offset - // - elems * elem_size + offset <= max_total_bytes <= usize::MAX - #[allow(clippy::arithmetic_side_effects)] - let without_padding = offset + elems * elem_size.get(); - // `self_bytes` is equal to the offset bytes plus the bytes - // consumed by the trailing slice plus any padding bytes - // required to satisfy the alignment. Note that we have computed - // the maximum number of trailing slice elements that could fit - // in `self_bytes`, so any padding is guaranteed to be less than - // the size of an extra element. + // round_up(size_phase + elems * elem_size, size_align) + // = max_rounded_bytes - round_down(unused_bytes, size_align) // - // Guaranteed not to overflow: - // - By previous comment: without_padding == elems * elem_size + - // offset <= max_total_bytes - // - By construction, `max_total_bytes` is a multiple of - // `self.align`. - // - At most, adding padding needed to round `without_padding` - // up to the next multiple of the alignment will bring - // `self_bytes` up to `max_total_bytes`. + // `prove_slice_dst_validator_selected_size_fits` checks this + // identity and its intermediate bounds, using the relations + // in `prove_max_trailing_bytes_components`. The capacity is + // exact by `prove_max_trailing_bytes`; the element helper's + // contract establishes the largest whole-element count. + #[allow(clippy::arithmetic_side_effects)] + let unused_bytes = max_slice_bytes % trailing.elem_size.get(); + let unused_aligned_bytes = + util::round_down_to_next_multiple_of_alignment(unused_bytes, size_align); #[allow(clippy::arithmetic_side_effects)] - let self_bytes = - without_padding + util::padding_needed_for(without_padding, self.align); + let max_rounded_bytes = util::round_down_to_next_multiple_of_alignment( + bytes_len - trailing.size_base, + size_align, + ); + // `unused_aligned_bytes <= unused_bytes <= max_slice_bytes + // <= max_rounded_bytes`, and `size_base + max_rounded_bytes + // <= bytes_len`, so neither operation can overflow. + #[allow(clippy::arithmetic_side_effects)] + let self_bytes = trailing.size_base + (max_rounded_bytes - unused_aligned_bytes); (elems, self_bytes) } }; @@ -878,7 +1820,7 @@ impl DstLayout { // - In the `Sized` branch, only returns `size` if `size <= // bytes_len`. // - In the `SliceDst` branch, calculates `self_bytes <= - // max_toatl_bytes`, which is upper-bounded by `bytes_len`. + // bytes_len`. #[allow(clippy::arithmetic_side_effects)] CastType::Suffix => bytes_len - self_bytes, }; @@ -887,9 +1829,147 @@ impl DstLayout { } } +/// Computes the affine destination-metadata map. +/// +/// # Safety +/// +/// `base + metadata * multiple`, evaluated over the mathematical nonnegative +/// integers, must not exceed `usize::MAX`. +#[inline(always)] +#[allow(unstable_name_collisions)] +#[cfg_attr(kani, kani::requires(metadata.checked_mul(multiple) + .and_then(|scaled| base.checked_add(scaled)).is_some()))] +#[cfg_attr(kani, kani::ensures(|&result| { + metadata.checked_mul(multiple).and_then(|scaled| base.checked_add(scaled)) == Some(result) +}))] +unsafe fn add_scaled_metadata(base: usize, metadata: usize, multiple: usize) -> usize { + // The call-site safety argument establishes that both operations are + // representable for valid source metadata. This harness verifies the + // production implementation selected by Kani's modern rustc against + // checked arithmetic under precisely that precondition. The distributive + // natural-number argument which establishes the precondition is kept + // beside the unsafe pointer projection where it can be reviewed with the + // layout premises that it consumes. + #[cfg(kani)] + #[kani::proof_for_contract(add_scaled_metadata)] + #[kani::solver(kissat)] + fn proof() { + let base: usize = kani::any(); + let metadata: usize = kani::any(); + let multiple: usize = kani::any(); + let Some(scaled) = metadata.checked_mul(multiple) else { + kani::assume(false); + loop {} + }; + let Some(expected) = base.checked_add(scaled) else { + kani::assume(false); + loop {} + }; + + // SAFETY: The checked operations above returned `Some`, so the + // operation is representable in `usize`. The standard-library + // contracts say that this rules out undefined behavior for both + // unchecked operations [1][2]. + // + // [1] Per https://doc.rust-lang.org/1.93.1/std/primitive.usize.html#method.unchecked_mul: + // + // This results in undefined behavior [..] when `checked_mul` would + // return `None`. + // + // [2] Per https://doc.rust-lang.org/1.93.1/std/primitive.usize.html#method.unchecked_add: + // + // This results in undefined behavior [..] when `checked_add` would + // return `None`. + assert_eq!(unsafe { add_scaled_metadata(base, metadata, multiple) }, expected); + } + + #[allow(unused_imports)] + use crate::util::polyfills::*; + + // SAFETY: The caller promises that `base + metadata * multiple` is + // representable over the nonnegative integers. Thus `metadata * multiple` + // is representable too. On toolchains with inherent unchecked arithmetic, + // this is exactly the condition under which `usize::unchecked_mul` does + // not have undefined behavior [1]. On older supported toolchains, method + // resolution selects `polyfills::NumExt::unchecked_mul`, whose safety + // contract requires the same no-overflow condition. Kani checks the two + // implementations in separate harnesses. + // + // [1] Per https://doc.rust-lang.org/1.93.1/std/primitive.usize.html#method.unchecked_mul: + // + // This results in undefined behavior when `self * rhs > usize::MAX` or + // `self * rhs < usize::MIN`, i.e. when `checked_mul` would return `None`. + // + let scaled = unsafe { metadata.unchecked_mul(multiple) }; + + // SAFETY: The caller promises that `base + metadata * multiple` is + // representable in `usize`, and `scaled == metadata * multiple`. On + // toolchains with inherent unchecked arithmetic, that is exactly the + // condition under which `usize::unchecked_add` does not have undefined + // behavior [1]. On older supported toolchains, method resolution selects + // `polyfills::NumExt::unchecked_add`, whose safety contract requires the + // same no-overflow condition. Kani checks the two implementations in + // separate harnesses. + // + // [1] Per https://doc.rust-lang.org/1.93.1/std/primitive.usize.html#method.unchecked_add: + // + // This results in undefined behavior when `self + rhs > usize::MAX` or + // `self + rhs < usize::MIN`, i.e. when `checked_add` would return `None`. + unsafe { base.unchecked_add(scaled) } +} + pub(crate) use cast_from::CastFrom; mod cast_from; +#[cfg(any(test, kani))] +mod padding_testutil { + use crate::KnownLayout; + + #[derive(KnownLayout)] + #[repr(C, align(4))] + pub(super) struct Aligned { + pub(super) prefix: Prefix, + pub(super) tail: Tail, + } + + /// Compare direct padding with the complete size minus the slice end. + /// Counts whose complete object size exceeds `usize::MAX` are omitted. + pub(super) fn check_padding_for_elems(elems: usize) -> Option + where + Target: ?Sized + KnownLayout, + { + let layout = crate::trailing_slice_layout::(); + let trailing_size = elems.checked_mul(layout.elem_size)?; + + // Evaluate the complete rounded size independently of both helpers: + // `size_for_elems` delegates to the padding method being tested. + let (alignment, phase) = layout.size_rounding_align_and_phase.components(); + #[allow(clippy::arithmetic_side_effects)] + let mask = alignment.get() - 1; + let unrounded = phase.checked_add(trailing_size)?; + let rounded = unrounded.checked_add(mask)? & !mask; + let object_size = layout.size_base.checked_add(rounded)?; + let trailing_end = layout.offset.checked_add(trailing_size).unwrap(); + let expected = object_size.checked_sub(trailing_end).unwrap(); + + let padding = layout.padding_for_elems(elems); + assert_eq!(padding, expected); + Some(padding) + } + + pub(super) fn check_layouts(elems: usize) { + // Slices have no trailing padding. The aligned fixtures cover + // zero and nonzero phases, a three-byte element whose padding + // cycles with the element count, and zero-sized elements. + let _ = check_padding_for_elems::<[u8]>(elems); + let _ = check_padding_for_elems::<[()]>(elems); + let _ = check_padding_for_elems::>(elems); + let _ = check_padding_for_elems::>(elems); + let _ = check_padding_for_elems::>(elems); + let _ = check_padding_for_elems::>(elems); + } +} + // FIXME(#67): For some reason, on our MSRV toolchain, this `allow` isn't // enforced despite having `#![allow(unknown_lints)]` at the crate root, but // putting it here works. Once our MSRV is high enough that this bug has been @@ -936,14 +2016,187 @@ mod tests { assert_eq!(max.align(), max_align); assert_eq!(max.components().1, max_phase); - assert_eq!(mem::size_of::(), mem::size_of::()); + assert_eq!(mem::size_of::(), 4 * mem::size_of::()); + } + + #[test] + #[allow(clippy::arithmetic_side_effects)] + fn test_trailing_slice_layout_normal_form() { + // Compare the stored base-and-phase formula with + // `prefix + round_up(offset + elems * elem_size, align)`, where + // `prefix < align`. Move whole alignment-sized chunks of `offset` + // into `size_base` and retain the remainder as the phase. Also verify + // that `size_offset()` recovers the exact `offset` used by casts to + // choose between metadata values with the same rounded object size. + for align in [1, 2, 4, 8, 16] { + let align = NonZeroUsize::new(align).unwrap(); + for prefix in 0..align.get() { + for offset in 0..32 { + let phase = offset % align; + let size_base = (offset - phase) | prefix; + let layout = TrailingSliceLayout { + offset: 0, + elem_size: 3, + size_base, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(align, phase), + }; + assert_eq!(layout.size_offset(), offset); + + for elems in 0..32 { + let unrounded = offset + elems * layout.elem_size; + let old_size = + prefix + unrounded + util::padding_needed_for(unrounded, align); + assert_eq!(layout.size_for_elems(elems), Some(old_size)); + } + } + } + } + } + + #[test] + #[allow(clippy::arithmetic_side_effects)] + fn test_normalized_size_bounds() { + // Use checked evaluation as an independent oracle, including layouts + // whose empty object is already too large to represent. + let checked_size = |layout: TrailingSliceLayout, trailing_bytes: usize| { + let (alignment, phase) = layout.size_rounding_align_and_phase.components(); + let unrounded = phase.checked_add(trailing_bytes)?; + let rounded = unrounded.checked_add(util::padding_needed_for(unrounded, alignment))?; + layout.size_base.checked_add(rounded) + }; + for exponent in 0..POINTER_WIDTH_BITS { + let alignment = NonZeroUsize::new(1usize << exponent).unwrap(); + for phase in [0, alignment.get() / 2, alignment.get() - 1] { + for base in [0, 1, alignment.get() - 1, alignment.get(), usize::MAX - 1, usize::MAX] + { + let mut layout = TrailingSliceLayout { + offset: 0, + elem_size: 1, + size_base: base, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(alignment, phase), + }; + for available in [0, 1, alignment.get() - 1, alignment.get(), usize::MAX] { + match layout.max_trailing_bytes(available) { + Some(max_bytes) => { + assert!(checked_size(layout, max_bytes).unwrap() <= available); + if let Some(next_bytes) = max_bytes.checked_add(1) { + assert!(checked_size(layout, next_bytes) + .map(|size| size > available) + .unwrap_or(true)); + } + } + None => { + assert!(checked_size(layout, 0) + .map(|size| size > available) + .unwrap_or(true)); + } + } + } + + for elem_size in [0, 1, 3, alignment.get(), usize::MAX] { + layout.elem_size = elem_size; + let max_elems = layout + .max_trailing_bytes(usize::MAX) + .and_then(|bytes| bytes.checked_div(elem_size)) + .unwrap_or(usize::MAX); + for elems in [ + 0, + 1, + max_elems.saturating_sub(1), + max_elems, + max_elems.saturating_add(1), + usize::MAX, + ] { + let expected = elems + .checked_mul(elem_size) + .and_then(|bytes| checked_size(layout, bytes)); + // The physical slice offset does not affect + // the size formula. Raw layouts may place the + // slice beyond the object, and adding its byte + // count to the offset may overflow. + for offset in [0, usize::MAX] { + layout.offset = offset; + assert_eq!(layout.size_for_elems(elems), expected); + } + } + } + } + } + } + } + + #[test] + fn test_padding_for_elems_for_rust_layouts() { + use super::padding_testutil::{check_layouts, check_padding_for_elems, Aligned}; + + for elems in 0..256 { + check_layouts(elems); + } + let size_limit = usize::MAX; + for remaining in 0..16 { + check_layouts(size_limit - remaining); + check_layouts(size_limit / 3 - remaining); + } + check_layouts(usize::MAX); + + for (elems, expected) in [(0, 3), (1, 0), (2, 1), (3, 2)] { + assert_eq!( + check_padding_for_elems::>(elems), + Some(expected) + ); + } + // Every count is valid for zero-sized trailing elements. + assert_eq!(check_padding_for_elems::>(usize::MAX), Some(3)); + } + + #[test] + fn test_padding_for_elems() { + for exponent in 0..POINTER_WIDTH_BITS { + let alignment = NonZeroUsize::new(1usize << exponent).unwrap(); + let mask = alignment.get() - 1; + for phase in [0, alignment.get() / 2, mask] { + // Include zero-sized elements, a negative fixed term, fixed + // padding larger than the alignment, and overflowing sums + // and products. Raw layouts need not describe Rust types. + for (offset, elem_size, size_base) in [ + (0, 0, 0), + (0, 1, 1), + (9, 3, 8), + (3, 3, 0), + (9, 3, 12), + (usize::MAX, usize::MAX, usize::MAX), + (usize::MAX, 0, usize::MAX), + ] { + let layout = TrailingSliceLayout { + offset, + elem_size, + size_base, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(alignment, phase), + }; + for elems in [0, 1, 2, 3, alignment.get(), usize::MAX] { + // Independently reconstruct the complete rounded + // size and slice end, then subtract. Round up by + // adding the mask and clearing the low bits. + let trailing_bytes = elems.wrapping_mul(elem_size); + let unrounded = phase.wrapping_add(trailing_bytes); + let rounded = unrounded.wrapping_add(mask) & !mask; + let object_size = size_base.wrapping_add(rounded); + let trailing_end = offset.wrapping_add(trailing_bytes); + assert_eq!( + layout.padding_for_elems(elems), + object_size.wrapping_sub(trailing_end), + ); + } + } + } + } } #[test] fn test_dst_layout_for_slice() { let layout = DstLayout::for_slice::(); match layout.size_info { - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) => { + SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size, .. }) => { assert_eq!(offset, 0); assert_eq!(elem_size, 4); } @@ -956,17 +2209,18 @@ mod tests { #[allow(clippy::decimal_literal_representation)] #[test] fn test_dst_layout_extend_sized_with_sized() { - // This macro constructs a layout corresponding to a `u8` and extends it - // with a zero-sized trailing field of given alignment `n`. The macro - // tests that the resulting layout has both size and alignment `min(n, - // P)` for all valid values of `repr(packed(P))`. + // This macro constructs a layout corresponding to a `u8` and extends + // it with a zero-sized trailing field of the specified alignment. The + // resulting size and alignment must both be the field's alignment + // capped by the packing limit. Exercise every supported packing limit + // as well as the absence of a packing limit. macro_rules! test_align_is_size { ($n:expr) => { let base = DstLayout::for_type::(); let trailing_field = DstLayout::for_type::>(); - let packs = - core::iter::once(None).chain((0..29).map(|p| NonZeroUsize::new(2usize.pow(p)))); + let packs = core::iter::once(None) + .chain((0..=29).map(|p| NonZeroUsize::new(2usize.pow(p)))); for pack in packs { let composite = base.extend(trailing_field, pack); @@ -1014,6 +2268,7 @@ mod tests { test_align_is_size!(33554432); test_align_is_size!(134217728); test_align_is_size!(268435456); + test_align_is_size!(536870912); } /// Tests of when a sized `DstLayout` is extended with a DST field. @@ -1027,15 +2282,29 @@ mod tests { let aligns = (0..29).map(|p| NonZeroUsize::new(2usize.pow(p)).unwrap()); let packs = core::iter::once(None).chain(aligns.clone().map(Some)); - for align in aligns { + for field_type_align in aligns { for pack in packs.clone() { let base = DstLayout::for_type::(); let elem_size = 42; let trailing_field_offset = 11; + #[allow(clippy::arithmetic_side_effects)] + let trailing_phase = trailing_field_offset % field_type_align; + let trailing_base = util::round_down_to_next_multiple_of_alignment( + trailing_field_offset, + field_type_align, + ); let trailing_field = DstLayout { - align, - size_info: SizeInfo::SliceDst(TrailingSliceLayout { elem_size, offset: 11 }), + align: field_type_align, + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + elem_size, + offset: trailing_field_offset, + size_base: trailing_base, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + field_type_align, + trailing_phase, + ), + }), statically_shallow_unpadded: false, }; @@ -1043,7 +2312,9 @@ mod tests { let max_align = pack.unwrap_or(DstLayout::CURRENT_MAX_ALIGN).get(); - let align = align.get().min(max_align); + let align = field_type_align.get().min(max_align); + let field_offset = align; + let size_base = field_offset + trailing_base; assert_eq!( composite, @@ -1051,7 +2322,12 @@ mod tests { align: NonZeroUsize::new(align).unwrap(), size_info: SizeInfo::SliceDst(TrailingSliceLayout { elem_size, - offset: align + trailing_field_offset, + offset: field_offset + trailing_field_offset, + size_base, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + field_type_align, + trailing_phase, + ), }), statically_shallow_unpadded: false, } @@ -1127,7 +2403,8 @@ mod tests { => padded { size: current_max_align * 2, align: current_max_align }); } - /// Tests that calling `pad_to_align` on a DST `DstLayout` is a no-op. + /// Tests that calling `pad_to_align` on a DST `DstLayout` incorporates the + /// type's trailing-padding operation into its runtime size formula. #[test] fn test_dst_layout_pad_to_align_with_dst() { for align in (0..29).map(|p| NonZeroUsize::new(2usize.pow(p)).unwrap()) { @@ -1135,13 +2412,138 @@ mod tests { for elem_size in 0..10 { let layout = DstLayout { align, - size_info: SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }), + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset, + elem_size, + size_base: offset, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + DstLayout::MIN_ALIGN, + 0, + ), + }), statically_shallow_unpadded: false, }; - assert_eq!(layout.pad_to_align(), layout); + assert_eq!( + layout.pad_to_align(), + DstLayout { + align, + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset, + elem_size, + size_base: offset - offset % align, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + align, + offset % align + ), + }), + statically_shallow_unpadded: false, + } + ); } } } + + // Exercise the branch where the existing inner rounding alignment is + // at least the enclosing alignment. The inner rounded term is already + // aligned to two, so padding only advances `size_base` from three to + // four. + let inner_rounding_dominates = DstLayout { + align: NonZeroUsize::new(2).unwrap(), + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset: 3, + elem_size: 1, + size_base: 3, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(4).unwrap(), + 1, + ), + }), + statically_shallow_unpadded: false, + }; + assert_eq!( + inner_rounding_dominates.pad_to_align(), + DstLayout { + align: NonZeroUsize::new(2).unwrap(), + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset: 3, + elem_size: 1, + size_base: 4, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(4).unwrap(), + 1 + ), + }), + statically_shallow_unpadded: false, + } + ); + + // Exercise the branch where the enclosing alignment is larger. For + // a trailing-slice byte count `trailing_size`, rounding + // `6 + round_up(1 + trailing_size, 4)` to eight gives the equivalent + // formula `8 + round_up(1 + trailing_size, 8)`. + let outer_rounding_dominates = DstLayout { + align: NonZeroUsize::new(8).unwrap(), + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset: 7, + elem_size: 1, + size_base: 6, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(4).unwrap(), + 1, + ), + }), + statically_shallow_unpadded: false, + }; + assert_eq!( + outer_rounding_dominates.pad_to_align(), + DstLayout { + align: NonZeroUsize::new(8).unwrap(), + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset: 7, + elem_size: 1, + size_base: 8, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(8).unwrap(), + 1 + ), + }), + statically_shallow_unpadded: false, + } + ); + } + + #[test] + fn test_trailing_slice_layout_size_sequence_equivalence() { + // These are the two normalized descriptions produced by a + // `repr(C, packed(2))` wrapper around slice DSTs whose element types + // both have size four but alignments four and two respectively. Their + // formulas differ structurally, but both simplify to `2 + 4 * elems`. + let align4 = TrailingSliceLayout { + offset: 2, + elem_size: 4, + size_base: 2, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(4).unwrap(), + 0, + ), + }; + let align2 = TrailingSliceLayout { + offset: 2, + elem_size: 4, + size_base: 2, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(2).unwrap(), + 0, + ), + }; + assert!(align4.has_same_size_sequence(align2)); + for elems in 0..8 { + assert_eq!(align4.size_for_elems(elems), Some(2 + 4 * elems)); + assert_eq!(align4.size_for_elems(elems), align2.size_for_elems(elems)); + } + + let dynamically_padded = TrailingSliceLayout { elem_size: 1, ..align4 }; + assert!(!dynamically_padded.has_same_size_sequence(align2)); } // This test takes a long time when running under Miri, so we skip it in @@ -1160,16 +2562,32 @@ mod tests { #[allow(non_local_definitions)] impl From<(usize, usize)> for SizeInfo { fn from((offset, elem_size): (usize, usize)) -> SizeInfo { - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) + SizeInfo::SliceDst(TrailingSliceLayout { + offset, + elem_size, + size_base: offset, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + DstLayout::MIN_ALIGN, + 0, + ), + }) } } fn layout>(s: S, align: usize) -> DstLayout { - DstLayout { - size_info: s.into(), - align: NonZeroUsize::new(align).unwrap(), - statically_shallow_unpadded: false, - } + let align = NonZeroUsize::new(align).unwrap(); + let size_info = match s.into() { + SizeInfo::SliceDst(mut trailing) => { + let phase = trailing.size_base % align; + trailing.size_base = + util::round_down_to_next_multiple_of_alignment(trailing.size_base, align); + trailing.size_rounding_align_and_phase = + RoundingAlignAndPhase::new(align, phase); + SizeInfo::SliceDst(trailing) + } + size_info => size_info, + }; + DstLayout { size_info, align, statically_shallow_unpadded: false } } /// This macro accepts arguments in the form of: @@ -1366,10 +2784,18 @@ mod tests { let resulting_size = match layout.size_info { SizeInfo::Sized { size } => size, - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) => { + SizeInfo::SliceDst(TrailingSliceLayout { + elem_size, + size_base, + size_rounding_align_and_phase, + .. + }) => { + let (size_align, size_phase) = size_rounding_align_and_phase.components(); let padded_size = |elems| { - let without_padding = offset + elems * elem_size; - without_padding + util::padding_needed_for(without_padding, align) + let without_padding = size_phase + elems * elem_size; + size_base + + without_padding + + util::padding_needed_for(without_padding, size_align) }; let resulting_size = padded_size(elems); @@ -1397,8 +2823,13 @@ mod tests { } else { let min_size = match layout.size_info { SizeInfo::Sized { size } => size, - SizeInfo::SliceDst(TrailingSliceLayout { offset, .. }) => { - offset + util::padding_needed_for(offset, layout.align) + SizeInfo::SliceDst(TrailingSliceLayout { + size_base, + size_rounding_align_and_phase, + .. + }) => { + let (size_align, size_phase) = size_rounding_align_and_phase.components(); + size_base + size_phase + util::padding_needed_for(size_phase, size_align) } }; @@ -1427,6 +2858,35 @@ mod tests { .map(|(size_info, align)| layout(size_info, align)); itertools::iproduct!(layouts, 0..8, 0..8, [CastType::Prefix, CastType::Suffix]) .for_each(validate_behavior); + + // Exercise a normalized formula which cannot be represented as + // `round_up(offset + elems * elem_size, align)`: the physical trailing + // slice begins at offset 7, while an inner aligned DST contributes the + // size formula `2 + round_up(5 + elems, 4)` to a packed outer type. + let nested_packed = DstLayout { + align: NonZeroUsize::new(2).unwrap(), + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset: 7, + elem_size: 1, + size_base: 6, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(4).unwrap(), + 1, + ), + }), + statically_shallow_unpadded: false, + }; + itertools::iproduct!([nested_packed], 0..8, 0..24, [CastType::Prefix, CastType::Suffix]) + .for_each(validate_behavior); + + assert!(matches!( + nested_packed.validate_cast_and_convert_metadata(0, 8, CastType::Prefix), + Err(MetadataCastError::Size) + )); + assert!(matches!( + nested_packed.validate_cast_and_convert_metadata(0, 10, CastType::Prefix), + Ok((3, 10)) + )); } #[test] @@ -1466,9 +2926,18 @@ mod tests { let dst = args.elem_size.is_some(); let layout = { let size_info = match args.elem_size { - Some(elem_size) => { - SizeInfo::SliceDst(TrailingSliceLayout { offset: args.offset, elem_size }) - } + Some(elem_size) => SizeInfo::SliceDst(TrailingSliceLayout { + offset: args.offset, + elem_size, + size_base: util::round_down_to_next_multiple_of_alignment( + args.offset, + args.align, + ), + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + args.align, + args.offset % args.align, + ), + }), None => SizeInfo::Sized { // Rust only supports types whose sizes are a multiple // of their alignment. If the macro created a type like @@ -1786,67 +3255,350 @@ mod tests { #[cfg(kani)] mod proofs { + //! Arithmetic models and input generators for the layout proofs. + //! + //! The contract models specify expected results without calling the + //! operation being checked. The composition models (`extended_layout`, + //! `padded_layout`, and `repr_c_layout`) also define the contracts' input + //! domains: `Some` supplies an expected layout, while `None` excludes an + //! input from the corresponding non-panicking contract. These models check + //! representability in `usize`, without imposing Rust's `isize::MAX` bound. + use core::alloc::Layout; use super::*; - impl kani::Arbitrary for DstLayout { - fn any() -> Self { - let align: NonZeroUsize = kani::any(); - let size_info: SizeInfo = kani::any(); - - kani::assume(align.is_power_of_two()); - kani::assume(align < DstLayout::THEORETICAL_MAX_ALIGN); - - // For testing purposes, we most care about instantiations of - // `DstLayout` that can correspond to actual Rust types. We use - // `Layout` to verify that our `DstLayout` satisfies the validity - // conditions of Rust layouts. - kani::assume( - match size_info { - SizeInfo::Sized { size } => Layout::from_size_align(size, align.get()), - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size: _ }) => { - // `SliceDst` cannot encode an exact size, but we know - // it is at least `offset` bytes. - Layout::from_size_align(offset, align.get()) - } - } - .is_ok(), - ); + /// Evaluates the normalized object size for a trailing slice of + /// `trailing_size` bytes: + /// + /// ```text + /// size_base + round_up(size_phase + trailing_size, size_align) + /// ``` + /// + /// Here, `size_align` and `size_phase` come from `layout.size_rounding_align_and_phase`. + /// `round_up` rounds its input up to a multiple of its alignment. Returns + /// `None` if the size is not representable in `usize`. + /// + /// Using byte counts separates rounding from element-count multiplication + /// and permits proofs over byte counts that are not whole elements. + pub(super) fn slice_dst_size_for_trailing_bytes( + layout: TrailingSliceLayout, + trailing_size: usize, + ) -> Option { + let without_padding = + layout.size_rounding_align_and_phase.components().1.checked_add(trailing_size)?; + let padding = + util::padding_needed_for(without_padding, layout.size_rounding_align_and_phase.align()); + let rounded = without_padding.checked_add(padding)?; + layout.size_base.checked_add(rounded) + } + + /// Returns the least multiple of `align` greater than or equal to `bytes`, + /// or `None` if that multiple exceeds `usize::MAX`. + /// + /// Requires a power-of-two `align`. Composition models use this checked + /// operation to identify inputs for which adding padding would overflow. + fn round_up(bytes: usize, align: NonZeroUsize) -> Option { + bytes.checked_add(util::padding_needed_for(bytes, align)) + } + + /// Models the largest trailing-slice byte count whose object size fits in + /// `available` bytes. Returns `None` if even an empty trailing slice cannot + /// fit. The result need not be a multiple of the element size. + /// + /// Subtracting `size_base` leaves the budget for the rounded contribution. + /// Rounding that budget down to `size_align` and subtracting `size_phase` + /// gives the largest byte count whose `slice_dst_size_for_trailing_bytes` + /// result is at most `available`. Metadata and casting contracts use + /// this bound without calling the production capacity calculation. + pub(super) fn max_trailing_bytes( + trailing: TrailingSliceLayout, + available: usize, + ) -> Option { + let (align, phase) = trailing.size_rounding_align_and_phase.components(); + let capacity = available.checked_sub(trailing.size_base)?; + util::round_down_to_next_multiple_of_alignment(capacity, align).checked_sub(phase) + } - Self { align: align, size_info: size_info, statically_shallow_unpadded: kani::any() } + /// Models appending `field` to a sized `prefix` for `DstLayout::extend`. + /// + /// Places the field at the first offset after the prefix that satisfies the + /// field's alignment, capped by `packed` when present. The resulting + /// alignment is the maximum of the prefix's alignment and this effective + /// field alignment. A DST field retains its inner rounding operation, with + /// its slice offset and size base shifted by the field's placement. + /// Trailing padding for the enclosing layout is left to `padded_layout`. + /// + /// Returns `None` for a non-power-of-two alignment, an unsized prefix, or + /// arithmetic overflow. The `statically_shallow_unpadded` flag is true only + /// if both inputs have it set and field placement introduces no gap. + pub(super) fn extended_layout( + prefix: DstLayout, + field: DstLayout, + packed: Option, + ) -> Option { + if !prefix.align.is_power_of_two() + || !field.align.is_power_of_two() + || packed.map_or(false, |align| !align.is_power_of_two()) + { + return None; } + let SizeInfo::Sized { size: prefix_size } = prefix.size_info else { return None }; + let field_align = field.align.min(packed.unwrap_or(DstLayout::THEORETICAL_MAX_ALIGN)); + let field_offset = round_up(prefix_size, field_align)?; + let size_info = match field.size_info { + SizeInfo::Sized { size } => SizeInfo::Sized { size: field_offset.checked_add(size)? }, + SizeInfo::SliceDst(trailing) => SizeInfo::SliceDst(TrailingSliceLayout { + offset: field_offset.checked_add(trailing.offset)?, + size_base: field_offset.checked_add(trailing.size_base)?, + ..trailing + }), + }; + Some(DstLayout { + align: prefix.align.max(field_align), + size_info, + statically_shallow_unpadded: prefix.statically_shallow_unpadded + && field.statically_shallow_unpadded + && field_offset == prefix_size, + }) } - impl kani::Arbitrary for SizeInfo { - fn any() -> Self { - let is_sized: bool = kani::any(); + /// Models adding trailing padding for `DstLayout::pad_to_align`. + /// + /// A sized layout rounds its size up to a multiple of `layout.align`. A DST + /// instead composes that rounding with its existing size expression: + /// + /// ```text + /// round_up(size_base + round_up(size_phase + trailing_size, size_align), + /// layout.align) + /// ``` + /// + /// Here, `size_align` and `size_phase` come from the DST's + /// `size_rounding_align_and_phase`. The result encodes this expression with + /// one base, phase, and rounding alignment, preserving the physical slice + /// offset and element size. The `statically_shallow_unpadded` flag is + /// cleared only when a sized layout gains padding; DST padding is accounted + /// for by the size expression. + /// + /// Returns `None` if `layout.align` is not a power of two or a checked + /// intermediate overflows. The contract compares this expected encoding + /// with the production result; the separate padding formula proofs check + /// that the encoding preserves the rounded size. + pub(super) fn padded_layout(layout: DstLayout) -> Option { + if !layout.align.is_power_of_two() { + return None; + } + let (size_info, no_static_padding) = match layout.size_info { + SizeInfo::Sized { size } => { + let rounded = round_up(size, layout.align)?; + (SizeInfo::Sized { size: rounded }, rounded == size) + } + SizeInfo::SliceDst(trailing) => { + let (inner_align, phase) = trailing.size_rounding_align_and_phase.components(); + let (size_base, size_rounding_align_and_phase) = if inner_align >= layout.align { + ( + round_up(trailing.size_base, layout.align)?, + trailing.size_rounding_align_and_phase, + ) + } else { + let fixed = round_up(trailing.size_base, inner_align)?.checked_add(phase)?; + let new_phase = fixed & (layout.align.get() - 1); + (fixed - new_phase, RoundingAlignAndPhase::new(layout.align, new_phase)) + }; + ( + SizeInfo::SliceDst(TrailingSliceLayout { + size_base, + size_rounding_align_and_phase, + ..trailing + }), + true, + ) + } + }; + Some(DstLayout { + size_info, + statically_shallow_unpadded: layout.statically_shallow_unpadded && no_static_padding, + ..layout + }) + } - match is_sized { - true => { - let size: usize = kani::any(); + /// Models a complete layout for `DstLayout::for_repr_c_struct` by composing + /// the field-placement and trailing-padding specifications. + /// + /// Starts with an empty prefix of alignment `align`, or one if absent. + /// Appends fields in order, with `packed` as an optional cap on field + /// alignment; the final layout is padded to its resulting alignment. Only + /// the last field may be unsized, since field extension requires a sized + /// prefix. + /// + /// Returns `None` if the initial alignment is not a power of two or any + /// field extension or final padding step returns `None`. + pub(super) fn repr_c_layout( + align: Option, + packed: Option, + fields: &[DstLayout], + ) -> Option { + let align = align.unwrap_or(DstLayout::MIN_ALIGN); + if !align.is_power_of_two() { + return None; + } + let mut layout = DstLayout { + align, + size_info: SizeInfo::Sized { size: 0 }, + statically_shallow_unpadded: true, + }; + for &field in fields { + layout = extended_layout(layout, field, packed)?; + } + padded_layout(layout) + } - kani::assume(size <= DstLayout::MAX_SIZE); + // The inline contract harnesses use unrestricted `Arbitrary` values. + // These generators retain the narrower domains of the Rust-layout proofs. + + /// Generates any power-of-two alignment representable in `usize`, including + /// `DstLayout::THEORETICAL_MAX_ALIGN`. This restricts the alignment alone; + /// callers must impose any constraints relating it to a layout's size. + fn any_layout_align() -> NonZeroUsize { + let exponent: u8 = kani::any(); + kani::assume(usize::from(exponent) < POINTER_WIDTH_BITS); + + // `exponent < POINTER_WIDTH_BITS`, so the shift is in range and its + // result is non-zero. This construction is surjective over every + // power-of-two alignment up to and including + // `THEORETICAL_MAX_ALIGN`. `Layout` admits that maximum alignment for + // zero-sized layouts, so the validity assumptions below—not this + // generator—filter combinations that cannot describe Rust types. + NonZeroUsize::new(1usize << exponent).unwrap() + } - SizeInfo::Sized { size } + /// Generates layouts satisfying the arithmetic restrictions used by the + /// standalone Rust-layout proofs. + /// + /// The fixed size, or the zero-element DST size, must be accepted by + /// `Layout::from_size_align`. For a DST, `size_base + size_phase` must also + /// be representable and at least the physical slice offset. Component + /// bounds come from `any_bounded_size_info`; `statically_shallow_unpadded` + /// remains arbitrary. These conditions do not establish that a Rust type + /// with this layout exists. + fn any_valid_dst_layout() -> DstLayout { + let align = any_layout_align(); + let size_info = any_bounded_size_info(); + + kani::assume( + match size_info { + SizeInfo::Sized { size } => Layout::from_size_align(size, align.get()), + SizeInfo::SliceDst(trailing) => { + // `SliceDst` cannot encode one exact size. Validate its + // minimum size and the invariants required of the size + // formula for layouts of real Rust types. + let min_size = trailing.size_for_elems(0); + let unrounded_min_size = trailing + .size_base + .checked_add(trailing.size_rounding_align_and_phase.components().1); + + kani::assume(matches!( + unrounded_min_size, + Some(size) if size >= trailing.offset + )); + + match min_size { + Some(min_size) => Layout::from_size_align(min_size, align.get()), + None => Layout::from_size_align(usize::MAX, align.get()), + } } - false => SizeInfo::SliceDst(kani::any()), } + .is_ok(), + ); + + DstLayout { align: align, size_info: size_info, statically_shallow_unpadded: kani::any() } + } + + /// Generates either a fixed size at most `DstLayout::MAX_SIZE`, or a DST + /// description from `any_bounded_trailing_layout`. + /// + /// Supplies bounded size components to `any_valid_dst_layout`, which adds + /// constraints involving the enclosing layout's alignment. + fn any_bounded_size_info() -> SizeInfo { + let is_sized: bool = kani::any(); + + match is_sized { + true => { + let size: usize = kani::any(); + + kani::assume(size <= DstLayout::MAX_SIZE); + + SizeInfo::Sized { size } + } + false => SizeInfo::SliceDst(any_bounded_trailing_layout()), } } - impl kani::Arbitrary for TrailingSliceLayout { - fn any() -> Self { - let elem_size: usize = kani::any(); - let offset: usize = kani::any(); + /// Generates a trailing-slice description whose element size, slice offset, + /// and size base are each at most `DstLayout::MAX_SIZE` (`isize::MAX`). + /// + /// The rounding alignment spans all representable powers of two, and the + /// phase spans all values below that alignment. No further relationships + /// between these components are imposed: their combined size may overflow + /// or fail to contain the slice. Standalone proofs add the relationships + /// they require; contract harnesses use unrestricted `Arbitrary` values + /// instead. + fn any_bounded_trailing_layout() -> TrailingSliceLayout { + let elem_size: usize = kani::any(); + let offset: usize = kani::any(); + let size_base: usize = kani::any(); + let size_align = any_layout_align(); + let raw_size_phase: usize = kani::any(); + // Since `size_align` is a power of two, masking is surjective over + // precisely the values in `0..size_align`. + #[allow(clippy::arithmetic_side_effects)] + let size_phase = raw_size_phase & (size_align.get() - 1); - kani::assume(elem_size < DstLayout::MAX_SIZE); - kani::assume(offset < DstLayout::MAX_SIZE); + kani::assume(elem_size <= DstLayout::MAX_SIZE); + kani::assume(offset <= DstLayout::MAX_SIZE); + kani::assume(size_base <= DstLayout::MAX_SIZE); - TrailingSliceLayout { elem_size, offset } + TrailingSliceLayout { + elem_size, + offset, + size_base, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(size_align, size_phase), } } + #[kani::proof] + fn prove_padding_for_elems_for_rust_layouts() { + // For each fixture, check every element count whose complete size + // fits in `usize`, including all counts for zero-sized elements. + padding_testutil::check_layouts(kani::any()); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_padding_for_elems() { + // Check the modular result for every field value and element count, + // including layouts that cannot describe a Rust type. + let layout = TrailingSliceLayout { + offset: kani::any(), + elem_size: kani::any(), + size_base: kani::any(), + size_rounding_align_and_phase: RoundingAlignAndPhase(kani::any()), + }; + let elems: usize = kani::any(); + let (size_align, size_phase) = layout.size_rounding_align_and_phase.components(); + let mask = size_align.get() - 1; + + // Evaluate the complete size and trailing-slice end separately in + // modular arithmetic, without reducing the trailing bytes first or + // canceling them from the difference. + let trailing_bytes = elems.wrapping_mul(layout.elem_size); + let unrounded = size_phase.wrapping_add(trailing_bytes); + let rounded = unrounded.wrapping_add(mask) & !mask; + let object_size = layout.size_base.wrapping_add(rounded); + let trailing_end = layout.offset.wrapping_add(trailing_bytes); + assert_eq!(layout.padding_for_elems(elems), object_size.wrapping_sub(trailing_end)); + } + #[kani::proof] fn prove_size_rounding_align_and_phase_encoding() { let encoded: usize = kani::any(); @@ -1872,9 +3624,381 @@ mod proofs { assert_eq!(rounding.components(), (align, phase)); } + #[kani::proof] + fn prove_trailing_slice_layout_normal_form() { + let rounding_word: usize = kani::any(); + kani::assume(rounding_word != 0); + let align = RoundingAlignAndPhase(NonZeroUsize::new(rounding_word).unwrap()).align(); + + let raw_prefix: usize = kani::any(); + let offset: usize = kani::any(); + let trailing_size: usize = kani::any(); + #[allow(clippy::arithmetic_side_effects)] + let prefix = raw_prefix & (align.get() - 1); + #[allow(clippy::arithmetic_side_effects)] + let phase = offset & (align.get() - 1); + #[allow(clippy::arithmetic_side_effects)] + let aligned_offset = offset - phase; + let layout = TrailingSliceLayout { + offset: 0, + elem_size: 1, + size_base: aligned_offset | prefix, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(align, phase), + }; + + assert_eq!(layout.size_offset(), offset); + + let old_size = offset + .checked_add(trailing_size) + .and_then(|without_padding| { + without_padding.checked_add(util::padding_needed_for(without_padding, align)) + }) + .and_then(|rounded| prefix.checked_add(rounded)); + assert_eq!(layout.size_for_elems(trailing_size), old_size); + assert_eq!(slice_dst_size_for_trailing_bytes(layout, trailing_size), old_size); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_size_for_elems_uses_element_product() { + let layout: TrailingSliceLayout = any_bounded_trailing_layout(); + let elems: usize = kani::any(); + let expected = layout + .elem_size + .checked_mul(elems) + .and_then(|bytes| slice_dst_size_for_trailing_bytes(layout, bytes)); + assert_eq!(layout.size_for_elems(elems), expected); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_element_product_preserves_alignment() { + let elem_size: usize = kani::any(); + let elems: usize = kani::any(); + let align = any_layout_align(); + #[allow(clippy::arithmetic_side_effects)] + let align_mask = align.get() - 1; + + kani::assume(elem_size & align_mask == 0); + let Some(bytes) = elem_size.checked_mul(elems) else { + kani::assume(false); + loop {} + }; + assert_eq!(bytes & align_mask, 0); + } + + #[kani::proof] + fn prove_trailing_slice_layout_advance() { + let layout: TrailingSliceLayout = any_bounded_trailing_layout(); + let bytes: usize = kani::any(); + let elem_size: usize = kani::any(); + let trailing_size: usize = kani::any(); + + let size_offset = layout.size_offset(); + let advanced_offset = size_offset.checked_add(bytes); + let advanced = layout.advance(bytes, elem_size); + assert_eq!(advanced.is_some(), advanced_offset.is_some()); + let Some(advanced_offset) = advanced_offset else { + kani::assume(false); + loop {} + }; + let Some(advanced) = advanced else { unreachable!() }; + + assert_eq!(advanced.offset, layout.offset); + assert_eq!(advanced.size_offset(), advanced_offset); + assert_eq!(advanced.elem_size, elem_size); + assert_eq!( + advanced.size_rounding_align_and_phase.align(), + layout.size_rounding_align_and_phase.align() + ); + #[allow(clippy::arithmetic_side_effects)] + let size_align_mask = advanced.size_rounding_align_and_phase.align().get() - 1; + assert_eq!(advanced.size_base & size_align_mask, layout.size_base & size_align_mask); + + let size_align = layout.size_rounding_align_and_phase.align(); + #[allow(clippy::arithmetic_side_effects)] + let size_prefix = layout.size_base & (size_align.get() - 1); + let expected_size = advanced_offset + .checked_add(trailing_size) + .and_then(|without_padding| { + without_padding.checked_add(util::padding_needed_for(without_padding, size_align)) + }) + .and_then(|rounded| size_prefix.checked_add(rounded)); + assert_eq!(slice_dst_size_for_trailing_bytes(advanced, trailing_size), expected_size); + } + + #[kani::proof] + fn prove_has_same_size_sequence() { + let left: TrailingSliceLayout = any_bounded_trailing_layout(); + let right: TrailingSliceLayout = any_bounded_trailing_layout(); + let trailing_size: usize = kani::any(); + + kani::assume(left.has_same_size_sequence(right)); + let left_align = left.size_rounding_align_and_phase.align().get(); + let right_align = right.size_rounding_align_and_phase.align().get(); + let max_align = if left_align > right_align { left_align } else { right_align }; + #[allow(clippy::arithmetic_side_effects)] + let max_align_mask = max_align - 1; + if left.elem_size & max_align_mask == 0 { + // In this branch `has_same_size_sequence` relies on every actual + // trailing byte count being a multiple of both alignments. Prove + // the formula for that entire byte class, which includes every + // representable `elems * elem_size`, without introducing a + // nonlinear symbolic multiplication into the harness. + kani::assume(trailing_size & max_align_mask == 0); + } + assert_eq!( + slice_dst_size_for_trailing_bytes(left, trailing_size), + slice_dst_size_for_trailing_bytes(right, trailing_size) + ); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_max_trailing_bytes() { + let trailing: TrailingSliceLayout = any_bounded_trailing_layout(); + let available_bytes: usize = kani::any(); + let trailing_bytes: usize = kani::any(); + let fits = slice_dst_size_for_trailing_bytes(trailing, trailing_bytes) + .map(|size| size <= available_bytes) + .unwrap_or(false); + match trailing.max_trailing_bytes(available_bytes) { + Some(max_bytes) => assert_eq!(trailing_bytes <= max_bytes, fits), + None => assert!(!fits), + } + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_max_trailing_bytes_components() { + let trailing: TrailingSliceLayout = any_bounded_trailing_layout(); + let bytes_len: usize = kani::any(); + let Some(max_slice_bytes) = trailing.max_trailing_bytes(bytes_len) else { + kani::assume(false); + loop {} + }; + let (size_align, size_phase) = trailing.size_rounding_align_and_phase.components(); + let Some(bytes_after_base) = bytes_len.checked_sub(trailing.size_base) else { + unreachable!() + }; + let max_rounded_bytes = + util::round_down_to_next_multiple_of_alignment(bytes_after_base, size_align); + assert_eq!(size_phase.checked_add(max_slice_bytes), Some(max_rounded_bytes)); + assert!(trailing.size_base.checked_add(max_rounded_bytes).unwrap() <= bytes_len); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_slice_dst_validator_selected_size_fits() { + let size_align = any_layout_align(); + #[allow(clippy::arithmetic_side_effects)] + let align_mask = size_align.get() - 1; + let raw_phase: usize = kani::any(); + let size_phase = raw_phase & align_mask; + let raw_capacity: usize = kani::any(); + let max_rounded_bytes = raw_capacity & !align_mask; + let size_base: usize = kani::any(); + let bytes_len: usize = kani::any(); + let trailing_size: usize = kani::any(); + + // These are exactly the relations established for every successful + // capacity query by `prove_max_trailing_bytes_components`. Isolate + // them from the helper's implementation to keep this arithmetic + // identity independent of how that helper computes its bound. + let Some(max_slice_bytes) = max_rounded_bytes.checked_sub(size_phase) else { + kani::assume(false); + loop {} + }; + let Some(max_object_size) = size_base.checked_add(max_rounded_bytes) else { + kani::assume(false); + loop {} + }; + kani::assume(max_object_size <= bytes_len); + kani::assume(trailing_size <= max_slice_bytes); + + // For a whole-element count selected by the validator, this unused + // byte count is also `max_slice_bytes % elem_size`. The identity here + // holds for every byte count satisfying the capacity bound. + #[allow(clippy::arithmetic_side_effects)] + let unused_bytes = max_slice_bytes - trailing_size; + let unused_aligned_bytes = + util::round_down_to_next_multiple_of_alignment(unused_bytes, size_align); + #[allow(clippy::arithmetic_side_effects)] + let object_size = size_base + (max_rounded_bytes - unused_aligned_bytes); + + let unrounded = size_phase.checked_add(trailing_size).unwrap(); + let rounded = + unrounded.checked_add(util::padding_needed_for(unrounded, size_align)).unwrap(); + assert_eq!(Some(object_size), size_base.checked_add(rounded)); + assert!(object_size <= bytes_len); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_slice_dst_validator_rejects_larger_trailing_size() { + let trailing: TrailingSliceLayout = any_bounded_trailing_layout(); + let bytes_len: usize = kani::any(); + let larger_trailing_size: usize = kani::any(); + let Some(max_slice_bytes) = trailing.max_trailing_bytes(bytes_len) else { + kani::assume(false); + loop {} + }; + kani::assume(larger_trailing_size > max_slice_bytes); + if let Some(object_size) = slice_dst_size_for_trailing_bytes(trailing, larger_trailing_size) + { + assert!(object_size > bytes_len); + } + } + + #[kani::proof] + fn prove_slice_formula_contains_trailing_slice() { + let trailing: TrailingSliceLayout = any_bounded_trailing_layout(); + let trailing_size: usize = kani::any(); + let (_, size_phase) = trailing.size_rounding_align_and_phase.components(); + + let Some(unrounded_min) = trailing.size_base.checked_add(size_phase) else { + kani::assume(false); + loop {} + }; + kani::assume(unrounded_min >= trailing.offset); + let Some(object_size) = slice_dst_size_for_trailing_bytes(trailing, trailing_size) else { + kani::assume(false); + loop {} + }; + + let Some(trailing_end) = trailing.offset.checked_add(trailing_size) else { unreachable!() }; + assert!(trailing_end <= object_size); + } + + #[kani::proof] + fn prove_finalized_slice_formula_is_aligned() { + let trailing: TrailingSliceLayout = any_bounded_trailing_layout(); + let outer_align = any_layout_align(); + let trailing_size: usize = kani::any(); + kani::assume(trailing.size_rounding_align_and_phase.align() >= outer_align); + #[allow(clippy::arithmetic_side_effects)] + let outer_align_mask = outer_align.get() - 1; + kani::assume(trailing.size_base & outer_align_mask == 0); + let Some(object_size) = slice_dst_size_for_trailing_bytes(trailing, trailing_size) else { + kani::assume(false); + loop {} + }; + + assert_eq!(object_size & outer_align_mask, 0); + } + + #[kani::proof] + fn prove_validator_checks_selected_endpoint_alignment() { + let align = any_layout_align(); + let addr: usize = kani::any(); + let bytes_len: usize = kani::any(); + let Some(end) = addr.checked_add(bytes_len) else { + kani::assume(false); + loop {} + }; + let suffix: bool = kani::any(); + let cast_type = if suffix { CastType::Suffix } else { CastType::Prefix }; + let selected_endpoint = if suffix { end } else { addr }; + #[allow(clippy::arithmetic_side_effects)] + let align_mask = align.get() - 1; + + // The endpoint check precedes and is independent of the size-info + // branch. A zero-sized layout makes every aligned case succeed and + // isolates that control flow, including prefix/suffix split selection. + let layout = DstLayout { + align, + size_info: SizeInfo::Sized { size: 0 }, + statically_shallow_unpadded: false, + }; + match layout.validate_cast_and_convert_metadata(addr, bytes_len, cast_type) { + Ok((elems, split_at)) => { + assert_eq!(selected_endpoint & align_mask, 0); + assert_eq!(elems, 0); + if suffix { + assert_eq!(split_at, bytes_len); + } else { + assert_eq!(split_at, 0); + } + } + Err(MetadataCastError::Alignment) => { + assert_ne!(selected_endpoint & align_mask, 0); + } + Err(MetadataCastError::Size) => unreachable!(), + } + } + + #[kani::proof] + fn prove_aligned_suffix_end_and_size_imply_aligned_start() { + let align = any_layout_align(); + #[allow(clippy::arithmetic_side_effects)] + let align_mask = align.get() - 1; + let addr: usize = kani::any(); + let bytes_len: usize = kani::any(); + let object_size: usize = kani::any(); + let Some(end) = addr.checked_add(bytes_len) else { + kani::assume(false); + loop {} + }; + kani::assume(end & align_mask == 0); + kani::assume(object_size & align_mask == 0); + kani::assume(object_size <= bytes_len); + + #[allow(clippy::arithmetic_side_effects)] + let split_at = bytes_len - object_size; + let Some(object_start) = addr.checked_add(split_at) else { unreachable!() }; + assert_eq!(object_start & align_mask, 0); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_size_formula_bounds_size_offset() { + let trailing: TrailingSliceLayout = any_bounded_trailing_layout(); + let elems: usize = kani::any(); + let Some(object_size) = trailing.size_for_elems(elems) else { + kani::assume(false); + loop {} + }; + let Some(trailing_size) = trailing.elem_size.checked_mul(elems) else { unreachable!() }; + let size_offset = trailing.size_offset(); + let Some(without_padding) = size_offset.checked_add(trailing_size) else { unreachable!() }; + + assert!(without_padding <= object_size); + } + + // Older supported compilers resolve the unchecked operation syntax in + // `add_scaled_metadata` to `polyfills::NumExt`; newer compilers prefer the + // inherent methods. Use UFCS to verify the fallback implementation which + // Kani's modern rustc cannot select in the production helper. + #[kani::proof] + #[kani::solver(kissat)] + fn prove_num_ext_affine_fallback_matches_checked_arithmetic() { + let base: usize = kani::any(); + let metadata: usize = kani::any(); + let multiple: usize = kani::any(); + let Some(scaled) = metadata.checked_mul(multiple) else { + kani::assume(false); + loop {} + }; + let Some(expected) = base.checked_add(scaled) else { + kani::assume(false); + loop {} + }; + + // SAFETY: The checked multiplication returned `Some`, satisfying + // `NumExt::unchecked_mul`'s no-overflow contract. + let scaled = + unsafe { ::unchecked_mul(metadata, multiple) }; + // SAFETY: The checked addition returned `Some`, and `scaled` is the + // checked product, satisfying `NumExt::unchecked_add`'s no-overflow + // contract. + let actual = + unsafe { ::unchecked_add(base, scaled) }; + assert_eq!(actual, expected); + } + #[kani::proof] fn prove_requires_dynamic_padding() { - let layout: DstLayout = kani::any(); + let layout: DstLayout = any_valid_dst_layout(); let SizeInfo::SliceDst(size_info) = layout.size_info else { kani::assume(false); @@ -1889,22 +4013,25 @@ mod proofs { loop {} }; - let Some(unpadded_size) = size_info.offset.checked_add(trailing_slice_size) else { - // The `unpadded_size` exceeds `usize::MAX`; `meta`` is invalid. + let Some(trailing_slice_end) = size_info.offset.checked_add(trailing_slice_size) else { + // The trailing slice end exceeds `usize::MAX`; `meta` is invalid. kani::assume(false); loop {} }; - if unpadded_size >= DstLayout::MAX_SIZE { - // The `unpadded_size` exceeds `isize::MAX`; `meta` is invalid. + let Some(size) = slice_dst_size_for_trailing_bytes(size_info, trailing_slice_size) else { kani::assume(false); loop {} - } + }; - let trailing_padding = util::padding_needed_for(unpadded_size, layout.align); + if size > DstLayout::MAX_SIZE || trailing_slice_end > size { + // This metadata cannot describe a valid instance. + kani::assume(false); + loop {} + } if !layout.requires_dynamic_padding() { - assert!(trailing_padding == 0); + assert_eq!(trailing_slice_end, size); } } @@ -1912,8 +4039,8 @@ mod proofs { fn prove_dst_layout_extend() { use crate::util::{max, min, padding_needed_for}; - let base: DstLayout = kani::any(); - let field: DstLayout = kani::any(); + let base: DstLayout = any_valid_dst_layout(); + let field: DstLayout = any_valid_dst_layout(); let packed: Option = kani::any(); if let Some(max_align) = packed { @@ -1929,9 +4056,6 @@ mod proofs { unreachable!(); }; - // Under the above conditions, `DstLayout::extend` will not panic. - let composite = base.extend(field, packed); - // The field's alignment is clamped by `max_align` (i.e., the // `packed` attribute, if any) [1]. // @@ -1942,10 +4066,6 @@ mod proofs { // alignment of the field's type. let field_align = min(field.align, packed.unwrap_or(DstLayout::THEORETICAL_MAX_ALIGN)); - // The struct's alignment is the maximum of its previous alignment and - // `field_align`. - assert_eq!(composite.align, max(base.align, field_align)); - // Compute the minimum amount of inter-field padding needed to // satisfy the field's alignment, and offset of the trailing field. // [1] @@ -1955,7 +4075,38 @@ mod proofs { // Inter-field padding is guaranteed to be the minimum required in // order to satisfy each field's (possibly altered) alignment. let padding = padding_needed_for(base_size, field_align); - let offset = base_size + padding; + let offset = match base_size.checked_add(padding) { + Some(offset) => offset, + None => { + kani::assume(false); + loop {} + } + }; + + // Restrict the proof to extensions whose zero-metadata composite size + // is representable. This semantic condition implies the + // representability of each implementation intermediate below; prove + // those implications rather than assuming the intermediates. + match field.size_info { + SizeInfo::Sized { size } => kani::assume(offset.checked_add(size).is_some()), + SizeInfo::SliceDst(trailing) => { + let Some(field_min) = trailing.size_for_elems(0) else { + kani::assume(false); + loop {} + }; + kani::assume(offset.checked_add(field_min).is_some()); + + assert!(offset.checked_add(trailing.offset).is_some()); + assert!(offset.checked_add(trailing.size_base).is_some()); + } + } + + // Under the above conditions, `DstLayout::extend` will not panic. + let composite = base.extend(field, packed); + + // The struct's alignment is the maximum of its previous alignment and + // `field_align`. + assert_eq!(composite.align, max(base.align, field_align)); // For testing purposes, we'll also construct `alloc::Layout` // stand-ins for `DstLayout`, and show that `extend` behaves @@ -1992,29 +4143,64 @@ mod proofs { panic!("The composite of two sized layouts must be sized.") } } - SizeInfo::SliceDst(TrailingSliceLayout { - offset: field_offset, - elem_size: field_elem_size, - }) => { - if let SizeInfo::SliceDst(TrailingSliceLayout { - offset: composite_offset, - elem_size: composite_elem_size, - }) = composite.size_info - { + SizeInfo::SliceDst(field_trailing) => { + if let SizeInfo::SliceDst(composite_trailing) = composite.size_info { // The offset of the trailing slice component is the sum // of the offset of the trailing field and the trailing // slice offset within that field. - assert_eq!(composite_offset, offset + field_offset); + assert_eq!(composite_trailing.offset, offset + field_trailing.offset); // The elem size is unchanged. - assert_eq!(composite_elem_size, field_elem_size); + assert_eq!(composite_trailing.elem_size, field_trailing.elem_size); + // Extension preserves the invariant that the physical + // trailing-slice field fits within the unrounded minimum + // size. This is one of the premises consumed by the + // validator soundness proof. + let composite_phase = + composite_trailing.size_rounding_align_and_phase.components().1; + let Some(composite_unrounded_min) = + composite_trailing.size_base.checked_add(composite_phase) + else { + unreachable!() + }; + assert!(composite_unrounded_min >= composite_trailing.offset); + + // The normalized formula must continue to describe the + // size of the field at every trailing byte contribution + // for which the checked size computation succeeds. This + // is stronger than considering only products of a slice + // length and `elem_size`, and avoids a nonlinear + // multiplication in the verifier. + let trailing_size: usize = kani::any(); + let field_size = + match slice_dst_size_for_trailing_bytes(field_trailing, trailing_size) { + Some(size) => size, + None => { + kani::assume(false); + loop {} + } + }; + let expected_size = match offset.checked_add(field_size) { + Some(size) => size, + None => { + kani::assume(false); + loop {} + } + }; + assert_eq!( + slice_dst_size_for_trailing_bytes(composite_trailing, trailing_size), + Some(expected_size) + ); + // `alloc::Layout` permits sizes which are not a multiple + // of alignment, so this represents the unpadded prefix of + // the DST through the start of its trailing slice. let field_analog = - Layout::from_size_align(field_offset, field_align.get()).unwrap(); + Layout::from_size_align(field_trailing.offset, field_align.get()).unwrap(); if let Ok((actual_composite, actual_offset)) = base_analog.extend(field_analog) { assert_eq!(actual_offset, offset); - assert_eq!(actual_composite.size(), composite_offset); + assert_eq!(actual_composite.size(), composite_trailing.offset); assert_eq!(actual_composite.align(), composite.align.get()); } else { // An error here reflects that composite of `base` @@ -2035,8 +4221,8 @@ mod proofs { #[kani::proof] #[kani::should_panic] fn prove_dst_layout_extend_dst_panics() { - let base: DstLayout = kani::any(); - let field: DstLayout = kani::any(); + let base: DstLayout = any_valid_dst_layout(); + let field: DstLayout = any_valid_dst_layout(); let packed: Option = kani::any(); if let Some(max_align) = packed { @@ -2049,39 +4235,263 @@ mod proofs { let _ = base.extend(field, packed); } + fn prove_dst_layout_pad_to_align_dst_invariants( + layout: DstLayout, + unpadded_trailing: TrailingSliceLayout, + padded: DstLayout, + ) { + // Calling `pad_to_align` does not alter the `DstLayout`'s alignment. + assert_eq!(padded.align, layout.align); + assert_eq!(padded.statically_shallow_unpadded, layout.statically_shallow_unpadded); + + let SizeInfo::SliceDst(padded_trailing) = padded.size_info else { + panic!("The padding of a DST layout must result in a DST layout.") + }; + assert_eq!(padded_trailing.offset, unpadded_trailing.offset); + assert_eq!(padded_trailing.elem_size, unpadded_trailing.elem_size); + + // A finalized DST formula is visibly aligned: the rounding alignment + // is at least the outer alignment, and the fixed base is a multiple of + // the outer alignment. This is the invariant which lets suffix + // validation infer alignment of the object's start from alignment of + // its end. + assert!(padded_trailing.size_rounding_align_and_phase.align() >= padded.align); + #[allow(clippy::arithmetic_side_effects)] + let padded_align_mask = padded.align.get() - 1; + assert_eq!(padded_trailing.size_base & padded_align_mask, 0); + assert_eq!(padded.pad_to_align(), padded); + let padded_phase = padded_trailing.size_rounding_align_and_phase.components().1; + let Some(padded_unrounded_min) = padded_trailing.size_base.checked_add(padded_phase) else { + unreachable!() + }; + assert!(padded_unrounded_min >= padded_trailing.offset); + } + #[kani::proof] - fn prove_dst_layout_pad_to_align() { + fn prove_dst_layout_pad_to_align_sized() { use crate::util::padding_needed_for; - let layout: DstLayout = kani::any(); + let layout: DstLayout = any_valid_dst_layout(); + let SizeInfo::Sized { size: unpadded_size } = layout.size_info else { + kani::assume(false); + loop {} + }; let padded = layout.pad_to_align(); - - // Calling `pad_to_align` does not alter the `DstLayout`'s alignment. assert_eq!(padded.align, layout.align); + let SizeInfo::Sized { size: padded_size } = padded.size_info else { + panic!("The padding of a sized layout must result in a sized layout.") + }; - if let SizeInfo::Sized { size: unpadded_size } = layout.size_info { - if let SizeInfo::Sized { size: padded_size } = padded.size_info { - // If the layout is sized, it will remain sized after padding is - // added. Its sum will be its unpadded size and the size of the - // trailing padding needed to satisfy its alignment - // requirements. - let padding = padding_needed_for(unpadded_size, layout.align); - assert_eq!(padded_size, unpadded_size + padding); - - // Prove that calling `DstLayout::pad_to_align` behaves - // identically to `Layout::pad_to_align`. - let layout_analog = - Layout::from_size_align(unpadded_size, layout.align.get()).unwrap(); - let padded_analog = layout_analog.pad_to_align(); - assert_eq!(padded_analog.align(), layout.align.get()); - assert_eq!(padded_analog.size(), padded_size); - } else { - panic!("The padding of a sized layout must result in a sized layout.") - } - } else { - // If the layout is a DST, padding cannot be statically added. - assert_eq!(padded.size_info, layout.size_info); - } + // If the layout is sized, it will remain sized after padding is added. + // Its sum will be its unpadded size and the size of the trailing + // padding needed to satisfy its alignment requirements. + let padding = padding_needed_for(unpadded_size, layout.align); + assert_eq!(padded_size, unpadded_size + padding); + assert_eq!( + padded.statically_shallow_unpadded, + layout.statically_shallow_unpadded && padding == 0 + ); + + // Prove that calling `DstLayout::pad_to_align` behaves identically to + // `Layout::pad_to_align`. + let layout_analog = Layout::from_size_align(unpadded_size, layout.align.get()).unwrap(); + let padded_analog = layout_analog.pad_to_align(); + assert_eq!(padded_analog.align(), layout.align.get()); + assert_eq!(padded_analog.size(), padded_size); + } + + #[kani::proof] + fn prove_dst_layout_pad_to_align_dst_inner_rounding_transition() { + use crate::util::padding_needed_for; + + let layout: DstLayout = any_valid_dst_layout(); + let SizeInfo::SliceDst(trailing) = layout.size_info else { + kani::assume(false); + loop {} + }; + kani::assume(trailing.size_rounding_align_and_phase.align() >= layout.align); + + // `any_valid_dst_layout` requires the zero-metadata layout to be valid. + // Prove that this semantic condition bounds both the padded minimum + // and the implementation intermediate; do not assume the intermediate + // merely because the implementation uses it. + let Some(min_size) = trailing.size_for_elems(0) else { unreachable!() }; + let min_padding = padding_needed_for(min_size, layout.align); + let Some(padded_min_size) = min_size.checked_add(min_padding) else { unreachable!() }; + assert!(padded_min_size <= DstLayout::MAX_SIZE); + + let padding = padding_needed_for(trailing.size_base, layout.align); + let Some(expected_base) = trailing.size_base.checked_add(padding) else { unreachable!() }; + + let padded = layout.pad_to_align(); + let SizeInfo::SliceDst(padded_trailing) = padded.size_info else { unreachable!() }; + assert_eq!(padded_trailing.size_base, expected_base); + assert_eq!( + padded_trailing.size_rounding_align_and_phase, + trailing.size_rounding_align_and_phase + ); + prove_dst_layout_pad_to_align_dst_invariants(layout, trailing, padded); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_dst_layout_pad_to_align_dst_inner_rounding_formula() { + use crate::util::padding_needed_for; + + let size_align = any_layout_align(); + let outer_align = any_layout_align(); + kani::assume(size_align >= outer_align); + let size_base: usize = kani::any(); + let raw_size_phase: usize = kani::any(); + #[allow(clippy::arithmetic_side_effects)] + let size_phase = raw_size_phase & (size_align.get() - 1); + let trailing_size: usize = kani::any(); + + // Evaluate `size_base + round_up(size_phase + trailing_size, + // size_align)`, then round that complete size to `outer_align`. + // Compare with the equivalent expression obtained by rounding only + // `size_base` to `outer_align`, since `size_align` is a multiple of + // `outer_align`. Valid Rust objects have representable sizes; discard + // only trailing byte counts whose input or padded object size is not + // representable. + let Some(unrounded_inner) = size_phase.checked_add(trailing_size) else { + kani::assume(false); + loop {} + }; + let Some(rounded_inner) = + unrounded_inner.checked_add(padding_needed_for(unrounded_inner, size_align)) + else { + kani::assume(false); + loop {} + }; + let Some(unpadded_size) = size_base.checked_add(rounded_inner) else { + kani::assume(false); + loop {} + }; + let Some(expected_size) = + unpadded_size.checked_add(padding_needed_for(unpadded_size, outer_align)) + else { + kani::assume(false); + loop {} + }; + + let Some(padded_base) = size_base.checked_add(padding_needed_for(size_base, outer_align)) + else { + unreachable!() + }; + let Some(actual_size) = padded_base.checked_add(rounded_inner) else { unreachable!() }; + assert_eq!(actual_size, expected_size); + } + + #[kani::proof] + fn prove_dst_layout_pad_to_align_dst_outer_alignment_transition() { + use crate::util::padding_needed_for; + + let layout: DstLayout = any_valid_dst_layout(); + let SizeInfo::SliceDst(trailing) = layout.size_info else { + kani::assume(false); + loop {} + }; + let size_align = trailing.size_rounding_align_and_phase.align(); + kani::assume(size_align < layout.align); + + // `any_valid_dst_layout` requires the zero-metadata layout to be valid. + // Prove that this semantic condition bounds both the padded minimum + // and every implementation intermediate; do not assume the + // intermediates merely because the implementation uses them. + let Some(min_size) = trailing.size_for_elems(0) else { unreachable!() }; + let min_padding = padding_needed_for(min_size, layout.align); + let Some(padded_min_size) = min_size.checked_add(min_padding) else { unreachable!() }; + assert!(padded_min_size <= DstLayout::MAX_SIZE); + + let base_padding = padding_needed_for(trailing.size_base, size_align); + let Some(rounded_base) = trailing.size_base.checked_add(base_padding) else { + unreachable!() + }; + let Some(t) = + rounded_base.checked_add(trailing.size_rounding_align_and_phase.components().1) + else { + unreachable!() + }; + #[allow(clippy::arithmetic_side_effects)] + let expected_phase = t & (layout.align.get() - 1); + #[allow(clippy::arithmetic_side_effects)] + let expected_base = t - expected_phase; + + let padded = layout.pad_to_align(); + let SizeInfo::SliceDst(padded_trailing) = padded.size_info else { unreachable!() }; + assert_eq!(padded_trailing.size_base, expected_base); + assert_eq!( + padded_trailing.size_rounding_align_and_phase, + RoundingAlignAndPhase::new(layout.align, expected_phase) + ); + prove_dst_layout_pad_to_align_dst_invariants(layout, trailing, padded); + } + + #[kani::proof] + #[kani::solver(kissat)] + fn prove_dst_layout_pad_to_align_dst_outer_alignment_formula() { + use crate::util::padding_needed_for; + + let size_align = any_layout_align(); + let outer_align = any_layout_align(); + kani::assume(size_align < outer_align); + let size_base: usize = kani::any(); + let raw_size_phase: usize = kani::any(); + #[allow(clippy::arithmetic_side_effects)] + let size_phase = raw_size_phase & (size_align.get() - 1); + let trailing_size: usize = kani::any(); + + // Evaluate `size_base + round_up(size_phase + trailing_size, + // size_align)`, then round that complete size to `outer_align`. + let Some(unrounded_inner) = size_phase.checked_add(trailing_size) else { + kani::assume(false); + loop {} + }; + let Some(rounded_inner) = + unrounded_inner.checked_add(padding_needed_for(unrounded_inner, size_align)) + else { + kani::assume(false); + loop {} + }; + let Some(unpadded_size) = size_base.checked_add(rounded_inner) else { + kani::assume(false); + loop {} + }; + let Some(expected_size) = + unpadded_size.checked_add(padding_needed_for(unpadded_size, outer_align)) + else { + kani::assume(false); + loop {} + }; + + // Since `outer_align` is a larger multiple of `size_align`, absorb + // the inner rounding into the outer rounding and split the fixed + // contribution into a base and phase: + // + // fixed_contribution = round_up(size_base, size_align) + size_phase + // new_phase = fixed_contribution % outer_align + // new_base = fixed_contribution - new_phase + // new_base + round_up(new_phase + trailing_size, outer_align) + let Some(rounded_base) = size_base.checked_add(padding_needed_for(size_base, size_align)) + else { + unreachable!() + }; + let Some(t) = rounded_base.checked_add(size_phase) else { unreachable!() }; + #[allow(clippy::arithmetic_side_effects)] + let new_phase = t & (outer_align.get() - 1); + #[allow(clippy::arithmetic_side_effects)] + let new_base = t - new_phase; + let Some(new_unrounded_inner) = new_phase.checked_add(trailing_size) else { + unreachable!() + }; + let Some(new_rounded_inner) = + new_unrounded_inner.checked_add(padding_needed_for(new_unrounded_inner, outer_align)) + else { + unreachable!() + }; + let Some(actual_size) = new_base.checked_add(new_rounded_inner) else { unreachable!() }; + assert_eq!(actual_size, expected_size); } } diff --git a/zerocopy/src/lib.rs b/zerocopy/src/lib.rs index 63bb4d5d5b..6040d6e0dd 100644 --- a/zerocopy/src/lib.rs +++ b/zerocopy/src/lib.rs @@ -778,10 +778,16 @@ pub unsafe trait KnownLayout { /// where `size == size_of::()` /// - If `Self` is a slice DST, then `LAYOUT.size_info == /// SizeInfo::SliceDst(slice_layout)` where: + /// - `slice_layout.size_rounding_align_and_phase` decodes to an alignment + /// that is a power of two and a phase which is less than that alignment /// - The size, `size`, of an instance of `Self` with `elems` trailing - /// slice elements is equal to `slice_layout.offset + - /// slice_layout.elem_size * elems` rounded up to the nearest multiple - /// of `LAYOUT.align` + /// slice elements is equal to `slice_layout.size_base + + /// round_up(size_phase + slice_layout.elem_size * elems, size_align)`, + /// where `(size_align, size_phase)` is decoded from + /// `slice_layout.size_rounding_align_and_phase` + /// - The trailing slice begins at byte offset `slice_layout.offset` + /// - `slice_layout.offset + slice_layout.elem_size * elems` does not + /// overflow and is no greater than `size` /// - For such an instance, any bytes in the range `[slice_layout.offset + /// slice_layout.elem_size * elems, size)` are padding and must not be /// assumed to be initialized @@ -996,11 +1002,7 @@ impl PointerMetadata for usize { #[inline] fn size_for_metadata(self, layout: DstLayout) -> Option { match layout.size_info { - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) => { - let slice_len = elem_size.checked_mul(self)?; - let without_padding = offset.checked_add(slice_len)?; - without_padding.checked_add(util::padding_needed_for(without_padding, layout.align)) - } + SizeInfo::SliceDst(trailing) => trailing.size_for_elems(self), // NOTE: This branch is unreachable, but we return `None` rather // than `unreachable!()` to avoid generating panic paths. SizeInfo::Sized { .. } => None, @@ -6740,6 +6742,39 @@ mod tests { } } + // These types exercise a nested slice DST whose inner trailing-padding + // operation cannot be flattened into the alignment of the packed outer + // type. For `NestedPackedDstOuter<[u8]>`, the trailing slice begins at + // offset 7, but the size for `elems` elements is + // `2 + round_up(5 + elems, 4)`. + // + // The Reference rejects directly placing an alignment-4 field in a + // `repr(packed(2))` type. Rust currently accepts this construction through + // generic indirection. So long as Rust accepts it, this test deliberately + // treats its layout as following the otherwise-applicable `repr(C)` and + // `repr(packed)` rules. + #[derive(FromBytes, Immutable, KnownLayout)] + #[repr(C, align(4))] + struct NestedPackedDstWord([u8; 4]); + + #[derive(FromBytes, Immutable, KnownLayout)] + #[repr(C)] + struct NestedPackedDstInner { + a: NestedPackedDstWord, + b: u8, + tail: T, + } + + #[derive(FromBytes, Immutable, KnownLayout)] + #[repr(C, packed(2))] + struct NestedPackedDstOuter { + z: u8, + inner: ManuallyDrop>, + } + + #[repr(C, align(2))] + struct Align2(T); + #[test] fn test_known_layout() { // Test that `$ty` and `ManuallyDrop<$ty>` have the expected layout. @@ -6754,17 +6789,25 @@ mod tests { }; } - let layout = - |offset, align, trailing_slice_elem_size, statically_shallow_unpadded| DstLayout { - align: NonZeroUsize::new(align).unwrap(), + let layout = |offset, align, trailing_slice_elem_size, statically_shallow_unpadded| { + let align = NonZeroUsize::new(align).unwrap(); + let size_phase = offset % align; + DstLayout { + align, size_info: match trailing_slice_elem_size { None => SizeInfo::Sized { size: offset }, - Some(elem_size) => { - SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }) - } + Some(elem_size) => SizeInfo::SliceDst(TrailingSliceLayout { + offset, + elem_size, + size_base: offset - size_phase, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + align, size_phase, + ), + }), }, statically_shallow_unpadded, - }; + } + }; test!((), layout(0, 1, None, false)); test!(u8, layout(1, 1, None, false)); @@ -6832,6 +6875,101 @@ mod tests { } } + #[test] + fn test_nested_packed_dst_layout() { + fn assert_native_size(expected: usize) { + let value = NestedPackedDstOuter { + z: 0, + inner: ManuallyDrop::new(NestedPackedDstInner { + a: NestedPackedDstWord([0; 4]), + b: 0, + tail: [0; N], + }), + }; + let value: &NestedPackedDstOuter<[u8]> = &value; + assert_eq!(mem::size_of_val(value), expected); + assert_eq!(NestedPackedDstOuter::<[u8]>::size_for_metadata(N), Some(expected)); + } + + assert_native_size::<0>(10); + assert_native_size::<1>(10); + assert_native_size::<2>(10); + assert_native_size::<3>(10); + assert_native_size::<4>(14); + assert_native_size::<7>(14); + assert_native_size::<8>(18); + + let layout = NestedPackedDstOuter::<[u8]>::LAYOUT; + assert_eq!(layout.align.get(), 2); + assert!(layout.requires_dynamic_padding()); + assert_eq!( + layout.size_info, + SizeInfo::SliceDst(TrailingSliceLayout { + offset: 7, + elem_size: 1, + size_base: 6, + size_rounding_align_and_phase: RoundingAlignAndPhase::new( + NonZeroUsize::new(4).unwrap(), + 1 + ), + }) + ); + + // Eight bytes used to be incorrectly accepted for a zero-element + // value. The actual Rust layout requires ten bytes. + let short = Align2([0u8; 8]); + assert!(NestedPackedDstOuter::<[u8]>::ref_from_bytes_with_elems(&short.0, 0).is_err()); + assert!(pointer::Ptr::from_ref(&short.0[..]) + .try_cast_into::, pointer::BecauseImmutable>( + CastType::Prefix, + Some(0), + ) + .is_err()); + + let full = Align2([0, 0, 0, 0, 0, 0, 0, 0, b'S', b'!']); + let value = NestedPackedDstOuter::<[u8]>::ref_from_bytes_with_elems(&full.0, 0).unwrap(); + assert_eq!(mem::size_of_val(value), 10); + + // When metadata is inferred, lengths 0 through 3 all produce a + // ten-byte object. Parsing chooses the largest trailing-slice length + // which fits exactly. + let inferred = NestedPackedDstOuter::<[u8]>::ref_from_bytes(&full.0).unwrap(); + assert_eq!(pointer::Ptr::from_ref(inferred).len(), 3); + + // Trailing padding includes the inner type's padding, even when the + // outer type's alignment is reduced by packing. + let trailing_layout = trailing_slice_layout::>(); + for (elems, expected) in [(0, 3), (1, 2), (2, 1), (3, 0), (4, 3)] { + assert_eq!(trailing_layout.padding_for_elems(elems), expected); + } + } + + // Before Rust 1.76, rust-lang/rust#118537 caused `addr_of_mut!` to + // miscompute the offset of an unsized field in a packed struct. The fix is + // rust-lang/rust#118540. Keep the layout and parsing regression above on + // all supported compilers, and gate only the projection regression which + // necessarily exercises that independent compiler bug. + #[rustversion::since(1.76.0)] + #[test] + fn test_nested_packed_dst_projection() { + let full = Align2([0, 0, 0, 0, 0, 0, 0, 0, b'S', b'!']); + + // The projected inner value occupies the final eight bytes of the full + // ten-byte input, including the two sentinel bytes. + let (value, rest) = pointer::Ptr::from_ref(&full.0[..]) + .try_cast_into::, pointer::BecauseImmutable>( + CastType::Prefix, + Some(0), + ) + .unwrap(); + assert!(rest.as_ref().is_empty()); + let inner = value.project::().unwrap(); + assert_eq!( + inner.as_bytes::().as_ref(), + &[0, 0, 0, 0, 0, 0, b'S', b'!'] + ); + } + #[cfg(feature = "derive")] #[test] fn test_known_layout_derive() { @@ -6887,10 +7025,19 @@ mod tests { statically_shallow_unpadded: false, }; - let unsized_layout = |align, elem_size, offset, statically_shallow_unpadded| DstLayout { - align: NonZeroUsize::new(align).unwrap(), - size_info: SizeInfo::SliceDst(TrailingSliceLayout { offset, elem_size }), - statically_shallow_unpadded, + let unsized_layout = |align, elem_size, offset, statically_shallow_unpadded| { + let align = NonZeroUsize::new(align).unwrap(); + let size_phase = offset % align; + DstLayout { + align, + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset, + elem_size, + size_base: offset - size_phase, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(align, size_phase), + }), + statically_shallow_unpadded, + } }; // | `repr(C)`? | generic? | `KnownLayout`? | `Sized`? | Type Name | @@ -7907,6 +8054,16 @@ mod tests { assert_eq!(&*s, &[0, 3, 0]); } + #[test] + fn test_new_box_zeroed_with_elems_nested_packed_dst() { + let mut value = NestedPackedDstOuter::<[u8]>::new_box_zeroed_with_elems(0).unwrap(); + assert_eq!(mem::size_of_val(&*value), 10); + + // Exercise the whole-object write that exposed the former + // under-allocation under Miri and AddressSanitizer. + value.zero(); + } + #[test] fn test_new_box_zeroed_with_elems_empty() { let s: Box<[u64]> = <[u64]>::new_box_zeroed_with_elems(0).unwrap(); diff --git a/zerocopy/src/macros.rs b/zerocopy/src/macros.rs index 5f7479aa1e..9e908a0c3a 100644 --- a/zerocopy/src/macros.rs +++ b/zerocopy/src/macros.rs @@ -1411,6 +1411,38 @@ mod tests { let x: &SliceDst = transmute_ref!(slice_dst_big); assert_eq!(x, slice_dst_small); + // A packed outer DST can preserve the trailing field's stronger + // rounding alignment. These two types consequently have different + // normalized layout formulas even though both have size `2 + 4 * n` + // for every trailing slice length. Ensure the semantic size-sequence + // comparison accepts this representation-preserving transmutation. + #[derive(FromBytes, Immutable, IntoBytes, KnownLayout)] + #[repr(C, align(2))] + struct TransmuteAlign2([u8; 4]); + + #[derive(FromBytes, Immutable, IntoBytes, KnownLayout)] + #[repr(C, packed(2))] + struct TransmutePacked4 { + head: u16, + tail: [u32], + } + + #[derive(FromBytes, Immutable, IntoBytes, KnownLayout)] + #[repr(C, packed(2))] + struct TransmutePacked2 { + head: u16, + tail: [TransmuteAlign2], + } + + #[repr(C, align(2))] + struct TransmuteBytes([u8; 10]); + + let bytes = TransmuteBytes([0; 10]); + let src = TransmutePacked4::ref_from_bytes(&bytes.0).unwrap(); + let dst: &TransmutePacked2 = transmute_ref!(src); + assert_eq!(mem::size_of_val(src), 10); + assert_eq!(mem::size_of_val(dst), 10); + // Test that it's legal to transmute a reference while shrinking the // lifetime (note that `X` has the lifetime `'static`). let x: &[u8; 8] = transmute_ref!(X); diff --git a/zerocopy/src/pointer/inner.rs b/zerocopy/src/pointer/inner.rs index 023df07d04..d926e1e520 100644 --- a/zerocopy/src/pointer/inner.rs +++ b/zerocopy/src/pointer/inner.rs @@ -289,10 +289,10 @@ where /// /// Given `let (left, right) = ptr.split_at(l_len)`, it is guaranteed that /// `left` and `right` are contiguous and non-overlapping if - /// `l_len.padding_needed_for() == 0`. This is true for all `[T]`. + /// the left part has no trailing padding. This is true for all `[T]`. /// - /// If `l_len.padding_needed_for() != 0`, then the left pointer will overlap - /// the right pointer to satisfy `T`'s padding requirements. + /// Any trailing padding in the left part begins at the right part's + /// starting address and may overlap its referent. #[inline] #[must_use] pub unsafe fn split_at_unchecked( @@ -313,23 +313,19 @@ where // Trivially, `slf.meta() <= slf.meta()`. let right = unsafe { right.slice_unchecked(l_len..self.meta().get()) }; - // SAFETY: If `l_len.padding_needed_for() == 0`, then `left` and `right` - // are non-overlapping. Proof: `left` is constructed `slf` with `l_len` - // as its (exclusive) upper bound. If `l_len.padding_needed_for() == 0`, - // then `left` requires no trailing padding following its final element. - // Since `right` is constructed from `slf`'s trailing slice with `l_len` - // as its (inclusive) lower bound, no byte is referred to by both - // pointers. + // SAFETY: If the left part has no trailing padding, then `left` and + // `right` are non-overlapping. Proof: `left` has `l_len` trailing + // elements, and `right` starts at element `l_len` of the original + // trailing slice. Without padding after `left`'s final element, no + // byte is referred to by both pointers. // - // Conversely, `l_len.padding_needed_for() == N`, where `N - // > 0`, `left` requires `N` bytes of trailing padding following its - // final element. Since `right` is constructed from the trailing slice - // of `slf` with `l_len` as its (inclusive) lower bound, the first `N` - // bytes of `right` are aliased by `left`. + // Any trailing padding in `left` begins at `right`'s starting + // address. If `right` is nonzero sized, the referents overlap. (left, right) } - /// Produces the trailing slice of `self`. + /// Produces the trailing slice of `self` using `T::Elem` as its element + /// representation. #[inline] #[must_use] pub fn trailing_slice(self) -> PtrInner<'a, [T::Elem]> @@ -368,9 +364,10 @@ where // 1. If `ptr`'s referent is not zero sized, then `ptr` has valid // provenance for `A` because `raw` is derived from the same // allocated object as `self` via provenance-preserving operations. - // 2. If `ptr`'s referent is not zero sized, then `ptr` addresses a byte - // range which is entirely contained in `A`, by previous safety proof - // on `bytes`. + // 2. `T: SplitAt` guarantees that `T::Elem` has the size of the actual + // trailing element. The new slice has the same length and start as + // the original trailing slice, so it covers the same byte range. + // If nonzero sized, that range is entirely contained in `A`. // 3. `ptr` addresses a byte range whose length fits in an `isize`, by // consequence of #2. // 4. `ptr` addresses a byte range which does not wrap around the diff --git a/zerocopy/src/pointer/mod.rs b/zerocopy/src/pointer/mod.rs index 03b17bf078..52ba00fd95 100644 --- a/zerocopy/src/pointer/mod.rs +++ b/zerocopy/src/pointer/mod.rs @@ -47,10 +47,7 @@ where pub mod cast { use core::{marker::PhantomData, mem}; - use crate::{ - layout::{SizeInfo, TrailingSliceLayout}, - HasField, KnownLayout, PtrInner, - }; + use crate::{layout::SizeInfo, HasField, KnownLayout, PtrInner}; /// A pointer cast or projection. /// @@ -182,11 +179,9 @@ pub mod cast { // SAFETY: By the `static_assert!`, `Src` and `Dst` are either: // - Both sized and equal in size - // - Both slice DSTs with the same trailing slice offset and element size - // and with align_of::() == align_of::(). These ensure that any - // given pointer metadata encodes the same size for both `Src` and `Dst` - // (note that the alignment is required as it affects the amount of - // trailing padding). Thus, `project` preserves the set of referent bytes. + // - Both slice DSTs with the same trailing-slice offset, the same object + // size for every metadata value, and with align_of::() == + // align_of::(). Thus, `project` preserves the set of referent bytes. unsafe impl Project for CastUnsized where Src: ?Sized + KnownLayout, @@ -201,10 +196,11 @@ pub mod cast { let dst = ::LAYOUT; match (src.size_info, dst.size_info) { (SizeInfo::Sized { size: src_size }, SizeInfo::Sized { size: dst_size }) => src_size == dst_size, - ( - SizeInfo::SliceDst(TrailingSliceLayout { offset: src_offset, elem_size: src_elem_size }), - SizeInfo::SliceDst(TrailingSliceLayout { offset: dst_offset, elem_size: dst_elem_size }) - ) => src.align.get() == dst.align.get() && src_offset == dst_offset && src_elem_size == dst_elem_size, + (SizeInfo::SliceDst(src_trailing), SizeInfo::SliceDst(dst_trailing)) => { + src.align.get() == dst.align.get() + && src_trailing.offset == dst_trailing.offset + && src_trailing.has_same_size_sequence(dst_trailing) + }, _ => false, } }); @@ -225,10 +221,8 @@ pub mod cast { // SAFETY: By the `static_assert!` in `Project::project`, `Src` and `Dst` // are either: // - Both sized and equal in size - // - Both slice DSTs with the same alignment, trailing slice offset, and - // element size. These ensure that any given pointer metadata encodes the - // same size for both `Src` and `Dst` (note that the alignment is required - // as it affects the amount of trailing padding). + // - Both slice DSTs with the same alignment and trailing-slice offset, and + // with equal sizes for every metadata value. unsafe impl CastExact for CastUnsized where Src: ?Sized + KnownLayout, diff --git a/zerocopy/src/split_at.rs b/zerocopy/src/split_at.rs index 720ba740c8..5e188e5abd 100644 --- a/zerocopy/src/split_at.rs +++ b/zerocopy/src/split_at.rs @@ -883,14 +883,16 @@ where #[inline(always)] fn via_runtime_check(self) -> Result<(Ptr<'a, T, I>, Ptr<'a, [T::Elem], I>), Self> { let l_len = self.l_len(); + // `l_len` witnesses an object size at most `isize::MAX`, and + // `KnownLayout::LAYOUT` guarantees that it contains the trailing + // slice. Thus `padding_for_elems` returns the exact trailing padding. + let trailing_padding = crate::trailing_slice_layout::().padding_for_elems(l_len.get()); // FIXME(#1290): Once we require `KnownLayout` on all fields, add an // `IS_IMMUTABLE` associated const, and add `T::IS_IMMUTABLE ||` to the // below check. - if l_len.padding_needed_for() == 0 { - // SAFETY: By `T: SplitAt`, `T` is either `[T]`, or a `repr(C)` or - // `repr(transparent)` slice DST, for which the trailing padding - // needed to accommodate `l_len` trailing elements is - // `l_len.padding_needed_for()`. If no trailing padding is required, + if trailing_padding == 0 { + // SAFETY: As established above, `trailing_padding` is the exact + // padding after the left part's trailing slice. If it is zero, // the left and right parts are strictly non-overlapping. Ok(unsafe { self.via_unchecked() }) } else { @@ -903,7 +905,7 @@ where /// # Safety /// /// The caller promises that if `I::Aliasing` is [`Exclusive`] or `T` - /// permits interior mutation, then `l_len.padding_needed_for() == 0`. + /// permits interior mutation, then the left part has no trailing padding. #[inline(always)] unsafe fn via_unchecked(self) -> (Ptr<'a, T, I>, Ptr<'a, [T::Elem], I>) { let l_len = self.l_len(); @@ -915,8 +917,9 @@ where // Lemma 0: `left` and `right` conform to the aliasing invariant // `I::Aliasing`. Proof: If `I::Aliasing` is `Exclusive` or `T` permits - // interior mutation, the caller promises that `l_len.padding_needed_for() - // == 0`. Consequently, by post-condition on `PtrInner::split_at_unchecked`, + // interior mutation, the caller promises that the left part has no + // trailing padding. Consequently, by post-condition on + // `PtrInner::split_at_unchecked`, // there is no trailing padding after `left`'s final element that would // overlap into `right`. If `I::Aliasing` is shared and `T` forbids interior // mutation, then overlap between their referents is permissible. @@ -942,8 +945,8 @@ where // `ptr`, which, by contract on `T: SplitAt::Elem`, has type // `[T::Elem]`. The `left` part cannot be used to invalidate `right`, // because the caller promises that if `I::Aliasing` is `Exclusive` - // or `T` permits interior mutation, then `l_len.padding_needed_for() - // == 0` and thus the parts will be non-overlapping. + // or `T` permits interior mutation, then the left part has no + // trailing padding and thus the parts will be non-overlapping. let right = unsafe { Ptr::from_inner(right) }; (left, right) diff --git a/zerocopy/src/util/mod.rs b/zerocopy/src/util/mod.rs index 28ccd53bbe..ddd876429a 100644 --- a/zerocopy/src/util/mod.rs +++ b/zerocopy/src/util/mod.rs @@ -146,9 +146,10 @@ pub(crate) fn validate_aligned_to(t: T) -> Result<(), Alignment /// on the answer it gives if this is not the case. #[cfg_attr( kani, - kani::requires(len <= DstLayout::MAX_SIZE), kani::requires(align.is_power_of_two()), - kani::ensures(|&p| (len + p) % align.get() == 0), + // A power-of-two alignment divides the `usize` modulus, so wrapping + // preserves congruence even when the next aligned value exceeds `usize`. + kani::ensures(|&p| len.wrapping_add(p) % align.get() == 0), // Ensures that we add the minimum required padding. kani::ensures(|&p| p < align.get()), )] @@ -598,31 +599,6 @@ mod len_of { self.meta } - #[inline] - pub(crate) fn padding_needed_for(&self) -> usize - where - T: KnownLayout, - { - let trailing_slice_layout = crate::trailing_slice_layout::(); - - // FIXME(#67): Remove this allow. See NumExt for more details. - #[allow( - unstable_name_collisions, - clippy::incompatible_msrv, - clippy::multiple_unsafe_ops_per_block - )] - // SAFETY: By invariant on `self`, a `&T` with metadata `self.meta` - // describes an object of size `<= isize::MAX`. This computes the - // size of such a `&T` without any trailing padding, and so neither - // the multiplication nor the addition will overflow. - let unpadded_size = unsafe { - let trailing_size = self.meta.unchecked_mul(trailing_slice_layout.elem_size); - trailing_size.unchecked_add(trailing_slice_layout.offset) - }; - - util::padding_needed_for(unpadded_size, T::LAYOUT.align) - } - #[inline(always)] pub(crate) fn validate_cast_and_convert_metadata( addr: usize, diff --git a/zerocopy/tests/codegen.rs b/zerocopy/tests/codegen.rs index cc3b0eb34e..92dc92846f 100644 --- a/zerocopy/tests/codegen.rs +++ b/zerocopy/tests/codegen.rs @@ -123,10 +123,15 @@ fn run_codegen_test(bench_name: &str, target_cpu: &str, bless: bool) { if bless { std::fs::write(expected_file_path, actual_result.as_bytes()).unwrap(); } else { - let expected_result = std::fs::read(expected_file_path).unwrap_or_default(); + let expected_result = std::fs::read(&expected_file_path).unwrap_or_default(); if actual_result.as_bytes() != expected_result { let expected = String::from_utf8_lossy(&expected_result[..]); - panic!("Bless codegen tests with BLESS=1\nGot unexpected output:\n{}", expected); + panic!( + "Bless codegen tests with BLESS=1\nSnapshot: {}\nExpected:\n{}\nActual:\n{}", + expected_file_path.display(), + expected, + actual_result + ); } } }; @@ -153,7 +158,11 @@ fn codegen() { }) .collect(); - let failed = handles.into_iter().any(|handle| handle.join().unwrap().is_err()); + // Join every worker so an early failure cannot hide later diagnostics. + let mut failed = false; + for handle in handles { + failed |= handle.join().unwrap().is_err(); + } if failed { panic!("One or more codegen tests failed. See thread panics above for details."); From ef2804b2d221af3269ed37c3e2433b708a6e93ed Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 10:36:20 -0400 Subject: [PATCH 11/12] Compare nested DST sizes with a recursive Rust oracle Check runtime nested-DST sizes against an independently implemented recursive Rust oracle, including padding inside packed fields. Extract and prove the independent checked Rust reference for arbitrary nested record descriptions and metadata. Use its remainder-based rounding in compiler regression tests, without sharing the production padding utility. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. gherrit-pr-id: Guzdt7nfgmmi7y3e4taezg43ssexn4hrf Agent-authored-by: AI agent acting on joshlf's behalf --- verification/aeneas/README.md | 2 +- verification/aeneas/golden/Funs.lean | 123 ++++- verification/aeneas/golden/Types.lean | 9 +- verification/aeneas/lean/LayoutModel.lean | 182 ++++++++ verification/aeneas/lean/Obligations.lean | 23 + verification/aeneas/lean/Proofs.lean | 1 + .../aeneas/lean/Proofs/NestedReference.lean | 419 ++++++++++++++++++ zerocopy/src/layout/mod.rs | 338 ++++++++++++++ zerocopy/src/layout/nested_reference.rs | 120 +++++ 9 files changed, 1211 insertions(+), 6 deletions(-) create mode 100644 verification/aeneas/lean/Proofs/NestedReference.lean create mode 100644 zerocopy/src/layout/nested_reference.rs diff --git a/verification/aeneas/README.md b/verification/aeneas/README.md index 77a2d614bc..b04805d2a0 100644 --- a/verification/aeneas/README.md +++ b/verification/aeneas/README.md @@ -113,7 +113,7 @@ zerocopy's unsafe pointer operations are sound. ## Scope and proofs -Rust review entry points: [arithmetic assertions](../../zerocopy/src/util/checks.rs). +Rust review entry points: [arithmetic assertions](../../zerocopy/src/util/checks.rs), [nested reference](../../zerocopy/src/layout/nested_reference.rs). Extraction starts from the function and nominal-type owners of every present `aeneas` fence in `zerocopy/src`, including their dependencies. Every present diff --git a/verification/aeneas/golden/Funs.lean b/verification/aeneas/golden/Funs.lean index daf298db34..1fa172b4b2 100644 --- a/verification/aeneas/golden/Funs.lean +++ b/verification/aeneas/golden/Funs.lean @@ -65,14 +65,14 @@ def Usize.Insts.CoreNumNonzeroZeroablePrimitiveNonZeroUsizeInner : } /-- [zerocopy::layout::POINTER_WIDTH_BITS] - Source: 'src/layout/mod.rs', lines 17:0-17:62 -/ + Source: 'src/layout/mod.rs', lines 20:0-20:62 -/ @[global_simps, irreducible] def layout.POINTER_WIDTH_BITS : Result Std.Usize := do let i ← core.mem.size_of Std.Usize i * 8#usize /-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::new]: - Source: 'src/layout/mod.rs', lines 97:4-116:5 -/ + Source: 'src/layout/mod.rs', lines 100:4-119:5 -/ def layout.RoundingAlignAndPhase.new (align : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner) (phase : Std.Usize) : @@ -93,7 +93,7 @@ def layout.RoundingAlignAndPhase.new | some encoded1 => ok { _0 := encoded1 } /-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::components]: - Source: 'src/layout/mod.rs', lines 130:4-157:5 -/ + Source: 'src/layout/mod.rs', lines 133:4-160:5 -/ def layout.RoundingAlignAndPhase.components (self : layout.RoundingAlignAndPhase) : Result ((core.num.nonzero.NonZero Std.Usize @@ -121,7 +121,7 @@ def layout.RoundingAlignAndPhase.components ok (align1, phase) /-- [zerocopy::layout::{zerocopy::layout::RoundingAlignAndPhase}::align]: - Source: 'src/layout/mod.rs', lines 170:4-179:5 -/ + Source: 'src/layout/mod.rs', lines 173:4-182:5 -/ def layout.RoundingAlignAndPhase.align (self : layout.RoundingAlignAndPhase) : Result (core.num.nonzero.NonZero Std.Usize @@ -130,6 +130,121 @@ def layout.RoundingAlignAndPhase.align let (nz, _) ← layout.RoundingAlignAndPhase.components self ok nz +/-- [zerocopy::layout::nested_reference::round_up]: + Source: 'src/layout/nested_reference.rs', lines 50:0-60:1 -/ +def layout.nested_reference.round_up + (size : Std.Usize) (align : Std.Usize) : Result (Option Std.Usize) := do + if align = 0#usize + then ok none + else + let remainder ← size % align + if remainder = 0#usize + then ok (some size) + else let i ← align - remainder + ok (Usize.checked_add size i) + +/-- [zerocopy::layout::nested_reference::apply_layer]: + Source: 'src/layout/nested_reference.rs', lines 62:0-84:1 -/ +def layout.nested_reference.apply_layer + (layer : layout.nested_reference.NestedLayer) (size : Std.Usize) + (alignment : Std.Usize) : + Result (Option (Std.Usize × Std.Usize)) + := do + let b ← core.num.Usize.is_power_of_two layer.packed + if b + then + let b1 ← core.num.Usize.is_power_of_two layer.min_align + if b1 + then + if layer.min_align > layer.packed + then ok none + else + let field_align ← + if alignment < layer.packed + then ok alignment + else ok layer.packed + let o ← + layout.nested_reference.round_up layer.prefix_bytes field_align + match o with + | none => ok none + | some offset => + let alignment1 ← + if layer.min_align < field_align + then ok field_align + else ok layer.min_align + let o1 ← lift (Usize.checked_add offset size) + match o1 with + | none => ok none + | some size1 => + let o2 ← layout.nested_reference.round_up size1 alignment1 + match o2 with + | none => ok none + | some size2 => ok (some (size2, alignment1)) + else ok none + else ok none + +/-- [zerocopy::layout::nested_reference::size_for_metadata]: loop body 0: + Source: 'src/layout/nested_reference.rs', lines 112:4-119:5 -/ +@[rust_loop_body] +def layout.nested_reference.size_for_metadata_loop.body + (leading : Slice layout.nested_reference.NestedLayer) + (state : Option (Std.Usize × Std.Usize)) (i : Std.Usize) : + Result (ControlFlow ((Option (Std.Usize × Std.Usize)) × Std.Usize) (Option + (Std.Usize × Std.Usize))) + := do + if i != 0#usize + then + let i1 ← i - 1#usize + let layer ← Slice.index_usize leading i1 + match state with + | none => ok (cont (none, i1)) + | some p => + let (size, alignment) := p + let state1 ← layout.nested_reference.apply_layer layer size alignment + ok (cont (state1, i1)) + else ok (done state) + +/-- [zerocopy::layout::nested_reference::size_for_metadata]: loop 0: + Source: 'src/layout/nested_reference.rs', lines 112:4-119:5 -/ +@[rust_loop] +def layout.nested_reference.size_for_metadata_loop + (leading : Slice layout.nested_reference.NestedLayer) + (state : Option (Std.Usize × Std.Usize)) (i : Std.Usize) : + Result (Option (Std.Usize × Std.Usize)) + := do + loop + (fun (state1, i1) => layout.nested_reference.size_for_metadata_loop.body + leading state1 i1) + (state, i) + +/-- [zerocopy::layout::nested_reference::size_for_metadata]: + Source: 'src/layout/nested_reference.rs', lines 98:0-124:1 -/ +def layout.nested_reference.size_for_metadata + (leading : Slice layout.nested_reference.NestedLayer) (elem_size : Std.Usize) + (leaf_align : Std.Usize) (elems : Std.Usize) : + Result (Option Std.Usize) + := do + let b ← core.num.Usize.is_power_of_two leaf_align + if b + then + let i ← elem_size % leaf_align + if i != 0#usize + then ok none + else + let o ← lift (Usize.checked_mul elem_size elems) + let state ← + match o with + | none => ok none + | some size => ok (some (size, leaf_align)) + let i1 := Slice.len leading + let state1 ← + layout.nested_reference.size_for_metadata_loop leading state i1 + match state1 with + | none => ok none + | some p => let (size, _) := p + ok (some size) + else ok none + /-- [zerocopy::util::round_down_to_next_multiple_of_alignment]: Source: 'src/util/mod.rs', lines 261:0-279:1 -/ def util.round_down_to_next_multiple_of_alignment diff --git a/verification/aeneas/golden/Types.lean b/verification/aeneas/golden/Types.lean index d081d8f6b7..2c2b95e9a7 100644 --- a/verification/aeneas/golden/Types.lean +++ b/verification/aeneas/golden/Types.lean @@ -40,9 +40,16 @@ structure core.num.nonzero.ZeroablePrimitive (Self : Type) (Self_NonZeroInner : innerCopyInst : core.marker.Copy Self_NonZeroInner /-- [zerocopy::layout::RoundingAlignAndPhase] - Source: 'src/layout/mod.rs', lines 78:0-78:54 -/ + Source: 'src/layout/mod.rs', lines 81:0-81:54 -/ structure layout.RoundingAlignAndPhase where _0 : core.num.nonzero.NonZero Std.Usize core.num.niche_types.NonZeroUsizeInner +/-- [zerocopy::layout::nested_reference::NestedLayer] + Source: 'src/layout/nested_reference.rs', lines 31:0-35:1 -/ +structure layout.nested_reference.NestedLayer where + packed : Std.Usize + min_align : Std.Usize + prefix_bytes : Std.Usize + end Zerocopy diff --git a/verification/aeneas/lean/LayoutModel.lean b/verification/aeneas/lean/LayoutModel.lean index d2859cdab2..adc6a518f7 100644 --- a/verification/aeneas/lean/LayoutModel.lean +++ b/verification/aeneas/lean/LayoutModel.lean @@ -27,3 +27,185 @@ namespace Zerocopy.Proofs end Zerocopy.Proofs + +namespace Zerocopy.Proofs.NestedReference + +/- The independent recursive rule uses unbounded arithmetic and complete +inner-field sizes. Layers are ordered from outermost to innermost. These +definitions do not call an extracted implementation or manipulate its +normalized base and phase. +-/ +def layerValid (layer : layout.nested_reference.NestedLayer) : Prop := + layer.packed.val.isPowerOfTwo ∧ layer.min_align.val.isPowerOfTwo ∧ + layer.min_align.val ≤ layer.packed.val + +def valid (layers : List layout.nested_reference.NestedLayer) + (elem alignment : Nat) : Prop := + alignment.isPowerOfTwo ∧ elem % alignment = 0 ∧ ∀ layer ∈ layers, layerValid layer + +def step (layer : layout.nested_reference.NestedLayer) (inner : Nat × Nat) : Nat × Nat := + let placementAlign := min inner.2 layer.packed.val + let alignment := max layer.min_align.val placementAlign + (LayoutMath.roundUp (LayoutMath.roundUp layer.prefix_bytes.val placementAlign + inner.1) alignment, + alignment) + +def state (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata : Nat) : Nat × Nat := + layers.foldr step (metadata * elem, alignment) + +theorem state_cons (layer : layout.nested_reference.NestedLayer) + (layers : List layout.nested_reference.NestedLayer) (elem alignment metadata : Nat) : + state (layer :: layers) elem alignment metadata = + step layer (state layers elem alignment metadata) := rfl + +theorem step_contains_inner (layer : layout.nested_reference.NestedLayer) (inner : Nat × Nat) : + inner.1 ≤ (step layer inner).1 := by + simp only [step, LayoutMath.roundUp] + omega + +theorem step_alignment_power (layer : layout.nested_reference.NestedLayer) + (inner : Nat × Nat) (hl : layerValid layer) (ha : inner.2.isPowerOfTwo) : + (step layer inner).2.isPowerOfTwo := by + simp only [step] + have hm : (min inner.2 layer.packed.val).isPowerOfTwo := by + by_cases h : inner.2 ≤ layer.packed.val + · simpa only [Nat.min_eq_left h] using ha + · simpa only [Nat.min_eq_right (by omega : layer.packed.val ≤ inner.2)] using hl.1 + by_cases h : layer.min_align.val ≤ min inner.2 layer.packed.val + · simpa only [Nat.max_eq_right h] using hm + · simpa only [Nat.max_eq_left (by omega : min inner.2 layer.packed.val ≤ layer.min_align.val)] + using hl.2.1 + +theorem state_alignment_power (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata : Nat) (h : valid layers elem alignment) : + (state layers elem alignment metadata).2.isPowerOfTwo := by + induction layers with + | nil => exact h.1 + | cons layer layers ih => + simp only [state, List.foldr_cons] + apply step_alignment_power + · exact h.2.2 layer (by simp) + · apply ih + exact ⟨h.1, h.2.1, fun x hx => h.2.2 x (by simp [hx])⟩ + +theorem state_alignment_bound (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata bound : Nat) (ha : alignment ≤ bound) + (hl : ∀ layer ∈ layers, layer.min_align.val ≤ bound) : + (state layers elem alignment metadata).2 ≤ bound := by + induction layers with + | nil => exact ha + | cons layer layers ih => + rw [state_cons] + simp only [step] + apply max_le + · exact hl layer (by simp) + · apply le_trans (Nat.min_le_left _ _) + exact ih (fun x hx => hl x (by simp [hx])) + +theorem state_contains_suffix (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata count : Nat) : + (state (layers.drop count) elem alignment metadata).1 ≤ + (state layers elem alignment metadata).1 := by + induction layers generalizing count with + | nil => simp + | cons layer layers ih => + cases count with + | zero => simp + | succ count => + simp only [List.drop_succ_cons, state_cons] + exact le_trans (ih count) (step_contains_inner layer (state layers elem alignment metadata)) + +theorem suffix_zero_size_fits (layers : List layout.nested_reference.NestedLayer) + (elem alignment limit count : Nat) (hfit : (state layers elem alignment 0).1 ≤ limit) : + (state (layers.drop count) elem alignment 0).1 ≤ limit := + le_trans (state_contains_suffix layers elem alignment 0 count) hfit + +theorem step_contains_placed_field (layer : layout.nested_reference.NestedLayer) + (inner : Nat × Nat) : + LayoutMath.roundUp layer.prefix_bytes.val (min inner.2 layer.packed.val) + inner.1 ≤ + (step layer inner).1 := by + simp only [step, LayoutMath.roundUp] + omega + +theorem step_contains_prefix (layer : layout.nested_reference.NestedLayer) + (inner : Nat × Nat) : layer.prefix_bytes.val ≤ (step layer inner).1 := by + simp only [step, LayoutMath.roundUp] + omega + +theorem valid_cons (layer : layout.nested_reference.NestedLayer) + (layers : List layout.nested_reference.NestedLayer) (elem alignment : Nat) : + valid (layer :: layers) elem alignment ↔ layerValid layer ∧ valid layers elem alignment := by + simp only [valid, List.mem_cons, forall_eq_or_imp] + tauto + +noncomputable def checkedState (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata : Nat) : Option (Nat × Nat) := by + classical + exact if valid layers elem alignment then + if (state layers elem alignment metadata).1 ≤ Usize.max + then some (state layers elem alignment metadata) else none + else none + +noncomputable def checkedLayer (layer : layout.nested_reference.NestedLayer) + (inner : Option (Nat × Nat)) : Option (Nat × Nat) := by + classical + exact if layerValid layer then inner.bind (fun inner => + if (step layer inner).1 ≤ Usize.max then some (step layer inner) else none) else none + +theorem checkedState_cons (layer : layout.nested_reference.NestedLayer) + (layers : List layout.nested_reference.NestedLayer) (elem alignment metadata : Nat) : + checkedState (layer :: layers) elem alignment metadata = + checkedLayer layer (checkedState layers elem alignment metadata) := by + classical + by_cases hl : layerValid layer + · by_cases hv : valid layers elem alignment + · by_cases hf : (state layers elem alignment metadata).1 ≤ Usize.max + · simp only [checkedState, checkedLayer, valid_cons, hl, hv, true_and, if_true, + hf, state_cons, Option.bind_some] + · have ho : ¬(step layer (state layers elem alignment metadata)).1 ≤ Usize.max := by + have := step_contains_inner layer (state layers elem alignment metadata) + omega + simp only [checkedState, checkedLayer, valid_cons, hl, hv, true_and, if_true, + hf, if_false, state_cons, ho, Option.bind_none] + · simp only [checkedState, checkedLayer, valid_cons, hl, hv, true_and, if_false, + if_true, Option.bind_none] + · simp only [checkedState, checkedLayer, valid_cons, hl, false_and, if_false] + +theorem checkedState_some (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata : Nat) (out : Nat × Nat) + (h : checkedState layers elem alignment metadata = some out) : + valid layers elem alignment ∧ state layers elem alignment metadata = out ∧ out.1 ≤ Usize.max := by + classical + unfold checkedState at h + split at h + · rename_i hv + split at h + · rename_i hf + have heq := Option.some.inj h + exact ⟨hv, heq, heq ▸ hf⟩ + · contradiction + · contradiction + +theorem checkedState_drop (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata i : Nat) (hi : i < layers.length) : + checkedState (layers.drop i) elem alignment metadata = + checkedLayer layers[i] (checkedState (layers.drop (i + 1)) elem alignment metadata) := by + rw [List.drop_eq_getElem_cons hi, checkedState_cons] + +noncomputable def checkedSize (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata : Nat) : Option Nat := by + classical + exact if valid layers elem alignment then + if (state layers elem alignment metadata).1 ≤ Usize.max + then some (state layers elem alignment metadata).1 else none + else none + +theorem checkedSize_eq_map (layers : List layout.nested_reference.NestedLayer) + (elem alignment metadata : Nat) : + checkedSize layers elem alignment metadata = + (checkedState layers elem alignment metadata).map Prod.fst := by + classical + unfold checkedSize checkedState + split <;> (try split) <;> simp + +end Zerocopy.Proofs.NestedReference diff --git a/verification/aeneas/lean/Obligations.lean b/verification/aeneas/lean/Obligations.lean index 19453e6e6c..e2b5a5aa10 100644 --- a/verification/aeneas/lean/Obligations.lean +++ b/verification/aeneas/lean/Obligations.lean @@ -30,6 +30,29 @@ or a second proof that the compiler implements Rust correctly. open Aeneas Aeneas.Std AeneasSpecs namespace Zerocopy.Obligations open Zerocopy.Proofs + +def nested_round_up_spec_contract (size align : Usize) + (run : Result (Option Usize)) : Prop := + run ⦃ rounded => rounded.map UScalar.val = + if align.val = 0 then none + else if LayoutMath.roundUp size.val align.val ≤ Usize.max + then some (LayoutMath.roundUp size.val align.val) else none ⦄ + +def nested_round_up_spec : Prop := + ∀ size align, nested_round_up_spec_contract size align + (layout.nested_reference.round_up size align) + +def nested_reference_size_spec_contract + (leading : Slice layout.nested_reference.NestedLayer) (elem_size leaf_align elems : Usize) + (run : Result (Option Usize)) : Prop := + run ⦃ result => result.map UScalar.val = + NestedReference.checkedSize leading.val elem_size.val leaf_align.val elems.val ⦄ + +def nested_reference_size_spec : Prop := + ∀ leading elem_size leaf_align elems, + nested_reference_size_spec_contract leading elem_size leaf_align elems + (layout.nested_reference.size_for_metadata leading elem_size leaf_align elems) + abbrev NonZeroUsize := core.num.nonzero.NonZero Usize core.num.niche_types.NonZeroUsizeInner diff --git a/verification/aeneas/lean/Proofs.lean b/verification/aeneas/lean/Proofs.lean index 89ff7b9e67..bee36adb80 100644 --- a/verification/aeneas/lean/Proofs.lean +++ b/verification/aeneas/lean/Proofs.lean @@ -9,6 +9,7 @@ those terms. -/ module public import Specs public import Proofs.Util +public import Proofs.NestedReference public import Aeneas public import Loops public import RepresentationLaws diff --git a/verification/aeneas/lean/Proofs/NestedReference.lean b/verification/aeneas/lean/Proofs/NestedReference.lean new file mode 100644 index 0000000000..071f791689 --- /dev/null +++ b/verification/aeneas/lean/Proofs/NestedReference.lean @@ -0,0 +1,419 @@ +/- 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 Loops +public import Obligations +public import ModelLemmas +import all Init.Data.Nat.Power2.Basic +@[expose] public section + +open Aeneas Aeneas.Std +namespace Zerocopy.Proofs.Raw + +def nestedStateView (state : Usize × Usize) : Nat × Nat := (state.1.val, state.2.val) + +theorem nested_round_up_spec (size align : Usize) : + layout.nested_reference.round_up size align + ⦃ rounded => rounded.map UScalar.val = + if align.val = 0 then none + else if LayoutMath.roundUp size.val align.val ≤ Usize.max + then some (LayoutMath.roundUp size.val align.val) else none ⦄ := by + unfold layout.nested_reference.round_up + by_cases hz : align = 0#usize + · subst align + simp + · simp only [hz, if_false] + have ha : 0 < align.val := by + have hn : align.val ≠ 0 := by + intro h + apply hz + exact UScalar.eq_of_val_eq (by simpa using h) + omega + step with Usize.rem_spec size (y := align) (by omega) as ⟨remainder, hr⟩ + by_cases hrz : remainder = 0#usize + · have hr0 : size.val % align.val = 0 := by + have h := congrArg UScalar.val hrz + simpa only [hr, show (0#usize).val = 0 by simp] using h + simp only [hrz, if_true, WP.spec_ok, Option.map_some, + show ¬align.val = 0 by omega, if_false, LayoutMath.roundUp_eq _ _ hr0, + show size.val ≤ Usize.max by scalar_tac, if_true] + · simp only [hrz, if_false] + have hrem := Nat.mod_lt size.val ha + step with Usize.sub_spec (x := align) (y := remainder) (by rw [hr]; omega) + as ⟨padding, hp, _⟩ + have hrpos : 0 < size.val % align.val := by + have hn : remainder.val ≠ 0 := by + intro h + apply hrz + exact UScalar.eq_of_val_eq (by simpa using h) + omega + have hp' : padding.val = (align.val - size.val % align.val) % align.val := by + have hm : (align.val - size.val % align.val) % align.val = + align.val - size.val % align.val := Nat.mod_eq_of_lt (by omega) + rw [hp, hr, hm] + have hchecked := Usize.checked_add_bv_spec size padding + cases hc : Usize.checked_add size padding <;> + simp only [hc] at hchecked + · have hn : ¬LayoutMath.roundUp size.val align.val ≤ Usize.max := by + unfold LayoutMath.roundUp + omega + simp only [Option.map_none, show ¬align.val = 0 by omega, if_false, hn] + · rename_i rounded + have hs : LayoutMath.roundUp size.val align.val ≤ Usize.max := by + unfold LayoutMath.roundUp + omega + simp only [Option.map_some, show ¬align.val = 0 by omega, if_false, hs, if_true] + congr 1 + unfold LayoutMath.roundUp + omega + +theorem nested_apply_layer_spec (layer : layout.nested_reference.NestedLayer) + (size alignment : Usize) (ha : alignment.val.isPowerOfTwo) : + layout.nested_reference.apply_layer layer size alignment + ⦃ result => result.map nestedStateView = + NestedReference.checkedLayer layer (some (size.val, alignment.val)) ⦄ := by + classical + unfold layout.nested_reference.apply_layer + step as ⟨packedPow, hp⟩ + cases packedPow with + | false => + have hn : ¬layer.packed.val.isPowerOfTwo := by + intro h + have := (Iff.of_eq hp).mpr h + contradiction + have hl : ¬NestedReference.layerValid layer := by + intro h + exact hn h.1 + simp only [Bool.false_eq_true, if_false, WP.spec_ok, Option.map_none, + NestedReference.checkedLayer, hl, if_false] + | true => + have hp' : layer.packed.val.isPowerOfTwo := (Iff.of_eq hp).mp rfl + simp only [if_true] + step as ⟨minimumPow, hm⟩ + cases minimumPow with + | false => + have hn : ¬layer.min_align.val.isPowerOfTwo := by + intro h + have := (Iff.of_eq hm).mpr h + contradiction + have hl : ¬NestedReference.layerValid layer := by + intro h + exact hn h.2.1 + simp only [Bool.false_eq_true, if_false, WP.spec_ok, Option.map_none, + NestedReference.checkedLayer, hl, if_false] + | true => + have hm' : layer.min_align.val.isPowerOfTwo := (Iff.of_eq hm).mp rfl + simp only [if_true, UScalar.lt_equiv] + split + · rename_i hlarge + have hl : ¬NestedReference.layerValid layer := by + intro h + exact not_lt_of_ge h.2.2 hlarge + simp only [WP.spec_ok, Option.map_none, NestedReference.checkedLayer, hl, if_false] + · rename_i hsmall + have hl : NestedReference.layerValid layer := ⟨hp', hm', by omega⟩ + let field := min alignment layer.packed + have hfieldRaw : (if alignment < layer.packed then Result.ok alignment else .ok layer.packed) = + Result.ok field := by + by_cases h : alignment < layer.packed + · simp only [h, if_true, field, min_eq_left (le_of_lt h)] + · simp only [h, if_false, field, min_eq_right (le_of_not_gt h)] + have hfield : (if alignment.val < layer.packed.val then Result.ok alignment else .ok layer.packed) = + Result.ok field := by simpa only [UScalar.lt_equiv] using hfieldRaw + simp only [hfield, bind_ok] + have hf : field.val = min alignment.val layer.packed.val := Arithmetic.coe_min _ _ + have hfp : field.val.isPowerOfTwo := by + rw [hf] + by_cases h : alignment.val ≤ layer.packed.val + · simpa only [Nat.min_eq_left h] using ha + · simpa only [Nat.min_eq_right (by omega : layer.packed.val ≤ alignment.val)] using hp' + have hfpos := Nat.pos_of_isPowerOfTwo hfp + step with nested_round_up_spec layer.prefix_bytes field as ⟨offset, hoffset⟩ + change offset.map UScalar.val = if field.val = 0 then none else + if LayoutMath.roundUp layer.prefix_bytes.val field.val ≤ Usize.max then + some (LayoutMath.roundUp layer.prefix_bytes.val field.val) else none at hoffset + simp only [show ¬field.val = 0 by omega, if_false] at hoffset + let outer := max layer.min_align field + have houtRaw : (if layer.min_align < field then Result.ok field else .ok layer.min_align) = + Result.ok outer := by + by_cases h : layer.min_align < field + · simp only [h, if_true, outer, max_eq_right (le_of_lt h)] + · simp only [h, if_false, outer, max_eq_left (le_of_not_gt h)] + have hout : (if layer.min_align.val < field.val then Result.ok field else .ok layer.min_align) = + Result.ok outer := by simpa only [UScalar.lt_equiv] using houtRaw + have ho : outer.val = max layer.min_align.val field.val := Arithmetic.coe_max _ _ + have hopos : 0 < outer.val := by + rw [ho] + have := Nat.le_max_right layer.min_align.val field.val + omega + let target := NestedReference.step layer (size.val, alignment.val) + have htarget : target = + (LayoutMath.roundUp (LayoutMath.roundUp layer.prefix_bytes.val field.val + size.val) + outer.val, outer.val) := by + simp only [target, NestedReference.step, hf, ho] + have hcontains : LayoutMath.roundUp layer.prefix_bytes.val field.val ≤ target.1 := by + rw [htarget] + simp only [LayoutMath.roundUp] + omega + have hmodel : NestedReference.checkedLayer layer (some (size.val, alignment.val)) = + if target.1 ≤ Usize.max then some target else none := by + simp only [NestedReference.checkedLayer, hl, if_true, Option.bind_some] + rfl + simp only [hmodel] + cases offset with + | none => + have hnot : ¬LayoutMath.roundUp layer.prefix_bytes.val field.val ≤ Usize.max := by + split at hoffset <;> simp_all + have hn : ¬target.1 ≤ Usize.max := by omega + simp only [WP.spec_ok, Option.map_none, hn, if_false] + | some offset => + have hv : offset.val = LayoutMath.roundUp layer.prefix_bytes.val field.val := by + split at hoffset <;> simp_all + simp only [hout, bind_ok] + step as ⟨unpadded, hu⟩ + cases unpadded with + | none => + have hsum : Usize.max < offset.val + size.val := hu + have hn : ¬target.1 ≤ Usize.max := by + rw [htarget] + simp only [LayoutMath.roundUp] at hv ⊢ + omega + simp only [WP.spec_ok, Option.map_none, hn, if_false] + | some unpadded => + have husize : unpadded.val = offset.val + size.val := hu.2.1 + step with nested_round_up_spec unpadded outer as ⟨rounded, hr⟩ + change rounded.map UScalar.val = if outer.val = 0 then none else + if LayoutMath.roundUp unpadded.val outer.val ≤ Usize.max then + some (LayoutMath.roundUp unpadded.val outer.val) else none at hr + simp only [show ¬outer.val = 0 by omega, if_false] at hr + have hround : LayoutMath.roundUp unpadded.val outer.val = target.1 := by + rw [husize, hv, htarget] + rw [hround] at hr + cases rounded with + | none => + have hn : ¬target.1 ≤ Usize.max := by split at hr <;> simp_all + simp only [WP.spec_ok, Option.map_none, hn, if_false] + | some rounded => + have ht : target.1 ≤ Usize.max := by split at hr <;> simp_all + have hsize : rounded.val = target.1 := by split at hr <;> simp_all + have halign : outer.val = target.2 := by rw [htarget] + simp only [WP.spec_ok, Option.map_some, nestedStateView, ht, if_true, hsize, halign, + Prod.mk.eta] + +/-- The reverse loop retains complete recursive size at every layer. Its +termination measure is the remaining layer index; no bound limits nesting. -/ +theorem nested_reference_loop_spec + (leading : Slice layout.nested_reference.NestedLayer) (elem alignment metadata : Nat) + (state : Option (Usize × Usize)) (i : Usize) + (hi : i.val ≤ leading.val.length) + (hv : state.map nestedStateView = + NestedReference.checkedState (leading.val.drop i.val) elem alignment metadata) : + layout.nested_reference.size_for_metadata_loop leading state i + ⦃ out => out.map nestedStateView = + NestedReference.checkedState leading.val elem alignment metadata ⦄ := by + unfold layout.nested_reference.size_for_metadata_loop + apply loop.spec_decr_nat + (fun s : Option (Usize × Usize) × Usize => s.2.val) + (fun s => s.2.val ≤ leading.val.length ∧ s.1.map nestedStateView = + NestedReference.checkedState (leading.val.drop s.2.val) elem alignment metadata) + · rintro ⟨state, idx⟩ ⟨hi, hv⟩ + dsimp only at hi hv ⊢ + unfold layout.nested_reference.size_for_metadata_loop.body + simp only [bne_iff_ne] + split + · rename_i hn + have hpos : 0 < idx.val := by + have hz : idx.val ≠ 0 := by + intro h + apply hn + exact UScalar.eq_of_val_eq (by simpa using h) + omega + step with Usize.sub_spec (x := idx) (y := 1#usize) (by simp; omega) + as ⟨j, hj, _⟩ + have hj' : j.val + 1 = idx.val := by + change j.val = idx.val - 1 at hj + omega + have hjlt : j.val < leading.val.length := by omega + step with Slice.index_usize_spec leading j hjlt as ⟨layer, hlayer⟩ + have hmath : NestedReference.checkedState (leading.val.drop j.val) elem alignment metadata = + NestedReference.checkedLayer layer + (NestedReference.checkedState (leading.val.drop idx.val) elem alignment metadata) := by + rw [NestedReference.checkedState_drop leading.val elem alignment metadata j.val hjlt, + hj', hlayer] + cases state with + | none => + have hnext : (none : Option (Usize × Usize)).map nestedStateView = + NestedReference.checkedState (leading.val.drop j.val) elem alignment metadata := by + rw [hmath, ← hv] + simp [NestedReference.checkedLayer] + apply WP.spec.ret + change j.val ≤ leading.val.length ∧ + (none : Option (Usize × Usize)).map nestedStateView = + NestedReference.checkedState (leading.val.drop j.val) elem alignment metadata ∧ + j.val < idx.val + exact ⟨by omega, hnext, by omega⟩ + | some pair => + have heq : NestedReference.checkedState (leading.val.drop idx.val) elem alignment metadata = + some (nestedStateView pair) := hv.symm + obtain ⟨hvalid, heq, hfit⟩ := NestedReference.checkedState_some + (leading.val.drop idx.val) elem alignment metadata (nestedStateView pair) heq + have hpower : pair.2.val.isPowerOfTwo := by + have hp := NestedReference.state_alignment_power (leading.val.drop idx.val) + elem alignment metadata hvalid + rw [heq] at hp + exact hp + rcases pair with ⟨size, currentAlignment⟩ + step with nested_apply_layer_spec layer size currentAlignment hpower as ⟨next, hnext⟩ + have hview : next.map nestedStateView = + NestedReference.checkedState (leading.val.drop j.val) elem alignment metadata := by + rw [hmath, ← hv] + exact hnext + exact ⟨by omega, hview, by omega⟩ + · rename_i hz + have heq : idx.val = 0 := by + have h : idx = 0#usize := not_ne_iff.mp hz + simpa using congrArg UScalar.val h + simpa only [heq, List.drop_zero, WP.spec_ok] using hv + · exact ⟨hi, hv⟩ + +theorem nested_reference_size_spec + (leading : Slice layout.nested_reference.NestedLayer) (elem_size leaf_align elems : Usize) : + layout.nested_reference.size_for_metadata leading elem_size leaf_align elems + ⦃ result => result.map UScalar.val = + NestedReference.checkedSize leading.val elem_size.val leaf_align.val elems.val ⦄ := by + classical + unfold layout.nested_reference.size_for_metadata + step as ⟨leafPow, hp⟩ + cases leafPow with + | false => + have hn : ¬leaf_align.val.isPowerOfTwo := by + intro h + have := (Iff.of_eq hp).mpr h + contradiction + have hv : ¬NestedReference.valid leading.val elem_size.val leaf_align.val := by + intro h + exact hn h.1 + simp only [Bool.false_eq_true, if_false, WP.spec_ok, Option.map_none, + NestedReference.checkedSize, hv, if_false] + | true => + have hpow : leaf_align.val.isPowerOfTwo := (Iff.of_eq hp).mp rfl + have hpos := Nat.pos_of_isPowerOfTwo hpow + simp only [if_true] + step with Usize.rem_spec elem_size (y := leaf_align) (by omega) as ⟨remainder, hr⟩ + simp only [bne_iff_ne] + split + · rename_i hn + have hv : ¬NestedReference.valid leading.val elem_size.val leaf_align.val := by + intro h + apply hn + exact UScalar.eq_of_val_eq (by simpa only [hr, show (0#usize).val = 0 by simp] using h.2.1) + simp only [WP.spec_ok, Option.map_none, NestedReference.checkedSize, hv, if_false] + · rename_i hz + have hzero : elem_size.val % leaf_align.val = 0 := by + have h : remainder = 0#usize := not_ne_iff.mp hz + simpa only [hr, show (0#usize).val = 0 by simp] using congrArg UScalar.val h + step as ⟨product, hproduct⟩ + let start : Option (Usize × Usize) := match product with + | none => none + | some size => some (size, leaf_align) + have hinit : start.map nestedStateView = + NestedReference.checkedState (leading.val.drop (Slice.len leading).val) + elem_size.val leaf_align.val elems.val := by + have hvalid : NestedReference.valid [] elem_size.val leaf_align.val := + ⟨hpow, hzero, by simp⟩ + simp only [Slice.len_val, Slice.length, List.drop_length, NestedReference.checkedState, + hvalid, if_true, NestedReference.state, List.foldr_nil] + cases product with + | none => + have hn : ¬elems.val * elem_size.val ≤ Usize.max := by + simp only [] at hproduct + rw [Nat.mul_comm] + omega + simp only [start, Option.map_none, hn, if_false] + | some size => + have hv : size.val = elems.val * elem_size.val := by + simp only [] at hproduct + rw [Nat.mul_comm] + exact hproduct.2.1 + have hf : elems.val * elem_size.val ≤ Usize.max := by + simp only [] at hproduct + rw [Nat.mul_comm] + exact hproduct.1 + simp only [start, Option.map_some, nestedStateView, hv, hf, if_true] + have hrun := nested_reference_loop_spec leading elem_size.val leaf_align.val elems.val + start (Slice.len leading) (by simp only [Slice.len_val, Slice.length, le_refl]) hinit + cases product <;> simp only [start] at hrun ⊢ + all_goals + step with hrun as ⟨out, hout⟩ + have hfinal : (match out with | none => none | some pair => some pair.1).map UScalar.val = + NestedReference.checkedSize leading.val elem_size.val leaf_align.val elems.val := by + rw [NestedReference.checkedSize_eq_map, ← hout] + cases out <;> rfl + cases out with + | none => simpa only [WP.spec_ok] using hfinal + | some pair => + rcases pair with ⟨size, alignment⟩ + dsimp only + exact WP.spec.ret hfinal + +end Zerocopy.Proofs.Raw + +namespace Zerocopy.Proofs +open AeneasSpecs + +theorem nested_round_up_spec : Zerocopy.Specs.nested_round_up_spec := by + intro size align sizeValue hsize alignValue halign + apply WP.spec_mono (Raw.nested_round_up_spec size align) + intro rounded hr + refine ⟨rounded.map unsignedWord, ?_, hr⟩ + cases rounded <;> rfl +register_spec_step nested_round_up_spec + +theorem nested_reference_size_spec : Zerocopy.Specs.nested_reference_size_spec := by + intro leading elem_size leaf_align elems layers hl elemValue he alignmentValue ha metadataValue hn + apply WP.spec_mono (Raw.nested_reference_size_spec leading elem_size leaf_align elems) + intro result hr + refine ⟨result.map unsignedWord, ?_, hr⟩ + cases result <;> rfl +register_spec_step nested_reference_size_spec + +/-- No descriptor bit pattern is excluded by a hidden decoder guard. -/ +theorem nested_layer_decoder_total (raw : layout.nested_reference.NestedLayer) : + isValid raw := by + refine ⟨⟨unsignedWord raw.packed, unsignedWord raw.min_align, + unsignedWord raw.prefix_bytes⟩, ?_⟩ + rfl + +theorem nested_layers_decoder_total (raw : Slice layout.nested_reference.NestedLayer) : + isValid raw := + (slice_valid_iff raw).mpr (fun layer _ => nested_layer_decoder_total layer) + +@[contract_simps] theorem nested_reference_size_spec_implies_required + (leading : Slice layout.nested_reference.NestedLayer) (elem_size leaf_align elems : Usize) + (run : Result (Option Usize)) + (provided : Zerocopy.Specs.nested_reference_size_spec_contract leading elem_size leaf_align elems run) : + Zerocopy.Obligations.nested_reference_size_spec_contract leading elem_size leaf_align elems run := by + obtain ⟨layers, hlayers⟩ := nested_layers_decoder_total leading + have execution := provided layers hlayers (unsignedWord elem_size) rfl + (unsignedWord leaf_align) rfl (unsignedWord elems) rfl + apply WP.spec_mono execution + rintro result ⟨decoded, hdecoded, hr⟩ + exact hr + +@[contract_simps] theorem nested_round_up_spec_implies_required + (size align : Usize) (run : Result (Option Usize)) + (provided : Zerocopy.Specs.nested_round_up_spec_contract size align run) : + Zerocopy.Obligations.nested_round_up_spec_contract size align run := by + have execution := provided (unsignedWord size) rfl (unsignedWord align) rfl + apply WP.spec_mono execution + rintro rounded ⟨decoded, hdecoded, hr⟩ + exact hr + +end Zerocopy.Proofs diff --git a/zerocopy/src/layout/mod.rs b/zerocopy/src/layout/mod.rs index 0eb2e82a39..443de644cc 100644 --- a/zerocopy/src/layout/mod.rs +++ b/zerocopy/src/layout/mod.rs @@ -9,6 +9,9 @@ // This file may not be copied, modified, or distributed except according to // those terms. +#[allow(dead_code)] +mod nested_reference; + use core::{mem, num::NonZeroUsize}; use crate::util; @@ -1978,6 +1981,341 @@ mod padding_testutil { #[cfg(test)] mod tests { use super::*; + use crate::PointerMetadata as _; + + /// Models the size of nested `repr(C)` structs ending in a slice. + /// + /// `leading` lists structs from outermost to innermost. Each tuple gives + /// the packing factor, minimum alignment, and prefix size before padding + /// for the final field. `trailing_elem_size` and `trailing_alignment` give + /// the slice element size and alignment. An empty `leading` describes the + /// slice itself. + /// + /// The returned closure maps a slice length to the complete size, including + /// padding at every nesting level. It returns `None` only on `usize` + /// overflow; sizes above `isize::MAX` are permitted by this arithmetic model. + /// Packing affects field placement, preserving each field's internal + /// padding, even for descriptors that Rust would reject as types. + /// + /// # Panics + /// + /// Panics if any alignment or packing factor is not a power of two, a + /// minimum alignment exceeds its packing factor, or the element size is + /// not a multiple of the slice alignment. + fn size_for_metadata_model( + leading: &[(NonZeroUsize, NonZeroUsize, usize)], + trailing_elem_size: usize, + trailing_alignment: NonZeroUsize, + ) -> impl Fn(usize) -> Option + '_ { + // Rust requires power-of-two alignments and sizes divisible by their + // alignment [1]; alignment modifiers also take powers of two [2]. + // + // [1] https://doc.rust-lang.org/1.93.0/reference/type-layout.html#size-and-alignment + // [2] https://doc.rust-lang.org/1.93.0/reference/type-layout.html#the-alignment-modifiers + assert!(trailing_alignment.get().is_power_of_two()); + assert_eq!(trailing_elem_size % trailing_alignment, 0); + for &(packed, align, _) in leading { + assert!(packed.get().is_power_of_two()); + assert!(align.get().is_power_of_two()); + // The descriptor requires its minimum to fit under the packing + // cap. It also models combinations that Rust forbids, including + // simultaneous `align` and `packed` modifiers [2]. + assert!(align <= packed); + } + + move |elems| { + // A slice shares its array section's layout [3]. Array size is + // element size times length, with the element's alignment [4]. + // + // [3] https://doc.rust-lang.org/1.93.0/reference/type-layout.html#slice-layout + // [4] https://doc.rust-lang.org/1.93.0/reference/type-layout.html#array-layout + let mut size = trailing_elem_size.checked_mul(elems)?; + let mut alignment = trailing_alignment; + + // Work outward with each field's complete size, including its + // trailing padding: an enclosing representation "does not change + // the layout of the fields themselves" [5]. Thus packing a nested + // field preserves the padding already included in `size`. + // + // [5] https://doc.rust-lang.org/1.93.0/reference/type-layout.html#representations + for &(packed, align, prefix) in leading.iter().rev() { + // Packing caps the field's alignment for placement [2]. + let field_align = alignment.min(packed); + + // `repr(C)` places the next field at an aligned offset [6]; + // packing requires the least sufficient inter-field padding + // [2]. Here `prefix` is the end of the preceding fields. + // + // [6] https://doc.rust-lang.org/1.93.0/reference/type-layout.html#reprc-structs + let offset = nested_reference::round_up(prefix, field_align.get())?; + + // `repr(C)` uses the greatest field alignment [6], and an + // explicit alignment can only raise it [2]. Here `align` + // summarizes the minimum from the prefix and any explicit + // alignment. Both operands are <= `packed`, so their maximum + // also respects the packing cap. + alignment = align.max(field_align); + + // Advance past the complete field, then round to the struct's + // alignment, following `repr(C)`'s final sizing steps [6]. + size = offset.checked_add(size)?; + size = nested_reference::round_up(size, alignment.get())?; + } + // Each enclosing size is at least its field's size. Thus any + // checked multiplication or addition that overflowed above would + // also make the final size unrepresentable in `usize`. + Some(size) + } + } + + /// Computes [`size_for_metadata_model`]'s size function using `DstLayout`. + /// + /// Layout construction uses [`DstLayout::for_repr_c_struct`], and the + /// returned closure uses [`crate::PointerMetadata::size_for_metadata`]. + /// + /// # Panics + /// + /// Panics for invalid descriptors as in [`size_for_metadata_model`], or if + /// `DstLayout` cannot construct the layout. This includes static size + /// overflow and, in debug builds, alignments or packing factors above + /// [`DstLayout::CURRENT_MAX_ALIGN`]. + fn size_for_metadata_via_dst_layout( + leading: &[(NonZeroUsize, NonZeroUsize, usize)], + trailing_elem_size: usize, + trailing_alignment: NonZeroUsize, + ) -> impl Fn(usize) -> Option + '_ { + assert_eq!(trailing_elem_size % trailing_alignment, 0); + let mut layout = DstLayout { + align: trailing_alignment, + size_info: SizeInfo::SliceDst(TrailingSliceLayout { + offset: 0, + elem_size: trailing_elem_size, + size_base: 0, + size_rounding_align_and_phase: RoundingAlignAndPhase::new(trailing_alignment, 0), + }), + statically_shallow_unpadded: true, + }; + for &(packed, align, prefix) in leading.iter().rev() { + assert!(align <= packed); + let prefix = DstLayout { + align: DstLayout::MIN_ALIGN, + size_info: SizeInfo::Sized { size: prefix }, + statically_shallow_unpadded: true, + }; + layout = DstLayout::for_repr_c_struct(Some(align), Some(packed), &[prefix, layout]); + } + move |elems: usize| elems.size_for_metadata(layout) + } + + #[test] + fn test_model_via_dst_layout() { + let nz = |n| NonZeroUsize::new(n).unwrap(); + let cases: &[&[_]] = &[ + &[], + &[(nz(8), nz(1), 3)], + &[(nz(8), nz(8), 3)], + &[(nz(2), nz(1), 1), (nz(8), nz(8), 3)], + &[(nz(8), nz(8), 1), (nz(2), nz(1), 3), (nz(8), nz(8), 5)], + &[(nz(1), nz(1), 1), (nz(2), nz(2), 3), (nz(8), nz(8), 5)], + ]; + for &leading in cases { + for &(elem_size, alignment) in &[(0, 1), (0, 8), (1, 1), (2, 2), (3, 1), (8, 8)] { + let alignment = nz(alignment); + let expected = size_for_metadata_model(leading, elem_size, alignment); + let actual = size_for_metadata_via_dst_layout(leading, elem_size, alignment); + let max_elems = usize::MAX / elem_size.max(1); + for elems in (0..32).chain([ + max_elems - 1, + max_elems, + max_elems.saturating_add(1), + usize::MAX, + ]) { + assert_eq!( + actual(elems), + expected(elems), + "leading: {:?}, trailing: {:?}, elems: {}", + leading, + (elem_size, alignment), + elems, + ); + } + } + } + + for prefix in [usize::MAX - 1, usize::MAX] { + let leading = [(nz(1), nz(1), prefix)]; + let expected = size_for_metadata_model(&leading, 1, nz(1)); + let actual = size_for_metadata_via_dst_layout(&leading, 1, nz(1)); + for elems in [0, 1, 2] { + assert_eq!(actual(elems), expected(elems)); + } + } + } + + // This exercises arithmetic rather than unsafe operations; keep the + // smaller comparison test enabled under Miri instead of this large search. + #[test] + #[cfg_attr(miri, ignore)] + fn test_model_via_dst_layout_combinations() { + use rand::{rngs::SmallRng, Rng as _, SeedableRng as _}; + + fn check( + leading: &[(NonZeroUsize, NonZeroUsize, usize)], + trailing_elem_size: usize, + trailing_alignment: NonZeroUsize, + ) { + let expected = size_for_metadata_model(leading, trailing_elem_size, trailing_alignment); + assert!( + expected(0).is_some(), + "unrepresentable test layout: {:?}, {:?}", + leading, + (trailing_elem_size, trailing_alignment) + ); + let actual = std::panic::catch_unwind(|| { + size_for_metadata_via_dst_layout(leading, trailing_elem_size, trailing_alignment) + }) + .unwrap_or_else(|_| { + panic!( + "DstLayout rejected leading: {:?}, trailing: {:?}", + leading, + (trailing_elem_size, trailing_alignment) + ) + }); + let compare = |elems| { + let expected = expected(elems); + assert_eq!( + actual(elems), + expected, + "leading: {:?}, trailing: {:?}, elems: {}", + leading, + (trailing_elem_size, trailing_alignment), + elems, + ); + expected + }; + + let first_sixteen = 0..=16; + let pointer_width = (0..POINTER_WIDTH_BITS).step_by(4).flat_map(|shift| { + let elems = 1usize << shift; + [elems - 1, elems, elems + 1] + }); + let last_two = (usize::MAX - 1)..=usize::MAX; + let elemses = first_sixteen.chain(pointer_width).chain(last_two); + for elems in elemses { + let _ = compare(elems); + } + + // The modeled size is nondecreasing. Find the last fitting length, + // comparing every probe, then check both sides of that boundary. + // Zero-sized elements can remain representable at usize::MAX. + let (mut low, mut high) = (0, usize::MAX); + while low < high { + let distance = high - low; + let mid = low + distance / 2 + distance % 2; + if compare(mid).is_some() { + low = mid; + } else { + high = mid - 1; + } + } + assert!(compare(low).is_some()); + if let Some(previous) = low.checked_sub(1) { + assert!(compare(previous).is_some()); + } + if let Some(next) = low.checked_add(1) { + assert_eq!(compare(next), None); + } + } + + let nz = |n| NonZeroUsize::new(n).unwrap(); + let check_small_tails = |leading: &[_]| { + for alignment in [1, 2, 4, 8] { + for multiple in [0, 1, 2, 3, 7] { + check(leading, multiple * alignment, nz(alignment)); + } + } + }; + let mut fragments = Vec::new(); + for packed in [1, 2, 4] { + for align in [1, 2, 4].iter().copied().filter(|&align| align <= packed) { + for prefix in 0..=4 { + fragments.push((nz(packed), nz(align), prefix)); + } + } + } + + // Exhaust every combination in this small domain at depths 0, 1, 2. + check_small_tails(&[]); + for &outer in &fragments { + check_small_tails(&[outer]); + for &inner in &fragments { + check_small_tails(&[outer, inner]); + } + } + + let mut rng = SmallRng::seed_from_u64(0); + for depth in (0usize..=128).chain([256, 512, 1024]) { + // A small prefix adds at most 4 * max_align bytes per layer. + // Reserve half of usize::MAX for a large outermost prefix so + // construction remains representable even at the largest depth. + let align_limit = + DstLayout::CURRENT_MAX_ALIGN.get().min(usize::MAX / (8 * depth.max(1))); + let alignments: Vec<_> = (0..POINTER_WIDTH_BITS) + .map(|shift| 1usize << shift) + .take_while(|&align| align <= align_limit) + .collect(); + let last = alignments.len() - 1; + let max_align = alignments[last]; + for case in 0..40 { + let mut leading = Vec::with_capacity(depth); + for level in 0..depth { + // Mix arbitrary modifiers, increasing and decreasing + // alignments, alternating packing, and fully packed trees. + let (packed, align) = match case / 8 { + 0 => { + let packed = rng.gen_range(0..=last); + (packed, rng.gen_range(0..=packed)) + } + 1 => (last, level % alignments.len()), + 2 => (last, last - level % alignments.len()), + 3 => { + let index = if level % 2 == 0 { 0 } else { last }; + (index, index) + } + _ => (0, 0), + }; + let (packed, align) = (alignments[packed], alignments[align]); + let prefix = if level == 0 && case % 4 == 3 { + usize::MAX / 2 + rng.gen_range(0..=2) - 1 + } else { + match rng.gen_range(0..8) { + 0 => 0, + 1 => 1, + 2 => align - 1, + 3 => align, + 4 => align + 1, + 5 => packed - 1, + 6 => packed + 1, + _ => rng.gen_range(0..=2 * max_align), + } + }; + leading.push((nz(packed), nz(align), prefix)); + } + let alignment = alignments[rng.gen_range(0..=last)]; + let elem_size = match case % 8 { + 0 => 0, + 1 => alignment, + 2 => 2 * alignment, + 3 => 3 * alignment, + 4 => 7 * alignment, + 5 => 15 * alignment, + 6 => (usize::MAX / 2 / alignment) * alignment, + _ => (usize::MAX / alignment) * alignment, + }; + check(&leading, elem_size, nz(alignment)); + } + } + } const TEST_SIZE_ROUNDING_ALIGN_AND_PHASE_ALIGN: NonZeroUsize = match NonZeroUsize::new(8) { Some(align) => align, diff --git a/zerocopy/src/layout/nested_reference.rs b/zerocopy/src/layout/nested_reference.rs new file mode 100644 index 0000000000..f13b4e0e10 --- /dev/null +++ b/zerocopy/src/layout/nested_reference.rs @@ -0,0 +1,120 @@ +// 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. + +//! Independent arithmetic for nested `repr(C)` slice DSTs. +//! +//! The layers run from outermost to innermost. Packing caps placement +//! alignment, while every containing layer retains its field's complete size. +//! These descriptors also model arithmetic combinations Rust rejects as types, +//! including simultaneous packing and explicit alignment. No pointer or +//! allocation operation consumes these synthetic layouts. + +// The pinned extraction does not model Option's Try trait. Explicit matches +// also leave the overflow paths visible in this independent reference. +#![allow(clippy::question_mark, clippy::manual_map)] + +/// One containing record, before placing its final field. +/// +/// Plain words make the descriptor's mathematical decoding total. The Rust +/// functions below check the domain explicitly, including zero alignments. +#[derive(Copy, Clone)] +#[cfg_attr(test, derive(Debug))] +pub(super) struct NestedLayer { + pub(super) packed: usize, + pub(super) min_align: usize, + pub(super) prefix_bytes: usize, +} + +/// Returns the least multiple of `align` at least `size`, if representable. +/// +/// Zero alignment returns `None`. The remainder rule is independent of the +/// production padding utility and works for every positive alignment. +/// +/// ```aeneas +/// spec nested_round_up_spec +/// ensures(raw) rounded => rounded.map UScalar.val = +/// if align.val = 0 then none +/// else if LayoutMath.roundUp size.val align.val ≤ Usize.max +/// then some (LayoutMath.roundUp size.val align.val) else none +/// ``` +#[allow(clippy::arithmetic_side_effects)] +pub(super) fn round_up(size: usize, align: usize) -> Option { + if align == 0 { + return None; + } + let remainder = size % align; + if remainder == 0 { + Some(size) + } else { + size.checked_add(align - remainder) + } +} + +fn apply_layer(layer: NestedLayer, size: usize, alignment: usize) -> Option<(usize, usize)> { + if !layer.packed.is_power_of_two() + || !layer.min_align.is_power_of_two() + || layer.min_align > layer.packed + { + return None; + } + let field_align = if alignment < layer.packed { alignment } else { layer.packed }; + let offset = match round_up(layer.prefix_bytes, field_align) { + Some(offset) => offset, + None => return None, + }; + let alignment = if layer.min_align < field_align { field_align } else { layer.min_align }; + let unpadded_size = match offset.checked_add(size) { + Some(size) => size, + None => return None, + }; + let size = match round_up(unpadded_size, alignment) { + Some(size) => size, + None => return None, + }; + Some((size, alignment)) +} + +/// Computes complete recursive size at any element count. +/// +/// `None` means an invalid descriptor or `usize` overflow. An element size of +/// zero is valid at every power-of-two alignment. Sizes above `isize::MAX` +/// remain valid arithmetic results; no allocation is performed here. +/// +/// ```aeneas +/// spec nested_reference_size_spec +/// ensures(raw) result => result.map UScalar.val = +/// NestedReference.checkedSize leading.val elem_size.val leaf_align.val elems.val +/// ``` +#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] +pub(super) fn size_for_metadata( + leading: &[NestedLayer], + elem_size: usize, + leaf_align: usize, + elems: usize, +) -> Option { + if !leaf_align.is_power_of_two() || elem_size % leaf_align != 0 { + return None; + } + let mut state = match elem_size.checked_mul(elems) { + Some(size) => Some((size, leaf_align)), + None => None, + }; + let mut i = leading.len(); + while i != 0 { + i -= 1; + let layer = leading[i]; + state = match state { + Some((size, alignment)) => apply_layer(layer, size, alignment), + None => None, + }; + } + match state { + Some((size, _)) => Some(size), + None => None, + } +} From 381343b7648af8561dc5498805101c589068499b Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 2 Oct 2026 10:36:20 -0400 Subject: [PATCH 12/12] Support splitting packed DSTs through unaligned element views Provide unaligned element views for packed trailing slices and check split behavior without discarding nested padding. Forward SplitAt through ManuallyDrop, add via_no_dynamic_padding and retain via_unaligned as a deprecated forwarding API. Validation: fresh pinned extraction and complete-model golden comparison; independent golden/live Lean builds, required-contract checks, and the transitive axiom audit. Makes progress on #3617 gherrit-pr-id: Gcf7sazu56b7hwm5kb56l7lndntqb7aw2 Agent-authored-by: AI agent acting on joshlf's behalf --- zerocopy/AGENTS.md | 7 + zerocopy/src/split_at.rs | 695 ++++++++++++++---- zerocopy/zerocopy-derive/src/derive/mod.rs | 32 +- .../split_at_repr_c_packed.expected.rs | 22 + .../zerocopy-derive/src/output_tests/mod.rs | 18 +- .../tests/ui/struct.msrv.stderr | 4 +- .../tests/ui/struct.nightly.stderr | 7 +- .../tests/ui/struct.stable.stderr | 7 +- 8 files changed, 615 insertions(+), 177 deletions(-) create mode 100644 zerocopy/zerocopy-derive/src/output_tests/expected/split_at_repr_c_packed.expected.rs diff --git a/zerocopy/AGENTS.md b/zerocopy/AGENTS.md index 2469b82bd6..07090a2e6e 100644 --- a/zerocopy/AGENTS.md +++ b/zerocopy/AGENTS.md @@ -41,6 +41,13 @@ These instructions apply to work in the `zerocopy/` subtree. In Markdown, indent wrapped bullet continuation lines by two spaces and put a blank line after each section heading. +- **Derived traits in tests:** For traits whose documentation requires + derive-only implementation, test fixture types **MUST** use Zerocopy's + derives. Do not bypass a derive rejection with a manual implementation; + instead, test the lower-level internal primitive or extend the derive. + Generated derive-output snapshots and crate-owned built-in implementations + are exempt. + ## Task-Specific Skills Use the applicable skills in addition to these standing rules. Skills compose; diff --git a/zerocopy/src/split_at.rs b/zerocopy/src/split_at.rs index 5e188e5abd..0a98c130d0 100644 --- a/zerocopy/src/split_at.rs +++ b/zerocopy/src/split_at.rs @@ -17,6 +17,12 @@ use crate::pointer::invariant::{Aligned, Exclusive, Invariants, Safe, Shared}; /// This trait generalizes Rust's existing support for splitting slices to /// support slices and slice-based dynamically-sized types ("slice DSTs"). /// +/// For a `repr(C, packed)` or `repr(C, packed(N))` struct, the derive wraps +/// the trailing field's [`Elem`](Self::Elem) in [`Unalign`]. The returned +/// slice can therefore be accessed even when packing misaligns the original +/// elements. Each packed struct in the trailing-field chain adds one wrapper; +/// unpacked structs and [`ManuallyDrop`] forward their trailing type's `Elem`. +/// /// # Implementation /// /// **Do not implement this trait yourself!** Instead, use @@ -38,6 +44,28 @@ use crate::pointer::invariant::{Aligned, Exclusive, Invariants, Safe, Shared}; /// This derive performs a sophisticated, compile-time safety analysis to /// determine whether a type is `SplitAt`. /// +/// A packed struct exposes its trailing elements through `Unalign`: +/// +/// ``` +/// use zerocopy::{FromBytes, SplitAt, Unalign}; +/// # use zerocopy_derive::*; +/// +/// #[derive(FromBytes, IntoBytes, KnownLayout, SplitAt)] +/// #[repr(C, packed)] +/// struct Packet { +/// tag: u8, +/// words: [u16], +/// } +/// +/// let mut bytes = [0u8; 5]; +/// let packet = Packet::mut_from_bytes(&mut bytes[..]).unwrap(); +/// let (left, right): (&mut Packet, &mut [Unalign]) = +/// packet.split_at_mut(1).unwrap().via_into_bytes(); +/// left.tag = 1; +/// right[0] = Unalign::new(0x0202); +/// assert_eq!(bytes, [1, 0, 0, 2, 2]); +/// ``` +/// /// # Safety /// /// This trait does not convey any safety guarantees to code outside this crate. @@ -58,10 +86,20 @@ use crate::pointer::invariant::{Aligned, Exclusive, Invariants, Safe, Shared}; )] // # Safety // -// The trailing slice is well-aligned for its element type. `Self` is `[T]`, or -// a `repr(C)` or `repr(transparent)` slice DST. +// `Self` is a slice, a `repr(C)` (possibly packed) or `repr(transparent)` slice +// DST, or `ManuallyDrop` for some `T: SplitAt`. +// +// `Self::Elem` has the same size, bit validity, and `UnsafeCell` coverage as +// the actual trailing element type. Access through either representation +// preserves the other's validity, including writes through mutable references +// and interior mutation through shared references. For every aligned `Self`, +// its trailing slice's address is aligned for `Self::Elem`. pub unsafe trait SplitAt: KnownLayout { - /// The element type of the trailing slice. + /// The element type exposed by the split's returned slice. + /// + /// For `[T]`, this is `T`. For a struct, this is the trailing field's `Elem`, + /// wrapped in [`Unalign`] if the struct is packed. Nested packed structs + /// produce nested `Unalign` wrappers. type Elem; #[doc(hidden)] @@ -188,10 +226,7 @@ pub unsafe trait SplitAt: KnownLayout { /// Attempts to split `self` in two. /// /// Returns `None` if `l_len` is greater than the length of `self`'s - /// trailing slice, or if the given `l_len` would result in [the trailing - /// padding](KnownLayout#slice-dst-layout) of the left portion overlapping - /// the right portion. - /// + /// trailing slice. /// /// # Examples /// @@ -249,7 +284,8 @@ pub unsafe trait SplitAt: KnownLayout { } } -// SAFETY: `[T]`'s trailing slice is `[T]`, which is trivially aligned. +// SAFETY: `[T]` exposes its own element type, preserving size, validity, and +// `UnsafeCell` coverage. An aligned slice is aligned for its elements. unsafe impl SplitAt for [T] { type Elem = T; @@ -262,6 +298,29 @@ unsafe impl SplitAt for [T] { } } +// SAFETY: `ManuallyDrop` has the same layout and bit validity as `T` [1]. +// Its `KnownLayout` implementation preserves `T`'s metadata and trailing-slice +// layout. Forwarding `T::Elem` therefore preserves its size, validity, and +// alignment guarantees. Its `UnsafeCell` coverage also matches `T`'s, as +// established for `ManuallyDrop` in `impls.rs`; accesses through the forwarded +// element representation preserve validity in both directions. +// +// [1] Per https://doc.rust-lang.org/1.93.0/std/mem/struct.ManuallyDrop.html: +// +// `ManuallyDrop` is guaranteed to have the same layout and bit validity as +// `T` +unsafe impl SplitAt for ManuallyDrop { + type Elem = T::Elem; + + #[inline] + #[allow(dead_code)] + fn only_derive_is_allowed_to_implement_this_trait() + where + Self: Sized, + { + } +} + /// A `T` that has been split into two possibly-overlapping parts. /// /// For some dynamically sized types, the padding that appears after the @@ -270,10 +329,11 @@ unsafe impl SplitAt for [T] { /// requires trailing padding, the trailing padding of the left part of the /// split `T` will overlap the right part. If `T` is a mutable reference or /// permits interior mutation, you must ensure that the left and right parts do -/// not overlap. You can do this at zero-cost using using -/// [`Self::via_immutable`], [`Self::via_into_bytes`], or -/// [`Self::via_unaligned`], or with a dynamic check by using -/// [`Self::via_runtime_check`]. +/// not overlap. Use [`Self::via_into_bytes`] or +/// [`Self::via_no_dynamic_padding`] to establish this without a runtime check, +/// or [`Self::via_runtime_check`] to check a particular split. Shared references +/// to an [`Immutable`] type may overlap; use [`Self::via_immutable`] in that +/// case. #[derive(Debug)] pub struct Split { /// A pointer to the source slice DST. @@ -423,59 +483,100 @@ where (l.as_ref(), r.as_ref()) } - /// Produces the split parts of `self`, using [`Unaligned`] to ensure that - /// it is sound to have concurrent references to both parts. + /// Produces the split parts of `self` using [`Self::via_no_dynamic_padding`]. + #[deprecated(note = "use `Split::via_no_dynamic_padding` instead")] + #[doc(hidden)] + #[must_use = "has no side effects"] + #[inline(always)] + pub fn via_unaligned(self) -> (&'a T, &'a [T::Elem]) + where + T: Unaligned, + { + self.via_no_dynamic_padding() + } + + /// Produces the split parts of `self` using a compile-time assertion to + /// ensure that it is unconditionally sound to have concurrent references to + /// both parts. + /// + /// When possible, you should prefer using [`Self::via_into_bytes`], which + /// ensures this safety property through regular trait bounds. /// /// # Examples /// + /// In the below example, a single byte of padding exists (on most + /// platforms) between `Packet<[u16]>`'s `length` and `body` fields, + /// precluding the use of [`Self::via_into_bytes`]. + /// /// ``` - /// use zerocopy::{SplitAt, FromBytes}; + /// use zerocopy::SplitAt; /// # use zerocopy_derive::*; /// - /// #[derive(SplitAt, FromBytes, KnownLayout, Immutable, Unaligned)] + /// #[derive(SplitAt, KnownLayout)] /// #[repr(C)] - /// struct Packet { + /// struct Packet { /// length: u8, - /// body: [u8], + /// body: B, /// } /// - /// // These bytes encode a `Packet`. - /// let bytes = &[4, 1, 2, 3, 4, 5, 6, 7, 8, 9][..]; - /// - /// let packet = Packet::ref_from_bytes(bytes).unwrap(); + /// let packet: &Packet<[u16]> = &Packet { length: 4, body: [1u16, 2, 3, 4, 5, 6, 7, 8, 9] }; /// /// assert_eq!(packet.length, 4); /// assert_eq!(packet.body, [1, 2, 3, 4, 5, 6, 7, 8, 9]); /// - /// // Attempt to split `packet` at `length`. - /// let split = packet.split_at(packet.length as usize).unwrap(); + /// if let Some(split) = packet.split_at(packet.length as usize) { + /// // Every body length requires no trailing padding. + /// let (packet, rest) = split.via_no_dynamic_padding(); + /// assert_eq!(packet.length, 4); + /// assert_eq!(packet.body, [1, 2, 3, 4]); + /// assert_eq!(rest, [5, 6, 7, 8, 9]); + /// } else { + /// unreachable!("The packet's length field is within its body"); + /// } + /// ``` /// - /// // Use the `Unaligned` bound on `Packet` to prove that it's okay to - /// // return concurrent references to `packet` and `rest`. - /// let (packet, rest) = split.via_unaligned(); + /// # Compile-Time Assertions /// - /// assert_eq!(packet.length, 4); - /// assert_eq!(packet.body, [1, 2, 3, 4]); - /// assert_eq!(rest, [5, 6, 7, 8, 9]); + /// This method rejects at compile-time any type that admits dynamic + /// trailing padding. In the below example, some `split_at` indices would + /// produce left parts whose trailing padding overlaps with the right part: + /// + /// ```compile_fail,E0080 + /// use zerocopy::SplitAt; + /// # use zerocopy_derive::*; + /// + /// #[derive(SplitAt, KnownLayout)] + /// #[repr(C, align(2))] + /// struct Packet { + /// length: u16, + /// body: B, + /// } + /// + /// let packet: &Packet<[u8]> = &Packet { length: 4, body: [1u8, 2, 3, 4, 5, 6, 7, 8, 9] }; + /// + /// if let Some(split) = packet.split_at(packet.length as usize) { + /// let _ = split.via_no_dynamic_padding(); // ⚠ Compile Error! + /// } else { + /// unreachable!("The packet's length field is within its body"); + /// } /// ``` /// - #[doc = codegen_header!("h5", "split_via_unaligned")] + /// If you need to split such types, use [`Self::via_runtime_check`]. + /// + #[doc = codegen_header!("h5", "split_via_no_dynamic_padding")] /// /// See [`Split::via_immutable`](#method.split_via_immutable.codegen). #[must_use = "has no side effects"] #[inline(always)] - pub fn via_unaligned(self) -> (&'a T, &'a [T::Elem]) - where - T: Unaligned, - { - let (l, r) = self.into_ptr().via_unaligned(); + pub fn via_no_dynamic_padding(self) -> (&'a T, &'a [T::Elem]) { + let (l, r) = self.into_ptr().via_no_dynamic_padding(); (l.as_ref(), r.as_ref()) } /// Produces the split parts of `self`, using a dynamic check to ensure that /// it is sound to have concurrent references to both parts. You should /// prefer using [`Self::via_immutable`], [`Self::via_into_bytes`], or - /// [`Self::via_unaligned`], which have no runtime cost. + /// [`Self::via_no_dynamic_padding`], which perform no runtime check. /// /// Note that this check is overly conservative if `T` is [`Immutable`]; for /// some types, this check will reject some splits which @@ -484,52 +585,40 @@ where /// # Examples /// /// ``` - /// use zerocopy::{SplitAt, FromBytes, IntoBytes, network_endian::U16}; + /// use zerocopy::SplitAt; /// # use zerocopy_derive::*; /// - /// #[derive(SplitAt, FromBytes, KnownLayout, Immutable, Debug)] + /// #[derive(SplitAt, KnownLayout)] /// #[repr(C, align(2))] - /// struct Packet { - /// length: U16, - /// body: [u8], + /// struct Packet { + /// length: u16, + /// body: B, /// } /// - /// // These bytes encode a `Packet`. - /// let bytes = [ - /// 4u16.to_be(), - /// 1u16.to_be(), - /// 2u16.to_be(), - /// 3u16.to_be(), - /// 4u16.to_be() - /// ]; - /// - /// let packet = Packet::ref_from_bytes(bytes.as_bytes()).unwrap(); + /// let packet: &Packet<[u8]> = &Packet { length: 4, body: [1u8, 2, 3, 4, 5, 6, 7, 8, 9] }; /// /// assert_eq!(packet.length, 4); - /// assert_eq!(packet.body, [0, 1, 0, 2, 0, 3, 0, 4]); - /// - /// // Attempt to split `packet` at `length`. - /// let split = packet.split_at(packet.length.into()).unwrap(); + /// assert_eq!(packet.body, [1, 2, 3, 4, 5, 6, 7, 8, 9]); /// - /// // Use a dynamic check to prove that it's okay to return concurrent - /// // references to `packet` and `rest`. - /// let (packet, rest) = split.via_runtime_check().unwrap(); + /// // The two-byte prefix and four-byte body need no trailing padding. + /// if let Some(split) = packet.split_at(packet.length as usize) { + /// if let Ok((packet, rest)) = split.via_runtime_check() { + /// assert_eq!(packet.length, 4); + /// assert_eq!(packet.body, [1, 2, 3, 4]); + /// assert_eq!(rest, [5, 6, 7, 8, 9]); + /// } else { + /// unreachable!("A four-byte body requires no trailing padding"); + /// } + /// } else { + /// unreachable!("The packet's length field is within its body"); + /// } /// - /// assert_eq!(packet.length, 4); - /// assert_eq!(packet.body, [0, 1, 0, 2]); - /// assert_eq!(rest, [0, 3, 0, 4]); - /// - /// // Attempt to split `packet` at `length - 1`. - /// let idx = packet.length.get() - 1; - /// let split = packet.split_at(idx as usize).unwrap(); - /// - /// // Attempt (and fail) to use a dynamic check to prove that it's okay - /// // to return concurrent references to `packet` and `rest`. Note that - /// // this is a case of `via_runtime_check` being overly conservative. - /// // Although the left and right parts indeed overlap, the `Immutable` - /// // bound ensures that concurrently referencing these overlapping - /// // parts is sound. - /// assert!(split.via_runtime_check().is_err()); + /// // A three-byte body needs a padding byte that would overlap `rest`. + /// if let Some(split) = packet.split_at((packet.length - 1) as usize) { + /// assert!(split.via_runtime_check().is_err()); + /// } else { + /// unreachable!("One less than the packet's length is within its body"); + /// } /// ``` /// #[doc = codegen_section!( @@ -665,101 +754,135 @@ where (l.as_mut(), r.as_mut()) } - /// Produces the split parts of `self`, using [`Unaligned`] to ensure that - /// it is sound to have concurrent references to both parts. + /// Produces the split parts of `self` using [`Self::via_no_dynamic_padding`]. + #[deprecated(note = "use `Split::via_no_dynamic_padding` instead")] + #[doc(hidden)] + #[must_use = "has no side effects"] + #[inline(always)] + pub fn via_unaligned(self) -> (&'a mut T, &'a mut [T::Elem]) + where + T: Unaligned, + { + self.via_no_dynamic_padding() + } + + /// Produces the split parts of `self` using a compile-time assertion to + /// ensure that it is unconditionally sound to have concurrent references to + /// both parts. + /// + /// When possible, you should prefer using [`Self::via_into_bytes`], which + /// ensures this safety property through regular trait bounds. /// /// # Examples /// + /// In the below example, a single byte of padding exists (on most + /// platforms) between `Packet<[u16]>`'s `length` and `body` fields, + /// precluding the use of [`Self::via_into_bytes`]. + /// /// ``` - /// use zerocopy::{SplitAt, FromBytes}; + /// use zerocopy::SplitAt; /// # use zerocopy_derive::*; /// - /// #[derive(SplitAt, FromBytes, KnownLayout, IntoBytes, Unaligned)] + /// #[derive(SplitAt, KnownLayout)] /// #[repr(C)] /// struct Packet { /// length: u8, /// body: B, /// } /// - /// // These bytes encode a `Packet`. - /// let mut bytes = &mut [4, 1, 2, 3, 4, 5, 6, 7, 8, 9][..]; - /// - /// let packet = Packet::<[u8]>::mut_from_bytes(bytes).unwrap(); + /// let packet: &mut Packet<[u16]> = &mut Packet { length: 4, body: [1u16, 2, 3, 4, 5, 6, 7, 8, 9] }; /// /// assert_eq!(packet.length, 4); /// assert_eq!(packet.body, [1, 2, 3, 4, 5, 6, 7, 8, 9]); /// - /// { - /// // Attempt to split `packet` at `length`. - /// let split = packet.split_at_mut(packet.length as usize).unwrap(); - /// - /// // Use the `Unaligned` bound on `Packet` to prove that it's okay to - /// // return concurrent references to `packet` and `rest`. - /// let (packet, rest) = split.via_unaligned(); - /// + /// // Every body length requires no trailing padding. + /// if let Some(split) = packet.split_at_mut(packet.length as usize) { + /// let (packet, rest) = split.via_no_dynamic_padding(); /// assert_eq!(packet.length, 4); /// assert_eq!(packet.body, [1, 2, 3, 4]); /// assert_eq!(rest, [5, 6, 7, 8, 9]); - /// /// rest.fill(0); + /// } else { + /// unreachable!("The packet's length field is within its body"); /// } /// /// assert_eq!(packet.length, 4); /// assert_eq!(packet.body, [1, 2, 3, 4, 0, 0, 0, 0, 0]); /// ``` /// + /// # Compile-Time Assertions + /// + /// This method rejects at compile-time any type that admits dynamic + /// trailing padding. In the below example, some `split_at` indices would + /// produce left parts whose trailing padding overlaps with the right part: + /// + /// ```compile_fail,E0080 + /// use zerocopy::SplitAt; + /// # use zerocopy_derive::*; + /// + /// #[derive(SplitAt, KnownLayout)] + /// #[repr(C, align(2))] + /// struct Packet { + /// length: u16, + /// body: B, + /// } + /// + /// let packet: &mut Packet<[u8]> = &mut Packet { length: 4, body: [1u8, 2, 3, 4, 5, 6, 7, 8, 9] }; + /// + /// if let Some(split) = packet.split_at_mut(packet.length as usize) { + /// let _ = split.via_no_dynamic_padding(); // ⚠ Compile Error! + /// } else { + /// unreachable!("The packet's length field is within its body"); + /// } + /// ``` + /// + /// If you need to split such types, use [`Self::via_runtime_check`]. + /// /// # Code Generation /// /// See [`Split::via_immutable`](#method.split_via_immutable.codegen). #[must_use = "has no side effects"] #[inline(always)] - pub fn via_unaligned(self) -> (&'a mut T, &'a mut [T::Elem]) - where - T: Unaligned, - { - let (l, r) = self.into_ptr().via_unaligned(); + pub fn via_no_dynamic_padding(self) -> (&'a mut T, &'a mut [T::Elem]) { + let (l, r) = self.into_ptr().via_no_dynamic_padding(); (l.as_mut(), r.as_mut()) } /// Produces the split parts of `self`, using a dynamic check to ensure that /// it is sound to have concurrent references to both parts. You should - /// prefer using [`Self::via_into_bytes`] or [`Self::via_unaligned`], which - /// have no runtime cost. + /// prefer using [`Self::via_into_bytes`] or + /// [`Self::via_no_dynamic_padding`], which perform no runtime check. /// /// # Examples /// /// ``` - /// use zerocopy::{SplitAt, FromBytes}; + /// use zerocopy::SplitAt; /// # use zerocopy_derive::*; /// - /// #[derive(SplitAt, FromBytes, KnownLayout, IntoBytes, Debug)] - /// #[repr(C)] + /// #[derive(SplitAt, KnownLayout)] + /// #[repr(C, align(2))] /// struct Packet { - /// length: u8, + /// length: u16, /// body: B, /// } /// - /// // These bytes encode a `Packet`. - /// let mut bytes = &mut [4, 1, 2, 3, 4, 5, 6, 7, 8, 9][..]; - /// - /// let packet = Packet::<[u8]>::mut_from_bytes(bytes).unwrap(); + /// let packet: &mut Packet<[u8]> = &mut Packet { length: 4, body: [1u8, 2, 3, 4, 5, 6, 7, 8, 9] }; /// /// assert_eq!(packet.length, 4); /// assert_eq!(packet.body, [1, 2, 3, 4, 5, 6, 7, 8, 9]); /// - /// { - /// // Attempt to split `packet` at `length`. - /// let split = packet.split_at_mut(packet.length as usize).unwrap(); - /// - /// // Use a dynamic check to prove that it's okay to return concurrent - /// // references to `packet` and `rest`. - /// let (packet, rest) = split.via_runtime_check().unwrap(); - /// - /// assert_eq!(packet.length, 4); - /// assert_eq!(packet.body, [1, 2, 3, 4]); - /// assert_eq!(rest, [5, 6, 7, 8, 9]); - /// - /// rest.fill(0); + /// // The two-byte prefix and four-byte body need no trailing padding. + /// if let Some(split) = packet.split_at_mut(packet.length as usize) { + /// if let Ok((packet, rest)) = split.via_runtime_check() { + /// assert_eq!(packet.length, 4); + /// assert_eq!(packet.body, [1, 2, 3, 4]); + /// assert_eq!(rest, [5, 6, 7, 8, 9]); + /// rest.fill(0); + /// } else { + /// unreachable!("A four-byte body requires no trailing padding"); + /// } + /// } else { + /// unreachable!("The packet's length field is within its body"); /// } /// /// assert_eq!(packet.length, 4); @@ -860,26 +983,44 @@ where unsafe { self.via_unchecked() } } - /// Produces the split parts of `self`, using [`Unaligned`] to ensure that - /// it is sound to have concurrent references to both parts. + /// Produces the split parts of `self` after the conservative layout + /// predicate establishes that `T` never requires dynamic trailing padding. #[inline(always)] - fn via_unaligned(self) -> (Ptr<'a, T, I>, Ptr<'a, [T::Elem], I>) - where - T: Unaligned, - { - // SAFETY: By `T: SplitAt + Unaligned`, `T` is either a slice or a - // `repr(C)` or `repr(transparent)` slice DST that is well-aligned at - // any address and length. If `T` is a slice DST with alignment 1, - // `repr(C)` or `repr(transparent)` ensures that no padding is placed - // after the final element of the trailing slice. Consequently, `T` can - // be split into strictly non-overlapping parts any any index. + fn via_no_dynamic_padding(self) -> (Ptr<'a, T, I>, Ptr<'a, [T::Elem], I>) { + static_assert!( + T: ?Sized + KnownLayout => !T::LAYOUT.requires_dynamic_padding(), + "`Split::via_no_dynamic_padding` cannot be used with a type whose layout may require dynamic trailing padding; use `Split::via_runtime_check` instead" + ); + + // SAFETY: The assertion establishes that `requires_dynamic_padding()` + // is false. By that method's guarantee, every valid metadata—including + // `self.l_len()`, which is valid by `Split`'s invariant—describes an + // object with no trailing padding. By the postcondition of + // `PtrInner::split_at_unchecked`, the two returned referents are then + // contiguous and non-overlapping. Since the source already conforms to + // `I::Aliasing`, splitting it into disjoint byte ranges preserves that + // invariant: under `Exclusive`, neither result aliases or accesses the + // other's bytes; under `Shared`, interior mutation through one result + // cannot mutate bytes pointed to by the other. This satisfies Rust's + // reference aliasing rules [1]. + // + // [1] Per https://doc.rust-lang.org/1.93.1/std/ptr/index.html#pointer-to-reference-conversion: + // + // When creating a mutable reference, then while this reference + // exists, the memory it points to must not get accessed (read or + // written) through any other pointer or reference not derived from + // this reference. + // + // When creating a shared reference, then while this reference + // exists, the memory it points to must not get mutated (except inside + // `UnsafeCell`). unsafe { self.via_unchecked() } } /// Produces the split parts of `self`, using a dynamic check to ensure that /// it is sound to have concurrent references to both parts. You should /// prefer using [`Self::via_immutable`], [`Self::via_into_bytes`], or - /// [`Self::via_unaligned`], which have no runtime cost. + /// [`Self::via_no_dynamic_padding`], which perform no runtime check. #[inline(always)] fn via_runtime_check(self) -> Result<(Ptr<'a, T, I>, Ptr<'a, [T::Elem], I>), Self> { let l_len = self.l_len(); @@ -918,11 +1059,10 @@ where // Lemma 0: `left` and `right` conform to the aliasing invariant // `I::Aliasing`. Proof: If `I::Aliasing` is `Exclusive` or `T` permits // interior mutation, the caller promises that the left part has no - // trailing padding. Consequently, by post-condition on - // `PtrInner::split_at_unchecked`, - // there is no trailing padding after `left`'s final element that would - // overlap into `right`. If `I::Aliasing` is shared and `T` forbids interior - // mutation, then overlap between their referents is permissible. + // trailing padding. Consequently, by the postcondition of + // `PtrInner::split_at_unchecked`, `left` and `right` do not overlap. + // If `I::Aliasing` is shared and `T` forbids interior mutation, then + // overlap between their referents is permissible. // SAFETY: // 0. `left` conforms to the aliasing invariant of `I::Aliasing`, by Lemma 0. @@ -936,17 +1076,19 @@ where // SAFETY: // 0. `right` conforms to the aliasing invariant of `I::Aliasing`, by Lemma // 0. - // 1. `right` conforms to the alignment invariant of `I::Alignment, because - // if `ptr` with `I::Alignment = Aligned`, then by invariant on `T: - // SplitAt`, the trailing slice of `ptr` (from which `right` is derived) - // will also be well-aligned. + // 1. `right` conforms to the alignment invariant of `I::Alignment`: + // if the source is aligned, `T: SplitAt` guarantees that its + // trailing slice's address is aligned for `T::Elem`. Advancing by + // whole elements preserves that alignment. // 2. `right` conforms to the validity invariant of `I::Validity`, - // because `right: [T::Elem]` is derived from the trailing slice of - // `ptr`, which, by contract on `T: SplitAt::Elem`, has type - // `[T::Elem]`. The `left` part cannot be used to invalidate `right`, - // because the caller promises that if `I::Aliasing` is `Exclusive` - // or `T` permits interior mutation, then the left part has no - // trailing padding and thus the parts will be non-overlapping. + // because `T: SplitAt` guarantees that `T::Elem` has the size and + // bit validity of the original trailing element. It also guarantees + // compatible shared and mutable access, so writes through `right` + // preserve the original elements' validity and interior mutation + // respects the original `UnsafeCell` coverage. The left part + // cannot invalidate `right`: when the references are exclusive or + // permit interior mutation, the caller guarantees no trailing + // padding and hence no overlap. let right = unsafe { Ptr::from_inner(right) }; (left, right) @@ -955,6 +1097,185 @@ where #[cfg(test)] mod tests { + use core::{cell::Cell, mem::ManuallyDrop}; + + use crate::{Immutable, KnownLayout, SplitAt, Unalign, Unaligned}; + + #[derive(KnownLayout, SplitAt, Immutable, Unaligned)] + #[repr(C, packed)] + struct Packed { + prefix: u8, + tail: ManuallyDrop, + } + + #[derive(KnownLayout, SplitAt, Immutable)] + #[repr(C, packed(2))] + struct Packed2 { + prefix: u8, + tail: ManuallyDrop, + } + + #[derive(KnownLayout, SplitAt, Immutable)] + #[repr(C)] + struct Inner { + prefix: u32, + tail: T, + } + + #[test] + fn test_split_at_packed() { + use crate::{FromBytes, IntoBytes}; + + #[derive(FromBytes, IntoBytes, KnownLayout, SplitAt, Immutable, Unaligned)] + #[repr(C, packed)] + struct Packet { + prefix: u8, + tail: [u16], + } + + // The tail starts at an odd address, exercising unaligned reads and + // writes on targets where `u16` has alignment 2. + #[repr(align(8))] + struct Bytes([u8; 9]); + + for i in 0..=4 { + let mut bytes = Bytes([1; 9]); + let packet = Packet::ref_from_bytes(&bytes.0).unwrap(); + let (left, right): (&Packet, &[Unalign]) = + packet.split_at(i).unwrap().via_immutable(); + assert_eq!(core::mem::size_of_val(left), 1 + 2 * i); + assert_eq!(right.len(), 4 - i); + assert!(right.iter().all(|elem| elem.get() == 0x0101)); + assert!(left.split_at(i).is_some()); + assert!(left.split_at(i + 1).is_none()); + assert!(packet.split_at(5).is_none()); + + let (_, right) = packet.split_at(i).unwrap().via_into_bytes(); + assert_eq!(right.len(), 4 - i); + let (_, right) = packet.split_at(i).unwrap().via_no_dynamic_padding(); + assert_eq!(right.len(), 4 - i); + + let packet = Packet::mut_from_bytes(&mut bytes.0).unwrap(); + let (left, right): (&mut Packet, &mut [Unalign]) = + packet.split_at_mut(i).unwrap().via_runtime_check().ok().unwrap(); + left.prefix = 2; + for elem in right { + *elem = Unalign::new(0x0202); + } + assert_eq!(bytes.0[0], 2); + assert!(bytes.0[1..1 + 2 * i].iter().all(|&byte| byte == 1)); + assert!(bytes.0[1 + 2 * i..].iter().all(|&byte| byte == 2)); + } + } + + #[test] + fn test_split_at_nested_packed() { + // A packing factor greater than 1 still exposes `Unalign` elements, + // including when the factor is below the original element alignment. + let mut words = Packed2 { prefix: 0, tail: ManuallyDrop::new([1u32, 2, 3]) }; + let dst: &mut Packed2<[u32]> = &mut words; + let (left, right): (&mut _, &mut [Unalign]) = + dst.split_at_mut(1).unwrap().via_no_dynamic_padding(); + left.prefix = 4; + right[0] = Unalign::new(5); + let dst: &Packed2<[u32]> = &words; + let (left, right) = dst.split_at(0).unwrap().via_immutable(); + assert_eq!(left.prefix, 4); + assert_eq!(right[0].get(), 1); + assert_eq!(right[1].get(), 5); + assert_eq!(right[2].get(), 3); + + let mut packet = Inner { + prefix: 0, + tail: Packed2 { + prefix: 1, + tail: ManuallyDrop::new(Packed { + prefix: 2, + tail: ManuallyDrop::new([3u16, 4, 5]), + }), + }, + }; + let dst: &Inner>> = &packet; + let (left, right): (&_, &[Unalign>]) = + dst.split_at(1).unwrap().via_immutable(); + assert_eq!(left.prefix, 0); + assert_eq!(right.len(), 2); + assert_eq!(right[0].get().get(), 4); + assert_eq!(right[1].get().get(), 5); + + // Split the packed field separately: the unpacked outer struct's + // padding does not constrain this split. + let dst: &mut Packed2> = &mut packet.tail; + let (left, right): (&mut _, &mut [Unalign>]) = + dst.split_at_mut(1).unwrap().via_no_dynamic_padding(); + left.prefix = 6; + right[0] = Unalign::new(Unalign::new(7)); + let dst: &Inner>> = &packet; + let (_, right) = dst.split_at(0).unwrap().via_immutable(); + assert_eq!(right[0].get().get(), 3); + assert_eq!(right[1].get().get(), 7); + assert_eq!(right[2].get().get(), 5); + } + + #[test] + fn test_split_at_packed_preserves_inner_padding() { + for i in 0..=4 { + let mut packet = Packed { + prefix: 1, + tail: ManuallyDrop::new(Inner { prefix: 0, tail: [2u8, 3, 4, 5] }), + }; + let dst: &Packed> = &packet; + let (left, right): (&_, &[Unalign]) = dst.split_at(i).unwrap().via_immutable(); + assert_eq!(right.len(), 4 - i); + assert_eq!(right.first().map(Unalign::get), [2, 3, 4, 5].get(i).copied()); + + // The inner `u32` prefix makes the tail start at byte 5 of the + // packed wrapper. Its size may still include inner padding. + let has_padding = core::mem::size_of_val(left) != 5 + i; + assert_eq!(dst.split_at(i).unwrap().via_runtime_check().is_err(), has_padding); + + let dst: &mut Packed> = &mut packet; + let split = dst.split_at_mut(i).unwrap().via_runtime_check(); + assert_eq!(split.is_err(), has_padding); + if let Ok((left, right)) = split { + left.prefix = 6; + for elem in right { + *elem = Unalign::new(7); + } + let dst: &Packed> = &packet; + let (left, right) = dst.split_at(i).unwrap().via_immutable(); + assert_eq!(left.prefix, 6); + assert!(right.iter().all(|elem| elem.get() == 7)); + } + } + } + + #[test] + fn test_split_at_packed_interior_mutation_and_zsts() { + let packet = Packed { + prefix: 0, + tail: ManuallyDrop::new([Cell::new(1u8), Cell::new(2), Cell::new(3)]), + }; + let dst: &Packed<[Cell]> = &packet; + let (left, right): (&_, &[Unalign>]) = + dst.split_at(1).unwrap().via_no_dynamic_padding(); + left.tail[0].set(4); + right[0].try_deref().unwrap().set(5); + assert_eq!(dst.tail[0].get(), 4); + assert_eq!(dst.tail[1].get(), 5); + assert_eq!(dst.tail[2].get(), 3); + + let mut packet = Packed { prefix: 0, tail: ManuallyDrop::new([(); 3]) }; + for i in 0..=3 { + let dst: &mut Packed<[()]> = &mut packet; + let (left, right): (&mut _, &mut [Unalign<()>]) = + dst.split_at_mut(i).unwrap().via_no_dynamic_padding(); + assert_eq!(left.tail.len(), i); + assert_eq!(right.len(), 3 - i); + assert_eq!(core::mem::size_of_val(left), 1); + } + } + #[cfg(feature = "derive")] #[test] fn test_split_at() { @@ -1014,6 +1335,8 @@ mod tests { trailing: [u8], } + assert!(SliceDst::LAYOUT.requires_dynamic_padding()); + const N: usize = 16; let arr = [1u16; N]; @@ -1073,21 +1396,79 @@ mod tests { assert_eq!(r, &[4]); } #[test] + #[allow(deprecated)] fn test_split_at_via_unaligned() { - use crate::{FromBytes, Immutable, IntoBytes, KnownLayout, SplitAt, Unaligned}; - #[derive(FromBytes, KnownLayout, SplitAt, IntoBytes, Immutable, Unaligned)] + use crate::{Immutable, KnownLayout, Split, SplitAt, Unaligned}; + + fn via_unaligned<'a, T>(split: Split<&'a T>) -> (&'a T, &'a [T::Elem]) + where + T: ?Sized + SplitAt + Unaligned, + { + split.via_unaligned() + } + + fn via_unaligned_mut<'a, T>(split: Split<&'a mut T>) -> (&'a mut T, &'a mut [T::Elem]) + where + T: ?Sized + SplitAt + Unaligned, + { + split.via_unaligned() + } + + #[derive(KnownLayout, SplitAt, Immutable, Unaligned)] #[repr(C)] - struct Packet { - length: u8, - body: [u8], + struct Packet { + prefix: [u8; 2], + body: B, } - let arr = [1, 2, 3, 4]; - let packet = Packet::ref_from_bytes(&arr[..]).unwrap(); + // Exercise generic callers using the original `T: Unaligned` bound. + assert!(!Packet::<[[u8; 2]]>::LAYOUT.requires_dynamic_padding()); + let packet = Packet { prefix: [0, 1], body: [[2, 3], [4, 5], [6, 7]] }; + let packet: &Packet<[[u8; 2]]> = &packet; let split = packet.split_at(2).unwrap(); - let (l, r) = split.via_unaligned(); - assert_eq!(l.length, 1); - assert_eq!(r, &[4]); + let (l, r) = via_unaligned(split); + assert_eq!(l.body, [[2, 3], [4, 5]]); + assert_eq!(r, &[[6, 7]]); + + let mut packet = Packet { prefix: [0, 1], body: [[2, 3], [4, 5], [6, 7]] }; + { + let packet: &mut Packet<[[u8; 2]]> = &mut packet; + let split = packet.split_at_mut(2).unwrap(); + let (l, r) = via_unaligned_mut(split); + l.body[0] = [8, 9]; + r[0] = [10, 11]; + } + assert_eq!(packet.body, [[8, 9], [4, 5], [10, 11]]); + } + + #[test] + fn test_split_at_via_no_dynamic_padding() { + use core::cell::Cell; + + use crate::SplitAt; + + // `u16` does not implement `Unaligned`, but `[u16]` never requires + // dynamic trailing padding. + let words = [1u16, 2, 3, 4]; + let split = SplitAt::split_at(&words[..], 2).unwrap(); + let (left, right) = split.via_no_dynamic_padding(); + assert_eq!(left, [1, 2]); + assert_eq!(right, [3, 4]); + + let mut words = [1u16, 2, 3, 4]; + let split = SplitAt::split_at_mut(&mut words[..], 2).unwrap(); + let (left, right) = split.via_no_dynamic_padding(); + left[0] = 5; + right[0] = 6; + assert_eq!(words, [5, 2, 6, 4]); + + // Exercise the safety-sensitive shared route with interior mutation. + let cells = [Cell::new(1u16), Cell::new(2), Cell::new(3)]; + let split = SplitAt::split_at(&cells[..], 2).unwrap(); + let (left, right) = split.via_no_dynamic_padding(); + left[0].set(4); + right[0].set(5); + assert_eq!([cells[0].get(), cells[1].get(), cells[2].get()], [4, 2, 5]); } } diff --git a/zerocopy/zerocopy-derive/src/derive/mod.rs b/zerocopy/zerocopy-derive/src/derive/mod.rs index 174434561e..6823157bc4 100644 --- a/zerocopy/zerocopy-derive/src/derive/mod.rs +++ b/zerocopy/zerocopy-derive/src/derive/mod.rs @@ -103,13 +103,6 @@ pub(crate) fn derive_split_at(ctx: &Ctx, _top_level: Trait) -> Result Result::Elem); + let elem = + if repr.get_packed().is_some() { quote!(#zerocopy_crate::Unalign<#elem>) } else { elem }; + + // SAFETY: The checks above require `repr(C)` or `repr(transparent)`, and + // `FieldBounds::TRAILING_SELF` requires the trailing field to be `SplitAt`. + // Its `Elem` therefore has the size, bit validity, and `UnsafeCell` + // coverage of the actual trailing element. For an unpacked struct, the + // trailing field is aligned, so its `SplitAt` alignment guarantee applies. + // For a packed struct, wrapping its `Elem` in `Unalign` preserves those + // representation properties and gives alignment 1. Thus the trailing + // slice can be accessed as `[#elem]` even if packing misaligns its field. + // Packing does not change the field's internal layout [1], so its element + // size and byte offsets are preserved in either case. + // + // [1] Per https://doc.rust-lang.org/1.93.0/reference/type-layout.html#layout.repr.alignment.intro: + // + // `packed` may also alter the padding between fields (although it will + // not alter the padding inside of any field). Ok(ImplBlockBuilder::new(ctx, &ctx.ast.data, Trait::SplitAt, FieldBounds::TRAILING_SELF) .inner_extras(quote! { - type Elem = <#trailing_field as #zerocopy_crate::SplitAt>::Elem; + type Elem = #elem; }) .build()) } diff --git a/zerocopy/zerocopy-derive/src/output_tests/expected/split_at_repr_c_packed.expected.rs b/zerocopy/zerocopy-derive/src/output_tests/expected/split_at_repr_c_packed.expected.rs new file mode 100644 index 0000000000..e6c330bf5e --- /dev/null +++ b/zerocopy/zerocopy-derive/src/output_tests/expected/split_at_repr_c_packed.expected.rs @@ -0,0 +1,22 @@ +#[allow( + deprecated, + private_bounds, + non_local_definitions, + non_camel_case_types, + non_upper_case_globals, + non_snake_case, + non_ascii_idents, + clippy::missing_inline_in_public_items, +)] +#[deny(ambiguous_associated_items)] +#[automatically_derived] +const _: () = { + unsafe impl ::zerocopy::SplitAt for Foo + where + Self: Copy, + T: ::zerocopy::SplitAt, + { + fn only_derive_is_allowed_to_implement_this_trait() {} + type Elem = ::zerocopy::Unalign<::Elem>; + } +}; diff --git a/zerocopy/zerocopy-derive/src/output_tests/mod.rs b/zerocopy/zerocopy-derive/src/output_tests/mod.rs index ad4eb29e04..c788845d4a 100644 --- a/zerocopy/zerocopy-derive/src/output_tests/mod.rs +++ b/zerocopy/zerocopy-derive/src/output_tests/mod.rs @@ -1004,13 +1004,27 @@ fn test_split_at() { } expands to "expected/split_at_repr_transparent.expected.rs" } + test! { + SplitAt { + #[repr(C, packed)] + struct Foo(T) where Self: Copy; + } expands to "expected/split_at_repr_c_packed.expected.rs" + } + + test! { + SplitAt { + #[repr(C, packed(2))] + struct Foo(T) where Self: Copy; + } expands to "expected/split_at_repr_c_packed.expected.rs" + } + test! { SplitAt { #[repr(packed)] struct Foo(T) where Self: Copy; } expands to { ::core::compile_error! { - "must not have #[repr(packed)] attribute" + "must have #[repr(C)] or #[repr(transparent)] in order to guarantee this type's layout is splitable" } } no_build } @@ -1021,7 +1035,7 @@ fn test_split_at() { struct Foo(T) where Self: Copy; } expands to { ::core::compile_error! { - "must not have #[repr(packed)] attribute" + "must have #[repr(C)] or #[repr(transparent)] in order to guarantee this type's layout is splitable" } } no_build } diff --git a/zerocopy/zerocopy-derive/tests/ui/struct.msrv.stderr b/zerocopy/zerocopy-derive/tests/ui/struct.msrv.stderr index 6358819d63..ad2db50c39 100644 --- a/zerocopy/zerocopy-derive/tests/ui/struct.msrv.stderr +++ b/zerocopy/zerocopy-derive/tests/ui/struct.msrv.stderr @@ -317,9 +317,9 @@ error[E0277]: the trait bound `SplitAtNotKnownLayout: zerocopy_renamed::KnownLay | ^^^^^^^ the trait `zerocopy_renamed::KnownLayout` is not implemented for `SplitAtNotKnownLayout` | note: required by a bound in `SplitAt` - --> $WORKSPACE/src/split_at.rs:63:27 + --> $WORKSPACE/src/split_at.rs:97:27 | -63 | pub unsafe trait SplitAt: KnownLayout { +97 | pub unsafe trait SplitAt: KnownLayout { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `SplitAt` = note: this error originates in the derive macro `SplitAt` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/zerocopy/zerocopy-derive/tests/ui/struct.nightly.stderr b/zerocopy/zerocopy-derive/tests/ui/struct.nightly.stderr index 49b44a751b..07e443a31b 100644 --- a/zerocopy/zerocopy-derive/tests/ui/struct.nightly.stderr +++ b/zerocopy/zerocopy-derive/tests/ui/struct.nightly.stderr @@ -484,7 +484,7 @@ help: the trait `zerocopy_renamed::KnownLayout` is not implemented for `SplitAtN $IMPLEMENTATION_SAMPLES and $OTHER_TYPES others note: required by a bound in `SplitAt` - --> src/split_at.rs:63:0 + --> src/split_at.rs:97:0 = note: this error originates in the derive macro `SplitAt` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0277]: the trait bound `u8: SplitAt` is not satisfied @@ -502,9 +502,12 @@ help: the following other types implement trait `SplitAt` ... 363 | #[derive(SplitAt, KnownLayout)] | ^^^^^^^ `SplitAtSized` - --> src/split_at.rs:253:0 + --> src/split_at.rs:289:0 | = note: `[T]` + ::: src/split_at.rs:312:0 + | + = note: `ManuallyDrop` = note: this error originates in the derive macro `SplitAt` (in Nightly builds, run with -Z macro-backtrace for more info) help: add `#![feature(trivial_bounds)]` to the crate attributes to enable | diff --git a/zerocopy/zerocopy-derive/tests/ui/struct.stable.stderr b/zerocopy/zerocopy-derive/tests/ui/struct.stable.stderr index 27038d9b49..1ae10d808c 100644 --- a/zerocopy/zerocopy-derive/tests/ui/struct.stable.stderr +++ b/zerocopy/zerocopy-derive/tests/ui/struct.stable.stderr @@ -428,7 +428,7 @@ help: the trait `zerocopy_renamed::KnownLayout` is not implemented for `SplitAtN $IMPLEMENTATION_SAMPLES and $OTHER_TYPES others note: required by a bound in `SplitAt` - --> src/split_at.rs:63:0 + --> src/split_at.rs:97:0 = note: this error originates in the derive macro `SplitAt` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0277]: the trait bound `u8: SplitAt` is not satisfied @@ -446,9 +446,12 @@ help: the following other types implement trait `SplitAt` ... 363 | #[derive(SplitAt, KnownLayout)] | ^^^^^^^ `SplitAtSized` - --> src/split_at.rs:253:0 + --> src/split_at.rs:289:0 | = note: `[T]` + ::: src/split_at.rs:312:0 + | + = note: `ManuallyDrop` = note: this error originates in the derive macro `SplitAt` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0277]: the trait bound `AU16: zerocopy_renamed::Unaligned` is not satisfied