lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 04b532678eb526d8f7eefdf22ebe01ba442e53dd
parent 44b32ae6e2151e1ad18ef6f7c883ecfbbbf1b678
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 05:53:43 +0000

storage: validate idempotency keys before allocation

- validate borrowed idempotency text before creating owned storage
- retain exact 256-byte grammar and redacted diagnostics
- cover maximum, just-over, and multi-megabyte rejected inputs
- verify storage, contract, API, Clippy, doctest, and Rustdoc gates

Diffstat:
Mcontracts/api_baselines/radroots_storage.txt | 2+-
Mcrates/storage/README.md | 4++++
Mcrates/storage/src/journal.rs | 6+++---
Mcrates/storage/tests/journal.rs | 11+++++++++++
Mcrates/storage/tests/package_boundary.rs | 35+++++++++++++++++++++++++++++++++++
5 files changed, 54 insertions(+), 4 deletions(-)

diff --git a/contracts/api_baselines/radroots_storage.txt b/contracts/api_baselines/radroots_storage.txt @@ -732,7 +732,7 @@ pub const fn radroots_storage::journal::IdempotencyDigest::new([u8; 32]) -> Self pub struct radroots_storage::journal::IdempotencyKey(_) impl radroots_storage::journal::IdempotencyKey pub fn radroots_storage::journal::IdempotencyKey::as_str(&self) -> &str -pub fn radroots_storage::journal::IdempotencyKey::parse(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_storage::Error> +pub fn radroots_storage::journal::IdempotencyKey::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_storage::Error> impl core::fmt::Debug for radroots_storage::journal::IdempotencyKey pub fn radroots_storage::journal::IdempotencyKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl serde_core::ser::Serialize for radroots_storage::journal::IdempotencyKey diff --git a/crates/storage/README.md b/crates/storage/README.md @@ -99,6 +99,10 @@ evidence without owning a transport adapter. Projection storage owns only checkpoints, invalidation/rebuild state, and event-index manifests; reducer algorithms and projected domain rows stay with their domain owners. +Idempotency-key construction validates borrowed input before allocating its +bounded owned representation, so rejected oversized input cannot force a +second attacker-sized allocation at this public boundary. + ## Protected metadata and security `private_artifact` stores bounded metadata and opaque durable secret references. diff --git a/crates/storage/src/journal.rs b/crates/storage/src/journal.rs @@ -51,8 +51,8 @@ const fn all_zero(bytes: &[u8; 16]) -> bool { pub struct IdempotencyKey(String); impl IdempotencyKey { - pub fn parse(value: impl Into<String>) -> Result<Self, Error> { - let value = value.into(); + pub fn parse(value: impl AsRef<str>) -> Result<Self, Error> { + let value = value.as_ref(); if value.is_empty() || value.len() > IDEMPOTENCY_KEY_MAX_BYTES || value != value.trim() @@ -60,7 +60,7 @@ impl IdempotencyKey { { return Err(Error::InvalidIdempotencyKey); } - Ok(Self(value)) + Ok(Self(value.to_owned())) } pub fn as_str(&self) -> &str { diff --git a/crates/storage/tests/journal.rs b/crates/storage/tests/journal.rs @@ -325,6 +325,17 @@ fn journal_value_and_state_validation_matrix_is_complete() { IdempotencyKey::parse("x".repeat(radroots_storage::journal::IDEMPOTENCY_KEY_MAX_BYTES + 1)), Err(Error::InvalidIdempotencyKey) ); + assert_eq!( + IdempotencyKey::parse("x".repeat(4 * 1024 * 1024)), + Err(Error::InvalidIdempotencyKey) + ); + let maximum = + IdempotencyKey::parse("x".repeat(radroots_storage::journal::IDEMPOTENCY_KEY_MAX_BYTES)) + .expect("exact maximum key"); + assert_eq!( + maximum.as_str().len(), + radroots_storage::journal::IDEMPOTENCY_KEY_MAX_BYTES + ); let idempotency_key = key(1); assert_eq!(idempotency_key.as_str(), "sync-push-01"); let digest = IdempotencyDigest::new([2; 32]); diff --git a/crates/storage/tests/package_boundary.rs b/crates/storage/tests/package_boundary.rs @@ -1,6 +1,9 @@ use std::fs; use std::path::PathBuf; +const README: &str = include_str!("../README.md"); +const JOURNAL: &str = include_str!("../src/journal.rs"); + #[test] fn manifest_matches_the_release_v1_package_boundary() { let manifest = fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) @@ -108,3 +111,35 @@ fn root_exports_are_exact_and_implementation_types_do_not_leak() { ); } } + +#[test] +fn idempotency_keys_validate_borrowed_input_before_bounded_allocation() { + for required in [ + "Idempotency-key construction validates borrowed input before allocating its\nbounded owned representation", + "rejected oversized input cannot force a\nsecond attacker-sized allocation", + ] { + assert!(README.contains(required), "README is missing `{required}`"); + } + let parser = JOURNAL + .split_once("pub fn parse(value: impl AsRef<str>)") + .expect("borrowed idempotency parser") + .1 + .split_once("pub fn as_str") + .expect("idempotency accessor") + .0; + assert!(parser.contains("let value = value.as_ref();")); + assert_eq!(parser.matches("to_owned()").count(), 1); + let validation = parser + .find("value.len() > IDEMPOTENCY_KEY_MAX_BYTES") + .expect("pre-allocation byte bound"); + let allocation = parser + .find("Self(value.to_owned())") + .expect("bounded owned representation"); + assert!(validation < allocation); + for forbidden in ["impl Into<String>", "let value = value.into()"] { + assert!( + !parser.contains(forbidden), + "idempotency parser contains pre-validation allocation `{forbidden}`" + ); + } +}