diff --git a/CHANGELOG.md b/CHANGELOG.md index c020d44..01dfdf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,24 @@ All notable changes to SEMAPRAX are documented here. opening an image; default consumers cannot import the proof surface. This adds no provider, descriptor-v3, loader admission, host settlement execution, physical finalizer, public API, mobile evidence, or `SPX-B104` change. +- Froze the private metadata-only [callable ABI v3 + contract](docs/NATIVE-CALLABLE-ABI-V3.md): `SPXNABI3` fixes the descriptor + order, acyclic hash dependencies, recovery graph, capacity budget, dynamic + and iOS-static linkage roles, and provisional bounded request/execute/frame/ + decision/action/candidate plus host-only committed-receipt reservations. + `CertifyOutcome` embeds the canonical ordinal/outcome witness and a nonzero + trace-certificate-bound evidence digest independently recomputed by the host; + this binds the witness only and is not host acceptance of the trace-path DFA + certificate. Resealed witness or digest mutations are rejected. + Those seven statements are not complete runtime codecs and omit full + byte/tag/digest/host-HMAC transcripts; replacing them with independently + tested codecs may change private v3 known answers. The emitter is bound to its + build target and provides no Android/iOS/Windows cross-emission evidence. Both + existing loader constructors now reject v3 magic before path + canonicalization or image/symbol access, including malformed same-magic + headers. The compiler/host codec tranche grants no provider, runtime-wire, + loading, settlement, finalizer, ledger, mobile, or public authority and leaves + v2/proof bytes and `SPX-B104` unchanged. - Added a mandatory Windows callable-v2 dependency-isolation fixture. It places a same-name dependency in both CWD and legacy `PATH`, proves the root-image sibling wins for descriptor admission and invocation, then removes that diff --git a/README.md b/README.md index 5549029..83276ee 100644 --- a/README.md +++ b/README.md @@ -161,10 +161,23 @@ settlement, authenticated receipts, and explicit call/module quiescence. Its hidden target-neutral model and compiler derivation now serialize through the private [settlement-proof v1](docs/NATIVE-CALLABLE-SETTLEMENT-PROOF-V1.md) envelope and an independent host parser. That proof binds exact v2 metadata but -grants no authority and deliberately reserves no v3 ABI version. No v3 -descriptor, provider, loader admission, host settlement, or physical finalizer -is wired, so it supplies no native-runtime evidence and does not weaken -`SPX-B104`. +grants no authority. The private [callable ABI v3 metadata +contract](docs/NATIVE-CALLABLE-ABI-V3.md) now fixes the current private +`SPXNABI3` descriptor order, hash DAG, capacities, settlement graph, linkage +metadata, and seven provisional future wire-role/schema reservations. A +`CertifyOutcome` edge carries the exact ordinal/outcome witness plus a nonzero +digest bound to the trace-certificate fingerprint; the host recomputes that +digest but does not independently accept the trace-path DFA certificate. The +seven reservations omit complete byte/tag/digest/host-HMAC transcripts and may +change private v3 known answers when independently tested codecs are frozen. +The current emitter is bound to its compiler build target and has no +cross-target configuration; +Android/iOS/Windows cross-emission and runtime evidence remain absent. The +compiler-encoder/independent-host-parser tranche is metadata work only, and the +existing loader rejects v3 magic before path or image access. No v3 provider, +runtime wire codec, loader admission, host settlement, physical finalizer, or +public compiler path is wired, so this supplies no native-runtime evidence and +does not weaken `SPX-B104`. The hidden phase-aware transaction model now starts from the authenticated post-`CallCommit` state and separates one exact `SettlementDecisionCommit`, @@ -183,8 +196,10 @@ callable-v2 admission. It eagerly resolves one private callable and exposes only instance-bound, preallocated one-shot prepared calls—never a raw handle, generic lookup, or callable pointer. The ownership host now consumes the v2 lease and callable transport, but the unsafe caller must still establish trusted -image and dependency provenance. This is not a malicious-plugin boundary and -does not weaken `SPX-B104`. +image and dependency provenance. Both loader entry points reject `SPXNABI3` +metadata before canonicalization, native image load, or symbol lookup; there is +no v3 constructor or fallback to v2. This is not a malicious-plugin boundary +and does not weaken `SPX-B104`. The current critical-path implementation contract is [Owned resource vertical slice v1](docs/OWNED-RESOURCE-VERTICAL-V1.md): one deliberately narrow, @@ -254,7 +269,7 @@ The long-term compiler has two output principles: - Native machine code where performance and platform integration matter. - WebAssembly Components where portability and capability sandboxing matter. -Read [RFC 0001](docs/RFC-0001.md) for the language system, [RFC 0002](docs/RFC-0002-ALGEBRAIC-DATA.md) for algebraic data and aggregate ownership, [RFC 0003](docs/RFC-0003-CLEANUP-AND-RESOURCE-ABI.md) for implemented lifecycle source/resolution and the proposed exactly-once cleanup/runtime phases, and the model-backed, proposed [RFC 0004](docs/RFC-0004-NATIVE-CALL-SETTLEMENT.md) for the native recovery/settlement contract. [Settlement proof v1](docs/NATIVE-CALLABLE-SETTLEMENT-PROOF-V1.md) specifies the private authority-free compiler/host proof envelope. [Conformance trace v1](docs/CONFORMANCE-TRACE-V1.md) fixes the target-neutral status/trace projection, and [host ownership transactions v1](docs/HOST-OWNERSHIP-TRANSACTIONS-V1.md) fixes the preflight/commit/publication semantics that future ecosystem adapters must preserve. [The architecture](docs/ARCHITECTURE.md) describes the current implementation, [the quality gates](docs/QUALITY-GATES.md) define executable contribution evidence, [protocol migrations](docs/MIGRATIONS.md) cover agent-facing compatibility, [the roadmap](docs/ROADMAP.md) gives the staged path forward, and the [full-goal completion matrix](docs/COMPLETION-MATRIX.md) records requirement-by-requirement evidence. +Read [RFC 0001](docs/RFC-0001.md) for the language system, [RFC 0002](docs/RFC-0002-ALGEBRAIC-DATA.md) for algebraic data and aggregate ownership, [RFC 0003](docs/RFC-0003-CLEANUP-AND-RESOURCE-ABI.md) for implemented lifecycle source/resolution and the proposed exactly-once cleanup/runtime phases, and the model-backed, proposed [RFC 0004](docs/RFC-0004-NATIVE-CALL-SETTLEMENT.md) for the native recovery/settlement contract. [Settlement proof v1](docs/NATIVE-CALLABLE-SETTLEMENT-PROOF-V1.md) specifies the private authority-free compiler/host proof envelope, while [callable ABI v3](docs/NATIVE-CALLABLE-ABI-V3.md) freezes the separate metadata-only physical contract. [Conformance trace v1](docs/CONFORMANCE-TRACE-V1.md) fixes the target-neutral status/trace projection, and [host ownership transactions v1](docs/HOST-OWNERSHIP-TRANSACTIONS-V1.md) fixes the preflight/commit/publication semantics that future ecosystem adapters must preserve. [The architecture](docs/ARCHITECTURE.md) describes the current implementation, [the quality gates](docs/QUALITY-GATES.md) define executable contribution evidence, [protocol migrations](docs/MIGRATIONS.md) cover agent-facing compatibility, [the roadmap](docs/ROADMAP.md) gives the staged path forward, and the [full-goal completion matrix](docs/COMPLETION-MATRIX.md) records requirement-by-requirement evidence. ## Status diff --git a/crates/semaprax-native-host/src/descriptor_v3.rs b/crates/semaprax-native-host/src/descriptor_v3.rs new file mode 100644 index 0000000..549dc1d --- /dev/null +++ b/crates/semaprax-native-host/src/descriptor_v3.rs @@ -0,0 +1,2179 @@ +//! Independent strict decoder for the metadata-only callable native descriptor v3. +//! +//! This module intentionally does not share a reader, writer, constants, or +//! semantic types with the compiler encoder or the callable-v2/proof decoders. +//! Successfully decoding these bytes grants no runtime authority, loads no +//! image, and permits no physical finalizer. + +#![forbid(unsafe_code)] +#![cfg_attr( + not(test), + allow( + dead_code, + reason = "callable descriptor v3 remains private admission metadata" + ) +)] + +use std::collections::HashSet; + +use sha2::{Digest, Sha256}; + +const MAGIC: &[u8; 8] = b"SPXNABI3"; +const VERSION: u32 = 3; +const HEADER_SIZE: u32 = 20; +const LINKAGE_DYNAMIC: u32 = 1; +const LINKAGE_IOS_STATIC: u32 = 2; +const CALL_ABI_TAG: u32 = 3; +const REQUIRED_OBLIGATIONS: u32 = 0x03ff; +const OWNED_PAYLOAD_WIRE_KIND: u32 = 1; +const GRAPH_VERSION: u32 = 3; + +const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024; +const MAX_TEXT_BYTES: usize = 64 * 1024; +const MAX_SYMBOL_BYTES: usize = 1024; +const MAX_WIRE_BYTES: u32 = 1024 * 1024; +const MAX_EVENT_COUNT: u32 = 65_536; +const MAX_DICTIONARY_BYTES: u32 = 1024 * 1024; +const MAX_DICTIONARY_ENTRIES: u32 = 65_536; +const MAX_RESOURCES: u32 = 4_096; +const MAX_CHECKPOINTS: u32 = 65_536; +const MAX_GRAPH_WORK_UNITS: u32 = 1_000_000; +const MAX_ACTIVE_FRAMES: u32 = 256; +const MAX_QUARANTINED_FRAMES: u32 = 64; +const MAX_INSTANCE_RESERVED_BYTES: u32 = 64 * 1024 * 1024; + +const MIN_SCALAR_PARAMETER_BYTES: usize = 4 + 4 + 4 + 1 + 4; +const MIN_RESULT_BYTES: usize = 4; + +const REQUEST_FIXED_BYTES: u32 = 104; +const REQUEST_I64_BYTES: u32 = 16; +const REQUEST_BOOL_BYTES: u32 = 12; +const REQUEST_OWNER_BYTES: u32 = 20; +const EXECUTE_RESPONSE_FIXED_BYTES: u32 = 124; +const EXECUTE_RESPONSE_EVENT_BYTES: u32 = 4; +const FRAME_FIXED_BYTES: u32 = 208; +const FRAME_RESOURCE_BYTES: u32 = 4; +const DECISION_BYTES: u32 = 172; +const ACTION_EVIDENCE_BYTES: u32 = 188; +const CANDIDATE_RECEIPT_BYTES: u32 = 264; +const CANONICAL_ACTIVE_FRAMES: u32 = 256; +const CANONICAL_QUARANTINED_FRAMES: u32 = 64; + +const SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-descriptor-schema.v3\0"; +const TARGET_DOMAIN: &[u8] = b"semaprax.native-callable-target.v3\0"; +const PHYSICAL_MODULE_DOMAIN: &[u8] = b"semaprax.native-callable-physical-module.v3\0"; +const GRAPH_DOMAIN: &[u8] = b"semaprax.native-callable-settlement-graph.v3\0"; +const REQUEST_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-request-schema.v3\0"; +const EXECUTE_RESPONSE_SCHEMA_DOMAIN: &[u8] = + b"semaprax.native-callable-execute-response-schema.v3\0"; +const FRAME_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-frame-schema.v3\0"; +const DECISION_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-decision-schema.v3\0"; +const ACTION_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-action-schema.v3\0"; +const CANDIDATE_RECEIPT_SCHEMA_DOMAIN: &[u8] = + b"semaprax.native-callable-candidate-receipt-schema.v3\0"; +const COMMITTED_RECEIPT_SCHEMA_DOMAIN: &[u8] = + b"semaprax.native-callable-committed-receipt-schema.v3\0"; +const CALL_ABI_DOMAIN: &[u8] = b"semaprax.native-callable-c-abi.v3\0"; +const CALL_CONTRACT_DOMAIN: &[u8] = b"semaprax.native-callable-contract.v3\0"; +const SYMBOL_SEED_DOMAIN: &[u8] = b"semaprax.native-callable-symbol-seed.v3\0"; +const GETTER_SYMBOL_DOMAIN: &[u8] = b"semaprax.native-callable-getter.v3\0"; +const EXECUTE_SYMBOL_DOMAIN: &[u8] = b"semaprax.native-callable-execute.v3\0"; +const SETTLE_SYMBOL_DOMAIN: &[u8] = b"semaprax.native-callable-settle.v3\0"; +const TRACE_EVIDENCE_DOMAIN: &[u8] = b"semaprax.native-recovery-trace-evidence.v1\0"; + +const DESCRIPTOR_SCHEMA_STATEMENT: &[u8] = b"SPXNABI3;u32le;header=20;sequential-no-offsets-no-trailing;target;linkage-profile;19-fingerprints;module;function;getter;execute;settle;abi-tag;obligations;15-capacities;signature;graph-len;graph"; +const REQUEST_SCHEMA_STATEMENT: &[u8] = b"SPXNRQ03;u32le-envelope;call-contract32;invocation-u64;frame-generation-u64;provider-challenge32;argument-count;ordered-indexed-arguments;scalar-or-owned-u64-payload"; +const EXECUTE_RESPONSE_SCHEMA_STATEMENT: &[u8] = b"SPXNEX03;u32le-envelope;call-contract32;invocation-u64;frame-generation-u64;provider-challenge32;checkpoint;outcome;result-payload;event-count;event-ordinals"; +const FRAME_SCHEMA_STATEMENT: &[u8] = b"SPXNFR03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;checkpoint;phase;resource-count;resource-states;pre-candidate-digest32"; +const DECISION_SCHEMA_STATEMENT: &[u8] = b"SPXNDC03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;decision-tag;decision-detail"; +const ACTION_SCHEMA_STATEMENT: &[u8] = b"SPXNAC03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;action-index;action-tag;owner-ordinal;before-state;after-state;checkpoint"; +const CANDIDATE_RECEIPT_SCHEMA_STATEMENT: &[u8] = b"SPXNCR03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;pre-candidate-frame-digest32;decision-digest32;action-evidence-digest32;candidate-outcome"; +const COMMITTED_RECEIPT_SCHEMA_STATEMENT: &[u8] = b"SPXHRP03;u32le-envelope;host-only-HMAC-SHA256;exact-instance-capability;call-contract;invocation;frame-generation;provider-challenge;candidate-digest;ledger-before;ledger-after;decision;action-evidence-digest;publication-result;atomic-ledger-and-receipt-visibility"; +const CALL_ABI_STATEMENT: &[u8] = b"extern-C;getter=const-u8-ptr(void);execute=u32(const-u8-ptr,u32,u8-ptr,u32);settle=u32(u8-ptr,u32,const-u8-ptr,u32,u8-ptr,u32);windows-cdecl;synchronous;same-thread;no-unwind;no-longjmp;no-callbacks;no-retained-pointers;no-reentrancy"; + +const PARAMETER_SCALAR: u32 = 1; +const PARAMETER_OWNED_RESOURCE: u32 = 2; +const SCALAR_I64: u32 = 1; +const SCALAR_BOOL: u32 = 2; +const RESULT_SCALAR_I64: u32 = 1; +const RESULT_OWNED_INPUT: u32 = 2; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Linkage { + Dynamic, + IosStatic, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Fingerprints { + pub(crate) schema: [u8; 32], + pub(crate) target: [u8; 32], + pub(crate) semantic_module: [u8; 32], + pub(crate) physical_module: [u8; 32], + pub(crate) function_template: [u8; 32], + pub(crate) execution_cleanup: [u8; 32], + pub(crate) event_dictionary: [u8; 32], + pub(crate) trace_path_certificate: [u8; 32], + pub(crate) recovery_contract: [u8; 32], + pub(crate) settlement_graph: [u8; 32], + pub(crate) request_schema: [u8; 32], + pub(crate) execute_response_schema: [u8; 32], + pub(crate) frame_schema: [u8; 32], + pub(crate) decision_schema: [u8; 32], + pub(crate) action_schema: [u8; 32], + pub(crate) candidate_receipt_schema: [u8; 32], + pub(crate) committed_receipt_schema: [u8; 32], + pub(crate) call_abi: [u8; 32], + pub(crate) call_contract: [u8; 32], +} + +impl Fingerprints { + fn iter(&self) -> impl Iterator { + [ + &self.schema, + &self.target, + &self.semantic_module, + &self.physical_module, + &self.function_template, + &self.execution_cleanup, + &self.event_dictionary, + &self.trace_path_certificate, + &self.recovery_contract, + &self.settlement_graph, + &self.request_schema, + &self.execute_response_schema, + &self.frame_schema, + &self.decision_schema, + &self.action_schema, + &self.candidate_receipt_schema, + &self.committed_receipt_schema, + &self.call_abi, + &self.call_contract, + ] + .into_iter() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ScalarKind { + I64, + Bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Parameter { + Scalar { + index: usize, + value: String, + kind: ScalarKind, + }, + Owned { + index: usize, + value: String, + owner_ordinal: usize, + resource: String, + lifecycle: String, + payload_wire_kind: u32, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ResultShape { + ScalarI64, + OwnedInput { + parameter_index: usize, + owner_ordinal: usize, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Capacities { + pub(crate) request: u32, + pub(crate) execute_response: u32, + pub(crate) frame: u32, + pub(crate) decision: u32, + pub(crate) action_evidence: u32, + pub(crate) candidate_receipt: u32, + pub(crate) event_count: u32, + pub(crate) dictionary_bytes: u32, + pub(crate) dictionary_entries: u32, + pub(crate) resource_count: u32, + pub(crate) checkpoint_count: u32, + pub(crate) graph_work_units: u32, + pub(crate) active_frames: u32, + pub(crate) quarantined_frames: u32, + pub(crate) instance_reserved_bytes: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ResourceState { + Live, + ProvisionalResult, + Finalizing, + Dead, + Published, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum Outcome { + ScalarSuccess, + SemanticFailure, + OwnedSuccess(u32), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum TraceOutcome { + ScalarSuccess, + OwnedSuccess, + Failure { selected_ordinal: u32 }, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct TraceEvidence { + pub(crate) digest: [u8; 32], + pub(crate) ordinals: Vec, + pub(crate) outcome: TraceOutcome, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) enum Action { + Finalize(u32), + StageOwnedResult(u32), + CertifyOutcome(TraceEvidence), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Checkpoint { + pub(crate) id: u32, + pub(crate) resources: Vec, + pub(crate) outcome: Option, + pub(crate) abort_order: Vec, + pub(crate) accept_order: Vec, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct Edge { + pub(crate) from: u32, + pub(crate) to: u32, + pub(crate) action: Action, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SettlementGraph { + pub(crate) function: String, + pub(crate) recovery_contract: [u8; 32], + pub(crate) execution_cleanup: [u8; 32], + pub(crate) trace_path_certificate: [u8; 32], + pub(crate) resource_count: usize, + pub(crate) checkpoints: Vec, + pub(crate) starts: Vec, + pub(crate) edges: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Descriptor { + pub(crate) target: String, + pub(crate) linkage: Linkage, + pub(crate) fingerprints: Fingerprints, + pub(crate) module: String, + pub(crate) function: String, + pub(crate) getter_symbol: String, + pub(crate) execute_symbol: String, + pub(crate) settle_symbol: String, + pub(crate) call_abi_tag: u32, + pub(crate) obligations: u32, + pub(crate) capacities: Capacities, + pub(crate) parameters: Vec, + pub(crate) result: ResultShape, + pub(crate) graph: SettlementGraph, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DescriptorError { + Malformed, + UnsupportedSchema, + WrongTarget, + NonCanonical, + ArtifactMismatch, +} + +impl Descriptor { + pub(crate) fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DESCRIPTOR_BYTES || bytes.len() < HEADER_SIZE as usize { + return Err(DescriptorError::Malformed); + } + let mut reader = Reader::new(bytes); + if reader.take(8)? != MAGIC || reader.u32()? != VERSION || reader.u32()? != HEADER_SIZE { + return Err(DescriptorError::UnsupportedSchema); + } + if reader.usize()? != bytes.len() { + return Err(DescriptorError::Malformed); + } + let target = reader.text(MAX_TEXT_BYTES)?; + if target != current_target_tag()? { + return Err(DescriptorError::WrongTarget); + } + let linkage = match reader.u32()? { + LINKAGE_DYNAMIC => Linkage::Dynamic, + LINKAGE_IOS_STATIC => Linkage::IosStatic, + _ => return Err(DescriptorError::UnsupportedSchema), + }; + if !linkage_matches_target(linkage) { + return Err(DescriptorError::WrongTarget); + } + let fingerprints = Fingerprints { + schema: reader.fingerprint()?, + target: reader.fingerprint()?, + semantic_module: reader.fingerprint()?, + physical_module: reader.fingerprint()?, + function_template: reader.fingerprint()?, + execution_cleanup: reader.fingerprint()?, + event_dictionary: reader.fingerprint()?, + trace_path_certificate: reader.fingerprint()?, + recovery_contract: reader.fingerprint()?, + settlement_graph: reader.fingerprint()?, + request_schema: reader.fingerprint()?, + execute_response_schema: reader.fingerprint()?, + frame_schema: reader.fingerprint()?, + decision_schema: reader.fingerprint()?, + action_schema: reader.fingerprint()?, + candidate_receipt_schema: reader.fingerprint()?, + committed_receipt_schema: reader.fingerprint()?, + call_abi: reader.fingerprint()?, + call_contract: reader.fingerprint()?, + }; + if fingerprints.schema != schema_fingerprint() { + return Err(DescriptorError::UnsupportedSchema); + } + if fingerprints.target != target_fingerprint(target.as_bytes()) { + return Err(DescriptorError::WrongTarget); + } + if fingerprints.iter().any(|value| *value == [0; 32]) { + return Err(DescriptorError::NonCanonical); + } + + let module = reader.text(MAX_TEXT_BYTES)?; + let function = reader.text(MAX_TEXT_BYTES)?; + if fingerprints.physical_module + != physical_module_fingerprint( + &fingerprints.schema, + &fingerprints.target, + &fingerprints.semantic_module, + module.as_bytes(), + linkage, + ) + { + return Err(DescriptorError::NonCanonical); + } + let getter_symbol = reader.text(MAX_SYMBOL_BYTES)?; + let execute_symbol = reader.text(MAX_SYMBOL_BYTES)?; + let settle_symbol = reader.text(MAX_SYMBOL_BYTES)?; + let symbols = [&getter_symbol, &execute_symbol, &settle_symbol]; + if symbols.iter().any(|symbol| !is_c_symbol(symbol)) + || symbols[0] == symbols[1] + || symbols[0] == symbols[2] + || symbols[1] == symbols[2] + { + return Err(DescriptorError::NonCanonical); + } + + let call_abi_tag = reader.u32()?; + if call_abi_tag != CALL_ABI_TAG { + return Err(DescriptorError::UnsupportedSchema); + } + let obligations = reader.u32()?; + if obligations != REQUIRED_OBLIGATIONS { + return Err(DescriptorError::NonCanonical); + } + if fingerprints.request_schema != request_schema_fingerprint() + || fingerprints.execute_response_schema != execute_response_schema_fingerprint() + || fingerprints.frame_schema != frame_schema_fingerprint() + || fingerprints.decision_schema != decision_schema_fingerprint() + || fingerprints.action_schema != action_schema_fingerprint() + || fingerprints.candidate_receipt_schema != candidate_receipt_schema_fingerprint() + || fingerprints.committed_receipt_schema != committed_receipt_schema_fingerprint() + || fingerprints.call_abi != call_abi_fingerprint() + || fingerprints.candidate_receipt_schema == fingerprints.committed_receipt_schema + { + return Err(DescriptorError::UnsupportedSchema); + } + + let capacities = Capacities { + request: reader.u32()?, + execute_response: reader.u32()?, + frame: reader.u32()?, + decision: reader.u32()?, + action_evidence: reader.u32()?, + candidate_receipt: reader.u32()?, + event_count: reader.u32()?, + dictionary_bytes: reader.u32()?, + dictionary_entries: reader.u32()?, + resource_count: reader.u32()?, + checkpoint_count: reader.u32()?, + graph_work_units: reader.u32()?, + active_frames: reader.u32()?, + quarantined_frames: reader.u32()?, + instance_reserved_bytes: reader.u32()?, + }; + validate_capacity_ceilings(&capacities)?; + + let parameter_count = reader.usize()?; + let maximum_structural_count = + reader.remaining().saturating_sub(MIN_RESULT_BYTES + 4) / MIN_SCALAR_PARAMETER_BYTES; + if parameter_count > maximum_structural_count { + return Err(DescriptorError::NonCanonical); + } + let mut parameters = Vec::with_capacity(parameter_count); + let mut values = HashSet::with_capacity(parameter_count); + let mut next_owner = 0_usize; + for expected_index in 0..parameter_count { + let tag = reader.u32()?; + let index = reader.usize()?; + if index != expected_index { + return Err(DescriptorError::NonCanonical); + } + let value = reader.text(MAX_TEXT_BYTES)?; + if !values.insert(value.clone()) { + return Err(DescriptorError::NonCanonical); + } + match tag { + PARAMETER_SCALAR => { + let kind = match reader.u32()? { + SCALAR_I64 => ScalarKind::I64, + SCALAR_BOOL => ScalarKind::Bool, + _ => return Err(DescriptorError::NonCanonical), + }; + parameters.push(Parameter::Scalar { index, value, kind }); + } + PARAMETER_OWNED_RESOURCE => { + let owner_ordinal = reader.usize()?; + if owner_ordinal != next_owner { + return Err(DescriptorError::NonCanonical); + } + next_owner = next_owner + .checked_add(1) + .ok_or(DescriptorError::Malformed)?; + let resource = reader.text(MAX_TEXT_BYTES)?; + let lifecycle = reader.text(MAX_TEXT_BYTES)?; + let payload_wire_kind = reader.u32()?; + if payload_wire_kind != OWNED_PAYLOAD_WIRE_KIND { + return Err(DescriptorError::UnsupportedSchema); + } + parameters.push(Parameter::Owned { + index, + value, + owner_ordinal, + resource, + lifecycle, + payload_wire_kind, + }); + } + _ => return Err(DescriptorError::NonCanonical), + } + } + let result = match reader.u32()? { + RESULT_SCALAR_I64 => ResultShape::ScalarI64, + RESULT_OWNED_INPUT => { + let parameter_index = reader.usize()?; + let value = reader.text(MAX_TEXT_BYTES)?; + let owner_ordinal = reader.usize()?; + let Some(Parameter::Owned { + index, + value: expected_value, + owner_ordinal: expected_ordinal, + .. + }) = parameters.get(parameter_index) + else { + return Err(DescriptorError::NonCanonical); + }; + if *index != parameter_index + || *expected_value != value + || *expected_ordinal != owner_ordinal + { + return Err(DescriptorError::NonCanonical); + } + ResultShape::OwnedInput { + parameter_index, + owner_ordinal, + } + } + _ => return Err(DescriptorError::NonCanonical), + }; + validate_exact_capacities(&capacities, ¶meters)?; + + let graph_len = reader.usize()?; + if graph_len == 0 || graph_len > reader.remaining() { + return Err(DescriptorError::NonCanonical); + } + let graph_bytes = reader.take(graph_len)?; + if !reader.is_finished() { + return Err(DescriptorError::Malformed); + } + if fingerprints.settlement_graph != graph_fingerprint(graph_bytes) { + return Err(DescriptorError::ArtifactMismatch); + } + let graph = SettlementGraph::parse(graph_bytes, &capacities)?; + if encode_graph(&graph)? != graph_bytes { + return Err(DescriptorError::NonCanonical); + } + validate_cross_bindings( + &fingerprints, + &function, + ¶meters, + &result, + &capacities, + &graph, + )?; + + let expected_contract = call_contract_fingerprint( + &target, + linkage, + &fingerprints, + &module, + &function, + &capacities, + ¶meters, + &result, + ); + if fingerprints.call_contract != expected_contract { + return Err(DescriptorError::ArtifactMismatch); + } + let expected_symbols = derive_symbols(&fingerprints); + if ( + getter_symbol.as_str(), + execute_symbol.as_str(), + settle_symbol.as_str(), + ) != ( + expected_symbols.0.as_str(), + expected_symbols.1.as_str(), + expected_symbols.2.as_str(), + ) { + return Err(DescriptorError::NonCanonical); + } + + let descriptor = Self { + target, + linkage, + fingerprints, + module, + function, + getter_symbol, + execute_symbol, + settle_symbol, + call_abi_tag, + obligations, + capacities, + parameters, + result, + graph, + }; + if encode_descriptor(&descriptor)? != bytes { + return Err(DescriptorError::NonCanonical); + } + Ok(descriptor) + } +} + +impl SettlementGraph { + fn parse(bytes: &[u8], capacities: &Capacities) -> Result { + let mut reader = Reader::new(bytes); + if reader.u32()? != GRAPH_VERSION { + return Err(DescriptorError::UnsupportedSchema); + } + let function = reader.text(MAX_TEXT_BYTES)?; + let recovery_contract = reader.fingerprint()?; + let execution_cleanup = reader.fingerprint()?; + let trace_path_certificate = reader.fingerprint()?; + if [recovery_contract, execution_cleanup, trace_path_certificate].contains(&[0; 32]) { + return Err(DescriptorError::NonCanonical); + } + let resource_count = reader.usize()?; + let checkpoint_count = reader.usize()?; + if resource_count == 0 + || resource_count != capacities.resource_count as usize + || checkpoint_count == 0 + || checkpoint_count != capacities.checkpoint_count as usize + { + return Err(DescriptorError::NonCanonical); + } + let base_work = resource_count + .checked_mul(checkpoint_count) + .ok_or(DescriptorError::Malformed)?; + let minimum_checkpoint = 20_usize + .checked_add( + resource_count + .checked_mul(4) + .ok_or(DescriptorError::Malformed)?, + ) + .ok_or(DescriptorError::Malformed)?; + if checkpoint_count > reader.remaining() / minimum_checkpoint { + return Err(DescriptorError::Malformed); + } + let mut checkpoints = Vec::with_capacity(checkpoint_count); + for index in 0..checkpoint_count { + let id = reader.u32()?; + if id != u32::try_from(index + 1).map_err(|_| DescriptorError::Malformed)? { + return Err(DescriptorError::NonCanonical); + } + if reader.usize()? != resource_count { + return Err(DescriptorError::NonCanonical); + } + let mut resources = Vec::with_capacity(resource_count); + for _ in 0..resource_count { + resources.push(match reader.u32()? { + 1 => ResourceState::Live, + 2 => ResourceState::ProvisionalResult, + 3 => ResourceState::Finalizing, + 4 => ResourceState::Dead, + 5 => ResourceState::Published, + _ => return Err(DescriptorError::NonCanonical), + }); + } + let outcome = match reader.u32()? { + 0 => None, + 1 => Some(Outcome::ScalarSuccess), + 2 => Some(Outcome::SemanticFailure), + 3 => Some(Outcome::OwnedSuccess(reader.u32()?)), + _ => return Err(DescriptorError::NonCanonical), + }; + let abort_order = reader.ordinals(resource_count)?; + let accept_order = reader.ordinals(resource_count)?; + let checkpoint = Checkpoint { + id, + resources, + outcome, + abort_order, + accept_order, + }; + validate_checkpoint(&checkpoint, resource_count)?; + checkpoints.push(checkpoint); + } + let starts = reader.ordinals(checkpoint_count)?; + let edge_count = reader.usize()?; + if base_work != capacities.graph_work_units as usize || edge_count > reader.remaining() / 12 + { + return Err(DescriptorError::NonCanonical); + } + let mut edges = Vec::with_capacity(edge_count); + for _ in 0..edge_count { + let from = reader.u32()?; + let to = reader.u32()?; + let action = match reader.u32()? { + 1 => Action::Finalize(reader.u32()?), + 2 => Action::StageOwnedResult(reader.u32()?), + 3 => { + let digest = reader.fingerprint()?; + let ordinal_count = reader.usize()?; + let maximum_structural_count = reader.remaining().saturating_sub(4) / 4; + if ordinal_count > capacities.event_count as usize + || ordinal_count > maximum_structural_count + { + return Err(DescriptorError::NonCanonical); + } + let mut ordinals = Vec::with_capacity(ordinal_count); + for _ in 0..ordinal_count { + let ordinal = reader.u32()?; + if ordinal == 0 || ordinal > capacities.dictionary_entries { + return Err(DescriptorError::NonCanonical); + } + ordinals.push(ordinal); + } + let outcome = match reader.u32()? { + 1 => TraceOutcome::ScalarSuccess, + 2 => TraceOutcome::OwnedSuccess, + 3 => { + let selected_ordinal = reader.u32()?; + if selected_ordinal == 0 + || selected_ordinal > capacities.dictionary_entries + || !ordinals.contains(&selected_ordinal) + { + return Err(DescriptorError::NonCanonical); + } + TraceOutcome::Failure { selected_ordinal } + } + _ => return Err(DescriptorError::NonCanonical), + }; + let expected = + trace_evidence_fingerprint(&trace_path_certificate, &ordinals, outcome); + if digest == [0; 32] || digest != expected { + return Err(DescriptorError::ArtifactMismatch); + } + Action::CertifyOutcome(TraceEvidence { + digest, + ordinals, + outcome, + }) + } + _ => return Err(DescriptorError::NonCanonical), + }; + edges.push(Edge { from, to, action }); + } + if !reader.is_finished() { + return Err(DescriptorError::Malformed); + } + validate_progress(&checkpoints, &starts, &edges)?; + Ok(Self { + function, + recovery_contract, + execution_cleanup, + trace_path_certificate, + resource_count, + checkpoints, + starts, + edges, + }) + } +} + +fn validate_checkpoint( + checkpoint: &Checkpoint, + resource_count: usize, +) -> Result<(), DescriptorError> { + if checkpoint.resources.len() != resource_count + || checkpoint + .resources + .iter() + .any(|state| matches!(state, ResourceState::Finalizing | ResourceState::Published)) + { + return Err(DescriptorError::NonCanonical); + } + let provisional = checkpoint + .resources + .iter() + .enumerate() + .filter_map(|(ordinal, state)| { + (*state == ResourceState::ProvisionalResult).then_some(ordinal as u32) + }) + .collect::>(); + if provisional.len() > 1 { + return Err(DescriptorError::NonCanonical); + } + let abort_required = checkpoint + .resources + .iter() + .enumerate() + .filter_map(|(ordinal, state)| (*state != ResourceState::Dead).then_some(ordinal as u32)) + .collect::>(); + validate_exact_order(&checkpoint.abort_order, &abort_required)?; + let accept_required = checkpoint + .resources + .iter() + .enumerate() + .filter_map(|(ordinal, state)| (*state == ResourceState::Live).then_some(ordinal as u32)) + .collect::>(); + match checkpoint.outcome { + None if checkpoint.accept_order.is_empty() => {} + None => return Err(DescriptorError::NonCanonical), + Some(Outcome::ScalarSuccess | Outcome::SemanticFailure) if provisional.is_empty() => { + validate_exact_order(&checkpoint.accept_order, &accept_required)?; + } + Some(Outcome::OwnedSuccess(owner)) if provisional.as_slice() == [owner] => { + validate_exact_order(&checkpoint.accept_order, &accept_required)?; + } + Some(_) => return Err(DescriptorError::NonCanonical), + } + Ok(()) +} + +fn validate_exact_order(order: &[u32], required: &HashSet) -> Result<(), DescriptorError> { + let actual = order.iter().copied().collect::>(); + if actual.len() != order.len() || actual != *required { + return Err(DescriptorError::NonCanonical); + } + Ok(()) +} + +fn validate_progress( + checkpoints: &[Checkpoint], + starts: &[u32], + edges: &[Edge], +) -> Result<(), DescriptorError> { + if starts != [1] + || checkpoints[0].outcome.is_some() + || checkpoints[0] + .resources + .iter() + .any(|state| *state != ResourceState::Live) + { + return Err(DescriptorError::NonCanonical); + } + let mut seen_edges = HashSet::new(); + let mut seen_actions = HashSet::new(); + let mut reachable = HashSet::from([1_u32]); + let mut outgoing = HashSet::new(); + for edge in edges { + if edge.from == 0 + || edge.to == 0 + || edge.from >= edge.to + || edge.to as usize > checkpoints.len() + || !seen_edges.insert(edge.clone()) + || !seen_actions.insert((edge.from, edge.action.clone())) + || !reachable.contains(&edge.from) + { + return Err(DescriptorError::NonCanonical); + } + let from = &checkpoints[(edge.from - 1) as usize]; + let to = &checkpoints[(edge.to - 1) as usize]; + if from.outcome.is_some() || !valid_transition(from, to, &edge.action) { + return Err(DescriptorError::NonCanonical); + } + outgoing.insert(edge.from); + reachable.insert(edge.to); + } + if reachable.len() != checkpoints.len() + || checkpoints + .iter() + .any(|checkpoint| checkpoint.outcome.is_none() != outgoing.contains(&checkpoint.id)) + { + return Err(DescriptorError::NonCanonical); + } + Ok(()) +} + +fn valid_transition(from: &Checkpoint, to: &Checkpoint, action: &Action) -> bool { + match action { + Action::Finalize(owner) => { + let Some(position) = from + .abort_order + .iter() + .position(|candidate| candidate == owner) + else { + return false; + }; + let prefix_only_provisional = from.abort_order[..position].iter().all(|ordinal| { + from.resources[*ordinal as usize] == ResourceState::ProvisionalResult + }); + let mut expected_abort = from.abort_order.clone(); + expected_abort.remove(position); + let state_transition = + changed_state(from, to, *owner, ResourceState::Live, ResourceState::Dead) + || changed_state( + from, + to, + *owner, + ResourceState::ProvisionalResult, + ResourceState::Dead, + ); + to.outcome.is_none() + && state_transition + && from.accept_order.is_empty() + && to.accept_order.is_empty() + && prefix_only_provisional + && to.abort_order == expected_abort + } + Action::StageOwnedResult(owner) => { + to.outcome.is_none() + && changed_state( + from, + to, + *owner, + ResourceState::Live, + ResourceState::ProvisionalResult, + ) + && from.accept_order.is_empty() + && to.accept_order.is_empty() + && from.abort_order == to.abort_order + } + Action::CertifyOutcome(trace) => { + let expected_accept = to + .abort_order + .iter() + .copied() + .filter(|ordinal| to.resources[*ordinal as usize] == ResourceState::Live) + .collect::>(); + from.outcome.is_none() + && to.outcome.is_some() + && from.resources == to.resources + && from.accept_order.is_empty() + && from.abort_order == to.abort_order + && to.accept_order == expected_accept + && trace.digest != [0; 32] + && matches!( + (to.outcome, trace.outcome), + (Some(Outcome::ScalarSuccess), TraceOutcome::ScalarSuccess) + | (Some(Outcome::OwnedSuccess(_)), TraceOutcome::OwnedSuccess) + | (Some(Outcome::SemanticFailure), TraceOutcome::Failure { .. }) + ) + } + } +} + +fn changed_state( + from: &Checkpoint, + to: &Checkpoint, + owner: u32, + expected_from: ResourceState, + expected_to: ResourceState, +) -> bool { + let Ok(owner) = usize::try_from(owner) else { + return false; + }; + owner < from.resources.len() + && from.resources.len() == to.resources.len() + && from + .resources + .iter() + .zip(&to.resources) + .enumerate() + .all(|(index, (left, right))| { + if index == owner { + *left == expected_from && *right == expected_to + } else { + left == right + } + }) +} + +fn validate_capacity_ceilings(capacities: &Capacities) -> Result<(), DescriptorError> { + if [ + capacities.request, + capacities.execute_response, + capacities.frame, + capacities.decision, + capacities.action_evidence, + capacities.candidate_receipt, + ] + .iter() + .any(|value| *value == 0 || *value > MAX_WIRE_BYTES) + || capacities.event_count == 0 + || capacities.event_count > MAX_EVENT_COUNT + || capacities.dictionary_bytes == 0 + || capacities.dictionary_bytes > MAX_DICTIONARY_BYTES + || capacities.dictionary_entries == 0 + || capacities.dictionary_entries > MAX_DICTIONARY_ENTRIES + || capacities.resource_count == 0 + || capacities.resource_count > MAX_RESOURCES + || capacities.checkpoint_count == 0 + || capacities.checkpoint_count > MAX_CHECKPOINTS + || capacities.graph_work_units == 0 + || capacities.graph_work_units > MAX_GRAPH_WORK_UNITS + || capacities.active_frames == 0 + || capacities.active_frames > MAX_ACTIVE_FRAMES + || capacities.quarantined_frames == 0 + || capacities.quarantined_frames > MAX_QUARANTINED_FRAMES + || capacities.instance_reserved_bytes == 0 + || capacities.instance_reserved_bytes > MAX_INSTANCE_RESERVED_BYTES + { + return Err(DescriptorError::NonCanonical); + } + Ok(()) +} + +fn validate_exact_capacities( + capacities: &Capacities, + parameters: &[Parameter], +) -> Result<(), DescriptorError> { + let mut request = REQUEST_FIXED_BYTES; + for parameter in parameters { + request = request + .checked_add(match parameter { + Parameter::Scalar { + kind: ScalarKind::I64, + .. + } => REQUEST_I64_BYTES, + Parameter::Scalar { + kind: ScalarKind::Bool, + .. + } => REQUEST_BOOL_BYTES, + Parameter::Owned { .. } => REQUEST_OWNER_BYTES, + }) + .ok_or(DescriptorError::Malformed)?; + } + let execute_response = capacities + .event_count + .checked_mul(EXECUTE_RESPONSE_EVENT_BYTES) + .and_then(|events| EXECUTE_RESPONSE_FIXED_BYTES.checked_add(events)) + .ok_or(DescriptorError::Malformed)?; + let frame = capacities + .resource_count + .checked_mul(FRAME_RESOURCE_BYTES) + .and_then(|resources| FRAME_FIXED_BYTES.checked_add(resources)) + .ok_or(DescriptorError::Malformed)?; + let per_active = request + .checked_add(execute_response) + .and_then(|value| value.checked_add(frame)) + .and_then(|value| value.checked_add(DECISION_BYTES)) + .and_then(|value| value.checked_add(ACTION_EVIDENCE_BYTES)) + .and_then(|value| value.checked_add(CANDIDATE_RECEIPT_BYTES)) + .ok_or(DescriptorError::Malformed)?; + let active_reserved = CANONICAL_ACTIVE_FRAMES + .checked_mul(per_active) + .ok_or(DescriptorError::Malformed)?; + let quarantined_reserved = CANONICAL_QUARANTINED_FRAMES + .checked_mul( + frame + .checked_add(CANDIDATE_RECEIPT_BYTES) + .ok_or(DescriptorError::Malformed)?, + ) + .ok_or(DescriptorError::Malformed)?; + let instance_reserved = active_reserved + .checked_add(quarantined_reserved) + .ok_or(DescriptorError::Malformed)?; + if capacities.request != request + || capacities.execute_response != execute_response + || capacities.frame != frame + || capacities.decision != DECISION_BYTES + || capacities.action_evidence != ACTION_EVIDENCE_BYTES + || capacities.candidate_receipt != CANDIDATE_RECEIPT_BYTES + || capacities.active_frames != CANONICAL_ACTIVE_FRAMES + || capacities.quarantined_frames != CANONICAL_QUARANTINED_FRAMES + || capacities.instance_reserved_bytes != instance_reserved + { + return Err(DescriptorError::NonCanonical); + } + Ok(()) +} + +fn validate_cross_bindings( + fingerprints: &Fingerprints, + function: &str, + parameters: &[Parameter], + result: &ResultShape, + capacities: &Capacities, + graph: &SettlementGraph, +) -> Result<(), DescriptorError> { + if graph.function != function + || graph.recovery_contract != fingerprints.recovery_contract + || graph.execution_cleanup != fingerprints.execution_cleanup + || graph.trace_path_certificate != fingerprints.trace_path_certificate + { + return Err(DescriptorError::ArtifactMismatch); + } + let owned_count = parameters + .iter() + .filter(|parameter| matches!(parameter, Parameter::Owned { .. })) + .count(); + if owned_count != graph.resource_count || owned_count != capacities.resource_count as usize { + return Err(DescriptorError::ArtifactMismatch); + } + for checkpoint in &graph.checkpoints { + match (*result, checkpoint.outcome) { + (_, None | Some(Outcome::SemanticFailure)) => {} + (ResultShape::ScalarI64, Some(Outcome::ScalarSuccess)) => {} + ( + ResultShape::OwnedInput { owner_ordinal, .. }, + Some(Outcome::OwnedSuccess(graph_owner)), + ) if owner_ordinal == graph_owner as usize => {} + _ => return Err(DescriptorError::ArtifactMismatch), + } + } + Ok(()) +} + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn remaining(&self) -> usize { + self.bytes.len().saturating_sub(self.offset) + } + + fn is_finished(&self) -> bool { + self.offset == self.bytes.len() + } + + fn take(&mut self, count: usize) -> Result<&'a [u8], DescriptorError> { + let end = self + .offset + .checked_add(count) + .ok_or(DescriptorError::Malformed)?; + let bytes = self + .bytes + .get(self.offset..end) + .ok_or(DescriptorError::Malformed)?; + self.offset = end; + Ok(bytes) + } + + fn u32(&mut self) -> Result { + let bytes: [u8; 4] = self + .take(4)? + .try_into() + .map_err(|_| DescriptorError::Malformed)?; + Ok(u32::from_le_bytes(bytes)) + } + + fn usize(&mut self) -> Result { + usize::try_from(self.u32()?).map_err(|_| DescriptorError::Malformed) + } + + fn fingerprint(&mut self) -> Result<[u8; 32], DescriptorError> { + self.take(32)? + .try_into() + .map_err(|_| DescriptorError::Malformed) + } + + fn text(&mut self, max: usize) -> Result { + let length = self.usize()?; + if length == 0 || length > max { + return Err(DescriptorError::NonCanonical); + } + let bytes = self.take(length)?; + let value = std::str::from_utf8(bytes).map_err(|_| DescriptorError::Malformed)?; + if value.contains('\0') { + return Err(DescriptorError::NonCanonical); + } + Ok(value.to_owned()) + } + + fn ordinals(&mut self, max: usize) -> Result, DescriptorError> { + let count = self.usize()?; + if count > max || count > self.remaining() / 4 { + return Err(DescriptorError::NonCanonical); + } + let mut values = Vec::with_capacity(count); + for _ in 0..count { + let ordinal = self.u32()?; + if ordinal as usize >= max { + return Err(DescriptorError::NonCanonical); + } + values.push(ordinal); + } + Ok(values) + } +} + +struct Writer { + bytes: Vec, +} + +impl Writer { + fn new() -> Self { + Self { bytes: Vec::new() } + } + + fn bytes(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + fn u32(&mut self, value: u32) { + self.bytes(&value.to_le_bytes()); + } + + fn usize(&mut self, value: usize) -> Result<(), DescriptorError> { + self.u32(u32::try_from(value).map_err(|_| DescriptorError::Malformed)?); + Ok(()) + } + + fn text(&mut self, value: &str) -> Result<(), DescriptorError> { + self.usize(value.len())?; + self.bytes(value.as_bytes()); + Ok(()) + } + + fn ordinals(&mut self, values: &[u32]) -> Result<(), DescriptorError> { + self.usize(values.len())?; + for value in values { + self.u32(*value); + } + Ok(()) + } + + fn finish(self) -> Vec { + self.bytes + } +} + +pub(crate) fn encode_graph(graph: &SettlementGraph) -> Result, DescriptorError> { + let mut writer = Writer::new(); + writer.u32(GRAPH_VERSION); + writer.text(&graph.function)?; + writer.bytes(&graph.recovery_contract); + writer.bytes(&graph.execution_cleanup); + writer.bytes(&graph.trace_path_certificate); + writer.usize(graph.resource_count)?; + writer.usize(graph.checkpoints.len())?; + for checkpoint in &graph.checkpoints { + writer.u32(checkpoint.id); + writer.usize(checkpoint.resources.len())?; + for state in &checkpoint.resources { + writer.u32(match state { + ResourceState::Live => 1, + ResourceState::ProvisionalResult => 2, + ResourceState::Finalizing => 3, + ResourceState::Dead => 4, + ResourceState::Published => 5, + }); + } + match checkpoint.outcome { + None => writer.u32(0), + Some(Outcome::ScalarSuccess) => writer.u32(1), + Some(Outcome::SemanticFailure) => writer.u32(2), + Some(Outcome::OwnedSuccess(owner)) => { + writer.u32(3); + writer.u32(owner); + } + } + writer.ordinals(&checkpoint.abort_order)?; + writer.ordinals(&checkpoint.accept_order)?; + } + writer.ordinals(&graph.starts)?; + writer.usize(graph.edges.len())?; + for edge in &graph.edges { + writer.u32(edge.from); + writer.u32(edge.to); + match &edge.action { + Action::Finalize(owner) => { + writer.u32(1); + writer.u32(*owner); + } + Action::StageOwnedResult(owner) => { + writer.u32(2); + writer.u32(*owner); + } + Action::CertifyOutcome(evidence) => { + writer.u32(3); + writer.bytes(&evidence.digest); + writer.usize(evidence.ordinals.len())?; + for ordinal in &evidence.ordinals { + writer.u32(*ordinal); + } + match evidence.outcome { + TraceOutcome::ScalarSuccess => writer.u32(1), + TraceOutcome::OwnedSuccess => writer.u32(2), + TraceOutcome::Failure { selected_ordinal } => { + writer.u32(3); + writer.u32(selected_ordinal); + } + } + } + } + } + Ok(writer.finish()) +} + +pub(crate) fn encode_descriptor(descriptor: &Descriptor) -> Result, DescriptorError> { + let mut writer = Writer::new(); + writer.bytes(MAGIC); + writer.u32(VERSION); + writer.u32(HEADER_SIZE); + writer.u32(0); + writer.text(&descriptor.target)?; + writer.u32(linkage_tag(descriptor.linkage)); + for fingerprint in descriptor.fingerprints.iter() { + writer.bytes(fingerprint); + } + writer.text(&descriptor.module)?; + writer.text(&descriptor.function)?; + writer.text(&descriptor.getter_symbol)?; + writer.text(&descriptor.execute_symbol)?; + writer.text(&descriptor.settle_symbol)?; + writer.u32(descriptor.call_abi_tag); + writer.u32(descriptor.obligations); + for capacity in capacity_values(&descriptor.capacities) { + writer.u32(capacity); + } + encode_signature(&mut writer, &descriptor.parameters, &descriptor.result)?; + let graph = encode_graph(&descriptor.graph)?; + writer.usize(graph.len())?; + writer.bytes(&graph); + let mut bytes = writer.finish(); + let total = u32::try_from(bytes.len()).map_err(|_| DescriptorError::Malformed)?; + bytes[16..20].copy_from_slice(&total.to_le_bytes()); + Ok(bytes) +} + +fn encode_signature( + writer: &mut Writer, + parameters: &[Parameter], + result: &ResultShape, +) -> Result<(), DescriptorError> { + writer.usize(parameters.len())?; + for parameter in parameters { + match parameter { + Parameter::Scalar { index, value, kind } => { + writer.u32(PARAMETER_SCALAR); + writer.usize(*index)?; + writer.text(value)?; + writer.u32(match kind { + ScalarKind::I64 => SCALAR_I64, + ScalarKind::Bool => SCALAR_BOOL, + }); + } + Parameter::Owned { + index, + value, + owner_ordinal, + resource, + lifecycle, + payload_wire_kind, + } => { + writer.u32(PARAMETER_OWNED_RESOURCE); + writer.usize(*index)?; + writer.text(value)?; + writer.usize(*owner_ordinal)?; + writer.text(resource)?; + writer.text(lifecycle)?; + writer.u32(*payload_wire_kind); + } + } + } + match result { + ResultShape::ScalarI64 => writer.u32(RESULT_SCALAR_I64), + ResultShape::OwnedInput { + parameter_index, + owner_ordinal, + } => { + writer.u32(RESULT_OWNED_INPUT); + writer.usize(*parameter_index)?; + let value = match ¶meters[*parameter_index] { + Parameter::Owned { value, .. } => value, + Parameter::Scalar { .. } => return Err(DescriptorError::NonCanonical), + }; + writer.text(value)?; + writer.usize(*owner_ordinal)?; + } + } + Ok(()) +} + +fn current_target_tag() -> Result { + let endian = if cfg!(target_endian = "little") { + "little" + } else { + "big" + }; + let environment = if cfg!(target_os = "android") { + "android" + } else if cfg!(target_env = "msvc") { + "msvc" + } else if cfg!(target_env = "gnu") { + "gnu" + } else if cfg!(target_env = "musl") { + "musl" + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + "apple" + } else { + return Err(DescriptorError::WrongTarget); + }; + let object = if cfg!(windows) { + "coff" + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + "macho" + } else if cfg!(any(target_os = "linux", target_os = "android")) { + "elf" + } else { + return Err(DescriptorError::WrongTarget); + }; + if cfg!(target_os = "ios") { + let variant = if cfg!(target_abi = "macabi") { + "catalyst" + } else if cfg!(target_abi = "sim") { + "simulator" + } else { + "device" + }; + Ok(format!( + "{}-ios-{variant}-{environment}-{object}-ptr{}-{endian}-callable-v3", + std::env::consts::ARCH, + usize::BITS + )) + } else { + Ok(format!( + "{}-{}-{environment}-{object}-ptr{}-{endian}-callable-v3", + std::env::consts::ARCH, + std::env::consts::OS, + usize::BITS + )) + } +} + +fn linkage_matches_target(linkage: Linkage) -> bool { + if cfg!(target_os = "ios") { + linkage == Linkage::IosStatic + } else { + linkage == Linkage::Dynamic + } +} + +const fn linkage_tag(linkage: Linkage) -> u32 { + match linkage { + Linkage::Dynamic => LINKAGE_DYNAMIC, + Linkage::IosStatic => LINKAGE_IOS_STATIC, + } +} + +fn schema_fingerprint() -> [u8; 32] { + domain_hash(SCHEMA_DOMAIN, &[DESCRIPTOR_SCHEMA_STATEMENT]) +} + +fn target_fingerprint(target: &[u8]) -> [u8; 32] { + domain_hash(TARGET_DOMAIN, &[target]) +} + +fn physical_module_fingerprint( + schema: &[u8; 32], + target: &[u8; 32], + semantic_module: &[u8; 32], + module: &[u8], + linkage: Linkage, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(PHYSICAL_MODULE_DOMAIN); + for field in [schema.as_slice(), target, semantic_module, module] { + hash_field(&mut hasher, field); + } + hash_u32(&mut hasher, linkage_tag(linkage)); + hasher.finalize().into() +} + +fn graph_fingerprint(bytes: &[u8]) -> [u8; 32] { + domain_hash(GRAPH_DOMAIN, &[bytes]) +} + +fn trace_evidence_fingerprint( + trace_certificate: &[u8; 32], + ordinals: &[u32], + outcome: TraceOutcome, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(TRACE_EVIDENCE_DOMAIN); + hasher.update(trace_certificate); + hasher.update((ordinals.len() as u64).to_le_bytes()); + for ordinal in ordinals { + hash_u32(&mut hasher, *ordinal); + } + match outcome { + TraceOutcome::ScalarSuccess => hasher.update([1]), + TraceOutcome::OwnedSuccess => hasher.update([2]), + TraceOutcome::Failure { selected_ordinal } => { + hasher.update([3]); + hash_u32(&mut hasher, selected_ordinal); + } + } + hasher.finalize().into() +} + +fn request_schema_fingerprint() -> [u8; 32] { + domain_hash(REQUEST_SCHEMA_DOMAIN, &[REQUEST_SCHEMA_STATEMENT]) +} + +fn execute_response_schema_fingerprint() -> [u8; 32] { + domain_hash( + EXECUTE_RESPONSE_SCHEMA_DOMAIN, + &[EXECUTE_RESPONSE_SCHEMA_STATEMENT], + ) +} + +fn frame_schema_fingerprint() -> [u8; 32] { + domain_hash(FRAME_SCHEMA_DOMAIN, &[FRAME_SCHEMA_STATEMENT]) +} + +fn decision_schema_fingerprint() -> [u8; 32] { + domain_hash(DECISION_SCHEMA_DOMAIN, &[DECISION_SCHEMA_STATEMENT]) +} + +fn action_schema_fingerprint() -> [u8; 32] { + domain_hash(ACTION_SCHEMA_DOMAIN, &[ACTION_SCHEMA_STATEMENT]) +} + +fn candidate_receipt_schema_fingerprint() -> [u8; 32] { + domain_hash( + CANDIDATE_RECEIPT_SCHEMA_DOMAIN, + &[CANDIDATE_RECEIPT_SCHEMA_STATEMENT], + ) +} + +fn committed_receipt_schema_fingerprint() -> [u8; 32] { + domain_hash( + COMMITTED_RECEIPT_SCHEMA_DOMAIN, + &[COMMITTED_RECEIPT_SCHEMA_STATEMENT], + ) +} + +fn call_abi_fingerprint() -> [u8; 32] { + domain_hash(CALL_ABI_DOMAIN, &[CALL_ABI_STATEMENT]) +} + +#[allow(clippy::too_many_arguments)] +fn call_contract_fingerprint( + target: &str, + linkage: Linkage, + fingerprints: &Fingerprints, + module: &str, + function: &str, + capacities: &Capacities, + parameters: &[Parameter], + result: &ResultShape, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(CALL_CONTRACT_DOMAIN); + for bytes in [ + target.as_bytes(), + fingerprints.schema.as_slice(), + &fingerprints.target, + &fingerprints.semantic_module, + &fingerprints.physical_module, + &fingerprints.function_template, + &fingerprints.execution_cleanup, + &fingerprints.event_dictionary, + &fingerprints.trace_path_certificate, + &fingerprints.recovery_contract, + &fingerprints.settlement_graph, + &fingerprints.request_schema, + &fingerprints.execute_response_schema, + &fingerprints.frame_schema, + &fingerprints.decision_schema, + &fingerprints.action_schema, + &fingerprints.candidate_receipt_schema, + &fingerprints.committed_receipt_schema, + &fingerprints.call_abi, + module.as_bytes(), + function.as_bytes(), + ] { + hash_field(&mut hasher, bytes); + } + hash_u32(&mut hasher, linkage_tag(linkage)); + hash_u32(&mut hasher, CALL_ABI_TAG); + hash_u32(&mut hasher, REQUIRED_OBLIGATIONS); + for capacity in capacity_values(capacities) { + hash_u32(&mut hasher, capacity); + } + hash_signature(&mut hasher, parameters, result); + hasher.finalize().into() +} + +fn hash_signature(hasher: &mut Sha256, parameters: &[Parameter], result: &ResultShape) { + hash_u32(hasher, parameters.len() as u32); + for parameter in parameters { + match parameter { + Parameter::Scalar { index, value, kind } => { + hash_u32(hasher, PARAMETER_SCALAR); + hash_u32(hasher, *index as u32); + hash_field(hasher, value.as_bytes()); + hash_u32( + hasher, + match kind { + ScalarKind::I64 => SCALAR_I64, + ScalarKind::Bool => SCALAR_BOOL, + }, + ); + } + Parameter::Owned { + index, + value, + owner_ordinal, + resource, + lifecycle, + payload_wire_kind, + } => { + hash_u32(hasher, PARAMETER_OWNED_RESOURCE); + hash_u32(hasher, *index as u32); + hash_field(hasher, value.as_bytes()); + hash_u32(hasher, *owner_ordinal as u32); + hash_field(hasher, resource.as_bytes()); + hash_field(hasher, lifecycle.as_bytes()); + hash_u32(hasher, *payload_wire_kind); + } + } + } + match result { + ResultShape::ScalarI64 => hash_u32(hasher, RESULT_SCALAR_I64), + ResultShape::OwnedInput { + parameter_index, + owner_ordinal, + } => { + hash_u32(hasher, RESULT_OWNED_INPUT); + hash_u32(hasher, *parameter_index as u32); + let value = match ¶meters[*parameter_index] { + Parameter::Owned { value, .. } => value, + Parameter::Scalar { .. } => return, + }; + hash_field(hasher, value.as_bytes()); + hash_u32(hasher, *owner_ordinal as u32); + } + } +} + +fn derive_symbols(fingerprints: &Fingerprints) -> (String, String, String) { + let mut hasher = Sha256::new(); + hasher.update(SYMBOL_SEED_DOMAIN); + for fingerprint in [ + &fingerprints.physical_module, + &fingerprints.function_template, + &fingerprints.recovery_contract, + &fingerprints.settlement_graph, + &fingerprints.request_schema, + &fingerprints.execute_response_schema, + &fingerprints.frame_schema, + &fingerprints.decision_schema, + &fingerprints.action_schema, + &fingerprints.candidate_receipt_schema, + &fingerprints.committed_receipt_schema, + &fingerprints.call_abi, + &fingerprints.call_contract, + ] { + hash_field(&mut hasher, fingerprint); + } + let seed: [u8; 32] = hasher.finalize().into(); + ( + derive_symbol(GETTER_SYMBOL_DOMAIN, &seed, "descriptor_v3"), + derive_symbol(EXECUTE_SYMBOL_DOMAIN, &seed, "execute_v3"), + derive_symbol(SETTLE_SYMBOL_DOMAIN, &seed, "settle_v3"), + ) +} + +fn derive_symbol(domain: &[u8], seed: &[u8; 32], suffix: &str) -> String { + let digest = domain_hash(domain, &[seed]); + let mut symbol = String::with_capacity(4 + 48 + 1 + suffix.len()); + symbol.push_str("spx_"); + for byte in &digest[..24] { + use std::fmt::Write as _; + write!(symbol, "{byte:02x}").expect("writing to a string cannot fail"); + } + symbol.push('_'); + symbol.push_str(suffix); + symbol +} + +fn capacity_values(capacities: &Capacities) -> [u32; 15] { + [ + capacities.request, + capacities.execute_response, + capacities.frame, + capacities.decision, + capacities.action_evidence, + capacities.candidate_receipt, + capacities.event_count, + capacities.dictionary_bytes, + capacities.dictionary_entries, + capacities.resource_count, + capacities.checkpoint_count, + capacities.graph_work_units, + capacities.active_frames, + capacities.quarantined_frames, + capacities.instance_reserved_bytes, + ] +} + +fn is_c_symbol(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + (first == b'_' || first.is_ascii_alphabetic()) + && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()) +} + +fn domain_hash(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain); + for field in fields { + hash_field(&mut hasher, field); + } + hasher.finalize().into() +} + +fn hash_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +fn hash_u32(hasher: &mut Sha256, value: u32) { + hasher.update(value.to_le_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_graph() -> SettlementGraph { + let trace_path_certificate = [0x37; 32]; + let ordinals = vec![7, 8]; + let trace_outcome = TraceOutcome::ScalarSuccess; + SettlementGraph { + function: "token.consume".to_owned(), + recovery_contract: [0x38; 32], + execution_cleanup: [0x35; 32], + trace_path_certificate, + resource_count: 1, + checkpoints: vec![ + Checkpoint { + id: 1, + resources: vec![ResourceState::Live], + outcome: None, + abort_order: vec![0], + accept_order: vec![], + }, + Checkpoint { + id: 2, + resources: vec![ResourceState::Dead], + outcome: None, + abort_order: vec![], + accept_order: vec![], + }, + Checkpoint { + id: 3, + resources: vec![ResourceState::Dead], + outcome: Some(Outcome::ScalarSuccess), + abort_order: vec![], + accept_order: vec![], + }, + ], + starts: vec![1], + edges: vec![ + Edge { + from: 1, + to: 2, + action: Action::Finalize(0), + }, + Edge { + from: 2, + to: 3, + action: Action::CertifyOutcome(TraceEvidence { + digest: trace_evidence_fingerprint( + &trace_path_certificate, + &ordinals, + trace_outcome, + ), + ordinals, + outcome: trace_outcome, + }), + }, + ], + } + } + + fn fixture_parameters() -> Vec { + vec![ + Parameter::Owned { + index: 0, + value: "token.value".to_owned(), + owner_ordinal: 0, + resource: "token.type".to_owned(), + lifecycle: "token.drop".to_owned(), + payload_wire_kind: OWNED_PAYLOAD_WIRE_KIND, + }, + Parameter::Scalar { + index: 1, + value: "delta.value".to_owned(), + kind: ScalarKind::I64, + }, + ] + } + + fn fixture_capacities() -> Capacities { + Capacities { + request: 140, + execute_response: 188, + frame: 212, + decision: 172, + action_evidence: 188, + candidate_receipt: 264, + event_count: 16, + dictionary_bytes: 2048, + dictionary_entries: 8, + resource_count: 1, + checkpoint_count: 3, + graph_work_units: 3, + active_frames: 256, + quarantined_frames: 64, + instance_reserved_bytes: 328_448, + } + } + + fn fixture() -> Descriptor { + let target = current_target_tag().unwrap(); + let linkage = if cfg!(target_os = "ios") { + Linkage::IosStatic + } else { + Linkage::Dynamic + }; + let module = "test.callable_v3".to_owned(); + let function = "token.consume".to_owned(); + let graph = fixture_graph(); + let graph_bytes = encode_graph(&graph).unwrap(); + let schema = schema_fingerprint(); + let target_fingerprint = target_fingerprint(target.as_bytes()); + let semantic_module = [0x31; 32]; + let mut fingerprints = Fingerprints { + schema, + target: target_fingerprint, + semantic_module, + physical_module: physical_module_fingerprint( + &schema, + &target_fingerprint, + &semantic_module, + module.as_bytes(), + linkage, + ), + function_template: [0x34; 32], + execution_cleanup: graph.execution_cleanup, + event_dictionary: [0x36; 32], + trace_path_certificate: graph.trace_path_certificate, + recovery_contract: graph.recovery_contract, + settlement_graph: graph_fingerprint(&graph_bytes), + request_schema: request_schema_fingerprint(), + execute_response_schema: execute_response_schema_fingerprint(), + frame_schema: frame_schema_fingerprint(), + decision_schema: decision_schema_fingerprint(), + action_schema: action_schema_fingerprint(), + candidate_receipt_schema: candidate_receipt_schema_fingerprint(), + committed_receipt_schema: committed_receipt_schema_fingerprint(), + call_abi: call_abi_fingerprint(), + call_contract: [0; 32], + }; + let capacities = fixture_capacities(); + let parameters = fixture_parameters(); + let result = ResultShape::ScalarI64; + fingerprints.call_contract = call_contract_fingerprint( + &target, + linkage, + &fingerprints, + &module, + &function, + &capacities, + ¶meters, + &result, + ); + let (getter_symbol, execute_symbol, settle_symbol) = derive_symbols(&fingerprints); + Descriptor { + target, + linkage, + fingerprints, + module, + function, + getter_symbol, + execute_symbol, + settle_symbol, + call_abi_tag: CALL_ABI_TAG, + obligations: REQUIRED_OBLIGATIONS, + capacities, + parameters, + result, + graph, + } + } + + fn reseal(descriptor: &mut Descriptor) { + descriptor.fingerprints.physical_module = physical_module_fingerprint( + &descriptor.fingerprints.schema, + &descriptor.fingerprints.target, + &descriptor.fingerprints.semantic_module, + descriptor.module.as_bytes(), + descriptor.linkage, + ); + descriptor.fingerprints.settlement_graph = + graph_fingerprint(&encode_graph(&descriptor.graph).unwrap()); + descriptor.fingerprints.call_contract = call_contract_fingerprint( + &descriptor.target, + descriptor.linkage, + &descriptor.fingerprints, + &descriptor.module, + &descriptor.function, + &descriptor.capacities, + &descriptor.parameters, + &descriptor.result, + ); + ( + descriptor.getter_symbol, + descriptor.execute_symbol, + descriptor.settle_symbol, + ) = derive_symbols(&descriptor.fingerprints); + } + + fn hex(bytes: &[u8]) -> String { + let mut value = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(value, "{byte:02x}").unwrap(); + } + value + } + + #[test] + fn canonical_metadata_round_trips_and_binds_every_surface() { + let expected = fixture(); + let bytes = encode_descriptor(&expected).unwrap(); + let parsed = Descriptor::parse(&bytes).unwrap(); + assert_eq!(parsed, expected); + assert_eq!(&bytes[..8], MAGIC); + assert_eq!(parsed.capacities, fixture_capacities()); + assert_eq!(parsed.graph.starts, [1]); + assert_eq!(parsed.graph.edges.len(), 2); + assert_ne!( + parsed.fingerprints.candidate_receipt_schema, + parsed.fingerprints.committed_receipt_schema + ); + assert_ne!(parsed.getter_symbol, parsed.execute_symbol); + assert_ne!(parsed.getter_symbol, parsed.settle_symbol); + assert_ne!(parsed.execute_symbol, parsed.settle_symbol); + } + + #[test] + fn schema_and_graph_known_answers_are_stable_and_symbols_are_deterministic() { + let descriptor = fixture(); + assert_eq!( + hex(&descriptor.fingerprints.schema), + "e03477296a8f90b7544c340c4d41155eb7cbd860a40de40319b41b8a0249c5b5" + ); + assert_eq!( + hex(&descriptor.fingerprints.settlement_graph), + "09f4e623e8345613ad82796fd5600c9b251ee16a1ad1d5ca6ed6432056770a74" + ); + assert_eq!( + descriptor.fingerprints.call_contract, + call_contract_fingerprint( + &descriptor.target, + descriptor.linkage, + &descriptor.fingerprints, + &descriptor.module, + &descriptor.function, + &descriptor.capacities, + &descriptor.parameters, + &descriptor.result, + ) + ); + assert_eq!( + ( + descriptor.getter_symbol.clone(), + descriptor.execute_symbol.clone(), + descriptor.settle_symbol.clone() + ), + derive_symbols(&descriptor.fingerprints) + ); + assert!(descriptor.getter_symbol.ends_with("_descriptor_v3")); + assert!(descriptor.execute_symbol.ends_with("_execute_v3")); + assert!(descriptor.settle_symbol.ends_with("_settle_v3")); + } + + #[test] + fn rejects_every_prefix_trailing_byte_and_single_byte_mutation() { + let bytes = encode_descriptor(&fixture()).unwrap(); + for length in 0..bytes.len() { + assert!( + Descriptor::parse(&bytes[..length]).is_err(), + "accepted prefix length {length}" + ); + } + for trailing in [0_u8, 1, 0x7f, 0xff] { + let mut hostile = bytes.clone(); + hostile.push(trailing); + assert!(Descriptor::parse(&hostile).is_err()); + } + for offset in 0..bytes.len() { + let mut hostile = bytes.clone(); + hostile[offset] ^= 1; + assert!( + Descriptor::parse(&hostile).is_err(), + "accepted mutation at byte {offset}" + ); + } + } + + #[test] + fn rejects_version_confusion_without_negotiation_or_fallback() { + let canonical = encode_descriptor(&fixture()).unwrap(); + for (magic, version) in [ + (b"SPXNABI1".as_slice(), 1_u32), + (b"SPXNABI2".as_slice(), 2_u32), + (b"SPXNPRF1".as_slice(), 1_u32), + ] { + let mut hostile = canonical.clone(); + hostile[..8].copy_from_slice(magic); + hostile[8..12].copy_from_slice(&version.to_le_bytes()); + assert_eq!( + Descriptor::parse(&hostile), + Err(DescriptorError::UnsupportedSchema) + ); + } + } + + #[test] + fn rejects_rehashed_hostile_capacities_and_cross_bindings() { + let mut cases = Vec::new(); + let mut request = fixture(); + request.capacities.request += 1; + cases.push(request); + let mut work = fixture(); + work.capacities.graph_work_units += 1; + cases.push(work); + let mut resources = fixture(); + resources.capacities.resource_count += 1; + cases.push(resources); + let mut active = fixture(); + active.capacities.active_frames -= 1; + cases.push(active); + let mut reserved = fixture(); + reserved.capacities.instance_reserved_bytes += 1; + cases.push(reserved); + let mut function = fixture(); + function.graph.function = "token.other".to_owned(); + cases.push(function); + let mut recovery = fixture(); + recovery.graph.recovery_contract = [0x99; 32]; + cases.push(recovery); + let mut cleanup = fixture(); + cleanup.graph.execution_cleanup = [0x99; 32]; + cases.push(cleanup); + let mut trace = fixture(); + trace.graph.trace_path_certificate = [0x99; 32]; + cases.push(trace); + + for mut hostile in cases { + reseal(&mut hostile); + assert!(Descriptor::parse(&encode_descriptor(&hostile).unwrap()).is_err()); + } + } + + #[test] + fn rejects_rehashed_hostile_graph_topology_states_orders_and_tags() { + let mut cases = Vec::new(); + let mut checkpoint_id = fixture(); + checkpoint_id.graph.checkpoints[0].id = 2; + cases.push(checkpoint_id); + let mut state = fixture(); + state.graph.checkpoints[0].resources[0] = ResourceState::Finalizing; + cases.push(state); + let mut order = fixture(); + order.graph.checkpoints[0].abort_order.clear(); + cases.push(order); + let mut start = fixture(); + start.graph.starts = vec![2]; + cases.push(start); + let mut backwards = fixture(); + backwards.graph.edges[0].to = backwards.graph.edges[0].from; + cases.push(backwards); + let mut zero_trace = fixture(); + let Action::CertifyOutcome(evidence) = &mut zero_trace.graph.edges[1].action else { + unreachable!() + }; + evidence.digest = [0; 32]; + cases.push(zero_trace); + let mut nonzero_changed_digest = fixture(); + let Action::CertifyOutcome(evidence) = &mut nonzero_changed_digest.graph.edges[1].action + else { + unreachable!() + }; + evidence.digest = [0x99; 32]; + cases.push(nonzero_changed_digest); + let mut changed_witness = fixture(); + let Action::CertifyOutcome(evidence) = &mut changed_witness.graph.edges[1].action else { + unreachable!() + }; + evidence.ordinals[0] += 1; + cases.push(changed_witness); + let mut changed_witness_outcome = fixture(); + let Action::CertifyOutcome(evidence) = &mut changed_witness_outcome.graph.edges[1].action + else { + unreachable!() + }; + evidence.outcome = TraceOutcome::OwnedSuccess; + cases.push(changed_witness_outcome); + + for mut hostile in cases { + reseal(&mut hostile); + assert!(Descriptor::parse(&encode_descriptor(&hostile).unwrap()).is_err()); + } + + let mut unknown_tag = encode_descriptor(&fixture()).unwrap(); + let graph_start = graph_start(&unknown_tag); + let first_state = graph_start + 4 + 4 + "token.consume".len() + 3 * 32 + 4 + 4 + 4 + 4; + unknown_tag[first_state..first_state + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(Descriptor::parse(&unknown_tag).is_err()); + } + + #[test] + fn rejects_hostile_text_counts_symbols_linkage_and_global_ceiling() { + let bytes = encode_descriptor(&fixture()).unwrap(); + let target_length = HEADER_SIZE as usize; + let target_start = target_length + 4; + let mut nul = bytes.clone(); + nul[target_start] = 0; + assert!(Descriptor::parse(&nul).is_err()); + let mut bad_utf8 = bytes.clone(); + bad_utf8[target_start] = 0xff; + assert!(Descriptor::parse(&bad_utf8).is_err()); + let mut hostile_count = bytes.clone(); + hostile_count[target_length..target_length + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(Descriptor::parse(&hostile_count).is_err()); + + let mut wrong_linkage = fixture(); + wrong_linkage.linkage = if wrong_linkage.linkage == Linkage::Dynamic { + Linkage::IosStatic + } else { + Linkage::Dynamic + }; + reseal(&mut wrong_linkage); + assert_eq!( + Descriptor::parse(&encode_descriptor(&wrong_linkage).unwrap()), + Err(DescriptorError::WrongTarget) + ); + + let mut duplicate_symbol = fixture(); + duplicate_symbol.execute_symbol = duplicate_symbol.getter_symbol.clone(); + assert!(Descriptor::parse(&encode_descriptor(&duplicate_symbol).unwrap()).is_err()); + assert_eq!( + Descriptor::parse(&vec![0; MAX_DESCRIPTOR_BYTES + 1]), + Err(DescriptorError::Malformed) + ); + } + + #[test] + fn committed_receipt_schema_is_host_only_and_not_candidate_evidence() { + assert_ne!( + CANDIDATE_RECEIPT_SCHEMA_DOMAIN, + COMMITTED_RECEIPT_SCHEMA_DOMAIN + ); + assert!(std::str::from_utf8(COMMITTED_RECEIPT_SCHEMA_STATEMENT) + .unwrap() + .contains("host-only-HMAC-SHA256")); + assert!(!std::str::from_utf8(CANDIDATE_RECEIPT_SCHEMA_STATEMENT) + .unwrap() + .contains("host-only")); + } + + fn graph_start(bytes: &[u8]) -> usize { + let mut reader = Reader::new(bytes); + reader.take(8).unwrap(); + reader.u32().unwrap(); + reader.u32().unwrap(); + reader.u32().unwrap(); + reader.text(MAX_TEXT_BYTES).unwrap(); + reader.u32().unwrap(); + reader.take(19 * 32).unwrap(); + for _ in 0..5 { + reader.text(MAX_TEXT_BYTES).unwrap(); + } + reader.u32().unwrap(); + reader.u32().unwrap(); + reader.take(15 * 4).unwrap(); + let parameter_count = reader.usize().unwrap(); + for _ in 0..parameter_count { + match reader.u32().unwrap() { + PARAMETER_SCALAR => { + reader.u32().unwrap(); + reader.text(MAX_TEXT_BYTES).unwrap(); + reader.u32().unwrap(); + } + PARAMETER_OWNED_RESOURCE => { + reader.u32().unwrap(); + reader.text(MAX_TEXT_BYTES).unwrap(); + reader.u32().unwrap(); + reader.text(MAX_TEXT_BYTES).unwrap(); + reader.text(MAX_TEXT_BYTES).unwrap(); + reader.u32().unwrap(); + } + _ => unreachable!(), + } + } + match reader.u32().unwrap() { + RESULT_SCALAR_I64 => {} + RESULT_OWNED_INPUT => { + reader.u32().unwrap(); + reader.text(MAX_TEXT_BYTES).unwrap(); + reader.u32().unwrap(); + } + _ => unreachable!(), + } + let graph_len = reader.usize().unwrap(); + assert_eq!(graph_len, reader.remaining()); + reader.offset + } +} diff --git a/crates/semaprax-native-host/src/descriptor_v3_integration.rs b/crates/semaprax-native-host/src/descriptor_v3_integration.rs new file mode 100644 index 0000000..f4ea724 --- /dev/null +++ b/crates/semaprax-native-host/src/descriptor_v3_integration.rs @@ -0,0 +1,237 @@ +//! Cross-implementation tests between the compiler v3 encoder and independent host parser. + +use semaprax::codegen::{ + emit_native_adapter_admission, emit_native_callable_admission, + emit_native_callable_settlement_proof, emit_native_callable_v3_descriptor, +}; +use semaprax::hir::DeclarationId; +use semaprax::owned_resource_corpus::build_owned_resource_corpus_v1; +use sha2::{Digest, Sha256}; + +use crate::descriptor::Descriptor as DescriptorV1; +use crate::descriptor_v2::Descriptor as DescriptorV2; +use crate::descriptor_v3::{ + encode_descriptor, encode_graph, Action, Descriptor, Linkage, Outcome, Parameter, + ResourceState, ResultShape, TraceOutcome, +}; +use crate::settlement_proof::BoundSettlementProof; + +fn compiler_descriptor() -> semaprax::codegen::NativeCallableV3DescriptorArtifact { + let corpus = build_owned_resource_corpus_v1().unwrap(); + emit_native_callable_v3_descriptor(&corpus.program, &DeclarationId::new("token.discard-two")) + .unwrap() +} + +#[test] +fn compiler_v3_descriptor_is_accepted_with_exact_bound_metadata() { + let artifact = compiler_descriptor(); + let descriptor = Descriptor::parse(artifact.bytes()).unwrap(); + + assert_eq!(descriptor.module, "test.owned_resource_corpus"); + assert_eq!(descriptor.function, "token.discard-two"); + assert_eq!(descriptor.getter_symbol, artifact.getter_symbol()); + assert_eq!(descriptor.execute_symbol, artifact.execute_symbol()); + assert_eq!(descriptor.settle_symbol, artifact.settle_symbol()); + assert_eq!( + descriptor.fingerprints.call_contract, + artifact.call_contract() + ); + assert_eq!(descriptor.call_abi_tag, 3); + assert_eq!(descriptor.obligations, 0x03ff); + assert_eq!( + descriptor.linkage, + if cfg!(target_os = "ios") { + Linkage::IosStatic + } else { + Linkage::Dynamic + } + ); + + assert_eq!(descriptor.parameters.len(), 2); + assert!(descriptor + .parameters + .iter() + .enumerate() + .all(|(ordinal, parameter)| matches!( + parameter, + Parameter::Owned { + index, + owner_ordinal, + .. + } if *index == ordinal && *owner_ordinal == ordinal + ))); + assert_eq!(descriptor.result, ResultShape::ScalarI64); + + assert_eq!(descriptor.capacities.request, 144); + assert_eq!( + descriptor.capacities.execute_response, + 124 + 4 * descriptor.capacities.event_count + ); + assert_eq!(descriptor.capacities.frame, 216); + assert_eq!(descriptor.capacities.decision, 172); + assert_eq!(descriptor.capacities.action_evidence, 188); + assert_eq!(descriptor.capacities.candidate_receipt, 264); + assert_eq!(descriptor.capacities.resource_count, 2); + assert_eq!(descriptor.capacities.checkpoint_count, 4); + assert_eq!(descriptor.capacities.graph_work_units, 8); + assert_eq!(descriptor.capacities.active_frames, 256); + assert_eq!(descriptor.capacities.quarantined_frames, 64); + let per_active = descriptor.capacities.request + + descriptor.capacities.execute_response + + descriptor.capacities.frame + + descriptor.capacities.decision + + descriptor.capacities.action_evidence + + descriptor.capacities.candidate_receipt; + let per_quarantine = descriptor.capacities.frame + descriptor.capacities.candidate_receipt; + assert_eq!( + descriptor.capacities.instance_reserved_bytes, + 256 * per_active + 64 * per_quarantine + ); + + assert_eq!(descriptor.graph.function, descriptor.function); + assert_eq!(descriptor.graph.resource_count, 2); + assert_eq!(descriptor.graph.checkpoints.len(), 4); + assert_eq!(descriptor.graph.starts, [1]); + assert!(descriptor + .graph + .checkpoints + .first() + .unwrap() + .resources + .iter() + .all(|state| *state == ResourceState::Live)); + assert!(descriptor + .graph + .checkpoints + .iter() + .any(|checkpoint| checkpoint.outcome == Some(Outcome::ScalarSuccess))); + assert!(descriptor + .graph + .edges + .iter() + .any(|edge| matches!(&edge.action, Action::Finalize(_)))); + assert!(descriptor + .graph + .edges + .iter() + .any(|edge| matches!(&edge.action, Action::CertifyOutcome(_)))); + assert_eq!( + hex(&Sha256::digest(encode_graph(&descriptor.graph).unwrap())), + "0da4af442f926506e2dcfc71fd0a6895dd3f48223922f06bae4f2ac9cf67a380" + ); +} + +#[test] +fn all_fourteen_authoritative_corpus_cases_parse_and_reencode_canonically() { + let corpus = build_owned_resource_corpus_v1().unwrap(); + assert_eq!(corpus.cases.len(), 14); + let mut witnessed_outcomes = std::collections::BTreeSet::new(); + for case in &corpus.cases { + let artifact = emit_native_callable_v3_descriptor( + &corpus.program, + &DeclarationId::new(case.function_id), + ) + .unwrap(); + let descriptor = Descriptor::parse(artifact.bytes()).unwrap(); + assert_eq!(descriptor.function, case.function_id); + assert_eq!(encode_descriptor(&descriptor).unwrap(), artifact.bytes()); + for edge in &descriptor.graph.edges { + if let Action::CertifyOutcome(evidence) = &edge.action { + witnessed_outcomes.insert(match evidence.outcome { + TraceOutcome::ScalarSuccess => 1, + TraceOutcome::OwnedSuccess => 2, + TraceOutcome::Failure { .. } => 3, + }); + } + } + } + assert_eq!( + witnessed_outcomes, + std::collections::BTreeSet::from([1, 2, 3]) + ); +} + +#[test] +fn exact_linux_compiler_descriptor_known_answer_is_stable() { + if cfg!(all( + target_os = "linux", + target_arch = "x86_64", + target_env = "gnu", + target_pointer_width = "64", + target_endian = "little" + )) { + let artifact = compiler_descriptor(); + assert_eq!(artifact.bytes().len(), 1_722); + assert_eq!( + hex(&Sha256::digest(artifact.bytes())), + "53096cf416ba8fe1fb7ca694649c81fcc93d3b5cfe71cdf5413c01b8f04ab64e" + ); + assert_eq!( + hex(&artifact.call_contract()), + "9b9c13fc2c5cf506bd99b0cdcec326f7394bd94665d373dec2861f467149e496" + ); + assert!(Descriptor::parse(artifact.bytes()).is_ok()); + } +} + +#[test] +fn compiler_v3_descriptor_rejects_every_truncation_trailing_and_byte_mutation() { + let canonical = compiler_descriptor().bytes().to_vec(); + assert!(Descriptor::parse(&canonical).is_ok()); + for length in 0..canonical.len() { + assert!( + Descriptor::parse(&canonical[..length]).is_err(), + "accepted compiler descriptor prefix length {length}" + ); + } + for trailing in [0_u8, 1, 0x7f, 0xff] { + let mut hostile = canonical.clone(); + hostile.push(trailing); + assert!(Descriptor::parse(&hostile).is_err()); + } + for offset in 0..canonical.len() { + let mut hostile = canonical.clone(); + hostile[offset] ^= 1; + assert!( + Descriptor::parse(&hostile).is_err(), + "accepted compiler descriptor mutation at {offset}" + ); + } +} + +#[test] +fn all_four_artifact_families_reject_every_other_magic_without_fallback() { + let corpus = build_owned_resource_corpus_v1().unwrap(); + let function = DeclarationId::new("token.discard-two"); + let v1 = emit_native_adapter_admission(&corpus.program, &function, "descriptor-v1.h").unwrap(); + let v2 = emit_native_callable_admission(&corpus.program, &function).unwrap(); + let proof = emit_native_callable_settlement_proof(&corpus.program, &function).unwrap(); + let v3 = compiler_descriptor(); + + assert!(DescriptorV1::parse(v1.descriptor()).is_ok()); + assert!(DescriptorV2::parse(v2.descriptor()).is_ok()); + assert!(BoundSettlementProof::parse(proof.bytes()).is_ok()); + assert!(Descriptor::parse(v3.bytes()).is_ok()); + + for hostile in [v2.descriptor(), proof.bytes(), v3.bytes()] { + assert!(DescriptorV1::parse(hostile).is_err()); + } + for hostile in [v1.descriptor(), proof.bytes(), v3.bytes()] { + assert!(DescriptorV2::parse(hostile).is_err()); + } + for hostile in [v1.descriptor(), v2.descriptor(), v3.bytes()] { + assert!(BoundSettlementProof::parse(hostile).is_err()); + } + assert!(Descriptor::parse(v1.descriptor()).is_err()); + assert!(Descriptor::parse(v2.descriptor()).is_err()); + assert!(Descriptor::parse(proof.bytes()).is_err()); +} + +fn hex(bytes: &[u8]) -> String { + let mut value = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(value, "{byte:02x}").unwrap(); + } + value +} diff --git a/crates/semaprax-native-host/src/lib.rs b/crates/semaprax-native-host/src/lib.rs index da9a7f7..ad91962 100644 --- a/crates/semaprax-native-host/src/lib.rs +++ b/crates/semaprax-native-host/src/lib.rs @@ -13,6 +13,9 @@ mod descriptor; mod descriptor_v2; #[cfg(test)] mod descriptor_v2_integration; +mod descriptor_v3; +#[cfg(test)] +mod descriptor_v3_integration; mod settlement_proof; // Temporary audited source sharing keeps these protocol implementations diff --git a/crates/semaprax-native-loader/src/lib.rs b/crates/semaprax-native-loader/src/lib.rs index dda47e4..41c2406 100644 --- a/crates/semaprax-native-loader/src/lib.rs +++ b/crates/semaprax-native-loader/src/lib.rs @@ -358,6 +358,8 @@ pub enum OpenError { InvalidExpectedDescriptorLength { actual: usize, maximum: usize }, /// Settlement-proof envelopes are metadata and can never be loaded. SettlementProofEnvelopeNotLoadable, + /// Callable-v3 descriptors are metadata-only until exact v3 admission exists. + CallableV3DescriptorNotLoadable, /// Callable admission accepts only the separately versioned descriptor v2. InvalidCallableDescriptorSchema, /// The platform loader rejected the library. @@ -400,6 +402,8 @@ impl fmt::Display for OpenError { Self::SettlementProofEnvelopeNotLoadable => formatter.write_str( "native callable settlement-proof envelopes are proof-only and cannot be loaded", ), + Self::CallableV3DescriptorNotLoadable => formatter + .write_str("native callable v3 descriptors are metadata-only and cannot be loaded"), Self::InvalidCallableDescriptorSchema => formatter .write_str("native callable admission requires an exact SPXNABI2 descriptor"), Self::LibraryOpen(error) => write!(formatter, "native module open failed: {error}"), @@ -446,8 +450,9 @@ impl Error for OpenError { /// Exact equality proves only that the resolved getter returned /// `expected_descriptor`. It does not prove that the getter belongs to the root /// image, that the root image is compatible, or that admission was sound. -/// An `SPXNPRF1` settlement-proof envelope is rejected during input validation -/// before path canonicalization or library loading; it is not a descriptor. +/// `SPXNPRF1` settlement-proof envelopes and `SPXNABI3` metadata-only +/// descriptors are rejected during input validation before path +/// canonicalization or library loading; neither is a loadable descriptor. /// /// # Safety /// @@ -534,8 +539,9 @@ pub unsafe fn open_admitted_exact( /// returned retain. Before crossing this boundary, the caller must also have /// decoded `expected_descriptor` with the exact canonical descriptor-v2 codec; /// this loader checks only its fixed envelope and byte equality. -/// `SPXNPRF1` proof envelopes are rejected before path canonicalization or -/// library loading and are never interpreted as callable descriptors. +/// `SPXNPRF1` proof envelopes and `SPXNABI3` metadata-only descriptors are +/// rejected before path canonicalization or library loading and are never +/// interpreted as callable-v2 descriptors. /// /// In addition, `callable_symbol` must name an eagerly resolvable function with /// the exact C ABI @@ -656,6 +662,9 @@ fn validate_inputs( if expected_descriptor.starts_with(b"SPXNPRF1") { return Err(OpenError::SettlementProofEnvelopeNotLoadable); } + if expected_descriptor.starts_with(b"SPXNABI3") { + return Err(OpenError::CallableV3DescriptorNotLoadable); + } Ok(()) } diff --git a/crates/semaprax-native-loader/tests/callable_v3_descriptor_rejection.rs b/crates/semaprax-native-loader/tests/callable_v3_descriptor_rejection.rs new file mode 100644 index 0000000..5e29086 --- /dev/null +++ b/crates/semaprax-native-loader/tests/callable_v3_descriptor_rejection.rs @@ -0,0 +1,71 @@ +use semaprax_native_loader::{open_admitted_callable_exact, open_admitted_exact, OpenError}; + +const GETTER_SYMBOL: &[u8] = b"semaprax_descriptor_v3"; +const CALLABLE_SYMBOL: &[u8] = b"semaprax_callable_v3"; + +fn descriptor_v3_header() -> Vec { + let mut descriptor = Vec::new(); + descriptor.extend_from_slice(b"SPXNABI3"); + descriptor.extend_from_slice(&3_u32.to_le_bytes()); + descriptor.extend_from_slice(&20_u32.to_le_bytes()); + descriptor.extend_from_slice(&20_u32.to_le_bytes()); + descriptor +} + +fn assert_rejected_by_both_loaders(descriptor: &[u8]) { + let absent_library = std::env::current_dir() + .expect("current directory") + .join("semaprax-callable-v3-must-not-be-opened"); + assert!(absent_library.is_absolute()); + + // SAFETY: Input validation rejects the v3 magic before path + // canonicalization, loading, symbol lookup, or invocation, so no foreign + // code is reached. + let descriptor_only = + unsafe { open_admitted_exact(&absent_library, GETTER_SYMBOL, descriptor) }; + // SAFETY: The same shared input validation rejects v3 before the callable + // loader can classify it as v2 or reach any foreign code. + let callable = unsafe { + open_admitted_callable_exact(&absent_library, GETTER_SYMBOL, CALLABLE_SYMBOL, descriptor) + }; + + assert!(matches!( + descriptor_only, + Err(OpenError::CallableV3DescriptorNotLoadable) + )); + assert!(matches!( + callable, + Err(OpenError::CallableV3DescriptorNotLoadable) + )); +} + +#[test] +fn callable_v3_descriptor_is_rejected_before_any_path_or_library_access() { + assert_rejected_by_both_loaders(&descriptor_v3_header()); +} + +#[test] +fn malformed_v3_headers_cannot_fall_through_to_callable_v2_admission() { + let canonical = descriptor_v3_header(); + let mut malformed = Vec::new(); + + let mut version_two = canonical.clone(); + version_two[8..12].copy_from_slice(&2_u32.to_le_bytes()); + malformed.push(version_two); + + let mut wrong_header_size = canonical.clone(); + wrong_header_size[12..16].copy_from_slice(&21_u32.to_le_bytes()); + malformed.push(wrong_header_size); + + let mut wrong_total_size = canonical.clone(); + wrong_total_size[16..20].copy_from_slice(&19_u32.to_le_bytes()); + malformed.push(wrong_total_size); + + let mut trailing_byte = canonical; + trailing_byte.push(0); + malformed.push(trailing_byte); + + for descriptor in malformed { + assert_rejected_by_both_loaders(&descriptor); + } +} diff --git a/crates/semaprax-native-loader/tests/runtime_loader.rs b/crates/semaprax-native-loader/tests/runtime_loader.rs index c4eb3e6..3ad63c4 100644 --- a/crates/semaprax-native-loader/tests/runtime_loader.rs +++ b/crates/semaprax-native-loader/tests/runtime_loader.rs @@ -13,6 +13,9 @@ const EXPECTED: &[u8] = b"SPX-native-descriptor-v1"; const CALLABLE_EXPECTED: &[u8] = &[ b'S', b'P', b'X', b'N', b'A', b'B', b'I', b'2', 2, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0, 0, ]; +const CALLABLE_V3_METADATA: &[u8] = &[ + b'S', b'P', b'X', b'N', b'A', b'B', b'I', b'3', 3, 0, 0, 0, 20, 0, 0, 0, 20, 0, 0, 0, +]; static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); struct Fixture { @@ -302,6 +305,53 @@ fn callable_schema_and_symbols_fail_closed_before_or_during_admission() { } } +#[test] +fn callable_v3_metadata_rejection_does_not_open_a_native_image() { + let descriptor_fixture = Fixture::build(); + // SAFETY: Shared input validation rejects v3 before the trusted fixture is + // opened. If that ordering regresses, the exact v1 getter still provides a + // readable range longer than the 20-byte expected metadata. + let descriptor_error = require_error( + unsafe { + open_admitted_exact( + &descriptor_fixture.library, + b"spx_descriptor_good", + CALLABLE_V3_METADATA, + ) + }, + "callable v3 metadata unexpectedly reached descriptor-only loading", + ); + assert!(matches!( + descriptor_error, + OpenError::CallableV3DescriptorNotLoadable + )); + assert!( + !descriptor_fixture.unload_marker.exists(), + "descriptor-only v3 rejection must happen before image load" + ); + + let callable_fixture = Fixture::build(); + // SAFETY: Shared input validation rejects v3 before image or symbol access. + // The trusted fixture exposes exact 20-byte descriptor storage if that + // ordering regresses, so even the failure path remains within its contract. + let callable_error = require_callable_error(unsafe { + open_admitted_callable_exact( + &callable_fixture.library, + b"spx_descriptor_callable", + b"spx_callable_echo", + CALLABLE_V3_METADATA, + ) + }); + assert!(matches!( + callable_error, + OpenError::CallableV3DescriptorNotLoadable + )); + assert!( + !callable_fixture.unload_marker.exists(), + "callable v3 rejection must happen before image load or symbol lookup" + ); +} + #[test] fn missing_callable_is_rejected_after_exact_descriptor_comparison() { let fixture = Fixture::build(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 83facb8..2fcd8fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -150,9 +150,9 @@ validates its transition semantics, and requires its source call-contract and trace-certificate fingerprints to match v2. The v2 loader rejects the proof magic before opening an image. This proof path has no invocation reservation, module-instance or frame-generation binding, physical finalizer authority, -descriptor-v3 contract, provider, loader admission, host execution, or public -compiler connection; it is not physical fallback evidence and does not change -`SPX-B104`. +embedded descriptor-v3 contract, provider, loader admission, host execution, +or public compiler connection; it is not physical fallback evidence and does +not change `SPX-B104`. The hidden phase model now keeps the eligibility evidence for three irreversible physical boundaries distinct: `CallCommit`, @@ -171,6 +171,29 @@ it allocates and owns no host secret, ledger, exact-instance reservation, loader pin, or physical finalizer. The current proof envelope and callable host do not wire the physical v3 boundary. +The separate [native callable ABI v3](NATIVE-CALLABLE-ABI-V3.md) now fixes that +boundary's private metadata: sequential `SPXNABI3` descriptor fields, an +acyclic hash DAG, bounded recovery graph, exact buffer and instance capacities, +future `execute`/`settle` symbols, six provider-wire role/schema reservations, a +distinct provisional host-only committed-receipt role, and dynamic-image versus +iOS-static linkage metadata. Each `CertifyOutcome` carries its ordinal/outcome +witness and a nonzero digest bound to the trace-certificate fingerprint; the +host recomputes that digest without independently accepting or walking the +trace-path DFA certificate. Resealed witness/digest mutations fail. The seven +runtime statements omit complete byte/tag/digest/host-HMAC transcripts; they +are not frozen codecs and may change private v3 known answers when replaced by +independently tested codecs. The +compiler encoder is bound to its build target and exposes no cross-target +configuration; Android/iOS/Windows cross-emission and runtime evidence remain +absent. The compiler encoder and independently implemented host parser are a +metadata-only tranche. The loader rejects the full v3 magic in its +shared input validator before canonicalization, image loading, getter lookup, +or callable lookup; its exact callable-v2 classifier remains unchanged. Runtime +wire codecs, provider code, v3 loader/static registration, exact-instance frame +reservation, host receipt authentication, physical finalizers, and ledger +publication remain absent. This is architecture evidence, not execution +evidence, and `SPX-B104` remains closed. + The Windows CI lane now explicitly reruns the generated O0/O2 callable corpus and a loader fixture that places a same-name dependency in CWD and legacy `PATH`, then removes the root sibling to require fail-closed `LibraryOpen`. diff --git a/docs/COMPLETION-MATRIX.md b/docs/COMPLETION-MATRIX.md index 15f148b..53e8be0 100644 --- a/docs/COMPLETION-MATRIX.md +++ b/docs/COMPLETION-MATRIX.md @@ -55,9 +55,22 @@ quiescence gap. Its hidden target-neutral model and private compiler derivation from validated cleanup HIR are implemented for the current direct-trivial owned slice. A separately versioned private `SPXNPRF1` envelope now binds the exact v2 call contract and trace certificate to a bounded binary graph, which -the host parses independently without loading or executing it. No descriptor-v3, -provider, loader admission, host settlement execution, physical finalizer, or -public compiler path is wired, so this adds no native runtime evidence to any row. +the host parses independently without loading or executing it. The separate +metadata-only `SPXNABI3` contract now fixes descriptor/hash/graph/capacity and +linkage-profile metadata while reserving seven provisional bounded runtime +roles. Those role statements omit full wire/tag/digest/host-HMAC transcripts +and must be replaced by independently tested codecs, which may change private +v3 known answers. `CertifyOutcome` binds an embedded ordinal/outcome witness to +the trace-certificate fingerprint through a nonzero host-recomputed digest; +this is not independent host acceptance of the trace-path DFA certificate, and +resealed witness/digest mutations are rejected. The emitter is +build-target-bound with no cross-target configuration or Android/iOS/Windows +cross-emission/runtime evidence. Both +existing loaders reject v3 magic before path or image access. Its private +compiler-encoder/independent-host-parser tranche does not add a provider, +runtime wire codec, loader admission, host settlement +execution, physical finalizer, or public compiler path, so it adds no native +runtime evidence to any row. The hidden linear phase model now starts from the sole authenticated post-`CallCommit` state and exercises exact `SettlementDecisionCommit`, provider-candidate, model-`ReceiptCommitted`, and absorbing `Quarantined` diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 18ec6cd..c09da39 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -281,6 +281,45 @@ symbols do not change. The v2 loader rejects the proof magic before opening an image, default consumers cannot import the proof surface, and no proof byte grants loading, execution, adoption, settlement, or finalizer authority. +## Pre-v3 proof to private `SPXNABI3` metadata + +The [native callable ABI v3](NATIVE-CALLABLE-ABI-V3.md) is a new metadata +format, not an extension or reinterpretation of `SPXNPRF1` or `SPXNABI2`. +Consumers must dispatch on the full eight-byte magic and exact version, reject +unknown or malformed versions, and never negotiate or fall back. V3 carries its +settlement graph directly and uses new descriptor, graph, wire-schema, ABI, +contract, and symbol domains. V2 and proof bytes, hashes, symbols, bundles, and +public build-only behavior remain unchanged. + +The two existing dynamic-loader constructors reject every bounded descriptor +beginning with `SPXNABI3` before path canonicalization, image load, or symbol +lookup. This includes a same-magic blob with a changed version, header size, or +total length; it must not fall through to v2 classification or generic +descriptor loading. There is no v3 loader or iOS static-registration +constructor. + +The v3 document reserves six provider wire roles and a separate host-only +committed-receipt role. Their current strings and fingerprints are provisional +bounded schema reservations, not complete codecs: they omit full byte/tag/ +digest transcripts and the exact host-HMAC transcript. They must be replaced +and frozen with independently tested codecs before runtime admission, and that +replacement may change private v3 fingerprints, symbols, and descriptor known +answers. V3 `CertifyOutcome` carries the canonical ordinal/outcome witness and a +nonzero digest over the trace-certificate fingerprint plus that transcript. The +host recomputes this digest and rejects resealed witness/digest mutations, but +does not thereby accept or walk the trace-path DFA certificate independently. +Provider candidate evidence must never be relabeled as a host receipt, +and model `ReceiptCommitted` must not be treated as public ledger +`ReceiptCommit`. + +The current emitter derives its target from the compiler build and exposes no +cross-target configuration. Android, iOS, and Windows cross-emission/runtime +evidence remain absent. Future iOS device, simulator, and Mac Catalyst/macabi +profiles must retain distinct target strings even though they share static +registration. No migration may infer physical finalizer success from +`Finalizing`; interruption remains uncertain and quarantined without retry. +This migration changes no native execution gate and leaves `SPX-B104` closed. + ## Revision token FNV-1a64 to SHA-256 Graph v3 and later, semantic patch bases, CLI output, and `semaprax.web.v2`/`semaprax.web.v3` manifests use one algorithm-tagged token: diff --git a/docs/NATIVE-CALLABLE-ABI-V3.md b/docs/NATIVE-CALLABLE-ABI-V3.md new file mode 100644 index 0000000..8260fd3 --- /dev/null +++ b/docs/NATIVE-CALLABLE-ABI-V3.md @@ -0,0 +1,364 @@ +# Native callable ABI v3 + +Status: private metadata contract. `SPXNABI3` fixes the current compiler/host +descriptor projection. Its seven future physical-wire strings and fingerprints +are provisional bounded role/schema reservations, not complete wire codecs. +No v3 provider, runtime wire codec, loader admission, settlement host, +finalizer, or public compiler surface exists. The current loader rejects v3 +metadata before path or image access. Ordinary native resource compilation +remains `SPX-B104`. + +## Scope and primitives + +This ABI is the metadata boundary for [RFC 0004](RFC-0004-NATIVE-CALL-SETTLEMENT.md). +It binds one validated direct-trivial owned callable to its recovery graph, +future `execute`/`settle` entry points, exact descriptor capacities, and a +dynamic-image or iOS-static linkage role. The current emitter derives the +physical target from the compiler's own build target and exposes no cross-target +configuration. Android, iOS, and Windows cross-emission and runtime evidence +are absent. It is a new contract: v1, v2, settlement-proof v1, and v3 are +mutually incompatible and there is no negotiation or fallback. + +All descriptor and graph integers are little-endian `u32`. A fingerprint is 32 +raw SHA-256 bytes. Text is a `u32` byte length followed by non-empty, NUL-free, +well-formed UTF-8. Graph bytes are framed by a `u32` byte length. Counts are +dense, all arithmetic is checked, truncation and trailing bytes fail closed, +and the complete descriptor is at most 64 KiB. No native struct layout, +padding, pointer, handle, `size_t`, credential, loader path, or host secret is +part of the descriptor. + +The linkage profile is closed: + +| Tag | Profile | +| ---: | --- | +| `1` | Dynamic image on Linux, macOS, Windows, or Android | +| `2` | iOS static registration; no dynamic image open or unload | + +These tags describe private metadata roles, not implemented platform hosts. +Future iOS device, iOS simulator, and Mac Catalyst/macabi targets MUST retain +distinct target strings and MUST NOT share admission evidence merely because +they use the same static-registration linkage tag. + +## Descriptor layout + +The canonical descriptor is sequential; variable text, signature, and graph +fields make fixed byte offsets inappropriate. It is encoded in this exact +order: + +| Field | Encoding | +| --- | --- | +| Magic | eight bytes `SPXNABI3` | +| Version | `u32 = 3` | +| Header size | `u32 = 20` | +| Total size | `u32`, exactly the complete descriptor length | +| Physical target | framed text | +| Linkage profile | `u32`, closed table above | +| Fingerprints | the 19 fingerprints below, each 32 bytes | +| Module identity | framed text | +| Function identity | framed text | +| Descriptor getter symbol | framed text | +| Execute symbol | framed text | +| Settle symbol | framed text | +| Call-ABI tag | `u32 = 3` | +| Required obligations | `u32 = 0x000003ff` | +| Capacities | the 15 `u32` values below, in listed order | +| Signature | canonical v2-shaped parameter/result transcript below | +| Settlement graph byte length | `u32` | +| Settlement graph | exactly that many canonical graph bytes | + +The 19 fingerprints are ordered: descriptor schema, target, semantic module, +physical module, function template, execution/cleanup, event dictionary, +trace-path certificate, recovery contract, settlement graph, request schema, +execute-response schema, frame schema, decision schema, action-evidence schema, +candidate-receipt schema, committed-receipt schema, call ABI, and call contract. +Every fingerprint is nonzero. + +The 15 capacities are ordered: request bytes, execute-response bytes, frame +bytes, decision bytes, action-evidence bytes, candidate-receipt bytes, maximum +event count, dictionary bytes, dictionary entries, resource count, checkpoint +count, graph work units, active frames, quarantined frames, and reserved +instance bytes. Their exact derivation is: + +```text +request = 104 + sum(i64: 16, bool: 12, owned: 20) +execute_response = 124 + 4 * maximum_event_count +frame = 208 + 4 * resource_count +decision = 172 +action_evidence = 188 +candidate_receipt = 264 + +resource_count <= 4_096 +checkpoint_count <= 65_536 +graph_work_units = resource_count * checkpoint_count <= 1_000_000 +active_frames = 256 +quarantined_frames = 64 +reserved_instance_bytes + = 256 * (request + execute_response + frame + decision + + action_evidence + candidate_receipt) + + 64 * (frame + candidate_receipt) + <= 64 MiB +``` + +The first six byte capacities are nonzero and at most 1 MiB. Event and +dictionary bounds retain their authenticated compiler-derived values and must +fit their `u32` fields. No decoder may truncate to a bound or repair a count. +The obligations word is one indivisible v3 profile. Its only canonical value is +`0x000003ff`; it is not a feature-negotiation mask. + +Signature entries are exactly the v2 canonical shape. Parameter tag `1` is +`u32 tag`, dense `u32 index`, value-identity text, and scalar kind `1 = i64` or +`2 = bool`. Parameter tag `2` additionally carries dense owner ordinal, +resource-identity text, lifecycle-identity text, and payload kind `1 = opaque +u64`. Result tag `1` is scalar `i64`. Result tag `2` carries the selected owned +parameter index, its exact value identity, and owner ordinal. The result must +refer to one preceding owned parameter. + +## Settlement graph + +The embedded pointer-free graph is encoded as: + +1. `u32 version = 3`; +2. function-identity text; +3. recovery-contract, execution/cleanup, and trace-certificate fingerprints; +4. `u32 resource_count` and `u32 checkpoint_count`; +5. each dense checkpoint: ordinal, state count, one state tag per resource, + admitted outcome, abort cleanup count and ordinals, then accept cleanup count + and ordinals; +6. start count and dense start ordinals; and +7. edge count followed by `from`, `to`, and one typed action. + +State tags are `1 Live`, `2 ProvisionalResult`, `3 Finalizing`, `4 Dead`, and +`5 Published`. Outcome tag `0` is absent, `1` scalar success, `2` semantic +failure, and `3` owned success followed by its owner ordinal. Edge action tag +`1` is `Finalize(owner)` and `2` is `StageOwnedResult(owner)`. Tag `3` is: + +```text +CertifyOutcome( + trace_evidence: 32 bytes, + ordinal_count: u32, + ordinals: ordinal_count * u32, + trace_outcome: u32 + 1 = scalar success + 2 = owned success + 3 = failure, followed by selected_ordinal: u32 +) +``` + +The evidence digest is nonzero and the host recomputes it exactly as: + +```text +SHA256( + "semaprax.native-recovery-trace-evidence.v1\0" + || trace_path_certificate_fingerprint[32] + || ordinal_count_as_u64_le + || each_ordinal_as_u32_le + || trace_outcome_as_one_byte + || selected_ordinal_as_u32_le_if_failure +) +``` + +This binds one canonical ordinal/outcome witness to the separately carried +trace-certificate fingerprint. It is not independent host acceptance, +reconstruction, or walking of the trace-path trie-DFA certificate itself. The +graph must satisfy RFC 0004 density, reachability, forward-DAG, +state-transition, cleanup-order, and terminal-outcome rules. `Finalizing` and +`Published` are closed vocabulary values but are never admissible checkpoint +states. Canonical bytes are never sorted or repaired by the host. + +## Hash DAG and symbols + +Let `F(x)` mean `u64` big-endian byte length followed by `x`. Domain strings +include their displayed terminal NUL. SHA-256 inputs are concatenated exactly; +numeric transcript fields are raw little-endian `u32` values. + +The descriptor schema and current provisional request, execute-response, frame, +decision, action, candidate-receipt, and committed-receipt role fingerprints +use respectively: + +```text +semaprax.native-callable-descriptor-schema.v3\0 +semaprax.native-callable-request-schema.v3\0 +semaprax.native-callable-execute-response-schema.v3\0 +semaprax.native-callable-frame-schema.v3\0 +semaprax.native-callable-decision-schema.v3\0 +semaprax.native-callable-action-schema.v3\0 +semaprax.native-callable-candidate-receipt-schema.v3\0 +semaprax.native-callable-committed-receipt-schema.v3\0 +``` + +Each is currently the hash of its domain plus one ASCII statement. The +descriptor statement is part of the private metadata layout. The seven runtime +role statements are provisional reservations: they intentionally omit complete +byte layouts, tag namespaces, digest transcripts, and—for the host receipt—the +exact HMAC input/authentication transcript. Their current literal bytes, with no +terminal NUL, are: + +```text +SPXNABI3;u32le;header=20;sequential-no-offsets-no-trailing;target;linkage-profile;19-fingerprints;module;function;getter;execute;settle;abi-tag;obligations;15-capacities;signature;graph-len;graph +SPXNRQ03;u32le-envelope;call-contract32;invocation-u64;frame-generation-u64;provider-challenge32;argument-count;ordered-indexed-arguments;scalar-or-owned-u64-payload +SPXNEX03;u32le-envelope;call-contract32;invocation-u64;frame-generation-u64;provider-challenge32;checkpoint;outcome;result-payload;event-count;event-ordinals +SPXNFR03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;checkpoint;phase;resource-count;resource-states;pre-candidate-digest32 +SPXNDC03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;decision-tag;decision-detail +SPXNAC03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;action-index;action-tag;owner-ordinal;before-state;after-state;checkpoint +SPXNCR03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;pre-candidate-frame-digest32;decision-digest32;action-evidence-digest32;candidate-outcome +SPXHRP03;u32le-envelope;host-only-HMAC-SHA256;exact-instance-capability;call-contract;invocation;frame-generation;provider-challenge;candidate-digest;ledger-before;ledger-after;decision;action-evidence-digest;publication-result;atomic-ledger-and-receipt-visibility +extern-C;getter=const-u8-ptr(void);execute=u32(const-u8-ptr,u32,u8-ptr,u32);settle=u32(u8-ptr,u32,const-u8-ptr,u32,u8-ptr,u32);windows-cdecl;synchronous;same-thread;no-unwind;no-longjmp;no-callbacks;no-retained-pointers;no-reentrancy +``` + +For any current one-statement identity, its fingerprint is +`SHA256(domain || F(statement))`. The target fingerprint is +`SHA256("semaprax.native-callable-target.v3\0" || F(target_utf8))`. The +physical-module fingerprint is SHA-256 over +`semaprax.native-callable-physical-module.v3\0`, then length-framed descriptor +schema, target, and semantic-module fingerprints, length-framed module UTF-8, +and the raw little-endian linkage word. The settlement-graph hash is: + +```text +SHA256("semaprax.native-callable-settlement-graph.v3\0" || F(graph_bytes)) +``` + +Each `CertifyOutcome` witness is therefore sealed twice for different purposes: +the trace-evidence digest binds the trace-certificate fingerprint plus exact +ordinal/outcome transcript, while the graph fingerprint binds its complete +wire encoding and topology. Recomputing the outer graph and call-contract +hashes after mutating a nonzero witness or digest does not make the descriptor +valid; independent host recomputation of the trace-evidence digest must still +match. + +The call-ABI fingerprint uses +`semaprax.native-callable-c-abi.v3\0` and binds the exact ABI statement, +including its Windows calling convention and synchronous +no-unwind/no-`longjmp`/no-retained-pointer/no-callback rules. Its fingerprint is +`SHA256(domain || F(statement))`; the ABI tag and obligations are bound +separately by the call contract. + +The call-contract fingerprint uses +`semaprax.native-callable-contract.v3\0`. It length-frames, in order, target, +the first 18 descriptor fingerprints (everything before the call contract), +module identity, and function identity; appends raw linkage, ABI, obligations, +and all 15 capacity words; then appends the canonical signature transcript. +The call contract does not hash symbols or the descriptor containing itself. +This makes the dependency graph acyclic. + +Before provider or runtime admission, each of the seven provisional role +statements MUST be replaced by and frozen against a complete normative codec, +including every byte, tag, digest, authentication input, bound, and failure +rule. Independent encoder/parser tests and known answers MUST cover those +codecs. Because the private call contract binds these fingerprints, that +replacement may intentionally change private v3 fingerprints, symbols, and +descriptor known answers. No compatibility promise attaches to the current +private KATs. + +After the contract exists, the symbol seed under +`semaprax.native-callable-symbol-seed.v3\0` length-frames physical module, +function template, recovery contract, settlement graph, the seven wire-schema +fingerprints, call ABI, and call contract. Role hashes use the distinct domains +`semaprax.native-callable-getter.v3\0`, +`semaprax.native-callable-execute.v3\0`, and +`semaprax.native-callable-settle.v3\0`. Each symbol is `spx_`, the first 24 +digest bytes as lowercase hexadecimal, and suffix `descriptor_v3`, +`execute_v3`, or `settle_v3`. Symbols are distinct valid C identifiers bounded +to 1,024 bytes. + +```text +schema statements ----> schema fingerprints ---+ +target ----------------> target fingerprint ----+--> physical module +semantic HIR ----------> semantic/template/execution/dictionary/trace/recovery +settlement graph bytes -> graph fingerprint -----+ +all preceding identities + capacities + signature --> call contract +physical/template/recovery/graph/wires/ABI/contract --> symbol seed --> symbols +``` + +## Provisional runtime role/schema reservations + +This milestone reserves bounded roles and current fingerprint inputs, not +complete runtime schemas, encoders, or provider behavior. The six +provider-visible future roles are: + +| Future role | Reserved string | Descriptor capacity | +| --- | --- | --- | +| Execute request | `SPXNRQ03` | request bytes | +| Execute response | `SPXNEX03` | execute-response bytes | +| Recovery frame | `SPXNFR03` | frame bytes | +| Settlement decision | `SPXNDC03` | decision bytes | +| Action evidence | `SPXNAC03` | action-evidence bytes | +| Candidate receipt | `SPXNCR03` | candidate-receipt bytes | + +The provider must never emit a committed receipt. `SPXHRP03` provisionally +identifies a future host-only committed-receipt role created only after +independent candidate parsing, exact-instance/frame-generation replay, and host +authentication. It is not provider output, is not covered by a provider buffer +capacity, and is the only role eligible to accompany public ledger +`ReceiptCommit`. None of these seven runtime codecs is complete, frozen, or +implemented by this metadata milestone. + +## Phases, finalizer uncertainty, and lifetime + +The authoritative post-`CallCommit` phases are `Executing`, `DecisionLocked`, +`ActionInProgress`, `ProviderSettled`, host `ReceiptCommitted`, and absorbing +`Quarantined`. A provider candidate is evidence, not the host-committed phase. +Unwind before decision lock selects `Abort(HostUnwind)`; after lock it resumes +the exact decision. A resource records `Finalizing` before physical effect and +`Dead` only after normal return. Interruption while `Finalizing` is uncertain, +must quarantine, and must never retry the finalizer. + +The future ownership relation is an acyclic lifetime DAG: + +```text +module instance / static registration + -> nonreused frame generation and invocation + -> preallocated request, response, frame, decision, action, candidate + -> locked decision and provider evidence + -> independently authenticated host committed receipt + -> one ledger outcome + +quarantine retains the module/static registration, frame, buffers, +decision/evidence, owners/results, callbacks, and finalizer pins +``` + +Dynamic images become only unload-eligible after draining and release of every +frame, owner, result, credential, callback, and finalizer pin. The iOS static +profile resolves an admitted registration table at link/bootstrap time and has +no `dlopen`/unload claim, but it must preserve the same logical instance, +generation, settlement, draining, and quarantine rules. + +## Threat boundaries and nonclaims + +Descriptor equality and hash validation do not authenticate code provenance, +make malicious native code memory-safe, observe omitted side effects, recover a +process crash, or make an interrupted non-idempotent finalizer retryable. This +contract does not implement provider symbols, physical wire codecs, loader or +static-registration admission, callbacks, async work, concurrency, fork/hot +reload, imported finalizers, cross-target emission, mobile execution, public +adoption, or ecosystem FFI. The emitter is bound to its own build target; there +is no Android/iOS/Windows cross-emission evidence. The existing dynamic loader +rejects `SPXNABI3` before canonicalization, +image load, or symbol lookup; no v3 loader constructor exists. + +## Mandatory gates + +Before any runtime or public claim, all of these must pass together: + +- deterministic compiler bytes and fixed fingerprints plus an independently + implemented host parser and canonical re-encoder; +- complete replacement and freeze of all seven provisional runtime role + statements as independently encoded/parsed byte, tag, digest, and host-HMAC + transcripts, accepting deliberate changes to private v3 known answers; +- every-prefix, trailing-byte, every-byte mutation, hostile count/text/tag, + overflow, cap, graph-topology, cross-module/target/trace, and rehashed + substitution rejection; +- exact trace-evidence digest recomputation, nonzero digest enforcement, and + resealed witness/digest mutation rejection without claiming independent + trace-path DFA certificate acceptance; +- v1/v2/proof/v3 version-confusion rejection, unchanged v2/proof known answers, + default-consumer hiding, and loader pre-open rejection for both constructors; +- all 14 authoritative corpus cases, dynamic and iOS-static profile fixtures, + exact candidate replay, host-only committed-receipt authentication, and + duplicate/stale/cross-bound rejection; +- physical finalizer order/counters, every interruption boundary, quarantine, + draining/unload or static-retention, sanitizers, and Linux/macOS/Windows plus + Android/iOS evidence; and +- the complete repository formatting, strict Clippy, test, doctest, rustdoc, + package, dependency-policy, example, and documentation-link gates. + +Until the physical and public gates pass, `SPX-B104` remains closed. diff --git a/docs/QUALITY-GATES.md b/docs/QUALITY-GATES.md index aabbab4..94b3b64 100644 --- a/docs/QUALITY-GATES.md +++ b/docs/QUALITY-GATES.md @@ -74,9 +74,10 @@ The remaining Cargo and `semaprax` commands are shell-neutral. | Native module lease topology | Fake-backed unit evidence still proves equal-fingerprint instance nonconflation, exact retention, drain ordering, cross-instance rejection despite equal bytes, exactly-once fake release, and no cycles; the real physical-host gate below must independently prove the corresponding loader-backed lifetime rather than treating the fake as physical evidence | | Native settlement model | Fixed resource/checkpoint/progress/work ceilings; nonzero recovery-contract and nonempty/no-NUL function identity; one all-live post-`CallCommit` start; dense checkpoints; canonical typed progress; exact cleanup permutations; trace-bound terminal outcomes; exhaustive abort and accepted-outcome owner-state enumeration through six resources; executable post-`CallCommit` `Executing`, exact `DecisionLocked`, `ActionInProgress`, `ProviderSettled`, model-`ReceiptCommitted`, and absorbing `Quarantined` phases; pre-decision unwind selecting `Abort(HostUnwind)` and post-decision unwind resuming the exact locked decision; unknown/conflicting phase quarantine; `Finalizing` recorded before effect, `Dead` only after normal return, and interruption quarantine with no retry; provider `Published` distinguished from public ledger publication; unique receipt-commit eligibility only after candidate validation; same-decision idempotence with no repeated actions; stale/cross-bound/skipped/reordered/duplicate candidate rejection; exact independently reconstructed receipts; zero active finalizers and terminal dispositions; deterministic domain-separated certificate/receipt fingerprints and migration note; hostile structural/progress/receipt mutations; quarantine evidence retention; start-only nonmutating progress walks; non-`Clone`, non-formatting frame compile-fail tests; and an executable nonclaim that model preparation is not invocation/module/frame-generation reservation, allocation-free physical execution, physical-finalizer authority, host authentication, or ledger mutation authority | | Native callable settlement proof v1 | Private `SPXNPRF1` only; exact unchanged callable-v2 bytes plus canonical pointer-free binary graph in one immutable envelope; 64 KiB global ceiling enforced during compiler serialization and before host allocation; independent encoder/parser and canonical re-encoding; acyclic domain-separated schema/v2/graph/envelope fingerprints; exact source-v2 call-contract and trace-certificate cross-binding; dense checkpoints, one all-live start, reachability, forward DAG, typed transition and cleanup-order continuity validation; all 14 corpus cases; fixed known answer; every prefix, trailing byte, and single-byte mutation; rehashed hostile graph, unknown tag, invalid text, hostile count, cross-module, and changed-trace rejection; v2 loader pre-open rejection; default-consumer compile-fail; unchanged v2 bundle/provider/CLI and `SPX-B104`; and explicit no-provenance, no-authority, no-descriptor-v3, no-provider, no-runtime-settlement nonclaims | +| Native callable ABI v3 metadata | Private `SPXNABI3` only; exact current 20-byte envelope and sequential descriptor/signature/graph order; 64 KiB descriptor ceiling; closed linkage, tag, obligation, count, capacity, and instance-budget values; acyclic domain-separated descriptor/target/module/graph/role/ABI/contract/symbol hashes; deterministic build-target-bound compiler bytes and independent host parse/canonical re-encode; dense bounded settlement-graph replay; `CertifyOutcome` carrying the exact ordinal/outcome witness plus nonzero `semaprax.native-recovery-trace-evidence.v1` digest over the trace-certificate fingerprint, u64le ordinal count, u32le ordinals, and one-byte outcome/detail; independent host digest recomputation and resealed witness/digest mutation rejection, explicitly without claiming independent trace-path DFA certificate acceptance; all 14 corpus cases; current private byte and fingerprint known answers; every prefix, trailing byte, and single-byte mutation plus hostile UTF-8/NUL/tag/count/cap/overflow/graph/rehashed substitution rejection; v1/v2/proof/v3 confusion and cross-target/module/trace rejection; distinct v3 symbols; unchanged v2/proof known answers; default-consumer hiding; both existing loaders rejecting same-magic canonical and malformed v3 before path/image/symbol access; seven runtime strings/fingerprints explicitly provisional until complete byte/tag/digest/host-HMAC transcripts have independent encoder/parser/KAT evidence, with private v3 known-answer changes permitted at that freeze; explicit no cross-target configuration or Android/iOS/Windows cross-emission/runtime evidence; distinct future iOS device/simulator/macabi targets; and explicit no-provider, no-runtime-wire-codec, no-loader/static-admission, no-receipt-authority, no-finalizer, no-public-compiler, no-mobile-runtime, and unchanged `SPX-B104` evidence | | Native callable bundle | Public preflight requires an explicit persistent function ID and at least one direct `own` trivial-resource parameter; deterministic descriptor/provider/dictionary/certificate derivation and preflight SHA-256; strict host Clang shared-library build; exact regular-file-only staged/final inventory with Windows import-library suppression and checked removal of the linker's `.exp` side artifact; canonical sorted per-payload SHA-256 manifest plus manifest checksum; byte-identical double build on each host; build-only API/CLI with no load/invoke/adopt/authority surface; default-feature external-consumer compile-fail proof for host internals; ordinary native `SPX-B104`; missing/automatic/excluded-function diagnostics; observed file/directory/dangling-symlink no-overwrite; failed-Clang staging cleanup; canonical example and green [Ubuntu](https://github.com/wavect/semaprax/actions/runs/31259216533/job/93107277094), [macOS](https://github.com/wavect/semaprax/actions/runs/31259216533/job/93107277081), and [Windows](https://github.com/wavect/semaprax/actions/runs/31259216533/job/93107277085) hosted-CI builds. The canonical output parent is trusted against concurrent adversarial mutation because portable `std` lacks atomic directory rename-no-replace | | Rust native-host ASan | Dedicated Ubuntu 24.04 job; every Rust command routed through the exact pinned nightly and compiler commit; audited Clang 18 major for the executable and generated providers; `cfg(sanitize = "address")` compile-time probe; intentional Rust heap-use-after-free diagnostic; rebuilt target standard library; verbose host-crate `rustc` flag proof; ASan symbols in the host binary and callbacks in the generated provider; and real callable-host plus authoritative-corpus execution. The [fail-closed contract and exact green public job](RUST-HOST-SANITIZERS.md) must remain intact; configuration or static tests alone never count as runtime evidence | -| Native loader quarantine | Separate unpublished crate; main crate still forbids unsafe; exact dependency pin and workspace supply-chain gate; separately documented descriptor-only and callable-v2 unsafe constructors; no generic lookup/raw handle/pointer/manual close; canonical-path, getter/callable-symbol, descriptor, and request/response bounds; null and exact-byte checks; `SPXNABI1` rejection before callable loading; Unix `RTLD_NOW | RTLD_LOCAL`; Windows root/default-safe dependency search without current-directory/legacy-PATH lookup; mandatory same-name Windows collision fixture proving the sibling dependency wins and a missing sibling fails specifically as `LibraryOpen`; eager exact callable lookup; one-shot instance-bound prepared calls; compile-fail `!Send`, `!Sync`, non-`Clone`, and non-formatting lease/call checks; real Linux/macOS runtime-loaded positive, rejection, last-reference, and private-host retention fixtures; explicit malicious-code, code-identity, same-image-provenance, immediate-unmapping, unobserved-host Windows runtime, iOS/Android, callable safety, quiescence, fork, and public-adapter nonclaims | +| Native loader quarantine | Separate unpublished crate; main crate still forbids unsafe; exact dependency pin and workspace supply-chain gate; separately documented descriptor-only and callable-v2 unsafe constructors; no generic lookup/raw handle/pointer/manual close; canonical-path, getter/callable-symbol, descriptor, and request/response bounds; null and exact-byte checks; `SPXNABI1` rejection before callable loading; `SPXNPRF1` and full-magic `SPXNABI3` rejection by both constructors before canonicalization/image/symbol access, including absent-path, malformed same-magic, and Unix unload-marker evidence; Unix `RTLD_NOW | RTLD_LOCAL`; Windows root/default-safe dependency search without current-directory/legacy-PATH lookup; mandatory same-name Windows collision fixture proving the sibling dependency wins and a missing sibling fails specifically as `LibraryOpen`; eager exact callable lookup; one-shot instance-bound prepared calls; compile-fail `!Send`, `!Sync`, non-`Clone`, and non-formatting lease/call checks; real Linux/macOS runtime-loaded positive, rejection, last-reference, and private-host retention fixtures; explicit malicious-code, code-identity, same-image-provenance, immediate-unmapping, unobserved-host Windows runtime, iOS/Android, callable safety, quiescence, fork, and public-adapter nonclaims | | Native physical ownership host | Separate unpublished crate and unchanged compiler `unsafe_code = "forbid"`/`SPX-B104`; strict descriptor-v1 and independent descriptor-v2 decoding; every-byte/truncation/trailing mutation rejection; compiler-authenticated dictionary plus trace-path certificate; strict allocation-free postcommit response decoding; exact loader-instance callable binding; OS-seeded same-thread authority; credential-to-ledger resource/lifecycle/slot/generation checks; non-mutating fully allocated plans and atomic commit; unsafe trusted adoption; noncopying/nonformatting/`!Send`/`!Sync` owners; reusable owners after precommit rejection; scalar/owned success, normalized semantic/adapter failure, generation rotation, draining, lease retention, and cross-instance rejection; real generated O0/O2 shared libraries executing the full 14-case reference corpus through the host; green public Linux generated-provider ASan+UBSan, Rust-host ASan, and Windows callable/dependency-isolation evidence; public build-only bundle emission. Callable-v3 additionally requires an independent candidate-receipt parser/replay gate, exact-instance and frame-generation binding, host-only receipt-authentication KAT and mutations, duplicate/stale/cross-bound rejection, one atomic ledger publication, and crash/unwind injection around `ReceiptCommit`. Keep `SPX-B104` closed until general physical/malformed-response fallback cleanup and quiescence, Android/iOS profiles, and public execution/admission are proven | | Wasm owned ABI v1 | Exactly one direct trivial-resource identity and stable `SPX-W111` exclusions; replay-validated plan admission and hostile-plan `SPX-H006`; deterministic Wasm bytes, embedded exact SHA-256 artifact authentication before host construction, export metadata, `semaprax.web.v3` manifest mapping, and scalar-lane regression; private host imports; exact export/count/canonical-i64/positive-i32/result-kind validation; one-shot branded adoption with reuse rejection and nonmutation on capacity failure; instance-tagged slot/generation stale, replay, duplicate, same-module, and duplicated-generated-module same-realm cross-instance rejection plus invalid/repeated allocator fail-closed tests; complete 4/8-byte precommit out-range/alignment validation; one reserved status cell plus resolvable exhaustion sentinel; exact canonical `semaprax.status.v1` field shape; atomic owner staging and reusable owners after rejection; reverse exact-once cleanup and equal-payload exact-owner selection; owned-result rotation; requires/overflow/ensures failure with poison preservation; result/status/slot exhaustion at bounded test limits; real Node execution; compiler-generated semantic ordinals authenticated by the deterministic dictionary and materialized to exact reference traces for the shared 14-case corpus. A trusted unpoisoned same-realm host environment, cross-realm/worker identity, Components, imports/finalizers, async/shared memory, and production callable-native equivalence remain explicit boundaries/nonclaims | | Owned resource vertical slice | The complete [v1 contract](OWNED-RESOURCE-VERTICAL-V1.md): structural narrow-slice admission; production-reachable compiler and host APIs; indivisible parameter-ordered owner commit; reusable owners after rejection; exact-once ordinary/result/failure cleanup; result-aware finalization; native unsafe admission and adoption quarantine; same-thread exact loader retention and draining; instance-bound Wasm `(slot, generation)` handles; exact native/reference/Wasm status/publication/trace equality for the full corpus; real Linux/macOS/Windows native calls and Node/Wasm execution; O0/O2 and mandatory ASan/UBSan; deterministic C/descriptor-v2/Wasm bytes and symbols; exhaustion/wraparound, hostile-boundary, compile-fail trait, MSRV, package, and dependency-policy gates; unchanged fail-closed diagnostics for every excluded shape | diff --git a/docs/RFC-0004-NATIVE-CALL-SETTLEMENT.md b/docs/RFC-0004-NATIVE-CALL-SETTLEMENT.md index da51146..8e3418f 100644 --- a/docs/RFC-0004-NATIVE-CALL-SETTLEMENT.md +++ b/docs/RFC-0004-NATIVE-CALL-SETTLEMENT.md @@ -1,6 +1,7 @@ # RFC 0004: Native call recovery and settlement -- Status: Proposed; private phase model/derivation/proof serialization implemented, physical v3 unwired +- Status: Proposed; private phase model/proof and v3 metadata contract exist, + physical v3 unwired - Version: 0.1 - Audience: compiler, native code generator, loader, ownership-host, adapter, and conformance-test implementers @@ -22,9 +23,14 @@ private compiler derivation from validated cleanup HIR. The authority-free [settlement-proof v1](NATIVE-CALLABLE-SETTLEMENT-PROOF-V1.md) format now embeds the exact callable-v2 descriptor and a canonical binary graph; an independent host parser validates its bounds, hashes, topology, transitions, -and cross-artifact bindings. This is not descriptor v3 or a runtime wire. There -is no v3 provider symbol, code generator, loader path, physical finalizer, -ownership-host wiring, or public callable-v3 compiler surface. Callable v2 has an independent public +and cross-artifact bindings. The separate metadata-only [native callable ABI +v3](NATIVE-CALLABLE-ABI-V3.md) now fixes the current private `SPXNABI3` +descriptor projection, capacities, linkage metadata, hash DAG, and graph. Its +seven future runtime strings/fingerprints are provisional bounded role/schema +reservations that omit full byte/tag/digest/host-HMAC transcripts; they are not +frozen wire codecs. There is no v3 provider code, loader/static +admission, physical finalizer, ownership-host wiring, or public callable-v3 +compiler surface. Callable v2 has an independent public build-only bundle surface plus a feature-gated execution experiment; ordinary native resource execution still fails with `SPX-B104`, and the model plus this document satisfy no physical-runtime completion gate. @@ -62,6 +68,9 @@ This RFC extends, but does not replace, the following contracts: - [Native callable ABI v2](NATIVE-CALLABLE-ABI-V2.md) defines the current private request/response and trace-certificate experiment, including its unresolved physical-failure boundary. +- [Native callable ABI v3](NATIVE-CALLABLE-ABI-V3.md) fixes the current private + metadata descriptor and capacities while reserving provisional future wire + roles without granting execution or settlement authority. - [Conformance trace v1](CONFORMANCE-TRACE-V1.md) remains the semantic trace. Recovery checkpoints, physical resource state, adapter failure, and settlement receipts are adapter evidence and must not be inserted into that @@ -173,12 +182,15 @@ must be exactly dense `1..=N`, and every size multiplication is checked. These bounds make model validation finite; they are not an implemented physical ABI or byte-capacity guarantee. -A future descriptor and wire schema MUST authenticate exact frame, -certificate, action, and receipt byte capacities and MUST set a versioned hard -byte ceiling before wiring. Every alignment, addition, multiplication, -host-size conversion, and allocation must be checked before commit. Exceeding a -bound is a precommit rejection, never postcommit truncation. The present model's -canonical JSON is test evidence, not a stable native wire layout. +The metadata-only `SPXNABI3` descriptor authenticates exact request, response, +frame, decision, action, candidate-receipt, count, and instance-reservation +capacities under versioned hard ceilings. The corresponding runtime wire codecs +are still future work; the current role statements are incomplete and may +change private v3 fingerprints/symbols/known answers when complete independently +tested codecs are frozen. Every alignment, addition, multiplication, host-size +conversion, and allocation must be checked before commit. Exceeding a bound is +a precommit rejection, never postcommit truncation. The present model's +canonical JSON remains test evidence, not a native runtime wire layout. ## Owner-level resource states @@ -352,10 +364,17 @@ state, permutation, outcome, and work-budget checks described above. This certificate is a bounded target-neutral decision table and progress graph, not yet a physical provider protocol. The private compiler derives it from independently validated HIR, cleanup inventory, cleanup plan, direct-owner -recovery layout, result meaning, and the semantic trace certificate. A future descriptor must fingerprint it separately from the -semantic event dictionary and trace-path certificate, and an independently -implemented host parser must reject every noncanonical byte, identity, count, -bound, or fingerprint mismatch before commit. +recovery layout, result meaning, and the semantic trace certificate. The +metadata-only v3 descriptor fingerprints it separately from the semantic event +dictionary and trace-path certificate. A `CertifyOutcome` edge additionally +carries its ordinal/outcome witness and a nonzero digest computed over that +transcript plus the trace-certificate fingerprint. The host recomputes the +exact `semaprax.native-recovery-trace-evidence.v1` digest and rejects resealed +witness/digest mutations. This proves only the witness binding; the v3 metadata +parser does not independently accept, reconstruct, or walk the trace-path DFA +certificate. It must still reject every noncanonical byte, identity, count, +bound, or fingerprint mismatch; physical commit and provider admission remain +unwired. ## Candidate and committed receipts @@ -516,14 +535,19 @@ model, atomic `settle` helper, or proof envelope. 2. Derive and independently validate the settlement certificate from cleanup HIR while callable v3 remains unreachable from compiler preflight. 3. Serialize the derived proof through one bounded authority-free envelope and - independently parse it while reserving no callable-v3 ABI version. -4. Add the real descriptor-v3 contract and generated `execute`/`settle` - artifacts behind the private feature; keep v2 tests unchanged as - compatibility evidence. -5. Connect the exact-instance loader and host with the combined settlement guard, + parse it independently from callable v3. +4. Maintain the current private descriptor-v3 metadata contract, build-target- + bound compiler encoder, and + independent host parser behind the private feature; keep v2/proof known + answers unchanged as compatibility evidence. +5. Replace all seven provisional role statements with complete independently + encoded/parsed byte, tag, digest, and host-HMAC transcripts; freeze new + private v3 known answers; then add generated `execute`/`settle` provider + artifacts behind the private feature. +6. Connect the exact-instance loader and host with the combined settlement guard, receipt authentication, poison, draining, and quarantine. -6. Run the complete failure-injection, sanitizer, unload, and platform matrix. -7. Connect callable-v3 compiler execution/admission only after the full admitted +7. Run the complete failure-injection, sanitizer, unload, and platform matrix. +8. Connect callable-v3 compiler execution/admission only after the full admitted slice is proven. The independent callable-v2 build-only bundle remains non-executing, and every excluded shape retains its stable fail-closed diagnostic. @@ -534,8 +558,15 @@ infrastructure, not evidence for a later one. ## Explicit nonclaims and current status -This RFC specifies no stable public C ABI, public Rust API, callable-v3 byte -layout or magic, symbol, capability token, or loader constructor. The private +This RFC specifies no stable public C ABI, public Rust API, capability token, +or loader/static-registration constructor. The private metadata-only callable +v3 document fixes its descriptor bytes, derived symbols, capacities, and future +wire-role reservations, but those role statements are not complete/frozen +runtime codecs and provider symbols are not implemented or admitted. The +emitter derives only its compiler build target and has no cross-target +configuration. Android/iOS/Windows cross-emission and runtime evidence are +absent; future iOS device, simulator, and Mac Catalyst/macabi targets remain +distinct. The private `SPXNPRF1` proof format is versioned separately and cannot be executed or loaded. It does not implement imports or finalizers, aggregates, callbacks, async, concurrency, fork recovery, @@ -545,8 +576,9 @@ successful cleanup and does not recover from interruption inside a finalizer. As of this revision, the hidden target-neutral owner-state/progress model, phase-aware linear transaction and its 29 focused tests, private compiler -derivation, bounded binary proof encoder, and independent proof parser are -implemented; none of their physical runtime pieces are wired. +derivation, bounded binary proof encoder, independent proof parser, and v3 +metadata contract exist; the existing loader rejects v3 before path/image +access, and none of the physical v3 runtime pieces are wired. Callable v2 continues to retire logical ledger state after physical failure without proving general physical fallback cleanup or quiescence. Therefore the completion diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d4e300e..a7f4163 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -81,12 +81,25 @@ model proposed for the physical-failure blocker. The hidden target-neutral model and private compiler derivation from validated cleanup HIR now exist for the current direct-trivial owned slice. A private `SPXNPRF1` proof envelope and independent semantic parser bind that graph to the exact callable-v2 contract; -this deliberately reserves no callable-v3 ABI version and grants no authority. -None of the callable-v3 descriptor, provider, loader admission, host settlement, -physical-finalizer, or public compiler pieces are wired, so this does not advance -phase 3 or `SPX-B104`. - -Before freezing descriptor-v3 bytes, the hidden settlement model now starts at +this grants no authority. The separate metadata-only [callable ABI +v3](NATIVE-CALLABLE-ABI-V3.md) now fixes the current private descriptor/hash/ +graph/capacity and linkage-role metadata. Its seven runtime statements are +provisional bounded reservations, not complete wire/tag/digest/host-HMAC +transcripts, and may change private v3 known answers when real codecs are +frozen. `CertifyOutcome` now binds its embedded ordinal/outcome witness to the +trace-certificate fingerprint through a nonzero host-recomputed digest and +rejects resealed mutations; this is not independent host acceptance of the +trace-path DFA certificate. The emitter is bound to its compiler build target +with no Android/iOS/Windows cross-emission or runtime evidence; future iOS +device, simulator, and macabi targets remain distinct. This +compiler-encoder/independent-parser +tranche is not a provider or runtime wire, and both existing loaders reject v3 +magic before path/image access. No v3 provider, loader/static admission, host +settlement, physical finalizer, or public compiler execution is wired, so this +does not advance phase 3 or `SPX-B104`. + +With the current private descriptor-v3 metadata defined, the hidden settlement +model starts at the authenticated post-`CallCommit` boundary and makes one exact `SettlementDecisionCommit`, provider settlement, and model `ReceiptCommitted` eligibility executable. diff --git a/src/codegen.rs b/src/codegen.rs index ce533a3..a6dd02e 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -1,5 +1,7 @@ mod native_adapter_abi; mod native_callable_abi; +#[cfg(any(test, feature = "unstable-native-host-internal"))] +mod native_callable_abi_v3; mod native_callable_bundle; mod native_callable_execution; #[cfg_attr( @@ -406,8 +408,9 @@ pub fn emit_native_callable_admission( } /// Feature-gated, authority-free callable-v2 settlement proof used only by the -/// unpublished native host's independent decoder tests. This is not callable -/// descriptor v3; no descriptor-v3 format, provider, or execution path exists. +/// unpublished native host's independent decoder tests. This remains distinct +/// from the metadata-only callable-v3 descriptor and provides no provider or +/// execution path. #[cfg(any(test, feature = "unstable-native-host-internal"))] #[doc(hidden)] pub struct NativeCallableSettlementProofArtifact( @@ -432,6 +435,49 @@ pub fn emit_native_callable_settlement_proof( .map(NativeCallableSettlementProofArtifact) } +/// Feature-gated, metadata-only callable-v3 descriptor artifact. +/// +/// This exposes no provider, loader, native function pointer, finalizer, or +/// host authority. The unpublished host uses the bytes and exact symbol names +/// solely to exercise its independent parser and version-confusion gates. +#[cfg(any(test, feature = "unstable-native-host-internal"))] +#[doc(hidden)] +pub struct NativeCallableV3DescriptorArtifact(native_callable_abi_v3::NativeCallableV3Descriptor); + +#[cfg(any(test, feature = "unstable-native-host-internal"))] +#[doc(hidden)] +impl NativeCallableV3DescriptorArtifact { + pub fn bytes(&self) -> &[u8] { + &self.0.bytes + } + + pub fn getter_symbol(&self) -> &str { + &self.0.getter_symbol + } + + pub fn execute_symbol(&self) -> &str { + &self.0.execute_symbol + } + + pub fn settle_symbol(&self) -> &str { + &self.0.settle_symbol + } + + pub fn call_contract(&self) -> [u8; 32] { + self.0.call_contract + } +} + +/// Derive metadata-only callable-v3 bytes from validated compiler facts. +#[cfg(any(test, feature = "unstable-native-host-internal"))] +#[doc(hidden)] +pub fn emit_native_callable_v3_descriptor( + program: &ResolvedProgram, + function_id: &DeclarationId, +) -> Result { + native_callable_abi_v3::derive(program, function_id).map(NativeCallableV3DescriptorArtifact) +} + fn native_callable_execution_cleanup_fingerprint(components: &[&[u8]]) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(b"semaprax.native-callable-execution-cleanup.v2\0"); diff --git a/src/codegen/native_callable_abi_v3.rs b/src/codegen/native_callable_abi_v3.rs new file mode 100644 index 0000000..585f0c2 --- /dev/null +++ b/src/codegen/native_callable_abi_v3.rs @@ -0,0 +1,1441 @@ +//! Canonical, metadata-only callable native adapter descriptor v3. +//! +//! This encoder is intentionally independent from `SPXNABI2` and +//! `SPXNPRF1`: it consumes validated compiler facts and the target-neutral +//! settlement derivation directly, and embeds neither predecessor artifact. +//! The result names future entry points but emits no provider, opens no image, +//! reserves no invocation, and grants no physical-finalizer authority. + +#![forbid(unsafe_code)] +#![cfg_attr( + not(test), + allow(dead_code, reason = "callable-v3 remains unpublished metadata") +)] + +use std::collections::HashMap; +use std::fmt::Write as _; + +use sha2::{Digest, Sha256}; + +use crate::diagnostic::Diagnostic; +use crate::hir::{DeclarationId, ResolvedProgram}; +use crate::native_settlement::{ + NativeSettlementCertificate, SettlementOutcome, SettlementProgressAction, + SettlementResourceState, +}; +use crate::trace_path_certificate::TracePathOutcome; + +use super::native_host_contract::{ + NativeAdapterParameterProjection, NativeAdapterResultProjection, NativeHostScalarKind, +}; +use super::{ + native_callable_execution, native_cleanup, native_cleanup_emit, native_host_contract, + native_resource, native_runtime, native_settlement_derivation, native_trace_runtime, + native_value, NATIVE_SCALAR_RUNTIME_C, +}; + +const MAGIC: &[u8; 8] = b"SPXNABI3"; +const VERSION: u32 = 3; +const HEADER_SIZE: u32 = 20; +const FINGERPRINT_BYTES: usize = 32; +const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024; +const MAX_WIRE_BYTES: u32 = 1024 * 1024; +const MAX_EVENT_COUNT: u32 = 65_536; +const MAX_DICTIONARY_BYTES: u32 = 1024 * 1024; +const MAX_DICTIONARY_ENTRIES: u32 = 65_536; +const MAX_RESOURCE_COUNT: u32 = 4_096; +const MAX_CHECKPOINT_COUNT: u32 = 65_536; +const MAX_GRAPH_WORK_UNITS: u32 = 1_000_000; +const MAX_INSTANCE_RESERVED_BYTES: u32 = 64 * 1024 * 1024; + +const LINKAGE_DYNAMIC_IMAGE: u32 = 1; +const LINKAGE_IOS_STATIC_REGISTRATION: u32 = 2; +const CALL_ABI_TAG: u32 = 3; +const CALL_OBLIGATIONS: u32 = 0x03ff; +const ACTIVE_FRAME_LIMIT: u32 = 256; +const QUARANTINED_FRAME_LIMIT: u32 = 64; + +const PARAMETER_SCALAR: u32 = 1; +const PARAMETER_OWNED_RESOURCE: u32 = 2; +const SCALAR_I64: u32 = 1; +const SCALAR_BOOL: u32 = 2; +const OWNED_PAYLOAD_WIRE_KIND: u32 = 1; +const RESULT_SCALAR_I64: u32 = 1; +const RESULT_OWNED_INPUT: u32 = 2; + +const GRAPH_VERSION: u32 = 3; +const STATE_LIVE: u32 = 1; +const STATE_PROVISIONAL_RESULT: u32 = 2; +const STATE_FINALIZING: u32 = 3; +const STATE_DEAD: u32 = 4; +const STATE_PUBLISHED: u32 = 5; +const OUTCOME_NONE: u32 = 0; +const OUTCOME_SCALAR_SUCCESS: u32 = 1; +const OUTCOME_SEMANTIC_FAILURE: u32 = 2; +const OUTCOME_OWNED_SUCCESS: u32 = 3; +const ACTION_FINALIZE: u32 = 1; +const ACTION_STAGE_OWNED_RESULT: u32 = 2; +const ACTION_CERTIFY_OUTCOME: u32 = 3; + +// These are exact maximum-wire formula constants. The exact instance +// capability remains host state and is deliberately not asserted by provider +// bytes; invocation, generation, and challenge only bind wire evidence. +const REQUEST_FIXED_BYTES: u32 = 104; +const REQUEST_I64_BYTES: u32 = 16; +const REQUEST_BOOL_BYTES: u32 = 12; +const REQUEST_OWNER_BYTES: u32 = 20; +const EXECUTE_RESPONSE_FIXED_AND_MAX_PAYLOAD_BYTES: u32 = 124; +const EVENT_ORDINAL_BYTES: u32 = 4; +const FRAME_FIXED_BYTES: u32 = 208; +const FRAME_RESOURCE_STATE_BYTES: u32 = 4; +const DECISION_BYTES: u32 = 172; +const ACTION_EVIDENCE_BYTES: u32 = 188; +const CANDIDATE_RECEIPT_BYTES: u32 = 264; + +const DESCRIPTOR_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-descriptor-schema.v3\0"; +const TARGET_DOMAIN: &[u8] = b"semaprax.native-callable-target.v3\0"; +const PHYSICAL_MODULE_DOMAIN: &[u8] = b"semaprax.native-callable-physical-module.v3\0"; +const SETTLEMENT_GRAPH_DOMAIN: &[u8] = b"semaprax.native-callable-settlement-graph.v3\0"; +const TRACE_EVIDENCE_DOMAIN: &[u8] = b"semaprax.native-recovery-trace-evidence.v1\0"; +const REQUEST_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-request-schema.v3\0"; +const EXECUTE_RESPONSE_SCHEMA_DOMAIN: &[u8] = + b"semaprax.native-callable-execute-response-schema.v3\0"; +const FRAME_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-frame-schema.v3\0"; +const DECISION_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-decision-schema.v3\0"; +const ACTION_SCHEMA_DOMAIN: &[u8] = b"semaprax.native-callable-action-schema.v3\0"; +const CANDIDATE_RECEIPT_SCHEMA_DOMAIN: &[u8] = + b"semaprax.native-callable-candidate-receipt-schema.v3\0"; +const COMMITTED_RECEIPT_SCHEMA_DOMAIN: &[u8] = + b"semaprax.native-callable-committed-receipt-schema.v3\0"; +const CALL_ABI_DOMAIN: &[u8] = b"semaprax.native-callable-c-abi.v3\0"; +const CALL_CONTRACT_DOMAIN: &[u8] = b"semaprax.native-callable-contract.v3\0"; +const SYMBOL_SEED_DOMAIN: &[u8] = b"semaprax.native-callable-symbol-seed.v3\0"; +const GETTER_SYMBOL_DOMAIN: &[u8] = b"semaprax.native-callable-getter.v3\0"; +const EXECUTE_SYMBOL_DOMAIN: &[u8] = b"semaprax.native-callable-execute.v3\0"; +const SETTLE_SYMBOL_DOMAIN: &[u8] = b"semaprax.native-callable-settle.v3\0"; + +// Literal statements are part of the current private metadata KAT inputs. +// The provisional runtime-role statements may still change, with matching +// compiler/host fixtures and KATs, before any v3 compatibility promise. +const DESCRIPTOR_SCHEMA_STATEMENT: &[u8] = b"SPXNABI3;u32le;header=20;sequential-no-offsets-no-trailing;target;linkage-profile;19-fingerprints;module;function;getter;execute;settle;abi-tag;obligations;15-capacities;signature;graph-len;graph"; +const REQUEST_SCHEMA_STATEMENT: &[u8] = b"SPXNRQ03;u32le-envelope;call-contract32;invocation-u64;frame-generation-u64;provider-challenge32;argument-count;ordered-indexed-arguments;scalar-or-owned-u64-payload"; +const EXECUTE_RESPONSE_SCHEMA_STATEMENT: &[u8] = b"SPXNEX03;u32le-envelope;call-contract32;invocation-u64;frame-generation-u64;provider-challenge32;checkpoint;outcome;result-payload;event-count;event-ordinals"; +const FRAME_SCHEMA_STATEMENT: &[u8] = b"SPXNFR03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;checkpoint;phase;resource-count;resource-states;pre-candidate-digest32"; +const DECISION_SCHEMA_STATEMENT: &[u8] = b"SPXNDC03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;decision-tag;decision-detail"; +const ACTION_SCHEMA_STATEMENT: &[u8] = b"SPXNAC03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;action-index;action-tag;owner-ordinal;before-state;after-state;checkpoint"; +const CANDIDATE_RECEIPT_SCHEMA_STATEMENT: &[u8] = b"SPXNCR03;u32le-envelope;call-contract32;recovery-contract32;settlement-graph32;invocation-u64;frame-generation-u64;provider-challenge32;pre-candidate-frame-digest32;decision-digest32;action-evidence-digest32;candidate-outcome"; +const COMMITTED_RECEIPT_SCHEMA_STATEMENT: &[u8] = b"SPXHRP03;u32le-envelope;host-only-HMAC-SHA256;exact-instance-capability;call-contract;invocation;frame-generation;provider-challenge;candidate-digest;ledger-before;ledger-after;decision;action-evidence-digest;publication-result;atomic-ledger-and-receipt-visibility"; +const CALL_ABI_STATEMENT: &[u8] = b"extern-C;getter=const-u8-ptr(void);execute=u32(const-u8-ptr,u32,u8-ptr,u32);settle=u32(u8-ptr,u32,const-u8-ptr,u32,u8-ptr,u32);windows-cdecl;synchronous;same-thread;no-unwind;no-longjmp;no-callbacks;no-retained-pointers;no-reentrancy"; + +/// Metadata only. Possession of these bytes or names confers no native-call +/// or physical-finalizer authority. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct NativeCallableV3Descriptor { + pub(super) bytes: Vec, + pub(super) getter_symbol: String, + pub(super) execute_symbol: String, + pub(super) settle_symbol: String, + pub(super) call_contract: [u8; FINGERPRINT_BYTES], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CanonicalParameter { + index: u32, + value: String, + kind: CanonicalParameterKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum CanonicalParameterKind { + Scalar(u32), + Owned { + ordinal: u32, + resource: String, + lifecycle: String, + payload_wire_kind: u32, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum CanonicalResult { + ScalarI64, + Owned { + parameter_index: u32, + value: String, + owner_ordinal: u32, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Capacities { + request: u32, + execute_response: u32, + frame: u32, + decision: u32, + action_evidence: u32, + candidate_receipt: u32, + event_count: u32, + dictionary_bytes: u32, + dictionary_entries: u32, + resource_count: u32, + checkpoint_count: u32, + graph_work_units: u32, + active_frames: u32, + quarantined_frames: u32, + instance_reserved_bytes: u32, +} + +impl Capacities { + fn words(&self) -> [u32; 15] { + [ + self.request, + self.execute_response, + self.frame, + self.decision, + self.action_evidence, + self.candidate_receipt, + self.event_count, + self.dictionary_bytes, + self.dictionary_entries, + self.resource_count, + self.checkpoint_count, + self.graph_work_units, + self.active_frames, + self.quarantined_frames, + self.instance_reserved_bytes, + ] + } +} + +pub(super) fn derive( + program: &ResolvedProgram, + function_id: &DeclarationId, +) -> Result { + derive_for_target( + program, + function_id, + &physical_target_tag()?, + physical_linkage_profile(), + ) +} + +fn derive_for_target( + program: &ResolvedProgram, + function_id: &DeclarationId, + target: &str, + linkage_profile: u32, +) -> Result { + validate_target_profile(target, linkage_profile)?; + crate::hir::validate(program)?; + + let resource_abi = native_resource::build_resource_abi(program)?; + let function = program + .functions + .iter() + .find(|candidate| &candidate.id == function_id) + .ok_or_else(|| v3_error("function is not in the validated program"))?; + let cleanup = native_cleanup::classify(program, function)?; + let mut values = + native_value::plan(program, function, &cleanup, &resource_abi, &HashMap::new())?; + let dictionary = crate::semantic_trace::build_semantic_event_dictionary(program, function_id)?; + let trace = crate::trace_path_certificate::build_trace_path_certificate( + program, + function, + &dictionary, + )?; + values.cleanup_bindings.semantic_events = Some(dictionary.clone()); + let declarations = native_value::emit_declarations(&values); + let cleanup_body = native_cleanup_emit::emit_with_block_prologues( + &cleanup, + &values.cleanup_bindings, + |block, output| { + output.push_str(&native_value::emit_block_prologue(&values, block)); + Ok(()) + }, + )?; + let mut status_runtime = String::new(); + native_runtime::emit_status_runtime(&mut status_runtime); + let mut trace_runtime = String::new(); + native_trace_runtime::emit_trace_runtime(&mut trace_runtime); + let execution = native_callable_execution::plan( + program, + function, + &cleanup, + &values, + &resource_abi, + &dictionary, + declarations.clone(), + cleanup_body.clone(), + )?; + let (normalized_execution_projection, codec_profile_fingerprint) = + execution.normalized_projection()?; + let execution_cleanup = super::native_callable_execution_cleanup_fingerprint(&[ + resource_abi.declarations.as_bytes(), + status_runtime.as_bytes(), + trace_runtime.as_bytes(), + NATIVE_SCALAR_RUNTIME_C.as_bytes(), + declarations.as_bytes(), + cleanup_body.as_bytes(), + &codec_profile_fingerprint, + normalized_execution_projection.as_bytes(), + ]); + + let template = native_host_contract::derive_from_admitted( + program, + function_id, + &resource_abi, + &cleanup, + &values, + )?; + let projection = native_host_contract::project_for_callable_abi(&template); + let semantic_module = decode_fingerprint( + &projection.module_abi_fingerprint, + "semantic module ABI fingerprint", + )?; + let function_template = decode_fingerprint( + &projection.function_template_fingerprint, + "function-template fingerprint", + )?; + let (parameters, result) = canonical_signature(&projection.parameters, &projection.result)?; + + let settlement = native_settlement_derivation::derive_native_settlement(program, function_id)?; + if settlement.trace_certificate_fingerprint() != trace.fingerprint() { + return Err(v3_error( + "execution and settlement derivations disagree on the trace certificate", + )); + } + let graph = encode_graph( + &settlement, + execution_cleanup, + settlement.trace_certificate_fingerprint(), + MAX_DESCRIPTOR_BYTES, + )?; + + let schema = framed_fingerprint(DESCRIPTOR_SCHEMA_DOMAIN, DESCRIPTOR_SCHEMA_STATEMENT); + let target_fingerprint = framed_fingerprint(TARGET_DOMAIN, target.as_bytes()); + let physical_module = physical_module_fingerprint( + &schema, + &target_fingerprint, + &semantic_module, + projection.module.as_bytes(), + linkage_profile, + ); + let settlement_graph = framed_fingerprint(SETTLEMENT_GRAPH_DOMAIN, &graph); + let request_schema = framed_fingerprint(REQUEST_SCHEMA_DOMAIN, REQUEST_SCHEMA_STATEMENT); + let execute_response_schema = framed_fingerprint( + EXECUTE_RESPONSE_SCHEMA_DOMAIN, + EXECUTE_RESPONSE_SCHEMA_STATEMENT, + ); + let frame_schema = framed_fingerprint(FRAME_SCHEMA_DOMAIN, FRAME_SCHEMA_STATEMENT); + let decision_schema = framed_fingerprint(DECISION_SCHEMA_DOMAIN, DECISION_SCHEMA_STATEMENT); + let action_schema = framed_fingerprint(ACTION_SCHEMA_DOMAIN, ACTION_SCHEMA_STATEMENT); + let candidate_receipt_schema = framed_fingerprint( + CANDIDATE_RECEIPT_SCHEMA_DOMAIN, + CANDIDATE_RECEIPT_SCHEMA_STATEMENT, + ); + let committed_receipt_schema = framed_fingerprint( + COMMITTED_RECEIPT_SCHEMA_DOMAIN, + COMMITTED_RECEIPT_SCHEMA_STATEMENT, + ); + let call_abi = framed_fingerprint(CALL_ABI_DOMAIN, CALL_ABI_STATEMENT); + let capacities = derive_capacities( + ¶meters, + values.required_event_capacity, + dictionary.canonical_json().len(), + dictionary.entries().len(), + settlement.certificate(), + )?; + let pre_contract_fingerprints = [ + schema, + target_fingerprint, + semantic_module, + physical_module, + function_template, + execution_cleanup, + dictionary.fingerprint(), + trace.fingerprint(), + settlement.recovery_contract_fingerprint(), + settlement_graph, + request_schema, + execute_response_schema, + frame_schema, + decision_schema, + action_schema, + candidate_receipt_schema, + committed_receipt_schema, + call_abi, + ]; + let call_contract = call_contract_fingerprint( + target, + &pre_contract_fingerprints, + &projection.module, + &projection.function, + linkage_profile, + &capacities, + ¶meters, + &result, + ); + let seed = symbol_seed(&[ + physical_module, + function_template, + settlement.recovery_contract_fingerprint(), + settlement_graph, + request_schema, + execute_response_schema, + frame_schema, + decision_schema, + action_schema, + candidate_receipt_schema, + committed_receipt_schema, + call_abi, + call_contract, + ]); + let getter_symbol = exact_symbol(&seed, GETTER_SYMBOL_DOMAIN, "descriptor_v3"); + let execute_symbol = exact_symbol(&seed, EXECUTE_SYMBOL_DOMAIN, "execute_v3"); + let settle_symbol = exact_symbol(&seed, SETTLE_SYMBOL_DOMAIN, "settle_v3"); + if getter_symbol == execute_symbol + || getter_symbol == settle_symbol + || execute_symbol == settle_symbol + { + return Err(v3_error( + "derived callable-v3 symbols are not pairwise distinct", + )); + } + + let mut writer = WireWriter::new(MAX_DESCRIPTOR_BYTES); + writer.raw(MAGIC)?; + writer.u32(VERSION)?; + writer.u32(HEADER_SIZE)?; + let total_offset = writer.bytes.len(); + writer.u32(0)?; + writer.text(target, "physical target tag")?; + writer.u32(linkage_profile)?; + for fingerprint in pre_contract_fingerprints + .into_iter() + .chain(std::iter::once(call_contract)) + { + require_initialized(&fingerprint, "callable-v3 fingerprint")?; + writer.raw(&fingerprint)?; + } + writer.text(&projection.module, "module identity")?; + writer.text(&projection.function, "function identity")?; + writer.text(&getter_symbol, "descriptor getter symbol")?; + writer.text(&execute_symbol, "execute symbol")?; + writer.text(&settle_symbol, "settle symbol")?; + writer.u32(CALL_ABI_TAG)?; + writer.u32(CALL_OBLIGATIONS)?; + for capacity in capacities.words() { + writer.u32(capacity)?; + } + encode_signature(&mut writer, ¶meters, &result)?; + writer.u32(wire_u32(graph.len(), "settlement graph byte length")?)?; + writer.raw(&graph)?; + let total = wire_u32(writer.bytes.len(), "callable-v3 descriptor byte length")?; + writer.bytes[total_offset..total_offset + 4].copy_from_slice(&total.to_le_bytes()); + + Ok(NativeCallableV3Descriptor { + bytes: writer.bytes, + getter_symbol, + execute_symbol, + settle_symbol, + call_contract, + }) +} + +fn canonical_signature( + projected: &[NativeAdapterParameterProjection], + projected_result: &NativeAdapterResultProjection, +) -> Result<(Vec, CanonicalResult), Diagnostic> { + let mut parameters = Vec::with_capacity(projected.len()); + let mut next_owner = 0_usize; + for (expected_index, parameter) in projected.iter().enumerate() { + let canonical = match parameter { + NativeAdapterParameterProjection::Scalar { + parameter_index, + value_id, + kind, + } => { + require_index(*parameter_index, expected_index, "parameter")?; + CanonicalParameter { + index: wire_u32(*parameter_index, "parameter index")?, + value: checked_text(value_id.as_str(), "parameter value identity")?, + kind: CanonicalParameterKind::Scalar(match kind { + NativeHostScalarKind::I64 => SCALAR_I64, + NativeHostScalarKind::Bool => SCALAR_BOOL, + }), + } + } + NativeAdapterParameterProjection::OwnedResource { + parameter_index, + value_id, + owner_ordinal, + resource_type, + lifecycle, + } => { + require_index(*parameter_index, expected_index, "parameter")?; + require_index(*owner_ordinal, next_owner, "owner ordinal")?; + next_owner += 1; + CanonicalParameter { + index: wire_u32(*parameter_index, "parameter index")?, + value: checked_text(value_id.as_str(), "parameter value identity")?, + kind: CanonicalParameterKind::Owned { + ordinal: wire_u32(*owner_ordinal, "owner ordinal")?, + resource: checked_text(resource_type, "resource identity")?, + lifecycle: checked_text(lifecycle, "lifecycle identity")?, + payload_wire_kind: OWNED_PAYLOAD_WIRE_KIND, + }, + } + } + }; + parameters.push(canonical); + } + let result = match projected_result { + NativeAdapterResultProjection::ScalarI64 => CanonicalResult::ScalarI64, + NativeAdapterResultProjection::OwnedInput { + parameter_index, + value_id, + owner_ordinal, + } => { + let parameter_index = wire_u32(*parameter_index, "owned-result parameter index")?; + let value = checked_text(value_id.as_str(), "owned-result value identity")?; + let owner_ordinal = wire_u32(*owner_ordinal, "owned-result owner ordinal")?; + match parameters.get(parameter_index as usize) { + Some(CanonicalParameter { + index, + value: admitted_value, + kind: CanonicalParameterKind::Owned { ordinal, .. }, + }) if *index == parameter_index + && admitted_value == &value + && *ordinal == owner_ordinal => {} + _ => { + return Err(v3_error( + "owned result does not exactly select an admitted owned parameter", + )); + } + } + CanonicalResult::Owned { + parameter_index, + value, + owner_ordinal, + } + } + }; + Ok((parameters, result)) +} + +fn derive_capacities( + parameters: &[CanonicalParameter], + event_count: u32, + dictionary_bytes: usize, + dictionary_entries: usize, + certificate: &NativeSettlementCertificate, +) -> Result { + let mut request = REQUEST_FIXED_BYTES; + for parameter in parameters { + let increment = match parameter.kind { + CanonicalParameterKind::Scalar(SCALAR_I64) => REQUEST_I64_BYTES, + CanonicalParameterKind::Scalar(SCALAR_BOOL) => REQUEST_BOOL_BYTES, + CanonicalParameterKind::Owned { .. } => REQUEST_OWNER_BYTES, + CanonicalParameterKind::Scalar(_) => { + return Err(v3_error("unknown scalar wire kind")); + } + }; + request = request + .checked_add(increment) + .ok_or_else(|| v3_error("request capacity overflow"))?; + } + let execute_response = EVENT_ORDINAL_BYTES + .checked_mul(event_count) + .and_then(|events| EXECUTE_RESPONSE_FIXED_AND_MAX_PAYLOAD_BYTES.checked_add(events)) + .ok_or_else(|| v3_error("execute-response capacity overflow"))?; + let resource_count = wire_u32(certificate.resource_count(), "settlement resource count")?; + let checkpoint_count = wire_u32( + certificate.checkpoints().len(), + "settlement checkpoint count", + )?; + if resource_count == 0 || resource_count > MAX_RESOURCE_COUNT { + return Err(v3_error( + "settlement resource count is outside the v3 bound", + )); + } + if checkpoint_count == 0 || checkpoint_count > MAX_CHECKPOINT_COUNT { + return Err(v3_error( + "settlement checkpoint count is outside the v3 bound", + )); + } + let graph_work_units = resource_count + .checked_mul(checkpoint_count) + .filter(|work| *work <= MAX_GRAPH_WORK_UNITS) + .ok_or_else(|| v3_error("settlement graph work exceeds the v3 bound"))?; + let frame = FRAME_RESOURCE_STATE_BYTES + .checked_mul(resource_count) + .and_then(|states| FRAME_FIXED_BYTES.checked_add(states)) + .ok_or_else(|| v3_error("frame capacity overflow"))?; + for (label, capacity) in [ + ("request", request), + ("execute response", execute_response), + ("frame", frame), + ("decision", DECISION_BYTES), + ("action evidence", ACTION_EVIDENCE_BYTES), + ("candidate receipt", CANDIDATE_RECEIPT_BYTES), + ] { + if capacity == 0 || capacity > MAX_WIRE_BYTES { + return Err(v3_error(format!( + "{label} capacity exceeds the {MAX_WIRE_BYTES}-byte v3 wire bound" + ))); + } + } + let per_active = [ + request, + execute_response, + frame, + DECISION_BYTES, + ACTION_EVIDENCE_BYTES, + CANDIDATE_RECEIPT_BYTES, + ] + .into_iter() + .try_fold(0_u32, |sum, value| sum.checked_add(value)) + .ok_or_else(|| v3_error("active-frame reserve capacity overflow"))?; + let per_quarantine = frame + .checked_add(CANDIDATE_RECEIPT_BYTES) + .ok_or_else(|| v3_error("quarantine reserve capacity overflow"))?; + let instance_reserved_bytes = ACTIVE_FRAME_LIMIT + .checked_mul(per_active) + .and_then(|active| { + QUARANTINED_FRAME_LIMIT + .checked_mul(per_quarantine) + .and_then(|quarantine| active.checked_add(quarantine)) + }) + .filter(|reserve| *reserve <= MAX_INSTANCE_RESERVED_BYTES) + .ok_or_else(|| v3_error("instance reserve exceeds the 64-MiB v3 bound"))?; + let dictionary_bytes = wire_u32(dictionary_bytes, "event dictionary byte length")?; + let dictionary_entries = wire_u32(dictionary_entries, "event dictionary entry count")?; + if event_count == 0 || dictionary_bytes == 0 || dictionary_entries == 0 { + return Err(v3_error("event and dictionary capacities must be nonzero")); + } + if event_count > MAX_EVENT_COUNT + || dictionary_bytes > MAX_DICTIONARY_BYTES + || dictionary_entries > MAX_DICTIONARY_ENTRIES + { + return Err(v3_error( + "event or dictionary capacity exceeds the callable-v3 bound", + )); + } + Ok(Capacities { + request, + execute_response, + frame, + decision: DECISION_BYTES, + action_evidence: ACTION_EVIDENCE_BYTES, + candidate_receipt: CANDIDATE_RECEIPT_BYTES, + event_count, + dictionary_bytes, + dictionary_entries, + resource_count, + checkpoint_count, + graph_work_units, + active_frames: ACTIVE_FRAME_LIMIT, + quarantined_frames: QUARANTINED_FRAME_LIMIT, + instance_reserved_bytes, + }) +} + +fn encode_signature( + writer: &mut WireWriter, + parameters: &[CanonicalParameter], + result: &CanonicalResult, +) -> Result<(), Diagnostic> { + writer.u32(wire_u32(parameters.len(), "parameter count")?)?; + for parameter in parameters { + match ¶meter.kind { + CanonicalParameterKind::Scalar(kind) => { + writer.u32(PARAMETER_SCALAR)?; + writer.u32(parameter.index)?; + writer.text(¶meter.value, "parameter value identity")?; + writer.u32(*kind)?; + } + CanonicalParameterKind::Owned { + ordinal, + resource, + lifecycle, + payload_wire_kind, + } => { + writer.u32(PARAMETER_OWNED_RESOURCE)?; + writer.u32(parameter.index)?; + writer.text(¶meter.value, "parameter value identity")?; + writer.u32(*ordinal)?; + writer.text(resource, "resource identity")?; + writer.text(lifecycle, "lifecycle identity")?; + writer.u32(*payload_wire_kind)?; + } + } + } + match result { + CanonicalResult::ScalarI64 => writer.u32(RESULT_SCALAR_I64)?, + CanonicalResult::Owned { + parameter_index, + value, + owner_ordinal, + } => { + writer.u32(RESULT_OWNED_INPUT)?; + writer.u32(*parameter_index)?; + writer.text(value, "owned-result value identity")?; + writer.u32(*owner_ordinal)?; + } + } + Ok(()) +} + +fn hash_signature( + hasher: &mut Sha256, + parameters: &[CanonicalParameter], + result: &CanonicalResult, +) { + hash_u32( + hasher, + u32::try_from(parameters.len()).expect("validated parameter count fits u32"), + ); + for parameter in parameters { + match ¶meter.kind { + CanonicalParameterKind::Scalar(kind) => { + hash_u32(hasher, PARAMETER_SCALAR); + hash_u32(hasher, parameter.index); + hash_field(hasher, parameter.value.as_bytes()); + hash_u32(hasher, *kind); + } + CanonicalParameterKind::Owned { + ordinal, + resource, + lifecycle, + payload_wire_kind, + } => { + hash_u32(hasher, PARAMETER_OWNED_RESOURCE); + hash_u32(hasher, parameter.index); + hash_field(hasher, parameter.value.as_bytes()); + hash_u32(hasher, *ordinal); + hash_field(hasher, resource.as_bytes()); + hash_field(hasher, lifecycle.as_bytes()); + hash_u32(hasher, *payload_wire_kind); + } + } + } + match result { + CanonicalResult::ScalarI64 => hash_u32(hasher, RESULT_SCALAR_I64), + CanonicalResult::Owned { + parameter_index, + value, + owner_ordinal, + } => { + hash_u32(hasher, RESULT_OWNED_INPUT); + hash_u32(hasher, *parameter_index); + hash_field(hasher, value.as_bytes()); + hash_u32(hasher, *owner_ordinal); + } + } +} + +fn encode_graph( + derivation: &native_settlement_derivation::NativeSettlementDerivation, + execution_cleanup: [u8; FINGERPRINT_BYTES], + trace_certificate: [u8; FINGERPRINT_BYTES], + byte_budget: usize, +) -> Result, Diagnostic> { + let certificate = derivation.certificate(); + for (fingerprint, label) in [ + (certificate.recovery_contract(), "recovery contract"), + (execution_cleanup, "execution cleanup"), + (trace_certificate, "trace certificate"), + ] { + require_initialized(&fingerprint, label)?; + } + let mut graph = WireWriter::new(byte_budget); + graph.u32(GRAPH_VERSION)?; + graph.text( + certificate.function().as_str(), + "settlement function identity", + )?; + graph.raw(&certificate.recovery_contract())?; + graph.raw(&execution_cleanup)?; + graph.raw(&trace_certificate)?; + graph.u32(wire_u32( + certificate.resource_count(), + "settlement resource count", + )?)?; + graph.u32(wire_u32( + certificate.checkpoints().len(), + "settlement checkpoint count", + )?)?; + for checkpoint in certificate.checkpoints() { + graph.u32(checkpoint.checkpoint())?; + graph.u32(wire_u32( + checkpoint.resources().len(), + "checkpoint resource-state count", + )?)?; + for state in checkpoint.resources() { + graph.u32(match state { + SettlementResourceState::Live => STATE_LIVE, + SettlementResourceState::ProvisionalResult => STATE_PROVISIONAL_RESULT, + SettlementResourceState::Finalizing => STATE_FINALIZING, + SettlementResourceState::Dead => STATE_DEAD, + SettlementResourceState::Published => STATE_PUBLISHED, + })?; + } + match checkpoint.normal_outcome() { + None => graph.u32(OUTCOME_NONE)?, + Some(SettlementOutcome::ScalarSuccess) => graph.u32(OUTCOME_SCALAR_SUCCESS)?, + Some(SettlementOutcome::SemanticFailure) => graph.u32(OUTCOME_SEMANTIC_FAILURE)?, + Some(SettlementOutcome::OwnedSuccess { owner_ordinal }) => { + graph.u32(OUTCOME_OWNED_SUCCESS)?; + graph.u32(owner_ordinal)?; + } + } + push_ordinals(&mut graph, checkpoint.abort_cleanup_order())?; + push_ordinals(&mut graph, checkpoint.accept_cleanup_order())?; + } + push_ordinals(&mut graph, certificate.start_checkpoints())?; + graph.u32(wire_u32( + certificate.progress_edges().len(), + "settlement progress-edge count", + )?)?; + for edge in certificate.progress_edges() { + graph.u32(edge.from())?; + graph.u32(edge.to())?; + match edge.action() { + SettlementProgressAction::Finalize { owner_ordinal } => { + graph.u32(ACTION_FINALIZE)?; + graph.u32(owner_ordinal)?; + } + SettlementProgressAction::StageOwnedResult { owner_ordinal } => { + graph.u32(ACTION_STAGE_OWNED_RESULT)?; + graph.u32(owner_ordinal)?; + } + SettlementProgressAction::CertifyOutcome { trace_evidence } => { + graph.u32(ACTION_CERTIFY_OUTCOME)?; + graph.raw(&trace_evidence)?; + let witness = derivation + .trace_evidence_witness(&trace_evidence) + .ok_or_else(|| v3_error("certify-outcome edge has no trace witness"))?; + if trace_evidence_fingerprint( + trace_certificate, + witness.ordinals(), + witness.outcome(), + ) != trace_evidence + { + return Err(v3_error( + "certify-outcome trace witness does not reproduce its digest", + )); + } + graph.u32(wire_u32( + witness.ordinals().len(), + "trace witness ordinal count", + )?)?; + for ordinal in witness.ordinals() { + graph.u32(*ordinal)?; + } + match witness.outcome() { + TracePathOutcome::ScalarSuccess => graph.u32(1)?, + TracePathOutcome::OwnedSuccess => graph.u32(2)?, + TracePathOutcome::Failure { selected_ordinal } => { + graph.u32(3)?; + graph.u32(selected_ordinal)?; + } + } + } + } + } + Ok(graph.bytes) +} + +fn trace_evidence_fingerprint( + trace_certificate: [u8; 32], + ordinals: &[u32], + outcome: TracePathOutcome, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(TRACE_EVIDENCE_DOMAIN); + hasher.update(trace_certificate); + hasher.update((ordinals.len() as u64).to_le_bytes()); + for ordinal in ordinals { + hasher.update(ordinal.to_le_bytes()); + } + match outcome { + TracePathOutcome::ScalarSuccess => hasher.update([1]), + TracePathOutcome::OwnedSuccess => hasher.update([2]), + TracePathOutcome::Failure { selected_ordinal } => { + hasher.update([3]); + hasher.update(selected_ordinal.to_le_bytes()); + } + } + hasher.finalize().into() +} + +fn push_ordinals(writer: &mut WireWriter, ordinals: &[u32]) -> Result<(), Diagnostic> { + writer.u32(wire_u32(ordinals.len(), "settlement ordinal count")?)?; + for ordinal in ordinals { + writer.u32(*ordinal)?; + } + Ok(()) +} + +fn physical_module_fingerprint( + schema: &[u8; 32], + target: &[u8; 32], + semantic_module: &[u8; 32], + module: &[u8], + linkage_profile: u32, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(PHYSICAL_MODULE_DOMAIN); + for field in [schema.as_slice(), target, semantic_module, module] { + hash_field(&mut hasher, field); + } + hash_u32(&mut hasher, linkage_profile); + hasher.finalize().into() +} + +#[allow(clippy::too_many_arguments)] +fn call_contract_fingerprint( + target: &str, + fingerprints: &[[u8; 32]; 18], + module: &str, + function: &str, + linkage_profile: u32, + capacities: &Capacities, + parameters: &[CanonicalParameter], + result: &CanonicalResult, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(CALL_CONTRACT_DOMAIN); + hash_field(&mut hasher, target.as_bytes()); + for fingerprint in fingerprints { + hash_field(&mut hasher, fingerprint); + } + hash_field(&mut hasher, module.as_bytes()); + hash_field(&mut hasher, function.as_bytes()); + for word in [linkage_profile, CALL_ABI_TAG, CALL_OBLIGATIONS] + .into_iter() + .chain(capacities.words()) + { + hash_u32(&mut hasher, word); + } + hash_signature(&mut hasher, parameters, result); + hasher.finalize().into() +} + +fn symbol_seed(fingerprints: &[[u8; 32]; 13]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(SYMBOL_SEED_DOMAIN); + for fingerprint in fingerprints { + hash_field(&mut hasher, fingerprint); + } + hasher.finalize().into() +} + +fn exact_symbol(seed: &[u8; 32], domain: &[u8], suffix: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain); + hash_field(&mut hasher, seed); + let digest = hasher.finalize(); + let mut symbol = String::from("spx_"); + for byte in &digest[..24] { + write!(symbol, "{byte:02x}").expect("writing to a string cannot fail"); + } + write!(symbol, "_{suffix}").expect("writing to a string cannot fail"); + symbol +} + +fn framed_fingerprint(domain: &[u8], payload: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update((payload.len() as u64).to_be_bytes()); + hasher.update(payload); + hasher.finalize().into() +} + +fn hash_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +fn hash_u32(hasher: &mut Sha256, value: u32) { + hasher.update(value.to_le_bytes()); +} + +fn physical_linkage_profile() -> u32 { + if cfg!(target_os = "ios") { + LINKAGE_IOS_STATIC_REGISTRATION + } else { + LINKAGE_DYNAMIC_IMAGE + } +} + +fn physical_target_tag() -> Result { + let endian = if cfg!(target_endian = "little") { + "little" + } else { + "big" + }; + let environment = if cfg!(target_os = "android") { + "android" + } else if cfg!(target_env = "msvc") { + "msvc" + } else if cfg!(target_env = "gnu") { + "gnu" + } else if cfg!(target_env = "musl") { + "musl" + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + "apple" + } else { + return Err(v3_error("physical target environment is unknown")); + }; + let object = if cfg!(windows) { + "coff" + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + "macho" + } else if cfg!(any(target_os = "linux", target_os = "android")) { + "elf" + } else { + return Err(v3_error("physical target object format is unknown")); + }; + if cfg!(target_os = "ios") { + let variant = if cfg!(target_abi = "macabi") { + "catalyst" + } else if cfg!(target_abi = "sim") { + "simulator" + } else { + "device" + }; + Ok(format!( + "{}-ios-{variant}-{environment}-{object}-ptr{}-{endian}-callable-v3", + std::env::consts::ARCH, + usize::BITS, + )) + } else { + Ok(format!( + "{}-{}-{environment}-{object}-ptr{}-{endian}-callable-v3", + std::env::consts::ARCH, + std::env::consts::OS, + usize::BITS, + )) + } +} + +fn validate_target_profile(target: &str, linkage_profile: u32) -> Result<(), Diagnostic> { + validate_text(target, "physical target tag")?; + let ios = target.split('-').any(|component| component == "ios"); + let dynamic_os = target + .split('-') + .any(|component| matches!(component, "linux" | "macos" | "windows" | "android")); + match linkage_profile { + LINKAGE_DYNAMIC_IMAGE if dynamic_os && !ios => Ok(()), + LINKAGE_IOS_STATIC_REGISTRATION if ios => Ok(()), + LINKAGE_DYNAMIC_IMAGE | LINKAGE_IOS_STATIC_REGISTRATION => Err(v3_error( + "target and callable-v3 linkage profile are incompatible", + )), + _ => Err(v3_error("unknown callable-v3 linkage profile")), + } +} + +fn require_index(actual: usize, expected: usize, label: &str) -> Result<(), Diagnostic> { + if actual != expected { + return Err(v3_error(format!( + "noncanonical {label} {actual}; expected {expected}" + ))); + } + Ok(()) +} + +fn decode_fingerprint(value: &str, label: &str) -> Result<[u8; 32], Diagnostic> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(v3_error(format!( + "{label} is not canonical lowercase SHA-256" + ))); + } + let mut decoded = [0_u8; 32]; + for (index, byte) in decoded.iter_mut().enumerate() { + let offset = index * 2; + *byte = u8::from_str_radix(&value[offset..offset + 2], 16) + .map_err(|_| v3_error(format!("{label} contains invalid hexadecimal")))?; + } + require_initialized(&decoded, label)?; + Ok(decoded) +} + +fn require_initialized(fingerprint: &[u8; 32], label: &str) -> Result<(), Diagnostic> { + if fingerprint.iter().all(|byte| *byte == 0) { + return Err(v3_error(format!("{label} must be nonzero"))); + } + Ok(()) +} + +fn checked_text(value: &str, label: &str) -> Result { + validate_text(value, label)?; + Ok(value.to_owned()) +} + +fn validate_text(value: &str, label: &str) -> Result<(), Diagnostic> { + if value.is_empty() || value.as_bytes().contains(&0) { + return Err(v3_error(format!("{label} must be nonempty and NUL-free"))); + } + wire_u32(value.len(), label)?; + Ok(()) +} + +fn wire_u32(value: usize, label: &str) -> Result { + u32::try_from(value).map_err(|_| v3_error(format!("{label} exceeds u32"))) +} + +struct WireWriter { + bytes: Vec, + byte_budget: usize, +} + +impl WireWriter { + fn new(byte_budget: usize) -> Self { + Self { + bytes: Vec::new(), + byte_budget, + } + } + + fn raw(&mut self, value: &[u8]) -> Result<(), Diagnostic> { + let next = self + .bytes + .len() + .checked_add(value.len()) + .ok_or_else(|| v3_error("wire byte length overflow"))?; + if next > self.byte_budget { + return Err(v3_error(format!( + "wire bytes exceed the {}-byte budget", + self.byte_budget + ))); + } + self.bytes.extend_from_slice(value); + Ok(()) + } + + fn u32(&mut self, value: u32) -> Result<(), Diagnostic> { + self.raw(&value.to_le_bytes()) + } + + fn text(&mut self, value: &str, label: &str) -> Result<(), Diagnostic> { + validate_text(value, label)?; + self.u32(wire_u32(value.len(), label)?)?; + self.raw(value.as_bytes()) + } +} + +fn v3_error(message: impl Into) -> Diagnostic { + Diagnostic::io( + "SPX-I106", + format!("native callable descriptor v3: {}", message.into()), + ) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::fmt::Write as _; + + use crate::owned_resource_corpus::build_owned_resource_corpus_v1; + + use super::*; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().fold( + String::with_capacity(bytes.len() * 2), + |mut output, byte| { + write!(output, "{byte:02x}").expect("writing to a string cannot fail"); + output + }, + ) + } + + fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn read_text<'a>(bytes: &'a [u8], offset: &mut usize) -> &'a str { + let length = read_u32(bytes, *offset) as usize; + *offset += 4; + let value = std::str::from_utf8(&bytes[*offset..*offset + length]).unwrap(); + *offset += length; + value + } + + #[test] + fn all_fourteen_corpus_cases_derive_bounded_deterministic_v3_only_bytes() { + let corpus = build_owned_resource_corpus_v1().unwrap(); + assert_eq!(corpus.cases.len(), 14); + let mut exact_by_function = BTreeSet::new(); + for case in &corpus.cases { + let function = DeclarationId::new(case.function_id); + let settlement = + native_settlement_derivation::derive_native_settlement(&corpus.program, &function) + .unwrap(); + let mut witness_count = 0; + for edge in settlement.certificate().progress_edges() { + if let SettlementProgressAction::CertifyOutcome { trace_evidence } = edge.action() { + let witness = settlement.trace_evidence_witness(&trace_evidence).unwrap(); + assert_eq!( + trace_evidence_fingerprint( + settlement.trace_certificate_fingerprint(), + witness.ordinals(), + witness.outcome(), + ), + trace_evidence + ); + witness_count += 1; + } + } + assert!(witness_count > 0, "{}", case.scenario_id); + let first = derive_for_target( + &corpus.program, + &function, + "x86_64-linux-gnu-elf-ptr64-little-callable-v3", + LINKAGE_DYNAMIC_IMAGE, + ) + .unwrap(); + let second = derive_for_target( + &corpus.program, + &function, + "x86_64-linux-gnu-elf-ptr64-little-callable-v3", + LINKAGE_DYNAMIC_IMAGE, + ) + .unwrap(); + assert_eq!(first, second, "{}", case.scenario_id); + assert!(first.bytes.len() <= MAX_DESCRIPTOR_BYTES); + assert_eq!(&first.bytes[..8], MAGIC); + assert_eq!(read_u32(&first.bytes, 8), VERSION); + assert_eq!(read_u32(&first.bytes, 12), HEADER_SIZE); + assert_eq!(read_u32(&first.bytes, 16) as usize, first.bytes.len()); + assert!(!first.bytes.windows(8).any(|window| window == b"SPXNABI2")); + assert!(!first.bytes.windows(8).any(|window| window == b"SPXNPRF1")); + exact_by_function.insert((case.function_id, first.bytes)); + } + assert_eq!(exact_by_function.len(), 7); + } + + #[test] + fn descriptor_has_a_stable_known_answer_and_pairwise_distinct_symbols() { + let corpus = build_owned_resource_corpus_v1().unwrap(); + let descriptor = derive_for_target( + &corpus.program, + &DeclarationId::new("token.discard-two"), + "x86_64-linux-gnu-elf-ptr64-little-callable-v3", + LINKAGE_DYNAMIC_IMAGE, + ) + .unwrap(); + assert_ne!(descriptor.getter_symbol, descriptor.execute_symbol); + assert_ne!(descriptor.getter_symbol, descriptor.settle_symbol); + assert_ne!(descriptor.execute_symbol, descriptor.settle_symbol); + assert_eq!(descriptor.bytes.len(), 1_722); + assert_eq!( + hex(&Sha256::digest(&descriptor.bytes)), + "53096cf416ba8fe1fb7ca694649c81fcc93d3b5cfe71cdf5413c01b8f04ab64e" + ); + assert_eq!( + hex(&descriptor.call_contract), + "9b9c13fc2c5cf506bd99b0cdcec326f7394bd94665d373dec2861f467149e496" + ); + } + + #[test] + fn graph_is_v3_and_has_no_contract_or_symbol_cycle() { + let corpus = build_owned_resource_corpus_v1().unwrap(); + let descriptor = derive_for_target( + &corpus.program, + &DeclarationId::new("token.discard-two"), + "x86_64-linux-gnu-elf-ptr64-little-callable-v3", + LINKAGE_DYNAMIC_IMAGE, + ) + .unwrap(); + let mut offset = HEADER_SIZE as usize; + assert_eq!( + read_text(&descriptor.bytes, &mut offset), + "x86_64-linux-gnu-elf-ptr64-little-callable-v3" + ); + offset += 4 + 19 * FINGERPRINT_BYTES; + let _module = read_text(&descriptor.bytes, &mut offset); + let _function = read_text(&descriptor.bytes, &mut offset); + assert_eq!( + read_text(&descriptor.bytes, &mut offset), + descriptor.getter_symbol + ); + assert_eq!( + read_text(&descriptor.bytes, &mut offset), + descriptor.execute_symbol + ); + assert_eq!( + read_text(&descriptor.bytes, &mut offset), + descriptor.settle_symbol + ); + offset += 4 + 4 + 15 * 4; + let parameter_count = read_u32(&descriptor.bytes, offset) as usize; + offset += 4; + for _ in 0..parameter_count { + let tag = read_u32(&descriptor.bytes, offset); + offset += 8; + let _value = read_text(&descriptor.bytes, &mut offset); + match tag { + PARAMETER_SCALAR => offset += 4, + PARAMETER_OWNED_RESOURCE => { + offset += 4; + let _resource = read_text(&descriptor.bytes, &mut offset); + let _lifecycle = read_text(&descriptor.bytes, &mut offset); + offset += 4; + } + _ => panic!("unexpected parameter tag"), + } + } + match read_u32(&descriptor.bytes, offset) { + RESULT_SCALAR_I64 => offset += 4, + RESULT_OWNED_INPUT => { + offset += 8; + let _value = read_text(&descriptor.bytes, &mut offset); + offset += 4; + } + _ => panic!("unexpected result tag"), + } + let graph_len = read_u32(&descriptor.bytes, offset) as usize; + offset += 4; + assert_eq!(offset + graph_len, descriptor.bytes.len()); + let graph = &descriptor.bytes[offset..]; + assert_eq!(read_u32(graph, 0), GRAPH_VERSION); + assert_eq!( + hex(&Sha256::digest(graph)), + "0da4af442f926506e2dcfc71fd0a6895dd3f48223922f06bae4f2ac9cf67a380" + ); + assert!(!graph + .windows(descriptor.call_contract.len()) + .any(|window| window == descriptor.call_contract)); + for symbol in [ + &descriptor.getter_symbol, + &descriptor.execute_symbol, + &descriptor.settle_symbol, + ] { + assert!(!graph + .windows(symbol.len()) + .any(|window| window == symbol.as_bytes())); + } + } + + #[test] + fn target_linkage_profiles_are_closed_and_ios_variants_remain_distinct() { + let corpus = build_owned_resource_corpus_v1().unwrap(); + let function = DeclarationId::new("token.discard-two"); + for target in [ + "aarch64-linux-gnu-elf-ptr64-little-callable-v3", + "aarch64-macos-apple-macho-ptr64-little-callable-v3", + "x86_64-windows-msvc-coff-ptr64-little-callable-v3", + "aarch64-android-android-elf-ptr64-little-callable-v3", + ] { + derive_for_target(&corpus.program, &function, target, LINKAGE_DYNAMIC_IMAGE).unwrap(); + } + let device = derive_for_target( + &corpus.program, + &function, + "aarch64-ios-device-apple-macho-ptr64-little-callable-v3", + LINKAGE_IOS_STATIC_REGISTRATION, + ) + .unwrap(); + let simulator = derive_for_target( + &corpus.program, + &function, + "aarch64-ios-simulator-apple-macho-ptr64-little-callable-v3", + LINKAGE_IOS_STATIC_REGISTRATION, + ) + .unwrap(); + let catalyst = derive_for_target( + &corpus.program, + &function, + "aarch64-ios-catalyst-apple-macho-ptr64-little-callable-v3", + LINKAGE_IOS_STATIC_REGISTRATION, + ) + .unwrap(); + assert_ne!(device.bytes, simulator.bytes); + assert_ne!(device.bytes, catalyst.bytes); + assert_ne!(simulator.bytes, catalyst.bytes); + assert!(derive_for_target( + &corpus.program, + &function, + "aarch64-ios-device-apple-macho-ptr64-little-callable-v3", + LINKAGE_DYNAMIC_IMAGE, + ) + .is_err()); + assert!(derive_for_target( + &corpus.program, + &function, + "aarch64-linux-gnu-elf-ptr64-little-callable-v3", + LINKAGE_IOS_STATIC_REGISTRATION, + ) + .is_err()); + assert!(derive_for_target(&corpus.program, &function, "aarch64-plan9", 99).is_err()); + } + + #[test] + fn physical_target_source_pins_ios_device_simulator_and_catalyst_cfgs() { + let source = include_str!("native_callable_abi_v3.rs"); + let macabi = source.find("cfg!(target_abi = \"macabi\")").unwrap(); + let simulator = source.find("cfg!(target_abi = \"sim\")").unwrap(); + assert!(macabi < simulator); + assert!(source.contains("\"catalyst\"")); + assert!(source.contains("\"simulator\"")); + assert!(source.contains("\"device\"")); + + let current = physical_target_tag().unwrap(); + if cfg!(target_os = "ios") { + let expected = if cfg!(target_abi = "macabi") { + "-ios-catalyst-" + } else if cfg!(target_abi = "sim") { + "-ios-simulator-" + } else { + "-ios-device-" + }; + assert!(current.contains(expected)); + } else { + assert!(!current.contains("-ios-")); + } + } + + #[test] + fn schema_statements_pin_normative_eight_byte_runtime_magics() { + for (statement, magic) in [ + (REQUEST_SCHEMA_STATEMENT, b"SPXNRQ03".as_slice()), + (EXECUTE_RESPONSE_SCHEMA_STATEMENT, b"SPXNEX03".as_slice()), + (FRAME_SCHEMA_STATEMENT, b"SPXNFR03".as_slice()), + (DECISION_SCHEMA_STATEMENT, b"SPXNDC03".as_slice()), + (ACTION_SCHEMA_STATEMENT, b"SPXNAC03".as_slice()), + (CANDIDATE_RECEIPT_SCHEMA_STATEMENT, b"SPXNCR03".as_slice()), + (COMMITTED_RECEIPT_SCHEMA_STATEMENT, b"SPXHRP03".as_slice()), + ] { + assert!(statement.starts_with(magic)); + assert_eq!(magic.len(), 8); + } + for obsolete in [ + b"SPXNREQ3".as_slice(), + b"SPXNXRS3".as_slice(), + b"SPXNFRM3".as_slice(), + b"SPXNDEC3".as_slice(), + b"SPXNACT3".as_slice(), + b"SPXNCAN3".as_slice(), + b"SPXNCOM3".as_slice(), + ] { + assert!(![ + REQUEST_SCHEMA_STATEMENT, + EXECUTE_RESPONSE_SCHEMA_STATEMENT, + FRAME_SCHEMA_STATEMENT, + DECISION_SCHEMA_STATEMENT, + ACTION_SCHEMA_STATEMENT, + CANDIDATE_RECEIPT_SCHEMA_STATEMENT, + COMMITTED_RECEIPT_SCHEMA_STATEMENT, + ] + .iter() + .any(|statement| statement.starts_with(obsolete))); + } + } +} diff --git a/src/codegen/native_settlement_derivation.rs b/src/codegen/native_settlement_derivation.rs index 6ecb563..54c3f5c 100644 --- a/src/codegen/native_settlement_derivation.rs +++ b/src/codegen/native_settlement_derivation.rs @@ -50,6 +50,7 @@ pub(super) struct NativeSettlementDerivation { recovery_contract_fingerprint: [u8; 32], trace_certificate_fingerprint: [u8; 32], certificate: NativeSettlementCertificate, + trace_evidence_witnesses: BTreeMap<[u8; 32], TraceEvidenceWitness>, } impl NativeSettlementDerivation { @@ -64,6 +65,31 @@ impl NativeSettlementDerivation { pub(super) fn certificate(&self) -> &NativeSettlementCertificate { &self.certificate } + + pub(super) fn trace_evidence_witness( + &self, + fingerprint: &[u8; 32], + ) -> Option<&TraceEvidenceWitness> { + self.trace_evidence_witnesses.get(fingerprint) + } +} + +/// Canonical witness whose digest is carried by one `CertifyOutcome` edge. +/// This remains compiler-private proof data and confers no runtime authority. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct TraceEvidenceWitness { + ordinals: Vec, + outcome: TracePathOutcome, +} + +impl TraceEvidenceWitness { + pub(super) fn ordinals(&self) -> &[u32] { + &self.ordinals + } + + pub(super) const fn outcome(&self) -> TracePathOutcome { + self.outcome + } } #[derive(Clone)] @@ -81,6 +107,8 @@ struct TerminalPath { progress: Vec, outcome: SettlementOutcome, trace_evidence: [u8; 32], + trace_ordinals: Vec, + trace_outcome: TracePathOutcome, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -161,6 +189,21 @@ pub(super) fn derive_native_settlement( &dictionary, &trace_certificate, )?; + let mut trace_evidence_witnesses = BTreeMap::new(); + for terminal in &terminals { + let witness = TraceEvidenceWitness { + ordinals: terminal.trace_ordinals.clone(), + outcome: terminal.trace_outcome, + }; + match trace_evidence_witnesses.insert(terminal.trace_evidence, witness.clone()) { + Some(existing) if existing != witness => { + return Err(derivation_error( + "trace-evidence digest collision has inconsistent witnesses", + )); + } + Some(_) | None => {} + } + } let (checkpoints, progress_edges) = derive_checkpoints(&terminals, owners.len())?; let certificate = NativeSettlementCertificate::try_new_with_progress( function.id.clone(), @@ -178,6 +221,7 @@ pub(super) fn derive_native_settlement( recovery_contract_fingerprint, trace_certificate_fingerprint: trace_certificate.fingerprint(), certificate, + trace_evidence_witnesses, }) } @@ -434,6 +478,8 @@ fn collect_terminal_paths( &state.trace_ordinals, trace_outcome, ), + trace_ordinals: state.trace_ordinals, + trace_outcome, }; if seen_terminals.insert(terminal.clone()) { terminals.push(terminal); @@ -465,6 +511,8 @@ fn collect_terminal_paths( &state.trace_ordinals, trace_outcome, ), + trace_ordinals: state.trace_ordinals, + trace_outcome, }; if seen_terminals.insert(terminal.clone()) { terminals.push(terminal); diff --git a/tests/native_callable_bundle.rs b/tests/native_callable_bundle.rs index b1a4ac9..4897015 100644 --- a/tests/native_callable_bundle.rs +++ b/tests/native_callable_bundle.rs @@ -255,6 +255,60 @@ fn main() { ); } +#[test] +fn default_feature_external_consumer_cannot_import_callable_v3_metadata_api() { + let fixture = Fixture::create("default-v3-surface"); + let consumer = fixture.path("consumer"); + fs::create_dir(&consumer).unwrap(); + let manifest_root = env!("CARGO_MANIFEST_DIR").replace('\\', "\\\\"); + fs::write( + consumer.join("Cargo.toml"), + format!( + r#"[package] +name = "semaprax-default-v3-surface-check" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +semaprax = {{ path = "{manifest_root}", default-features = false }} +"#, + ), + ) + .unwrap(); + fs::create_dir(consumer.join("src")).unwrap(); + fs::write( + consumer.join("src/main.rs"), + r#"use semaprax::codegen::{emit_native_callable_v3_descriptor, NativeCallableV3DescriptorArtifact}; + +fn main() { + let _ = emit_native_callable_v3_descriptor; + let _ = std::mem::size_of::(); +} +"#, + ) + .unwrap(); + + let checked = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .args(["check", "--offline", "--manifest-path"]) + .arg(consumer.join("Cargo.toml")) + .env("CARGO_TARGET_DIR", fixture.path("consumer-target")) + .output() + .unwrap(); + assert!( + !checked.status.success(), + "default surface exposed callable-v3 metadata API" + ); + let stderr = String::from_utf8_lossy(&checked.stderr); + assert!( + stderr.contains("emit_native_callable_v3_descriptor") + && stderr.contains("NativeCallableV3DescriptorArtifact") + && (stderr.contains("unresolved import") || stderr.contains("private")), + "unexpected callable-v3 default-surface diagnostic:\n{stderr}" + ); +} + #[test] fn build_refuses_existing_files_directories_and_symlinks_without_mutation() { let fixture = Fixture::create("no-overwrite");