diff --git a/knot2/crates/knot-cobs/src/social.rs b/knot2/crates/knot-cobs/src/social.rs new file mode 100644 index 000000000..3f0dd3cb8 --- /dev/null +++ b/knot2/crates/knot-cobs/src/social.rs @@ -0,0 +1,821 @@ +use std::fmt::Debug; +use std::marker::PhantomData; + +use knot_cob::{ + Append, Authored, Authorize, ChangePayload, Checkpoint, Emits, Evaluate, HistoryModel, + SnapshotStride, StateSize, WriteContext, +}; +use knot_types::{ + AccountDid, ActorId, Blocked, Decision, RecordAddress, RecordCid, RecordCollection, + SubjectNumber, UnixSeconds, +}; +use serde::de::{DeserializeOwned, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Creation { + pub address: RecordAddress, + pub author: AccountDid, + pub number: SubjectNumber, + pub parent: Option, + pub created_at: UnixSeconds, + pub produces: RecordCid, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Editing { + pub author: AccountDid, + pub from: RecordCid, + pub produces: RecordCid, + pub edited_at: UnixSeconds, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Erasure { + pub author: AccountDid, + pub from: RecordCid, + pub erased_at: UnixSeconds, +} + +pub trait SocialBody: + Serialize + DeserializeOwned + Debug + Clone + PartialEq + Eq + Sized +{ + const COLLECTION: &'static str; +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "op", content = "data", rename_all = "snake_case")] +pub enum SocialChange { + Open { creation: Creation, body: B }, + Edit { editing: Editing, body: B }, + Erase { erasure: Erasure }, +} + +pub trait Social { + fn emission(&self) -> Emission<'_>; +} + +impl Social for SocialChange { + fn emission(&self) -> Emission<'_> { + match self { + Self::Open { creation, .. } => Emission::Open(creation), + Self::Edit { editing, .. } => Emission::Edit(editing), + Self::Erase { erasure } => Emission::Erase(erasure), + } + } +} + +impl Authored for SocialChange { + fn author(&self) -> &AccountDid { + self.emission().author() + } +} + +impl ChangePayload for SocialChange { + const TYPE: &'static str = B::COLLECTION; + + fn address(&self) -> Option { + match self { + Self::Open { creation, .. } => Some(creation.address.clone()), + Self::Edit { .. } | Self::Erase { .. } => None, + } + } + + fn emits(&self) -> Emits { + match self.emission().produces() { + Some(produces) => Emits::Version(produces), + None => Emits::Tombstone, + } + } + + fn collection() -> Option { + Some(collection_of::()) + } + + fn stamped_at(&self) -> Option { + Some(self.emission().at()) + } +} + +pub struct SocialCob(PhantomData); + +impl SocialCob { + pub fn collection() -> RecordCollection { + collection_of::() + } +} + +fn collection_of() -> RecordCollection { + RecordCollection::new(B::COLLECTION).expect("Social collection is valid nsid") +} + +impl Evaluate for SocialCob { + type State = SocialState; + type Change = SocialChange; + + const HISTORY: HistoryModel = HistoryModel::Convergent; + + fn initial() -> Self::State { + SocialState::Unopened + } + + fn apply(state: Self::State, change: Self::Change, _author: &ActorId) -> Self::State { + state.fold(&change) + } +} + +impl Checkpoint for SocialCob { + const SNAPSHOT_STRIDE: SnapshotStride = SnapshotStride::new(64); + + fn checkpoint_size(_state: &Self::State) -> StateSize { + StateSize::new(1) + } +} + +impl Authorize for SocialCob { + fn may_create(cx: &WriteContext<'_>) -> Decision { + cx.permission().contributes() + } + + fn may_append(cx: &WriteContext<'_>, state: &Self::State, change: &Self::Change) -> Append { + state.decide(cx, change) + } +} + +#[macro_export] +macro_rules! social_cob { + ( + body = $body:ty, + change = $change:ident, + cob = $cob:ident, + collection = $collection:literal $(,)? + ) => { + impl $crate::SocialBody for $body { + const COLLECTION: &'static str = $collection; + } + + pub type $change = $crate::SocialChange<$body>; + pub type $cob = $crate::SocialCob<$body>; + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Emission<'a> { + Open(&'a Creation), + Edit(&'a Editing), + Erase(&'a Erasure), +} + +impl<'a> Emission<'a> { + pub const fn author(self) -> &'a AccountDid { + match self { + Self::Open(creation) => &creation.author, + Self::Edit(editing) => &editing.author, + Self::Erase(erasure) => &erasure.author, + } + } + + pub const fn from(self) -> Option { + match self { + Self::Open(_) => None, + Self::Edit(editing) => Some(editing.from), + Self::Erase(erasure) => Some(erasure.from), + } + } + + pub const fn produces(self) -> Option { + match self { + Self::Open(creation) => Some(creation.produces), + Self::Edit(editing) => Some(editing.produces), + Self::Erase(_) => None, + } + } + + pub const fn at(self) -> UnixSeconds { + match self { + Self::Open(creation) => creation.created_at, + Self::Edit(editing) => editing.edited_at, + Self::Erase(erasure) => erasure.erased_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SocialObject { + address: RecordAddress, + author: AccountDid, + number: SubjectNumber, + parent: Option, + created_at: UnixSeconds, + editor: AccountDid, + updated_at: UnixSeconds, + version: Option, +} + +impl SocialObject { + pub const fn address(&self) -> &RecordAddress { + &self.address + } + + pub const fn author(&self) -> &AccountDid { + &self.author + } + + pub const fn number(&self) -> SubjectNumber { + self.number + } + + pub const fn parent(&self) -> Option<&RecordAddress> { + self.parent.as_ref() + } + + pub const fn created_at(&self) -> UnixSeconds { + self.created_at + } + + pub const fn editor(&self) -> &AccountDid { + &self.editor + } + + pub const fn updated_at(&self) -> UnixSeconds { + self.updated_at + } + + pub const fn version(&self) -> Option { + self.version + } + + pub const fn is_erased(&self) -> bool { + self.version.is_none() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SocialState { + #[default] + Unopened, + Live(SocialObject), +} +impl SocialState { + pub const fn object(&self) -> Option<&SocialObject> { + match self { + Self::Unopened => None, + Self::Live(object) => Some(object), + } + } + + pub fn fold(self, change: &impl Social) -> Self { + match (self, change.emission()) { + (Self::Unopened, Emission::Open(creation)) => Self::Live(SocialObject { + address: creation.address.clone(), + author: creation.author.clone(), + number: creation.number, + parent: creation.parent.clone(), + created_at: creation.created_at, + editor: creation.author.clone(), + updated_at: creation.created_at, + version: Some(creation.produces), + }), + (Self::Unopened, Emission::Edit(_) | Emission::Erase(_)) => Self::Unopened, + (Self::Live(object), Emission::Open(_)) => Self::Live(object), + (Self::Live(object), emission) if emission.from() != object.version => { + Self::Live(object) + } + (Self::Live(object), emission) => Self::Live(SocialObject { + editor: emission.author().clone(), + updated_at: emission.at(), + version: emission.produces(), + ..object + }), + } + } + + pub fn decide(&self, cx: &WriteContext<'_>, change: &impl Social) -> Append { + let Self::Live(object) = self else { + return Append::Deny; + }; + let emission = change.emission(); + let permission = cx.permission(); + let own = object.author == *cx.actor() && matches!(permission.blocked(), Blocked::No); + let permitted = match emission { + Emission::Open(_) => false, + Emission::Edit(_) => own && permission.contributes().is_allowed(), + Emission::Erase(_) => own || permission.moderates().is_allowed(), + }; + match (permitted, object.version) { + (false, _) | (true, None) => Append::Deny, + (true, Some(current)) if emission.from() != Some(current) => Append::Stale { current }, + (true, Some(_)) => Append::Allow, + } + } +} + +const MAX_BODY_DEPTH: usize = 32; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnyBody; + +// I know, I know, `IgnoredAny` exists. +// However it traverses nested values, and this is user input, and I don't want to traverse +// forever from malicious input. +impl<'de> Deserialize<'de> for AnyBody { + fn deserialize>(deserializer: D) -> Result { + Bounded(MAX_BODY_DEPTH) + .deserialize(deserializer) + .map(|()| Self) + } +} + +struct Bounded(usize); + +impl Bounded { + fn deeper(self) -> Result { + self.0 + .checked_sub(1) + .map(Self) + .ok_or_else(|| E::custom(format!("Body nests deeper than {MAX_BODY_DEPTH} levels"))) + } +} + +impl<'de> DeserializeSeed<'de> for Bounded { + type Value = (); + + fn deserialize>(self, deserializer: D) -> Result<(), D::Error> { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for Bounded { + type Value = (); + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Record body nested at most {MAX_BODY_DEPTH} levels deep") + } + + fn visit_bool(self, _: bool) -> Result<(), E> { + Ok(()) + } + + fn visit_i64(self, _: i64) -> Result<(), E> { + Ok(()) + } + + fn visit_i128(self, _: i128) -> Result<(), E> { + Ok(()) + } + + fn visit_u64(self, _: u64) -> Result<(), E> { + Ok(()) + } + + fn visit_u128(self, _: u128) -> Result<(), E> { + Ok(()) + } + + fn visit_f64(self, _: f64) -> Result<(), E> { + Ok(()) + } + + fn visit_str(self, _: &str) -> Result<(), E> { + Ok(()) + } + + fn visit_bytes(self, _: &[u8]) -> Result<(), E> { + Ok(()) + } + + fn visit_none(self) -> Result<(), E> { + Ok(()) + } + + fn visit_unit(self) -> Result<(), E> { + Ok(()) + } + + fn visit_some>(self, deserializer: D) -> Result<(), D::Error> { + self.deserialize(deserializer) + } + + fn visit_newtype_struct>( + self, + deserializer: D, + ) -> Result<(), D::Error> { + self.deserialize(deserializer) + } + + fn visit_seq>(self, mut seq: A) -> Result<(), A::Error> { + let inner = self.deeper::()?.0; + std::iter::from_fn(|| seq.next_element_seed(Bounded(inner)).transpose()) + .try_for_each(|element| element) + } + + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + let inner = self.deeper::()?.0; + std::iter::from_fn(|| { + map.next_entry_seed(Bounded(inner), Bounded(inner)) + .transpose() + }) + .try_for_each(|entry| entry.map(|((), ())| ())) + } +} + +pub type SocialWire = SocialChange; + +pub fn decode_social(payload: &[u8]) -> Result { + knot_cob::decode_payload(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + use knot_types::{Blocked, RecordBody, RecordRkey, RepoRole}; + use proptest::prelude::*; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct NoteBody { + text: String, + } + + social_cob! { + body = NoteBody, + change = NoteChange, + cob = NoteCob, + collection = "sh.tangled.test.note", + } + + fn who(suffix: u8) -> AccountDid { + AccountDid::new(format!("did:plc:a{suffix}")).unwrap() + } + + fn version(seed: u8) -> RecordCid { + RecordBody::new(vec![seed]).unwrap().cid() + } + + fn address() -> RecordAddress { + RecordAddress::new( + NoteCob::collection(), + RecordRkey::new("3lubrptx57d22").unwrap(), + ) + } + + fn creation(author: u8, seed: u8) -> Creation { + Creation { + address: address(), + author: who(author), + number: SubjectNumber::FIRST, + parent: None, + created_at: UnixSeconds::new(1), + produces: version(seed), + } + } + + fn opened(author: u8, seed: u8) -> SocialWire { + SocialWire::Open { + creation: creation(author, seed), + body: AnyBody, + } + } + + fn edited(author: u8, from: u8, seed: u8, at: i64) -> SocialWire { + SocialWire::Edit { + editing: Editing { + author: who(author), + from: version(from), + produces: version(seed), + edited_at: UnixSeconds::new(at), + }, + body: AnyBody, + } + } + + fn erased(author: u8, from: u8, at: i64) -> SocialWire { + SocialWire::Erase { + erasure: Erasure { + author: who(author), + from: version(from), + erased_at: UnixSeconds::new(at), + }, + } + } + + fn folded(changes: &[SocialWire]) -> SocialState { + changes + .iter() + .fold(SocialState::Unopened, |state, change| state.fold(change)) + } + + #[test] + fn a_change_before_the_root_folds_to_an_unopened_object() { + assert_eq!(folded(&[edited(1, 1, 2, 2)]), SocialState::Unopened); + assert_eq!(folded(&[erased(1, 1, 2)]), SocialState::Unopened); + assert!( + folded(&[opened(1, 1), edited(1, 1, 2, 2)]) + .object() + .is_some_and(|object| object.version() == Some(version(2))), + "Fold follows the version the latest change produced" + ); + } + + #[test] + fn a_second_root_never_moves_an_object_that_is_already_open() { + let once = folded(&[opened(1, 1)]); + let twice = folded(&[opened(1, 1), opened(2, 9)]); + assert_eq!( + once, twice, + "the address the root fixed, and the author that mr root named, both outlive any later root" + ); + } + + #[test] + fn an_erasure_clears_the_version_and_keeps_the_authorship() { + let state = folded(&[opened(1, 1), erased(1, 1, 5)]); + let object = state.object().expect("Erased object still exists"); + assert!(object.is_erased()); + assert_eq!(object.author(), &who(1)); + assert_eq!(object.number(), SubjectNumber::FIRST); + } + + #[test] + fn an_edit_made_from_an_older_version_never_reaches_the_fold() { + let live = folded(&[opened(1, 1), edited(1, 1, 2, 2)]); + let stale = folded(&[opened(1, 1), edited(1, 1, 2, 2), edited(1, 1, 3, 3)]); + assert_eq!( + live, stale, + "Edit names the version it was made from, and fold pins that version there" + ); + } + + #[test] + fn only_one_of_two_changes_from_a_shared_version_is_stored() { + assert!( + folded(&[opened(1, 1), erased(1, 1, 2), edited(1, 1, 3, 3)]) + .object() + .is_some_and(SocialObject::is_erased), + "Edit from version erasure retired doesn't republish the record" + ); + assert_eq!( + folded(&[opened(1, 1), edited(1, 1, 3, 3), erased(1, 1, 2)]) + .object() + .and_then(SocialObject::version), + Some(version(3)), + "erasure chasing version that already moved is stale" + ); + } + + #[test] + fn the_wire_mirror_decodes_what_a_typed_change_encodes() { + let typed = NoteChange::Open { + creation: creation(1, 1), + body: NoteBody { + text: "kelp".to_owned(), + }, + }; + let bytes = typed.encode().unwrap(); + assert_eq!( + decode_social(&bytes).unwrap().emission(), + typed.emission(), + "Indexer reads the same schema the writer wrote, not some copy of it" + ); + assert_eq!( + NoteChange::decode(&bytes).unwrap(), + typed, + "Typed round trip keeps the body the wire mirror drops" + ); + } + + #[test] + fn a_social_change_stamps_itself_with_the_time_it_declares() { + let erasure = NoteChange::Erase { + erasure: Erasure { + author: who(1), + from: version(1), + erased_at: UnixSeconds::new(9), + }, + }; + assert_eq!( + erasure.stamped_at(), + Some(UnixSeconds::new(9)), + "Engine stamps the erasure with the signed clock, not the payload's" + ); + assert_eq!(erasure.emits(), Emits::Tombstone); + assert_eq!(NoteChange::collection(), Some(NoteCob::collection())); + assert_eq!(NoteChange::type_name().as_str(), NoteBody::COLLECTION); + } + + fn permission(role: RepoRole, blocked: Blocked) -> knot_types::ContributionPermission { + knot_types::ContributionPermission::new( + blocked, + role, + knot_types::ContributionPolicy::Anyone, + ) + } + + fn decide( + state: &SocialState, + actor: u8, + permission: knot_types::ContributionPermission, + change: &SocialWire, + ) -> Append { + let actor = who(actor); + let repo = knot_types::RepoDid::new("did:plc:squid").unwrap(); + state.decide(&WriteContext::new(&actor, &repo, permission), change) + } + + #[test] + fn the_blocklist_fronts_an_edit_of_an_account_s_own_object() { + let live = folded(&[opened(1, 1)]); + let edit = edited(1, 1, 2, 2); + let erase = erased(1, 1, 2); + assert_eq!( + decide(&live, 1, permission(RepoRole::Stranger, Blocked::No), &edit), + Append::Allow, + "Author who isn't blocked edits the text said author wrote" + ); + assert_eq!( + decide( + &live, + 1, + permission(RepoRole::Stranger, Blocked::Yes), + &edit + ), + Append::Deny, + "Banned account doesn't rewrite the text this knot publishes for the account" + ); + assert_eq!( + decide( + &live, + 1, + permission(RepoRole::Stranger, Blocked::Yes), + &erase + ), + Append::Deny, + "and doesn't erase text either, which is moderator's verb" + ); + assert_eq!( + decide(&live, 1, permission(RepoRole::Owner, Blocked::Yes), &erase), + Append::Deny, + "Blocklist fronts moderation carve-out the same way" + ); + } + + #[test] + fn an_author_erases_its_own_text_after_the_policy_closed_behind_it() { + let live = folded(&[opened(1, 1)]); + let closed = knot_types::ContributionPermission::new( + Blocked::No, + RepoRole::Stranger, + knot_types::ContributionPolicy::Collaborators, + ); + assert_eq!( + decide(&live, 1, closed, &edited(1, 1, 2, 2)), + Append::Deny, + "Stranger who lost their grant doesn't write a new version" + ); + assert_eq!( + decide(&live, 1, closed, &erased(1, 1, 2)), + Append::Allow, + "but words are still the author's to take back under any policy" + ); + } + + #[test] + fn an_edit_is_the_author_s_and_an_erasure_is_the_author_s_or_a_moderator_s() { + let live = folded(&[opened(1, 1)]); + let open = opened(2, 5); + let good = permission(RepoRole::Stranger, Blocked::No); + assert_eq!( + decide(&live, 2, good, &edited(2, 1, 2, 2)), + Append::Deny, + "Stranger doesn't edit somebody else's text" + ); + assert_eq!( + decide(&live, 2, good, &erased(2, 1, 2)), + Append::Deny, + "and doesn't erase text without moderation carve-out" + ); + assert_eq!( + decide( + &live, + 2, + permission(RepoRole::Collaborator, Blocked::No), + &erased(2, 1, 2) + ), + Append::Allow, + "Collaborator takes down the text the repository publishes" + ); + assert_eq!( + decide( + &live, + 2, + permission(RepoRole::Collaborator, Blocked::No), + &edited(2, 1, 2, 2) + ), + Append::Deny, + "moderation is a takedown, not a rewrite of somebody else's words" + ); + assert_eq!( + decide(&live, 1, good, &open), + Append::Deny, + "Root, and only root, opens object" + ); + assert_eq!( + decide(&SocialState::Unopened, 1, good, &edited(1, 1, 2, 2)), + Append::Deny, + "Object nobody opened doesn't take appends" + ); + } + + #[test] + fn an_edit_from_a_version_that_moved_on_reads_the_current_one() { + let live = folded(&[opened(1, 1), edited(1, 1, 2, 2)]); + let good = permission(RepoRole::Stranger, Blocked::No); + assert_eq!( + decide(&live, 1, good, &edited(1, 1, 3, 3)), + Append::Stale { + current: version(2) + }, + "Loser of the race reads the version it has to rebase onto" + ); + assert_eq!(decide(&live, 1, good, &edited(1, 2, 3, 3)), Append::Allow); + let erased_state = folded(&[opened(1, 1), erased(1, 1, 2)]); + assert_eq!( + decide(&erased_state, 1, good, &edited(1, 1, 3, 3)), + Append::Deny, + "Erased object doesn't leave any version to edit from" + ); + } + + fn wires() -> impl Strategy> { + proptest::collection::vec((0u8..3, 0u8..4, 1u8..5, 1u8..5), 0..8).prop_map(|ops| { + std::iter::once(opened(0, 0)) + .chain( + ops.into_iter() + .enumerate() + .map(|(index, (op, author, from, seed))| { + let at = index as i64 + 2; + match op { + 0 => opened(author, seed), + 1 => edited(author, from, seed, at), + _ => erased(author, from, at), + } + }), + ) + .collect() + }) + } + + #[derive(Serialize)] + struct Nest(Vec); + + fn nested(depth: usize) -> Nest { + (0..depth).fold(Nest(Vec::new()), |inner, _| Nest(vec![inner])) + } + + #[test] + fn the_wire_mirror_rejects_a_body_nested_past_the_depth_it_will_walk() { + let wire = |depth: usize| { + serde_ipld_dagcbor::to_vec(&SocialChange::Open { + creation: creation(1, 1), + body: nested(depth), + }) + .unwrap() + }; + assert!( + decode_social(&wire(MAX_BODY_DEPTH - 1)).is_ok(), + "Body within the bound decodes. Its nesting is the indexer's to ignore" + ); + assert!( + decode_social(&wire(MAX_BODY_DEPTH + 8)).is_err(), + "Body past bound is refused instead of walked recursively" + ); + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + #[test] + fn applying_a_change_twice_is_applying_it_once(changes in wires()) { + let once = folded(&changes); + let doubled: Vec = changes + .iter() + .flat_map(|change| [change.clone(), change.clone()]) + .collect(); + prop_assert_eq!( + once, + folded(&doubled), + "Replayed change set folds to the state the first pass reached" + ); + } + + #[test] + fn the_author_is_fixed_at_the_root_whatever_follows(changes in wires()) { + prop_assume!(changes.len() > 1); + let whole = folded(&changes); + let prefix = folded(&changes[..changes.len().saturating_sub(1)]); + prop_assert_eq!( + prefix.object().map(SocialObject::author), + whole.object().map(SocialObject::author), + "author is fixed at the root. Later changes don't move it" + ); + } + } +} diff --git a/knot2/crates/knot-cobs/tests/social.rs b/knot2/crates/knot-cobs/tests/social.rs new file mode 100644 index 000000000..271c38a19 --- /dev/null +++ b/knot2/crates/knot-cobs/tests/social.rs @@ -0,0 +1,308 @@ +mod common; + +use common::{account, at, cob_ref, fixture, home, owner_of, signer, write_cob_commit}; +use knot_cob::{ + Approved, ChangePayload, Checkpoint, CobError, CobStore, Materialized, Record, WriteContext, +}; +use knot_cobs::{Creation, Editing, Erasure, SocialObject, SocialState, social_cob}; +use knot_git::RefUpdate; +use knot_types::{ + AccountDid, Blocked, ContributionPermission, ContributionPolicy, RecordAddress, RecordBody, + RecordCid, RecordCollection, RecordRkey, RepoDid, RepoRole, SubjectNumber, UnixSeconds, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct NoteBody { + text: String, +} + +social_cob! { + body = NoteBody, + change = NoteChange, + cob = NoteCob, + collection = "sh.tangled.test.note", +} + +fn squid() -> RepoDid { + RepoDid::new("did:plc:squid").unwrap() +} + +fn address() -> RecordAddress { + RecordAddress::new( + RecordCollection::new("sh.tangled.test.note").unwrap(), + RecordRkey::new("3lubrptx57d22").unwrap(), + ) +} + +fn record(text: &str) -> (RecordBody, RecordCid) { + let body = RecordBody::new( + serde_ipld_dagcbor::to_vec(&std::collections::BTreeMap::from([("text", text)])).unwrap(), + ) + .unwrap(); + let cid = body.cid(); + (body, cid) +} + +fn opened(author: &AccountDid, text: &str, when: i64) -> Materialized { + let (body, produces) = record(text); + Materialized { + change: NoteChange::Open { + creation: Creation { + address: address(), + author: author.clone(), + number: SubjectNumber::FIRST, + parent: None, + created_at: at(when), + produces, + }, + body: NoteBody { + text: text.to_owned(), + }, + }, + record: Record::version(body), + } +} + +fn edited(author: &AccountDid, from: RecordCid, text: &str, when: i64) -> Materialized { + let (body, produces) = record(text); + Materialized { + change: NoteChange::Edit { + editing: Editing { + author: author.clone(), + from, + produces, + edited_at: at(when), + }, + body: NoteBody { + text: text.to_owned(), + }, + }, + record: Record::version(body), + } +} + +fn erased(author: &AccountDid, from: RecordCid, when: i64) -> Materialized { + Materialized { + change: NoteChange::Erase { + erasure: Erasure { + author: author.clone(), + from, + erased_at: at(when), + }, + }, + record: Record::Tombstone, + } +} + +fn context<'a>(actor: &'a AccountDid, repo: &'a RepoDid, role: RepoRole) -> WriteContext<'a> { + WriteContext::new( + actor, + repo, + ContributionPermission::new(Blocked::No, role, ContributionPolicy::Anyone), + ) +} + +fn live(state: &SocialState) -> RecordCid { + state + .object() + .and_then(SocialObject::version) + .expect("Object has current version") +} + +#[test] +fn a_social_object_opens_edits_and_erases_through_the_approved_doors_only() { + let (_dir, repo) = fixture(); + let store = CobStore::new(&repo); + let key = signer(31); + let (nel, olaren, squid) = (account("nel"), account("olaren"), squid()); + let author = context(&nel, &squid, RepoRole::Stranger); + + let first = opened(&nel, "kelp", 1); + let created = store + .create_approved::( + &home(), + &Approved::signed_by(&author, first).unwrap(), + &key, + at(1), + ) + .unwrap(); + let state = store.get::(created.object).unwrap(); + let opened_at = live(state.state()); + + let edit = edited(&nel, opened_at, "kelp forest", 2); + let produced = edit.change.emits().version().unwrap(); + store + .append_approved::( + &home(), + created.object, + &Approved::signed_by(&author, edit).unwrap(), + &key, + at(2), + ) + .unwrap(); + assert_eq!( + live(store.get::(created.object).unwrap().state()), + produced, + "Version edit named is version fold ends on" + ); + + let stranger = context(&olaren, &squid, RepoRole::Stranger); + let intruded = store.append_approved::( + &home(), + created.object, + &Approved::signed_by(&stranger, erased(&olaren, produced, 3)).unwrap(), + &key, + at(3), + ); + assert!( + matches!(intruded, Err(CobError::AppendRejected { .. })), + "somebody else's text isn't stranger's to erase, and stranger doesn't moderate: {intruded:?}" + ); + + let stale = store.append_approved::( + &home(), + created.object, + &Approved::signed_by(&author, edited(&nel, opened_at, "from the past", 3)).unwrap(), + &key, + at(3), + ); + let expected_uri = address().at_uri(&squid); + assert!( + matches!(&stale, Err(CobError::StaleRecord { uri, current, .. }) if *uri == expected_uri && *current == produced), + "edit, from version that moved on, reads at-uri and current version: {stale:?}" + ); + + store + .append_approved::( + &home(), + created.object, + &Approved::signed_by(&author, erased(&nel, produced, 4)).unwrap(), + &key, + at(4), + ) + .unwrap(); + let records: Vec = store + .changes_since::(created.object, None) + .unwrap() + .changes + .into_iter() + .map(|change| change.record) + .collect(); + assert!( + matches!( + records.as_slice(), + [Record::Version(_), Record::Version(_), Record::Tombstone] + ), + "every change of social object has version or tombstone, got {records:?}" + ); + assert!( + store + .get::(created.object) + .unwrap() + .state() + .object() + .is_some_and(SocialObject::is_erased), + "Erased object keeps its authorship and loses version" + ); +} + +#[test] +fn a_social_object_past_the_snapshot_stride_writes_a_checkpoint_that_folds_to_the_same_state() { + let (_dir, repo) = fixture(); + let store = CobStore::new(&repo); + let key = signer(32); + let (nel, squid) = (account("nel"), squid()); + let author = context(&nel, &squid, RepoRole::Stranger); + let object = store + .create_approved::( + &home(), + &Approved::signed_by(&author, opened(&nel, "kelp", 1)).unwrap(), + &key, + at(1), + ) + .unwrap() + .object; + + (2..=NoteCob::SNAPSHOT_STRIDE.get() as i64 + 1).for_each(|step| { + let from = live(store.get::(object).unwrap().state()); + store + .append_approved::( + &home(), + object, + &Approved::signed_by(&author, edited(&nel, from, &format!("step{step}"), step)) + .unwrap(), + &key, + at(step), + ) + .unwrap(); + }); + + let checkpoints = repo + .references() + .unwrap() + .into_iter() + .filter(|reference| reference.name.as_str().starts_with("refs/cob-checkpoints/")) + .count(); + assert_eq!(checkpoints, 1, "Stride wrote exactly one checkpoint"); + let (materialized, _) = store.materialize::(object).unwrap(); + assert_eq!( + serde_ipld_dagcbor::to_vec(&materialized).unwrap(), + serde_ipld_dagcbor::to_vec(store.get::(object).unwrap().state()).unwrap(), + "Checkpointed fold and fold from root are byte-identical" + ); +} + +#[test] +fn a_forked_social_history_written_outside_the_knot_folds_through_the_convergent_arm() { + let (_dir, repo) = fixture(); + let nsid = NoteChange::type_name(); + let actor = owner_of(33); + let nel = account("nel"); + let root_change = opened(&nel, "kelp", 1); + let opened_at = root_change.change.emits().version().unwrap(); + let enc = |change: &NoteChange| change.encode().unwrap(); + + let root = write_cob_commit(&repo, &nsid, &enc(&root_change.change), &[], &actor, 1); + let object = knot_cob::CobId::new(root); + let left = write_cob_commit( + &repo, + &nsid, + &enc(&edited(&nel, opened_at, "left", 2).change), + &[root], + &actor, + 2, + ); + let right_change = edited(&nel, opened_at, "right", 3); + let right = write_cob_commit(&repo, &nsid, &enc(&right_change.change), &[root], &actor, 3); + let merge = write_cob_commit( + &repo, + &nsid, + &enc(&edited(&nel, opened_at, "merged", 4).change), + &[left, right], + &actor, + 4, + ); + repo.update_ref(&RefUpdate::Create { + name: cob_ref(&nsid, object), + new: merge, + }) + .unwrap(); + + let folded = CobStore::new(&repo).get::(object).unwrap(); + let left_version = edited(&nel, opened_at, "left", 2) + .change + .emits() + .version() + .unwrap(); + assert_eq!( + live(folded.state()), + left_version, + "First edit in causal order takes version, and two that named root's \ + version after it are stale" + ); + assert_eq!( + folded.state().object().map(SocialObject::updated_at), + Some(UnixSeconds::new(2)) + ); +}