Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .markdownlint.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"MD013": false,
"MD033": {
"allowed_elements": ["u8", "br"]
"allowed_elements": ["u8", "br", "p", "img"]
},
"MD041": false,
"MD046": {
"style": "fenced"
}
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

## Unreleased

- Added `codec` module to `echo-wasm-abi`:
- Deterministic binary codec (`Reader`/`Writer`) for length-prefixed LE scalars
- Q32.32 fixed-point helpers (`fx_from_i64`, `fx_from_f32`, `vec3_fx_from_*`)
- Overflow-safe conversions with saturation for out-of-range inputs
- `Encode`/`Decode` traits for composable serialization
- Added `fixed` module to `warp-core`:
- `Fx32` scalar type for Q32.32 fixed-point arithmetic
- `Vec3Fx` 3D vector type with fixed-point components
- Overflow-safe constructors with range validation
- Added WSC (Write-Streaming Columnar) snapshot format to `warp-core`:
- Deterministic serialization of WARP graph state with zero-copy mmap deserialization
- 8-byte aligned columnar layout for SIMD-friendly access
Expand Down
238 changes: 238 additions & 0 deletions crates/echo-wasm-abi/src/codec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// SPDX-License-Identifier: Apache-2.0
// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
//! Minimal deterministic codec helpers (length-prefixed, LE scalars).

use thiserror::Error;

/// Errors produced by codec readers.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CodecError {
/// Attempted to read beyond the end of the buffer.
#[error("buffer too short")]
OutOfBounds,
/// UTF-8 decoding failed.
#[error("invalid utf-8")]
InvalidUtf8,
/// String length exceeded max bound.
#[error("string too long")]
StringTooLong,
/// Length prefix exceeded max bound.
#[error("length too large")]
LengthTooLarge,
/// Enum decoding failed.
#[error("invalid enum value")]
InvalidEnum,
}

/// Trait for deterministic encoding to bytes.
pub trait Encode {
/// Encode into the provided writer.
fn encode(&self, writer: &mut Writer) -> Result<(), CodecError>;
}

/// Trait for deterministic decoding from bytes.
pub trait Decode: Sized {
/// Decode from the provided reader.
fn decode(reader: &mut Reader) -> Result<Self, CodecError>;
}

/// Encode a value into a fresh Vec.
pub fn encode_to_vec<T: Encode>(value: &T) -> Result<Vec<u8>, CodecError> {
let mut writer = Writer::default();
value.encode(&mut writer)?;
Ok(writer.into_vec())
}

/// Decode a value from a byte slice.
pub fn decode_from_bytes<T: Decode>(bytes: &[u8]) -> Result<T, CodecError> {
let mut reader = Reader::new(bytes);
T::decode(&mut reader)
}
Comment on lines +39 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject trailing bytes to keep decoding canonical.

decode_from_bytes decodes a prefix and silently ignores any remaining bytes. That permits multiple encodings for the same value and can hide appended data. For a deterministic codec, this is a correctness and integrity risk. Enforce full consumption or provide a strict variant.

🛠️ Proposed fix (add TrailingBytes + strict check)
 pub enum CodecError {
@@
     /// Enum decoding failed.
     #[error("invalid enum value")]
     InvalidEnum,
+    /// Decoded value but leftover bytes remained.
+    #[error("trailing bytes")]
+    TrailingBytes,
 }
 pub fn decode_from_bytes<T: Decode>(bytes: &[u8]) -> Result<T, CodecError> {
     let mut reader = Reader::new(bytes);
-    T::decode(&mut reader)
+    let value = T::decode(&mut reader)?;
+    if reader.offset != bytes.len() {
+        return Err(CodecError::TrailingBytes);
+    }
+    Ok(value)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Encode a value into a fresh Vec.
pub fn encode_to_vec<T: Encode>(value: &T) -> Result<Vec<u8>, CodecError> {
let mut writer = Writer::default();
value.encode(&mut writer)?;
Ok(writer.into_vec())
}
/// Decode a value from a byte slice.
pub fn decode_from_bytes<T: Decode>(bytes: &[u8]) -> Result<T, CodecError> {
let mut reader = Reader::new(bytes);
T::decode(&mut reader)
}
/// Encode a value into a fresh Vec.
pub fn encode_to_vec<T: Encode>(value: &T) -> Result<Vec<u8>, CodecError> {
let mut writer = Writer::default();
value.encode(&mut writer)?;
Ok(writer.into_vec())
}
/// Decode a value from a byte slice.
pub fn decode_from_bytes<T: Decode>(bytes: &[u8]) -> Result<T, CodecError> {
let mut reader = Reader::new(bytes);
let value = T::decode(&mut reader)?;
if reader.offset != bytes.len() {
return Err(CodecError::TrailingBytes);
}
Ok(value)
}
🤖 Prompt for AI Agents
In `@crates/echo-wasm-abi/src/codec.rs` around lines 39 - 50, The current
decode_from_bytes function allows trailing bytes to be ignored; update
decode_from_bytes (and Reader usage) to enforce full consumption by checking the
reader after T::decode(&mut reader) and returning a new CodecError variant
(e.g., CodecError::TrailingBytes or similar) if any bytes remain; add that
variant to the CodecError enum and return Err when
reader.has_remaining()/remaining() != 0 (or !reader.is_eof()) so decoding is
strict and rejects inputs with extra data.


/// Deterministic writer for little-endian scalars and length-prefixed bytes.
#[derive(Debug, Default)]
pub struct Writer {
buf: Vec<u8>,
}

impl Writer {
/// Create a new writer with a pre-allocated capacity.
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
buf: Vec::with_capacity(capacity),
}
}

