diff --git a/packages/cultnet-rs/src/replication.rs b/packages/cultnet-rs/src/replication.rs index 7073f1a9..7b070fbd 100644 --- a/packages/cultnet-rs/src/replication.rs +++ b/packages/cultnet-rs/src/replication.rs @@ -522,7 +522,7 @@ impl CultNetDocumentRegistry { envelope: &CultCacheEnvelope, ) -> Result> { let binding = self.require_binding(&envelope.r#type)?; - let payload: Value = rmp_serde::from_slice(&envelope.payload).with_context(|| { + let payload: Value = cultcache_rs::decode_payload(&envelope.payload).with_context(|| { format!( "failed to decode CultCache envelope {:?} at {:?} as generic CultNet payload", envelope.r#type, envelope.key diff --git a/packages/cultnet-rs/src/service_identity.rs b/packages/cultnet-rs/src/service_identity.rs index 6f3e58d8..3d37adfa 100644 --- a/packages/cultnet-rs/src/service_identity.rs +++ b/packages/cultnet-rs/src/service_identity.rs @@ -306,7 +306,7 @@ fn open_service_identity_envelopes( { bail!("service identity store belongs to a different profile or schema"); } - let entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelope.payload) + let entry: ServiceIdentityPrivateEntry = cultcache_rs::decode_payload(&envelope.payload) .context("service identity private payload is malformed MessagePack")?; validate_private::

(&entry)?; if entry.protector_kind != platform_protector_kind() || entry.assurance != platform_assurance() @@ -741,6 +741,34 @@ mod tests { Ok(()) } + #[test] + fn a_credential_payload_nested_past_the_bound_is_refused_by_the_cache_walk() -> Result<()> { + let limit = cultcache_rs::MAX_PAYLOAD_DEPTH; + let credential = |levels: usize| -> Result> { + let envelope = CultCacheEnvelope { + key: IdunnServiceIdentity::PRIVATE_KEY.into(), + r#type: IdunnServiceIdentity::PRIVATE_TYPE.into(), + payload: [vec![0x91u8; levels], vec![0xc0]].concat(), + stored_at: "2026-10-11T00:00:00Z".into(), + schema_id: Some(IdunnServiceIdentity::PRIVATE_SCHEMA.into()), + }; + Ok(rmp_serde::to_vec(&vec![envelope])?) + }; + let past = open_service_identity_credential_bytes::(&credential(limit + 1)?) + .err() + .expect("a payload past the bound is refused"); + assert_eq!( + past.downcast_ref::(), + Some(&cultcache_rs::PayloadTooDeep { depth: limit + 1, limit }) + ); + // At the bound the walk lets the payload through to the decoder, which refuses its shape. + let at = open_service_identity_credential_bytes::(&credential(limit)?) + .err() + .expect("the payload is not a credential"); + assert!(at.downcast_ref::().is_none()); + Ok(()) + } + #[test] fn immutable_credential_reader_opens_without_a_path_and_retains_signing_authority() -> Result<()> { diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 2f4572d9..cac4d9dd 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -1,6 +1,7 @@ use anyhow::Result; use chrono::Duration; use chrono::Utc; +use cultcache_rs::CacheBackingStore; use cultcache_rs::CultCache; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; @@ -1778,6 +1779,49 @@ fn document_registry_advertises_mutation_contracts_with_bindings() -> Result<()> Ok(()) } +#[test] +fn a_stored_payload_nested_past_the_bound_is_not_replicated_as_a_document() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = temp.path().join("foreign.msgpack"); + let limit = cultcache_rs::MAX_PAYLOAD_DEPTH; + let envelope = |key: &str, levels: usize| cultcache_rs::CultCacheEnvelope { + key: key.to_string(), + r#type: GhostlightAgentStateFixture::TYPE.to_string(), + payload: [vec![0x91u8; levels], vec![0xc0]].concat(), + stored_at: "2026-10-11T00:00:00Z".to_string(), + schema_id: None, + }; + let mut registry = CultNetDocumentRegistry::new(); + registry.register(CultNetDocumentBinding::for_entry::( + "ghostlight.agent_state.v0".to_string(), + )); + // A store written by something that does not hold the bound. + let mut backing = SingleFileMessagePackBackingStore::new(&store); + backing.push(&envelope("at", limit))?; + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store))?; + cache.pull_all_backing_stores()?; + + let at_limit = registry.create_snapshot_response(&cache, "at", None, None)?; + let CultNetMessage::SnapshotResponse { documents, .. } = at_limit else { + panic!("expected a snapshot response"); + }; + assert_eq!(documents.len(), 1); + + backing.push(&envelope("past", limit + 1))?; + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store))?; + cache.pull_all_backing_stores()?; + let error = registry.create_snapshot_response(&cache, "past", None, None).unwrap_err(); + assert_eq!( + error.downcast_ref::(), + Some(&cultcache_rs::PayloadTooDeep { depth: limit + 1, limit }) + ); + Ok(()) +} + #[test] fn document_registry_replicates_typed_cultcache_state() -> Result<()> { let temp = tempfile::tempdir()?;