diff --git a/.github/workflows/tests-rs-nightly-long-running.yml b/.github/workflows/tests-rs-nightly-long-running.yml index 546566f721f..53f2603985e 100644 --- a/.github/workflows/tests-rs-nightly-long-running.yml +++ b/.github/workflows/tests-rs-nightly-long-running.yml @@ -30,6 +30,8 @@ jobs: drive-abci, drive-proof-verifier, dash-platform-queries, + platform-value, + platform-serialization, ] steps: - name: Check out repo diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index f6a979e1402..832871401f0 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -239,6 +239,19 @@ jobs: done done + # The guest alloc-only cut: DashVM guest code consumes platform-value + # and platform-serialization with default features off, as no_std + + # alloc. wasm32v1-none has no std library at all, so a std leak in that + # profile fails to compile here instead of surfacing when a guest is + # first built. The alloc_profile integration test runs the same profile + # natively and proves it decodes the same bytes as the native path. + - name: Check guest alloc-only cut + run: | + rustup target add wasm32v1-none + cargo check -p platform-serialization --no-default-features --target wasm32v1-none --locked + cargo check -p platform-value --no-default-features --target wasm32v1-none --locked + cargo test -p platform-value --no-default-features --test alloc_profile --locked + - name: Detect immutable structure changes if: github.event_name == 'pull_request' run: | diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index 19b6fda27e1..b601a536e58 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -47,8 +47,9 @@ crate that has what the change needs, and no lower. | A client API | `packages/rs-sdk` | Follow the query checklist in `packages/rs-sdk/README.md`. | | A JavaScript binding | `packages/wasm-dpp2`, `packages/wasm-sdk` | Mirror the Rust shape; never validate. See `packages/wasm-dpp2/CONVENTIONS.md`. | | Mobile orchestration (sync, identity registration, DashPay) | `packages/rs-platform-wallet` | The FFI crates and the Swift and Kotlin SDKs marshal; they do not decide. | +| A guest-visible value or codec rule | `packages/rs-platform-value` alloc profile, `packages/rs-platform-serialization/src/bounded.rs` | Builds for `wasm32v1-none` with `--no-default-features`; no `platform-version`, `dpp`, `drive` or entropy. | -Three boundaries are enforced by CI and worth knowing by name: +Four boundaries are enforced by CI and worth knowing by name: - **The verify-only cut.** `packages/wasm-drive-verify` builds `drive` with `default-features = false, features = ["verify"]`. Anything under @@ -65,6 +66,14 @@ Three boundaries are enforced by CI and worth knowing by name: crates and their known dependents. Adding a dependency on a wallet crate from a new place fails the build until the closure list in `.github/scripts/check-wallet-closure.py` is updated deliberately. +- **The guest alloc-only cut.** `platform-value` and `platform-serialization` + build with `--no-default-features` for `wasm32v1-none`, a target with no std + library, because DashVM guest code consumes them as `no_std` + `alloc`. + Anything reachable without the `std`, `random` or `platform-version` feature + must use `core` and `alloc` only, must not read thread-local or process + state, and must not pull `platform-version`, `dpp`, `drive` or an entropy + source. Guest-visible decoding takes explicit `CodecBounds`; see the + [Serialization](../serialization/platform-serialization.md) chapter. ## Versioned behaviour diff --git a/book/src/serialization/platform-serialization.md b/book/src/serialization/platform-serialization.md index da4c0e8d779..b50357d485f 100644 --- a/book/src/serialization/platform-serialization.md +++ b/book/src/serialization/platform-serialization.md @@ -253,6 +253,72 @@ fn platform_versioned_decode>( The `claim_container_read` call tells the decoder "I am about to read `len` elements of type `T`" and the decoder checks whether this fits within the remaining byte budget. If not, it returns an error before any allocation happens. +## Allocation-only guest profile + +DashVM guest code runs inside a sandbox with no operating system, no threads +and no access to the native `PlatformVersion` registry. It still has to read +and write the same value bytes as the node. `platform-serialization` and +`platform-value` therefore build in two profiles selected by Cargo features: + +| Crate | Feature | What it adds | +|---|---|---| +| `platform-serialization` | `std` | bincode's std readers and writers | +| `platform-serialization` | `platform-version` | `PlatformVersionEncode`, `PlatformVersionedDecode`, the free functions and every standard-type impl above | +| `platform-value` | `std` | the thread-local decode depth scope, patch diffing (`treediff`), the `indexmap` and `HashSet` helpers | +| `platform-value` | `random` | `Identifier::random`, `Identifier::random_with_rng`, `Bytes32::random_with_rng` (implies `std`) | +| `platform-value` | `platform-version` | the `PlatformVersion` aware impls for `Identifier` | +| `platform-value` | `json`, `cbor` | the JSON and CBOR converters (both imply `std`) | + +Default features enable everything, so no native consumer changes. With +`--no-default-features` both crates are `#![no_std]` plus `alloc`, and CI +builds them for `wasm32v1-none`, a target with no std library at all, so a +std leak in the alloc profile is a compile error rather than a surprise when a +guest is first built. + +The alloc profile keeps the `Value` type, its derived `Encode` and the +iterative `Decode` impl, the serde `to_value` and `from_value` conversions and +the `platform_value!` macro. What it does not have is ambient state: there is +no thread-local depth limit to read. The plain `Decode` impl therefore always +applies `DEFAULT_MAX_VALUE_DECODE_DEPTH`, and anything that needs other limits +uses the bounded pair instead: + +```rust +use platform_serialization::bounded::CodecBounds; +use platform_value::Value; + +let bounds = CodecBounds { max_bytes: 64 * 1024, max_depth: 256, max_elements: 65_536 }; +let bytes = value.encode_bounded(&bounds)?; +let decoded = Value::decode_bounded(&bytes, &bounds)?; +assert_eq!(decoded, value); +``` + +Bounded decoding is not canonical validation. bincode accepts overlong +variable-length integers, so two different byte strings can decode to the same +value; only encoder-produced bytes are guaranteed to round-trip byte for byte. +The ABI layer establishes canonical bytes by re-encoding the decoded value and +comparing. + +`CodecBounds` lives in `packages/rs-platform-serialization/src/bounded.rs` +together with `CodecBudget` (the running counters), `BoundsError` (fixed-width +fields only, so it can become wire-visible later without depending on the word +size), `BoundedSliceReader` (a slice reader that reports its unread remainder), +`canonical_config()` (the same big-endian varint configuration as the native +path) and `bounded_decode_from_slice`, which rejects trailing bytes. + +The bounded decoder runs the same state machine as the native `Decode` impl, +so accepted inputs produce identical values, but it treats every declared +length as untrusted. Container counts, byte strings, text and string lists are +checked against the unread input and the budget before anything is allocated; +byte leaves then allocate exactly the declared length, and containers start +empty and grow by push. Depth and element counts are charged at the container +header. Heap usage is bounded by the three limits together: byte leaves by +`max_bytes`, container storage by `max_elements` (plus vector growth slack), +and the traversal stack by `max_depth`. It is not bounded by `max_bytes` +alone, so callers size the depth and element limits deliberately rather than +relying on a small byte budget. The native path is untouched: it keeps the +thread-local limit, bincode's own leaf decoders and pre-sized containers, +because shipped protocol versions decode through it. + ## The `BincodeContext` type alias You will see `crate::BincodeContext` throughout the code: diff --git a/packages/check-features/src/main.rs b/packages/check-features/src/main.rs index 8cba6485410..a48f08ac010 100644 --- a/packages/check-features/src/main.rs +++ b/packages/check-features/src/main.rs @@ -11,6 +11,8 @@ fn main() { ("rs-drive-proof-verifier", vec![]), ("rs-platform-wallet", vec![]), ("dash-platform-queries", vec![]), + ("rs-platform-value", vec![]), + ("rs-platform-serialization", vec![]), ]; for (specific_crate, to_ignore) in crates { diff --git a/packages/rs-platform-serialization/Cargo.toml b/packages/rs-platform-serialization/Cargo.toml index d55316ffc6e..6e91702e74a 100644 --- a/packages/rs-platform-serialization/Cargo.toml +++ b/packages/rs-platform-serialization/Cargo.toml @@ -8,5 +8,23 @@ rust-version.workspace = true license = "MIT" [dependencies] -bincode = { workspace = true, features = ["serde"] } -platform-version = { path = "../rs-platform-version" } +# Same package and pin as the workspace entry. Inheriting it cannot switch the +# default features off, and the guest profile needs `std` off, so the pin is +# repeated here. +bincode = { package = "grovedb-bincode", version = "=2.1.0", default-features = false, features = ["alloc", "serde"] } +platform-version = { path = "../rs-platform-version", optional = true } + +### FEATURES ################################################################# + +[features] +default = ["std", "platform-version"] + +# Native profile: bincode's std readers and writers. Without it the crate is +# `no_std` + `alloc` and offers only the bounded codec seam plus the bincode +# re-exports; that is the profile DashVM guest code builds against. +std = ["bincode/std"] + +# The historical `PlatformVersion`-dispatched traits and free functions. Guest +# codecs receive explicit `CodecBounds` instead of the native version registry, +# so this stays optional. +platform-version = ["dep:platform-version"] diff --git a/packages/rs-platform-serialization/src/bounded.rs b/packages/rs-platform-serialization/src/bounded.rs new file mode 100644 index 00000000000..00b399104b5 --- /dev/null +++ b/packages/rs-platform-serialization/src/bounded.rs @@ -0,0 +1,777 @@ +//! Explicit bounds for allocation-only decoding. +//! +//! The native decoders in this workspace rely on ambient state (a thread-local +//! nesting limit, bincode byte budgets picked per type) and on the operating +//! system for entropy and I/O. Guest code that runs inside a DashVM sandbox has +//! none of that, and it must never allocate more than the caller allowed. This +//! module is the seam both profiles share: a caller supplies one +//! [`CodecBounds`], a [`CodecBudget`] counts against it while a value is +//! decoded, and every variable-length leaf is read through a helper that checks +//! the declared length against the unread input **before** allocating. +//! +//! What the bounds guarantee, precisely: +//! +//! - byte-sized leaves (byte strings, text) never allocate more than the +//! unread input, so their total is at most `max_bytes`; +//! - container storage starts empty and grows by push, so its capacity +//! tracks the entries actually decoded, which `max_elements` caps (with the +//! usual amortised growth slack of a vector); +//! - the traversal stack holds one frame per open container, which `max_depth` +//! caps. +//! +//! Heap usage is therefore bounded by the three limits together, not by +//! `max_bytes` alone: a three byte input can still allocate a frame and an +//! element vector. Callers size `max_depth` and `max_elements` with that in +//! mind. +//! +//! These helpers bound decoding; they do not make it canonical. bincode +//! accepts overlong variable-length integers, so two different byte strings +//! can decode to the same value here. Canonical validation (re-encode and +//! compare) belongs to the ABI layer built on top of this module. +//! +//! Nothing here depends on `std` or on the native `PlatformVersion` registry. + +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; +use bincode::config::{BigEndian, Configuration, NoLimit, Varint}; +use bincode::de::read::{BorrowReader, Reader}; +use bincode::de::{Decoder, DecoderImpl}; +use bincode::error::DecodeError; +use bincode::Decode; +use core::fmt::{self, Display, Formatter}; + +use crate::BincodeContext; + +/// Explicit limits for one bounded decode or encode. +/// +/// Supplied by the caller and never read from ambient state. Host code derives +/// the numbers from its protocol-version tables; this crate only enforces them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CodecBounds { + /// Maximum size of the encoded payload in bytes. + pub max_bytes: u32, + /// Maximum container nesting depth. The outermost array or map is depth 1. + /// + /// This is the limit that protects the stack. Bounded decoding and + /// encoding walk containers iteratively, but a decoded value tree is as + /// deep as this allows and its `Drop`, `Clone` and derived `Encode` are + /// recursive, so choose it for the target's stack rather than for the + /// width of the field. The native document depth limit of 256 is the + /// reference value; `u16::MAX` is representable, not recommended. + pub max_depth: u16, + /// Maximum number of container entries across the whole value: one per + /// array item, two per map entry (key and value), one per string in an + /// enumeration of strings. + pub max_elements: u32, +} + +/// Why a bounded operation was refused. +/// +/// Every field is a fixed-width integer, never `usize`: these errors are mapped +/// into ABI errors that become wire-visible later, so their layout must not +/// depend on the target word size. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BoundsError { + /// The payload is larger than `max_bytes`. + BytesExceeded { + /// Actual payload length. + len: u64, + /// The configured maximum. + max: u32, + }, + /// A container would nest deeper than `max_depth`. + DepthExceeded { + /// The depth that was about to be entered. Wider than the limit so + /// that one past `u16::MAX` is still representable. + depth: u32, + /// The configured maximum. + max: u16, + }, + /// The running element count would exceed `max_elements`. + ElementsExceeded { + /// The count after the refused claim. + elements: u64, + /// The configured maximum. + max: u32, + }, + /// A length prefix declares more entries or bytes than the unread input + /// could possibly contain. Reported before any allocation. + DeclaredLengthExceedsInput { + /// The declared length. + declared: u64, + /// Bytes left unread when the length was seen. + remaining: u64, + }, +} + +impl Display for BoundsError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + BoundsError::BytesExceeded { len, max } => { + write!(f, "payload of {len} bytes exceeds the maximum of {max}") + } + BoundsError::DepthExceeded { depth, max } => { + write!( + f, + "value nesting depth {depth} exceeds the maximum of {max}" + ) + } + BoundsError::ElementsExceeded { elements, max } => { + write!( + f, + "{elements} container elements exceed the maximum of {max}" + ) + } + BoundsError::DeclaredLengthExceedsInput { + declared, + remaining, + } => write!( + f, + "declared length {declared} exceeds the {remaining} unread input bytes" + ), + } + } +} + +impl core::error::Error for BoundsError {} + +/// Running counters against one [`CodecBounds`]. +#[derive(Debug)] +pub struct CodecBudget<'a> { + bounds: &'a CodecBounds, + depth: u32, + elements: u64, +} + +impl<'a> CodecBudget<'a> { + /// Starts counting from zero against `bounds`. + pub const fn new(bounds: &'a CodecBounds) -> Self { + Self { + bounds, + depth: 0, + elements: 0, + } + } + + /// The bounds this budget counts against. + pub const fn bounds(&self) -> &'a CodecBounds { + self.bounds + } + + /// The current container nesting depth. + pub const fn depth(&self) -> u32 { + self.depth + } + + /// The number of container elements claimed so far. + pub const fn elements(&self) -> u64 { + self.elements + } + + /// Rejects a payload longer than `max_bytes`. Checked once, before the + /// first byte is read or written. + pub fn check_len(&self, len: usize) -> Result<(), BoundsError> { + let len = len as u64; + if len > u64::from(self.bounds.max_bytes) { + return Err(BoundsError::BytesExceeded { + len, + max: self.bounds.max_bytes, + }); + } + Ok(()) + } + + /// Records entering an array or map. Fails when the new depth would exceed + /// `max_depth`. The counter is wider than the limit and the addition is + /// checked, so a limit of `u16::MAX` still refuses the 65,536th level + /// instead of pinning the counter and letting every deeper level through. + pub fn enter_container(&mut self) -> Result<(), BoundsError> { + let max = self.bounds.max_depth; + let depth = self + .depth + .checked_add(1) + .ok_or(BoundsError::DepthExceeded { + depth: u32::MAX, + max, + })?; + if depth > u32::from(max) { + return Err(BoundsError::DepthExceeded { depth, max }); + } + self.depth = depth; + Ok(()) + } + + /// Records leaving the innermost array or map. + pub fn exit_container(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// Adds `count` container elements to the running total. Fails when the + /// total would exceed `max_elements`; the addition itself cannot overflow + /// because it saturates. + pub fn claim_elements(&mut self, count: u64) -> Result<(), BoundsError> { + let elements = self.elements.saturating_add(count); + if elements > u64::from(self.bounds.max_elements) { + return Err(BoundsError::ElementsExceeded { + elements, + max: self.bounds.max_elements, + }); + } + self.elements = elements; + Ok(()) + } +} + +/// Rejects a declared length that the unread input cannot contain. +/// +/// Every entry of a container, every byte of a byte string and every string of +/// a string list occupies at least one input byte, so a declared length above +/// the unread remainder is malformed. Call this before any allocation sized by +/// a decoded length. +pub fn check_declared_len(declared: u64, remaining: usize) -> Result<(), BoundsError> { + let remaining = remaining as u64; + if declared > remaining { + return Err(BoundsError::DeclaredLengthExceedsInput { + declared, + remaining, + }); + } + Ok(()) +} + +/// A reader that can report how much of its input is still unread. +/// +/// The bounded leaf decoders need this to check declared lengths against the +/// remaining input before allocating. +pub trait RemainingInput { + /// Bytes not yet consumed. + fn remaining(&self) -> usize; +} + +/// A bincode reader over a slice that exposes the unread remainder. +/// +/// Behaves exactly like bincode's own slice reader; the only addition is +/// [`BoundedSliceReader::remaining`], which lets callers reject declared +/// lengths that cannot fit and detect trailing bytes. +#[derive(Debug)] +pub struct BoundedSliceReader<'a> { + remaining: &'a [u8], +} + +impl<'a> BoundedSliceReader<'a> { + /// Wraps `bytes`. + pub const fn new(bytes: &'a [u8]) -> Self { + Self { remaining: bytes } + } + + /// Bytes not yet consumed. + pub const fn remaining(&self) -> usize { + self.remaining.len() + } +} + +impl RemainingInput for BoundedSliceReader<'_> { + fn remaining(&self) -> usize { + self.remaining.len() + } +} + +impl Reader for BoundedSliceReader<'_> { + #[inline] + fn read(&mut self, bytes: &mut [u8]) -> Result<(), DecodeError> { + if bytes.len() > self.remaining.len() { + return Err(DecodeError::UnexpectedEnd { + additional: bytes.len() - self.remaining.len(), + }); + } + let (head, tail) = self.remaining.split_at(bytes.len()); + bytes.copy_from_slice(head); + self.remaining = tail; + Ok(()) + } + + #[inline] + fn peek_read(&mut self, n: usize) -> Option<&[u8]> { + self.remaining.get(..n) + } + + #[inline] + fn consume(&mut self, n: usize) { + self.remaining = self.remaining.get(n..).unwrap_or_default(); + } +} + +impl<'de> BorrowReader<'de> for BoundedSliceReader<'de> { + #[inline] + fn take_bytes(&mut self, length: usize) -> Result<&'de [u8], DecodeError> { + if length > self.remaining.len() { + return Err(DecodeError::UnexpectedEnd { + additional: length - self.remaining.len(), + }); + } + let (head, tail) = self.remaining.split_at(length); + self.remaining = tail; + Ok(head) + } +} + +/// The bincode configuration used for canonical wire bytes: standard layout, +/// big endian, variable-length integers, no byte limit (the caller's +/// [`CodecBounds`] is the limit). Identical to the native platform config. +pub const fn canonical_config() -> Configuration { + bincode::config::standard() + .with_big_endian() + .with_no_limit() +} + +/// The decoder type used by [`bounded_decode_from_slice`]. +pub type BoundedDecoder<'a> = + DecoderImpl, Configuration, BincodeContext>; + +/// Failure of a bounded decode. +#[derive(Debug)] +pub enum BoundedDecodeError { + /// A bound from [`CodecBounds`] was hit. + Bounds(BoundsError), + /// The bytes are not a valid encoding. + Decode(DecodeError), + /// The value ended before the input did. + TrailingBytes { + /// Bytes left after the value. + remaining: u64, + }, +} + +impl From for BoundedDecodeError { + fn from(error: BoundsError) -> Self { + BoundedDecodeError::Bounds(error) + } +} + +impl From for BoundedDecodeError { + fn from(error: DecodeError) -> Self { + BoundedDecodeError::Decode(error) + } +} + +impl Display for BoundedDecodeError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + BoundedDecodeError::Bounds(error) => write!(f, "bounds: {error}"), + BoundedDecodeError::Decode(error) => write!(f, "decode: {error}"), + BoundedDecodeError::TrailingBytes { remaining } => { + write!(f, "{remaining} trailing bytes after the value") + } + } + } +} + +impl core::error::Error for BoundedDecodeError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + BoundedDecodeError::Bounds(error) => Some(error), + // bincode's errors implement the error trait only with `std`. + #[cfg(feature = "std")] + BoundedDecodeError::Decode(error) => Some(error), + _ => None, + } + } +} + +/// Reads a bincode length prefix and returns it once the unread input could +/// contain that many entries. No allocation happens here. +pub fn decode_bounded_len(decoder: &mut D) -> Result +where + D: Decoder, + D::R: RemainingInput, +{ + let declared = >::decode(decoder)?; + check_declared_len(declared, decoder.reader().remaining())?; + // `declared` is at most the unread length, which is a `usize`, so this + // conversion only fails on a platform bincode itself does not support. + usize::try_from(declared).map_err(|_| DecodeError::OutsideUsizeRange(declared).into()) +} + +/// Reads a length-prefixed byte string, allocating exactly the declared length +/// and only after that length has been checked against the unread input. +/// +/// bincode's own `Vec` decoder allocates from the declared length before +/// reading the payload, so it is never used on the bounded path. +pub fn decode_bounded_bytes(decoder: &mut D) -> Result, BoundedDecodeError> +where + D: Decoder, + D::R: RemainingInput, +{ + let len = decode_bounded_len(decoder)?; + decoder.claim_bytes_read(len)?; + let mut bytes = vec![0u8; len]; + decoder.reader().read(&mut bytes)?; + Ok(bytes) +} + +/// Reads a length-prefixed UTF-8 string through [`decode_bounded_bytes`] and +/// validates the encoding after the bounded read. +pub fn decode_bounded_string(decoder: &mut D) -> Result +where + D: Decoder, + D::R: RemainingInput, +{ + let bytes = decode_bounded_bytes(decoder)?; + String::from_utf8(bytes).map_err(|error| { + DecodeError::Utf8 { + inner: error.utf8_error(), + } + .into() + }) +} + +/// Reads a length-prefixed list of strings. The count is charged to the +/// element budget and checked against the unread input (each entry needs at +/// least its own length byte) before the list starts; the list then grows by +/// push and each string goes through [`decode_bounded_string`]. +pub fn decode_bounded_string_list( + decoder: &mut D, + budget: &mut CodecBudget<'_>, +) -> Result, BoundedDecodeError> +where + D: Decoder, + D::R: RemainingInput, +{ + let count = decode_bounded_len(decoder)?; + budget.claim_elements(count as u64)?; + let mut strings = Vec::new(); + for _ in 0..count { + strings.push(decode_bounded_string(decoder)?); + } + Ok(strings) +} + +/// Runs `decode` over exactly `bytes` under `bounds`. +/// +/// Checks the input length first, hands the closure a decoder over the whole +/// input plus a fresh [`CodecBudget`], and finally requires that every byte was +/// consumed: trailing bytes are an error. +/// +/// This does not make the accepted encoding unique. bincode decodes overlong +/// variable-length integers leniently, so distinct inputs can still decode to +/// the same value; a caller that needs canonical bytes re-encodes the result +/// and compares. That check belongs to the ABI layer. +pub fn bounded_decode_from_slice<'a, T, F>( + bytes: &'a [u8], + bounds: &CodecBounds, + decode: F, +) -> Result +where + F: FnOnce(&mut BoundedDecoder<'a>, &mut CodecBudget<'_>) -> Result, +{ + let mut budget = CodecBudget::new(bounds); + budget.check_len(bytes.len())?; + let mut decoder = DecoderImpl::new(BoundedSliceReader::new(bytes), canonical_config(), ()); + let value = decode(&mut decoder, &mut budget)?; + let remaining = decoder.reader().remaining(); + if remaining != 0 { + return Err(BoundedDecodeError::TrailingBytes { + remaining: remaining as u64, + }); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use bincode::Encode; + + const BOUNDS: CodecBounds = CodecBounds { + max_bytes: 64, + max_depth: 3, + max_elements: 8, + }; + + fn encode(value: T) -> Vec { + bincode::encode_to_vec(value, canonical_config()).expect("encoding cannot fail") + } + + fn decode_all(bytes: &[u8], decode: F) -> Result + where + F: FnOnce(&mut BoundedDecoder<'_>, &mut CodecBudget<'_>) -> Result, + { + bounded_decode_from_slice(bytes, &BOUNDS, decode) + } + + #[test] + fn should_accept_input_exactly_at_the_byte_bound() { + let budget = CodecBudget::new(&BOUNDS); + assert_eq!(budget.check_len(64), Ok(())); + } + + #[test] + fn should_reject_input_one_byte_over_the_bound() { + let budget = CodecBudget::new(&BOUNDS); + assert_eq!( + budget.check_len(65), + Err(BoundsError::BytesExceeded { len: 65, max: 64 }) + ); + } + + #[test] + fn should_accept_depth_exactly_at_the_bound_and_reject_one_more() { + let mut budget = CodecBudget::new(&BOUNDS); + for _ in 0..3 { + assert_eq!(budget.enter_container(), Ok(())); + } + assert_eq!(budget.depth(), 3); + assert_eq!( + budget.enter_container(), + Err(BoundsError::DepthExceeded { depth: 4, max: 3 }) + ); + budget.exit_container(); + assert_eq!(budget.enter_container(), Ok(())); + } + + #[test] + fn should_refuse_the_level_past_a_u16_max_depth_limit() { + let bounds = CodecBounds { + max_bytes: u32::MAX, + max_depth: u16::MAX, + max_elements: u32::MAX, + }; + let mut budget = CodecBudget::new(&bounds); + for _ in 0..u16::MAX { + assert_eq!(budget.enter_container(), Ok(())); + } + assert_eq!(budget.depth(), u32::from(u16::MAX)); + assert_eq!( + budget.enter_container(), + Err(BoundsError::DepthExceeded { + depth: 65_536, + max: u16::MAX + }) + ); + // The refused level did not move the counter, so one exit and one + // entry still land exactly on the limit. + budget.exit_container(); + assert_eq!(budget.enter_container(), Ok(())); + assert_eq!(budget.depth(), u32::from(u16::MAX)); + } + + #[test] + fn should_accept_overlong_varints_like_bincode_does() { + // Two encodings of the same u16 value 0: the minimal single byte and + // the overlong two byte form behind the u16 marker. Bounded decoding + // is not canonical validation; the ABI layer re-encodes and compares. + let minimal = [0u8]; + let overlong = [251u8, 0, 0]; + let read = |bytes: &[u8]| { + decode_all(bytes, |decoder, _| { + >::decode(decoder).map_err(Into::into) + }) + .unwrap() + }; + assert_eq!(read(&minimal), 0); + assert_eq!(read(&overlong), 0); + assert_eq!(encode(0u16), minimal); + } + + #[test] + fn should_not_underflow_depth_on_extra_exit() { + let mut budget = CodecBudget::new(&BOUNDS); + budget.exit_container(); + assert_eq!(budget.depth(), 0); + } + + #[test] + fn should_accept_elements_exactly_at_the_bound_and_reject_one_more() { + let mut budget = CodecBudget::new(&BOUNDS); + assert_eq!(budget.claim_elements(5), Ok(())); + assert_eq!(budget.claim_elements(3), Ok(())); + assert_eq!(budget.elements(), 8); + assert_eq!( + budget.claim_elements(1), + Err(BoundsError::ElementsExceeded { + elements: 9, + max: 8 + }) + ); + } + + #[test] + fn should_reject_element_claims_that_would_overflow_the_counter() { + let mut budget = CodecBudget::new(&BOUNDS); + assert_eq!(budget.claim_elements(4), Ok(())); + assert_eq!( + budget.claim_elements(u64::MAX), + Err(BoundsError::ElementsExceeded { + elements: u64::MAX, + max: 8 + }) + ); + } + + #[test] + fn should_accept_declared_length_equal_to_remaining_and_reject_one_more() { + assert_eq!(check_declared_len(10, 10), Ok(())); + assert_eq!( + check_declared_len(11, 10), + Err(BoundsError::DeclaredLengthExceedsInput { + declared: 11, + remaining: 10 + }) + ); + } + + #[test] + fn should_read_bytes_whose_declared_length_equals_the_remaining_input() { + let bytes = encode(vec![7u8, 8, 9]); + let decoded = decode_all(&bytes, |decoder, _| decode_bounded_bytes(decoder)).unwrap(); + assert_eq!(decoded, vec![7, 8, 9]); + } + + #[test] + fn should_reject_bytes_declaring_one_more_than_the_remaining_input() { + let mut bytes = encode(vec![7u8, 8, 9]); + bytes.truncate(bytes.len() - 1); + match decode_all(&bytes, |decoder, _| decode_bounded_bytes(decoder)) { + Err(BoundedDecodeError::Bounds(BoundsError::DeclaredLengthExceedsInput { + declared: 3, + remaining: 2, + })) => {} + other => panic!("expected declared length rejection, got {other:?}"), + } + } + + #[test] + fn should_reject_bytes_declaring_u64_max_without_allocating() { + let bytes = encode(u64::MAX); + match decode_all(&bytes, |decoder, _| decode_bounded_bytes(decoder)) { + Err(BoundedDecodeError::Bounds(BoundsError::DeclaredLengthExceedsInput { + declared: u64::MAX, + remaining: 0, + })) => {} + other => panic!("expected declared length rejection, got {other:?}"), + } + } + + #[test] + fn should_validate_utf8_after_the_bounded_read() { + let bytes = encode(vec![0xffu8, 0xfe]); + match decode_all(&bytes, |decoder, _| decode_bounded_string(decoder)) { + Err(BoundedDecodeError::Decode(DecodeError::Utf8 { .. })) => {} + other => panic!("expected utf8 rejection, got {other:?}"), + } + let bytes = encode("héllo"); + let decoded = decode_all(&bytes, |decoder, _| decode_bounded_string(decoder)).unwrap(); + assert_eq!(decoded, "héllo"); + } + + #[test] + fn should_read_a_string_list_and_charge_its_count_to_the_element_budget() { + let bytes = encode(vec!["a", "bc", ""]); + let (strings, elements) = decode_all(&bytes, |decoder, budget| { + let strings = decode_bounded_string_list(decoder, budget)?; + Ok((strings, budget.elements())) + }) + .unwrap(); + assert_eq!(strings, vec!["a", "bc", ""]); + assert_eq!(elements, 3); + } + + #[test] + fn should_reject_a_string_list_count_beyond_the_remaining_input() { + // Count 5 with only two bytes of payload after the prefix. + let bytes = [5u8, 0, 0]; + match decode_all(&bytes, |decoder, budget| { + decode_bounded_string_list(decoder, budget) + }) { + Err(BoundedDecodeError::Bounds(BoundsError::DeclaredLengthExceedsInput { + declared: 5, + remaining: 2, + })) => {} + other => panic!("expected declared length rejection, got {other:?}"), + } + } + + #[test] + fn should_reject_a_string_list_count_beyond_the_element_budget() { + let bytes = encode(vec![""; 9]); + match decode_all(&bytes, |decoder, budget| { + decode_bounded_string_list(decoder, budget) + }) { + Err(BoundedDecodeError::Bounds(BoundsError::ElementsExceeded { + elements: 9, + max: 8, + })) => {} + other => panic!("expected element budget rejection, got {other:?}"), + } + } + + #[test] + fn should_reject_an_inner_string_declaring_beyond_the_remaining_input() { + // Count 1, then a string claiming 200 bytes with one byte present. + let bytes = [1u8, 200, 0]; + match decode_all(&bytes, |decoder, budget| { + decode_bounded_string_list(decoder, budget) + }) { + Err(BoundedDecodeError::Bounds(BoundsError::DeclaredLengthExceedsInput { + declared: 200, + remaining: 1, + })) => {} + other => panic!("expected declared length rejection, got {other:?}"), + } + } + + #[test] + fn should_reject_trailing_bytes() { + let mut bytes = encode(42u32); + bytes.push(0); + match decode_all(&bytes, |decoder, _| { + >::decode(decoder).map_err(Into::into) + }) { + Err(BoundedDecodeError::TrailingBytes { remaining: 1 }) => {} + other => panic!("expected trailing byte rejection, got {other:?}"), + } + } + + #[test] + fn should_reject_input_longer_than_max_bytes_before_decoding() { + let bytes = vec![0u8; 65]; + match decode_all::<(), _>(&bytes, |_, _| panic!("the closure must not run")) { + Err(BoundedDecodeError::Bounds(BoundsError::BytesExceeded { len: 65, max: 64 })) => {} + other => panic!("expected byte bound rejection, got {other:?}"), + } + } + + #[test] + fn should_expose_the_unread_remainder_through_the_reader() { + let mut reader = BoundedSliceReader::new(&[1, 2, 3, 4]); + assert_eq!(reader.remaining(), 4); + let mut two = [0u8; 2]; + reader.read(&mut two).unwrap(); + assert_eq!(two, [1, 2]); + assert_eq!(reader.remaining(), 2); + assert_eq!(reader.peek_read(1), Some(&[3][..])); + reader.consume(1); + assert_eq!(reader.take_bytes(1).unwrap(), &[4]); + assert_eq!(reader.remaining(), 0); + assert!(matches!( + reader.read(&mut two), + Err(DecodeError::UnexpectedEnd { additional: 2 }) + )); + assert!(matches!( + reader.take_bytes(1), + Err(DecodeError::UnexpectedEnd { additional: 1 }) + )); + } + + #[test] + fn should_use_the_native_big_endian_varint_configuration() { + let native = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + assert_eq!( + bincode::encode_to_vec(300u16, canonical_config()).unwrap(), + bincode::encode_to_vec(300u16, native).unwrap() + ); + } +} diff --git a/packages/rs-platform-serialization/src/de/mod.rs b/packages/rs-platform-serialization/src/de/mod.rs index c2ef224f3bd..960cd99a209 100644 --- a/packages/rs-platform-serialization/src/de/mod.rs +++ b/packages/rs-platform-serialization/src/de/mod.rs @@ -1,12 +1,16 @@ //! Decoder-based structs and traits. +#[cfg(feature = "platform-version")] mod impl_core; +#[cfg(feature = "platform-version")] mod impl_tuples; +#[cfg(feature = "platform-version")] mod impls; pub use bincode::de::{BorrowDecoder, Decoder}; pub use bincode::error::DecodeError; pub use bincode::{BorrowDecode, Decode}; +#[cfg(feature = "platform-version")] use platform_version::version::PlatformVersion; /// Decode with the default `()` context to avoid repeated generic arguments. @@ -104,6 +108,7 @@ impl<'de, T> DefaultBorrowDecode<'de> for T where /// # } /// # bincode::impl_borrow_decode!(Foo); /// ``` +#[cfg(feature = "platform-version")] pub trait PlatformVersionedDecode: Sized { /// Attempt to decode this type with the given [Decode]. fn platform_versioned_decode>( @@ -117,6 +122,7 @@ pub trait PlatformVersionedDecode: Sized { /// This trait should be implemented for types that contain borrowed data, like `&str` and `&[u8]`. If your type does not have borrowed data, consider implementing [Decode] instead. /// /// This trait will be automatically implemented if you enable the `derive` feature and add `#[derive(bincode::Decode)]` to a type with a lifetime. +#[cfg(feature = "platform-version")] pub trait PlatformVersionedBorrowDecode<'de>: Sized { /// Attempt to decode this type with the given [BorrowDecode]. fn platform_versioned_borrow_decode>( @@ -126,6 +132,7 @@ pub trait PlatformVersionedBorrowDecode<'de>: Sized { } /// Helper macro to implement `PlatformVersionedBorrowDecode` for any type that implements `PlatformVersionedDecode`. +#[cfg(feature = "platform-version")] #[macro_export] macro_rules! impl_platform_versioned_borrow_decode { ($ty:ty) => { @@ -149,6 +156,7 @@ macro_rules! impl_platform_versioned_borrow_decode { } /// Decodes only the option variant from the decoder. Will not read any more data than that. +#[cfg(feature = "platform-version")] #[inline] pub(crate) fn decode_option_variant>( decoder: &mut D, @@ -181,9 +189,11 @@ pub(crate) fn decode_option_variant> /// well within OS limits (1 Mi × 64 bytes = 64 MiB). /// - This is a last-resort guard; the primary protection is the per-type /// byte-budget configured via `#[platform_serialize(limit = N)]`. +#[cfg(feature = "platform-version")] const MAX_COLLECTION_LEN: u64 = 1024 * 1024; /// Decodes the length of any slice, container, etc from the decoder +#[cfg(feature = "platform-version")] #[inline] pub(crate) fn decode_slice_len>( decoder: &mut D, @@ -197,7 +207,7 @@ pub(crate) fn decode_slice_len>( v.try_into().map_err(|_| DecodeError::OutsideUsizeRange(v)) } -#[cfg(test)] +#[cfg(all(test, feature = "platform-version"))] mod tests { use super::*; use bincode::config; diff --git a/packages/rs-platform-serialization/src/enc/mod.rs b/packages/rs-platform-serialization/src/enc/mod.rs index a159d5589bc..8b3a8ed5bce 100644 --- a/packages/rs-platform-serialization/src/enc/mod.rs +++ b/packages/rs-platform-serialization/src/enc/mod.rs @@ -1,8 +1,14 @@ +use alloc::vec::Vec; +use bincode::enc; +#[cfg(feature = "platform-version")] use bincode::enc::Encoder; use bincode::error::EncodeError; -use bincode::{enc, Encode}; +#[cfg(feature = "platform-version")] +use bincode::Encode; +#[cfg(feature = "platform-version")] use platform_version::version::PlatformVersion; +#[cfg(feature = "platform-version")] mod impls; #[derive(Default)] @@ -37,6 +43,7 @@ impl enc::write::Writer for VecWriter { } } +#[cfg(feature = "platform-version")] pub trait PlatformVersionEncode { /// Encode a given type. fn platform_encode( @@ -47,6 +54,7 @@ pub trait PlatformVersionEncode { } /// Encode the variant of the given option. Will not encode the option itself. +#[cfg(feature = "platform-version")] #[inline] pub(crate) fn encode_option_variant( encoder: &mut E, @@ -59,12 +67,13 @@ pub(crate) fn encode_option_variant( } /// Encodes the length of any slice, container, etc into the given encoder +#[cfg(feature = "platform-version")] #[inline] pub(crate) fn encode_slice_len(encoder: &mut E, len: usize) -> Result<(), EncodeError> { (len as u64).encode(encoder) } -#[cfg(test)] +#[cfg(all(test, feature = "platform-version"))] #[allow(clippy::drop_non_drop)] mod tests { use super::*; diff --git a/packages/rs-platform-serialization/src/features/impl_alloc.rs b/packages/rs-platform-serialization/src/features/impl_alloc.rs index f15803efefd..7e680a8465e 100644 --- a/packages/rs-platform-serialization/src/features/impl_alloc.rs +++ b/packages/rs-platform-serialization/src/features/impl_alloc.rs @@ -10,6 +10,7 @@ use alloc::{ collections::*, rc::Rc, string::String, + vec, vec::Vec, }; use bincode::config::Config; diff --git a/packages/rs-platform-serialization/src/features/mod.rs b/packages/rs-platform-serialization/src/features/mod.rs index 291678545b2..c8f095f2775 100644 --- a/packages/rs-platform-serialization/src/features/mod.rs +++ b/packages/rs-platform-serialization/src/features/mod.rs @@ -1,4 +1,7 @@ +#[cfg(feature = "platform-version")] mod impl_alloc; +#[cfg(all(feature = "std", feature = "platform-version"))] mod impl_std; +#[cfg(feature = "platform-version")] pub use impl_alloc::platform_encode_to_vec; diff --git a/packages/rs-platform-serialization/src/lib.rs b/packages/rs-platform-serialization/src/lib.rs index b9610bb4f75..3d36cec87f0 100644 --- a/packages/rs-platform-serialization/src/lib.rs +++ b/packages/rs-platform-serialization/src/lib.rs @@ -1,13 +1,37 @@ +#![cfg_attr(not(any(test, feature = "std")), no_std)] + +//! Bincode based serialization for Dash Platform. +//! +//! Two profiles share this crate: +//! +//! - the native profile (default features) adds the `PlatformVersion` aware +//! traits and free functions that shipped protocol versions decode through; +//! - the allocation-only profile (`--no-default-features`) is `no_std` + +//! `alloc` and offers the [`bounded`] codec seam, where every decode carries +//! caller supplied [`bounded::CodecBounds`] instead of ambient state. + +extern crate alloc; +#[cfg(any(test, feature = "std"))] +extern crate std; + +pub mod bounded; pub mod de; pub mod enc; mod features; +#[cfg(feature = "platform-version")] use bincode::config::Config; +#[cfg(feature = "platform-version")] use bincode::de::read::Reader; +#[cfg(feature = "platform-version")] use bincode::de::{read, DecoderImpl}; +#[cfg(feature = "platform-version")] use bincode::enc::write::Writer; +#[cfg(feature = "platform-version")] use bincode::enc::{write, EncoderImpl}; +#[cfg(feature = "platform-version")] pub use enc::PlatformVersionEncode; +#[cfg(feature = "platform-version")] pub use features::platform_encode_to_vec; /// Alias for the decoding context used across this crate. @@ -15,23 +39,24 @@ pub type BincodeContext = (); pub use de::DefaultBorrowDecode; pub use de::DefaultDecode; +#[cfg(feature = "platform-version")] pub use de::PlatformVersionedBorrowDecode; +#[cfg(feature = "platform-version")] pub use de::PlatformVersionedDecode; pub use bincode::enc::Encode; pub use bincode::error; pub use de::BorrowDecode; pub use de::Decode; +#[cfg(feature = "platform-version")] use platform_version::version::PlatformVersion; -extern crate alloc; -extern crate std; - /// Encode the given value into the given slice. Returns the amount of bytes that have been written. /// /// See the [config] module for more information on configurations. /// /// [config]: config/index.html +#[cfg(feature = "platform-version")] pub fn platform_encode_into_slice( val: E, dst: &mut [u8], @@ -49,6 +74,7 @@ pub fn platform_encode_into_slice( /// See the [config] module for more information on configurations. /// /// [config]: config/index.html +#[cfg(feature = "platform-version")] pub fn encode_into_writer( val: E, writer: W, @@ -64,6 +90,7 @@ pub fn encode_into_writer( /// See the [config] module for more information on configurations. /// /// [config]: config/index.html +#[cfg(feature = "platform-version")] pub fn platform_versioned_decode_from_slice( src: &[u8], config: C, @@ -79,6 +106,7 @@ pub fn platform_versioned_decode_from_slice, @@ -98,6 +126,7 @@ pub fn platform_versioned_borrow_decode_from_slice< /// See the [config] module for more information on configurations. /// /// [config]: config/index.html +#[cfg(feature = "platform-version")] pub fn platform_versioned_decode_from_reader( reader: R, config: C, @@ -107,7 +136,7 @@ pub fn platform_versioned_decode_from_reader Result<(), Error>; diff --git a/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_extensions.rs b/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_extensions.rs index 36c60f06273..1d2e1a36e19 100644 --- a/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_extensions.rs +++ b/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_extensions.rs @@ -1,5 +1,7 @@ use crate::{BinaryData, Bytes20, Bytes32, Error, Identifier, Value}; -use std::collections::BTreeMap; +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec::Vec; pub trait BTreeValueRemoveFromMapHelper { fn remove_optional_string(&mut self, key: &str) -> Result, Error>; diff --git a/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_inner_value_extensions.rs b/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_inner_value_extensions.rs index 240f3a60d0a..ff4bf0541cc 100644 --- a/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_inner_value_extensions.rs +++ b/packages/rs-platform-value/src/btreemap_extensions/btreemap_removal_inner_value_extensions.rs @@ -1,5 +1,6 @@ use crate::{Error, Value}; -use std::collections::BTreeMap; +use alloc::collections::BTreeMap; +use alloc::string::String; pub trait BTreeValueRemoveInnerValueFromMapHelper { fn remove_optional_inner_value_array>( diff --git a/packages/rs-platform-value/src/btreemap_extensions/equal_underlying_data.rs b/packages/rs-platform-value/src/btreemap_extensions/equal_underlying_data.rs index 099e1196d33..862325e07d8 100644 --- a/packages/rs-platform-value/src/btreemap_extensions/equal_underlying_data.rs +++ b/packages/rs-platform-value/src/btreemap_extensions/equal_underlying_data.rs @@ -1,5 +1,6 @@ use crate::Value; -use std::collections::BTreeMap; +use alloc::collections::BTreeMap; +use alloc::string::String; /* ========================================================= * * Trait: EqualUnderlyingData * * ========================================================= */ diff --git a/packages/rs-platform-value/src/btreemap_extensions/mod.rs b/packages/rs-platform-value/src/btreemap_extensions/mod.rs index 0476b489dd8..75d2bdda4ff 100644 --- a/packages/rs-platform-value/src/btreemap_extensions/mod.rs +++ b/packages/rs-platform-value/src/btreemap_extensions/mod.rs @@ -1,11 +1,13 @@ +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::borrow::Borrow; +use core::convert::TryFrom; #[cfg(feature = "json")] -use serde_json::Value as JsonValue; -use std::borrow::Borrow; -use std::collections::BTreeMap; -use std::convert::TryFrom; +use core::convert::TryInto; +use core::iter::FromIterator; #[cfg(feature = "json")] -use std::convert::TryInto; -use std::iter::FromIterator; +use serde_json::Value as JsonValue; use crate::{BinaryData, Error, Identifier, Value, ValueMap}; diff --git a/packages/rs-platform-value/src/display.rs b/packages/rs-platform-value/src/display.rs index e7807fbdf63..48d8eb9678f 100644 --- a/packages/rs-platform-value/src/display.rs +++ b/packages/rs-platform-value/src/display.rs @@ -1,10 +1,12 @@ use crate::Value; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use base64::prelude::BASE64_STANDARD; use base64::Engine; -use std::fmt::{Display, Formatter}; +use core::fmt::{self, Display, Formatter}; impl Display for Value { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { f.write_str(&self.string_representation()) } } diff --git a/packages/rs-platform-value/src/eq.rs b/packages/rs-platform-value/src/eq.rs index d048fb5a4be..0f8943df8bd 100644 --- a/packages/rs-platform-value/src/eq.rs +++ b/packages/rs-platform-value/src/eq.rs @@ -1,4 +1,6 @@ use crate::Value; +use alloc::string::String; +use alloc::vec::Vec; macro_rules! implpartialeq { ($($t:ty),+ $(,)?) => { diff --git a/packages/rs-platform-value/src/error.rs b/packages/rs-platform-value/src/error.rs index 129456bbc2b..b5daaab8dcc 100644 --- a/packages/rs-platform-value/src/error.rs +++ b/packages/rs-platform-value/src/error.rs @@ -1,4 +1,5 @@ -use std::fmt::Display; +use alloc::string::{String, ToString}; +use core::fmt::Display; use thiserror::Error; diff --git a/packages/rs-platform-value/src/guest_bounds.rs b/packages/rs-platform-value/src/guest_bounds.rs new file mode 100644 index 00000000000..442667a2b6a --- /dev/null +++ b/packages/rs-platform-value/src/guest_bounds.rs @@ -0,0 +1,712 @@ +//! Bounded value codec for allocation-only guests. +//! +//! [`Value::decode_bounded`] and [`Value::encode_bounded`] take explicit +//! [`CodecBounds`] instead of the native thread-local depth limit. They run the +//! same iterative decoder state machine as the blanket [`bincode::Decode`] impl +//! and produce the same bytes as the derived [`bincode::Encode`] impl, so guest +//! bytes are native bytes. What differs is how untrusted lengths are handled: +//! +//! - every declared length (container count, byte string, text, string list +//! and each of its strings) is checked against the unread input and the +//! budget before any allocation; +//! - byte leaves then allocate exactly the declared length, and containers +//! start empty and grow by push; +//! - depth and element counts are charged at the header, so an oversized +//! container is refused before its first entry is decoded. +//! +//! Heap usage is bounded by the three limits together: byte leaves by +//! `max_bytes`, container storage by `max_elements` (plus vector growth +//! slack), and the frame stack by `max_depth`. It is not bounded by +//! `max_bytes` alone; a three byte array header still allocates a frame and an +//! element vector. +//! +//! Bounded decoding is not canonical validation. bincode accepts overlong +//! variable-length integers, so two byte strings can decode to the same value +//! here; canonical bytes are established by re-encoding and comparing, which +//! the ABI layer does. + +use alloc::string::String; +use alloc::vec::Vec; +use bincode::de::Decoder; +use bincode::enc::write::{SizeWriter, Writer}; +use bincode::enc::{Encode, Encoder, EncoderImpl}; +use bincode::error::EncodeError; +use core::fmt::{self, Display, Formatter}; +use platform_serialization::bounded::{ + bounded_decode_from_slice, canonical_config, decode_bounded_bytes, decode_bounded_len, + decode_bounded_string, decode_bounded_string_list, BoundedDecodeError, BoundsError, + CodecBounds, CodecBudget, RemainingInput, +}; + +use crate::{ + decode_value_with, Value, ValueLeafReader, ValueMap, VALUE_ARRAY_VARIANT, VALUE_MAP_VARIANT, +}; + +/// Failure of [`Value::encode_bounded`]. +#[derive(Debug)] +pub enum BoundedEncodeError { + /// A bound from [`CodecBounds`] was hit. + Bounds(BoundsError), + /// bincode refused to encode a leaf. + Encode(EncodeError), +} + +impl From for BoundedEncodeError { + fn from(error: BoundsError) -> Self { + BoundedEncodeError::Bounds(error) + } +} + +impl From for BoundedEncodeError { + fn from(error: EncodeError) -> Self { + BoundedEncodeError::Encode(error) + } +} + +impl Display for BoundedEncodeError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + BoundedEncodeError::Bounds(error) => write!(f, "bounds: {error}"), + BoundedEncodeError::Encode(error) => write!(f, "encode: {error}"), + } + } +} + +impl core::error::Error for BoundedEncodeError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + BoundedEncodeError::Bounds(error) => Some(error), + // bincode's errors implement the error trait only with `std`. + #[cfg(feature = "std")] + BoundedEncodeError::Encode(error) => Some(error), + #[cfg(not(feature = "std"))] + BoundedEncodeError::Encode(_) => None, + } + } +} + +/// Leaf reader that charges every length to a [`CodecBudget`]. Byte leaves +/// never allocate more than the unread input; containers start empty. +struct BudgetedLeaves<'b, 'a> { + budget: &'b mut CodecBudget<'a>, +} + +impl ValueLeafReader for BudgetedLeaves<'_, '_> +where + D: Decoder, + D::R: RemainingInput, +{ + type Error = BoundedDecodeError; + + fn array_header( + &mut self, + decoder: &mut D, + _depth: usize, + ) -> Result<(usize, Vec), BoundedDecodeError> { + self.budget.enter_container()?; + let len = decode_bounded_len(decoder)?; + self.budget.claim_elements(len as u64)?; + Ok((len, Vec::new())) + } + + fn map_header( + &mut self, + decoder: &mut D, + _depth: usize, + ) -> Result<(usize, ValueMap), BoundedDecodeError> { + self.budget.enter_container()?; + let len = decode_bounded_len(decoder)?; + // A key and a value per entry. + self.budget.claim_elements((len as u64).saturating_mul(2))?; + Ok((len, Vec::new())) + } + + fn container_end(&mut self) { + self.budget.exit_container(); + } + + fn bytes(&mut self, decoder: &mut D) -> Result, BoundedDecodeError> { + decode_bounded_bytes(decoder) + } + + fn text(&mut self, decoder: &mut D) -> Result { + decode_bounded_string(decoder) + } + + fn string_list(&mut self, decoder: &mut D) -> Result, BoundedDecodeError> { + decode_bounded_string_list(decoder, self.budget) + } +} + +/// One pending container while encoding without recursion. +enum EncodeFrame<'v> { + Array(core::slice::Iter<'v, Value>), + Map { + entries: core::slice::Iter<'v, (Value, Value)>, + /// The value of the entry whose key is currently being written. Keys + /// are leaves in every canonical value, but a native value may carry a + /// container key, so the value waits until the key's subtree closes. + pending_value: Option<&'v Value>, + }, +} + +/// Writes `value` with the same layout as the derived [`Encode`] impl, walking +/// containers with an explicit stack and charging depth and element counts to +/// `budget` at each container header. +fn encode_value_with( + value: &Value, + encoder: &mut E, + budget: &mut CodecBudget<'_>, +) -> Result<(), BoundedEncodeError> { + let mut frames = Vec::>::new(); + let mut next = Some(value); + + loop { + if let Some(value) = next.take() { + encode_leaf_or_push(value, encoder, budget, &mut frames)?; + continue; + } + + let Some(frame) = frames.last_mut() else { + return Ok(()); + }; + match frame { + EncodeFrame::Array(items) => match items.next() { + Some(item) => next = Some(item), + None => { + frames.pop(); + budget.exit_container(); + } + }, + EncodeFrame::Map { + entries, + pending_value, + } => { + if let Some(value) = pending_value.take() { + next = Some(value); + } else if let Some((key, value)) = entries.next() { + *pending_value = Some(value); + next = Some(key); + } else { + frames.pop(); + budget.exit_container(); + } + } + } + } +} + +fn encode_leaf_or_push<'v, E: Encoder>( + value: &'v Value, + encoder: &mut E, + budget: &mut CodecBudget<'_>, + frames: &mut Vec>, +) -> Result<(), BoundedEncodeError> { + match value { + Value::Array(items) => { + budget.enter_container()?; + budget.claim_elements(items.len() as u64)?; + VALUE_ARRAY_VARIANT.encode(encoder)?; + (items.len() as u64).encode(encoder)?; + frames.push(EncodeFrame::Array(items.iter())); + } + Value::Map(entries) => { + budget.enter_container()?; + budget.claim_elements((entries.len() as u64).saturating_mul(2))?; + VALUE_MAP_VARIANT.encode(encoder)?; + (entries.len() as u64).encode(encoder)?; + frames.push(EncodeFrame::Map { + entries: entries.iter(), + pending_value: None, + }); + } + Value::EnumString(strings) => { + budget.claim_elements(strings.len() as u64)?; + value.encode(encoder)?; + } + // Listed one by one on purpose: this match is where bounded encoding + // decides what counts as a leaf. A new `Value` variant fails to + // compile here until someone decides whether it nests or is counted. + Value::U128(_) + | Value::I128(_) + | Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + | Value::Bytes(_) + | Value::Bytes20(_) + | Value::Bytes32(_) + | Value::Bytes36(_) + | Value::EnumU8(_) + | Value::Identifier(_) + | Value::Float(_) + | Value::Text(_) + | Value::Bool(_) + | Value::Null => value.encode(encoder)?, + } + Ok(()) +} + +impl Value { + /// Decodes one value from exactly `bytes` under explicit `bounds`. + /// + /// Rejects input longer than `max_bytes` before reading, nesting deeper + /// than `max_depth` and more container entries than `max_elements` at the + /// container header, any declared length the unread input cannot contain + /// before allocating, and trailing bytes after the value. + /// + /// Accepted inputs decode to the same value as the plain [`bincode::Decode`] + /// impl with the native big-endian configuration. + /// + /// Decoding itself never recurses, but the returned tree is as deep as + /// `max_depth` allows and `Value`'s `Drop`, `Clone`, `PartialEq` and + /// derived `Encode` walk it recursively, as they do for every value in + /// this crate. That holds for the value returned on success and for the + /// partially built value discarded on rejection alike, so `max_depth` is + /// what protects the stack and must be sized for the target: the native + /// document limit of 256 is comfortably within a small thread stack; see + /// [`CodecBounds::max_depth`]. + pub fn decode_bounded(bytes: &[u8], bounds: &CodecBounds) -> Result { + bounded_decode_from_slice(bytes, bounds, |decoder, budget| { + decode_value_with(decoder, &mut BudgetedLeaves { budget }) + }) + } + + /// Encodes the value with the canonical configuration under explicit + /// `bounds`. + /// + /// Depth and element counts are checked during a size pass whose only + /// allocation is the traversal stack (one frame per open container, so at + /// most `max_depth` frames); the output is then refused if it would exceed + /// `max_bytes`, and only afterwards is the output buffer allocated, sized + /// exactly once. The bytes are identical to the derived + /// [`bincode::Encode`] output. + pub fn encode_bounded(&self, bounds: &CodecBounds) -> Result, BoundedEncodeError> { + let size = { + let mut budget = CodecBudget::new(bounds); + let mut sizer = EncoderImpl::new(SizeWriter::default(), canonical_config()); + encode_value_with(self, &mut sizer, &mut budget)?; + sizer.into_writer().bytes_written + }; + CodecBudget::new(bounds).check_len(size)?; + + let mut budget = CodecBudget::new(bounds); + let mut encoder = EncoderImpl::new(ExactWriter::with_capacity(size), canonical_config()); + encode_value_with(self, &mut encoder, &mut budget)?; + Ok(encoder.into_writer().bytes) + } +} + +/// A writer into a `Vec` sized once, up front, from the size pass. +struct ExactWriter { + bytes: Vec, +} + +impl ExactWriter { + fn with_capacity(capacity: usize) -> Self { + Self { + bytes: Vec::with_capacity(capacity), + } + } +} + +impl Writer for ExactWriter { + #[inline] + fn write(&mut self, bytes: &[u8]) -> Result<(), EncodeError> { + self.bytes.extend_from_slice(bytes); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform_value; + use bincode::error::DecodeError; + + const BOUNDS: CodecBounds = CodecBounds { + max_bytes: 128, + max_depth: 4, + max_elements: 16, + }; + + fn native_encode(value: &Value) -> Vec { + bincode::encode_to_vec(value, canonical_config()).expect("native encode") + } + + fn native_decode(bytes: &[u8]) -> Value { + let (value, consumed): (Value, usize) = + bincode::decode_from_slice(bytes, canonical_config()).expect("native decode"); + assert_eq!(consumed, bytes.len()); + value + } + + fn sample_values() -> Vec { + vec![ + Value::Null, + Value::Bool(true), + Value::U8(250), + Value::I8(-128), + Value::U16(251), + Value::U32(70_000), + Value::U64(u64::MAX), + Value::I64(i64::MIN), + Value::U128(u128::MAX), + Value::I128(i128::MIN), + Value::Float(-0.0), + Value::Bytes(vec![]), + Value::Bytes(vec![1, 2, 3]), + Value::Bytes20([7; 20]), + Value::Bytes32([8; 32]), + Value::Bytes36([9; 36]), + Value::EnumU8(vec![1, 2]), + Value::EnumString(vec!["a".into(), "bc".into()]), + Value::Identifier([3; 32]), + Value::Text("héllo".into()), + Value::Array(vec![]), + Value::Map(vec![]), + platform_value!({ "a": [1, { "b": null }], "c": "d" }), + Value::Array(vec![Value::Array(vec![Value::Array(vec![Value::Null])])]), + ] + } + + #[test] + fn should_encode_identically_to_the_derived_encode_impl() { + for value in sample_values() { + let bounded = value.encode_bounded(&BOUNDS).expect("within bounds"); + assert_eq!(bounded, native_encode(&value), "{value:?}"); + } + } + + #[test] + fn should_decode_identically_to_the_native_decode_impl() { + for value in sample_values() { + let bytes = native_encode(&value); + let bounded = Value::decode_bounded(&bytes, &BOUNDS).expect("within bounds"); + assert_eq!(bounded, native_decode(&bytes)); + assert_eq!(bounded, value); + } + } + + #[test] + fn should_accept_depth_exactly_at_the_bound_and_reject_one_more() { + let mut value = Value::Null; + for _ in 0..4 { + value = Value::Array(vec![value]); + } + let bytes = native_encode(&value); + assert_eq!(Value::decode_bounded(&bytes, &BOUNDS).unwrap(), value); + assert_eq!(value.encode_bounded(&BOUNDS).unwrap(), bytes); + + let deeper = Value::Array(vec![value]); + let bytes = native_encode(&deeper); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds(BoundsError::DepthExceeded { + depth: 5, + max: 4 + })) + )); + assert!(matches!( + deeper.encode_bounded(&BOUNDS), + Err(BoundedEncodeError::Bounds(BoundsError::DepthExceeded { + depth: 5, + max: 4 + })) + )); + } + + #[test] + fn should_count_map_entries_twice_and_reject_one_element_over_the_bound() { + // 8 entries = 16 elements, exactly the bound. + let entries: ValueMap = (0..8).map(|i| (Value::U8(i), Value::Null)).collect(); + let value = Value::Map(entries.clone()); + let bytes = native_encode(&value); + assert_eq!(Value::decode_bounded(&bytes, &BOUNDS).unwrap(), value); + + let mut over = entries; + over.push((Value::U8(8), Value::Null)); + let over = Value::Map(over); + let bytes = native_encode(&over); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds(BoundsError::ElementsExceeded { + elements: 18, + max: 16 + })) + )); + assert!(matches!( + over.encode_bounded(&BOUNDS), + Err(BoundedEncodeError::Bounds(BoundsError::ElementsExceeded { + elements: 18, + max: 16 + })) + )); + } + + #[test] + fn should_charge_string_list_entries_to_the_element_budget() { + let value = Value::Array(vec![Value::EnumString(vec![String::new(); 16])]); + let bytes = native_encode(&value); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds(BoundsError::ElementsExceeded { + elements: 17, + max: 16 + })) + )); + assert!(matches!( + value.encode_bounded(&BOUNDS), + Err(BoundedEncodeError::Bounds(BoundsError::ElementsExceeded { + elements: 17, + max: 16 + })) + )); + } + + #[test] + fn should_reject_output_over_max_bytes_without_allocating_it() { + let value = Value::Bytes(vec![0; 200]); + assert!(matches!( + value.encode_bounded(&BOUNDS), + Err(BoundedEncodeError::Bounds(BoundsError::BytesExceeded { + len: 202, + max: 128 + })) + )); + let bytes = native_encode(&value); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds(BoundsError::BytesExceeded { + len: 202, + max: 128 + })) + )); + } + + #[test] + fn should_reject_declared_array_length_beyond_the_input() { + // Array header claiming 100 items, then nothing. + let bytes = [21u8, 100]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: 100, + remaining: 0 + } + )) + )); + } + + #[test] + fn should_reject_declared_map_length_beyond_the_input() { + let bytes = [22u8, 3, 20, 20]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: 3, + remaining: 2 + } + )) + )); + } + + #[test] + fn should_reject_byte_leaves_declaring_beyond_the_input() { + // Bytes claiming u64::MAX with no payload. + let bytes = [10u8, 253, 255, 255, 255, 255, 255, 255, 255, 255]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: u64::MAX, + remaining: 0 + } + )) + )); + // EnumU8 claiming 2^32 with three payload bytes. + let bytes = [14u8, 253, 0, 0, 0, 1, 0, 0, 0, 0, 1, 2, 3]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: 4_294_967_296, + remaining: 3 + } + )) + )); + // Text claiming 5 with 4 bytes present. + let bytes = [18u8, 5, b'a', b'b', b'c', b'd']; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: 5, + remaining: 4 + } + )) + )); + } + + #[test] + fn should_reject_string_list_counts_and_inner_lengths_beyond_the_input() { + let bytes = [15u8, 9, 0, 0]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: 9, + remaining: 2 + } + )) + )); + let bytes = [15u8, 1, 7, b'x']; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: 7, + remaining: 1 + } + )) + )); + } + + /// Bounds at the native document depth limit, the value the provisional + /// ABI bounds carry. + const DOCUMENT_DEPTH_BOUNDS: CodecBounds = CodecBounds { + max_bytes: 4096, + max_depth: 256, + max_elements: 1024, + }; + + /// Runs `body` on a thread with a deliberately small stack so a recursion + /// proportional to the nesting depth shows up as an overflow instead of + /// hiding behind the test harness's large main-thread stack. + fn on_small_stack(body: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn(body) + .expect("spawn") + .join() + .expect("the small-stack thread must not overflow") + } + + fn nested_arrays(depth: usize) -> Value { + (0..depth).fold(Value::Null, |inner, _| Value::Array(vec![inner])) + } + + #[test] + fn should_decode_and_drop_a_value_at_the_document_depth_limit_on_a_small_stack() { + let bytes = native_encode(&nested_arrays(256)); + let depth = on_small_stack(move || { + let value = + Value::decode_bounded(&bytes, &DOCUMENT_DEPTH_BOUNDS).expect("at the limit"); + value.first_depth_exceeding(0) + }); + assert_eq!(depth, Some(1)); + } + + #[test] + fn should_reject_trailing_bytes_after_a_value_at_the_document_depth_limit_on_a_small_stack() { + let mut bytes = native_encode(&nested_arrays(256)); + bytes.push(0); + let result = on_small_stack(move || Value::decode_bounded(&bytes, &DOCUMENT_DEPTH_BOUNDS)); + assert!(matches!( + result, + Err(BoundedDecodeError::TrailingBytes { remaining: 1 }) + )); + } + + #[test] + fn should_reject_a_malformed_sibling_of_a_completed_deep_subtree_on_a_small_stack() { + // An outer array of two items: a completed subtree that reaches the + // depth limit, then an unknown variant. The completed subtree sits in + // the outer frame when the error is returned and is discarded there. + let mut bytes = vec![21u8, 2]; + bytes.extend(native_encode(&nested_arrays(255))); + bytes.push(23); + let result = on_small_stack(move || Value::decode_bounded(&bytes, &DOCUMENT_DEPTH_BOUNDS)); + assert!(matches!( + result, + Err(BoundedDecodeError::Decode(DecodeError::UnexpectedVariant { + found: 23, + .. + })) + )); + } + + #[test] + fn should_reject_trailing_bytes_and_unknown_variants() { + let mut bytes = native_encode(&Value::Null); + bytes.push(0); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::TrailingBytes { remaining: 1 }) + )); + assert!(matches!( + Value::decode_bounded(&[23u8], &BOUNDS), + Err(BoundedDecodeError::Decode(DecodeError::UnexpectedVariant { + found: 23, + .. + })) + )); + } + + #[test] + fn should_accept_overlong_varints_so_canonicality_needs_a_re_encode_check() { + // `U16(0)` as the minimal bytes and as two overlong forms: one on the + // payload, one on the variant index. All decode; only the minimal form + // survives a re-encode comparison, which is the ABI layer's job. + let minimal = [6u8, 0]; + let overlong_payload = [6u8, 251, 0, 0]; + let overlong_variant = [251u8, 0, 6, 0]; + for bytes in [&minimal[..], &overlong_payload[..], &overlong_variant[..]] { + assert_eq!( + Value::decode_bounded(bytes, &BOUNDS).unwrap(), + Value::U16(0) + ); + } + assert_eq!(Value::U16(0).encode_bounded(&BOUNDS).unwrap(), minimal); + } + + #[test] + fn should_reject_invalid_utf8_text_after_the_bounded_read() { + let bytes = [18u8, 2, 0xff, 0xfe]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Decode(DecodeError::Utf8 { .. })) + )); + } + + #[test] + fn should_encode_container_map_keys_like_the_derived_impl() { + let value = Value::Map(vec![ + ( + Value::Array(vec![Value::U8(1)]), + Value::Map(vec![(Value::Null, Value::Null)]), + ), + (Value::Text("k".into()), Value::Array(vec![])), + ]); + let bytes = native_encode(&value); + assert_eq!(value.encode_bounded(&BOUNDS).unwrap(), bytes); + assert_eq!(Value::decode_bounded(&bytes, &BOUNDS).unwrap(), value); + } + + #[test] + fn should_release_depth_when_a_container_closes() { + // Two sibling containers at depth 4 must both be accepted: depth is + // released on close, not accumulated. + let leaf = Value::Array(vec![Value::Array(vec![Value::Array(vec![Value::Null])])]); + let value = Value::Array(vec![leaf.clone(), leaf]); + let bytes = native_encode(&value); + assert_eq!(Value::decode_bounded(&bytes, &BOUNDS).unwrap(), value); + assert_eq!(value.encode_bounded(&BOUNDS).unwrap(), bytes); + } +} diff --git a/packages/rs-platform-value/src/index.rs b/packages/rs-platform-value/src/index.rs index a39ccdd8e92..f57372b0f68 100644 --- a/packages/rs-platform-value/src/index.rs +++ b/packages/rs-platform-value/src/index.rs @@ -1,3 +1,4 @@ +use alloc::string::String; use core::fmt::{self, Display}; use core::ops; @@ -132,6 +133,8 @@ where // Prevent users from implementing the Index trait. mod private { + use alloc::string::String; + pub trait Sealed {} impl Sealed for usize {} impl Sealed for str {} diff --git a/packages/rs-platform-value/src/inner_value.rs b/packages/rs-platform-value/src/inner_value.rs index 24d4c35359d..f03a62f668d 100644 --- a/packages/rs-platform-value/src/inner_value.rs +++ b/packages/rs-platform-value/src/inner_value.rs @@ -1,9 +1,12 @@ use crate::value_map::{ValueMap, ValueMapHelper}; use crate::{BinaryData, Bytes32, Identifier}; use crate::{Error, Value}; +use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::cmp::Ordering; +#[cfg(feature = "std")] use indexmap::IndexMap; -use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; impl Value { pub fn has(&self, key: &str) -> Result { @@ -647,6 +650,7 @@ impl Value { } /// Gets the inner index map sorted by a specified property + #[cfg(feature = "std")] pub fn inner_optional_index_map<'a, T>( document_type: &'a [(Value, Value)], key: &'a str, diff --git a/packages/rs-platform-value/src/inner_value_at_path.rs b/packages/rs-platform-value/src/inner_value_at_path.rs index 2c5508d0b24..249400e0eaa 100644 --- a/packages/rs-platform-value/src/inner_value_at_path.rs +++ b/packages/rs-platform-value/src/inner_value_at_path.rs @@ -1,6 +1,9 @@ use crate::value_map::ValueMapHelper; use crate::{error, Error, Value, ValueMap}; -use std::collections::BTreeMap; +use alloc::collections::BTreeMap; +use alloc::string::ToString; +use alloc::vec::Vec; +use core::cmp::Ordering; pub(crate) fn is_array_path(text: &str) -> Result)>, Error> { // 1. Find the last '[' character. @@ -342,16 +345,16 @@ impl Value { }; // We are setting the value of just member of the array match number_part.cmp(&array.len()) { - std::cmp::Ordering::Less => { + Ordering::Less => { //this already exists current_value = array.get_mut(number_part).unwrap(); } - std::cmp::Ordering::Equal => { + Ordering::Equal => { //we should create a new map array.push(Value::Map(ValueMap::new())); current_value = array.get_mut(number_part).unwrap(); } - std::cmp::Ordering::Greater => { + Ordering::Greater => { return Err(Error::StructureError( "trying to insert into an array path higher than current array length" .to_string(), diff --git a/packages/rs-platform-value/src/lib.rs b/packages/rs-platform-value/src/lib.rs index 8239d58dbbb..6d1f53f3b10 100644 --- a/packages/rs-platform-value/src/lib.rs +++ b/packages/rs-platform-value/src/lib.rs @@ -5,13 +5,19 @@ //! Forked from ciborium value //! //! -extern crate core; +#![cfg_attr(not(any(test, feature = "std")), no_std)] + +#[macro_use] +extern crate alloc; +#[cfg(any(test, feature = "std"))] +extern crate std; pub mod btreemap_extensions; pub mod converter; pub mod display; mod eq; mod error; +pub mod guest_bounds; mod index; mod inner_array_value; pub mod inner_value; @@ -27,8 +33,13 @@ mod value_map; mod value_serialization; pub use crate::value_map::{ValueMap, ValueMapHelper}; +use alloc::borrow::ToOwned; +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::any; +use core::mem; pub use error::Error; -use std::collections::BTreeMap; pub type Hash256 = [u8; 32]; @@ -43,14 +54,21 @@ pub use types::identifier::{Identifier, IdentifierBytes32, IDENTIFIER_MEDIA_TYPE pub use value_serialization::{from_value, to_value}; -use bincode::de::Decoder; +use bincode::de::{Decoder, UntrustedDecoder}; use bincode::error::{AllowedEnumVariants, DecodeError}; -use bincode::{Decode, Encode}; +use bincode::{Decode, DecodeUntrusted, Encode}; pub use patch::{patch, Patch}; +/// Items the `platform_value!` macro expands to. Not part of the public API. +#[doc(hidden)] +pub mod __private { + pub use alloc::vec; +} + /// The defensive nesting limit used when decoding a [`Value`] without an explicit scope. pub const DEFAULT_MAX_VALUE_DECODE_DEPTH: usize = 256; +#[cfg(feature = "std")] std::thread_local! { static VALUE_DECODE_DEPTH_LIMIT: std::cell::Cell> = const { std::cell::Cell::new(Some(DEFAULT_MAX_VALUE_DECODE_DEPTH)) }; @@ -61,6 +79,11 @@ std::thread_local! { /// This is used by version-aware protocol decoders so historical versions can retain their /// original behavior while current versions reject excessive nesting before constructing a /// recursive value tree. +/// +/// Only the native profile has this thread-local scope. The allocation-only profile decodes +/// with [`DEFAULT_MAX_VALUE_DECODE_DEPTH`] through the plain [`Decode`] impl and takes explicit +/// bounds through [`Value::decode_bounded`]. +#[cfg(feature = "std")] pub fn with_value_decode_depth_limit(max_depth: Option, decode: impl FnOnce() -> T) -> T { struct RestoreDepthLimit(Option); @@ -151,6 +174,11 @@ pub enum Value { Map(ValueMap), } +/// The variant index of [`Value::Array`] on the wire. Fixed by the derived `Encode`. +pub(crate) const VALUE_ARRAY_VARIANT: u32 = 21; +/// The variant index of [`Value::Map`] on the wire. Fixed by the derived `Encode`. +pub(crate) const VALUE_MAP_VARIANT: u32 = 22; + enum ValueDecodeFrame { Array { values: Vec, @@ -163,6 +191,52 @@ enum ValueDecodeFrame { }, } +/// Reads the variable-length parts of a [`Value`] on behalf of the shared iterative decoder. +/// +/// The decoder state machine in [`decode_value_with`] owns the wire grammar: variant indices, +/// frame bookkeeping and bincode's own byte accounting. Everything that allocates from a decoded +/// length goes through this trait, so the native path can keep bincode's allocating decoders and +/// the guest path can route every length through an explicit budget instead. +pub(crate) trait ValueLeafReader { + /// The error the leaf reader produces; the state machine's own errors are `DecodeError`. + type Error: From; + + /// Reads an array length header. `depth` counts the array being entered, so the outermost + /// container is depth 1. Returns the length and the storage the elements are pushed into. + fn array_header( + &mut self, + decoder: &mut D, + depth: usize, + ) -> Result<(usize, Vec), Self::Error>; + + /// Reads a map length header; see [`ValueLeafReader::array_header`]. + fn map_header( + &mut self, + decoder: &mut D, + depth: usize, + ) -> Result<(usize, ValueMap), Self::Error>; + + /// Called once for every container whose header was read, when it is complete. + fn container_end(&mut self); + + /// Called before each element is pushed into container storage. Readers that pre-size their + /// storage from the declared length need nothing here; readers that start containers empty + /// grow them one fallible reservation at a time so a hostile length cannot force a large + /// allocation. + fn reserve_element(&mut self, _storage: &mut Vec) -> Result<(), Self::Error> { + Ok(()) + } + + /// Reads the payload of [`Value::Bytes`] or [`Value::EnumU8`]. + fn bytes(&mut self, decoder: &mut D) -> Result, Self::Error>; + + /// Reads the payload of [`Value::Text`]. + fn text(&mut self, decoder: &mut D) -> Result; + + /// Reads the payload of [`Value::EnumString`]. + fn string_list(&mut self, decoder: &mut D) -> Result, Self::Error>; +} + fn decode_value_container_len(decoder: &mut D) -> Result where D: Decoder, @@ -172,163 +246,269 @@ where .map_err(|_| DecodeError::OutsideUsizeRange(len)) } -fn validate_value_decode_depth(depth: usize) -> Result<(), DecodeError> { - VALUE_DECODE_DEPTH_LIMIT.with(|limit| match limit.get() { +fn check_value_decode_depth(depth: usize, limit: Option) -> Result<(), DecodeError> { + match limit { Some(max_depth) if depth > max_depth => Err(DecodeError::OtherString(format!( "value nesting depth {depth} exceeds maximum {max_depth}" ))), _ => Ok(()), - }) + } } -// Share the wire schema and domain checks across both decoding APIs. -macro_rules! impl_value_decode { - ($decode:ident, $decoder:ident, $method:ident, $untrusted:expr) => { - impl bincode::$decode for Value { - fn $method>( - decoder: &mut D, - ) -> Result { - let mut frames = Vec::::new(); - let mut completed_value = None; - - loop { - if let Some(value) = completed_value.take() { - let Some(frame) = frames.last_mut() else { - return Ok(value); - }; +#[cfg(feature = "std")] +fn validate_value_decode_depth(depth: usize) -> Result<(), DecodeError> { + VALUE_DECODE_DEPTH_LIMIT.with(|limit| check_value_decode_depth(depth, limit.get())) +} - match frame { - ValueDecodeFrame::Array { values, remaining } => { - if $untrusted { - values - .try_reserve(1) - .map_err(|_| DecodeError::LimitExceeded)?; - } - values.push(value); - *remaining -= 1; - - if *remaining == 0 { - let ValueDecodeFrame::Array { values, .. } = - frames.pop().expect("the array frame was just observed") - else { - unreachable!("the observed frame changed") - }; - completed_value = Some(Value::Array(values)); - } else { - decoder.unclaim_bytes_read(std::mem::size_of::()); - } - } - ValueDecodeFrame::Map { - entries, - remaining, - pending_key, - } => { - if pending_key.is_none() { - *pending_key = Some(value); - } else { - let key = pending_key - .take() - .expect("the map frame was expecting a value"); - if $untrusted { - entries - .try_reserve(1) - .map_err(|_| DecodeError::LimitExceeded)?; - } - entries.push((key, value)); - *remaining -= 1; - - if *remaining == 0 { - let ValueDecodeFrame::Map { entries, .. } = - frames.pop().expect("the map frame was just observed") - else { - unreachable!("the observed frame changed") - }; - completed_value = Some(Value::Map(entries)); - } else { - decoder.unclaim_bytes_read(std::mem::size_of::<( - Value, - Value, - )>( - )); - } - } - } - } +/// Without `std` there is no thread-local scope, so the plain [`Decode`] impl always applies +/// [`DEFAULT_MAX_VALUE_DECODE_DEPTH`]. Guests that need other limits use [`Value::decode_bounded`]. +#[cfg(not(feature = "std"))] +fn validate_value_decode_depth(depth: usize) -> Result<(), DecodeError> { + check_value_decode_depth(depth, Some(DEFAULT_MAX_VALUE_DECODE_DEPTH)) +} - continue; - } +/// The leaf reader behind the blanket [`Decode`] impl: bincode's own allocating decoders, the +/// historical depth limit, and pre-sized container storage. Shipped protocol versions decode +/// through this path, so its behaviour is frozen. +struct NativeLeaves; - let variant_index = >::$method(decoder)?; - completed_value = Some(match variant_index { - 0 => Value::U128(bincode::$decode::$method(decoder)?), - 1 => Value::I128(bincode::$decode::$method(decoder)?), - 2 => Value::U64(bincode::$decode::$method(decoder)?), - 3 => Value::I64(bincode::$decode::$method(decoder)?), - 4 => Value::U32(bincode::$decode::$method(decoder)?), - 5 => Value::I32(bincode::$decode::$method(decoder)?), - 6 => Value::U16(bincode::$decode::$method(decoder)?), - 7 => Value::I16(bincode::$decode::$method(decoder)?), - 8 => Value::U8(bincode::$decode::$method(decoder)?), - 9 => Value::I8(bincode::$decode::$method(decoder)?), - 10 => Value::Bytes(bincode::$decode::$method(decoder)?), - 11 => Value::Bytes20(bincode::$decode::$method(decoder)?), - 12 => Value::Bytes32(bincode::$decode::$method(decoder)?), - 13 => Value::Bytes36(bincode::$decode::$method(decoder)?), - 14 => Value::EnumU8(bincode::$decode::$method(decoder)?), - 15 => Value::EnumString(bincode::$decode::$method(decoder)?), - 16 => Value::Identifier(bincode::$decode::$method(decoder)?), - 17 => Value::Float(bincode::$decode::$method(decoder)?), - 18 => Value::Text(bincode::$decode::$method(decoder)?), - 19 => Value::Bool(bincode::$decode::$method(decoder)?), - 20 => Value::Null, - 21 => { - validate_value_decode_depth(frames.len() + 1)?; - let len = decode_value_container_len(decoder)?; - decoder.claim_container_read::(len)?; - - if len == 0 { - Value::Array(Vec::new()) - } else { - frames.push(ValueDecodeFrame::Array { - values: Vec::with_capacity(if $untrusted { 0 } else { len }), - remaining: len, - }); - decoder.unclaim_bytes_read(std::mem::size_of::()); - continue; - } - } - 22 => { - validate_value_decode_depth(frames.len() + 1)?; - let len = decode_value_container_len(decoder)?; - decoder.claim_container_read::<(Value, Value)>(len)?; - - if len == 0 { - Value::Map(Vec::new()) - } else { - frames.push(ValueDecodeFrame::Map { - entries: Vec::with_capacity(if $untrusted { 0 } else { len }), - remaining: len, - pending_key: None, - }); - decoder.unclaim_bytes_read(std::mem::size_of::<(Value, Value)>()); - continue; - } - } - found => { - return Err(DecodeError::UnexpectedVariant { - type_name: std::any::type_name::(), - allowed: &AllowedEnumVariants::Range { min: 0, max: 22 }, - found, - }); +impl ValueLeafReader for NativeLeaves { + type Error = DecodeError; + + fn array_header( + &mut self, + decoder: &mut D, + depth: usize, + ) -> Result<(usize, Vec), DecodeError> { + validate_value_decode_depth(depth)?; + let len = decode_value_container_len(decoder)?; + decoder.claim_container_read::(len)?; + Ok((len, Vec::with_capacity(len))) + } + + fn map_header( + &mut self, + decoder: &mut D, + depth: usize, + ) -> Result<(usize, ValueMap), DecodeError> { + validate_value_decode_depth(depth)?; + let len = decode_value_container_len(decoder)?; + decoder.claim_container_read::<(Value, Value)>(len)?; + Ok((len, Vec::with_capacity(len))) + } + + fn container_end(&mut self) {} + + fn bytes(&mut self, decoder: &mut D) -> Result, DecodeError> { + Decode::decode(decoder) + } + + fn text(&mut self, decoder: &mut D) -> Result { + Decode::decode(decoder) + } + + fn string_list(&mut self, decoder: &mut D) -> Result, DecodeError> { + Decode::decode(decoder) + } +} + +/// Decodes one [`Value`] without recursion. Containers are kept as an explicit frame stack whose +/// length is the current nesting depth; `leaves` reads every length-prefixed payload. +pub(crate) fn decode_value_with(decoder: &mut D, leaves: &mut L) -> Result +where + D: Decoder, + L: ValueLeafReader, +{ + let mut frames = Vec::::new(); + let mut completed_value = None; + + loop { + if let Some(value) = completed_value.take() { + let Some(frame) = frames.last_mut() else { + return Ok(value); + }; + + match frame { + ValueDecodeFrame::Array { values, remaining } => { + leaves.reserve_element(values)?; + values.push(value); + *remaining -= 1; + + if *remaining == 0 { + let ValueDecodeFrame::Array { values, .. } = + frames.pop().expect("the array frame was just observed") + else { + unreachable!("the observed frame changed") + }; + leaves.container_end(); + completed_value = Some(Value::Array(values)); + } else { + decoder.unclaim_bytes_read(mem::size_of::()); + } + } + ValueDecodeFrame::Map { + entries, + remaining, + pending_key, + } => { + if pending_key.is_none() { + *pending_key = Some(value); + } else { + let key = pending_key + .take() + .expect("the map frame was expecting a value"); + leaves.reserve_element(entries)?; + entries.push((key, value)); + *remaining -= 1; + + if *remaining == 0 { + let ValueDecodeFrame::Map { entries, .. } = + frames.pop().expect("the map frame was just observed") + else { + unreachable!("the observed frame changed") + }; + leaves.container_end(); + completed_value = Some(Value::Map(entries)); + } else { + decoder.unclaim_bytes_read(mem::size_of::<(Value, Value)>()); } - }); + } } } + + continue; } - }; + + let variant_index = >::decode(decoder)?; + completed_value = Some(match variant_index { + 0 => Value::U128(Decode::decode(decoder)?), + 1 => Value::I128(Decode::decode(decoder)?), + 2 => Value::U64(Decode::decode(decoder)?), + 3 => Value::I64(Decode::decode(decoder)?), + 4 => Value::U32(Decode::decode(decoder)?), + 5 => Value::I32(Decode::decode(decoder)?), + 6 => Value::U16(Decode::decode(decoder)?), + 7 => Value::I16(Decode::decode(decoder)?), + 8 => Value::U8(Decode::decode(decoder)?), + 9 => Value::I8(Decode::decode(decoder)?), + 10 => Value::Bytes(leaves.bytes(decoder)?), + 11 => Value::Bytes20(Decode::decode(decoder)?), + 12 => Value::Bytes32(Decode::decode(decoder)?), + 13 => Value::Bytes36(Decode::decode(decoder)?), + 14 => Value::EnumU8(leaves.bytes(decoder)?), + 15 => Value::EnumString(leaves.string_list(decoder)?), + 16 => Value::Identifier(Decode::decode(decoder)?), + 17 => Value::Float(Decode::decode(decoder)?), + 18 => Value::Text(leaves.text(decoder)?), + 19 => Value::Bool(Decode::decode(decoder)?), + 20 => Value::Null, + VALUE_ARRAY_VARIANT => { + let (len, values) = leaves.array_header(decoder, frames.len() + 1)?; + + if len == 0 { + leaves.container_end(); + Value::Array(values) + } else { + frames.push(ValueDecodeFrame::Array { + values, + remaining: len, + }); + decoder.unclaim_bytes_read(mem::size_of::()); + continue; + } + } + VALUE_MAP_VARIANT => { + let (len, entries) = leaves.map_header(decoder, frames.len() + 1)?; + + if len == 0 { + leaves.container_end(); + Value::Map(entries) + } else { + frames.push(ValueDecodeFrame::Map { + entries, + remaining: len, + pending_key: None, + }); + decoder.unclaim_bytes_read(mem::size_of::<(Value, Value)>()); + continue; + } + } + found => { + return Err(DecodeError::UnexpectedVariant { + type_name: any::type_name::(), + allowed: &AllowedEnumVariants::Range { min: 0, max: 22 }, + found, + } + .into()); + } + }); + } +} + +impl Decode for Value { + fn decode>(decoder: &mut D) -> Result { + decode_value_with(decoder, &mut NativeLeaves) + } +} + +/// The leaf reader behind the [`DecodeUntrusted`] impl. Same wire grammar and depth limit as +/// [`NativeLeaves`], but nothing is sized from a declared length: containers start empty and grow +/// one fallible reservation at a time, and byte leaves go through bincode's own untrusted decoders. +struct UntrustedLeaves; + +impl ValueLeafReader for UntrustedLeaves { + type Error = DecodeError; + + fn array_header( + &mut self, + decoder: &mut D, + depth: usize, + ) -> Result<(usize, Vec), DecodeError> { + validate_value_decode_depth(depth)?; + let len = decode_value_container_len(decoder)?; + decoder.claim_container_read::(len)?; + Ok((len, Vec::new())) + } + + fn map_header( + &mut self, + decoder: &mut D, + depth: usize, + ) -> Result<(usize, ValueMap), DecodeError> { + validate_value_decode_depth(depth)?; + let len = decode_value_container_len(decoder)?; + decoder.claim_container_read::<(Value, Value)>(len)?; + Ok((len, Vec::new())) + } + + fn container_end(&mut self) {} + + fn reserve_element(&mut self, storage: &mut Vec) -> Result<(), DecodeError> { + storage + .try_reserve(1) + .map_err(|_| DecodeError::LimitExceeded) + } + + fn bytes(&mut self, decoder: &mut D) -> Result, DecodeError> { + DecodeUntrusted::decode_untrusted(decoder) + } + + fn text(&mut self, decoder: &mut D) -> Result { + DecodeUntrusted::decode_untrusted(decoder) + } + + fn string_list(&mut self, decoder: &mut D) -> Result, DecodeError> { + DecodeUntrusted::decode_untrusted(decoder) + } +} + +impl DecodeUntrusted for Value { + fn decode_untrusted>( + decoder: &mut D, + ) -> Result { + decode_value_with(decoder, &mut UntrustedLeaves) + } } -impl_value_decode!(Decode, Decoder, decode, false); -impl_value_decode!(DecodeUntrusted, UntrustedDecoder, decode_untrusted, true); bincode::impl_borrow_decode_untrusted!(Value); bincode::impl_borrow_decode!(Value); diff --git a/packages/rs-platform-value/src/macros.rs b/packages/rs-platform-value/src/macros.rs index 1023ba33849..19910cd1559 100644 --- a/packages/rs-platform-value/src/macros.rs +++ b/packages/rs-platform-value/src/macros.rs @@ -276,12 +276,14 @@ macro_rules! platform_value_internal { // The platform_value_internal macro above cannot invoke vec directly because it uses // local_inner_macros. A vec invocation there would resolve to $crate::vec. -// Instead invoke vec here outside of local_inner_macros. +// Instead invoke vec here outside of local_inner_macros, through the crate's own +// re-export so the expansion also works inside a `#![no_std]` guest that has no +// `vec!` in its prelude. #[macro_export] #[doc(hidden)] macro_rules! platform_value_internal_vec { ($($content:tt)*) => { - vec![$($content)*] + $crate::__private::vec![$($content)*] }; } diff --git a/packages/rs-platform-value/src/patch/mod.rs b/packages/rs-platform-value/src/patch/mod.rs index f3dd7c7c5cf..c5274c51551 100644 --- a/packages/rs-platform-value/src/patch/mod.rs +++ b/packages/rs-platform-value/src/patch/mod.rs @@ -66,19 +66,25 @@ //! # } //! ``` +#[cfg(feature = "std")] pub use self::diff::diff; use crate::value_map::ValueMap; use crate::{Value, ValueMapHelper}; +use alloc::borrow::{Cow, ToOwned}; +use alloc::string::String; +use alloc::vec::Vec; +use core::mem; +use core::ops::Deref; use serde::{Deserialize, Serialize}; -use std::borrow::Cow; use thiserror::Error; +#[cfg(feature = "std")] mod diff; /// Representation of Platform Value Patch (list of patch operations) #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct Patch(pub Vec); -impl std::ops::Deref for Patch { +impl Deref for Patch { type Target = [PatchOperation]; fn deref(&self) -> &[PatchOperation] { @@ -222,7 +228,7 @@ fn split_pointer(pointer: &str) -> Result<(&str, &str), PatchErrorKind> { fn add(doc: &mut Value, path: &str, value: Value) -> Result, PatchErrorKind> { if path.is_empty() { - return Ok(Some(std::mem::replace(doc, value))); + return Ok(Some(mem::replace(doc, value))); } let (parent, last_unescaped) = split_pointer(path)?; @@ -273,7 +279,7 @@ fn replace(doc: &mut Value, path: &str, value: Value) -> Result Option { if s.starts_with('+') || (s.starts_with('0') && s.len() != 1) { diff --git a/packages/rs-platform-value/src/replace.rs b/packages/rs-platform-value/src/replace.rs index c3088f1d470..9bb0577196f 100644 --- a/packages/rs-platform-value/src/replace.rs +++ b/packages/rs-platform-value/src/replace.rs @@ -1,6 +1,8 @@ use crate::btreemap_extensions::btreemap_field_replacement::IntegerReplacementType; use crate::inner_value_at_path::is_array_path; use crate::{Error, ReplacementType, Value, ValueMapHelper}; +use alloc::vec::Vec; +#[cfg(feature = "std")] use std::collections::HashSet; impl Value { @@ -311,6 +313,7 @@ impl Value { .try_for_each(|path| self.replace_integer_type_at_path(path, replacement_type)) } + #[cfg(feature = "std")] /// `replace_to_binary_types_when_setting_with_path` will replace a value with a corresponding /// binary type (Identifier or Binary Data) if that data is in one of the given paths. /// Paths can either be terminal, or can represent an object or an array (with values) where @@ -382,6 +385,7 @@ impl Value { Ok(()) } + #[cfg(feature = "std")] /// `replace_to_binary_types_when_setting_with_path` will replace a value with a corresponding /// binary type (Identifier or Binary Data) if that data is in one of the given paths. /// Paths can either be terminal, or can represent an object or an array (with values) where @@ -890,6 +894,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_root_binary_types_identifier_exact_match() { let b58 = base58_of_32_bytes(2); let mut value = Value::Text(b58); @@ -910,6 +915,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_root_binary_types_binary_exact_match() { let b58 = base58_of_32_bytes(4); let mut value = Value::Text(b58); @@ -931,6 +937,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_root_binary_types_prefix_based() { let b58 = base58_of_32_bytes(6); let inner = Value::Map(vec![(Value::Text("sub_id".into()), Value::Text(b58))]); @@ -953,6 +960,7 @@ mod tests { } #[test] + #[cfg(feature = "std")] fn replace_root_binary_types_prefix_replaces_sub_path() { let b58 = base58_of_32_bytes(6); let inner = Value::Map(vec![(Value::Text("sub_id".into()), Value::Text(b58))]); @@ -978,6 +986,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_root_binary_types_no_match_returns_ok() { let mut value = Value::Map(vec![(Value::Text("a".into()), Value::U32(1))]); let result = value.replace_to_binary_types_of_root_value_when_setting_at_path( @@ -993,6 +1002,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_when_setting_with_path_identifier_exact() { let b58 = base58_of_32_bytes(11); let mut value = Value::Text(b58); @@ -1012,6 +1022,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_when_setting_with_path_strip_prefix() { let b58 = base58_of_32_bytes(15); let mut value = Value::Map(vec![(Value::Text("sub_id".into()), Value::Text(b58))]); @@ -1034,6 +1045,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_when_setting_with_path_binary_strip_prefix() { use base64::prelude::*; let raw = vec![1u8, 2, 3, 4, 5]; @@ -1055,6 +1067,7 @@ mod tests { // =============================================================== #[test] + #[cfg(feature = "std")] fn replace_when_setting_with_path_no_match_ok() { let mut value = Value::Map(vec![(Value::Text("a".into()), Value::U32(1))]); let result = value.replace_to_binary_types_when_setting_with_path( diff --git a/packages/rs-platform-value/src/string_encoding.rs b/packages/rs-platform-value/src/string_encoding.rs index bce2c69fbbf..c52f9c4b4c8 100644 --- a/packages/rs-platform-value/src/string_encoding.rs +++ b/packages/rs-platform-value/src/string_encoding.rs @@ -1,9 +1,11 @@ use crate::Error; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use base64; use base64::prelude::BASE64_STANDARD; use base64::Engine; use bs58; -use std::fmt; +use core::fmt; #[derive(Debug, Copy, Clone)] pub enum Encoding { diff --git a/packages/rs-platform-value/src/system_bytes.rs b/packages/rs-platform-value/src/system_bytes.rs index 866af3eb0c2..3470bdafa2e 100644 --- a/packages/rs-platform-value/src/system_bytes.rs +++ b/packages/rs-platform-value/src/system_bytes.rs @@ -2,6 +2,9 @@ use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; use base64::{alphabet, Engine}; use crate::{BinaryData, Bytes20, Bytes32, Bytes36, Error, Identifier, Value}; +use alloc::borrow::ToOwned; +use alloc::string::ToString; +use alloc::vec::Vec; pub const PADDING_INDIFFERENT: GeneralPurposeConfig = GeneralPurposeConfig::new() .with_encode_padding(false) diff --git a/packages/rs-platform-value/src/types/binary_data.rs b/packages/rs-platform-value/src/types/binary_data.rs index 0d07ea5faff..808a74d6800 100644 --- a/packages/rs-platform-value/src/types/binary_data.rs +++ b/packages/rs-platform-value/src/types/binary_data.rs @@ -1,12 +1,14 @@ use crate::string_encoding::Encoding; use crate::types::encoding_string_to_encoding; use crate::{string_encoding, Error, Value}; +use alloc::string::String; +use alloc::vec::Vec; use base64::prelude::BASE64_STANDARD; use base64::Engine; use bincode::{Decode, DecodeUntrusted, Encode}; +use core::fmt; use serde::de::Visitor; use serde::{Deserialize, Serialize}; -use std::fmt; #[derive(Default, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Encode, Decode, DecodeUntrusted)] pub struct BinaryData(pub Vec); diff --git a/packages/rs-platform-value/src/types/bytes_20.rs b/packages/rs-platform-value/src/types/bytes_20.rs index 928fd3a6f33..092d77e28d3 100644 --- a/packages/rs-platform-value/src/types/bytes_20.rs +++ b/packages/rs-platform-value/src/types/bytes_20.rs @@ -1,12 +1,14 @@ use crate::string_encoding::Encoding; use crate::types::encoding_string_to_encoding; use crate::{string_encoding, Error, Value}; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use base64::prelude::BASE64_STANDARD; use base64::Engine; use bincode::{Decode, DecodeUntrusted, Encode}; +use core::fmt; use serde::de::Visitor; use serde::{Deserialize, Serialize}; -use std::fmt; #[derive( Default, @@ -29,8 +31,8 @@ impl AsRef<[u8]> for Bytes20 { &self.0 } } -impl std::fmt::Display for Bytes20 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for Bytes20 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.to_string(Encoding::Base58)) } } diff --git a/packages/rs-platform-value/src/types/bytes_32.rs b/packages/rs-platform-value/src/types/bytes_32.rs index 33d36bd84ff..1c089489506 100644 --- a/packages/rs-platform-value/src/types/bytes_32.rs +++ b/packages/rs-platform-value/src/types/bytes_32.rs @@ -1,14 +1,18 @@ use crate::string_encoding::Encoding; use crate::types::encoding_string_to_encoding; use crate::{string_encoding, Error, Value}; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use base64::prelude::BASE64_STANDARD; use base64::Engine; use bincode::{Decode, DecodeUntrusted, Encode}; +use core::fmt; +#[cfg(feature = "random")] use rand::rngs::StdRng; +#[cfg(feature = "random")] use rand::Rng; use serde::de::Visitor; use serde::{Deserialize, Serialize}; -use std::fmt; #[derive( Default, @@ -44,6 +48,7 @@ impl Bytes32 { Ok(Bytes32::new(buffer)) } + #[cfg(feature = "random")] pub fn random_with_rng(rng: &mut StdRng) -> Self { Bytes32(rng.gen()) } @@ -258,6 +263,7 @@ impl From<&Bytes32> for String { #[allow(clippy::needless_borrows_for_generic_args)] mod tests { use super::*; + #[cfg(feature = "random")] use rand::SeedableRng; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -652,6 +658,7 @@ mod tests { // --------------------------------------------------------------- #[test] + #[cfg(feature = "random")] fn random_with_rng_produces_non_zero() { let mut rng = StdRng::seed_from_u64(12345); let b = Bytes32::random_with_rng(&mut rng); @@ -660,6 +667,7 @@ mod tests { } #[test] + #[cfg(feature = "random")] fn random_with_rng_deterministic_with_same_seed() { let mut rng1 = StdRng::seed_from_u64(42); let mut rng2 = StdRng::seed_from_u64(42); @@ -669,6 +677,7 @@ mod tests { } #[test] + #[cfg(feature = "random")] fn random_with_rng_different_seeds_differ() { let mut rng1 = StdRng::seed_from_u64(1); let mut rng2 = StdRng::seed_from_u64(2); diff --git a/packages/rs-platform-value/src/types/bytes_36.rs b/packages/rs-platform-value/src/types/bytes_36.rs index f56c49fece2..54f0719e069 100644 --- a/packages/rs-platform-value/src/types/bytes_36.rs +++ b/packages/rs-platform-value/src/types/bytes_36.rs @@ -1,12 +1,14 @@ use crate::string_encoding::Encoding; use crate::types::encoding_string_to_encoding; use crate::{string_encoding, Error, Value}; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use base64::prelude::BASE64_STANDARD; use base64::Engine; use bincode::{Decode, DecodeUntrusted, Encode}; +use core::fmt; use serde::de::Visitor; use serde::{Deserialize, Serialize}; -use std::fmt; #[derive( Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Copy, Encode, Decode, DecodeUntrusted, diff --git a/packages/rs-platform-value/src/types/identifier.rs b/packages/rs-platform-value/src/types/identifier.rs index 1e37e578ca9..40cec746ff8 100644 --- a/packages/rs-platform-value/src/types/identifier.rs +++ b/packages/rs-platform-value/src/types/identifier.rs @@ -1,16 +1,28 @@ +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +#[cfg(feature = "platform-version")] use bincode::enc::Encoder; +#[cfg(feature = "platform-version")] use bincode::error::EncodeError; use bincode::{Decode, DecodeUntrusted, Encode}; +use core::convert::{TryFrom, TryInto}; +use core::fmt; +#[cfg(feature = "platform-version")] +use platform_serialization::{PlatformVersionEncode, PlatformVersionedDecode}; +#[cfg(feature = "platform-version")] +use platform_version::version::PlatformVersion; +#[cfg(feature = "random")] use rand::distributions::Standard; +#[cfg(feature = "random")] use rand::prelude::Distribution; +#[cfg(feature = "random")] use rand::rngs::StdRng; +#[cfg(feature = "random")] use rand::Rng; use serde::de::Visitor; use serde::{Deserialize, Serialize}; #[cfg(feature = "json")] use serde_json::Value as JsonValue; -use std::convert::{TryFrom, TryInto}; -use std::fmt; use crate::string_encoding::{Encoding, ALL_ENCODINGS}; use crate::types::encoding_string_to_encoding; @@ -52,6 +64,7 @@ pub struct IdentifierBytes32(pub [u8; 32]); )] pub struct Identifier(pub IdentifierBytes32); +#[cfg(feature = "random")] impl Distribution for Standard { fn sample(&self, rng: &mut R) -> Identifier { let bytes: [u8; 32] = rng.gen(); @@ -59,20 +72,22 @@ impl Distribution for Standard { } } -impl platform_serialization::PlatformVersionEncode for Identifier { +#[cfg(feature = "platform-version")] +impl PlatformVersionEncode for Identifier { fn platform_encode( &self, encoder: &mut E, - _: &platform_version::version::PlatformVersion, + _: &PlatformVersion, ) -> Result<(), EncodeError> { self.0 .0.encode(encoder) } } -impl platform_serialization::PlatformVersionedDecode for Identifier { +#[cfg(feature = "platform-version")] +impl PlatformVersionedDecode for Identifier { fn platform_versioned_decode( decoder: &mut D, - _platform_version: &platform_version::version::PlatformVersion, + _platform_version: &PlatformVersion, ) -> Result { let bytes = <[u8; 32]>::decode(decoder)?; Ok(Identifier::new(bytes)) @@ -201,10 +216,12 @@ impl Identifier { Identifier(IdentifierBytes32(buffer)) } + #[cfg(feature = "random")] pub fn random() -> Identifier { Identifier(IdentifierBytes32(rand::random::<[u8; 32]>())) } + #[cfg(feature = "random")] pub fn random_with_rng(rng: &mut StdRng) -> Identifier { Identifier(IdentifierBytes32(rng.gen())) } @@ -357,8 +374,8 @@ impl From<[u8; 32]> for Identifier { } } -impl std::fmt::Display for Identifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for Identifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.to_string(Encoding::Base58)) } } diff --git a/packages/rs-platform-value/src/value_map.rs b/packages/rs-platform-value/src/value_map.rs index c87e2f0cb8b..cf661f4f925 100644 --- a/packages/rs-platform-value/src/value_map.rs +++ b/packages/rs-platform-value/src/value_map.rs @@ -1,7 +1,10 @@ use crate::{Error, Value}; +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::cmp::Ordering; +#[cfg(feature = "std")] use indexmap::IndexMap; -use std::cmp::Ordering; -use std::collections::BTreeMap; pub type ValueMap = Vec<(Value, Value)>; @@ -427,6 +430,7 @@ mod tests { // --------------------------------------------------------------- #[test] + #[cfg(feature = "std")] fn map_ref_into_indexed_string_map_sorts_by_integer_key() { let map: ValueMap = vec![ ( @@ -448,6 +452,7 @@ mod tests { } #[test] + #[cfg(feature = "std")] fn map_ref_into_indexed_string_map_error_missing_sort_key() { let map: ValueMap = vec![( text("item"), @@ -758,6 +763,7 @@ impl Value { /// The index map is in the order sorted by the sort key /// The type T is the type of the value of the sort key /// Returns `Err(Error::Structure("reason"))` otherwise. + #[cfg(feature = "std")] pub fn map_ref_into_indexed_string_map<'a, T>( map: &'a ValueMap, sort_key: &str, diff --git a/packages/rs-platform-value/src/value_serialization/de.rs b/packages/rs-platform-value/src/value_serialization/de.rs index 325d2b28f8d..63dd56f8f6f 100644 --- a/packages/rs-platform-value/src/value_serialization/de.rs +++ b/packages/rs-platform-value/src/value_serialization/de.rs @@ -1,7 +1,9 @@ +use alloc::string::String; +use alloc::vec::Vec; use base64::prelude::BASE64_STANDARD; use base64::Engine; +use core::iter::Peekable; use core::{fmt, slice}; -use std::iter::Peekable; use serde::de::value::SeqDeserializer; use serde::de::{self, Deserializer as _, IntoDeserializer}; @@ -860,6 +862,7 @@ mod tests { } #[test] + #[cfg(feature = "std")] fn deserialize_map_type_mismatch_errors() { let val = Value::U32(42); let result: Result, Error> = from_value(val); @@ -935,6 +938,7 @@ mod tests { } #[test] + #[cfg(feature = "std")] fn round_trip_hashmap() { let mut original = std::collections::HashMap::new(); original.insert("a".to_string(), 1u32); diff --git a/packages/rs-platform-value/src/value_serialization/mod.rs b/packages/rs-platform-value/src/value_serialization/mod.rs index 4ca6aa4a78a..822cd5d5954 100644 --- a/packages/rs-platform-value/src/value_serialization/mod.rs +++ b/packages/rs-platform-value/src/value_serialization/mod.rs @@ -107,7 +107,8 @@ where T::deserialize(de::Deserializer(value)) } -#[cfg(test)] +// serde only implements its traits for `HashMap` with `std`. +#[cfg(all(test, feature = "std"))] #[allow(clippy::needless_borrows_for_generic_args)] mod tests { use serde::{Deserialize, Serialize}; diff --git a/packages/rs-platform-value/src/value_serialization/ser.rs b/packages/rs-platform-value/src/value_serialization/ser.rs index 146cb6d1084..69f714cc888 100644 --- a/packages/rs-platform-value/src/value_serialization/ser.rs +++ b/packages/rs-platform-value/src/value_serialization/ser.rs @@ -1,10 +1,13 @@ use crate::error::Error; use crate::value_map::ValueMap; use crate::{to_value, Value}; +use alloc::borrow::ToOwned; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use base64::prelude::BASE64_STANDARD; use base64::Engine; +use core::fmt::Display; use serde::ser::Serialize; -use std::fmt::Display; // We only use our own error type; no need for From conversions provided by the // standard library's try! macro. This reduces lines of LLVM IR by 4%. @@ -708,6 +711,7 @@ mod tests { // --------------------------------------------------------------- #[test] + #[cfg(feature = "std")] fn serialize_hashmap() { let mut map = std::collections::HashMap::new(); map.insert("key", 42u32); @@ -981,6 +985,7 @@ mod tests { // --------------------------------------------------------------- #[test] + #[cfg(feature = "std")] fn map_key_bool_now_supported() { let mut map = std::collections::HashMap::new(); map.insert(true, "value"); @@ -995,6 +1000,7 @@ mod tests { } #[test] + #[cfg(feature = "std")] fn map_key_string_works() { let mut map = std::collections::HashMap::new(); map.insert("key".to_string(), 42u32); @@ -1003,6 +1009,7 @@ mod tests { } #[test] + #[cfg(feature = "std")] fn map_key_integer_works() { let mut map = std::collections::HashMap::new(); map.insert(42u32, "value"); diff --git a/packages/rs-platform-value/tests/alloc_profile.rs b/packages/rs-platform-value/tests/alloc_profile.rs new file mode 100644 index 00000000000..049addbf906 --- /dev/null +++ b/packages/rs-platform-value/tests/alloc_profile.rs @@ -0,0 +1,199 @@ +//! Integration test for the allocation-only profile. +//! +//! Run with `cargo test -p platform-value --no-default-features --test alloc_profile`. +//! The test binary links `std` itself, but the library under test is built +//! without `std`, `random` and `platform-version`, so everything exercised here +//! is what a DashVM guest can reach. Under default features the same test runs +//! against the native profile, which proves the two profiles agree. + +use platform_serialization::bounded::{BoundedDecodeError, BoundsError, CodecBounds}; +use platform_value::guest_bounds::BoundedEncodeError; +use platform_value::{ + from_value, platform_value, to_value, Identifier, Value, DEFAULT_MAX_VALUE_DECODE_DEPTH, +}; +use serde::{Deserialize, Serialize}; + +const BOUNDS: CodecBounds = CodecBounds { + max_bytes: 1024, + max_depth: 8, + max_elements: 64, +}; + +fn native_config() -> bincode::config::Configuration { + bincode::config::standard().with_big_endian() +} + +fn native_encode(value: &Value) -> Vec { + bincode::encode_to_vec(value, native_config()).expect("native encode") +} + +fn native_decode(bytes: &[u8]) -> Result { + bincode::decode_from_slice::(bytes, native_config()).map(|(value, _)| value) +} + +fn nested_arrays(depth: usize) -> Value { + let mut value = Value::Null; + for _ in 0..depth { + value = Value::Array(vec![value]); + } + value +} + +#[test] +fn should_build_values_with_the_macro_and_round_trip_them_through_serde() { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Document { + owner: Identifier, + tags: Vec, + score: u32, + } + + let document = Document { + owner: Identifier::new([7; 32]), + tags: vec!["a".into(), "b".into()], + score: 3, + }; + let value = to_value(&document).expect("serialize"); + let expected = platform_value!({ + "owner": Identifier::new([7; 32]), + "tags": ["a", "b"], + "score": 3u32, + }); + assert_eq!(value, expected); + let recovered: Document = from_value(value).expect("deserialize"); + assert_eq!(recovered, document); +} + +#[test] +fn should_produce_native_bytes_from_the_bounded_encoder() { + let mut value = platform_value!({ + "id": Identifier::new([1; 32]), + "bytes": Value::Bytes(vec![1, 2, 3]), + "list": [1u8, 2u8, { "deep": null }], + "text": "héllo", + }); + // The serializer behind the macro has no enumeration support, so the + // enumeration leaves are attached by hand. + if let Value::Map(entries) = &mut value { + entries.push(( + Value::Text("enum".into()), + Value::EnumString(vec!["x".into()]), + )); + entries.push((Value::Text("enum_u8".into()), Value::EnumU8(vec![4, 5]))); + } + let bounded = value.encode_bounded(&BOUNDS).expect("within bounds"); + assert_eq!(bounded, native_encode(&value)); + assert_eq!(Value::decode_bounded(&bounded, &BOUNDS).unwrap(), value); + assert_eq!(native_decode(&bounded).unwrap(), value); +} + +#[test] +fn should_keep_the_identifier_encoding_unchanged() { + let id = Identifier::new([9; 32]); + let bytes = bincode::encode_to_vec(id, native_config()).expect("encode"); + assert_eq!(bytes, vec![9; 32]); + let (decoded, consumed): (Identifier, usize) = + bincode::decode_from_slice(&bytes, native_config()).expect("decode"); + assert_eq!(decoded, id); + assert_eq!(consumed, 32); +} + +#[test] +fn should_apply_the_default_depth_limit_through_the_plain_decode_impl() { + let at_limit = native_encode(&nested_arrays(DEFAULT_MAX_VALUE_DECODE_DEPTH)); + assert!(native_decode(&at_limit).is_ok()); + + let over = native_encode(&nested_arrays(DEFAULT_MAX_VALUE_DECODE_DEPTH + 1)); + let error = native_decode(&over).expect_err("one level over the limit"); + assert!(error + .to_string() + .contains("value nesting depth 257 exceeds maximum 256")); +} + +#[test] +fn should_reject_depth_over_the_explicit_bound_on_both_paths() { + let value = nested_arrays(9); + let bytes = native_encode(&value); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds(BoundsError::DepthExceeded { + depth: 9, + max: 8 + })) + )); + assert!(matches!( + value.encode_bounded(&BOUNDS), + Err(BoundedEncodeError::Bounds(BoundsError::DepthExceeded { + depth: 9, + max: 8 + })) + )); + let inside = nested_arrays(8); + let bytes = native_encode(&inside); + assert_eq!(Value::decode_bounded(&bytes, &BOUNDS).unwrap(), inside); +} + +#[test] +fn should_reject_declared_lengths_beyond_the_input_before_allocating() { + // Array header declaring 2^32 items with a three byte tail. + let bytes = [21u8, 253, 0, 0, 0, 1, 0, 0, 0, 0, 20, 20, 20]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: 4_294_967_296, + remaining: 3 + } + )) + )); + // Bytes declaring u64::MAX with no payload. + let bytes = [10u8, 253, 255, 255, 255, 255, 255, 255, 255, 255]; + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds( + BoundsError::DeclaredLengthExceedsInput { + declared: u64::MAX, + remaining: 0 + } + )) + )); +} + +#[test] +fn should_reject_input_over_max_bytes_elements_over_the_bound_and_trailing_bytes() { + // One variant byte, a three byte length prefix and the payload. + let big = Value::Bytes(vec![0; 2000]); + let bytes = native_encode(&big); + assert_eq!(bytes.len(), 2004); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds(BoundsError::BytesExceeded { + len: 2004, + max: 1024 + })) + )); + assert!(matches!( + big.encode_bounded(&BOUNDS), + Err(BoundedEncodeError::Bounds(BoundsError::BytesExceeded { + len: 2004, + max: 1024 + })) + )); + + let wide = Value::Array(vec![Value::Null; 65]); + let bytes = native_encode(&wide); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::Bounds(BoundsError::ElementsExceeded { + elements: 65, + max: 64 + })) + )); + + let mut bytes = native_encode(&Value::U8(1)); + bytes.push(0); + assert!(matches!( + Value::decode_bounded(&bytes, &BOUNDS), + Err(BoundedDecodeError::TrailingBytes { remaining: 1 }) + )); +} diff --git a/packages/rs-platform-value/tests/coverage_tests.rs b/packages/rs-platform-value/tests/coverage_tests.rs index 182467d53f0..c642264b96a 100644 --- a/packages/rs-platform-value/tests/coverage_tests.rs +++ b/packages/rs-platform-value/tests/coverage_tests.rs @@ -1,3 +1,7 @@ +// These tests exercise the native profile: patch diffing and the thread-local +// decode depth scope only exist with `std`. The allocation-only profile has its +// own integration test in `alloc_profile.rs`. +#![cfg(feature = "std")] #![allow(clippy::approx_constant)] #![allow(clippy::op_ref)] diff --git a/packages/rs-platform-value/tests/untrusted_decode.rs b/packages/rs-platform-value/tests/untrusted_decode.rs index 91ce1c0601b..f64674a19b4 100644 --- a/packages/rs-platform-value/tests/untrusted_decode.rs +++ b/packages/rs-platform-value/tests/untrusted_decode.rs @@ -1,4 +1,6 @@ use bincode::config; +use platform_serialization::bounded::{BoundsError, CodecBounds}; +use platform_value::guest_bounds::BoundedEncodeError; use platform_value::Value; use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; @@ -114,3 +116,31 @@ fn should_preserve_value_depth_and_budget_checks() { bincode::decode_from_slice_untrusted::(&bytes, config.with_limit::<8>()).is_err() ); } + +#[test] +fn should_reject_oversized_bounded_output_without_allocating() { + // Built outside the observation window: only `encode_bounded` is measured. + let value = Value::Bytes(vec![0; 200]); + let bounds = CodecBounds { + max_bytes: 128, + max_depth: 4, + max_elements: 16, + }; + + LARGEST_REQUEST.with(|largest| largest.set(0)); + OBSERVING.with(|enabled| enabled.set(true)); + let result = value.encode_bounded(&bounds); + OBSERVING.with(|enabled| enabled.set(false)); + let largest = LARGEST_REQUEST.with(Cell::get); + + assert!(matches!( + result, + Err(BoundedEncodeError::Bounds(BoundsError::BytesExceeded { + len: 202, + max: 128 + })) + )); + // A byte leaf needs no traversal frames, so the size pass touches the + // heap nowhere and the output buffer is never reserved. + assert_eq!(largest, 0, "rejection allocated {largest} bytes"); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0835736b9de..d68b1389c5a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,4 +2,7 @@ # Rust version the same as in /README.md channel = "1.98.1" -targets = ["wasm32-unknown-unknown"] +# wasm32v1-none has no std library at all: the guest alloc-only cut in CI +# uses it so any std leak in platform-value or platform-serialization is a +# compile error rather than a runtime surprise. +targets = ["wasm32-unknown-unknown", "wasm32v1-none"]