/// Write raw bytes.
pub fn write_bytes(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes);
}

/// Write a single byte.
pub fn write_u8(&mut self, value: u8) {
self.buf.push(value);
}

/// Write a little-endian u32.
pub fn write_u32_le(&mut self, value: u32) {
self.buf.extend_from_slice(&value.to_le_bytes());
}

/// Write a little-endian u16.
pub fn write_u16_le(&mut self, value: u16) {
self.buf.extend_from_slice(&value.to_le_bytes());
}

/// Write a little-endian i64.
pub fn write_i64_le(&mut self, value: i64) {
self.buf.extend_from_slice(&value.to_le_bytes());
}

/// Write length-prefixed bytes (u32 LE length).
pub fn write_len_prefixed_bytes(&mut self, bytes: &[u8]) -> Result<(), CodecError> {
let len: u32 = bytes
.len()
.try_into()
.map_err(|_| CodecError::LengthTooLarge)?;
self.write_u32_le(len);
self.write_bytes(bytes);
Ok(())
}

/// Write a length-prefixed UTF-8 string with a max bound.
pub fn write_string(&mut self, value: &str, max_len: usize) -> Result<(), CodecError> {
let bytes = value.as_bytes();
if bytes.len() > max_len {
return Err(CodecError::StringTooLong);
}
self.write_len_prefixed_bytes(bytes)
}

/// Consume the writer and return the buffer.
#[must_use]
pub fn into_vec(self) -> Vec<u8> {
self.buf
}
}

/// Deterministic reader for little-endian scalars and length-prefixed bytes.
#[derive(Debug)]
pub struct Reader<'a> {
bytes: &'a [u8],
offset: usize,
}

impl<'a> Reader<'a> {
/// Create a reader over the provided byte slice.
#[must_use]
pub fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}

fn take(&mut self, len: usize) -> Result<&'a [u8], CodecError> {
let end = self
.offset
.checked_add(len)
.ok_or(CodecError::OutOfBounds)?;
if end > self.bytes.len() {
return Err(CodecError::OutOfBounds);
}
let out = &self.bytes[self.offset..end];
self.offset = end;
Ok(out)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Read a little-endian u32.
pub fn read_u32_le(&mut self) -> Result<u32, CodecError> {
let chunk = self.take(4)?;
let raw: [u8; 4] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?;
Ok(u32::from_le_bytes(raw))
}

/// Read a single byte.
pub fn read_u8(&mut self) -> Result<u8, CodecError> {
let chunk = self.take(1)?;
Ok(chunk[0])
}

/// Read a little-endian u16.
pub fn read_u16_le(&mut self) -> Result<u16, CodecError> {
let chunk = self.take(2)?;
let raw: [u8; 2] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?;
Ok(u16::from_le_bytes(raw))
}

/// Read a little-endian i64.
pub fn read_i64_le(&mut self) -> Result<i64, CodecError> {
let chunk = self.take(8)?;
let raw: [u8; 8] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?;
Ok(i64::from_le_bytes(raw))
}

/// Read a length-prefixed byte slice with a max bound.
pub fn read_len_prefixed_bytes(&mut self, max_len: usize) -> Result<&'a [u8], CodecError> {
let len = self.read_u32_le()? as usize;
if len > max_len {
return Err(CodecError::LengthTooLarge);
}
self.take(len)
}

/// Read a length-prefixed UTF-8 string with a max bound.
pub fn read_string(&mut self, max_len: usize) -> Result<String, CodecError> {
let bytes = self.read_len_prefixed_bytes(max_len)?;
std::str::from_utf8(bytes)
.map(|s| s.to_string())
.map_err(|_| CodecError::InvalidUtf8)
}
}

/// Convert an integer to Q32.32 fixed-point representation.
///
/// Valid input range is `i32::MIN..=i32::MAX`. Values outside this range
/// saturate to `i64::MIN` or `i64::MAX` respectively.
#[inline]
#[must_use]
pub fn fx_from_i64(n: i64) -> i64 {
// Q32.32 can only represent integers in i32 range without overflow
if n > i64::from(i32::MAX) {
i64::MAX
} else if n < i64::from(i32::MIN) {
i64::MIN
} else {
n << 32
}
}

