the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it
This commit is contained in:
1 parent
8b511b20e7
commit
bf81792c49
15 files changed
+3207
-10
No files matched your search
@@ -20,6 +20,7 @@ pub mod friend;
|
||||
pub mod playlist;
|
||||
pub mod inbox;
|
||||
pub mod space;
|
||||
pub mod proposals;
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
//! The collaborative-playlist proposal board (F7 → the app). One board per
|
||||
//! shared playlist, keyed by (owner, playlist uuid); a MULTI-WRITER contract
|
||||
//! in the inbox mold — anyone may offer, `merge_board` is the bouncer.
|
||||
//!
|
||||
//! Three record families:
|
||||
//! - **AuthorityCheckpoint** — the owner's signed contributor roster with a
|
||||
//! monotonic epoch, co-located with the board. Highest (epoch, hash) wins;
|
||||
//! an epoch that drops an author IS revocation, and that author's pending
|
||||
//! proposals leave the board deterministically.
|
||||
//! - **Proposal** — immutable, contributor-signed, bound to an EXACT base
|
||||
//! revision (the F3 content hash) and valid only while its author is on
|
||||
//! the CURRENT roster. Bounded ops, bounded size, per-author and total
|
||||
//! caps (newest per author kept).
|
||||
//! - **Decision** — owner-signed accept/reject with a monotonic sequence:
|
||||
//! the audit trail. A decided proposal leaves the pending set and no stale
|
||||
//! replica can bring it back. The canonical playlist never lives here — an
|
||||
//! accepted proposal becomes an ordinary owner-signed revision + pointer
|
||||
//! bump outside.
|
||||
//!
|
||||
//! BYTE-COMPATIBILITY IS THE CONTRACT: these shapes are bincode-identical to
|
||||
//! the research crate the bundled `proposal_contract.wasm` was proven with
|
||||
//! (research/freenet-f7); the entry types are `playlist::EntryV1` itself.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::playlist as f3;
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const PARAMS_FAMILY: &[u8] = b"soulstream.playlist-props.v1";
|
||||
/// Proposals and decisions sign under the playlist domain — the delegate
|
||||
/// allowlist has no separate board entry, and a board IS playlist business.
|
||||
pub const SIGN_DOMAIN: &str = f3::SIGN_DOMAIN;
|
||||
|
||||
pub const MAX_STATE_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_CONTRIBUTORS: usize = 64;
|
||||
pub const MAX_OPS_PER_PROPOSAL: usize = 32;
|
||||
pub const MAX_PROPOSAL_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_PROPOSALS: usize = 128;
|
||||
pub const PER_AUTHOR_PENDING_CAP: usize = 8;
|
||||
pub const MAX_DECISIONS: usize = 256;
|
||||
pub const MAX_REASON_BYTES: usize = 128;
|
||||
|
||||
fn verify(public: &[u8; 32], body: &[u8], sig: &[u8]) -> bool {
|
||||
crate::verify_sig_in(SIGN_DOMAIN, public, &sign_payload(body), sig)
|
||||
}
|
||||
|
||||
/// What the delegate is handed for any board record: blake3 of its sig body.
|
||||
pub fn sign_payload(body: &[u8]) -> Vec<u8> {
|
||||
blake3::hash(body).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- records
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthorityCheckpoint {
|
||||
pub epoch: u64,
|
||||
pub contributors: Vec<[u8; 32]>,
|
||||
pub sig: Vec<u8>, // owner
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckpointSigBody<'a> {
|
||||
epoch: u64,
|
||||
contributors: &'a [[u8; 32]],
|
||||
}
|
||||
|
||||
pub fn checkpoint_sig_body(c: &AuthorityCheckpoint) -> Vec<u8> {
|
||||
bincode::serialize(&CheckpointSigBody { epoch: c.epoch, contributors: &c.contributors }).expect("serializes")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProposalOp {
|
||||
Add { position: u32, entry: f3::EntryV1 },
|
||||
Remove { entry_id: [u8; 16] },
|
||||
Move { entry_id: [u8; 16], to_position: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Proposal {
|
||||
pub proposal_id: [u8; 16],
|
||||
pub author: [u8; 32],
|
||||
pub authority_epoch: u64,
|
||||
pub base_revision_hash: [u8; 32],
|
||||
pub ops: Vec<ProposalOp>,
|
||||
pub note: String,
|
||||
pub created_at: u64,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProposalSigBody<'a> {
|
||||
proposal_id: &'a [u8; 16],
|
||||
author: &'a [u8; 32],
|
||||
authority_epoch: u64,
|
||||
base_revision_hash: &'a [u8; 32],
|
||||
ops: &'a [ProposalOp],
|
||||
note: &'a str,
|
||||
created_at: u64,
|
||||
}
|
||||
|
||||
pub fn proposal_sig_body(p: &Proposal) -> Vec<u8> {
|
||||
bincode::serialize(&ProposalSigBody {
|
||||
proposal_id: &p.proposal_id,
|
||||
author: &p.author,
|
||||
authority_epoch: p.authority_epoch,
|
||||
base_revision_hash: &p.base_revision_hash,
|
||||
ops: &p.ops,
|
||||
note: &p.note,
|
||||
created_at: p.created_at,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Decision {
|
||||
pub proposal_id: [u8; 16],
|
||||
pub accept: bool,
|
||||
pub seq: u64,
|
||||
pub reason: String,
|
||||
/// When accepted: the revision the owner published applying it.
|
||||
pub result_revision_hash: Option<[u8; 32]>,
|
||||
pub sig: Vec<u8>, // owner
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DecisionSigBody<'a> {
|
||||
proposal_id: &'a [u8; 16],
|
||||
accept: bool,
|
||||
seq: u64,
|
||||
reason: &'a str,
|
||||
result_revision_hash: &'a Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
pub fn decision_sig_body(d: &Decision) -> Vec<u8> {
|
||||
bincode::serialize(&DecisionSigBody {
|
||||
proposal_id: &d.proposal_id,
|
||||
accept: d.accept,
|
||||
seq: d.seq,
|
||||
reason: &d.reason,
|
||||
result_revision_hash: &d.result_revision_hash,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BoardState {
|
||||
pub schema_version: u32,
|
||||
pub owner: [u8; 32],
|
||||
pub playlist_uuid: [u8; 16],
|
||||
pub authority: Option<AuthorityCheckpoint>,
|
||||
pub proposals: Vec<Proposal>,
|
||||
pub decisions: Vec<Decision>,
|
||||
}
|
||||
|
||||
pub fn make_params(owner: &[u8; 32], uuid: &[u8; 16]) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48);
|
||||
p.extend_from_slice(PARAMS_FAMILY);
|
||||
p.extend_from_slice(owner);
|
||||
p.extend_from_slice(uuid);
|
||||
p
|
||||
}
|
||||
|
||||
pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> {
|
||||
let t = PARAMS_FAMILY.len();
|
||||
if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY {
|
||||
return None;
|
||||
}
|
||||
Some((params[t..t + 32].try_into().unwrap(), params[t + 32..t + 48].try_into().unwrap()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- merge
|
||||
|
||||
pub fn checkpoint_valid(owner: &[u8; 32], c: &AuthorityCheckpoint) -> bool {
|
||||
c.contributors.len() <= MAX_CONTRIBUTORS && verify(owner, &checkpoint_sig_body(c), &c.sig)
|
||||
}
|
||||
|
||||
pub fn proposal_valid(p: &Proposal) -> bool {
|
||||
if p.ops.is_empty() || p.ops.len() > MAX_OPS_PER_PROPOSAL {
|
||||
return false;
|
||||
}
|
||||
if p.note.len() > f3::MAX_DESC_BYTES {
|
||||
return false;
|
||||
}
|
||||
if bincode::serialized_size(p).map(|n| n as usize > MAX_PROPOSAL_BYTES).unwrap_or(true) {
|
||||
return false;
|
||||
}
|
||||
for op in &p.ops {
|
||||
if let ProposalOp::Add { entry, .. } = op {
|
||||
for s in [&entry.recording.title, &entry.recording.album] {
|
||||
if s.len() > f3::MAX_FIELD_BYTES {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if entry.note.as_deref().map(str::len).unwrap_or(0) > f3::MAX_NOTE_BYTES {
|
||||
return false;
|
||||
}
|
||||
if entry.slsk_hint.as_deref().map(str::len).unwrap_or(0) > f3::MAX_FIELD_BYTES {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
verify(&p.author, &proposal_sig_body(p), &p.sig)
|
||||
}
|
||||
|
||||
pub fn decision_valid(owner: &[u8; 32], d: &Decision) -> bool {
|
||||
d.reason.len() <= MAX_REASON_BYTES && verify(owner, &decision_sig_body(d), &d.sig)
|
||||
}
|
||||
|
||||
/// Deterministic multi-writer merge — the bouncer. Every delivery order of
|
||||
/// the same candidates yields the same bytes.
|
||||
pub fn merge_board(owner: &[u8; 32], uuid: &[u8; 16], candidates: &[&[u8]]) -> BoardState {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
let mut parsed: Vec<BoardState> = Vec::new();
|
||||
for bytes in candidates {
|
||||
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(st) = bincode::deserialize::<BoardState>(bytes) else { continue };
|
||||
if st.schema_version != SCHEMA_VERSION || &st.owner != owner || &st.playlist_uuid != uuid {
|
||||
continue;
|
||||
}
|
||||
parsed.push(st);
|
||||
}
|
||||
|
||||
// 1. The winning authority checkpoint: highest (epoch, blake3) valid one.
|
||||
let mut best_auth: Option<(u64, [u8; 32], AuthorityCheckpoint)> = None;
|
||||
for st in &parsed {
|
||||
if let Some(c) = &st.authority {
|
||||
if checkpoint_valid(owner, c) {
|
||||
let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes());
|
||||
let better = match &best_auth {
|
||||
None => true,
|
||||
Some((e, h, _)) => rank > (*e, *h),
|
||||
};
|
||||
if better {
|
||||
best_auth = Some((rank.0, rank.1, c.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let authority = best_auth.map(|(_, _, c)| c);
|
||||
|
||||
// 2. Decisions: union by proposal id, highest (seq, hash) per proposal;
|
||||
// the oldest age out past the cap — the window is honest.
|
||||
let mut dec_by_id: BTreeMap<[u8; 16], (u64, [u8; 32], Decision)> = BTreeMap::new();
|
||||
for st in &parsed {
|
||||
for d in &st.decisions {
|
||||
if !decision_valid(owner, d) {
|
||||
continue;
|
||||
}
|
||||
let rank = (d.seq, *blake3::hash(&decision_sig_body(d)).as_bytes());
|
||||
match dec_by_id.get(&d.proposal_id) {
|
||||
Some((s, h, _)) if (*s, *h) >= rank => {}
|
||||
_ => {
|
||||
dec_by_id.insert(d.proposal_id, (rank.0, rank.1, d.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut decisions: Vec<Decision> = dec_by_id.into_values().map(|(_, _, d)| d).collect();
|
||||
decisions.sort_by_key(|d| (d.seq, d.proposal_id));
|
||||
if decisions.len() > MAX_DECISIONS {
|
||||
let cut = decisions.len() - MAX_DECISIONS;
|
||||
decisions.drain(0..cut);
|
||||
}
|
||||
|
||||
// 3. Proposals: union by id (a collision keeps the smaller hash), gated
|
||||
// on the CURRENT roster, undecided only, then the caps (newest per
|
||||
// author).
|
||||
let decided: BTreeSet<[u8; 16]> = decisions.iter().map(|d| d.proposal_id).collect();
|
||||
let roster: Vec<[u8; 32]> = authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default();
|
||||
let mut prop_by_id: BTreeMap<[u8; 16], ([u8; 32], Proposal)> = BTreeMap::new();
|
||||
for st in &parsed {
|
||||
for p in &st.proposals {
|
||||
if decided.contains(&p.proposal_id) {
|
||||
continue;
|
||||
}
|
||||
if !roster.contains(&p.author) {
|
||||
continue;
|
||||
}
|
||||
if !proposal_valid(p) {
|
||||
continue;
|
||||
}
|
||||
let h = *blake3::hash(&bincode::serialize(p).unwrap()).as_bytes();
|
||||
match prop_by_id.get(&p.proposal_id) {
|
||||
Some((eh, _)) if eh <= &h => {}
|
||||
_ => {
|
||||
prop_by_id.insert(p.proposal_id, (h, p.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut all: Vec<Proposal> = prop_by_id.into_values().map(|(_, p)| p).collect();
|
||||
all.sort_by_key(|p| (p.created_at, p.proposal_id));
|
||||
let mut per_author: BTreeMap<[u8; 32], Vec<Proposal>> = BTreeMap::new();
|
||||
for p in all {
|
||||
per_author.entry(p.author).or_default().push(p);
|
||||
}
|
||||
let mut proposals: Vec<Proposal> = Vec::new();
|
||||
for (_, mut list) in per_author {
|
||||
let keep_from = list.len().saturating_sub(PER_AUTHOR_PENDING_CAP);
|
||||
proposals.extend(list.drain(keep_from..));
|
||||
}
|
||||
proposals.sort_by_key(|p| (p.created_at, p.proposal_id));
|
||||
proposals.truncate(MAX_PROPOSALS);
|
||||
|
||||
BoardState { schema_version: SCHEMA_VERSION, owner: *owner, playlist_uuid: *uuid, authority, proposals, decisions }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apply
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ApplyError {
|
||||
StaleBase { expected: [u8; 32], got: [u8; 32] },
|
||||
RemoveMissing([u8; 16]),
|
||||
MoveMissing([u8; 16]),
|
||||
}
|
||||
|
||||
impl core::fmt::Display for ApplyError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match self {
|
||||
ApplyError::StaleBase { .. } => write!(
|
||||
f,
|
||||
"the proposal targets a revision that is no longer current; ask for a re-submit against the current one"
|
||||
),
|
||||
ApplyError::RemoveMissing(_) => write!(f, "a remove targets an entry not in the base revision"),
|
||||
ApplyError::MoveMissing(_) => write!(f, "a move targets an entry not in the base revision"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure application of a proposal's ops to the CURRENT canonical manifest.
|
||||
/// A stale base is refused outright: exact-base binding is the whole
|
||||
/// concurrency model — the owner rebases by asking for a re-submit, never
|
||||
/// by guessing intent.
|
||||
pub fn apply_proposal(
|
||||
current: &f3::ManifestV1,
|
||||
current_bytes_hash: &[u8; 32],
|
||||
p: &Proposal,
|
||||
) -> Result<Vec<f3::EntryV1>, ApplyError> {
|
||||
if &p.base_revision_hash != current_bytes_hash {
|
||||
return Err(ApplyError::StaleBase { expected: *current_bytes_hash, got: p.base_revision_hash });
|
||||
}
|
||||
let mut entries = current.entries.clone();
|
||||
for op in &p.ops {
|
||||
match op {
|
||||
ProposalOp::Add { position, entry } => {
|
||||
let at = (*position as usize).min(entries.len());
|
||||
entries.insert(at, entry.clone());
|
||||
}
|
||||
ProposalOp::Remove { entry_id } => {
|
||||
let Some(i) = entries.iter().position(|e| &e.entry_id == entry_id) else {
|
||||
return Err(ApplyError::RemoveMissing(*entry_id));
|
||||
};
|
||||
entries.remove(i);
|
||||
}
|
||||
ProposalOp::Move { entry_id, to_position } => {
|
||||
let Some(i) = entries.iter().position(|e| &e.entry_id == entry_id) else {
|
||||
return Err(ApplyError::MoveMissing(*entry_id));
|
||||
};
|
||||
let e = entries.remove(i);
|
||||
let at = (*to_position as usize).min(entries.len());
|
||||
entries.insert(at, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, e) in entries.iter_mut().enumerate() {
|
||||
e.position = i as u32;
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn kp(seed: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[seed; 32])
|
||||
}
|
||||
fn sign(key: &SigningKey, body: &[u8]) -> Vec<u8> {
|
||||
key.sign(&crate::signable(SIGN_DOMAIN, &sign_payload(body))).to_bytes().to_vec()
|
||||
}
|
||||
fn entry(i: u32, title: &str) -> f3::EntryV1 {
|
||||
let mut id = [0u8; 16];
|
||||
id[..4].copy_from_slice(&i.to_le_bytes());
|
||||
id[15] = 0xEE;
|
||||
f3::EntryV1 {
|
||||
entry_id: id,
|
||||
position: i,
|
||||
recording: f3::RecordingRef {
|
||||
musicbrainz_recording_id: None,
|
||||
isrcs: vec![],
|
||||
deezer_id: Some(format!("dz{i}")),
|
||||
apple_music_id: None,
|
||||
title: title.into(),
|
||||
artists: vec!["Artist".into()],
|
||||
album: "Album".into(),
|
||||
duration_ms: Some(180_000),
|
||||
release_year: Some(2000),
|
||||
},
|
||||
note: None,
|
||||
slsk_hint: None,
|
||||
}
|
||||
}
|
||||
fn checkpoint(owner: &SigningKey, epoch: u64, contributors: &[&SigningKey]) -> AuthorityCheckpoint {
|
||||
let mut c = AuthorityCheckpoint {
|
||||
epoch,
|
||||
contributors: contributors.iter().map(|k| k.verifying_key().to_bytes()).collect(),
|
||||
sig: vec![],
|
||||
};
|
||||
c.sig = sign(owner, &checkpoint_sig_body(&c));
|
||||
c
|
||||
}
|
||||
fn proposal(author: &SigningKey, id: u8, epoch: u64, base: [u8; 32], created: u64) -> Proposal {
|
||||
let mut p = Proposal {
|
||||
proposal_id: [id; 16],
|
||||
author: author.verifying_key().to_bytes(),
|
||||
authority_epoch: epoch,
|
||||
base_revision_hash: base,
|
||||
ops: vec![ProposalOp::Add { position: 0, entry: entry(90 + id as u32, "Proposed") }],
|
||||
note: "please add".into(),
|
||||
created_at: created,
|
||||
sig: vec![],
|
||||
};
|
||||
p.sig = sign(author, &proposal_sig_body(&p));
|
||||
p
|
||||
}
|
||||
fn decision(owner: &SigningKey, pid: [u8; 16], accept: bool, seq: u64) -> Decision {
|
||||
let mut d = Decision {
|
||||
proposal_id: pid,
|
||||
accept,
|
||||
seq,
|
||||
reason: if accept { "applied".into() } else { "no".into() },
|
||||
result_revision_hash: accept.then_some([7u8; 32]),
|
||||
sig: vec![],
|
||||
};
|
||||
d.sig = sign(owner, &decision_sig_body(&d));
|
||||
d
|
||||
}
|
||||
fn state(owner: &SigningKey, auth: Option<AuthorityCheckpoint>, proposals: Vec<Proposal>, decisions: Vec<Decision>) -> Vec<u8> {
|
||||
bincode::serialize(&BoardState {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
owner: owner.verifying_key().to_bytes(),
|
||||
playlist_uuid: [7u8; 16],
|
||||
authority: auth,
|
||||
proposals,
|
||||
decisions,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn permute(items: &[usize], f: &mut dyn FnMut(&[usize])) {
|
||||
fn go(cur: &mut Vec<usize>, rest: &mut Vec<usize>, f: &mut dyn FnMut(&[usize])) {
|
||||
if rest.is_empty() {
|
||||
f(cur);
|
||||
return;
|
||||
}
|
||||
for i in 0..rest.len() {
|
||||
let x = rest.remove(i);
|
||||
cur.push(x);
|
||||
go(cur, rest, f);
|
||||
cur.pop();
|
||||
rest.insert(i, x);
|
||||
}
|
||||
}
|
||||
go(&mut Vec::new(), &mut items.to_vec(), f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_converges_over_permutations_and_is_idempotent() {
|
||||
let owner = kp(1);
|
||||
let opub = owner.verifying_key().to_bytes();
|
||||
let b = kp(2);
|
||||
let c = kp(3);
|
||||
let base = [5u8; 32];
|
||||
let cp1 = checkpoint(&owner, 1, &[&b, &c]);
|
||||
let cp2 = checkpoint(&owner, 2, &[&b]);
|
||||
let p_b = proposal(&b, 10, 1, base, 100);
|
||||
let p_c = proposal(&c, 11, 1, base, 101);
|
||||
let d_b = decision(&owner, [10u8; 16], true, 1);
|
||||
let cands = vec![
|
||||
state(&owner, Some(cp1.clone()), vec![p_b.clone(), p_c.clone()], vec![]),
|
||||
state(&owner, Some(cp2.clone()), vec![], vec![]),
|
||||
state(&owner, Some(cp1.clone()), vec![p_c.clone()], vec![d_b.clone()]),
|
||||
state(&owner, None, vec![p_b.clone()], vec![d_b.clone()]),
|
||||
];
|
||||
let uuid = [7u8; 16];
|
||||
let reference = {
|
||||
let refs: Vec<&[u8]> = cands.iter().map(|v| v.as_slice()).collect();
|
||||
merge_board(&opub, &uuid, &refs)
|
||||
};
|
||||
permute(&[0, 1, 2, 3], &mut |order| {
|
||||
let refs: Vec<&[u8]> = order.iter().map(|&i| cands[i].as_slice()).collect();
|
||||
assert_eq!(merge_board(&opub, &uuid, &refs), reference, "diverged for {order:?}");
|
||||
});
|
||||
assert_eq!(reference.authority.as_ref().unwrap().epoch, 2);
|
||||
assert!(reference.proposals.is_empty());
|
||||
assert_eq!(reference.decisions.len(), 1);
|
||||
let rb = bincode::serialize(&reference).unwrap();
|
||||
assert_eq!(merge_board(&opub, &uuid, &[&rb, &rb]), reference);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_author_drops_and_never_re_enters() {
|
||||
let owner = kp(1);
|
||||
let opub = owner.verifying_key().to_bytes();
|
||||
let b = kp(2);
|
||||
let c = kp(3);
|
||||
let uuid = [7u8; 16];
|
||||
let cp1 = checkpoint(&owner, 1, &[&b, &c]);
|
||||
let cp2 = checkpoint(&owner, 2, &[&b]);
|
||||
let p_c_old = proposal(&c, 20, 1, [5u8; 32], 100);
|
||||
let with_c = state(&owner, Some(cp1), vec![p_c_old.clone()], vec![]);
|
||||
let revoked = state(&owner, Some(cp2), vec![], vec![]);
|
||||
let m = merge_board(&opub, &uuid, &[&with_c, &revoked]);
|
||||
assert!(m.proposals.is_empty());
|
||||
let mb = bincode::serialize(&m).unwrap();
|
||||
let offer = state(&owner, None, vec![proposal(&c, 21, 2, [5u8; 32], 200)], vec![]);
|
||||
assert!(merge_board(&opub, &uuid, &[&mb, &offer]).proposals.is_empty());
|
||||
let replay = state(&owner, Some(checkpoint(&owner, 1, &[&b, &c])), vec![p_c_old], vec![]);
|
||||
let m3 = merge_board(&opub, &uuid, &[&mb, &replay]);
|
||||
assert_eq!(m3.authority.as_ref().unwrap().epoch, 2);
|
||||
assert!(m3.proposals.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_oversized_and_non_owner_decisions_never_enter() {
|
||||
let owner = kp(1);
|
||||
let opub = owner.verifying_key().to_bytes();
|
||||
let b = kp(2);
|
||||
let mallory = kp(66);
|
||||
let uuid = [7u8; 16];
|
||||
let cp = checkpoint(&owner, 1, &[&b]);
|
||||
let mut forged = proposal(&mallory, 30, 1, [5u8; 32], 100);
|
||||
forged.author = b.verifying_key().to_bytes();
|
||||
let mut big = Proposal {
|
||||
proposal_id: [31u8; 16],
|
||||
author: b.verifying_key().to_bytes(),
|
||||
authority_epoch: 1,
|
||||
base_revision_hash: [5u8; 32],
|
||||
ops: (0..(MAX_OPS_PER_PROPOSAL as u32 + 1)).map(|i| ProposalOp::Add { position: i, entry: entry(i, "x") }).collect(),
|
||||
note: String::new(),
|
||||
created_at: 101,
|
||||
sig: vec![],
|
||||
};
|
||||
big.sig = sign(&b, &proposal_sig_body(&big));
|
||||
let good = proposal(&b, 32, 1, [5u8; 32], 102);
|
||||
let fake_dec = decision(&mallory, [32u8; 16], true, 1);
|
||||
let st = state(&owner, Some(cp), vec![forged, big, good], vec![fake_dec]);
|
||||
let m = merge_board(&opub, &uuid, &[&st]);
|
||||
assert_eq!(m.proposals.len(), 1);
|
||||
assert_eq!(m.proposals[0].proposal_id, [32u8; 16]);
|
||||
assert!(m.decisions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flood_and_sybil_bounded_newest_kept() {
|
||||
let owner = kp(1);
|
||||
let opub = owner.verifying_key().to_bytes();
|
||||
let b = kp(2);
|
||||
let uuid = [7u8; 16];
|
||||
let cp = checkpoint(&owner, 1, &[&b]);
|
||||
let mut props: Vec<Proposal> = (0..20).map(|i| proposal(&b, 100 + i, 1, [5u8; 32], 1000 + i as u64)).collect();
|
||||
for s in 0..50u8 {
|
||||
props.push(proposal(&kp(150 + s), 200 + s, 1, [5u8; 32], 5000));
|
||||
}
|
||||
let st = state(&owner, Some(cp), props, vec![]);
|
||||
let m = merge_board(&opub, &uuid, &[&st]);
|
||||
assert_eq!(m.proposals.len(), PER_AUTHOR_PENDING_CAP);
|
||||
assert!(m.proposals.iter().all(|p| p.created_at >= 1000 + 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_composes_and_refuses_stale_or_missing() {
|
||||
let b = kp(2);
|
||||
let current = f3::ManifestV1 {
|
||||
schema_version: f3::SCHEMA_VERSION,
|
||||
owner: [1u8; 32],
|
||||
playlist_uuid: [7u8; 16],
|
||||
revision_seq: 1,
|
||||
prev_revision_hash: None,
|
||||
name: "n".into(),
|
||||
description: String::new(),
|
||||
artwork: None,
|
||||
entries: vec![entry(0, "First"), entry(1, "Middle"), entry(2, "Last")],
|
||||
sig: vec![],
|
||||
};
|
||||
let hash = [9u8; 32];
|
||||
let mut p = Proposal {
|
||||
proposal_id: [1u8; 16],
|
||||
author: b.verifying_key().to_bytes(),
|
||||
authority_epoch: 1,
|
||||
base_revision_hash: hash,
|
||||
ops: vec![
|
||||
ProposalOp::Add { position: 99, entry: entry(50, "Appended") },
|
||||
ProposalOp::Move { entry_id: entry(1, "").entry_id, to_position: 0 },
|
||||
ProposalOp::Remove { entry_id: entry(0, "").entry_id },
|
||||
],
|
||||
note: String::new(),
|
||||
created_at: 1,
|
||||
sig: vec![],
|
||||
};
|
||||
p.sig = sign(&b, &proposal_sig_body(&p));
|
||||
let out = apply_proposal(¤t, &hash, &p).unwrap();
|
||||
let titles: Vec<&str> = out.iter().map(|e| e.recording.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Middle", "Last", "Appended"]);
|
||||
assert!(out.iter().enumerate().all(|(i, e)| e.position == i as u32));
|
||||
assert!(matches!(apply_proposal(¤t, &[8u8; 32], &p), Err(ApplyError::StaleBase { .. })));
|
||||
p.ops = vec![ProposalOp::Remove { entry_id: [0xAB; 16] }];
|
||||
assert!(matches!(apply_proposal(¤t, &hash, &p), Err(ApplyError::RemoveMissing(_))));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user