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:
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}`"
+ );
+ }
+}