tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit a8924637a85671bd35eb040bc217e2ea794dedf4
parent f4bbc80895292b97aa04f494a0128489447c2391
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 06:13:09 -0700

config: reject ignored production fields

- Remove the unused group redaction config surface from parsing, examples, and tests.
- Reject unknown runtime and group config fields instead of silently accepting unenforced settings.
- Apply max_outbox_replay_batch as a real bounded outbox replay drain limit.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.

Diffstat:
Mcrates/tangle/tests/version.rs | 4----
Mcrates/tangle_groups/src/lib.rs | 84++++++++++++++++++++++++++-----------------------------------------------------
Mcrates/tangle_runtime/src/config.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/groups.rs | 47+++++++++++++++++++++++++++++++++++++++--------
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 33+++++++++++++++++++++++++++++++++
Mcrates/tangle_test_support/src/lib.rs | 16++++++----------
6 files changed, 175 insertions(+), 79 deletions(-)

diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs @@ -112,10 +112,6 @@ fn tangle_run_starts_server_and_stays_alive_until_shutdown() { "relay_secret": TANGLE_V2_RELAY_SECRET_HEX, "owner_pubkeys": [FixtureKey::Owner.public_key().as_str()], "admin_pubkeys": [FixtureKey::Admin.public_key().as_str()], - "redaction": { - "redact_private_tags": true, - "redact_invite_codes": true - }, "limits": { "max_group_id_bytes": 128, "max_group_tags_per_event": 8, diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs @@ -122,37 +122,6 @@ impl fmt::Display for CanonicalRelayUrl { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -pub struct GroupRedactionConfig { - #[serde(default = "default_true")] - redact_private_tags: bool, - #[serde(default = "default_true")] - redact_invite_codes: bool, -} - -impl GroupRedactionConfig { - pub fn strict() -> Self { - Self { - redact_private_tags: true, - redact_invite_codes: true, - } - } - - pub fn redact_private_tags(&self) -> bool { - self.redact_private_tags - } - - pub fn redact_invite_codes(&self) -> bool { - self.redact_invite_codes - } -} - -impl Default for GroupRedactionConfig { - fn default() -> Self { - Self::strict() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] pub struct GroupPolicyConfig { #[serde(default)] @@ -205,7 +174,6 @@ impl Default for GroupPolicyConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct GroupRuntimeSettingsConfig { policy: GroupPolicyConfig, - redaction: GroupRedactionConfig, limits: GroupLimitsConfig, } @@ -213,21 +181,15 @@ impl GroupRuntimeSettingsConfig { pub fn strict() -> Self { Self { policy: GroupPolicyConfig::strict(), - redaction: GroupRedactionConfig::strict(), limits: GroupLimitsConfig::default(), } } pub fn new( policy: GroupPolicyConfig, - redaction: GroupRedactionConfig, limits: GroupLimitsConfig, ) -> Result<Self, GroupConfigError> { - let value = Self { - policy, - redaction, - limits, - }; + let value = Self { policy, limits }; value.validate()?; Ok(value) } @@ -241,10 +203,6 @@ impl GroupRuntimeSettingsConfig { self.policy } - pub fn redaction(&self) -> GroupRedactionConfig { - self.redaction - } - pub fn limits(&self) -> GroupLimitsConfig { self.limits } @@ -257,6 +215,7 @@ impl Default for GroupRuntimeSettingsConfig { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GroupLimitsConfig { #[serde(default = "default_max_group_id_bytes")] max_group_id_bytes: u16, @@ -418,10 +377,6 @@ impl GroupRuntimeConfig { self.settings.policy() } - pub fn redaction(&self) -> GroupRedactionConfig { - self.settings.redaction() - } - pub fn limits(&self) -> GroupLimitsConfig { self.settings.limits() } @@ -453,6 +408,7 @@ impl fmt::Display for GroupConfigError { impl std::error::Error for GroupConfigError {} #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct GroupRuntimeConfigDocument { enabled: bool, canonical_relay_url: Option<String>, @@ -464,8 +420,6 @@ struct GroupRuntimeConfigDocument { #[serde(default)] policy: GroupPolicyConfig, #[serde(default)] - redaction: GroupRedactionConfig, - #[serde(default)] limits: GroupLimitsConfig, } @@ -489,7 +443,7 @@ pub fn parse_group_runtime_config_json(raw: &str) -> Result<GroupRuntimeConfig, relay_secret, parse_pubkeys("groups.owner_pubkeys", document.owner_pubkeys)?, parse_pubkeys("groups.admin_pubkeys", document.admin_pubkeys)?, - GroupRuntimeSettingsConfig::new(document.policy, document.redaction, document.limits)?, + GroupRuntimeSettingsConfig::new(document.policy, document.limits)?, ) } @@ -534,10 +488,6 @@ where Ok(()) } -fn default_true() -> bool { - true -} - fn default_max_group_id_bytes() -> u16 { 128 } @@ -588,7 +538,6 @@ mod tests { "owner_pubkeys": ["{owner}"], "admin_pubkeys": ["{admin}"], "policy": {{"public_join": false, "invites_enabled": false}}, - "redaction": {{"redact_private_tags": true, "redact_invite_codes": true}}, "limits": {{ "max_group_id_bytes": 64, "max_group_tags_per_event": 4, @@ -611,8 +560,6 @@ mod tests { assert_eq!(config.policy(), GroupPolicyConfig::strict()); assert!(!config.policy().public_join()); assert!(!config.policy().invites_enabled()); - assert!(config.redaction().redact_private_tags()); - assert!(config.redaction().redact_invite_codes()); assert_eq!(config.limits().max_group_id_bytes(), 64); assert_eq!(config.limits().max_group_tags_per_event(), 4); assert_eq!(config.limits().max_supported_kinds(), 32); @@ -658,6 +605,29 @@ mod tests { } #[test] + fn group_config_rejects_removed_and_unknown_fields() { + let removed_redaction = parse_group_runtime_config_json( + r#"{"enabled": false, "redaction": {"redact_private_tags": true}}"#, + ) + .expect_err("redaction"); + assert!( + removed_redaction + .message() + .contains("unknown field `redaction`") + ); + + let unknown_limit = parse_group_runtime_config_json( + r#"{"enabled": false, "limits": {"max_unimplemented_outbox_batch": 10}}"#, + ) + .expect_err("unknown limit"); + assert!( + unknown_limit + .message() + .contains("unknown field `max_unimplemented_outbox_batch`") + ); + } + + #[test] fn relay_secret_debug_output_is_redacted() { let secret = RelaySecret::from_hex(&"a".repeat(64)).expect("secret"); diff --git a/crates/tangle_runtime/src/config.rs b/crates/tangle_runtime/src/config.rs @@ -133,6 +133,7 @@ impl Default for BaseRelayTracingConfig { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct BaseRelayRuntimeConfigDocument { server: BaseRelayServerConfigDocument, pocket: BaseRelayPocketConfigDocument, @@ -144,12 +145,14 @@ struct BaseRelayRuntimeConfigDocument { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct BaseRelayServerConfigDocument { listen_addr: String, relay_url: String, } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct BaseRelayPocketConfigDocument { data_directory: String, map_size_bytes: u64, @@ -165,23 +168,27 @@ enum BaseRelayPocketSyncPolicyDocument { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct BaseRelayAuthConfigDocument { challenge_ttl_seconds: u64, created_at_skew_seconds: u64, } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct BaseRelayRuntimeLimitsDocument { max_pending_events: usize, } #[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] struct BaseRelayObservabilityConfigDocument { #[serde(default)] tracing: BaseRelayTracingConfigDocument, } #[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] struct BaseRelayTracingConfigDocument { enabled: Option<bool>, filter: Option<String>, @@ -334,4 +341,67 @@ mod tests { "invalid: auth.created_at_skew_seconds must be greater than zero" ); } + + #[test] + fn base_relay_runtime_config_rejects_unknown_fields() { + let unknown_top_level = r#"{ + "server": { + "listen_addr": "127.0.0.1:0", + "relay_url": "wss://relay.radroots.test" + }, + "pocket": { + "data_directory": "runtime/pocket", + "map_size_bytes": 1073741824, + "reader_slots": 128, + "sync_policy": "flush_on_shutdown" + }, + "groups": { + "enabled": false + }, + "auth": { + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 + }, + "limits": { + "max_pending_events": 8 + }, + "ignored": true + }"#; + assert!( + parse_base_relay_runtime_config_json(unknown_top_level) + .expect_err("unknown top-level field") + .prefixed_message() + .contains("unknown field `ignored`") + ); + + let unknown_nested = r#"{ + "server": { + "listen_addr": "127.0.0.1:0", + "relay_url": "wss://relay.radroots.test" + }, + "pocket": { + "data_directory": "runtime/pocket", + "map_size_bytes": 1073741824, + "reader_slots": 128, + "sync_policy": "flush_on_shutdown" + }, + "groups": { + "enabled": false + }, + "auth": { + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 + }, + "limits": { + "max_pending_events": 8, + "max_unimplemented_limit": 99 + } + }"#; + assert!( + parse_base_relay_runtime_config_json(unknown_nested) + .expect_err("unknown nested field") + .prefixed_message() + .contains("unknown field `max_unimplemented_limit`") + ); + } } diff --git a/crates/tangle_runtime/src/groups.rs b/crates/tangle_runtime/src/groups.rs @@ -35,6 +35,7 @@ pub(crate) struct GroupService { policy: GroupPolicyConfig, limits: GroupLimitsConfig, member_snapshot_cap: u32, + outbox_replay_batch_cap: u32, } impl GroupService { @@ -62,6 +63,7 @@ impl GroupService { policy: config.policy(), limits: config.limits(), member_snapshot_cap: config.limits().max_member_list_pubkeys(), + outbox_replay_batch_cap: config.limits().max_outbox_replay_batch(), }; service.derive_missing_outbox_records(store)?; service.materialize_outbox(store)?; @@ -232,8 +234,22 @@ impl GroupService { &mut self, store: &PocketStoreHandle, ) -> Result<Vec<StoreOffset>, BaseRelayError> { - let records = self.outbox.replay_plan().records().to_vec(); - self.materialize_records(store, records) + let mut stored_offsets = Vec::new(); + loop { + let records = self + .outbox + .replay_plan() + .records() + .iter() + .take(self.outbox_replay_batch_cap()) + .cloned() + .collect::<Vec<_>>(); + if records.is_empty() { + break; + } + stored_offsets.extend(self.materialize_records(store, records)?); + } + Ok(stored_offsets) } fn materialize_outbox_for_group( @@ -241,12 +257,27 @@ impl GroupService { store: &PocketStoreHandle, group_id: &GroupId, ) -> Result<Vec<StoreOffset>, BaseRelayError> { - let records = self - .outbox - .replay_plan_for_group(group_id) - .records() - .to_vec(); - self.materialize_records(store, records) + let mut stored_offsets = Vec::new(); + loop { + let records = self + .outbox + .replay_plan_for_group(group_id) + .records() + .iter() + .take(self.outbox_replay_batch_cap()) + .cloned() + .collect::<Vec<_>>(); + if records.is_empty() { + break; + } + stored_offsets.extend(self.materialize_records(store, records)?); + } + Ok(stored_offsets) + } + + fn outbox_replay_batch_cap(&self) -> usize { + usize::try_from(self.outbox_replay_batch_cap) + .expect("u32 outbox replay batch cap fits usize") } fn materialize_records( diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -1321,6 +1321,23 @@ fn pending_and_retryable_group_outbox_records_materialize_on_restart() { } #[test] +fn max_outbox_replay_batch_one_drains_all_pending_generated_records() { + let config = test_store_config("outbox-batch-one"); + let owner_auth = authenticated(FixtureKey::Owner); + let mut relay = + BaseRelay::open_with_groups(&config, 8, &group_config_with_outbox_batch(1)).expect("relay"); + + accept_group_create(&mut relay, "BatchFarm", &[], 1, &owner_auth); + + assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 1); + assert_eq!(count_kind(&relay, KIND_GROUP_ADMINS), 1); + let counts = outbox_status_counts(&config); + assert_eq!(counts.pending, 0); + assert_eq!(counts.retryable, 0); + assert_eq!(counts.stored, 2); +} + +#[test] fn already_stored_generated_events_mark_outbox_stored_without_duplication_on_restart() { let config = test_store_config("outbox-generated-already-stored"); let owner_auth = authenticated(FixtureKey::Owner); @@ -1909,6 +1926,22 @@ fn group_config_with_public_join() -> GroupRuntimeConfig { .expect("groups") } +fn group_config_with_outbox_batch(batch: u32) -> GroupRuntimeConfig { + parse_group_runtime_config_json(&format!( + r#"{{ + "enabled": true, + "canonical_relay_url": "{TANGLE_V2_RELAY_URL}", + "relay_secret": "{TANGLE_V2_RELAY_SECRET_HEX}", + "owner_pubkeys": ["{}"], + "admin_pubkeys": ["{}"], + "limits": {{"max_outbox_replay_batch": {batch}}} + }}"#, + FixtureKey::Owner.public_key().as_str(), + FixtureKey::Admin.public_key().as_str() + )) + .expect("groups") +} + fn authenticated(key: FixtureKey) -> BaseAuthState { let auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 60, 600).expect("auth"); let mut auth = issue_challenge(auth, "challenge-a", 100); diff --git a/crates/tangle_test_support/src/lib.rs b/crates/tangle_test_support/src/lib.rs @@ -6,10 +6,10 @@ use k256::schnorr::{Signature, SigningKey}; use tangle_crypto::{RelaySigner, compute_event_id}; use tangle_groups::{ CanonicalRelayUrl, GroupGeneratedEventBuilder, GroupLimitsConfig, GroupOutboxPayload, - GroupPolicyConfig, GroupRedactionConfig, GroupRuntimeConfig, GroupRuntimeSettingsConfig, - KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, - KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, - KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, RelaySecret, + GroupPolicyConfig, GroupRuntimeConfig, GroupRuntimeSettingsConfig, KIND_GROUP_CREATE_GROUP, + KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, + KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, + RelaySecret, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -88,12 +88,8 @@ pub fn tangle_v2_group_config( Some(RelaySecret::from_hex(TANGLE_V2_RELAY_SECRET_HEX).map_err(|error| error.to_string())?), vec![owner.public_key()], admins.iter().map(|admin| admin.public_key()).collect(), - GroupRuntimeSettingsConfig::new( - GroupPolicyConfig::strict(), - GroupRedactionConfig::strict(), - GroupLimitsConfig::default(), - ) - .map_err(|error| error.to_string())?, + GroupRuntimeSettingsConfig::new(GroupPolicyConfig::strict(), GroupLimitsConfig::default()) + .map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string()) }