/// Convert f32 to Q32.32 using truncation toward zero.
#[inline]
#[must_use]
pub fn fx_from_f32(value: f32) -> i64 {
let scaled = (value as f64) * ((1u64 << 32) as f64);
if scaled.is_nan() {
0
} else if scaled.is_infinite() {
if scaled.is_sign_positive() {
i64::MAX
} else {
i64::MIN
}
} else {
scaled.trunc() as i64
}
}

/// Convert integer components to Q32.32 raw vector.
#[inline]
#[must_use]
pub fn vec3_fx_from_i64(x: i64, y: i64, z: i64) -> [i64; 3] {
[fx_from_i64(x), fx_from_i64(y), fx_from_i64(z)]
}

/// Convert f32 components to Q32.32 raw vector using truncation toward zero.
#[inline]
#[must_use]
pub fn vec3_fx_from_f32(x: f32, y: f32, z: f32) -> [i64; 3] {
[fx_from_f32(x), fx_from_f32(y), fx_from_f32(z)]
}
3 changes: 3 additions & 0 deletions crates/echo-wasm-abi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pub use canonical::{CanonError, decode_value, encode_value};
pub mod eintlog;
pub use eintlog::*;

/// Deterministic binary codec for length-prefixed scalars and Q32.32 fixed-point helpers.
pub mod codec;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Errors produced by the Intent Envelope parser.
#[derive(Debug, PartialEq, Eq)]
pub enum EnvelopeError {
Expand Down
39 changes: 39 additions & 0 deletions crates/echo-wasm-abi/tests/codec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: Apache-2.0
// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
//! Codec round-trip tests.

use echo_wasm_abi::codec::{
Reader, Writer, fx_from_f32, fx_from_i64, vec3_fx_from_f32, vec3_fx_from_i64,
};

#[test]
fn codec_round_trip_scalars_and_string() {
let mut w = Writer::with_capacity(64);
w.write_u32_le(42);
w.write_i64_le(-123);
w.write_string("hello", 1024).unwrap();
let bytes = w.into_vec();

let mut r = Reader::new(&bytes);
let a = r.read_u32_le().unwrap();
let b = r.read_i64_le().unwrap();
let c = r.read_string(1024).unwrap();

assert_eq!(a, 42);
assert_eq!(b, -123);
assert_eq!(c, "hello");
}

#[test]
fn codec_fx_helpers() {
assert_eq!(fx_from_i64(1), 1i64 << 32);
assert_eq!(
vec3_fx_from_i64(1, -2, 3),
[1i64 << 32, -2i64 << 32, 3i64 << 32]
);
assert_eq!(fx_from_f32(1.5), (1i64 << 32) + (1i64 << 31));
assert_eq!(
vec3_fx_from_f32(1.0, -2.5, 3.0),
[1i64 << 32, (-2i64 << 32) - (1i64 << 31), 3i64 << 32]
);
}
76 changes: 76 additions & 0 deletions crates/warp-core/src/fixed.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: Apache-2.0
// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
//! Deterministic fixed-point helpers (Q32.32).

/// Q32.32 fixed-point scalar.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fx32(i64);

impl Fx32 {
/// Construct from an integer value (n << 32).
///
/// Valid input range is `i32::MIN..=i32::MAX`. Values outside this range
/// saturate to the minimum or maximum representable Q32.32 value.
#[must_use]
pub fn from_i64(n: i64) -> Self {
// Q32.32 can only represent integers in i32 range without overflow
if n > i64::from(i32::MAX) {
Self(i64::MAX)
} else if n < i64::from(i32::MIN) {
Self(i64::MIN)
} else {
Self(n << 32)
}
}

/// Construct directly from raw Q32.32 bits.
#[must_use]
pub fn from_raw(raw: i64) -> Self {
Self(raw)
}

/// Return the raw Q32.32 representation.
#[must_use]
pub fn raw(self) -> i64 {
self.0
}
}

/// 3D vector in Q32.32 fixed-point.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Vec3Fx {
/// X component.
pub x: Fx32,
/// Y component.
pub y: Fx32,
/// Z component.
pub z: Fx32,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

impl Vec3Fx {
/// Construct from integer components (each converted to Q32.32).
#[must_use]
pub fn new_i64(x: i64, y: i64, z: i64) -> Self {
Self {
x: Fx32::from_i64(x),
y: Fx32::from_i64(y),
z: Fx32::from_i64(z),
}
}

/// Construct from raw Q32.32 components.
#[must_use]
pub fn from_raw(raw: [i64; 3]) -> Self {
Self {
x: Fx32::from_raw(raw[0]),
y: Fx32::from_raw(raw[1]),
z: Fx32::from_raw(raw[2]),
}
}

/// Return raw Q32.32 components.
#[must_use]
pub fn to_raw(self) -> [i64; 3] {
[self.x.raw(), self.y.raw(), self.z.raw()]
}
}
Loading