open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/tests/dvr.rs
Transaction Science e3f79fa2be Merge TKD #420 (wai.media.takedown) into train/live-room
Relay, by meaning: the DVR store's module text keeps both the minors' room
(AGE) and the takedown (TKD) paragraphs; a session's object loop keeps CO4b's
floor and audience-input filtering and TKD's takedown track feeding the store;
tests/dvr.rs lists both rules. Shared lists kept both sides (workflow step
and path, CHANGELOG, REGISTRY, SPEC §5, live-conformance index and
provenance line, features, registry constants, lib.rs); export regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:43:14 -04:00

1015 lines
57 KiB
Rust

//! The DVR store (WAI live-recording §8): a window of a room's tracks with their receipts,
//! on disk, served to FETCH below the live edge.
//!
//! - **The window.** A long publish crosses the window's byte budget: the oldest groups go,
//! whole, each media group with its receipts group, and the window holds what it says. A
//! window by age empties as its groups pass it.
//! - **FETCH inside, across and before the window**, ascending and descending: inside, every
//! object, each verifying against the fetched receipts; across, an End of Unknown Range
//! for what was evicted and every object after it, from the store and the cache together;
//! before, an End of Unknown Range alone.
//! - **A restart** keeps the store: every object is checked again, a corrupted one (a
//! flipped byte; bytes changed with their checks made again, so only the receipt finds
//! them) is dropped, counted and never served, and a write a crash cut short is removed.
//! - **Moderation.** A room key's redaction withholds its target from every DVR read and
//! erases it from disk; a deny withholds until an allow restores it; a record of an events
//! track is withheld from the object that carries it; a decision by someone who is not a
//! moderator changes nothing; and the decisions hold across a restart.
//! - **The seek power** (WAI live-rooms §10.6): a viewer whose grant lacks it is refused a
//! FETCH and a fill below the live edge (`seek_not_granted`) and served one at the live
//! edge; a viewer whose grant holds it is served the window.
//! - **Metering**: the relay signs `WMH1` version 2, with the store's figures.
//! - **A sealed room** (WAI confidentiality §9) is never kept: its first sealed object is
//! never written, what the store held of the room (its events and receipts, in the clear)
//! is deleted with its decisions, nothing more of it is written, and that holds across a
//! restart, while a room in the clear beside it keeps its window.
//! - **A room in which a minor publishes** (WAI age-assurance §8.4) is never kept either: once
//! a session proves a minor's publishing grant of the room, what the store held of it is
//! deleted, nothing more of it is written, and that holds across a restart, while another
//! room keeps its window; a minor's grant that publishes nothing changes nothing.
//! - **A signed takedown** (WAI takedown §10.1), riding its service's takedown track, makes the
//! store forget what it covers, in every room: an object by its content hash, a perceptual
//! match's candidate, and a track from a group on. None of it is served to a FETCH, from the
//! store or the cache, and its bytes leave the disk; an identical copy published later on
//! another track is never stored; the store signs a purge with the relay's key, once; a
//! takedown by a key the store does not trust, on its own track or on the service's,
//! changes nothing; and all of it holds across a restart.
mod common;
use common::*;
use ed25519_dalek::{Signer, SigningKey};
use joule_moq::carriage::ReceiptBundle;
use joule_moq::draft22::data::{FetchEntry, ObjectStatus, RangeEnd, SubgroupHeader, SubgroupId, SubgroupObject};
use joule_moq::draft22::{GroupOrder, Location, LocationFilter, Parameter, Parameters};
use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder, ReceiptVerifier};
use joule_moq::{GroupId, ObjectId, TrackAlias, TrackNamespace};
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::config::{Authority, DvrConfig, DvrWindow};
use joule_moq_relay::{RelayConfig, RelayServer};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::time::timeout;
const OBJECTS: u64 = 3;
const LEN: usize = 1000;
/// The cache's budget: about three groups with their receipts, so that what is older comes
/// from the store.
const CACHE: usize = 16 * 1024;
fn now_s() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
}
/// A directory of its own for a store.
fn store_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let d = std::env::temp_dir().join(format!("joule-moq-dvr-{name}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn dvr_config(path: &std::path::Path, max_age_s: u64, max_bytes: u64, room_keys: Vec<String>) -> DvrConfig {
DvrConfig { path: path.to_path_buf(), window: vec![DvrWindow { prefix: vec!["*".into()], max_age_s, max_bytes }], room_keys, takedown_keys: vec![] }
}
fn hexkey(k: &SigningKey) -> String {
hex::encode(k.verifying_key().to_bytes())
}
fn object_payload(tag: &str, g: u64, o: u64) -> Vec<u8> {
let mut v = format!("{tag}:{g}:{o}:").into_bytes();
v.resize(LEN, (g * 7 + o) as u8);
v
}
/// The receipts of a group of `payloads`, as group `g` of its receipts track carries them.
fn bundle_of(key: &SigningKey, receipt_alias: u64, g: u64, payloads: &[Vec<u8>]) -> ReceiptBundle {
let mut b = ReceiptBuilder::new(TrackAlias(receipt_alias), GroupId(g));
for p in payloads {
b.add_labelled_object(ObjectReceipt::hash_payload(p), 0, "test-publisher", "2026-10-19T00:00:00Z", vec![0]).unwrap();
}
let objects = payloads
.iter()
.enumerate()
.map(|(i, p)| ObjectReceipt {
track_alias: TrackAlias(receipt_alias),
group_id: GroupId(g),
object_id: ObjectId(i as u64),
content_hash: ObjectReceipt::hash_payload(p),
joules_micro: 0,
origin: "test-publisher".into(),
merkle_proof: b.proof_for(i).unwrap(),
energy_class: Some(vec![0]),
})
.collect();
let group = b.finalize_labelled(key, "test-publisher", "2026-10-19T00:00:01Z", None).unwrap();
ReceiptBundle { group, objects }
}
/// Publish group `g` of `t`: its receipts, then its objects on one subgroup stream.
async fn publish_multi(c: &Client, key: &SigningKey, t: &Tracks, g: u64, tag: &str) {
let payloads: Vec<Vec<u8>> = (0..OBJECTS).map(|o| object_payload(tag, g, o)).collect();
let bundle = bundle_of(key, t.receipt_alias, g, &payloads);
let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts");
let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
let objects: Vec<SubgroupObject> = payloads.into_iter().enumerate().map(|(o, p)| SubgroupObject::normal(o as u64, p)).collect();
c.send_subgroup(mh, &objects).await.expect("the objects");
}
fn room_tracks(room: [u8; 32], publisher: &SigningKey) -> Tracks {
Tracks::new(ns(&[&hex::encode(room), &hexkey(publisher), "video"]), "0", 10, 5, 128)
}
async fn flush(server: &RelayServer) {
let d = server.relay().dvr().expect("a DVR store").clone();
tokio::task::spawn_blocking(move || d.flush()).await.unwrap();
}
/// Wait, bounded, until the store has taken `n` groups: written, or found late (whole
/// after the window had moved past them, under load).
async fn written(server: &RelayServer, n: u64) {
let d = server.relay().dvr().expect("a DVR store").clone();
assert!(until(STEP, || d.counts().groups_written + d.counts().groups_late >= n).await, "{n} groups written: {:?}", d.counts());
flush(server).await;
}
fn range(start: (u64, u64), end_group: u64) -> Parameters {
Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStartGroupEnd { start: Location::new(start.0, start.1), end_group_delta: end_group - start.0 }))
}
/// What a fetch stream carried: its objects by location, and its End of Range entries.
#[derive(Debug, Default)]
struct Fetched {
order: Vec<(u64, u64)>,
objects: BTreeMap<(u64, u64), Vec<u8>>,
unknown: Vec<Location>,
other_ends: Vec<(RangeEnd, Location)>,
}
fn fetched(entries: Vec<FetchEntry>) -> Fetched {
let mut f = Fetched::default();
for e in entries {
match e {
FetchEntry::Object(o) => {
f.order.push((o.group_id, o.object_id));
f.objects.insert((o.group_id, o.object_id), o.payload);
}
FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location } => f.unknown.push(location),
FetchEntry::EndOfRange { kind, location } => f.other_ends.push((kind, location)),
}
}
f
}
/// FETCH a range of the media track and of its receipts track; every media object fetched
/// verifies against the fetched receipts of its group under the publisher's key. Returns
/// the media fetched.
async fn fetch_verified(c: &Client, t: &Tracks, key: &SigningKey, params: Parameters) -> Fetched {
let (_, media) = c.fetch(t.ns.clone(), t.media.clone(), params.clone()).await.expect("FETCH the media track");
let (_, rec) = c.fetch(t.ns.clone(), t.receipts.clone(), params).await.expect("FETCH the receipts track");
let media = fetched(media);
let rec = fetched(rec);
let v = ReceiptVerifier::with_anchors(vec![key.verifying_key()]);
for ((g, o), p) in &media.objects {
let b = ReceiptBundle::from_payload(rec.objects.get(&(*g, 0)).unwrap_or_else(|| panic!("the receipts of group {g} come with it"))).unwrap();
let vg = v.verify_group(&b.group).expect("the group receipt verifies");
let r = b.objects.iter().find(|r| r.object_id == ObjectId(*o)).expect("a receipt for the object");
v.verify_payload(p, r, &vg).unwrap_or_else(|e| panic!("({g}, {o}) verifies against its receipt: {e}"));
}
media
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_window_and_fetch_inside_across_and_before_it() {
timeout(Duration::from_secs(180), window_run()).await.expect("the test finishes in time");
}
async fn window_run() {
const GROUPS: u64 = 40;
let dir = store_dir("window");
let room = [0x51; 32];
let publisher = SigningKey::from_bytes(&[0x52; 32]);
let t = room_tracks(room, &publisher);
// About seven groups of media and receipts in the window; the cache holds fewer.
let max_bytes = 30_000;
let cfg = dvr_config(&dir, 900, max_bytes, vec![]);
let server = relay(|c| {
c.cache.max_bytes = CACHE;
c.dvr = Some(cfg.clone());
})
.await;
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _pub = publish_tracks(&p, &t, None).await;
for g in 0..GROUPS {
publish_multi(&p, &publisher, &t, g, "w").await;
}
written(&server, 2 * GROUPS).await;
let d = server.relay().dvr().unwrap().clone();
let media_key = (t.ns.clone(), t.media.clone());
let rec_key = (t.ns.clone(), t.receipts.clone());
let held = d.groups(&media_key);
let first = *held.first().expect("groups held");
assert!(first > 0, "the oldest groups were evicted");
assert_eq!(held, (first..GROUPS).collect::<Vec<_>>(), "the window is the newest groups, no gap");
assert_eq!(d.groups(&rec_key), held, "each media group goes with its receipts group");
let counts = d.counts();
assert!(counts.bytes_held <= max_bytes, "the window holds its budget: {counts:?}");
assert_eq!(counts.groups_evicted + counts.groups_late, 2 * first, "whole groups, media and receipts");
let all = GROUPS * (OBJECTS + 1);
assert!(counts.objects_written <= all && counts.objects_written + counts.groups_late * OBJECTS >= all, "every object, but a group the window had passed: {counts:?}");
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
// Inside the window: every object, verified, in the requested order.
let inside = fetch_verified(&v, &t, &publisher, range((first, 0), first + 2)).await;
assert_eq!(inside.objects.len() as u64, 3 * OBJECTS);
assert!(inside.unknown.is_empty() && inside.other_ends.is_empty());
for ((g, o), p) in &inside.objects {
assert_eq!(p, &object_payload("w", *g, *o));
}
let desc = fetch_verified(&v, &t, &publisher, range((first, 0), first + 2).with(Parameter::GroupOrder(GroupOrder::Descending))).await;
let groups: Vec<u64> = desc.order.iter().map(|x| x.0).collect();
assert_eq!(groups, [first + 2, first + 2, first + 2, first + 1, first + 1, first + 1, first, first, first], "descending groups, objects ascending");
assert!(desc.order.windows(2).all(|w| w[0].0 != w[1].0 || w[0].1 < w[1].1));
// Across: what was evicted is unknown, and the rest comes, from the store and the cache.
let across = fetch_verified(&v, &t, &publisher, Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }))).await;
assert_eq!(across.unknown, vec![Location::new(first - 1, u64::MAX)], "End of Unknown Range through the last evicted group");
assert!(across.other_ends.is_empty());
assert_eq!(across.objects.len() as u64, (GROUPS - first) * OBJECTS, "every object the window holds and the cache's");
assert_eq!(across.order.first(), Some(&(first, 0)));
assert_eq!(across.order.last(), Some(&(GROUPS - 1, OBJECTS - 1)));
assert!(server.relay().stats().fetch_served_from_dvr >= 2);
// Before: nothing but the End of Unknown Range.
assert!(first > 3);
let (ok, before) = v.fetch(t.ns.clone(), t.media.clone(), range((0, 0), 2)).await.expect("a FETCH before the window");
assert_eq!(ok.end_location, Location::new(2, u64::MAX));
let before = fetched(before);
assert!(before.objects.is_empty());
assert_eq!(before.unknown, vec![Location::new(2, u64::MAX)]);
// The record of the room is version 2, with the store's figures.
v.close(0, "done");
p.close(0, "done");
assert!(until(2 * STEP, || server.relay().summaries().len() >= 2).await, "both sessions ended: {:?}; open: {:?}", server.relay().stats(), server.relay().session_diagnostics());
let rec = server.relay().hop_record(room, 0);
let bytes = rec.to_bytes();
assert_eq!(&bytes[..5], b"WMH1\x02", "version 2");
rec.verify().expect("the record verifies under the relay's key");
let back = joule_moq_relay::metering::HopRecord::decode(&bytes, &rec.ledger_bytes()).expect("it reads back");
assert_eq!(back, rec);
let dv = rec.dvr.expect("the DVR figures");
let served: u64 = server.relay().summaries().iter().flat_map(|s| s.summary.tally.subscriber.tracks.values().map(|x| x.dvr_objects)).sum();
assert_eq!(dv.objects_out, served);
assert!(dv.objects_out >= 2 * 3 * OBJECTS, "the inside fetches came from the store: {dv:?}");
let out: u64 = rec.entries.iter().filter(|e| e.dir == 2).map(|e| e.objects).sum();
let inn: u64 = rec.entries.iter().filter(|e| e.dir == 1).map(|e| e.objects).sum();
assert!(dv.objects_out <= out && dv.objects_stored <= inn, "{dv:?}, out {out}, in {inn}");
assert_eq!(dv.objects_stored, counts.objects_written, "every object of the room written in the span");
assert!(dv.byte_s > 0 && dv.dropped == 0);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_window_by_age_empties_as_its_groups_pass_it() {
timeout(Duration::from_secs(60), async {
let dir = store_dir("age");
let room = [0x53; 32];
let publisher = SigningKey::from_bytes(&[0x54; 32]);
let t = room_tracks(room, &publisher);
let cfg = dvr_config(&dir, 2, 1 << 30, vec![]);
let server = relay(|c| {
// About two groups: what aged out of the store is out of the cache too.
c.cache.max_bytes = 8 * 1024;
c.dvr = Some(cfg.clone());
})
.await;
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _pub = publish_tracks(&p, &t, None).await;
for g in 0..3 {
publish_multi(&p, &publisher, &t, g, "a").await;
}
written(&server, 6).await;
let d = server.relay().dvr().unwrap().clone();
let (key, rkey) = ((t.ns.clone(), t.media.clone()), (t.ns.clone(), t.receipts.clone()));
assert!(until(Duration::from_secs(10), || d.groups(&key).is_empty() && d.groups(&rkey).is_empty()).await, "past max_age_s every group goes");
assert_eq!(d.counts().groups_evicted, 6);
// The next group, fetched at once: what aged out is unknown, and the group is served.
publish_multi(&p, &publisher, &t, 3, "a").await;
// The relay has taken group 3 into both tracks before the fetches: taking it may
// evict a pair from the cache, and the media and receipts FETCHes are two requests,
// so a pair evicted between them would leave media without receipts.
let relay = server.relay().clone();
assert!(
until(STEP, || relay.largest(&key) == Some(Location::new(3, OBJECTS - 1)) && relay.largest(&rkey) == Some(Location::new(3, 0))).await,
"the relay took group 3"
);
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let f = fetch_verified(&v, &t, &publisher, range((0, 0), 3)).await;
// What aged out is unknown through the last group neither the store nor the cache
// holds; every group after it comes whole.
assert_eq!(f.unknown.len(), 1);
let k = f.unknown[0];
assert!(k.object == u64::MAX && k.group < 3, "{k:?}");
let want: Vec<(u64, u64)> = (k.group + 1..=3).flat_map(|g| (0..OBJECTS).map(move |o| (g, o))).collect();
assert_eq!(f.objects.keys().copied().collect::<Vec<_>>(), want);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
})
.await
.expect("in time");
}
/// Rewrite one object of the group record whose frame is at `frame_at` in a segment, in
/// place: `f` changes its payload (same length); with `fix`, its digest and check are made
/// again so that only its receipt finds it.
fn tamper(path: &std::path::Path, frame_at: u64, object: u64, fix: bool, f: impl Fn(&mut [u8])) {
let mut b = std::fs::read(path).unwrap();
let r = frame_at as usize + 5;
let count = u32::from_le_bytes(b[r + 53..r + 57].try_into().unwrap()) as u64;
let mut p = r + 93;
for i in 0..count {
let props_len = u32::from_le_bytes(b[p + 18..p + 22].try_into().unwrap()) as usize;
let payload_len = u32::from_le_bytes(b[p + 22..p + 26].try_into().unwrap()) as usize;
let at = p + 90 + props_len;
if i == object {
f(&mut b[at..at + payload_len]);
if fix {
let digest = ObjectReceipt::hash_payload(&b[at..at + payload_len]);
b[p + 26..p + 58].copy_from_slice(&digest);
let mut c = ring::digest::Context::new(&ring::digest::SHA256);
c.update(&b[p..p + 58]);
c.update(&b[p + 90..at + payload_len]);
let check = c.finish();
b[p + 58..p + 90].copy_from_slice(check.as_ref());
}
std::fs::write(path, &b).unwrap();
return;
}
p = at + payload_len;
}
panic!("no object {object}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_restart_keeps_the_store_and_drops_what_does_not_verify() {
timeout(Duration::from_secs(120), async {
const GROUPS: u64 = 10;
let dir = store_dir("restart");
let room = [0x55; 32];
let publisher = SigningKey::from_bytes(&[0x56; 32]);
let t = room_tracks(room, &publisher);
let cfg = dvr_config(&dir, 900, 1 << 30, vec![]);
let config = |c: &mut RelayConfig| {
c.cache.max_bytes = CACHE;
c.dvr = Some(cfg.clone());
};
let server = relay(config).await;
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _pub = publish_tracks(&p, &t, None).await;
for g in 0..GROUPS {
publish_multi(&p, &publisher, &t, g, "r").await;
}
written(&server, 2 * GROUPS).await;
let key = (t.ns.clone(), t.media.clone());
let d = server.relay().dvr().unwrap().clone();
let ((g3, at3), (g5, at5), (g9, _)) = (d.group_location(&key, 3).unwrap(), d.group_location(&key, 5).unwrap(), d.group_location(&key, 9).unwrap());
p.close(0, "done");
server.shutdown().await;
// A flipped byte in (3, 1); (5, 0) rewritten with its checks made again; and a frame a
// crash cut short at the end of a segment.
tamper(&g3, at3, 1, false, |p| p[10] ^= 0x20);
tamper(&g5, at5, 0, true, |p| p.reverse());
let whole = std::fs::metadata(&g9).unwrap().len();
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new().append(true).open(&g9).unwrap();
f.write_all(&1000u32.to_le_bytes()).unwrap();
f.write_all(&[1, b'W', b'D', b'G', b'1']).unwrap();
}
let server = relay(config).await;
let d = server.relay().dvr().unwrap().clone();
let c = d.counts();
assert_eq!((c.objects_dropped, c.torn_writes), (2, 1), "{c:?}");
assert_eq!(std::fs::metadata(&g9).unwrap().len(), whole, "the cut-short frame is cut off");
assert_eq!(d.groups(&key), (0..GROUPS).collect::<Vec<_>>());
// No publisher now: the store answers alone.
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let f = fetch_verified(&v, &t, &publisher, Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }))).await;
// An End of Range moves to a later group than the entry before it (MOQT §11.4.1.2),
// so (3, 1)'s marks group 3 from its start: (3, 0) goes with it.
assert_eq!(f.unknown, vec![Location::new(3, 1), Location::new(5, 0)], "each in its place as unknown");
assert!(!f.objects.contains_key(&(3, 1)) && !f.objects.contains_key(&(5, 0)), "never served");
assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 3, "every object but the two dropped and the one before (3, 1)");
assert!(f.objects.contains_key(&(3, 2)) && f.objects.contains_key(&(5, 1)), "the objects after them follow");
assert_eq!(server.relay().largest(&key), Some(Location::new(GROUPS - 1, OBJECTS - 1)));
// The dropped objects are out of their files too: a second restart finds nothing to drop.
server.shutdown().await;
let server = relay(config).await;
assert_eq!(server.relay().dvr().unwrap().counts().objects_dropped, 0);
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let f = fetch_verified(&v, &t, &publisher, range((3, 0), 5)).await;
assert_eq!(f.unknown, vec![Location::new(3, 1), Location::new(5, 0)]);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
})
.await
.expect("in time");
}
/// A record of live-events §3.1, without an anchor, signed by `key`.
fn event(key: &SigningKey, room: [u8; 32], seq: u64, t: u64, kind: u8, payload: &[u8]) -> Vec<u8> {
let mut b = b"WEV1\x01".to_vec();
b.extend_from_slice(&room);
b.extend_from_slice(&key.verifying_key().to_bytes());
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&[0; 32]);
b.extend_from_slice(&t.to_le_bytes());
b.push(0);
b.push(kind);
b.extend_from_slice(&(payload.len() as u16).to_le_bytes());
b.extend_from_slice(payload);
let mut m = b"wai:live-event\x01".to_vec();
m.extend_from_slice(&b);
b.extend_from_slice(&key.sign(&m).to_bytes());
b
}
fn event_digest(rec: &[u8]) -> [u8; 32] {
ring::digest::digest(&ring::digest::SHA256, &rec[..rec.len() - 64]).as_ref().try_into().unwrap()
}
fn decision(key: &SigningKey, room: [u8; 32], seq: u64, verdict: u8, target: [u8; 32]) -> Vec<u8> {
let mut p = vec![verdict];
p.extend_from_slice(&target);
p.extend_from_slice(&1u16.to_le_bytes());
event(key, room, seq, 1_000 + seq, 0x0B, &p)
}
fn msf(records: &[&[u8]]) -> Vec<u8> {
use base64::Engine;
let e: Vec<String> = records.iter().map(|r| format!("{{\"t\":1,\"data\":{{\"wev1\":\"{}\"}}}}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r))).collect();
format!("[{}]", e.join(",")).into_bytes()
}
/// A principal's events track: PUBLISH it, and send objects of group 0 one stream each.
struct EventsTrack {
ns: TrackNamespace,
alias: u64,
}
impl EventsTrack {
async fn open(c: &Client, room: [u8; 32], who: &SigningKey, alias: u64) -> (EventsTrack, (joule_moq_relay::wire::ReqTx, joule_moq_relay::wire::ReqRx)) {
let ns = ns(&[&hex::encode(room), &hexkey(who), "events"]);
let held = c.publish(ns.clone(), name("0"), alias, Parameters::new(), track_props(16, None)).await.expect("PUBLISH an events track");
(EventsTrack { ns, alias }, held)
}
/// Group `g`'s objects: the join set, then one object a record.
async fn group(&self, c: &Client, g: u64, records: &[Vec<u8>]) {
let h = SubgroupHeader { track_alias: self.alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(16), properties: false, end_of_group: true, first_object: true };
let mut objects = vec![SubgroupObject::normal(0, b"[]".to_vec())];
for (i, r) in records.iter().enumerate() {
objects.push(SubgroupObject::normal(i as u64 + 1, msf(&[r])));
}
c.send_subgroup(h, &objects).await.expect("the events");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn decisions_withhold_from_the_store_and_hold_across_a_restart() {
timeout(Duration::from_secs(120), async {
const GROUPS: u64 = 6;
let dir = store_dir("moderation");
let room = [0x57; 32];
let publisher = SigningKey::from_bytes(&[0x58; 32]);
let moderator = SigningKey::from_bytes(&[0x59; 32]);
let t = room_tracks(room, &publisher);
let cfg = dvr_config(&dir, 900, 1 << 30, vec![hexkey(&moderator)]);
let config = |c: &mut RelayConfig| {
c.cache.max_bytes = CACHE;
c.dvr = Some(cfg.clone());
};
let server = relay(config).await;
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _pub = publish_tracks(&p, &t, None).await;
for g in 0..GROUPS {
publish_multi(&p, &publisher, &t, g, "m").await;
}
// The publisher's events: two chat messages.
let (pe, _pe_held) = EventsTrack::open(&p, room, &publisher, 40).await;
let chat1 = event(&publisher, room, 0, 10, 0x03, b"\x02\x00hi");
let chat2 = event(&publisher, room, 1, 11, 0x03, b"\x03\x00bye");
pe.group(&p, 0, &[chat1.clone(), chat2.clone()]).await;
written(&server, 2 * GROUPS + 1).await;
let d = server.relay().dvr().unwrap().clone();
let h = |g: u64, o: u64| ObjectReceipt::hash_payload(&object_payload("m", g, o));
// The moderator (a room key): redact (2, 1), deny (3, 0), redact the first chat. The
// publisher, no moderator, redacts (4, 0): nothing comes of it.
let (m, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let (me, _me_held) = EventsTrack::open(&m, room, &moderator, 50).await;
me.group(&m, 0, &[decision(&moderator, room, 0, 3, h(2, 1)), decision(&moderator, room, 1, 2, h(3, 0)), decision(&moderator, room, 2, 3, event_digest(&chat1))]).await;
pe.group(&p, 1, &[decision(&publisher, room, 2, 3, h(4, 0))]).await;
assert!(until(STEP, || d.counts().decisions >= 3 && d.counts().objects_redacted >= 2).await, "{:?}", d.counts());
assert_eq!(d.counts().decisions, 3, "the publisher's decision is not honoured");
let all = || Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }));
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let f = fetch_verified(&v, &t, &publisher, all()).await;
assert_eq!(f.unknown, vec![Location::new(2, 1), Location::new(3, 0)], "withheld, each in its place");
assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 3, "and (2, 0), which an End of Range through (2, 1) covers");
assert!(f.objects.contains_key(&(2, 2)) && f.objects.contains_key(&(3, 1)));
assert!(f.objects.contains_key(&(4, 0)), "a decision by no moderator withholds nothing");
// The redacted object's bytes are gone from every file of its track; the denied one's stay.
let on_disk = |key: &(TrackNamespace, joule_moq::TrackName), bytes: &[u8]| -> bool {
let dir = d.track_dir(key).unwrap();
std::fs::read_dir(dir).unwrap().flatten().any(|e| std::fs::read(e.path()).unwrap().windows(bytes.len()).any(|w| w == bytes))
};
let media = (t.ns.clone(), t.media.clone());
assert!(!on_disk(&media, &object_payload("m", 2, 1)), "redacted: erased from disk");
assert!(on_disk(&media, &object_payload("m", 3, 0)), "denied: kept");
// The redacted chat is erased from the events track's files; the other stays.
let ev_key = (pe.ns.clone(), name("0"));
assert!(!on_disk(&ev_key, &msf(&[&chat1])), "redacted: erased from disk");
assert!(on_disk(&ev_key, &msf(&[&chat2])));
// The publisher's events: the redacted chat is withheld, the other is served as sent.
let (_, ev) = v.fetch(pe.ns.clone(), name("0"), all()).await.expect("FETCH the events track");
let ev = fetched(ev);
assert_eq!(ev.unknown, vec![Location::new(0, 1)], "the object holding the redacted record");
assert_eq!(ev.objects.get(&(0, 2)), Some(&msf(&[&chat2])));
// An allow supersedes the deny: (3, 0) is served again.
me.group(&m, 1, &[decision(&moderator, room, 3, 1, h(3, 0))]).await;
assert!(until(STEP, || d.counts().decisions >= 4).await);
let f = fetch_verified(&v, &t, &publisher, all()).await;
assert_eq!(f.unknown, vec![Location::new(2, 1)]);
assert!(f.objects.contains_key(&(3, 0)));
assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS - 2);
// Across a restart the decisions hold.
for c in [&p, &m, &v] {
c.close(0, "done");
}
server.shutdown().await;
let server = relay(config).await;
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let f = fetch_verified(&v, &t, &publisher, all()).await;
assert_eq!(f.unknown, vec![Location::new(2, 1)]);
let (_, ev) = v.fetch(pe.ns.clone(), name("0"), all()).await.expect("FETCH the events track");
assert_eq!(fetched(ev).unknown, vec![Location::new(0, 1)]);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
})
.await
.expect("in time");
}
/// Publish group `g` of `t` sealed: its receipts, then objects each carrying a secure-objects
/// Key ID in its Immutable Properties (WAI confidentiality §4.3).
async fn publish_sealed(c: &Client, key: &SigningKey, t: &Tracks, g: u64) {
use joule_moq::secure_objects::{immutable_properties, object_properties};
let payloads: Vec<Vec<u8>> = (0..OBJECTS).map(|o| object_payload("sealed", g, o)).collect();
let bundle = bundle_of(key, t.receipt_alias, g, &payloads);
let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts");
let props = object_properties(&immutable_properties(7, &[]), &[]);
let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: true, end_of_group: true, first_object: true };
let objects: Vec<SubgroupObject> = payloads.into_iter().enumerate().map(|(o, p)| SubgroupObject { object_id: o as u64, properties: props.clone(), status: ObjectStatus::Normal, payload: p }).collect();
c.send_subgroup(mh, &objects).await.expect("the sealed objects");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_sealed_room_is_never_kept() {
timeout(Duration::from_secs(120), async {
let dir = store_dir("sealed");
let (sealed, clear) = ([0x5a; 32], [0x5b; 32]);
let publisher = SigningKey::from_bytes(&[0x5c; 32]);
let moderator = SigningKey::from_bytes(&[0x5d; 32]);
let ts = room_tracks(sealed, &publisher);
let tc = Tracks::new(ns(&[&hex::encode(clear), &hexkey(&publisher), "video"]), "0", 20, 6, 128);
let cfg = dvr_config(&dir, 900, 1 << 30, vec![hexkey(&moderator)]);
let config = |c: &mut RelayConfig| {
c.cache.max_bytes = CACHE;
c.dvr = Some(cfg.clone());
};
let server = relay(config).await;
let d = server.relay().dvr().unwrap().clone();
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _ps = publish_tracks(&p, &ts, None).await;
let _pc = publish_tracks(&p, &tc, None).await;
// Before the sealed room's first media group: its events, in the clear, and a
// moderator's decision in each room.
let (pe, _pe_held) = EventsTrack::open(&p, sealed, &publisher, 40).await;
let chat = event(&publisher, sealed, 0, 10, 0x03, b"\x02\x00hi");
pe.group(&p, 0, &[chat]).await;
let (m, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let (ms, _ms_held) = EventsTrack::open(&m, sealed, &moderator, 50).await;
let (mc, _mc_held) = EventsTrack::open(&m, clear, &moderator, 60).await;
let (ds, dc) = (decision(&moderator, sealed, 0, 2, [1; 32]), decision(&moderator, clear, 0, 2, [2; 32]));
ms.group(&m, 0, std::slice::from_ref(&ds)).await;
mc.group(&m, 0, std::slice::from_ref(&dc)).await;
for g in 0..2 {
publish_multi(&p, &publisher, &tc, g, "c").await;
}
written(&server, 7).await;
assert!(until(STEP, || d.counts().decisions >= 2).await, "{:?}", d.counts());
let ev_key = (pe.ns.clone(), name("0"));
let ev_dir = d.track_dir(&ev_key).expect("the sealed room's events, kept so far");
assert!(ev_dir.exists() && d.groups(&ev_key) == vec![0]);
let log = dir.join("decisions.wdl");
let holds = |path: &std::path::Path, bytes: &[u8]| std::fs::read(path).unwrap_or_default().windows(bytes.len()).any(|w| w == bytes);
assert!(holds(&log, &ds) && holds(&log, &dc));
// The first sealed group: never written, and the room is forgotten.
publish_sealed(&p, &publisher, &ts, 0).await;
assert!(until(STEP, || d.counts().rooms_sealed == 1).await, "{:?}", d.counts());
flush(&server).await;
let room_keys = [(ts.ns.clone(), ts.media.clone()), (ts.ns.clone(), ts.receipts.clone()), ev_key.clone(), (ms.ns.clone(), name("0"))];
for k in &room_keys {
assert!(d.groups(k).is_empty() && d.track_dir(k).is_none(), "{k:?} is kept");
}
assert!(!ev_dir.exists(), "the sealed room's events are deleted from disk");
assert!(!holds(&log, &ds), "its decision leaves the log");
assert!(holds(&log, &dc), "the clear room's stays");
assert_eq!(std::fs::metadata(dir.join("sealed.wdl")).unwrap().len(), 64);
let clear_media = (tc.ns.clone(), tc.media.clone());
assert_eq!(d.groups(&clear_media), vec![0, 1], "the clear room keeps its window");
// Nothing more of it is written: its events and its next sealed group, then a group
// of the clear room, which is.
pe.group(&p, 1, &[event(&publisher, sealed, 1, 11, 0x03, b"\x03\x00bye")]).await;
publish_sealed(&p, &publisher, &ts, 1).await;
assert!(until(STEP, || server.relay().largest(&(ts.ns.clone(), ts.media.clone())) == Some(Location::new(1, OBJECTS - 1))).await);
publish_multi(&p, &publisher, &tc, 2, "c").await;
assert!(until(STEP, || d.groups(&clear_media).contains(&2)).await);
flush(&server).await;
for k in &room_keys {
assert!(d.groups(k).is_empty(), "{k:?} is written after the room was sealed");
}
// Across a restart: the room is still sealed, nothing of it is loaded or written, and
// only the clear room's decision is honoured again.
for c in [&p, &m] {
c.close(0, "done");
}
server.shutdown().await;
let server = relay(config).await;
let d = server.relay().dvr().unwrap().clone();
assert_eq!((d.counts().rooms_sealed, d.counts().decisions), (1, 1), "{:?}", d.counts());
assert_eq!(d.groups(&clear_media), vec![0, 1, 2]);
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let (pe, _pe_held) = EventsTrack::open(&p, sealed, &publisher, 40).await;
pe.group(&p, 2, &[event(&publisher, sealed, 2, 12, 0x03, b"\x02\x00ok")]).await;
let _pc = publish_tracks(&p, &tc, None).await;
publish_multi(&p, &publisher, &tc, 3, "c").await;
assert!(until(STEP, || d.groups(&clear_media).contains(&3)).await);
flush(&server).await;
assert!(d.groups(&ev_key).is_empty() && d.track_dir(&ev_key).is_none());
p.close(0, "done");
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
})
.await
.expect("in time");
}
/// Publish group `g` of `t` with these payloads: its receipts, then its objects.
async fn publish_payloads(c: &Client, key: &SigningKey, t: &Tracks, g: u64, payloads: Vec<Vec<u8>>) {
let bundle = bundle_of(key, t.receipt_alias, g, &payloads);
let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts");
let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
let objects: Vec<SubgroupObject> = payloads.into_iter().enumerate().map(|(o, p)| SubgroupObject::normal(o as u64, p)).collect();
c.send_subgroup(mh, &objects).await.expect("the objects");
}
/// A takedown as WAI takedown §5.3 lays it out, signed by `by`, naming `exact` and `matched`.
fn takedown_record(by: &SigningKey, exact: Vec<serde_json::Value>, matched: Vec<serde_json::Value>) -> (Vec<u8>, [u8; 32]) {
let mut v = serde_json::json!({
"kind": "wai-takedown", "policy": "00".repeat(32), "notice": null, "clock": null, "clock_start": null, "reply_at": null,
"targets": [exact[0].clone()], "verdict": "remove", "exact": exact, "matched": matched, "ground": "illegal", "category": 3,
"automated": {"detection": "no", "decision": "not"}, "explanation": "The item is unlawful.", "facts": "A notice named it.",
"decided_at": "2026-10-21T08:00:00Z", "signer_pubkey": hex::encode(by.verifying_key().to_bytes()),
});
let sig = by.sign(&joule_moq_relay::takedown::signing_bytes(&v).unwrap());
v["sig"] = serde_json::json!(hex::encode(sig.to_bytes()));
let link = joule_moq_relay::takedown::link_hash(&v).unwrap();
(serde_json::to_vec(&v).unwrap(), link)
}
/// Publish `records` on the takedown track of `owner`, `[owner, takedown]`, `0`: one group,
/// one object a record.
async fn publish_takedowns(c: &Client, owner: &SigningKey, alias: u64, records: &[Vec<u8>]) -> (joule_moq_relay::wire::ReqTx, joule_moq_relay::wire::ReqRx) {
let held = c.publish(ns(&[&hexkey(owner), "takedown"]), name("0"), alias, Parameters::new(), track_props(16, None)).await.expect("PUBLISH a takedown track");
let h = SubgroupHeader { track_alias: alias, group_id: 0, subgroup_id: SubgroupId::Zero, publisher_priority: Some(16), properties: false, end_of_group: true, first_object: true };
let objects: Vec<SubgroupObject> = records.iter().enumerate().map(|(i, r)| SubgroupObject::normal(i as u64, r.clone())).collect();
c.send_subgroup(h, &objects).await.expect("the takedowns");
held
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_signed_takedown_forgets_what_it_covers_and_holds_across_a_restart() {
timeout(Duration::from_secs(150), async {
const GROUPS: u64 = 5;
let dir = store_dir("takedown");
let key_dir = store_dir("takedown-key");
let key_file = key_dir.join("relay.key");
std::fs::write(&key_file, [0x67u8; 32]).unwrap();
let (room, room2) = ([0x63; 32], [0x68; 32]);
let publisher = SigningKey::from_bytes(&[0x64; 32]);
let service = SigningKey::from_bytes(&[0x65; 32]);
let stranger = SigningKey::from_bytes(&[0x66; 32]);
let t = room_tracks(room, &publisher);
let t2 = Tracks::new(ns(&[&hex::encode(room2), &hexkey(&publisher), "video"]), "0", 30, 7, 128);
let mut cfg = dvr_config(&dir, 900, 1 << 30, vec![]);
cfg.takedown_keys = vec![hexkey(&service)];
let config = |c: &mut RelayConfig| {
c.cache.max_bytes = CACHE;
c.dvr = Some(cfg.clone());
c.summary.key_path = Some(key_file.clone());
};
let server = relay(config).await;
let d = server.relay().dvr().unwrap().clone();
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _pub = publish_tracks(&p, &t, None).await;
for g in 0..GROUPS {
publish_multi(&p, &publisher, &t, g, "td").await;
}
written(&server, 2 * GROUPS).await;
let h = |g: u64, o: u64| ObjectReceipt::hash_payload(&object_payload("td", g, o));
let object = |x: [u8; 32]| serde_json::json!({"type": "object", "content_hash": hex::encode(x)});
let media = (t.ns.clone(), t.media.clone());
let track = joule_moq_relay::dvr::room_track_digest(&media);
let exact = vec![object(h(1, 1)), serde_json::json!({"type": "track", "track": hex::encode(track), "from_group": 3})];
let matched = vec![serde_json::json!({"match": "aa".repeat(32), "candidate": hex::encode(h(0, 2))})];
// A stranger's takedown, on its own track and on the service's: neither is honoured.
let (strange, _) = takedown_record(&stranger, exact.clone(), matched.clone());
let (s, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _st = publish_takedowns(&s, &stranger, 70, std::slice::from_ref(&strange)).await;
// The service's track carries the stranger's record, then the service's own.
let (rec, link) = takedown_record(&service, exact.clone(), matched.clone());
let (m, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _sv = publish_takedowns(&m, &service, 71, &[strange.clone(), rec.clone()]).await;
let purge_path = d.purge_path(&link);
assert!(until(STEP, || purge_path.exists()).await, "the purge is written: {:?}", d.counts());
assert_eq!(d.counts().takedowns, 1, "only the service's takedown is honoured");
assert_eq!(d.takedown(&strange).map_err(|_| ()), Err(()), "a stranger's record is refused");
// FETCH: what it covers is withheld, from the store and the cache alike; the rest
// verifies against its receipts.
let all = || Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(0, 0) }));
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
// A group's End of Unknown Range runs through its last withheld object, and covers the
// objects before it (as a moderation's does): groups 3 and 4 are withheld whole.
let unknown = vec![Location::new(0, 2), Location::new(1, 1), Location::new(3, OBJECTS - 1), Location::new(4, OBJECTS - 1)];
let want: std::collections::BTreeSet<(u64, u64)> = [(1, 2), (2, 0), (2, 1), (2, 2)].into_iter().collect();
let f = fetch_verified(&v, &t, &publisher, all()).await;
assert_eq!(f.unknown, unknown);
assert_eq!(f.objects.keys().copied().collect::<std::collections::BTreeSet<_>>(), want);
// Its bytes are gone from every file of the track.
let on_disk = |bytes: &[u8]| -> bool {
let dir = d.track_dir(&media).unwrap();
std::fs::read_dir(dir).unwrap().flatten().any(|e| std::fs::read(e.path()).unwrap().windows(bytes.len()).any(|w| w == bytes))
};
for (g, o) in [(1, 1), (0, 2), (3, 0), (4, 2)] {
assert!(!on_disk(&object_payload("td", g, o)), "({g}, {o}) erased from disk");
}
assert!(on_disk(&object_payload("td", 2, 0)), "what it does not cover stays");
// The purge: signed with the relay's key, naming each object and the track.
let purge = std::fs::read_to_string(&purge_path).unwrap();
let pv: serde_json::Value = serde_json::from_str(&purge).unwrap();
let sig: [u8; 64] = hex::decode(pv["sig"].as_str().unwrap()).unwrap().try_into().unwrap();
let relay_key = server.relay().summary_key().to_bytes();
assert_eq!(pv["signer_pubkey"], hex::encode(relay_key));
assert!(joule_moq_relay::takedown::verify_strict(&relay_key, &joule_moq_relay::takedown::signing_bytes(&pv).unwrap(), &sig));
assert_eq!(pv["takedown"], hex::encode(link));
assert_eq!(pv["kind"], "wai-takedown-purge");
let named: Vec<String> = pv["subjects"].as_array().unwrap().iter().map(|x| joule_moq_relay::takedown::jcs(x).unwrap()).collect();
let mut sorted = named.clone();
sorted.sort();
assert_eq!(named, sorted, "ascending");
assert_eq!(named.len(), 3, "the object, the candidate and the track");
assert!(named.contains(&joule_moq_relay::takedown::jcs(&object(h(1, 1))).unwrap()) && named.contains(&joule_moq_relay::takedown::jcs(&object(h(0, 2))).unwrap()));
// Across a restart: the takedown holds, the purge is the same bytes, and an identical
// copy published on another track is never stored or served.
for c in [&p, &s, &m, &v] {
c.close(0, "done");
}
server.shutdown().await;
let server = relay(config).await;
let d = server.relay().dvr().unwrap().clone();
assert_eq!((d.counts().takedowns, d.counts().purges_signed), (1, 0), "{:?}", d.counts());
assert_eq!(std::fs::read_to_string(&purge_path).unwrap(), purge);
let (v, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let f = fetch_verified(&v, &t, &publisher, all()).await;
assert_eq!(f.unknown, unknown);
assert_eq!(f.objects.keys().copied().collect::<std::collections::BTreeSet<_>>(), want);
let (p, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let _pub2 = publish_tracks(&p, &t2, None).await;
publish_payloads(&p, &publisher, &t2, 0, vec![object_payload("td", 1, 1), object_payload("copy", 0, 1), object_payload("copy", 0, 2)]).await;
let media2 = (t2.ns.clone(), t2.media.clone());
assert!(until(STEP, || d.groups(&media2).contains(&0)).await, "{:?}", d.counts());
flush(&server).await;
let f = fetch_verified(&v, &t2, &publisher, all()).await;
assert_eq!(f.objects.keys().copied().collect::<Vec<_>>(), vec![(0, 1), (0, 2)], "the identical copy is withheld");
let dir2 = d.track_dir(&media2).unwrap();
let copy = object_payload("td", 1, 1);
assert!(!std::fs::read_dir(dir2).unwrap().flatten().any(|e| std::fs::read(e.path()).unwrap().windows(copy.len()).any(|w| w == copy)), "and never stored");
for c in [&p, &v] {
c.close(0, "done");
}
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&key_dir);
})
.await
.expect("in time");
}
/// A grant laid out as live-rooms §4.1 lays it out, with these media bits and powers.
fn grant(authority: &SigningKey, room: [u8; 32], principal: &SigningKey, publish: u8, powers: u8) -> Vec<u8> {
grant_with(authority, room, principal, publish, powers, &[])
}
/// The same grant with these attributes (live-rooms §4.4), already laid out.
fn grant_with(authority: &SigningKey, room: [u8; 32], principal: &SigningKey, publish: u8, powers: u8, attrs: &[u8]) -> Vec<u8> {
let now = now_s();
let mut g = b"WRG1\x01".to_vec();
g.extend_from_slice(&room);
g.extend_from_slice(&principal.verifying_key().to_bytes());
g.extend_from_slice(&[0; 40]);
g.extend_from_slice(&(now - 60).to_le_bytes());
g.extend_from_slice(&(now + 600).to_le_bytes());
g.extend_from_slice(&[0, publish, 1, 0, powers, 0]);
g.extend_from_slice(&[0; 32]);
g.extend_from_slice(b"WEP1\x01");
g.extend_from_slice(&[0; 32]);
g.extend_from_slice(&[0; 28]);
g.extend_from_slice(&[0xff; 16]);
g.extend_from_slice(&[0; 3]);
g.extend_from_slice(&[0, 0]); // no profile, no origin
g.extend_from_slice(&(attrs.len() as u16).to_le_bytes());
g.extend_from_slice(attrs);
let mut m = joule_moq::grant_proof::GRANT_DOMAIN.to_vec();
m.extend_from_slice(&g);
g.extend_from_slice(&authority.sign(&m).to_bytes());
g
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn seeking_back_needs_the_seek_power() {
timeout(Duration::from_secs(120), async {
const GROUPS: u64 = 6;
let dir = store_dir("seek");
let authority = SigningKey::from_bytes(&[0x5a; 32]);
let (publisher, plain, seeker) = (SigningKey::from_bytes(&[0x5b; 32]), SigningKey::from_bytes(&[0x5c; 32]), SigningKey::from_bytes(&[0x5d; 32]));
let room = [0x5e; 32];
let t = room_tracks(room, &publisher);
let cfg = dvr_config(&dir, 900, 1 << 30, vec![]);
let server = relay(|c| {
c.cache.max_bytes = CACHE;
c.dvr = Some(cfg.clone());
c.auth.required = true;
c.auth.authorities = vec![Authority { key: hexkey(&authority), rooms: vec![] }];
})
.await;
let target = format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path);
let with = |g: Vec<u8>, k: &SigningKey| ClientOptions { grants: vec![(g, k.clone())], target: target.clone(), ..ClientOptions::default() };
let (p, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &publisher, 0b010, 0), &publisher)).await;
let _pub = publish_tracks(&p, &t, None).await;
for g in 0..GROUPS {
publish_multi(&p, &publisher, &t, g, "s").await;
}
written(&server, 2 * GROUPS).await;
let (a, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &plain, 0, 0), &plain)).await;
let (b, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &seeker, 0, 1 << 4), &seeker)).await;
// Without the power: refused below the live edge, a FETCH and a fill alike.
let refused = a.fetch(t.ns.clone(), t.media.clone(), range((0, 0), GROUPS - 1)).await.expect_err("below the live edge");
assert!(refused.contains("seek_not_granted") && refused.contains(&joule_moq::draft22::RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}");
let fill = Parameters::new().with(Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(1, 0) }));
let params = Parameters::new().with(Parameter::LocationFilter(LocationFilter::NextObject)).with(Parameter::FillParameters(fill));
let refused = a.subscribe(t.ns.clone(), t.media.clone(), params).await.expect_err("a fill below the live edge");
assert!(refused.contains("seek_not_granted"), "{refused}");
// At the live edge: served.
let f = fetch_verified(&a, &t, &publisher, range((GROUPS - 1, 0), GROUPS - 1)).await;
assert_eq!(f.objects.len() as u64, OBJECTS);
// With it: the window.
let f = fetch_verified(&b, &t, &publisher, range((0, 0), GROUPS - 1)).await;
assert_eq!(f.objects.len() as u64, GROUPS * OBJECTS);
assert!(server.relay().stats().seek_refused >= 2);
for c in [&p, &a, &b] {
c.close(0, "done");
}
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
})
.await
.expect("in time");
}
/// The attribute `minor` = `true` (live-rooms §4.4): a short key and a long value.
fn minor_attr() -> Vec<u8> {
[&[5u8][..], b"minor", &4u16.to_le_bytes(), b"true"].concat()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_minors_room_is_never_kept() {
timeout(Duration::from_secs(120), async {
let dir = store_dir("minors");
let authority = SigningKey::from_bytes(&[0x6a; 32]);
let (publisher, other, minor, watcher) = (SigningKey::from_bytes(&[0x6b; 32]), SigningKey::from_bytes(&[0x6c; 32]), SigningKey::from_bytes(&[0x6d; 32]), SigningKey::from_bytes(&[0x6e; 32]));
let (room, clear) = ([0x6f; 32], [0x70; 32]);
let t = room_tracks(room, &publisher);
let tc = room_tracks(clear, &other);
let cfg = dvr_config(&dir, 900, 1 << 30, vec![]);
let config = |c: &mut RelayConfig| {
c.cache.max_bytes = CACHE;
c.dvr = Some(cfg.clone());
c.auth.required = true;
c.auth.authorities = vec![Authority { key: hexkey(&authority), rooms: vec![] }];
};
let server = relay(config).await;
let d = server.relay().dvr().unwrap().clone();
let target = format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path);
let with = |g: Vec<u8>, k: &SigningKey| ClientOptions { grants: vec![(g, k.clone())], target: target.clone(), ..ClientOptions::default() };
let (p, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &publisher, 0b010, 0), &publisher)).await;
let (o, _) = connect(&server, Via::WebSocket, with(grant(&authority, clear, &other, 0b010, 0), &other)).await;
let _pt = publish_tracks(&p, &t, None).await;
let _pc = publish_tracks(&o, &tc, None).await;
for g in 0..2 {
publish_multi(&p, &publisher, &t, g, "m").await;
publish_multi(&o, &other, &tc, g, "c").await;
}
written(&server, 8).await;
let media = (t.ns.clone(), t.media.clone());
let clear_media = (tc.ns.clone(), tc.media.clone());
assert_eq!(d.groups(&media), vec![0, 1], "kept until a minor publishes in the room");
let media_dir = d.track_dir(&media).expect("the room's track, kept so far");
// A minor's grant that publishes nothing: nothing changes.
let (w, _) = connect(&server, Via::WebSocket, with(grant_with(&authority, room, &watcher, 0, 0, &minor_attr()), &watcher)).await;
publish_multi(&p, &publisher, &t, 2, "m").await;
assert!(until(STEP, || d.groups(&media).contains(&2)).await, "a minor's grant that publishes nothing keeps the window");
assert_eq!(d.counts().rooms_sealed, 0);
// A minor's publishing grant, proved: the room is forgotten and never kept again.
let (m, _) = connect(&server, Via::WebSocket, with(grant_with(&authority, room, &minor, 0b011, 0, &minor_attr()), &minor)).await;
assert!(until(STEP, || d.counts().rooms_sealed == 1).await, "{:?}", d.counts());
flush(&server).await;
let receipts = (t.ns.clone(), t.receipts.clone());
for k in [&media, &receipts] {
assert!(d.groups(k).is_empty() && d.track_dir(k).is_none(), "{k:?} is kept");
}
assert!(!media_dir.exists(), "the room's media is deleted from disk");
assert_eq!(std::fs::metadata(dir.join("sealed.wdl")).unwrap().len(), 64, "the room is recorded among those never kept");
publish_multi(&p, &publisher, &t, 3, "m").await;
publish_multi(&o, &other, &tc, 2, "c").await;
assert!(until(STEP, || server.relay().largest(&media) == Some(Location::new(3, OBJECTS - 1))).await, "the room is still delivered");
assert!(until(STEP, || d.groups(&clear_media).contains(&2)).await);
flush(&server).await;
assert!(d.groups(&media).is_empty(), "nothing more of the room is written");
assert_eq!(d.groups(&clear_media), vec![0, 1, 2], "the other room keeps its window");
// Across a restart, the room stays forgotten.
for c in [&p, &o, &w, &m] {
c.close(0, "done");
}
server.shutdown().await;
let server = relay(config).await;
let d = server.relay().dvr().unwrap().clone();
assert_eq!(d.counts().rooms_sealed, 1, "{:?}", d.counts());
// The restarted relay listens on another port: its proofs name the new target.
let target = format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path);
let with = |g: Vec<u8>, k: &SigningKey| ClientOptions { grants: vec![(g, k.clone())], target: target.clone(), ..ClientOptions::default() };
let (p, _) = connect(&server, Via::WebSocket, with(grant(&authority, room, &publisher, 0b010, 0), &publisher)).await;
let _pt = publish_tracks(&p, &t, None).await;
publish_multi(&p, &publisher, &t, 4, "m").await;
assert!(until(STEP, || server.relay().largest(&media) == Some(Location::new(4, OBJECTS - 1))).await);
flush(&server).await;
assert!(d.groups(&media).is_empty() && d.track_dir(&media).is_none());
assert_eq!(d.groups(&clear_media), vec![0, 1, 2]);
p.close(0, "done");
server.shutdown().await;
let _ = std::fs::remove_dir_all(&dir);
})
.await
.expect("in time");
}
#[test]
fn a_window_past_a_day_is_refused() {
let cfg = dvr_config(&PathBuf::from("/nonexistent"), 86_401, 1, vec![]);
let mut c = RelayConfig::loopback();
c.dvr = Some(cfg);
assert!(joule_moq_relay::Relay::new(c).is_err());
}