From 80a604a561a40374de86b0ff8510b3562f8c92e5 Mon Sep 17 00:00:00 2001 From: Tjerk Meesters Date: Tue, 3 Nov 2026 10:08:48 +0800 Subject: [PATCH] Add borrowed header-map decoding Signed-off-by: Tjerk Meesters --- CHANGELOG.md | 8 ++ README.md | 29 ++++++ src/hostcalls.rs | 163 ++++++++++++++++++++++++++---- src/lib.rs | 1 + src/map.rs | 252 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 435 insertions(+), 18 deletions(-) create mode 100644 src/map.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ada313a..98a1b6e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Added + +- Added `hostcalls::get_serialized_map` and `map::SerializedMap` for borrowing + header-map names and byte-valued payloads from a retained guest allocation, + with optional UTF-8 validation of values through `text_pairs()`. + ## [0.2.5] - 2026-05-20 ### Fixed diff --git a/README.md b/README.md index 4acad661..1844b1fc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,35 @@ - [Envoy filter metadata](./examples/envoy_filter_metadata/) - [Envoy TCP Routing](./examples/envoy_tcp_routing/) +## Borrowed header maps + +`hostcalls::get_serialized_map` retains the serialized guest allocation returned +by the host. `SerializedMap::pairs()` validates the map and returns a vector of +borrowed names and byte-valued payloads without copying each field. +`SerializedMap::text_pairs()` additionally validates values as UTF-8. Keep the +map owner alive while using either view; copy any values retained after it is +dropped, such as values needed by asynchronous callbacks. + +```rust +use proxy_wasm::{hostcalls, types::MapType}; + +if let Ok(map) = hostcalls::get_serialized_map(MapType::HttpRequestHeaders) { + if let Ok(pairs) = map.pairs() { + for (name, value) in pairs { + // The views remain valid while ordinary host calls run. + let _ = hostcalls::set_map_value_bytes( + MapType::HttpRequestHeaders, name, Some(value), + ); + } + } +} +``` + +The new accessors preserve order, duplicate names, empty fields, embedded NULs, +and payload bytes. Invalid buffers return `MapDecodeError` without exposing a +partial map. Names must be UTF-8; opaque values need not be. Existing owned APIs +and trait convenience methods are unchanged. + ## Articles & blog posts from the community - [Extending Envoy with WASM and Rust](https://antweiss.com/blog/extending-envoy-with-wasm-and-rust/) diff --git a/src/hostcalls.rs b/src/hostcalls.rs index 594a1bf8..1bd9281f 100644 --- a/src/hostcalls.rs +++ b/src/hostcalls.rs @@ -13,6 +13,7 @@ // limitations under the License. use crate::dispatcher; +use crate::map::SerializedMap; use crate::types::*; use std::ptr::{null, null_mut}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -162,6 +163,37 @@ fn proxy_get_header_map_pairs( mocks::proxy_get_header_map_pairs(map_type, return_map_data, return_map_size) } +/// Acquires a serialized map without copying individual names or values. +/// +/// The returned owner retains the host-transferred guest allocation. Use +/// `pairs()` for byte-valued views or `text_pairs()` for validated text views. +/// Host failures are returned unchanged. A null pointer with a nonzero size is +/// rejected as `InternalFailure`; a null, zero-length result is an empty map. +/// Existing owned map APIs retain their original behavior. +pub fn get_serialized_map(map_type: MapType) -> Result { + let mut return_data: *mut u8 = null_mut(); + let mut return_size: usize = 0; + unsafe { + match proxy_get_header_map_pairs(map_type, &mut return_data, &mut return_size) { + Status::Ok if return_data.is_null() => { + if return_size == 0 { + Ok(SerializedMap::default()) + } else { + Err(Status::InternalFailure) + } + } + Status::Ok => { + // Same ownership boundary as get_map/get_map_bytes: the host used + // proxy_on_memory_allocate(size), transferring an exact-sized + // guest allocation. The Vec is its sole owner, including decode + // error paths; no references into mutable host storage are kept. + Ok(Vec::from_raw_parts(return_data, return_size, return_size).into()) + } + status => Err(status), + } + } +} + pub fn get_map(map_type: MapType) -> Result, Status> { unsafe { let mut return_data: *mut u8 = null_mut(); @@ -1198,24 +1230,43 @@ pub fn increment_metric(metric_id: u32, offset: i64) -> Result<(), Status> { mod mocks { use crate::hostcalls::utils::tests::SERIALIZED_MAP; use crate::types::*; - use std::alloc::{Layout, alloc}; + use std::cell::RefCell; + + thread_local! { + static RESULT: RefCell<(Status, Option>, usize)> = + RefCell::new((Status::Ok, Some(SERIALIZED_MAP.to_vec()), 0)); + } + + pub fn set_result(status: Status, bytes: Option>, null_size: usize) { + RESULT.with(|result| *result.borrow_mut() = (status, bytes, null_size)); + } pub fn proxy_get_header_map_pairs( _map_type: MapType, return_map_data: *mut *mut u8, return_map_size: *mut usize, ) -> Status { - let layout = Layout::array::(SERIALIZED_MAP.len()).unwrap(); - unsafe { - *return_map_data = alloc(layout); - *return_map_size = SERIALIZED_MAP.len(); - std::ptr::copy( - SERIALIZED_MAP.as_ptr(), - *return_map_data, - SERIALIZED_MAP.len(), - ); - } - Status::Ok + RESULT.with(|result| { + let result = result.borrow(); + if result.0 == Status::Ok { + unsafe { + if let Some(bytes) = &result.1 { + // Exercise the actual SDK allocator's ownership contract. + *return_map_data = crate::allocator::proxy_on_memory_allocate(bytes.len()); + *return_map_size = bytes.len(); + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + *return_map_data, + bytes.len(), + ); + } else { + *return_map_data = std::ptr::null_mut(); + *return_map_size = result.2; + } + } + } + result.0 + }) } } @@ -1228,16 +1279,92 @@ mod tests { #[global_allocator] static ALLOCATOR: Mockalloc = Mockalloc(System); - #[mockalloc::test] + #[test] + fn serialized_map_status_and_empty_buffers() { + use super::{get_serialized_map, mocks}; + for status in [ + Status::NotFound, + Status::BadArgument, + Status::InternalFailure, + ] { + mocks::set_result(status, None, 0); + assert_eq!( + get_serialized_map(MapType::HttpRequestHeaders).unwrap_err(), + status + ); + } + for bytes in [None, Some(vec![]), Some(vec![0; 4])] { + mocks::set_result(Status::Ok, bytes, 0); + assert!( + get_serialized_map(MapType::HttpRequestHeaders) + .unwrap() + .pairs() + .unwrap() + .is_empty() + ); + } + mocks::set_result(Status::Ok, None, 4); + assert_eq!( + get_serialized_map(MapType::HttpRequestHeaders).unwrap_err(), + Status::InternalFailure + ); + mocks::set_result( + Status::Ok, + Some(super::utils::tests::SERIALIZED_MAP.to_vec()), + 0, + ); + } + + #[test] + fn serialized_map_allocations_and_drops() { + use super::{get_serialized_map, mocks, utils}; + // Warm up the thread-local mock before measuring acquisition and decode. + for count in [1, 4, 20, 64] { + let entries = vec![("key", "value"); count]; + mocks::set_result(Status::Ok, Some(utils::serialize_map(&entries)), 0); + for _ in 0..10 { + let info = mockalloc::record_allocs(|| { + let map = get_serialized_map(MapType::HttpRequestHeaders).unwrap(); + assert_eq!(map.pairs().unwrap().len(), count); + }); + info.result().unwrap(); + assert_eq!(info.num_allocs(), 2); + } + } + for bytes in [vec![1], utils::serialize_map_bytes(&[("key", b"\xff")])] { + mocks::set_result(Status::Ok, Some(bytes), 0); + mockalloc::assert_allocs(|| { + let map = get_serialized_map(MapType::HttpRequestHeaders).unwrap(); + assert!(map.text_pairs().is_err()); + }); + } + mocks::set_result(Status::Ok, Some(utils::tests::SERIALIZED_MAP.to_vec()), 0); + } + + #[test] fn test_get_map_no_leaks() { - let result = super::get_map(MapType::HttpRequestHeaders); - assert!(result.is_ok()); + super::mocks::set_result( + Status::Ok, + Some(super::utils::tests::SERIALIZED_MAP.to_vec()), + 0, + ); + mockalloc::assert_allocs(|| { + let result = super::get_map(MapType::HttpRequestHeaders).unwrap(); + assert!(!result.is_empty()); + }); } - #[mockalloc::test] + #[test] fn test_get_map_bytes_no_leaks() { - let result = super::get_map_bytes(MapType::HttpRequestHeaders); - assert!(result.is_ok()); + super::mocks::set_result( + Status::Ok, + Some(super::utils::tests::SERIALIZED_MAP.to_vec()), + 0, + ); + mockalloc::assert_allocs(|| { + let result = super::get_map_bytes(MapType::HttpRequestHeaders).unwrap(); + assert!(!result.is_empty()); + }); } } diff --git a/src/lib.rs b/src/lib.rs index b2fd0a34..25e9e6b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ extern crate test; pub mod hostcalls; +pub mod map; pub mod traits; pub mod types; diff --git a/src/map.rs b/src/map.rs new file mode 100644 index 00000000..4d52c97a --- /dev/null +++ b/src/map.rs @@ -0,0 +1,252 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Owned serialized maps with views borrowing their guest-owned storage. +use std::{ + convert::TryInto, + fmt, + str::{self, Utf8Error}, +}; + +/// A malformed serialized map. No partial map is returned on failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MapDecodeError { + Truncated, + SizeOverflow, + MissingTerminator, + TrailingData, + InvalidUtf8(Utf8Error), +} + +impl fmt::Display for MapDecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Truncated => f.write_str("truncated map"), + Self::SizeOverflow => f.write_str("map length overflow"), + Self::MissingTerminator => f.write_str("missing map field terminator"), + Self::TrailingData => f.write_str("trailing map data"), + Self::InvalidUtf8(error) => write!(f, "invalid map text: {}", error), + } + } +} +impl std::error::Error for MapDecodeError {} +impl From for MapDecodeError { + fn from(error: Utf8Error) -> Self { + Self::InvalidUtf8(error) + } +} + +/// Owns the guest allocation returned by a header-map host call. +/// +/// Views remain valid across ordinary host calls, but cannot outlive this owner. +/// Values are opaque bytes; text validation is opt-in via [`Self::text_pairs`]. +/// Names are validated as UTF-8 in both accessors. Order, duplicates, case, +/// empty fields, embedded NULs and whitespace are preserved. +/// +/// ```compile_fail +/// use proxy_wasm::map::SerializedMap; +/// let pairs = { +/// let map = SerializedMap::from(vec![0, 0, 0, 0]); +/// map.pairs().unwrap() +/// }; +/// println!("{:?}", pairs); +/// ``` +#[derive(Debug, Default)] +pub struct SerializedMap { + bytes: Vec, +} + +impl From> for SerializedMap { + /// Retains the buffer without copying. Validation occurs when requesting views. + fn from(bytes: Vec) -> Self { + Self { bytes } + } +} + +impl SerializedMap { + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + pub fn into_bytes(self) -> Vec { + self.bytes + } + + /// Validates the complete input and borrows names and byte-valued payloads. + /// Empty buffers and a zero count both represent an empty map. Malformed + /// input returns an error; no partial result is exposed. The pair vector is + /// the only allocation, and its count is bounded by the input before reserve. + pub fn pairs(&self) -> Result, MapDecodeError> { + self.decode(Ok) + } + + /// Like [`Self::pairs`], additionally validating all values as UTF-8. + /// Allocates one pair vector, without copying names or values. + pub fn text_pairs(&self) -> Result, MapDecodeError> { + self.decode(|value| Ok(str::from_utf8(value)?)) + } + + fn decode<'a, V>( + &'a self, + value: impl Fn(&'a [u8]) -> Result, + ) -> Result, MapDecodeError> { + let bytes = &self.bytes; + if bytes.is_empty() { + return Ok(Vec::new()); + } + let count = read_u32(bytes, 0)?; + let table_end = count + .checked_mul(8) + .and_then(|n| n.checked_add(4)) + .ok_or(MapDecodeError::SizeOverflow)?; + // Every entry needs two terminators even when both fields are empty. + let minimum = count + .checked_mul(2) + .and_then(|n| table_end.checked_add(n)) + .ok_or(MapDecodeError::SizeOverflow)?; + if minimum > bytes.len() { + return Err(MapDecodeError::Truncated); + } + let mut pairs = Vec::with_capacity(count); + let mut cursor = table_end; + for entry in 0..count { + let key_len = read_u32(bytes, 4 + entry * 8)?; + let value_len = read_u32(bytes, 8 + entry * 8)?; + let key = field(bytes, &mut cursor, key_len)?; + let payload = field(bytes, &mut cursor, value_len)?; + pairs.push((str::from_utf8(key)?, value(payload)?)); + } + if cursor != bytes.len() { + return Err(MapDecodeError::TrailingData); + } + Ok(pairs) + } +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let end = offset.checked_add(4).ok_or(MapDecodeError::SizeOverflow)?; + let raw = bytes.get(offset..end).ok_or(MapDecodeError::Truncated)?; + Ok(u32::from_le_bytes(raw.try_into().unwrap()) as usize) +} + +fn field<'a>(bytes: &'a [u8], cursor: &mut usize, len: usize) -> Result<&'a [u8], MapDecodeError> { + let end = cursor + .checked_add(len) + .ok_or(MapDecodeError::SizeOverflow)?; + let next = end.checked_add(1).ok_or(MapDecodeError::SizeOverflow)?; + let payload = bytes.get(*cursor..end).ok_or(MapDecodeError::Truncated)?; + match bytes.get(end) { + Some(0) => {} + Some(_) => return Err(MapDecodeError::MissingTerminator), + None => return Err(MapDecodeError::Truncated), + } + *cursor = next; + Ok(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode(pairs: &[(&[u8], &[u8])]) -> Vec { + let mut bytes = (pairs.len() as u32).to_le_bytes().to_vec(); + for (key, value) in pairs { + bytes.extend_from_slice(&(key.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&(value.len() as u32).to_le_bytes()); + } + for (key, value) in pairs { + bytes.extend_from_slice(key); + bytes.push(0); + bytes.extend_from_slice(value); + bytes.push(0); + } + bytes + } + + #[test] + fn borrowed_bytes_preserve_wire_content() { + let input: &[(&[u8], &[u8])] = &[ + (b"X-Key", b" first "), + (b"X-Key", b""), + (b"", b"\0\xff"), + ("é".as_bytes(), "你好".as_bytes()), + ]; + let map = SerializedMap::from(encode(input)); + let pairs = map.pairs().unwrap(); + for ((key, value), (expected_key, expected_value)) in pairs.iter().zip(input) { + assert_eq!(key.as_bytes(), *expected_key); + assert_eq!(*value, *expected_value); + // Actual views into the retained allocation, including empty fields. + let range = map.as_bytes().as_ptr_range(); + assert!(range.contains(&key.as_ptr())); + assert!(range.contains(&value.as_ptr())); + } + assert!(matches!( + map.text_pairs(), + Err(MapDecodeError::InvalidUtf8(_)) + )); + let text = SerializedMap::from(encode(&[(b"key\0name", b"value\0text"), (b"", b"")])); + assert_eq!( + text.text_pairs().unwrap(), + vec![("key\0name", "value\0text"), ("", "")] + ); + } + + #[test] + fn empty_maps_and_bad_text() { + for bytes in [vec![], vec![0; 4]] { + let map = SerializedMap::from(bytes); + assert!(map.pairs().unwrap().is_empty()); + assert!(map.text_pairs().unwrap().is_empty()); + } + let map = SerializedMap::from(encode(&[(b"\xff", b"value")])); + assert!(matches!(map.pairs(), Err(MapDecodeError::InvalidUtf8(_)))); + } + + #[test] + fn malformed_buffers_return_errors_without_panicking() { + let valid = encode(&[(b"a", b"b"), (b"c", b"d")]); + for length in 1..valid.len() { + assert!( + SerializedMap::from(valid[..length].to_vec()) + .pairs() + .is_err(), + "length {}", + length + ); + } + for offset in [0, 4, 8, 12, 16] { + let mut bad = valid.clone(); + bad[offset..offset + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(SerializedMap::from(bad).pairs().is_err()); + } + let mut bad = valid.clone(); + bad.push(0); + assert_eq!( + SerializedMap::from(bad).pairs(), + Err(MapDecodeError::TrailingData) + ); + for offset in [21, 23, 25, 27] { + let mut bad = valid.clone(); + bad[offset] = 1; + assert_eq!( + SerializedMap::from(bad).pairs(), + Err(MapDecodeError::MissingTerminator) + ); + } + assert_eq!( + SerializedMap::from(vec![0; 5]).pairs(), + Err(MapDecodeError::TrailingData) + ); + } +}