From 60b9d81f71d20b07ce0cf245b73d66dc59574b05 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 9 Nov 2026 06:46:53 -0800 Subject: [PATCH] fix: enforce release state invariants and macro hygiene - Represent scaled determinant products with explicit empty, nonzero, zero, and non-finite states while preserving arithmetic order. - Parse release handoffs into draft and publication variants with validated asset identities and fixed local transport paths. - Reject mismatched handoffs and malformed owner digests before effects. - Keep matrix dispatch error arms independent of caller-defined names. - Consolidate Rust imports and shorten repeated implementation paths. Refs #333 --- benches/common/angle.rs | 5 +- benches/common/interval_solve.rs | 3 +- benches/exact.rs | 3 +- benches/interval.rs | 5 +- benches/interval_solve.rs | 3 +- benches/public_api.rs | 3 +- benches/rational_solve.rs | 3 +- benches/vs_linalg.rs | 16 +- src/lib.rs | 12 +- src/polynomial.rs | 5 +- src/scaled_product.rs | 123 ++++---- src/vector.rs | 14 +- tests/column_basis.rs | 6 +- tests/interval_solve.rs | 3 +- tests/prelude_exports.rs | 14 +- tests/proptest_vector_angles.rs | 5 +- tests/rational_allocations.rs | 1 + tests/tooling/test_candidate_transfer.py | 49 +++- tests/tooling/test_release_context.py | 11 +- tooling/release_candidate.py | 348 ++++++++++++++++------- 20 files changed, 421 insertions(+), 211 deletions(-) diff --git a/benches/common/angle.rs b/benches/common/angle.rs index 4020509..4182101 100644 --- a/benches/common/angle.rs +++ b/benches/common/angle.rs @@ -2,6 +2,7 @@ //! Independently checked fixtures shared by angle benchmarks and their tests. +use core::array::from_fn; use core::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; use la_stack::{Vector, VectorAngle}; @@ -104,8 +105,8 @@ pub(crate) fn fixtures() -> [AngleInput; 14] { let alternating_reference = (4.0 * (support - 1.0)).sqrt().atan2(support - 2.0); // Deterministic varied dense coordinates, independently evaluated with // integer Gram products (no production minor or compensated reduction). - let dense_left = core::array::from_fn(|i| i32::try_from(i + 1).or_abort("small dimension")); - let dense_varied = core::array::from_fn(|i| { + let dense_left = from_fn(|i| i32::try_from(i + 1).or_abort("small dimension")); + let dense_varied = from_fn(|i| { i32::try_from(D - i).or_abort("small dimension") * if i % 2 == 0 { 1 } else { -1 } }); let dot: i32 = dense_left diff --git a/benches/common/interval_solve.rs b/benches/common/interval_solve.rs index 30fb460..9da993b 100644 --- a/benches/common/interval_solve.rs +++ b/benches/common/interval_solve.rs @@ -5,9 +5,10 @@ use std::array::from_fn; -use super::bench_utils::OrAbort; use la_stack::{ArithmeticOperation, Interval, IntervalMatrix, LaError}; +use super::bench_utils::OrAbort; + pub(crate) const CASES: usize = 32; #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/benches/exact.rs b/benches/exact.rs index 8ae3b4f..ed9cc29 100644 --- a/benches/exact.rs +++ b/benches/exact.rs @@ -35,8 +35,7 @@ use std::hint::black_box; use criterion::{BenchmarkGroup, Criterion, Throughput, measurement::WallTime}; -use la_stack::ExactF64Conversion; -use la_stack::{Matrix, Vector}; +use la_stack::{ExactF64Conversion, Matrix, Vector}; #[path = "common/bench_utils.rs"] mod bench_utils; diff --git a/benches/interval.rs b/benches/interval.rs index 2869455..a750702 100644 --- a/benches/interval.rs +++ b/benches/interval.rs @@ -2,6 +2,7 @@ //! Criterion coverage for conclusive and inconclusive interval determinants. +use core::array::from_fn; use std::hint::black_box; use criterion::Criterion; @@ -108,8 +109,8 @@ fn dimension_benchmarks(criterion: &mut Criterion) { ); // I + 11ᵀ has determinant N+1. Widen a repeated row to obtain an // inconclusive dense enclosure that still includes a singular matrix. - let dense_rows = core::array::from_fn(|row| { - core::array::from_fn(|column| { + let dense_rows = from_fn(|row| { + from_fn(|column| { if row == column { Interval::point(2.0).or_abort("dense diagonal") } else { diff --git a/benches/interval_solve.rs b/benches/interval_solve.rs index 590609c..7fafd34 100644 --- a/benches/interval_solve.rs +++ b/benches/interval_solve.rs @@ -2,9 +2,10 @@ //! Criterion diagnostics for interval reciprocal, division, and solution enclosures. +use std::hint::black_box; + use criterion::Criterion; use la_stack::Interval; -use std::hint::black_box; #[path = "common/bench_utils.rs"] mod bench_utils; diff --git a/benches/public_api.rs b/benches/public_api.rs index b4c24e8..1c9b033 100644 --- a/benches/public_api.rs +++ b/benches/public_api.rs @@ -4,8 +4,7 @@ use std::hint::black_box; -use criterion::measurement::WallTime; -use criterion::{BenchmarkGroup, Criterion}; +use criterion::{BenchmarkGroup, Criterion, measurement::WallTime}; use la_stack::{Interval, IntervalMatrix, Matrix, Tolerance, Vector}; #[path = "common/bench_utils.rs"] diff --git a/benches/rational_solve.rs b/benches/rational_solve.rs index 6859b38..8e5db1d 100644 --- a/benches/rational_solve.rs +++ b/benches/rational_solve.rs @@ -2,9 +2,10 @@ //! Focused structure-sensitive exact solve measurements, outside release reports. -use criterion::Criterion; use std::hint::black_box; +use criterion::Criterion; + #[path = "common/bench_utils.rs"] mod bench_utils; #[path = "common/rational_solve.rs"] diff --git a/benches/vs_linalg.rs b/benches/vs_linalg.rs index f5f5593..3fc2df3 100644 --- a/benches/vs_linalg.rs +++ b/benches/vs_linalg.rs @@ -11,10 +11,9 @@ //! - Matrix infinity norm is the maximum absolute row sum on all sides. //! - Euclidean-norm rows include safe reference kernels as well as peer crates. -use std::hint::black_box; +use std::{array::from_fn, hint::black_box}; -use criterion::measurement::WallTime; -use criterion::{BenchmarkGroup, Criterion}; +use criterion::{BenchmarkGroup, Criterion, measurement::WallTime}; use faer::linalg::matmul::dot::inner_prod; use faer::linalg::solvers::Solve; use faer::mat::AsMatRef; @@ -29,12 +28,11 @@ mod bench_utils; pub mod vs_linalg_common; use bench_utils::OrAbort; -use vs_linalg_common::norm_scenarios; -use vs_linalg_common::{LuSolveScenario, validated_lu_solve_input}; use vs_linalg_common::{ - PreparedFaerLuDet, delaunay_scaled_norm, faer_det_from_ldlt, iterative_hypot, + LuSolveScenario, PreparedFaerLuDet, delaunay_scaled_norm, faer_det_from_ldlt, iterative_hypot, make_balanced_dynamic_range_rows, make_ill_conditioned_matrix_rows, make_matrix_rows, - make_pivoting_matrix_rows, make_vector_array, matrix_entry, nalgebra_inf_norm, vector_entry, + make_pivoting_matrix_rows, make_vector_array, matrix_entry, nalgebra_inf_norm, norm_scenarios, + validated_lu_solve_input, vector_entry, }; /// Build the deterministic la-stack matrix shared by a benchmark family. @@ -671,12 +669,12 @@ where let nrhs = SVector::::from_fn(|i, _| rhs.as_array()[i]); let nlu = na.lu(); let nx = nlu.solve(&nrhs).or_abort("diagnostic nalgebra solve"); - input.validate_solution(&std::array::from_fn(|i| nx[i])); + input.validate_solution(&from_fn(|i| nx[i])); let fa = Mat::from_fn(D, D, |i, j| a.as_rows()[i][j]); let frhs = Mat::from_fn(D, 1, |i, _| rhs.as_array()[i]); let flu = fa.partial_piv_lu(); let fx = flu.solve(&frhs); - input.validate_solution(&std::array::from_fn(|i| fx[(i, 0)])); + input.validate_solution(&from_fn(|i| fx[(i, 0)])); let mut group = c.benchmark_group(format!("lu_solve_{}_d{D}", scenario.name())); group.bench_function("la_stack_lu_solve", |b| { diff --git a/src/lib.rs b/src/lib.rs index b20e15f..2037e44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1253,7 +1253,7 @@ macro_rules! try_with_stack_matrix { 5 => $crate::try_with_stack_matrix!(@arm 5, $matrix, $ret, $body), 6 => $crate::try_with_stack_matrix!(@arm 6, $matrix, $ret, $body), 7 => $crate::try_with_stack_matrix!(@arm 7, $matrix, $ret, $body), - requested => Err(::core::convert::From::from( + requested => ::core::result::Result::Err(::core::convert::From::from( $crate::LaError::unsupported_dimension( requested, $crate::MAX_STACK_MATRIX_DISPATCH_DIM, @@ -1272,7 +1272,7 @@ macro_rules! try_with_stack_matrix { 5 => $crate::try_with_stack_matrix!(@arm_mut 5, $matrix, $ret, $body), 6 => $crate::try_with_stack_matrix!(@arm_mut 6, $matrix, $ret, $body), 7 => $crate::try_with_stack_matrix!(@arm_mut 7, $matrix, $ret, $body), - requested => Err(::core::convert::From::from( + requested => ::core::result::Result::Err(::core::convert::From::from( $crate::LaError::unsupported_dimension( requested, $crate::MAX_STACK_MATRIX_DISPATCH_DIM, @@ -1337,7 +1337,7 @@ macro_rules! try_with_interval_matrix { 5 => $crate::try_with_interval_matrix!(@arm 5, $matrix, $ret, $body), 6 => $crate::try_with_interval_matrix!(@arm 6, $matrix, $ret, $body), 7 => $crate::try_with_interval_matrix!(@arm 7, $matrix, $ret, $body), - requested => Err(::core::convert::From::from( + requested => ::core::result::Result::Err(::core::convert::From::from( $crate::LaError::unsupported_dimension( requested, $crate::MAX_INTERVAL_MATRIX_DIM, @@ -1356,7 +1356,7 @@ macro_rules! try_with_interval_matrix { 5 => $crate::try_with_interval_matrix!(@arm_mut 5, $matrix, $ret, $body), 6 => $crate::try_with_interval_matrix!(@arm_mut 6, $matrix, $ret, $body), 7 => $crate::try_with_interval_matrix!(@arm_mut 7, $matrix, $ret, $body), - requested => Err(::core::convert::From::from( + requested => ::core::result::Result::Err(::core::convert::From::from( $crate::LaError::unsupported_dimension( requested, $crate::MAX_INTERVAL_MATRIX_DIM, @@ -1425,7 +1425,7 @@ macro_rules! try_with_rational_matrix { 6 => $crate::try_with_rational_matrix!(@arm 6, $matrix, $ret, $body), 7 => $crate::try_with_rational_matrix!(@arm 7, $matrix, $ret, $body), 8 => $crate::try_with_rational_matrix!(@arm 8, $matrix, $ret, $body), - requested => Err(::core::convert::From::from( + requested => ::core::result::Result::Err(::core::convert::From::from( $crate::LaError::unsupported_dimension( requested, $crate::MAX_RATIONAL_MATRIX_DISPATCH_DIM, @@ -1445,7 +1445,7 @@ macro_rules! try_with_rational_matrix { 6 => $crate::try_with_rational_matrix!(@arm_mut 6, $matrix, $ret, $body), 7 => $crate::try_with_rational_matrix!(@arm_mut 7, $matrix, $ret, $body), 8 => $crate::try_with_rational_matrix!(@arm_mut 8, $matrix, $ret, $body), - requested => Err(::core::convert::From::from( + requested => ::core::result::Result::Err(::core::convert::From::from( $crate::LaError::unsupported_dimension( requested, $crate::MAX_RATIONAL_MATRIX_DISPATCH_DIM, diff --git a/src/polynomial.rs b/src/polynomial.rs index d22a8d8..560f81f 100644 --- a/src/polynomial.rs +++ b/src/polynomial.rs @@ -6,6 +6,7 @@ //! See the [invariant and resource contract](https://github.com/acgetchell/la-stack/blob/main/docs/mathematical_basis.md#exact-sparse-polynomial-determinants). use std::collections::{BTreeMap, btree_map::Entry}; +use std::mem::take; use num_rational::BigRational; use num_traits::{One, Zero}; @@ -193,7 +194,7 @@ pub fn polynomial_determinant( let mut products = 0; for mask in 0..state_count - 1 { let row = mask.count_ones() as usize; - let partial = std::mem::take(&mut partials[mask]); + let partial = take(&mut partials[mask]); if partial.is_zero() { continue; } @@ -228,5 +229,5 @@ pub fn polynomial_determinant( } } } - Ok(std::mem::take(&mut partials[state_count - 1])) + Ok(take(&mut partials[state_count - 1])) } diff --git a/src/scaled_product.rs b/src/scaled_product.rs index c56925d..642b7be 100644 --- a/src/scaled_product.rs +++ b/src/scaled_product.rs @@ -65,7 +65,40 @@ struct NormalizedFactor { exponent: i128, } -/// A finite product kept as `(-1)^negative × mantissa × 2^exponent`. +impl NormalizedFactor { + const ONE: Self = Self { + mantissa: 1.0, + exponent: 0, + }; + + /// Fold one normalized factor into the running product. + #[inline] + const fn multiply(&mut self, factor: Self) { + self.mantissa *= factor.mantissa; + self.exponent += factor.exponent; + + // Both operands were in [1, 2), and even the two largest binary64 + // mantissas multiply to a value that rounds below 4.0. Therefore one + // factor-of-two normalization is sufficient to preserve + // `mantissa < 2`. + if self.mantissa >= 2.0 { + self.mantissa *= 0.5; + self.exponent += 1; + } + } +} + +enum ProductMagnitude { + Empty, + NonZero { + prefix: NormalizedFactor, + pending: NormalizedFactor, + }, + Zero, + NonFinite, +} + +/// A signed product that retains its range while accumulating finite factors. /// /// Non-zero finite factors are normalized to `1 ≤ mantissa < 2` before /// multiplication. Consequently, no intermediate mantissa multiplication can @@ -74,12 +107,8 @@ struct NormalizedFactor { /// subnormal product can be formed directly in the binary64 destination range /// without first rounding it as a normal mantissa. pub(crate) struct ScaledProduct { - mantissa: f64, - exponent: i128, - pending_factor: Option, negative: bool, - zero: bool, - non_finite: bool, + magnitude: ProductMagnitude, } impl ScaledProduct { @@ -87,12 +116,8 @@ impl ScaledProduct { #[inline] pub(crate) const fn new(negative: bool) -> Self { Self { - mantissa: 1.0, - exponent: 0, - pending_factor: None, negative, - zero: false, - non_finite: false, + magnitude: ProductMagnitude::Empty, } } @@ -110,18 +135,25 @@ impl ScaledProduct { let fraction = magnitude & FRACTION_MASK; if biased_exponent == EXPONENT_MASK { - self.non_finite = true; + self.magnitude = ProductMagnitude::NonFinite; return; } if biased_exponent == 0 && fraction == 0 { - self.zero = true; - return; - } - if self.zero { + if !matches!(self.magnitude, ProductMagnitude::NonFinite) { + self.magnitude = ProductMagnitude::Zero; + } return; } - - let (factor_mantissa, factor_exponent) = if biased_exponent == 0 { + let prefix = match self.magnitude { + ProductMagnitude::Empty => NormalizedFactor::ONE, + ProductMagnitude::NonZero { prefix, pending } => { + let mut prefix = prefix; + prefix.multiply(pending); + prefix + } + ProductMagnitude::Zero | ProductMagnitude::NonFinite => return, + }; + let (mantissa, exponent) = if biased_exponent == 0 { // A subnormal value is `fraction × 2^-1074`. Move its highest set // bit to the binary64 hidden-bit position to obtain a mantissa in // [1, 2), and compensate in the exponent. @@ -139,29 +171,10 @@ impl ScaledProduct { ) }; - if let Some(pending) = self.pending_factor { - self.absorb_factor(pending); - } - self.pending_factor = Some(NormalizedFactor { - mantissa: factor_mantissa, - exponent: factor_exponent, - }); - } - - /// Fold one normalized factor into the running product. - #[inline] - const fn absorb_factor(&mut self, factor: NormalizedFactor) { - self.mantissa *= factor.mantissa; - self.exponent += factor.exponent; - - // Both operands were in [1, 2), and even the two largest binary64 - // mantissas multiply to a value that rounds below 4.0. Therefore one - // factor-of-two normalization is sufficient to preserve - // `mantissa < 2`. - if self.mantissa >= 2.0 { - self.mantissa *= 0.5; - self.exponent += 1; - } + self.magnitude = ProductMagnitude::NonZero { + prefix, + pending: NormalizedFactor { mantissa, exponent }, + }; } /// Finalize the accumulated mantissa and pending factor as binary64. @@ -178,20 +191,16 @@ impl ScaledProduct { clippy::cast_sign_loss, reason = "the preceding bounds prove the normal and deferred-final biased exponents fit u64" )] - pub(crate) const fn finish(mut self) -> Option { - if self.non_finite { - return None; - } - + pub(crate) const fn finish(self) -> Option { let sign = if self.negative { SIGN_MASK } else { 0 }; - if self.zero { - return Some(f64::from_bits(sign)); - } - let Some(pending) = self.pending_factor else { - return Some(f64::from_bits(sign | (1023_u64 << FRACTION_BITS))); + let (mut prefix, pending) = match self.magnitude { + ProductMagnitude::Empty => return Some(f64::from_bits(sign | 1.0_f64.to_bits())), + ProductMagnitude::NonZero { prefix, pending } => (prefix, pending), + ProductMagnitude::Zero => return Some(f64::from_bits(sign)), + ProductMagnitude::NonFinite => return None, }; - let final_exponent = self.exponent + pending.exponent; + let final_exponent = prefix.exponent + pending.exponent; // The product of two mantissas in [1, 2) is strictly below 4. Values // below this exponent are therefore strictly below half the least @@ -206,7 +215,7 @@ impl ScaledProduct { // mantissas first would round once to 53 bits here and a second time // to the much coarser subnormal grid below. let left = f64::from_bits( - (1_u64 << FRACTION_BITS) | (self.mantissa.to_bits() & FRACTION_MASK), + (1_u64 << FRACTION_BITS) | (prefix.mantissa.to_bits() & FRACTION_MASK), ); let right_biased_exponent = (final_exponent - MIN_NORMAL_EXPONENT + EXPONENT_BIAS) as u64; @@ -218,14 +227,14 @@ impl ScaledProduct { return Some(f64::from_bits(sign | magnitude.to_bits())); } - self.absorb_factor(pending); - if self.exponent > EXPONENT_BIAS { + prefix.multiply(pending); + if prefix.exponent > EXPONENT_BIAS { return None; } - let mantissa_bits = self.mantissa.to_bits(); + let mantissa_bits = prefix.mantissa.to_bits(); let fraction = mantissa_bits & FRACTION_MASK; - let biased_exponent = (self.exponent + EXPONENT_BIAS) as u64; + let biased_exponent = (prefix.exponent + EXPONENT_BIAS) as u64; Some(f64::from_bits( sign | (biased_exponent << FRACTION_BITS) | fraction, )) diff --git a/src/vector.rs b/src/vector.rs index 4b65a19..19051c7 100644 --- a/src/vector.rs +++ b/src/vector.rs @@ -880,6 +880,7 @@ impl Default for Vector { #[cfg(test)] mod tests { + use core::array::from_fn; use core::hint::black_box; use approx::assert_abs_diff_eq; @@ -1395,8 +1396,7 @@ mod tests { fn assert_one_exact_normal_product() { let zero = Vector::::new([0.0; D]); for value in [f64::MIN_POSITIVE, -2.0, f64::MAX] { - let left = - Vector::::new(core::array::from_fn(|i| if i == 1 { value } else { -0.0 })); + let left = Vector::::new(from_fn(|i| if i == 1 { value } else { -0.0 })); let right = Vector::::new([1.0; D]); for (result, expected) in [ (left.dot_with_errbound(&right), value), @@ -1410,18 +1410,14 @@ mod tests { assert_eq!(certificate.upper_bound().to_bits(), expected.to_bits()); } } - let inexact = Vector::::new(core::array::from_fn(|i| if i == 1 { 0.1 } else { 0.0 })) + let inexact = Vector::::new(from_fn(|i| if i == 1 { 0.1 } else { 0.0 })) .dot_with_errbound(&Vector::new([0.3; D])) .unwrap() .unwrap(); assert!(inexact.absolute_error_bound() > 0.0); assert_eq!( - Vector::::new(core::array::from_fn(|i| if i == 1 { - f64::MIN_POSITIVE - } else { - 0.0 - })) - .dot_with_errbound(&Vector::new([0.5; D])), + Vector::::new(from_fn(|i| if i == 1 { f64::MIN_POSITIVE } else { 0.0 })) + .dot_with_errbound(&Vector::new([0.5; D])), Ok(None), ); } diff --git a/tests/column_basis.rs b/tests/column_basis.rs index 9a76c4c..cc4039a 100644 --- a/tests/column_basis.rs +++ b/tests/column_basis.rs @@ -3,7 +3,7 @@ //! Analytical rectangular ranks, numerical boundaries, and exact certification. use core::assert_matches; -use std::array::from_fn; +use std::{array::from_fn, hint::black_box}; use la_stack::prelude::*; use pastey::paste; @@ -261,9 +261,9 @@ fn allocation_free_and_const_evaluable() { }; assert_eq!(BASIS.unwrap().columns(), &[1, 2]); let allocations = allocation_counter::measure(|| { - let rows = std::hint::black_box([[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let rows = black_box([[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); assert_eq!( - std::hint::black_box(column_basis(&rows, tolerance(0.0)).unwrap()).rank(), + black_box(column_basis(&rows, tolerance(0.0)).unwrap()).rank(), 2 ); }); diff --git a/tests/interval_solve.rs b/tests/interval_solve.rs index ba81840..51f6fbb 100644 --- a/tests/interval_solve.rs +++ b/tests/interval_solve.rs @@ -274,10 +274,11 @@ fn benchmark_corpus_has_independently_validated_outcomes() { #[cfg(feature = "exact")] mod exact_tests { + use proptest::prelude::*; + use super::oracle::{assert_encloses, exact, exact_solve}; use super::proptest_config::with_default_cases; use super::*; - use proptest::prelude::*; fn check_point_system(a: [[i8; D]; D], b: [i8; D]) { let a = a.map(|row| row.map(|x| f64::from(x) / 10.0)); diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index cf8270d..623ae2b 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -18,6 +18,10 @@ macro_rules! gen_dispatch_capture_tests { ($name:ident, $dispatch:ident, $entry:expr, $max:expr) => { #[test] fn $name() -> Result<(), LaError> { + // A caller's value named Err must not affect the generated error arm. + struct Err; + let _caller_value = Err; + for dimension in 2..=5 { let mut visited = Vec::new(); let count = $dispatch!(dimension, |matrix| -> Result { @@ -56,14 +60,20 @@ macro_rules! gen_dispatch_capture_tests { visited.push(matrix.as_rows().len()); Ok(()) }); - assert_eq!(rejected, Err(LaError::unsupported_dimension(99, $max))); + assert_eq!( + rejected, + Result::Err(LaError::unsupported_dimension(99, $max)) + ); assert!(visited.is_empty()); let rejected = $dispatch!(99usize, |mut matrix| -> Result<(), LaError> { matrix.set(0, 0, $entry)?; visited.push(matrix.as_rows().len()); Ok(()) }); - assert_eq!(rejected, Err(LaError::unsupported_dimension(99, $max))); + assert_eq!( + rejected, + Result::Err(LaError::unsupported_dimension(99, $max)) + ); assert!(visited.is_empty()); Ok(()) } diff --git a/tests/proptest_vector_angles.rs b/tests/proptest_vector_angles.rs index 7683a12..20135c2 100644 --- a/tests/proptest_vector_angles.rs +++ b/tests/proptest_vector_angles.rs @@ -3,6 +3,7 @@ //! Exact Gram-determinant oracles, symmetry, and exact rescaling properties. use core::f64::consts::PI; +use core::iter::repeat_n; use la_stack::prelude::*; use pastey::paste; @@ -152,8 +153,8 @@ macro_rules! gen_angle_properties { for i in 0..$d { padded_a.push(a[i]); padded_b.push(b[i]); - padded_a.extend(core::iter::repeat_n(0.0, gaps[i])); - padded_b.extend(core::iter::repeat_n(-0.0, gaps[i])); + padded_a.extend(repeat_n(0.0, gaps[i])); + padded_b.extend(repeat_n(-0.0, gaps[i])); } prop_assert_eq!( padded_a.as_slice().angle(padded_b.as_slice()).unwrap().to_bits(), diff --git a/tests/rational_allocations.rs b/tests/rational_allocations.rs index 67fa60b..a8cf651 100644 --- a/tests/rational_allocations.rs +++ b/tests/rational_allocations.rs @@ -17,6 +17,7 @@ use std::{array::from_fn, hint::black_box}; use allocation_counter::{AllocationInfo, measure}; use la_stack::{BigInt, BigRational}; use pastey::paste; + use rational_bench::{RationalInputKind, rational_input}; /// Reproduce the positive-canonical branch before a62e72a removed its clones. diff --git a/tests/tooling/test_candidate_transfer.py b/tests/tooling/test_candidate_transfer.py index 4728fdc..55bc932 100644 --- a/tests/tooling/test_candidate_transfer.py +++ b/tests/tooling/test_candidate_transfer.py @@ -118,7 +118,7 @@ def test_actual_writer_handoff_rejects_replacement_before_upload( assert "upload" not in transfer["calls"] -@pytest.mark.parametrize("failure", ["duplicate", "missing", "extra", "path", "bool-id", "digest", "alias", "attached"]) +@pytest.mark.parametrize("failure", ["duplicate", "missing", "extra", "path", "bool-id", "digest", "alias", "attached", "nonfinite"]) def test_transfer_parser_rejects_invalid_assertions(transfer: dict[str, Any], failure: str) -> None: data = transfer["data"] if failure == "missing": @@ -135,11 +135,13 @@ def test_transfer_parser_rejects_invalid_assertions(transfer: dict[str, Any], fa data["assets"]["archive"]["id"] = 1 elif failure == "attached": data["assets"]["attached"] = {"name": f"la-stack-{TAG}-criterion-baseline.tar.gz", "id": 9, "sha256": "d" * 64} + elif failure == "nonfinite": + data["target"]["release_id"] = float("inf") payload = json.dumps(data) if failure == "duplicate": payload = payload.replace('"schema": 1', '"schema": 1, "schema": 1') diagnostics = { - "duplicate": "duplicate transfer field", + "duplicate": "duplicate JSON key", "missing": "four original assets", "extra": "invalid candidate transfer schema", "path": "safe names", @@ -147,11 +149,34 @@ def test_transfer_parser_rejects_invalid_assertions(transfer: dict[str, Any], fa "digest": "full SHA-256", "alias": "distinct names and IDs", "attached": "canonical attached archive", + "nonfinite": "nonfinite JSON number", } with pytest.raises(ValueError, match=diagnostics[failure]): candidate.parse_transfer(payload.encode()) +def test_draft_rejects_publication_transfer_before_reading_transport(transfer: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + data = transfer["data"] + data["assets"]["attached"] = {"name": f"la-stack-{TAG}-criterion-baseline.tar.gz", "id": 9, "sha256": data["assets"]["archive"]["sha256"]} + monkeypatch.setattr(candidate, "check_release_dependencies", lambda *_args: pytest.fail("wrong transfer must stop before checking dependencies")) + output = tmp_path / "output" + with pytest.raises(ValueError, match="draft verification requires the original four-asset transfer"): + candidate.receive(tmp_path / "checkout", output, json.dumps(data).encode(), source=tmp_path / "missing transport") + assert not output.exists() + + +def test_transfer_variants_keep_wire_schema(transfer: dict[str, Any]) -> None: + data = transfer["data"] + draft = candidate.parse_transfer(json.dumps(data).encode()) + assert isinstance(draft, candidate.DraftTransfer) + assert json.loads(candidate.serialize_transfer(draft)) == data + data["assets"]["attached"] = {"name": f"la-stack-{TAG}-criterion-baseline.tar.gz", "id": 9, "sha256": data["assets"]["archive"]["sha256"]} + publication = candidate.parse_transfer(json.dumps(data).encode()) + assert isinstance(publication, candidate.PublicationTransfer) + assert publication.original == draft + assert json.loads(candidate.serialize_transfer(publication)) == data + + @pytest.mark.parametrize("key", ["GITHUB_ACTOR", "GITHUB_TRIGGERING_ACTOR", "GITHUB_EVENT_NAME", "GITHUB_WORKFLOW_REF", "GITHUB_SHA", "GITHUB_REF"]) def test_dispatch_authentication_precedes_provider_access(transfer: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, key: str) -> None: monkeypatch.setenv(key, "untrusted") @@ -203,6 +228,24 @@ def replaced(_root: Path, target: ReleaseTarget, **_kwargs: object) -> None: candidate.stage(root, owner, "reviewed", prepared) +def test_staging_rejects_malformed_owner_digest_before_provider_access(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root, owner = tmp_path / "checkout", tmp_path / "owner" + final = candidate.final_directory(owner, SHA, "reviewed") + final.mkdir(parents=True) + (final / "policy.json").write_bytes(b"policy") + (final / "acceptance.json").write_bytes(b"reviewed") + (owner / "acquisition.sha256").write_bytes(b"invalid") + (owner / "publication.sha256").write_bytes(b"a" * 64) + prepared = tmp_path / "target.json" + prepared.write_bytes(serialize_release_target(ReleaseTarget(candidate.REPOSITORY, TAG, 42, SHA, TAG))) + monkeypatch.setattr(candidate, "resolve_revision", lambda *_args: SHA) + monkeypatch.setattr(candidate, "verify", lambda *_args: pytest.fail("invalid assertions must stop before revalidation")) + monkeypatch.setattr(candidate, "revalidate_release_target", lambda *_args, **_kwargs: pytest.fail("invalid assertions must stop before provider access")) + with pytest.raises(ValueError, match="full SHA-256 assertions"): + candidate.stage(root, owner, "reviewed", prepared) + assert not (final / "target.json").exists() + + def test_owner_records_reject_overwrite_and_path_escape(tmp_path: Path) -> None: candidate.freeze(tmp_path, {"record": b"original"}) candidate.freeze(tmp_path, {"record": b"original"}) @@ -227,6 +270,8 @@ def test_initial_stage_requires_the_exact_prepared_target(tmp_path: Path, monkey monkeypatch.setattr(candidate, "parse_candidate_policy", lambda *_args: SimpleNamespace(tag=TAG)) (final / "policy.json").write_bytes(b"owner policy") (final / "acceptance.json").write_bytes(b"reviewed acceptance") + for name in ("acquisition.sha256", "publication.sha256"): + (owner / name).write_bytes(b"a" * 64) target = ReleaseTarget(candidate.REPOSITORY, TAG, 42, SHA, TAG) changes = {"repository": "other/repo", "tag": "v9.9.9", "commit": "f" * 40, "expected_title": "edited", "release_id": 43} supplied = replace(target, **{field: changes[field]}) diff --git a/tests/tooling/test_release_context.py b/tests/tooling/test_release_context.py index 39ce5e0..2f68306 100644 --- a/tests/tooling/test_release_context.py +++ b/tests/tooling/test_release_context.py @@ -221,7 +221,15 @@ def test_registry_dispatch_sends_only_the_existing_reviewed_snapshot(tmp_path: P final = candidate.final_directory(owner, SHA, "reviewed") final.mkdir(parents=True) publication = final / "publication-transfer.json" - original = b"original owner publication\r\n" + target = ReleaseTarget(candidate.REPOSITORY, TAG, 42, SHA, TAG) + assets = {role: {"name": f"{role}.bin", "id": index, "sha256": "b" * 64} for index, role in enumerate((*candidate.ROLES, "attached"), 1)} + assets["attached"]["name"] = f"la-stack-{TAG}-criterion-baseline.tar.gz" + original = ( + json.dumps( + {"schema": 1, "target": json.loads(serialize_release_target(target)), "acquisition": "c" * 64, "publication": "d" * 64, "assets": assets} + ).encode() + + b"\r\n" + ) publication.write_bytes(original) accepted = final / "acceptance.json" reviewed = b"original full acceptance\r\n" @@ -229,7 +237,6 @@ def test_registry_dispatch_sends_only_the_existing_reviewed_snapshot(tmp_path: P accepted.write_bytes(reviewed) monkeypatch.setattr(candidate, "release_tag", lambda _root: TAG) monkeypatch.setattr(candidate, "record_path", lambda *_args: publication) - monkeypatch.setattr(candidate, "parse_transfer", lambda _payload: SimpleNamespace()) verified: list[Path] = [] def verify(*_args: object) -> Path: diff --git a/tooling/release_candidate.py b/tooling/release_candidate.py index aaf3590..1278daf 100644 --- a/tooling/release_candidate.py +++ b/tooling/release_candidate.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from functools import partial from pathlib import Path +from typing import Literal from research_repo_tools.candidate_publication import plan_candidate_publication from research_repo_tools.candidate_reuse import ( @@ -51,6 +52,7 @@ SETTINGS = "tooling/performance-release.toml" REPOSITORY = "acgetchell/la-stack" ROLES = ("policy", "publication", "acceptance", "archive") +type AssetRole = Literal["policy", "publication", "acceptance", "archive", "attached"] def release_tag(root: Path) -> str: @@ -327,40 +329,108 @@ def archive(owner: Path, commit: str, selection: str = "reviewed") -> Path: @dataclass(frozen=True, slots=True) -class Transfer: - """A parsed owner selection; only an authorized invocation authenticates it.""" +class AssetAssertion: + """A provider asset name, exact numeric ID, and independently selected digest.""" - target: ReleaseTarget - acquisition: str - publication: str - assets: tuple[tuple[str, str, int, str], ...] + name: str + identifier: int + sha256: str + + def __post_init__(self) -> None: + """Validate provider values before carrying them as assertions.""" + if ( + not isinstance(self.name, str) + or re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}", self.name) is None + or type(self.identifier) is not int + or self.identifier <= 0 + ): + message = "candidate assets require safe names and positive exact IDs" + raise ValueError(message) + if not isinstance(self.sha256, str) or re.fullmatch(r"[0-9a-f]{64}", self.sha256) is None: + message = "candidate asset requires a full SHA-256 assertion" + raise ValueError(message) -def transfer_asset(role: str, raw: object) -> tuple[str, str, int, str]: +def transfer_asset(raw: object) -> AssetAssertion: """Parse one exact inert asset selection.""" if not isinstance(raw, dict) or set(raw) != {"name", "id", "sha256"}: message = "invalid candidate asset assertion" raise ValueError(message) - name, identifier, digest = raw["name"], raw["id"], raw["sha256"] - if not isinstance(name, str) or re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}", name) is None or type(identifier) is not int or identifier <= 0: - message = "candidate assets require safe names and positive exact IDs" - raise ValueError(message) - if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: - message = "candidate asset requires a full SHA-256 assertion" + return AssetAssertion(raw["name"], raw["id"], raw["sha256"]) + + +@dataclass(frozen=True, slots=True) +class OriginalAssets: + """Every original role is required exactly once.""" + + policy: AssetAssertion + publication: AssetAssertion + acceptance: AssetAssertion + archive: AssetAssertion + + def __post_init__(self) -> None: + """Check the cross-role identity constraint.""" + assets = (self.policy, self.publication, self.acceptance, self.archive) + if len({asset.name for asset in assets}) != len(assets) or len({asset.identifier for asset in assets}) != len(assets): + message = "candidate assets must have distinct names and IDs" + raise ValueError(message) + + def role_assertions(self) -> tuple[tuple[AssetRole, AssetAssertion], ...]: + """Return the original assets with their fixed transport roles.""" + return (("policy", self.policy), ("publication", self.publication), ("acceptance", self.acceptance), ("archive", self.archive)) + + +@dataclass(frozen=True, slots=True) +class DraftTransfer: + """The original four-asset selection, before attachment.""" + + target: ReleaseTarget + acquisition: str + publication: str + assets: OriginalAssets + + +@dataclass(frozen=True, slots=True) +class PublicationTransfer: + """The original selection plus an independently selected canonical attachment.""" + + original: DraftTransfer + attached: AssetAssertion + + def __post_init__(self) -> None: + """Require a distinct attachment with the accepted archive bytes.""" + if any(self.attached.name == asset.name or self.attached.identifier == asset.identifier for _, asset in self.original.assets.role_assertions()): + message = "candidate assets must have distinct names and IDs" + raise ValueError(message) + if self.attached.name != archive_name(self.original.target.tag) or self.attached.sha256 != self.original.assets.archive.sha256: + message = "canonical attached archive must match the originally accepted bytes" + raise ValueError(message) + + def role_assertions(self) -> tuple[tuple[AssetRole, AssetAssertion], ...]: + """Return every asset selected for publication.""" + return (*self.original.assets.role_assertions(), ("attached", self.attached)) + + +type Transfer = DraftTransfer | PublicationTransfer + + +def archive_name(tag: str) -> str: + """Name the canonical attached benchmark archive.""" + return f"la-stack-{tag}-criterion-baseline.tar.gz" + + +def transfer_digest(value: object) -> str: + """Require an exact SHA-256 assertion before preparing transport.""" + if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None: + message = "candidate transfer requires full SHA-256 assertions" raise ValueError(message) - return role, name, identifier, digest + return value def parse_transfer(payload: bytes) -> Transfer: """Reject malformed or ambiguous operator assertions before provider access.""" - def unique(pairs: list[tuple[str, object]]) -> dict[str, object]: - if len(dict(pairs)) != len(pairs): - message = "duplicate transfer field" - raise ValueError(message) - return dict(pairs) - - data = json.loads(payload, object_pairs_hook=unique) + data: object = parse_release_json(payload) if ( not isinstance(data, dict) or set(data) != {"schema", "target", "acquisition", "publication", "assets"} @@ -374,24 +444,50 @@ def unique(pairs: list[tuple[str, object]]) -> dict[str, object]: message = "candidate transfer must select this repository and its exact release title" raise ValueError(message) - def digest(value: object) -> str: - if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None: - message = "candidate transfer requires full SHA-256 assertions" - raise ValueError(message) - return value - raw_assets = data["assets"] if not isinstance(raw_assets, dict) or set(raw_assets) not in (set(ROLES), {*ROLES, "attached"}): message = "candidate transfer requires four original assets and an optional attached archive" raise ValueError(message) - assets = tuple(transfer_asset(role, raw_assets[role]) for role in (*ROLES, *(["attached"] if "attached" in raw_assets else []))) - if len({item[1] for item in assets}) != len(assets) or len({item[2] for item in assets}) != len(assets): - message = "candidate assets must have distinct names and IDs" - raise ValueError(message) - if "attached" in raw_assets and (raw_assets["attached"]["name"] != f"la-stack-{target.tag}-criterion-baseline.tar.gz" or assets[-1][3] != assets[3][3]): - message = "canonical attached archive must match the originally accepted bytes" - raise ValueError(message) - return Transfer(target, digest(data["acquisition"]), digest(data["publication"]), assets) + assets = OriginalAssets( + policy=transfer_asset(raw_assets["policy"]), + publication=transfer_asset(raw_assets["publication"]), + acceptance=transfer_asset(raw_assets["acceptance"]), + archive=transfer_asset(raw_assets["archive"]), + ) + original = DraftTransfer(target, transfer_digest(data["acquisition"]), transfer_digest(data["publication"]), assets) + if "attached" in raw_assets: + return PublicationTransfer(original, transfer_asset(raw_assets["attached"])) + return original + + +def require_draft_transfer(transfer: Transfer) -> DraftTransfer: + """Require the original four-asset dispatch selection.""" + if isinstance(transfer, DraftTransfer): + return transfer + message = "draft verification requires the original four-asset transfer" + raise ValueError(message) + + +def require_publication_transfer(transfer: Transfer) -> PublicationTransfer: + """Require the canonical attachment before any publication operation.""" + if isinstance(transfer, PublicationTransfer): + return transfer + message = "publication requires the independently selected canonical archive" + raise ValueError(message) + + +def serialize_transfer(transfer: Transfer) -> bytes: + """Preserve the schema-1 wire shape of a validated owner selection.""" + original = transfer.original if isinstance(transfer, PublicationTransfer) else transfer + assets = transfer.role_assertions() if isinstance(transfer, PublicationTransfer) else transfer.assets.role_assertions() + record = { + "schema": 1, + "target": json.loads(serialize_release_target(original.target)), + "acquisition": original.acquisition, + "publication": original.publication, + "assets": {role: {"name": asset.name, "id": asset.identifier, "sha256": asset.sha256} for role, asset in assets}, + } + return json.dumps(record, sort_keys=True).encode("utf-8") + b"\n" def preparation_target(root: Path, owner: Path, selection: str) -> Path: @@ -422,11 +518,12 @@ def preparation_target(root: Path, owner: Path, selection: str) -> Path: def stage(root: Path, owner: Path, selection: str, prepared_target: Path | None = None) -> Path: """Upload inert original records, retaining dispatch assertions independently.""" + acquisition = transfer_digest((owner / "acquisition.sha256").read_text(encoding="ascii").strip()) + publication = transfer_digest((owner / "publication.sha256").read_text(encoding="ascii").strip()) accepted = verify_existing(root, owner, selection) commit = resolve_revision(root, "HEAD") final = final_directory(owner, commit, selection) policy = parse_candidate_policy((final / "policy.json").read_bytes()) - artifact = archive(owner, commit, selection) target_path = final / "target.json" target = parse_release_target((prepared_target or preparation_target(root, owner, selection)).read_bytes()) if (target.repository, target.tag, target.commit, target.expected_title) != (REPOSITORY, policy.tag, commit, policy.tag): @@ -435,6 +532,7 @@ def stage(root: Path, owner: Path, selection: str, prepared_target: Path | None freeze(final, {"target.json": serialize_release_target(target)}) target = parse_release_target(target_path.read_bytes()) revalidate_release_target(root, target, checkout=root) + artifact = archive(owner, commit, selection) inputs = {"policy": final / "policy.json", "publication": owner / "publication.json", "acceptance": accepted, "archive": artifact} transport = final / "transport" transport.mkdir(exist_ok=True) @@ -446,22 +544,25 @@ def stage(root: Path, owner: Path, selection: str, prepared_target: Path | None freeze(transport, {name: payload}) publish_release_asset(root, REPOSITORY, policy.tag, transport / name, target=target, checkout=root) release = revalidate_release_target(root, target, checkout=root) - entry = next(item for item in release.assets if item[0] == name) + entry = next((item for item in release.assets if item[0] == name), None) + if entry is None: + message = f"staged candidate asset is missing from the prepared release: {name}" + raise ValueError(message) assertions[role] = {"name": name, "id": entry[1], "sha256": hashlib.sha256(payload).hexdigest()} payload = ( json.dumps( { "schema": 1, "target": json.loads(serialize_release_target(target)), - "acquisition": (owner / "acquisition.sha256").read_text(encoding="ascii").strip(), - "publication": (owner / "publication.sha256").read_text(encoding="ascii").strip(), + "acquisition": acquisition, + "publication": publication, "assets": assertions, }, sort_keys=True, ).encode("utf-8") + b"\n" ) - parse_transfer(payload) + require_draft_transfer(parse_transfer(payload)) freeze(final, {"transfer.json": payload}) return final / "transfer.json" @@ -469,41 +570,35 @@ def stage(root: Path, owner: Path, selection: str, prepared_target: Path | None def seal(root: Path, owner: Path, selection: str) -> Path: """Independently select the canonical attached asset before publication.""" final = final_directory(owner, resolve_revision(root, "HEAD"), selection) - payload = (final / "transfer.json").read_bytes() - transfer = parse_transfer(payload) + transfer = require_draft_transfer(parse_transfer((final / "transfer.json").read_bytes())) release = revalidate_release_target(root, transfer.target, checkout=root) - name = f"la-stack-{transfer.target.tag}-criterion-baseline.tar.gz" + name = archive_name(transfer.target.tag) entry = next((item for item in release.assets if item[0] == name), None) if entry is None: message = "canonical accepted benchmark archive is not yet attached" raise ValueError(message) - digest = next(item[3] for item in transfer.assets if item[0] == "archive") + digest = transfer.assets.archive.sha256 + publication = PublicationTransfer(transfer, AssetAssertion(name, entry[1], digest)) with tempfile.TemporaryDirectory(prefix="la-stack-seal-") as temporary: download_release_asset(root, REPOSITORY, transfer.target.tag, name, Path(temporary) / "archive.tar.gz", expected_sha256=digest, allow_draft=True) if entry not in revalidate_release_target(root, transfer.target, checkout=root).assets: message = "attached archive identity changed during verification" raise ValueError(message) - data = json.loads(payload) - data["assets"]["attached"] = {"name": name, "id": entry[1], "sha256": digest} - sealed = json.dumps(data, sort_keys=True).encode("utf-8") + b"\n" - parse_transfer(sealed) - freeze(final, {"publication-transfer.json": sealed}) + freeze(final, {"publication-transfer.json": serialize_transfer(publication)}) return final / "publication-transfer.json" -def record_path(root: Path, owner: Path, selection: str, role: str) -> Path: +def record_path(root: Path, owner: Path, selection: str, role: Literal["transfer", "publication"]) -> Path: """Locate an already retained transfer for this exact clean release commit.""" commit = resolve_revision(root, "HEAD") require_release_checkout(root, commit) filename = "transfer.json" if role == "transfer" else "publication-transfer.json" path = final_directory(owner, commit, selection) / filename - transfer = parse_transfer(path.read_bytes()) + parsed = parse_transfer(path.read_bytes()) + transfer = require_draft_transfer(parsed) if role == "transfer" else require_publication_transfer(parsed).original if (transfer.target.tag, transfer.target.commit) != (release_tag(root), commit): message = "saved transfer differs from the current release checkout" raise ValueError(message) - if role == "publication" and "attached" not in {item[0] for item in transfer.assets}: - message = "publication requires the already captured canonical archive" - raise ValueError(message) return path @@ -514,7 +609,7 @@ def upload_dispatch(root: Path, owner: Path, selection: str, tag: str) -> None: raise ValueError(message) publication = record_path(root, owner, selection, "publication") transfer_bytes = publication.read_bytes() - transfer = parse_transfer(transfer_bytes) + transfer = require_publication_transfer(parse_transfer(transfer_bytes)) accepted = publication.parent / "acceptance.json" expected = accepted.read_bytes() # Missing review must never create a new selection. if verify_existing(root, owner, selection).read_bytes() != expected: @@ -549,63 +644,77 @@ def upload_dispatch(root: Path, owner: Path, selection: str, tag: str) -> None: ) -def require_dispatch(transfer: Transfer, *, published: bool) -> None: +def require_dispatch(transfer: Transfer) -> None: """Bind independent workflow input to the allowed operator, workflow and tag.""" - workflow = "publish-crates.yml" if published else "release-benchmarks.yml" + if isinstance(transfer, PublicationTransfer): + workflow = "publish-crates.yml" + target = transfer.original.target + else: + workflow = "release-benchmarks.yml" + target = transfer.target expected = { "GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_REPOSITORY": REPOSITORY, - "GITHUB_REF": f"refs/tags/{transfer.target.tag}", - "GITHUB_SHA": transfer.target.commit, - "GITHUB_WORKFLOW_REF": f"{REPOSITORY}/.github/workflows/{workflow}@refs/tags/{transfer.target.tag}", + "GITHUB_REF": f"refs/tags/{target.tag}", + "GITHUB_SHA": target.commit, + "GITHUB_WORKFLOW_REF": f"{REPOSITORY}/.github/workflows/{workflow}@refs/tags/{target.tag}", "GITHUB_ACTOR": "acgetchell", "GITHUB_TRIGGERING_ACTOR": "acgetchell", - "RELEASE_TAG": transfer.target.tag, + "RELEASE_TAG": target.tag, } if any(os.environ.get(key) != value for key, value in expected.items()): message = "candidate handoff requires the maintainer's exact workflow/tag dispatch" raise ValueError(message) -def transfer_files(root: Path, transfer: Transfer, directory: Path, incoming: Path | None) -> dict[str, Path]: - """Read exact dispatch-selected bytes from published assets or inert transport.""" - files: dict[str, Path] = {} - for role, name, _identifier, digest in transfer.assets: - path = directory / name - if incoming is None: - download_release_asset(root, REPOSITORY, transfer.target.tag, name, path, expected_sha256=digest) - else: - source = incoming / ("archive.tar.gz" if role == "archive" else f"{role}.json") - data = source.read_bytes() - if source.is_symlink() or hashlib.sha256(data).hexdigest() != digest: - message = "transported candidate bytes differ from independent dispatch assertions" - raise ValueError(message) - freeze(directory, {name: data}) - files[role] = path - return files +def transport_name(role: AssetRole) -> str: + """Use fixed local paths independent of provider asset names.""" + return "archive.tar.gz" if role == "archive" else f"{role}.json" + +def copy_draft_assets(transfer: DraftTransfer, directory: Path, incoming: Path) -> None: + """Check and retain the original inert transport bytes.""" + for role, asset in transfer.assets.role_assertions(): + name = transport_name(role) + source = incoming / name + if source.is_symlink(): + message = "transported candidate bytes differ from independent dispatch assertions" + raise ValueError(message) + data = source.read_bytes() + if hashlib.sha256(data).hexdigest() != asset.sha256: + message = "transported candidate bytes differ from independent dispatch assertions" + raise ValueError(message) + freeze(directory, {name: data}) -def check_published_transfer(root: Path, transfer: Transfer) -> None: + +def download_published_assets(root: Path, transfer: PublicationTransfer, directory: Path) -> None: + """Fetch the original and canonical assets into distinct role paths.""" + for role, asset in transfer.role_assertions(): + download_release_asset(root, REPOSITORY, transfer.original.target.tag, asset.name, directory / transport_name(role), expected_sha256=asset.sha256) + + +def check_published_transfer(root: Path, transfer: PublicationTransfer) -> None: """Require the immutable release and exact original and canonical assets.""" + target = transfer.original.target release = check_reviewed_release( load_config(root=root), - transfer.target.tag, - event=ReviewedRelease(transfer.target.tag, transfer.target.commit, transfer.target.release_id), + target.tag, + event=ReviewedRelease(target.tag, target.commit, target.release_id), ) if not release.immutable: message = "published candidate release must be immutable" raise ValueError(message) if (release.repository, release.tag, release.identifier, release.title) != ( REPOSITORY, - transfer.target.tag, - transfer.target.release_id, - transfer.target.expected_title, + target.tag, + target.release_id, + target.expected_title, ): message = "candidate release identity changed" raise ValueError(message) - for _role, name, identifier, digest in transfer.assets: - entry = next((item for item in release.assets if item[0] == name), None) - if entry is None or entry[1] != identifier or (entry[3] is not None and entry[3] != digest): + for _role, asset in transfer.role_assertions(): + entry = next((item for item in release.assets if item[0] == asset.name), None) + if entry is None or entry[1] != asset.identifier or (entry[3] is not None and entry[3] != asset.sha256): message = "candidate asset identity changed" raise ValueError(message) @@ -622,34 +731,60 @@ def __post_init__(self) -> None: if re.fullmatch(r"[0-9a-f]{64}", self.sha256) is None: message = "publication requires the independently reviewed full acceptance SHA-256" raise ValueError(message) - if self.expected is not None and hashlib.sha256(self.expected).hexdigest() != self.sha256: + if self.expected is not None and (not isinstance(self.expected, bytes) or hashlib.sha256(self.expected).hexdigest() != self.sha256): message = "approval receipt differs from independently reviewed acceptance" raise ValueError(message) +@dataclass(frozen=True, slots=True) +class DraftHandoff: + """Verify an original selection using inert transport.""" + + transfer: DraftTransfer + incoming: Path + + +@dataclass(frozen=True, slots=True) +class PublicationHandoff: + """Verify the immutable published selection using independent owner approval.""" + + transfer: PublicationTransfer + review: ReviewedAcceptance + + +def parse_handoff(payload: bytes, source: Path | ReviewedAcceptance) -> DraftHandoff | PublicationHandoff: + """Pair the transfer with its permitted source before authenticating the dispatch.""" + transfer = parse_transfer(payload) + if isinstance(source, ReviewedAcceptance): + return PublicationHandoff(require_publication_transfer(transfer), source) + return DraftHandoff(require_draft_transfer(transfer), source) + + def receive(root: Path, destination: Path, payload: bytes, *, source: Path | ReviewedAcceptance) -> Path: """Read-only hosted acceptance, pinned to original bytes and reviewed CI.""" - published = isinstance(source, ReviewedAcceptance) - transfer = parse_transfer(payload) - require_dispatch(transfer, published=published) - if published and "attached" not in {item[0] for item in transfer.assets}: - message = "publication requires the independently selected canonical archive" - raise ValueError(message) + handoff = parse_handoff(payload, source) + require_dispatch(handoff.transfer) + transfer = handoff.transfer.original if isinstance(handoff, PublicationHandoff) else handoff.transfer check_release_dependencies(root) destination = owner_directory(root, "", destination) destination.mkdir(parents=True, exist_ok=True) def release_identity() -> None: - if published: - check_published_transfer(root, transfer) + if isinstance(handoff, PublicationHandoff): + check_published_transfer(root, handoff.transfer) release_identity() with tempfile.TemporaryDirectory(prefix="la-stack-transfer-") as temporary: - files = transfer_files(root, transfer, Path(temporary), source if isinstance(source, Path) else None) + directory = Path(temporary) + if isinstance(handoff, PublicationHandoff): + download_published_assets(root, handoff.transfer, directory) + else: + copy_draft_assets(handoff.transfer, directory, handoff.incoming) release_identity() - policy = parse_candidate_policy(files["policy"].read_bytes()) - accepted = files["acceptance"].read_bytes() - evidence = verify_candidate_archive(files["archive"], policy, accepted, target=transfer.target) + policy = parse_candidate_policy((directory / "policy.json").read_bytes()) + accepted = (directory / "acceptance.json").read_bytes() + archive_path = directory / "archive.tar.gz" + evidence = verify_candidate_archive(archive_path, policy, accepted, target=transfer.target) actual = verify_candidate_release( root, evidence, @@ -657,25 +792,25 @@ def release_identity() -> None: load_evidence_policy(root / "tooling/release-evidence.toml"), partial(api, repository=REPOSITORY), acquisition_sha256=transfer.acquisition, - publication_receipt=files["publication"].read_bytes(), + publication_receipt=(directory / "publication.json").read_bytes(), publication_sha256=transfer.publication, # A published release may need explicitly reviewed newer native CI. # Before approval, the independent owner digest authenticates the # newly verified result. After approval, pin those exact bytes. - expected=source.expected if isinstance(source, ReviewedAcceptance) else accepted, + expected=handoff.review.expected if isinstance(handoff, PublicationHandoff) else accepted, ) - if isinstance(source, ReviewedAcceptance) and hashlib.sha256(actual).hexdigest() != source.sha256: + if isinstance(handoff, PublicationHandoff) and hashlib.sha256(actual).hexdigest() != handoff.review.sha256: message = "verified acceptance differs from the independently reviewed digest" raise ValueError(message) release_identity() - name = f"la-stack-{transfer.target.tag}-criterion-baseline.tar.gz" + name = archive_name(transfer.target.tag) freeze( destination, { "transfer.json": payload, "acceptance.json": actual, "target.json": serialize_release_target(transfer.target), - name: files["archive"].read_bytes(), + name: archive_path.read_bytes(), }, ) return destination / name @@ -746,8 +881,11 @@ def run_owner_operation(root: Path, options: argparse.Namespace) -> None: print(seal(root, owner, options.selection)) elif options.operation == "record": print(record_path(root, owner, options.selection, options.role)) - else: + elif options.operation == "upload": upload_dispatch(root, owner, options.selection, options.tag) + else: + message = f"unknown candidate owner operation: {options.operation}" + raise ValueError(message) def main() -> None: