diff --git a/Cargo.lock b/Cargo.lock index 8e693f7..7805f2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,3 +12,113 @@ dependencies = [ [[package]] name = "feder-vocab" version = "0.1.0" +dependencies = [ + "iri-string", + "serde", + "serde_json", +] + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 8407033..c56d565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,11 @@ version = "0.1.0" edition = "2024" license = "AGPL-3.0-only" +[workspace.dependencies] +iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } +serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } +serde_json = "1.0.140" + [workspace.lints.rust] warnings = "deny" diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index b82752b..8254263 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -1,3 +1,840 @@ //! Portable ActivityPub core logic for Feder. +#![no_std] + +extern crate alloc; + +use alloc::{string::String, vec::Vec}; pub use feder_vocab as vocab; + +/// Portable core state and decision logic. +#[derive(Debug)] +pub struct FederCore { + state: FederState, +} + +impl FederCore { + #[must_use] + pub fn new(config: FederConfig) -> Self { + Self { + state: FederState::new(config), + } + } + + #[must_use] + pub fn state(&self) -> &FederState { + &self.state + } + + /// Handle one core input and return runtime actions to perform later. + /// + /// This method intentionally performs no I/O. Returned actions describe + /// work for a runtime or test harness to perform later. + #[must_use] + pub fn handle(&mut self, input: Input) -> HandleResult { + match input { + Input::ReceivedFollow(input) => { + let actions = self.state.record_follow(input); + HandleResult::new(actions) + } + Input::UserCreateNote(input) => { + let actions = self.state.record_created_note(input); + HandleResult::new(actions) + } + } + } +} + +/// Runtime-provided configuration for portable core state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederConfig { + pub local_actor: vocab::Actor, +} + +impl FederConfig { + #[must_use] + pub fn new(local_actor: vocab::Actor) -> Self { + Self { local_actor } + } +} + +/// In-memory state used by portable core flows. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederState { + local_actor: vocab::Actor, + followers: Vec, + delivery_targets: Vec, + objects: Vec, + activities: Vec, +} + +impl FederState { + #[must_use] + pub fn new(config: FederConfig) -> Self { + Self { + local_actor: config.local_actor, + followers: Vec::new(), + delivery_targets: Vec::new(), + objects: Vec::new(), + activities: Vec::new(), + } + } + + #[must_use] + pub fn local_actor(&self) -> &vocab::Actor { + &self.local_actor + } + + #[must_use] + pub fn followers(&self) -> &[Follower] { + &self.followers + } + + #[must_use] + /// Delivery targets known from embedded actor data. + /// + /// ID-only followers are tracked in `followers`, but they do not produce a + /// delivery target until a runtime or later core flow resolves actor data. + pub fn delivery_targets(&self) -> &[DeliveryTarget] { + &self.delivery_targets + } + + #[must_use] + pub fn objects(&self) -> &[Object] { + &self.objects + } + + #[must_use] + pub fn activities(&self) -> &[Activity] { + &self.activities + } + + fn record_follow(&mut self, input: ReceivedFollow) -> Vec { + let follow = input.follow; + let Some(following) = reference_id(&follow.object) else { + return Vec::new(); + }; + + if following != &self.local_actor.id { + return Vec::new(); + } + + let Some(follower) = reference_id(&follow.actor).cloned() else { + return Vec::new(); + }; + + let relation = Follower { + follower: follower.clone(), + following: following.clone(), + }; + let mut actions = Vec::new(); + + if !self.followers.contains(&relation) { + self.followers.push(relation.clone()); + + actions.push(Action::StoreFollower(StoreFollower { + follower: follow.actor.clone(), + following: follow.object.clone(), + })); + } + + let mut inbox = self + .delivery_targets + .iter() + .find(|target| target.actor == follower) + .map(|target| target.inbox.clone()); + + if let vocab::Reference::Object(actor) = &follow.actor { + let target = DeliveryTarget { + actor: follower, + inbox: actor.inbox.clone(), + }; + let mut should_store_target = false; + + if let Some(existing) = self + .delivery_targets + .iter_mut() + .find(|existing| existing.actor == target.actor) + { + if existing.inbox != target.inbox { + existing.inbox = target.inbox.clone(); + should_store_target = true; + } + } else { + self.delivery_targets.push(target.clone()); + should_store_target = true; + } + + if should_store_target { + actions.push(Action::StoreDeliveryTarget(StoreDeliveryTarget { target })); + } + + inbox = Some(actor.inbox.clone()); + } + + if let Some(inbox) = inbox { + let accept = vocab::Accept::new( + input.accept_id, + vocab::Reference::id(self.local_actor.id.clone()), + vocab::Reference::object(follow), + ); + + actions.push(Action::SendActivity(SendActivity { + activity: Activity::Accept(accept), + inbox, + })); + } + + actions + } + + fn record_created_note(&mut self, input: UserCreateNote) -> Vec { + let Some(actor) = reference_id(&input.actor) else { + return Vec::new(); + }; + + if actor != &self.local_actor.id { + return Vec::new(); + } + + let actor = vocab::Reference::id(self.local_actor.id.clone()); + + let mut note = vocab::Note::new(input.note_id); + note.attributed_to = Some(actor.clone()); + note.content = Some(input.content); + note.published = input.published; + + let create = vocab::Create::new( + input.create_id, + actor, + vocab::Reference::object(note.clone()), + ); + + let object = Object::Note(note); + self.objects.push(object.clone()); + self.activities.push(Activity::CreateNote(create.clone())); + + let mut actions = Vec::from([Action::StoreObject(StoreObject { object })]); + + actions.extend(self.delivery_targets.iter().map(|target| { + Action::SendActivity(SendActivity { + activity: Activity::CreateNote(create.clone()), + inbox: target.inbox.clone(), + }) + })); + + actions + } +} + +fn reference_id(reference: &vocab::Reference) -> Option<&vocab::Iri> +where + T: HasId, +{ + match reference { + vocab::Reference::Id(id) => Some(id), + vocab::Reference::Object(object) => Some(object.id()), + } +} + +trait HasId { + fn id(&self) -> &vocab::Iri; +} + +impl HasId for vocab::Actor { + fn id(&self) -> &vocab::Iri { + &self.id + } +} + +/// Something entering the portable core from a runtime. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Input { + ReceivedFollow(ReceivedFollow), + UserCreateNote(UserCreateNote), +} + +/// Runtime-provided data for handling a received Follow. +/// +/// The Accept activity ID is an input so the core does not depend on clocks, +/// randomness, or platform-specific ID generation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReceivedFollow { + pub follow: vocab::Follow, + pub accept_id: vocab::Iri, +} + +/// Runtime-provided data for creating a local note. +/// +/// IDs and timestamps are inputs so the core does not depend on clocks, +/// randomness, or platform-specific ID generation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserCreateNote { + pub note_id: vocab::Iri, + pub create_id: vocab::Iri, + pub actor: vocab::Reference, + pub content: String, + pub published: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Follower { + pub follower: vocab::Iri, + pub following: vocab::Iri, +} + +/// A known actor inbox for future delivery. +/// +/// Core records this only when an incoming object embeds enough actor data to +/// expose an inbox. It does not imply every follower has been resolved. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeliveryTarget { + pub actor: vocab::Iri, + pub inbox: vocab::Iri, +} + +/// Something the runtime should perform after core handling. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Action { + StoreFollower(StoreFollower), + StoreDeliveryTarget(StoreDeliveryTarget), + StoreObject(StoreObject), + SendActivity(SendActivity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreFollower { + pub follower: vocab::Reference, + pub following: vocab::Reference, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreDeliveryTarget { + pub target: DeliveryTarget, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreObject { + pub object: Object, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SendActivity { + pub activity: Activity, + pub inbox: vocab::Iri, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Activity { + Accept(vocab::Accept), + CreateNote(vocab::Create), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Object { + Note(vocab::Note), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct HandleResult { + pub actions: Vec, +} + +impl HandleResult { + #[must_use] + pub fn new(actions: Vec) -> Self { + Self { actions } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.actions.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use alloc::string::ToString; + + fn iri(value: &str) -> vocab::Iri { + value.parse().expect("valid test IRI") + } + + fn actor(id: &str) -> vocab::Actor { + vocab::Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) + } + + fn core() -> FederCore { + FederCore::new(FederConfig::new(actor("https://example.com/users/alice"))) + } + + fn received_follow(follow: vocab::Follow, id: &str) -> Input { + Input::ReceivedFollow(ReceivedFollow { + follow, + accept_id: iri(id), + }) + } + + #[test] + fn core_is_created_with_local_actor_state() { + let core = core(); + + assert_eq!( + core.state().local_actor().id, + iri("https://example.com/users/alice") + ); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + assert!(core.state().objects().is_empty()); + assert!(core.state().activities().is_empty()); + } + + #[test] + fn received_follow_records_follower_and_emits_accept_actions() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert_eq!(result.actions.len(), 3); + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert_eq!( + core.state().delivery_targets(), + &[DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + }] + ); + assert_eq!( + result.actions[0], + Action::StoreFollower(StoreFollower { + follower: vocab::Reference::object(actor("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + }) + ); + assert_eq!( + result.actions[1], + Action::StoreDeliveryTarget(StoreDeliveryTarget { + target: DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + }, + }) + ); + + let Action::SendActivity(send) = &result.actions[2] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + + let Activity::Accept(accept) = &send.activity else { + panic!("expected Accept activity"); + }; + assert_eq!(accept.id, iri("https://example.com/activities/accept/1")); + assert_eq!( + accept.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + let vocab::Reference::Object(accepted_follow) = &accept.object else { + panic!("expected embedded Follow object"); + }; + assert_eq!( + accepted_follow.id, + iri("https://remote.example/activities/follow/1") + ); + } + + #[test] + fn received_follow_updates_existing_delivery_target_by_actor() { + let mut core = core(); + let first_follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let mut updated_actor = actor("https://remote.example/users/bob"); + updated_actor.inbox = iri("https://remote.example/inboxes/bob"); + let second_follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/2"), + vocab::Reference::object(updated_actor), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let first_result = core.handle(received_follow( + first_follow, + "https://example.com/activities/accept/1", + )); + let second_result = core.handle(received_follow( + second_follow, + "https://example.com/activities/accept/2", + )); + + assert_eq!(first_result.actions.len(), 3); + assert_eq!(second_result.actions.len(), 2); + assert_eq!( + second_result.actions[0], + Action::StoreDeliveryTarget(StoreDeliveryTarget { + target: DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/inboxes/bob"), + }, + }) + ); + + let Action::SendActivity(send) = &second_result.actions[1] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/inboxes/bob")); + + let Activity::Accept(accept) = &send.activity else { + panic!("expected Accept activity"); + }; + assert_eq!(accept.id, iri("https://example.com/activities/accept/2")); + + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert_eq!( + core.state().delivery_targets(), + &[DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/inboxes/bob"), + }] + ); + } + + #[test] + fn received_follow_with_actor_id_records_follower_without_delivery_target() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::id(iri("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert_eq!( + result.actions, + Vec::from([Action::StoreFollower(StoreFollower { + follower: vocab::Reference::id(iri("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + })]) + ); + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert!(core.state().delivery_targets().is_empty()); + } + + #[test] + fn received_follow_for_other_actor_is_ignored() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/other")), + ); + + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert!(result.is_empty()); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + } + + #[test] + fn user_create_note_records_created_object_and_emits_store_action() { + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 1); + assert_eq!(core.state().objects().len(), 1); + assert_eq!(core.state().activities().len(), 1); + + let Object::Note(note) = &core.state().objects()[0]; + assert_eq!(note.id, iri("https://example.com/notes/1")); + assert_eq!( + note.attributed_to, + Some(vocab::Reference::id(iri("https://example.com/users/alice"))) + ); + assert_eq!(note.content, Some("Hello from Feder.".to_string())); + assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); + + match &core.state().activities()[0] { + Activity::CreateNote(create) => { + assert_eq!(create.id, iri("https://example.com/activities/create/1")); + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + } + Activity::Accept(_) => panic!("expected Create activity"), + } + + assert_eq!( + result.actions[0], + Action::StoreObject(StoreObject { + object: Object::Note(note.clone()), + }) + ); + } + + #[test] + fn user_create_note_emits_create_activity_for_known_delivery_targets() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + let _ = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 2); + let Action::StoreObject(store) = &result.actions[0] else { + panic!("expected StoreObject action"); + }; + let Object::Note(note) = &store.object; + assert_eq!(note.id, iri("https://example.com/notes/1")); + + let Action::SendActivity(send) = &result.actions[1] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + + let Activity::CreateNote(create) = &send.activity else { + panic!("expected Create activity"); + }; + assert_eq!(create.id, iri("https://example.com/activities/create/1")); + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + let vocab::Reference::Object(created_note) = &create.object else { + panic!("expected embedded Note object"); + }; + assert_eq!(created_note.id, iri("https://example.com/notes/1")); + } + + #[test] + fn user_create_note_emits_create_activity_for_each_known_delivery_target() { + let mut core = core(); + for (index, follower) in [ + "https://remote.example/users/bob", + "https://another.example/users/carol", + ] + .into_iter() + .enumerate() + { + let follow = vocab::Follow::new( + iri(&format!("https://example.com/activities/follow/{index}")), + vocab::Reference::object(actor(follower)), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + let _ = core.handle(received_follow( + follow, + &format!("https://example.com/activities/accept/{index}"), + )); + } + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: None, + }; + + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 3); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + + let expected_inboxes = [ + iri("https://remote.example/users/bob/inbox"), + iri("https://another.example/users/carol/inbox"), + ]; + + for (action, expected_inbox) in result.actions[1..].iter().zip(expected_inboxes) { + let Action::SendActivity(send) = action else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, expected_inbox); + assert!(matches!(send.activity, Activity::CreateNote(_))); + } + } + + #[test] + fn mocked_core_flow_accepts_follow_then_delivers_created_note() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let follow_result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert_eq!(follow_result.actions.len(), 3); + assert!(matches!(follow_result.actions[0], Action::StoreFollower(_))); + assert!(matches!( + follow_result.actions[1], + Action::StoreDeliveryTarget(_) + )); + let Action::SendActivity(accept_delivery) = &follow_result.actions[2] else { + panic!("expected Accept delivery action"); + }; + assert_eq!( + accept_delivery.inbox, + iri("https://remote.example/users/bob/inbox") + ); + assert!(matches!(accept_delivery.activity, Activity::Accept(_))); + + let create_result = core.handle(Input::UserCreateNote(UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + })); + + assert_eq!(create_result.actions.len(), 2); + assert!(matches!(create_result.actions[0], Action::StoreObject(_))); + let Action::SendActivity(create_delivery) = &create_result.actions[1] else { + panic!("expected Create delivery action"); + }; + assert_eq!( + create_delivery.inbox, + iri("https://remote.example/users/bob/inbox") + ); + assert!(matches!(create_delivery.activity, Activity::CreateNote(_))); + + assert_eq!(core.state().followers().len(), 1); + assert_eq!(core.state().delivery_targets().len(), 1); + assert_eq!(core.state().objects().len(), 1); + assert_eq!(core.state().activities().len(), 1); + } + + #[test] + fn user_create_note_normalizes_embedded_local_actor_to_local_actor_id() { + let mut supplied_actor = actor("https://example.com/users/alice"); + supplied_actor.inbox = iri("https://untrusted.example/inbox"); + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::object(supplied_actor), + content: "Hello from Feder.".to_string(), + published: None, + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 1); + + let Object::Note(note) = &core.state().objects()[0]; + assert_eq!( + note.attributed_to, + Some(vocab::Reference::id(iri("https://example.com/users/alice"))) + ); + + let Activity::CreateNote(create) = &core.state().activities()[0] else { + panic!("expected Create activity"); + }; + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + } + + #[test] + fn user_create_note_for_non_local_actor_is_ignored() { + let input = UserCreateNote { + note_id: iri("https://remote.example/notes/1"), + create_id: iri("https://remote.example/activities/create/1"), + actor: vocab::Reference::id(iri("https://remote.example/users/bob")), + content: "Hello from elsewhere.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert!(result.is_empty()); + assert!(core.state().objects().is_empty()); + assert!(core.state().activities().is_empty()); + } + + #[test] + fn handle_result_wraps_action_lists() { + let result = HandleResult::new(Vec::from([Action::StoreFollower(StoreFollower { + follower: vocab::Reference::id(iri("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + })])); + + assert_eq!(result.actions.len(), 1); + } +} diff --git a/crates/feder-vocab/Cargo.toml b/crates/feder-vocab/Cargo.toml index d0d075e..4b6a648 100644 --- a/crates/feder-vocab/Cargo.toml +++ b/crates/feder-vocab/Cargo.toml @@ -5,6 +5,11 @@ edition.workspace = true license.workspace = true [dependencies] +iri-string.workspace = true +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true [lints] workspace = true diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 65dbcee..f806167 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -1 +1,483 @@ -//! Activity Vocabulary types for Feder. +//! Minimal Activity Vocabulary types for Feder. +#![no_std] +//! +//! This crate models ActivityPub/ActivityStreams protocol data only. It does +//! not fetch remote objects, read or write storage, deliver activities, or own +//! core decision logic. + +extern crate alloc; + +use alloc::{boxed::Box, string::String, vec::Vec}; +use iri_string::types::IriString; +use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeSeq}; + +/// The canonical Activity Streams JSON-LD context URL. +pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; + +/// An absolute ActivityPub/ActivityStreams identifier. +pub type Iri = IriString; + +/// A non-scalar ActivityStreams property value. +/// +/// ActivityStreams object slots can contain either an embedded object or the +/// object's IRI. Feder keeps both forms explicit and avoids dereferencing. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Reference { + Id(Iri), + Object(Box), +} + +impl Reference { + #[must_use] + pub fn id(id: Iri) -> Self { + Self::Id(id) + } + + #[must_use] + pub fn object(object: T) -> Self { + Self::Object(Box::new(object)) + } +} + +/// Zero or more ActivityStreams property values. +/// +/// Use this with `#[serde(default, skip_serializing_if = "References::is_empty")]` +/// on containing fields. Empty values then serialize as absent, one value +/// serializes as a scalar, and multiple values serialize as an array. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct References { + values: Vec, +} + +impl Default for References { + fn default() -> Self { + Self::new() + } +} + +impl References { + #[must_use] + pub fn new() -> Self { + Self { values: Vec::new() } + } + + #[must_use] + pub fn one(value: T) -> Self { + Self { + values: Vec::from([value]), + } + } + + #[must_use] + pub fn many(values: impl Into>) -> Self { + Self { + values: values.into(), + } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + #[must_use] + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn iter(&self) -> core::slice::Iter<'_, T> { + self.values.iter() + } + + pub fn into_vec(self) -> Vec { + self.values + } +} + +impl From> for References { + fn from(values: Vec) -> Self { + Self::many(values) + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum OneOrMany { + One(T), + Many(Vec), +} + +impl Serialize for References +where + T: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self.values.as_slice() { + [] => { + let sequence = serializer.serialize_seq(Some(0))?; + sequence.end() + } + [value] => value.serialize(serializer), + values => values.serialize(serializer), + } + } +} + +impl<'de, T> Deserialize<'de> for References +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(value) => Ok(References::one(value)), + OneOrMany::Many(values) => Ok(References::many(values)), + } + } +} + +macro_rules! activitystreams_type { + ($name:ident, $variant:ident) => { + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] + pub enum $name { + #[default] + $variant, + } + }; +} + +activitystreams_type!(NoteType, Note); +activitystreams_type!(FollowType, Follow); +activitystreams_type!(AcceptType, Accept); +activitystreams_type!(CreateType, Create); + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum ActorType { + Application, + Group, + Organization, + #[default] + Person, + Service, +} + +/// A minimal ActivityPub actor. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Actor { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: ActorType, + pub id: Iri, + pub inbox: Iri, + pub outbox: Iri, + #[serde(rename = "preferredUsername", skip_serializing_if = "Option::is_none")] + pub preferred_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl Actor { + #[must_use] + pub fn person(id: Iri, inbox: Iri, outbox: Iri) -> Self { + Self::new(ActorType::Person, id, inbox, outbox) + } + + #[must_use] + pub fn new(kind: ActorType, id: Iri, inbox: Iri, outbox: Iri) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind, + id, + inbox, + outbox, + preferred_username: None, + name: None, + } + } +} + +/// A minimal ActivityStreams Note object. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Note { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: NoteType, + pub id: Iri, + #[serde(rename = "attributedTo", skip_serializing_if = "Option::is_none")] + pub attributed_to: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub published: Option, +} + +impl Note { + #[must_use] + pub fn new(id: Iri) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: NoteType::default(), + id, + attributed_to: None, + content: None, + published: None, + } + } +} + +/// A minimal Follow activity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Follow { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: FollowType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Follow { + #[must_use] + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: FollowType::default(), + id, + actor, + object, + } + } +} + +/// A minimal Accept activity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Accept { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: AcceptType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Accept { + #[must_use] + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: AcceptType::default(), + id, + actor, + object, + } + } +} + +/// A minimal Create activity for a concrete object type. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Create { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: CreateType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Create { + #[must_use] + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: CreateType::default(), + id, + actor, + object, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + use serde::de::DeserializeOwned; + use serde_json::json; + + fn roundtrip(value: &T) -> T + where + T: DeserializeOwned + Serialize, + { + let json = serde_json::to_string(value).expect("serialize activitystreams value"); + serde_json::from_str(&json).expect("deserialize activitystreams value") + } + + fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") + } + + #[test] + fn actor_roundtrips_json() { + let mut actor = Actor::person( + iri("https://example.com/users/alice"), + iri("https://example.com/users/alice/inbox"), + iri("https://example.com/users/alice/outbox"), + ); + actor.preferred_username = Some("alice".to_string()); + actor.name = Some("Alice".to_string()); + + assert_eq!(roundtrip(&actor), actor); + } + + #[test] + fn actor_deserializes_basic_activitypub_json() { + let actor: Actor = serde_json::from_value(json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice", + "name": "Alice" + })) + .expect("deserialize actor from json"); + + assert_eq!(actor.id, iri("https://example.com/users/alice")); + assert_eq!(actor.preferred_username, Some("alice".to_string())); + } + + #[test] + fn actor_deserializes_non_person_activitypub_json() { + let actor: Actor = serde_json::from_value(json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Service", + "id": "https://example.com/actors/service", + "inbox": "https://example.com/actors/service/inbox", + "outbox": "https://example.com/actors/service/outbox", + "name": "Feder Service" + })) + .expect("deserialize service actor from json"); + + assert_eq!(actor.kind, ActorType::Service); + assert_eq!(actor.id, iri("https://example.com/actors/service")); + } + + #[test] + fn follow_and_accept_roundtrip_json() { + let follow = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::id(iri("https://remote.example/users/bob")), + Reference::id(iri("https://example.com/users/alice")), + ); + let accept = Accept::new( + iri("https://example.com/activities/accept/1"), + Reference::id(iri("https://example.com/users/alice")), + Reference::object(follow), + ); + + assert_eq!(roundtrip(&accept), accept); + } + + #[test] + fn create_note_roundtrips_json() { + let mut note = Note::new(iri("https://example.com/notes/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); + note.content = Some("Hello, fediverse.".to_string()); + note.published = Some("2026-05-29T06:30:00Z".to_string()); + + let create = Create::new( + iri("https://example.com/activities/create/1"), + Reference::id(iri("https://example.com/users/alice")), + Reference::object(note), + ); + + assert_eq!(roundtrip(&create), create); + } + + #[test] + fn concrete_types_reject_wrong_activitystreams_type() { + let result = serde_json::from_value::(json!({ + "type": "Accept", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": "https://example.com/users/alice" + })); + + assert!(result.is_err()); + } + + #[test] + fn references_deserializes_scalar_and_array() { + let one: References = serde_json::from_value(json!("https://example.com/users/alice")) + .expect("deserialize scalar references value"); + let many: References = serde_json::from_value(json!([ + "https://example.com/users/alice", + "https://example.com/users/bob" + ])) + .expect("deserialize array references value"); + + assert_eq!(one, References::one(iri("https://example.com/users/alice"))); + assert_eq!( + many, + References::many([ + iri("https://example.com/users/alice"), + iri("https://example.com/users/bob") + ]) + ); + } + + #[test] + fn references_serializes_empty_one_and_many() { + assert_eq!( + serde_json::to_value(References::::new()).expect("serialize empty references"), + json!([]) + ); + assert_eq!( + serde_json::to_value(References::one(iri("https://example.com/users/alice"))) + .expect("serialize one reference"), + json!("https://example.com/users/alice") + ); + assert_eq!( + serde_json::to_value(References::many([ + iri("https://example.com/users/alice"), + iri("https://example.com/users/bob") + ])) + .expect("serialize many references"), + json!([ + "https://example.com/users/alice", + "https://example.com/users/bob" + ]) + ); + } +} diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/phase1_shapes.rs new file mode 100644 index 0000000..371e682 --- /dev/null +++ b/crates/feder-vocab/tests/phase1_shapes.rs @@ -0,0 +1,176 @@ +use feder_vocab::{ + ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference, References, +}; +use serde_json::{Value, json}; + +fn serialize(value: impl serde::Serialize) -> Value { + serde_json::to_value(value).expect("serialize vocab value") +} + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +fn incoming_follow_json() -> serde_json::Value { + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Follow", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice" + } + }) +} + +#[test] +fn follow_activity_accepts_id_or_embedded_actor_references() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + + assert_eq!(follow.id, iri("https://remote.example/activities/follow/1")); + assert!( + matches!(follow.actor, Reference::Id(id) if id == iri("https://remote.example/users/bob")) + ); + assert!( + matches!(follow.object, Reference::Object(actor) if actor.id == iri("https://example.com/users/alice")) + ); +} + +#[test] +fn accept_activity_can_embed_follow_activity() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + + let outgoing_accept = Accept::new( + iri("https://example.com/activities/accept/1"), + Reference::id(iri("https://example.com/users/alice")), + Reference::object(follow), + ); + + assert_eq!( + serialize(outgoing_accept), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Accept", + "id": "https://example.com/activities/accept/1", + "actor": "https://example.com/users/alice", + "object": { + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Follow", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice" + } + } + }) + ); +} + +#[test] +fn local_note_can_shape_create_note_activity() { + let mut note = Note::new(iri("https://example.com/notes/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); + note.content = Some("Hello from Feder.".to_string()); + note.published = Some("2026-06-02T00:00:00Z".to_string()); + + let create = Create::new( + iri("https://example.com/activities/create/1"), + Reference::id(iri("https://example.com/users/alice")), + Reference::object(note), + ); + + assert_eq!( + serialize(create), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Create", + "id": "https://example.com/activities/create/1", + "actor": "https://example.com/users/alice", + "object": { + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Note", + "id": "https://example.com/notes/1", + "attributedTo": "https://example.com/users/alice", + "content": "Hello from Feder.", + "published": "2026-06-02T00:00:00Z" + } + }) + ); +} + +#[test] +fn reference_keeps_id_and_embedded_object_shapes_distinct() { + let id_reference: Reference = + serde_json::from_value(json!("https://example.com/notes/1")) + .expect("deserialize id reference"); + let object_reference: Reference = serde_json::from_value(json!({ + "type": "Note", + "id": "https://example.com/notes/1" + })) + .expect("deserialize embedded object reference"); + + assert!(matches!(id_reference, Reference::Id(id) if id == iri("https://example.com/notes/1"))); + assert!( + matches!(object_reference, Reference::Object(note) if note.id == iri("https://example.com/notes/1")) + ); +} + +#[test] +fn references_can_represent_common_recipient_shapes() { + let single: References = + serde_json::from_value(json!("https://www.w3.org/ns/activitystreams#Public")) + .expect("deserialize single recipient"); + let multiple: References = serde_json::from_value(json!([ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.com/users/alice/followers" + ])) + .expect("deserialize multiple recipients"); + + assert_eq!( + single, + References::one(iri("https://www.w3.org/ns/activitystreams#Public")) + ); + assert_eq!( + multiple, + References::many([ + iri("https://www.w3.org/ns/activitystreams#Public"), + iri("https://example.com/users/alice/followers") + ]) + ); +} + +#[test] +fn references_treat_absent_and_empty_array_as_empty() { + #[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)] + struct Recipients { + #[serde(default, skip_serializing_if = "References::is_empty")] + to: References, + } + + let absent: Recipients = serde_json::from_value(json!({})).expect("deserialize absent field"); + let empty: Recipients = + serde_json::from_value(json!({ "to": [] })).expect("deserialize empty field"); + let one = Recipients { + to: References::one(iri("https://www.w3.org/ns/activitystreams#Public")), + }; + + assert_eq!(absent, empty); + assert_eq!( + serde_json::to_value(absent).expect("serialize absent"), + json!({}) + ); + assert_eq!( + serde_json::to_value(one).expect("serialize one recipient"), + json!({ "to": "https://www.w3.org/ns/activitystreams#Public" }) + ); +}