#![cfg(feature = "payload-encryption")]
use std::sync::Arc;
use std::time::Duration;
use batpak::event::EventKind;
use batpak::prelude::*;
use batpak::store::{KeyScopeGranularity, ShredScope, Store, StoreConfig};
use flume::bounded;
use syncbat::{
CompositeSubscriptionRuntime, EntityStreamEnvelopeV1, EventStreamEnvelopeV1, SessionDelivery,
SessionPoll, SubscriptionId, SubscriptionRegistry, SubscriptionRoute,
SubscriptionRuntimeConfig, SubscriptionSession, SubscriptionSessionFactory, SubscriptionStore,
};
const GRAN: KeyScopeGranularity = KeyScopeGranularity::PerEntity;
const EVENT_KIND: EventKind = EventKind::custom(0x0A, 0x01);
const CATEGORY: u8 = 0x0A;
const ENTITY_SUB: &str = "secrets.entity.v1";
const EVENT_SUB: &str = "secrets.category.v1";
const ENTITY: &str = "entity:secrets";
const SCOPE: &str = "scope:vault";
const WIRE_SCHEMA: &str = "batpak.stream-envelope.v1";
fn open_encrypted() -> Result<(Arc<Store>, tempfile::TempDir), Box<dyn std::error::Error>> {
let dir = tempfile::TempDir::new()?;
let store = Store::open(
StoreConfig::new(dir.path())
.with_payload_encryption(GRAN)
.with_enable_checkpoint(false)
.with_enable_mmap_index(false)
.with_sync_every_n_events(1),
)?;
Ok((Arc::new(store), dir))
}
fn append(
store: &Store,
entity: &str,
payload: &serde_json::Value,
) -> Result<(), Box<dyn std::error::Error>> {
let coord = Coordinate::new(entity, SCOPE)?;
let _receipt = store
.append(&coord, EVENT_KIND, payload)
.map_err(|error| -> Box<dyn std::error::Error> { Box::new(error) })?;
Ok(())
}
fn registry() -> Result<SubscriptionRegistry, Box<dyn std::error::Error>> {
let mut registry = SubscriptionRegistry::new();
registry.insert(
SubscriptionId::new(ENTITY_SUB)
.map_err(|e| std::io::Error::other(format!("entity sub id: {e}")))?,
SubscriptionRoute::EntityStream {
entity: ENTITY.to_owned(),
scope: SCOPE.to_owned(),
wire_payload_schema_ref: WIRE_SCHEMA.to_owned(),
inner_event_payload_schema_ref: None,
backpressure_capacity: None,
},
)?;
registry.insert(
SubscriptionId::new(EVENT_SUB)
.map_err(|e| std::io::Error::other(format!("event sub id: {e}")))?,
SubscriptionRoute::EventCategory {
category: CATEGORY,
wire_payload_schema_ref: WIRE_SCHEMA.to_owned(),
inner_event_payload_schema_ref: None,
backpressure_capacity: None,
},
)?;
Ok(registry)
}
fn open_session(
store: Arc<Store>,
registry: &SubscriptionRegistry,
subscription_id: &str,
) -> Result<Box<dyn SubscriptionSession>, Box<dyn std::error::Error>> {
let (_control_tx, control_rx) = bounded(4);
let runtime = CompositeSubscriptionRuntime::new(
SubscriptionStore::new(store),
registry.clone(),
SubscriptionRuntimeConfig::default(),
);
runtime
.open_session(subscription_id, None, 128, control_rx)
.map_err(|error| -> Box<dyn std::error::Error> { Box::new(error) })
}
fn collect(
session: &mut dyn SubscriptionSession,
max_steps: usize,
) -> Result<Vec<SessionDelivery>, Box<dyn std::error::Error>> {
let mut out = Vec::new();
for _ in 0..max_steps {
match session.poll(Duration::from_millis(250))? {
SessionPoll::Delivery(delivery) => {
let done = matches!(
delivery,
SessionDelivery::Error(_) | SessionDelivery::End(_)
);
out.push(delivery);
if done {
break;
}
}
SessionPoll::Blocked | SessionPoll::Ended => break,
}
}
Ok(out)
}
fn entity_notes(deliveries: &[SessionDelivery]) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut notes = Vec::new();
for delivery in deliveries {
match delivery {
SessionDelivery::Event(event) => {
let envelope: EntityStreamEnvelopeV1 =
batpak::canonical::from_bytes(&event.envelope_bytes)?;
let payload: serde_json::Value = batpak::encoding::from_bytes(&envelope.payload)?;
let note = payload
.get("note")
.and_then(serde_json::Value::as_str)
.ok_or("delivered entity payload missing plaintext note")?;
notes.push(note.to_owned());
}
SessionDelivery::Error(_) => return Err("entity delivery faulted".into()),
SessionDelivery::Watermark(_) | SessionDelivery::End(_) => {}
}
}
Ok(notes)
}
fn event_notes(deliveries: &[SessionDelivery]) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut notes = Vec::new();
for delivery in deliveries {
match delivery {
SessionDelivery::Event(event) => {
let envelope: EventStreamEnvelopeV1 =
batpak::canonical::from_bytes(&event.envelope_bytes)?;
let payload: serde_json::Value = batpak::encoding::from_bytes(&envelope.payload)?;
let note = payload
.get("note")
.and_then(serde_json::Value::as_str)
.ok_or("delivered event payload missing plaintext note")?;
notes.push(note.to_owned());
}
SessionDelivery::Error(_) => return Err("event delivery faulted".into()),
SessionDelivery::Watermark(_) | SessionDelivery::End(_) => {}
}
}
Ok(notes)
}
#[test]
fn entity_subscription_over_encrypted_entity_delivers_decrypted(
) -> Result<(), Box<dyn std::error::Error>> {
let (store, _dir) = open_encrypted()?;
append(&store, ENTITY, &serde_json::json!({ "note": "one" }))?;
append(&store, ENTITY, &serde_json::json!({ "note": "two" }))?;
let registry = registry()?;
let mut session = open_session(Arc::clone(&store), ®istry, ENTITY_SUB)?;
let deliveries = collect(session.as_mut(), 8)?;
let notes = entity_notes(&deliveries)?;
assert_eq!(
notes,
vec!["one".to_owned(), "two".to_owned()],
"the entity subscription must deliver the DECRYPTED plaintext, not ciphertext"
);
Ok(())
}
#[test]
fn event_subscription_skips_shredded_and_stays_coherent() -> Result<(), Box<dyn std::error::Error>>
{
let (store, _dir) = open_encrypted()?;
append(
&store,
"entity:doomed",
&serde_json::json!({ "note": "must-never-deliver" }),
)?;
append(
&store,
"entity:live",
&serde_json::json!({ "note": "alpha" }),
)?;
append(
&store,
"entity:live",
&serde_json::json!({ "note": "beta" }),
)?;
let doomed = Coordinate::new("entity:doomed", SCOPE)?;
assert!(
store
.shred_scope(ShredScope::Entity(&doomed))
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?,
"shredding the doomed entity destroys its key"
);
let registry = registry()?;
let mut session = open_session(Arc::clone(&store), ®istry, EVENT_SUB)?;
let deliveries = collect(session.as_mut(), 12)?;
let notes = event_notes(&deliveries)?;
assert_eq!(
notes,
vec!["alpha".to_owned(), "beta".to_owned()],
"the shredded head is skipped LOUDLY yet both later live events deliver decrypted, in \
commit order — delivery stays coherent and never stalls"
);
assert!(
!notes.iter().any(|n| n == "must-never-deliver"),
"the shredded event's plaintext is NEVER delivered (and never as ciphertext)"
);
Ok(())
}