From 4b4b1090b2d6075ff564500b3ef790566d6c8841 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 17 Jul 2026 14:22:42 +0900 Subject: [PATCH 01/42] Add actor cryptographic public keys Add the security JSON-LD context and model embedded CryptographicKey values on ActivityPub actors. Cover the publicKey representation with serialization tests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-vocab/src/lib.rs | 88 ++++++++++++++++++- ...shapes.rs => activitypub_serialization.rs} | 47 ++++++++-- 2 files changed, 125 insertions(+), 10 deletions(-) rename crates/feder-vocab/tests/{phase1_shapes.rs => activitypub_serialization.rs} (79%) diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 1c1bc16..9672ecd 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -29,6 +29,9 @@ 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"; +/// The JSON-LD context for the security vocabulary used by actor public keys. +pub const SECURITY_CONTEXT: &str = "https://w3id.org/security/v1"; + /// An absolute ActivityPub/ActivityStreams identifier. pub type Iri = IriString; @@ -172,6 +175,37 @@ activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); activitystreams_type!(CreateType, Create); +/// A JSON-LD context represented by one or more IRIs. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Context { + Iri(Iri), + Iris(Vec), +} + +impl Context { + #[must_use] + pub fn one(context: Iri) -> Self { + Self::Iri(context) + } + + #[must_use] + pub fn many(contexts: impl Into>) -> Self { + Self::Iris(contexts.into()) + } + + fn include(&mut self, context: Iri) { + match self { + Self::Iri(existing) if existing == &context => {} + Self::Iri(existing) => { + *self = Self::Iris(Vec::from([existing.clone(), context])); + } + Self::Iris(existing) if !existing.contains(&context) => existing.push(context), + Self::Iris(_) => {} + } + } +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub enum ActorType { Application, @@ -189,11 +223,40 @@ pub struct Endpoints { pub shared_inbox: Option, } +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum CryptographicKeyType { + #[default] + CryptographicKey, +} + +/// A public key published by an ActivityPub actor. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CryptographicKey { + pub id: Iri, + #[serde(rename = "type")] + pub kind: CryptographicKeyType, + pub owner: Iri, + #[serde(rename = "publicKeyPem")] + pub public_key_pem: String, +} + +impl CryptographicKey { + #[must_use] + pub fn new(id: Iri, owner: Iri, public_key_pem: String) -> Self { + Self { + id, + kind: CryptographicKeyType::default(), + owner, + public_key_pem, + } + } +} + /// 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, + pub context: Option, #[serde(rename = "type")] pub kind: ActorType, pub id: Iri, @@ -205,6 +268,8 @@ pub struct Actor { pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub endpoints: Option, + #[serde(rename = "publicKey", skip_serializing_if = "Option::is_none")] + pub public_key: Option>, } impl Actor { @@ -216,11 +281,11 @@ impl Actor { #[must_use] pub fn new(kind: ActorType, id: Iri, inbox: Iri, outbox: Iri) -> Self { Self { - context: Some( + context: Some(Context::one( ACTIVITYSTREAMS_CONTEXT .parse() .expect("valid ActivityStreams IRI"), - ), + )), kind, id, inbox, @@ -228,8 +293,25 @@ impl Actor { preferred_username: None, name: None, endpoints: None, + public_key: None, } } + + pub fn set_public_key(&mut self, public_key: Reference) { + let security_context = SECURITY_CONTEXT + .parse() + .expect("valid security context IRI"); + self.context + .get_or_insert_with(|| { + Context::one( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ) + }) + .include(security_context); + self.public_key = Some(public_key); + } } /// A minimal ActivityStreams Note object. diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/activitypub_serialization.rs similarity index 79% rename from crates/feder-vocab/tests/phase1_shapes.rs rename to crates/feder-vocab/tests/activitypub_serialization.rs index 043f477..7d651d8 100644 --- a/crates/feder-vocab/tests/phase1_shapes.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -14,11 +14,12 @@ // along with this program. If not, see . use feder_vocab::{ - ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference, References, + ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, Reference, + References, SECURITY_CONTEXT, }; use serde_json::{Value, json}; -fn serialize(value: impl serde::Serialize) -> Value { +fn serialize_to_value(value: impl serde::Serialize) -> Value { serde_json::to_value(value).expect("serialize vocab value") } @@ -42,6 +43,38 @@ fn incoming_follow_json() -> serde_json::Value { }) } +#[test] +fn actor_serializes_embedded_cryptographic_key() { + let actor_id = iri("https://example.com/users/alice"); + let mut actor = Actor::person( + actor_id.clone(), + iri("https://example.com/users/alice/inbox"), + iri("https://example.com/users/alice/outbox"), + ); + actor.set_public_key(Reference::object(CryptographicKey::new( + iri("https://example.com/users/alice#main-key"), + actor_id, + "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n".to_string(), + ))); + + assert_eq!( + serialize_to_value(actor), + json!({ + "@context": [ACTIVITYSTREAMS_CONTEXT, SECURITY_CONTEXT], + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "publicKey": { + "id": "https://example.com/users/alice#main-key", + "type": "CryptographicKey", + "owner": "https://example.com/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n" + } + }) + ); +} + #[test] fn follow_activity_accepts_id_or_embedded_actor_references() { let follow: Follow = @@ -68,7 +101,7 @@ fn accept_activity_can_embed_follow_activity() { ); assert_eq!( - serialize(outgoing_accept), + serialize_to_value(outgoing_accept), json!({ "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Accept", @@ -92,7 +125,7 @@ fn accept_activity_can_embed_follow_activity() { } #[test] -fn local_note_can_shape_create_note_activity() { +fn local_note_serializes_as_create_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()); @@ -105,7 +138,7 @@ fn local_note_can_shape_create_note_activity() { ); assert_eq!( - serialize(create), + serialize_to_value(create), json!({ "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Create", @@ -124,7 +157,7 @@ fn local_note_can_shape_create_note_activity() { } #[test] -fn reference_keeps_id_and_embedded_object_shapes_distinct() { +fn reference_deserializes_id_and_embedded_object_distinctly() { let id_reference: Reference = serde_json::from_value(json!("https://example.com/notes/1")) .expect("deserialize id reference"); @@ -141,7 +174,7 @@ fn reference_keeps_id_and_embedded_object_shapes_distinct() { } #[test] -fn references_can_represent_common_recipient_shapes() { +fn references_deserialize_single_and_multiple_recipients() { let single: References = serde_json::from_value(json!("https://www.w3.org/ns/activitystreams#Public")) .expect("deserialize single recipient"); From e4e559aec75b4c7a2321656eaf73ddf50c3b1cf9 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 17 Jul 2026 16:12:35 +0900 Subject: [PATCH 02/42] Add feature-gated, Fedify-compatible 4096-bit RSA actor keys to Feder Core. Accept runtime-provided entropy, validate persisted pairs, and zeroize private PEM material. Use fixed fixtures for behavioral tests and exercise all workspace features and targets in CI. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 271 ++++++++++++++++++ Cargo.toml | 3 + crates/feder-core/Cargo.toml | 8 + crates/feder-core/src/http_signatures.rs | 159 ++++++++++ crates/feder-core/src/lib.rs | 3 + .../tests/fixtures/rsa-other-public-key.pem | 9 + .../tests/fixtures/rsa-private-key.pem | 28 ++ .../tests/fixtures/rsa-public-key.pem | 9 + .../src/storage/sqlite.rs | 2 +- mise.toml | 6 +- 10 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 crates/feder-core/src/http_signatures.rs create mode 100644 crates/feder-core/tests/fixtures/rsa-other-public-key.pem create mode 100644 crates/feder-core/tests/fixtures/rsa-private-key.pem create mode 100644 crates/feder-core/tests/fixtures/rsa-public-key.pem diff --git a/Cargo.lock b/Cargo.lock index 9a9f225..5b67a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -69,6 +75,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" @@ -103,6 +115,43 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "const-oid", + "crypto-common", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -120,6 +169,9 @@ name = "feder-core" version = "0.1.0" dependencies = [ "feder-vocab", + "rand_chacha", + "rsa", + "zeroize", ] [[package]] @@ -201,6 +253,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -338,6 +400,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -345,6 +410,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -409,12 +480,66 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -427,12 +552,42 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -451,6 +606,32 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "regex-automata" version = "0.4.14" @@ -468,6 +649,26 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -586,6 +787,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "single-user-server" version = "0.1.0" @@ -619,6 +830,22 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -631,6 +858,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -792,6 +1025,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -810,6 +1049,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -876,6 +1121,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 3e2de24..27b7b94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,12 @@ feder-core = { version = "0.1.0", path = "crates/feder-core" } feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" } feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } +rand_chacha = { version = "0.3.1", default-features = false } +rsa = { version = "0.9.10", default-features = false, features = ["pem", "u64_digit"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" tower = { version = "0.5", features = ["util"]} +zeroize = { version = "1.9.0", default-features = false, features = ["alloc"] } [workspace.lints.rust] warnings = "deny" diff --git a/crates/feder-core/Cargo.toml b/crates/feder-core/Cargo.toml index 40c5a8f..2cd3458 100644 --- a/crates/feder-core/Cargo.toml +++ b/crates/feder-core/Cargo.toml @@ -8,8 +8,16 @@ license.workspace = true homepage.workspace = true repository.workspace = true +[features] +http-signatures = ["dep:rsa", "dep:zeroize"] + [dependencies] feder-vocab.workspace = true +rsa = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } + +[dev-dependencies] +rand_chacha.workspace = true [lints] workspace = true diff --git a/crates/feder-core/src/http_signatures.rs b/crates/feder-core/src/http_signatures.rs new file mode 100644 index 0000000..fb23dc5 --- /dev/null +++ b/crates/feder-core/src/http_signatures.rs @@ -0,0 +1,159 @@ +//! Draft-Cavage HTTP Signature primitives. + +use alloc::string::String; +use core::fmt; + +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, + rand_core::CryptoRngCore, +}; +use zeroize::Zeroizing; + +const ACTOR_RSA_BITS: usize = 4096; + +/// A local actor's RSA key pair encoded for persistent storage. +#[derive(Clone, Eq, PartialEq)] +pub struct ActorKeyPair { + private_key_pem: Zeroizing, + public_key_pem: String, +} + +impl ActorKeyPair { + /// Loads a persisted key pair and checks that both keys belong together. + pub fn from_pem(private_key_pem: String, public_key_pem: String) -> Result { + let private_key_pem = Zeroizing::new(private_key_pem); + let private_key = + RsaPrivateKey::from_pkcs8_pem(&private_key_pem).map_err(KeyError::InvalidPrivateKey)?; + let public_key = RsaPublicKey::from_public_key_pem(&public_key_pem) + .map_err(KeyError::InvalidPublicKey)?; + + if RsaPublicKey::from(&private_key) != public_key { + return Err(KeyError::MismatchedKeyPair); + } + + Ok(Self { + private_key_pem, + public_key_pem, + }) + } + + #[must_use] + pub fn private_key_pem(&self) -> &str { + &self.private_key_pem + } + + #[must_use] + pub fn public_key_pem(&self) -> &str { + &self.public_key_pem + } +} + +impl fmt::Debug for ActorKeyPair { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActorKeyPair") + .field("private_key_pem", &"[REDACTED]") + .field("public_key_pem", &self.public_key_pem) + .finish() + } +} + +/// Errors produced while generating, encoding, or loading actor keys. +#[derive(Debug)] +pub enum KeyError { + Generation(rsa::Error), + PrivateKeyEncoding(rsa::pkcs8::Error), + PublicKeyEncoding(rsa::pkcs8::spki::Error), + InvalidPrivateKey(rsa::pkcs8::Error), + InvalidPublicKey(rsa::pkcs8::spki::Error), + MismatchedKeyPair, +} + +impl fmt::Display for KeyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Generation(_) => formatter.write_str("failed to generate RSA actor key"), + Self::PrivateKeyEncoding(_) => formatter.write_str("failed to encode RSA private key"), + Self::PublicKeyEncoding(_) => formatter.write_str("failed to encode RSA public key"), + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::MismatchedKeyPair => formatter.write_str("RSA actor keys do not match"), + } + } +} + +impl core::error::Error for KeyError {} + +/// Generates a 4096-bit RSA actor key pair for draft-Cavage HTTP signatures. +/// The caller must supply a cryptographically secure random number generator for the target runtime. +pub fn generate_actor_key_pair( + rng: &mut (impl CryptoRngCore + ?Sized), +) -> Result { + let private_key = RsaPrivateKey::new(rng, ACTOR_RSA_BITS).map_err(KeyError::Generation)?; + let public_key = RsaPublicKey::from(&private_key); + let private_key_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .map_err(KeyError::PrivateKeyEncoding)?; + let public_key_pem = public_key + .to_public_key_pem(LineEnding::LF) + .map_err(KeyError::PublicKeyEncoding)?; + + Ok(ActorKeyPair { + private_key_pem, + public_key_pem, + }) +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use rand_chacha::ChaCha20Rng; + use rsa::rand_core::SeedableRng; + use rsa::traits::PublicKeyParts; + + use super::*; + + const PRIVATE_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-private-key.pem"); + const PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-public-key.pem"); + const OTHER_PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-other-public-key.pem"); + + #[test] + fn generated_actor_key_pair_uses_4096_bit_rsa() { + let mut rng = test_rng(1); + let pair = generate_actor_key_pair(&mut rng).expect("generate actor key pair"); + let private_key = RsaPrivateKey::from_pkcs8_pem(pair.private_key_pem()) + .expect("parse generated private key"); + let public_key = RsaPublicKey::from_public_key_pem(pair.public_key_pem()) + .expect("parse generated public key"); + + assert_eq!(private_key.n().bits(), ACTOR_RSA_BITS); + assert_eq!(public_key.n().bits(), ACTOR_RSA_BITS); + assert_eq!(RsaPublicKey::from(&private_key), public_key); + } + + #[test] + fn persisted_actor_key_pair_rejects_mismatched_keys() { + let result = ActorKeyPair::from_pem( + PRIVATE_KEY_PEM.to_string(), + OTHER_PUBLIC_KEY_PEM.to_string(), + ); + + assert!(matches!(result, Err(KeyError::MismatchedKeyPair))); + } + + #[test] + fn actor_key_pair_debug_output_redacts_private_key() { + let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("load actor key pair fixture"); + let debug = alloc::format!("{pair:?}"); + + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(pair.private_key_pem())); + } + + fn test_rng(seed: u8) -> ChaCha20Rng { + ChaCha20Rng::from_seed([seed; 32]) + } +} diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 3e59dfa..e09b1e3 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -22,6 +22,9 @@ use alloc::{string::String, vec::Vec}; pub use feder_vocab as vocab; +#[cfg(feature = "http-signatures")] +pub mod http_signatures; + /// Portable core state and decision logic. #[derive(Debug)] pub struct FederCore { diff --git a/crates/feder-core/tests/fixtures/rsa-other-public-key.pem b/crates/feder-core/tests/fixtures/rsa-other-public-key.pem new file mode 100644 index 0000000..86fa3a6 --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-other-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo9l6PexyP7BtQOAvgpFV +Sb+iFq5jPL3VoFdr1jevGhBHslqP881CUQHOsSzjJFDkHFTHhN30uuucceAajt9N +IpPAoS9Ft6ouYDheg/MZEPNvbnUuen7IAx1hOSAGLzdXKHIFbQp9eBEcc8pKXuR5 +mhowALUjioqA7Ax3N6iw7uI7ANzrMX+VSmCmuHgUcXbJUy/4SqCmI1M3xyKvxEFh ++KPQCMYRpIh02j6tFU5k7P5aynDitPwqXJr1w+NVcLl1qh4y4tGQ6GpscS5KGEqd +sojcVZuweV0hku4fT5onA2WAd4CWZVUVM3gBau1OzTgyCajjbgWURQjH9DrAHSrO +FQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/feder-core/tests/fixtures/rsa-private-key.pem b/crates/feder-core/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/feder-core/tests/fixtures/rsa-public-key.pem b/crates/feder-core/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 1172695..275665e 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -361,7 +361,7 @@ mod tests { let action = store_follower_action(); store - .persist_actions(&[action.clone()]) + .persist_actions(core::slice::from_ref(&action)) .expect("persist follower action first time"); store .persist_actions(&[action]) diff --git a/mise.toml b/mise.toml index 469d5f3..8c9e654 100644 --- a/mise.toml +++ b/mise.toml @@ -17,8 +17,8 @@ linux-arm64 = "hongdown-*-aarch64-unknown-linux-musl.tar.bz2" [tasks.check] description = "Run type check, lint, and check Markdown format" run = [ - "cargo check", - "cargo clippy", + "cargo check --workspace --all-targets --all-features", + "cargo clippy --workspace --all-targets --all-features", "cargo fmt --check", "hongdown --check", "mise fmt --check", @@ -30,7 +30,7 @@ run = ["cargo fmt", "hongdown --write", "mise fmt"] [tasks.test] description = "Run the Rust tests" -run = "cargo test --workspace" +run = "cargo test --workspace --all-features" [tasks.bump] description = "Preview a workspace version bump" From 6d251e342a3dbebf94c8c78f0baefe36b6425bba Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 11:38:13 +0900 Subject: [PATCH 03/42] Persist actor RSA key pairs in SQLite Enable HTTP signature keys in the server runtime and store validated per-actor key pairs in the keys table. Reject accidental replacement and test schema creation, round trips, malformed keys, missing actors, and persistence across reopen. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/Cargo.toml | 2 +- .../feder-runtime-server/src/storage/mod.rs | 16 +- .../src/storage/sqlite.rs | 181 +++++++++++++++++- .../tests/fixtures/rsa-private-key.pem | 28 +++ .../tests/fixtures/rsa-public-key.pem | 9 + 5 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem create mode 100644 crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 7981fea..fa5b370 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -9,7 +9,7 @@ homepage.workspace = true repository.workspace = true [dependencies] -feder-core.workspace = true +feder-core = { workspace = true, features = ["http-signatures"] } feder-vocab.workspace = true axum = "0.8" serde.workspace = true diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 127f087..740f057 100644 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-runtime-server/src/storage/mod.rs @@ -15,7 +15,10 @@ pub mod sqlite; -use feder_core::Action; +use feder_core::{ + Action, + http_signatures::{ActorKeyPair, KeyError}, +}; use feder_vocab::Iri; pub use sqlite::SqliteStore; @@ -44,6 +47,9 @@ pub enum StoreError { #[error("invalid IRI: {0}")] InvalidIri(String), + + #[error(transparent)] + ActorKey(#[from] KeyError), } pub trait RuntimeStore { @@ -52,4 +58,12 @@ pub trait RuntimeStore { fn list_followers(&self, actor_id: &Iri) -> Result, StoreError>; fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError>; + + fn insert_actor_key_pair( + &mut self, + actor_id: &Iri, + key_pair: &ActorKeyPair, + ) -> Result<(), StoreError>; + + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, StoreError>; } diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 275665e..6c8d0b6 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,9 +15,9 @@ use std::path::Path; -use feder_core::Action; +use feder_core::{Action, http_signatures::ActorKeyPair}; use feder_vocab::{Actor, Iri, Reference}; -use rusqlite::{Connection, params}; +use rusqlite::{Connection, OptionalExtension, params}; use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; @@ -58,6 +58,11 @@ impl SqliteStore { ); CREATE INDEX IF NOT EXISTS idx_followers_following_actor_id ON followers (following_actor_id); + CREATE TABLE IF NOT EXISTS keys ( + actor_id TEXT PRIMARY KEY NOT NULL, + private_key_pem TEXT NOT NULL, + public_key_pem TEXT NOT NULL + ); "#, )?; @@ -178,6 +183,48 @@ impl RuntimeStore for SqliteStore { }) .collect() } + + fn insert_actor_key_pair( + &mut self, + actor_id: &Iri, + key_pair: &ActorKeyPair, + ) -> Result<(), StoreError> { + self.conn.execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + "#, + params![ + actor_id.as_str(), + key_pair.private_key_pem(), + key_pair.public_key_pem(), + ], + )?; + + Ok(()) + } + + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, StoreError> { + let encoded_keys = self + .conn + .query_row( + r#" + SELECT private_key_pem, public_key_pem + FROM keys + WHERE actor_id = ?1 + "#, + [actor_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + encoded_keys + .map(|(private_key_pem, public_key_pem)| { + ActorKeyPair::from_pem(private_key_pem, public_key_pem) + }) + .transpose() + .map_err(StoreError::from) + } } fn actor_reference_id(reference: &Reference) -> &Iri { @@ -220,6 +267,9 @@ mod tests { use super::*; + const PRIVATE_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-private-key.pem"); + const PUBLIC_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-public-key.pem"); + fn iri(value: &str) -> Iri { value.parse().expect("valid test IRI") } @@ -239,6 +289,11 @@ mod tests { ) } + fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair fixture") + } + #[test] fn open_in_memory_initializes_followers_table() { let store = SqliteStore::open_in_memory().expect("open in-memory store"); @@ -282,6 +337,128 @@ mod tests { assert_eq!(index_count, 1); } + #[test] + fn open_in_memory_initializes_keys_table() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let columns: Vec = { + let mut stmt = store + .conn + .prepare("PRAGMA table_info(keys)") + .expect("prepare keys table info query"); + stmt.query_map([], |row| row.get("name")) + .expect("query keys table info") + .collect::>() + .expect("collect keys table columns") + }; + + assert_eq!( + columns, + vec![ + "actor_id".to_string(), + "private_key_pem".to_string(), + "public_key_pem".to_string(), + ] + ); + } + + #[test] + fn actor_key_pair_roundtrips_for_actor() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let actor_id = iri("https://example.com/users/alice"); + let expected = actor_key_pair(); + + store + .insert_actor_key_pair(&actor_id, &expected) + .expect("insert actor key pair"); + let actual = store + .load_actor_key_pair(&actor_id) + .expect("load actor key pair") + .expect("stored actor key pair"); + + assert_eq!(actual, expected); + } + + #[test] + fn actor_key_pair_persists_across_store_reopen() { + let path = std::env::temp_dir().join(format!( + "feder-actor-key-test-{}-{}.sqlite3", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + let actor_id = iri("https://example.com/users/alice"); + let expected = actor_key_pair(); + + { + let mut store = SqliteStore::open(&path).expect("open SQLite store"); + store + .insert_actor_key_pair(&actor_id, &expected) + .expect("insert actor key pair"); + } + + let store = SqliteStore::open(&path).expect("reopen SQLite store"); + let actual = store + .load_actor_key_pair(&actor_id) + .expect("load actor key pair") + .expect("persisted actor key pair"); + + assert_eq!(actual, expected); + + drop(store); + let _ = std::fs::remove_file(path); + } + + #[test] + fn load_actor_key_pair_returns_none_for_unknown_actor() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let key_pair = store + .load_actor_key_pair(&iri("https://example.com/users/unknown")) + .expect("load actor key pair"); + + assert!(key_pair.is_none()); + } + + #[test] + fn load_actor_key_pair_rejects_invalid_stored_keys() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + store + .conn + .execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + "#, + params![ + "https://example.com/users/alice", + "not a private key", + PUBLIC_KEY_PEM, + ], + ) + .expect("insert invalid actor key pair"); + + let result = store.load_actor_key_pair(&iri("https://example.com/users/alice")); + + assert!(matches!(result, Err(StoreError::ActorKey(_)))); + } + + #[test] + fn insert_actor_key_pair_refuses_to_replace_existing_key() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let actor_id = iri("https://example.com/users/alice"); + let key_pair = actor_key_pair(); + + store + .insert_actor_key_pair(&actor_id, &key_pair) + .expect("insert actor key pair"); + let result = store.insert_actor_key_pair(&actor_id, &key_pair); + + assert!(matches!(result, Err(StoreError::Sqlite(_)))); + } + #[test] fn persist_actions_stores_follower() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); diff --git a/crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem b/crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem b/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- From 5a6544ac3308645f5454e85334c78da79a639521 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 12:48:40 +0900 Subject: [PATCH 04/42] Move runtime tests to integration tests Move HTTP endpoint and startup tests under the tests directory, with shared fixtures kept private to the integration test crate. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 63 ---- crates/feder-runtime-server/src/app.rs | 28 -- crates/feder-runtime-server/src/config.rs | 20 -- crates/feder-runtime-server/src/inbox.rs | 297 ------------------ crates/feder-runtime-server/src/webfinger.rs | 83 ----- .../feder-runtime-server/tests/cases/actor.rs | 74 +++++ .../feder-runtime-server/tests/cases/app.rs | 67 ++++ .../feder-runtime-server/tests/cases/inbox.rs | 276 ++++++++++++++++ .../tests/cases/webfinger.rs | 94 ++++++ .../feder-runtime-server/tests/common/mod.rs | 97 ++++++ crates/feder-runtime-server/tests/runtime.rs | 25 ++ 11 files changed, 633 insertions(+), 491 deletions(-) create mode 100644 crates/feder-runtime-server/tests/cases/actor.rs create mode 100644 crates/feder-runtime-server/tests/cases/app.rs create mode 100644 crates/feder-runtime-server/tests/cases/inbox.rs create mode 100644 crates/feder-runtime-server/tests/cases/webfinger.rs create mode 100644 crates/feder-runtime-server/tests/common/mod.rs create mode 100644 crates/feder-runtime-server/tests/runtime.rs diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index a57ea15..8ddfff0 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -37,66 +37,3 @@ pub async fn actor( ) .into_response()) } - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - #[tokio::test] - async fn returns_local_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/alice") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - - let body = to_bytes(response.into_body(), 2048) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); - assert_eq!(json["type"], "Person"); - assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); - assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); - assert_eq!(json["preferredUsername"], "alice"); - assert_eq!(json["name"], "alice"); - } - - #[tokio::test] - async fn rejects_unknown_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/bob") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 67d8181..9fdcdca 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -73,31 +73,3 @@ pub fn build_router(config: RuntimeConfig) -> Result { async fn healthz() -> StatusCode { StatusCode::NO_CONTENT } - -#[cfg(test)] -mod tests { - use axum::{ - body::Body, - http::{Request, StatusCode}, - }; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - #[tokio::test] - async fn returns_health_check() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/healthz") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - } -} diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs index fb1d7b5..918e18d 100644 --- a/crates/feder-runtime-server/src/config.rs +++ b/crates/feder-runtime-server/src/config.rs @@ -39,23 +39,3 @@ pub struct RuntimeConfig { pub inbox_auth_policy: InboxAuthPolicy, pub storage: StorageConfig, } - -#[cfg(test)] -pub(crate) fn test_config() -> RuntimeConfig { - RuntimeConfig { - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid outbox IRI"), - bind: "127.0.0.1:3000".parse().expect("valid bind address"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - storage: StorageConfig::InMemory, - } -} diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 83d9e14..ccd207a 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -118,300 +118,3 @@ pub async fn inbox( Ok(StatusCode::ACCEPTED.into_response()) } - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, Bytes}, - extract::Path, - http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, - response::Response, - }; - use serde_json::json; - use tower::ServiceExt; - - use crate::{ - app::AppState, - build_router, - config::{InboxAuthPolicy, StorageConfig, test_config}, - storage::RuntimeStore, - }; - - use super::inbox; - - fn activity_json_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/activity+json".parse().unwrap()); - headers - } - - fn follow_body() -> Bytes { - Bytes::from( - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Follow", - "id": "https://remote.example/activities/follow-1", - "actor": { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Person", - "id": "https://remote.example/users/bob", - "inbox": "https://remote.example/users/bob/inbox", - "outbox": "https://remote.example/users/bob/outbox" - }, - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize follow"), - ) - } - - async fn post_inbox( - app_state: AppState, - username: &str, - headers: HeaderMap, - body: Bytes, - ) -> Result { - inbox( - axum::extract::State(app_state), - Path(username.to_string()), - headers, - Method::POST, - Uri::from_static("/users/alice/inbox"), - body, - ) - .await - } - - #[tokio::test] - async fn valid_follow_reaches_core() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect("accepted follow"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - - let core = app_state.core.lock().expect("core lock"); - assert_eq!(core.state().followers().len(), 1); - assert_eq!( - core.state().followers()[0].follower.as_str(), - "https://remote.example/users/bob" - ); - assert_eq!( - core.state().followers()[0].following.as_str(), - "http://127.0.0.1:3000/users/alice" - ); - assert_eq!(core.state().delivery_targets().len(), 1); - assert_eq!( - core.state().delivery_targets()[0].inbox.as_str(), - "https://remote.example/users/bob/inbox" - ); - } - - #[tokio::test] - async fn require_signed_rejects_unsigned_follow_before_core() { - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let app_state = AppState::from_config(config).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect_err("unsigned follow should be rejected"); - - assert_eq!(error, StatusCode::UNAUTHORIZED); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_unknown_inbox_actor() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "bob", - activity_json_headers(), - follow_body(), - ) - .await - .expect_err("unknown inbox actor should be rejected"); - - assert_eq!(error, StatusCode::NOT_FOUND); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_unsupported_content_type() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); - - let error = post_inbox(app_state.clone(), "alice", headers, follow_body()) - .await - .expect_err("unsupported content type should be rejected"); - - assert_eq!(error, StatusCode::UNSUPPORTED_MEDIA_TYPE); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_malformed_json() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - Bytes::from_static(b"{not json"), - ) - .await - .expect_err("malformed json should be rejected"); - - assert_eq!(error, StatusCode::BAD_REQUEST); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn ignores_unsupported_activity_without_mutating_core() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = Bytes::from( - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Create", - "id": "https://remote.example/activities/create-1", - "actor": "https://remote.example/users/bob", - "object": { - "type": "Note", - "id": "https://remote.example/notes/1" - } - })) - .expect("serialize create"), - ); - - let response = post_inbox(app_state.clone(), "alice", activity_json_headers(), body) - .await - .expect("unsupported activity is accepted but ignored"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_oversized_inbox_body() { - let app = build_router(test_config()).expect("build router"); - let oversized_body = vec![b' '; 1_048_577]; - - let response = app - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/users/alice/inbox") - .header(CONTENT_TYPE, "application/activity+json") - .body(Body::from(oversized_body)) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); - } - - #[tokio::test] - async fn sqlite_storage_persists_followers_across_app_state_reopen() { - let path = std::env::temp_dir().join(format!( - "feder-runtime-server-test-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let app_state = AppState::from_config(config).expect("build app state"); - - let response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect("accepted follow"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - drop(app_state); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let app_state = AppState::from_config(config).expect("reopen app state"); - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers( - &"http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid IRI"), - ) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - assert_eq!( - followers[0].follower.as_str(), - "https://remote.example/users/bob" - ); - - let _ = std::fs::remove_file(path); - } -} diff --git a/crates/feder-runtime-server/src/webfinger.rs b/crates/feder-runtime-server/src/webfinger.rs index 23d86f2..c84f242 100644 --- a/crates/feder-runtime-server/src/webfinger.rs +++ b/crates/feder-runtime-server/src/webfinger.rs @@ -72,86 +72,3 @@ pub async fn webfinger( ) .into_response()) } - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; - - #[tokio::test] - async fn returns_webfinger_descriptor_for_local_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri(WEBFINGER_PATH) - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/jrd+json" - ); - - let body = to_bytes(response.into_body(), 1024) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); - assert_eq!(json["aliases"][0], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["links"][0]["rel"], "self"); - assert_eq!(json["links"][0]["type"], "application/activity+json"); - assert_eq!( - json["links"][0]["href"], - "http://127.0.0.1:3000/users/alice" - ); - } - - #[tokio::test] - async fn rejects_missing_resource() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn rejects_non_local_actor_resource() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs new file mode 100644 index 0000000..57a8314 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -0,0 +1,74 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{test_config, test_router}; + +#[tokio::test] +async fn returns_local_actor() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/users/alice") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + + let body = to_bytes(response.into_body(), 2048) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid json"); + + assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); + assert_eq!(json["type"], "Person"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); + assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); + assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); + assert_eq!(json["preferredUsername"], "alice"); + assert_eq!(json["name"], "alice"); +} + +#[tokio::test] +async fn rejects_unknown_actor() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/users/bob") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/cases/app.rs b/crates/feder-runtime-server/tests/cases/app.rs new file mode 100644 index 0000000..510ec4f --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/app.rs @@ -0,0 +1,67 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use feder_runtime_server::{AppState, config::StorageConfig, storage::RuntimeStore}; +use tower::ServiceExt; + +use crate::common::{temporary_database_path, test_config, test_router}; + +#[tokio::test] +async fn returns_health_check() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +#[test] +fn startup_generates_then_reuses_persisted_actor_key_pair() { + let path = temporary_database_path("feder-startup-key-test"); + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let first = AppState::from_config(config).expect("build first app state"); + let expected_public_key = first.actor_key_pair.public_key_pem().to_string(); + let stored = first + .store + .lock() + .expect("lock store") + .load_actor_key_pair(&first.local_actor.id) + .expect("load actor key pair") + .expect("stored actor key pair"); + assert_eq!(stored, *first.actor_key_pair); + drop(first); + + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let second = AppState::from_config(config).expect("reopen app state"); + + assert_eq!(second.actor_key_pair.public_key_pem(), expected_public_key); + + drop(second); + let _ = std::fs::remove_file(path); +} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs new file mode 100644 index 0000000..c9652e3 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -0,0 +1,276 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Router, + body::Body, + http::{Request, StatusCode, header::CONTENT_TYPE}, +}; +use feder_runtime_server::{ + app::router_with_state, + config::{InboxAuthPolicy, StorageConfig}, + storage::RuntimeStore, +}; +use serde_json::json; +use tower::ServiceExt; + +use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; + +fn follow_body() -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": "https://remote.example/activities/follow-1", + "actor": { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": "https://remote.example/users/bob", + "inbox": "https://remote.example/users/bob/inbox", + "outbox": "https://remote.example/users/bob/outbox" + }, + "object": "http://127.0.0.1:3000/users/alice" + })) + .expect("serialize follow") +} + +async fn post_inbox( + app: Router, + uri: &str, + content_type: &str, + body: impl Into, +) -> axum::response::Response { + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, content_type) + .body(body.into()) + .expect("valid request"), + ) + .await + .expect("response") +} + +#[tokio::test] +async fn valid_follow_reaches_core() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let core = state.core.lock().expect("core lock"); + assert_eq!(core.state().followers().len(), 1); + assert_eq!( + core.state().followers()[0].follower.as_str(), + "https://remote.example/users/bob" + ); + assert_eq!( + core.state().followers()[0].following.as_str(), + "http://127.0.0.1:3000/users/alice" + ); + assert_eq!(core.state().delivery_targets().len(), 1); + assert_eq!( + core.state().delivery_targets()[0].inbox.as_str(), + "https://remote.example/users/bob/inbox" + ); +} + +#[tokio::test] +async fn require_signed_rejects_unsigned_follow_before_core() { + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_unknown_inbox_actor() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/bob/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_unsupported_content_type() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_malformed_json() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + "{not json", + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn ignores_unsupported_activity_without_mutating_core() { + let state = test_app_state(test_config()).expect("build app state"); + let body = serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://remote.example/activities/create-1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Note", + "id": "https://remote.example/notes/1" + } + })) + .expect("serialize create"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_oversized_inbox_body() { + let response = post_inbox( + test_router(test_config()).expect("build router"), + "/users/alice/inbox", + "application/activity+json", + vec![b' '; 1_048_577], + ) + .await; + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + +#[tokio::test] +async fn sqlite_storage_persists_followers_across_app_state_reopen() { + let path = temporary_database_path("feder-runtime-server-test"); + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(config).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + drop(state); + + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(config).expect("reopen app state"); + let followers = state + .store + .lock() + .expect("store lock") + .list_followers( + &"http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid IRI"), + ) + .expect("list followers"); + + assert_eq!(followers.len(), 1); + assert_eq!( + followers[0].follower.as_str(), + "https://remote.example/users/bob" + ); + + drop(state); + let _ = std::fs::remove_file(path); +} diff --git a/crates/feder-runtime-server/tests/cases/webfinger.rs b/crates/feder-runtime-server/tests/cases/webfinger.rs new file mode 100644 index 0000000..3b6c963 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/webfinger.rs @@ -0,0 +1,94 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{test_config, test_router}; + +const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; + +#[tokio::test] +async fn returns_webfinger_descriptor_for_local_actor() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri(WEBFINGER_PATH) + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/jrd+json" + ); + + let body = to_bytes(response.into_body(), 1024) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid json"); + + assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); + assert_eq!(json["aliases"][0], "http://127.0.0.1:3000/users/alice"); + assert_eq!(json["links"][0]["rel"], "self"); + assert_eq!(json["links"][0]["type"], "application/activity+json"); + assert_eq!( + json["links"][0]["href"], + "http://127.0.0.1:3000/users/alice" + ); +} + +#[tokio::test] +async fn rejects_missing_resource() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/.well-known/webfinger") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn rejects_non_local_actor_resource() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs new file mode 100644 index 0000000..76843d2 --- /dev/null +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -0,0 +1,97 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::{Arc, Mutex}; + +use axum::Router; +use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; +use feder_runtime_server::{ + Error, + app::{AppState, router_with_state}, + config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, + storage::{RuntimeStore, SqliteStore}, +}; +use feder_vocab::Actor; + +pub fn test_config() -> RuntimeConfig { + RuntimeConfig { + actor_id: "http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid actor IRI"), + inbox: "http://127.0.0.1:3000/users/alice/inbox" + .parse() + .expect("valid inbox IRI"), + outbox: "http://127.0.0.1:3000/users/alice/outbox" + .parse() + .expect("valid outbox IRI"), + bind: "127.0.0.1:3000".parse().expect("valid bind address"), + username: "alice".to_string(), + handle_host: "127.0.0.1:3000".to_string(), + inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, + storage: StorageConfig::InMemory, + } +} + +pub fn test_app_state(config: RuntimeConfig) -> Result { + let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); + actor.preferred_username = Some(config.username.clone()); + actor.name = Some(config.username.clone()); + + let core = FederCore::new(FederConfig::new(actor.clone())); + let mut store = match &config.storage { + StorageConfig::InMemory => SqliteStore::open_in_memory()?, + StorageConfig::Sqlite { path } => SqliteStore::open(path)?, + }; + let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { + Some(key_pair) => key_pair, + None => { + let key_pair = fixture_actor_key_pair()?; + store.insert_actor_key_pair(&actor.id, &key_pair)?; + key_pair + } + }; + + Ok(AppState { + core: Arc::new(Mutex::new(core)), + store: Arc::new(Mutex::new(store)), + actor_key_pair: Arc::new(actor_key_pair), + local_actor: actor, + username: config.username, + handle_host: config.handle_host, + inbox_auth_policy: config.inbox_auth_policy, + }) +} + +pub fn test_router(config: RuntimeConfig) -> Result { + Ok(router_with_state(test_app_state(config)?)) +} + +pub fn temporary_database_path(prefix: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}.sqlite3", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )) +} + +fn fixture_actor_key_pair() -> Result { + ActorKeyPair::from_pem( + include_str!("../fixtures/rsa-private-key.pem").to_string(), + include_str!("../fixtures/rsa-public-key.pem").to_string(), + ) +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs new file mode 100644 index 0000000..2aa8296 --- /dev/null +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -0,0 +1,25 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +mod common; + +#[path = "cases/actor.rs"] +mod actor; +#[path = "cases/app.rs"] +mod app; +#[path = "cases/inbox.rs"] +mod inbox; +#[path = "cases/webfinger.rs"] +mod webfinger; From f5d697836b7fb0d66d8e639628b502b8ec528f97 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 13:07:44 +0900 Subject: [PATCH 05/42] Persist actor key pairs during startup Generate and persist a 4096-bit RSA key pair with operating system entropy when an actor has no stored key, and reuse the persisted pair on restart. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 15 +++++++++++++ Cargo.toml | 1 + crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/src/app.rs | 28 +++++++++++++++++++----- crates/feder-runtime-server/src/error.rs | 3 +++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b67a0c..c8bbcfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,7 @@ dependencies = [ "feder-core", "feder-vocab", "percent-encoding", + "rand_core", "rusqlite", "serde", "serde_json", @@ -263,6 +264,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -631,6 +643,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] [[package]] name = "regex-automata" diff --git a/Cargo.toml b/Cargo.toml index 27b7b94..e95e1c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } rand_chacha = { version = "0.3.1", default-features = false } +rand_core = { version = "0.6.4", features = ["getrandom"] } rsa = { version = "0.9.10", default-features = false, features = ["pem", "u64_digit"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index fa5b370..51e0761 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -16,6 +16,7 @@ serde.workspace = true thiserror = "2" serde_json.workspace = true percent-encoding = "2.3.2" +rand_core.workspace = true rusqlite = { version = "0.40.1", features = ["bundled"] } [dev-dependencies] diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 9fdcdca..7dcc368 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -17,18 +17,23 @@ use std::sync::{Arc, Mutex}; use crate::Error; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; -use crate::storage::SqliteStore; +use crate::storage::{RuntimeStore, SqliteStore}; use crate::webfinger::webfinger; use crate::{actor::actor, inbox::inbox}; use axum::routing::post; use axum::{Router, extract::DefaultBodyLimit, http::StatusCode, routing::get}; -use feder_core::{FederConfig, FederCore}; +use feder_core::{ + FederConfig, FederCore, + http_signatures::{ActorKeyPair, generate_actor_key_pair}, +}; use feder_vocab::Actor; +use rand_core::OsRng; #[derive(Clone)] pub struct AppState { pub core: Arc>, pub store: Arc>, + pub actor_key_pair: Arc, pub local_actor: Actor, pub username: String, pub handle_host: String, @@ -42,14 +47,23 @@ impl AppState { actor.name = Some(config.username.clone()); let core = FederCore::new(FederConfig::new(actor.clone())); - let store = match &config.storage { + let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, StorageConfig::Sqlite { path } => SqliteStore::open(path)?, }; + let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { + Some(key_pair) => key_pair, + None => { + let key_pair = generate_actor_key_pair(&mut OsRng)?; + store.insert_actor_key_pair(&actor.id, &key_pair)?; + key_pair + } + }; Ok(Self { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), + actor_key_pair: Arc::new(actor_key_pair), local_actor: actor, username: config.username, handle_host: config.handle_host, @@ -61,13 +75,17 @@ impl AppState { pub fn build_router(config: RuntimeConfig) -> Result { let state = AppState::from_config(config)?; - Ok(Router::new() + Ok(router_with_state(state)) +} + +pub fn router_with_state(state: AppState) -> Router { + Router::new() .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) .route("/users/{username}/inbox", post(inbox)) .layer(DefaultBodyLimit::max(1_048_576)) - .with_state(state)) + .with_state(state) } async fn healthz() -> StatusCode { diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index a031fa3..120f228 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -23,4 +23,7 @@ pub enum Error { #[error("storage failed")] Storage(#[from] crate::storage::StoreError), + + #[error("actor key generation failed")] + ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), } From aa359d2fe1420c0a5e97619fcd82aaac08d7808d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 13:21:52 +0900 Subject: [PATCH 06/42] Publish actor RSA public keys Attach the persisted RSA public key to the local actor as an embedded CryptographicKey using the #main-key identifier and security context. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/src/app.rs | 14 ++++++++++++-- .../feder-runtime-server/tests/cases/actor.rs | 17 ++++++++++++++++- crates/feder-runtime-server/tests/cases/app.rs | 15 +++++++++++++++ crates/feder-runtime-server/tests/common/mod.rs | 14 ++++++++++++-- 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8bbcfc..e6be604 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,7 @@ dependencies = [ "axum", "feder-core", "feder-vocab", + "iri-string", "percent-encoding", "rand_core", "rusqlite", diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 51e0761..90123db 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [dependencies] feder-core = { workspace = true, features = ["http-signatures"] } feder-vocab.workspace = true +iri-string.workspace = true axum = "0.8" serde.workspace = true thiserror = "2" diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 7dcc368..cb9d63f 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -26,7 +26,8 @@ use feder_core::{ FederConfig, FederCore, http_signatures::{ActorKeyPair, generate_actor_key_pair}, }; -use feder_vocab::Actor; +use feder_vocab::{Actor, CryptographicKey, Reference}; +use iri_string::types::IriFragmentStr; use rand_core::OsRng; #[derive(Clone)] @@ -46,7 +47,6 @@ impl AppState { actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); - let core = FederCore::new(FederConfig::new(actor.clone())); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, StorageConfig::Sqlite { path } => SqliteStore::open(path)?, @@ -59,6 +59,16 @@ impl AppState { key_pair } }; + let mut key_id = actor.id.clone(); + key_id.set_fragment(Some( + IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), + )); + actor.set_public_key(Reference::object(CryptographicKey::new( + key_id, + actor.id.clone(), + actor_key_pair.public_key_pem().to_string(), + ))); + let core = FederCore::new(FederConfig::new(actor.clone())); Ok(Self { core: Arc::new(Mutex::new(core)), diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index 57a8314..951c051 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -47,13 +47,28 @@ async fn returns_local_actor() { .expect("read response body"); let json: Value = serde_json::from_slice(&body).expect("valid json"); - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); + assert_eq!( + json["@context"], + serde_json::json!([ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ]) + ); assert_eq!(json["type"], "Person"); assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); assert_eq!(json["preferredUsername"], "alice"); assert_eq!(json["name"], "alice"); + assert_eq!( + json["publicKey"], + serde_json::json!({ + "id": "http://127.0.0.1:3000/users/alice#main-key", + "type": "CryptographicKey", + "owner": "http://127.0.0.1:3000/users/alice", + "publicKeyPem": include_str!("../fixtures/rsa-public-key.pem"), + }) + ); } #[tokio::test] diff --git a/crates/feder-runtime-server/tests/cases/app.rs b/crates/feder-runtime-server/tests/cases/app.rs index 510ec4f..6fd8552 100644 --- a/crates/feder-runtime-server/tests/cases/app.rs +++ b/crates/feder-runtime-server/tests/cases/app.rs @@ -18,6 +18,7 @@ use axum::{ http::{Request, StatusCode}, }; use feder_runtime_server::{AppState, config::StorageConfig, storage::RuntimeStore}; +use feder_vocab::Reference; use tower::ServiceExt; use crate::common::{temporary_database_path, test_config, test_router}; @@ -46,6 +47,20 @@ fn startup_generates_then_reuses_persisted_actor_key_pair() { config.storage = StorageConfig::Sqlite { path: path.clone() }; let first = AppState::from_config(config).expect("build first app state"); let expected_public_key = first.actor_key_pair.public_key_pem().to_string(); + let Reference::Object(published_key) = first + .local_actor + .public_key + .as_ref() + .expect("actor publishes public key") + else { + panic!("actor public key should be embedded"); + }; + assert_eq!( + published_key.id.as_str(), + "http://127.0.0.1:3000/users/alice#main-key" + ); + assert_eq!(published_key.owner, first.local_actor.id); + assert_eq!(published_key.public_key_pem, expected_public_key); let stored = first .store .lock() diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 76843d2..aa97a37 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -23,7 +23,8 @@ use feder_runtime_server::{ config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, storage::{RuntimeStore, SqliteStore}, }; -use feder_vocab::Actor; +use feder_vocab::{Actor, CryptographicKey, Reference}; +use iri_string::types::IriFragmentStr; pub fn test_config() -> RuntimeConfig { RuntimeConfig { @@ -49,7 +50,6 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); - let core = FederCore::new(FederConfig::new(actor.clone())); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, StorageConfig::Sqlite { path } => SqliteStore::open(path)?, @@ -62,6 +62,16 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { key_pair } }; + let mut key_id = actor.id.clone(); + key_id.set_fragment(Some( + IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), + )); + actor.set_public_key(Reference::object(CryptographicKey::new( + key_id, + actor.id.clone(), + actor_key_pair.public_key_pem().to_string(), + ))); + let core = FederCore::new(FederConfig::new(actor.clone())); Ok(AppState { core: Arc::new(Mutex::new(core)), From a905e26b84f8a232f403a3bfe4ebdcf99d22aa8d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 13:49:56 +0900 Subject: [PATCH 07/42] Execute outgoing activity deliveries Deliver SendActivity actions synchronously to recipient inboxes as ActivityPub JSON, report remote failures, and continue attempting later recipients after individual delivery failures. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1000 ++++++++++++++++- crates/feder-runtime-server/Cargo.toml | 3 +- crates/feder-runtime-server/README.md | 5 +- crates/feder-runtime-server/src/app.rs | 4 + crates/feder-runtime-server/src/error.rs | 3 + crates/feder-runtime-server/src/inbox.rs | 12 + crates/feder-runtime-server/src/lib.rs | 1 + crates/feder-runtime-server/src/send.rs | 93 ++ .../feder-runtime-server/tests/cases/inbox.rs | 78 +- .../feder-runtime-server/tests/cases/send.rs | 93 ++ .../feder-runtime-server/tests/common/mod.rs | 46 +- crates/feder-runtime-server/tests/runtime.rs | 2 + 12 files changed, 1308 insertions(+), 32 deletions(-) create mode 100644 crates/feder-runtime-server/src/send.rs create mode 100644 crates/feder-runtime-server/tests/cases/send.rs diff --git a/Cargo.lock b/Cargo.lock index e6be604..3bb4867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -75,6 +98,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -106,6 +135,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -115,12 +146,73 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -152,6 +244,23 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -183,7 +292,8 @@ dependencies = [ "feder-vocab", "iri-string", "percent-encoding", - "rand_core", + "rand_core 0.6.4", + "reqwest", "rusqlite", "serde", "serde_json", @@ -222,6 +332,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -272,8 +388,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -366,6 +498,22 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -374,15 +522,132 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "iri-string" version = "0.7.12" @@ -398,6 +663,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -405,6 +729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -440,12 +765,24 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -481,7 +818,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -490,7 +827,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -504,7 +841,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -544,6 +881,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -592,6 +935,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -610,6 +962,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -619,6 +1028,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -626,7 +1041,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -636,7 +1062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -645,7 +1071,22 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", ] [[package]] @@ -666,19 +1107,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "rsa" -version = "0.9.10" +name = "reqwest" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "const-oid", + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -710,6 +1200,96 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -722,6 +1302,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -810,9 +1437,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "single-user-server" version = "0.1.0" @@ -843,7 +1486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -874,6 +1517,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -896,6 +1545,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "thiserror" @@ -926,18 +1589,44 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -951,6 +1640,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -967,6 +1666,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1041,6 +1758,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1053,6 +1776,30 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -1071,6 +1818,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1090,6 +1856,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -1122,12 +1898,59 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1137,6 +1960,99 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -1157,12 +2073,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 90123db..f48b026 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -18,11 +18,12 @@ thiserror = "2" serde_json.workspace = true percent-encoding = "2.3.2" rand_core.workspace = true +reqwest = { version = "0.13.1", default-features = false, features = ["rustls"] } rusqlite = { version = "0.40.1", features = ["bundled"] } [dev-dependencies] serde_json.workspace = true -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] } tower.workspace = true [lints] diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index d14408a..44ab613 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -11,8 +11,9 @@ handle hosts. ActivityPub inbox handling for supported Follow activities is included. The runtime can use in-memory storage for tests and examples, or file-backed SQLite -storage for persisted follower state. Signature verification and delivery are -intentionally left to later issues. +storage for persisted follower state. Outgoing `SendActivity` actions are sent +synchronously to recipient inboxes as ActivityPub JSON. HTTP signature creation +and verification are intentionally left to later issues. Example diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index cb9d63f..57a036e 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex}; use crate::Error; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; +use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; use crate::webfinger::webfinger; use crate::{actor::actor, inbox::inbox}; @@ -35,6 +36,7 @@ pub struct AppState { pub core: Arc>, pub store: Arc>, pub actor_key_pair: Arc, + pub activity_sender: ActivitySender, pub local_actor: Actor, pub username: String, pub handle_host: String, @@ -69,11 +71,13 @@ impl AppState { actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); + let activity_sender = ActivitySender::new()?; Ok(Self { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair: Arc::new(actor_key_pair), + activity_sender, local_actor: actor, username: config.username, handle_host: config.handle_host, diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index 120f228..c3aa76c 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -26,4 +26,7 @@ pub enum Error { #[error("actor key generation failed")] ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), + + #[error("activity sender setup failed")] + ActivitySender(#[from] crate::send::SendError), } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index ccd207a..65f8fe1 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -26,6 +26,7 @@ use serde_json::{Value, from_slice, from_value}; use crate::app::AppState; use crate::config::InboxAuthPolicy; +use crate::send::SendError; use crate::storage::RuntimeStore; pub struct InboxRequest { @@ -116,5 +117,16 @@ pub async fn inbox( .persist_actions(&result.actions) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + app_state + .activity_sender + .send_actions(&result.actions) + .await + .map_err(|error| match error { + SendError::Request(_) | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, + SendError::BuildClient(_) + | SendError::Serialize(_) + | SendError::UnsupportedActivity => StatusCode::INTERNAL_SERVER_ERROR, + })?; + Ok(StatusCode::ACCEPTED.into_response()) } diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 598520f..7fc5da5 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -18,6 +18,7 @@ pub mod app; pub mod config; pub mod error; pub mod inbox; +pub mod send; pub mod storage; pub mod webfinger; diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs new file mode 100644 index 0000000..6ada4ef --- /dev/null +++ b/crates/feder-runtime-server/src/send.rs @@ -0,0 +1,93 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_core::{Action, Activity, SendActivity}; +use reqwest::{Client, StatusCode, redirect::Policy}; + +#[derive(Clone, Debug)] +pub struct ActivitySender { + client: Client, +} + +impl ActivitySender { + pub fn new() -> Result { + let client = Client::builder() + .redirect(Policy::none()) + .build() + .map_err(SendError::BuildClient)?; + + Ok(Self { client }) + } + + pub async fn send_actions(&self, actions: &[Action]) -> Result<(), SendError> { + let mut first_error = None; + + for action in actions { + if let Action::SendActivity(send) = action + && let Err(error) = self.send(send).await + && first_error.is_none() + { + first_error = Some(error); + } + } + + first_error.map_or(Ok(()), Err) + } + + async fn send(&self, send: &SendActivity) -> Result<(), SendError> { + let body = match &send.activity { + Activity::Accept(activity) => serde_json::to_vec(activity), + Activity::CreateNote(activity) => serde_json::to_vec(activity), + _ => return Err(SendError::UnsupportedActivity), + } + .map_err(SendError::Serialize)?; + + let response = self + .client + .post(send.inbox.as_str()) + .header(reqwest::header::CONTENT_TYPE, "application/activity+json") + .body(body) + .send() + .await + .map_err(SendError::Request)?; + + if !response.status().is_success() { + return Err(SendError::UnsuccessfulStatus { + inbox: send.inbox.to_string(), + status: response.status(), + }); + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SendError { + #[error("failed to build HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("failed to serialize activity")] + Serialize(#[source] serde_json::Error), + + #[error("failed to send activity")] + Request(#[source] reqwest::Error), + + #[error("sending activity to {inbox} returned {status}")] + UnsuccessfulStatus { inbox: String, status: StatusCode }, + + #[error("activity type is not supported for sending")] + UnsupportedActivity, +} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index c9652e3..a46d3c5 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -26,9 +26,15 @@ use feder_runtime_server::{ use serde_json::json; use tower::ServiceExt; -use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; +use crate::common::{ + spawn_inbox_server, temporary_database_path, test_app_state, test_config, test_router, +}; fn follow_body() -> Vec { + follow_body_for_inbox("https://remote.example/users/bob/inbox") +} + +fn follow_body_for_inbox(inbox: &str) -> Vec { serde_json::to_vec(&json!({ "@context": "https://www.w3.org/ns/activitystreams", "type": "Follow", @@ -37,7 +43,7 @@ fn follow_body() -> Vec { "@context": "https://www.w3.org/ns/activitystreams", "type": "Person", "id": "https://remote.example/users/bob", - "inbox": "https://remote.example/users/bob/inbox", + "inbox": inbox, "outbox": "https://remote.example/users/bob/outbox" }, "object": "http://127.0.0.1:3000/users/alice" @@ -65,32 +71,75 @@ async fn post_inbox( #[tokio::test] async fn valid_follow_reaches_core() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let state = test_app_state(test_config()).expect("build app state"); let response = post_inbox( router_with_state(state.clone()), "/users/alice/inbox", "application/activity+json", - follow_body(), + follow_body_for_inbox(&inbox), ) .await; assert_eq!(response.status(), StatusCode::ACCEPTED); - let core = state.core.lock().expect("core lock"); - assert_eq!(core.state().followers().len(), 1); + { + let core = state.core.lock().expect("core lock"); + assert_eq!(core.state().followers().len(), 1); + assert_eq!( + core.state().followers()[0].follower.as_str(), + "https://remote.example/users/bob" + ); + assert_eq!( + core.state().followers()[0].following.as_str(), + "http://127.0.0.1:3000/users/alice" + ); + assert_eq!(core.state().delivery_targets().len(), 1); + assert_eq!(core.state().delivery_targets()[0].inbox.as_str(), inbox); + } + + let request = requests.recv().await.expect("receive Accept request"); assert_eq!( - core.state().followers()[0].follower.as_str(), - "https://remote.example/users/bob" + request.headers.get(CONTENT_TYPE).unwrap(), + "application/activity+json" ); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Accept"); + assert_eq!(activity["actor"], "http://127.0.0.1:3000/users/alice"); assert_eq!( - core.state().followers()[0].following.as_str(), - "http://127.0.0.1:3000/users/alice" + activity["object"]["id"], + "https://remote.example/activities/follow-1" ); - assert_eq!(core.state().delivery_targets().len(), 1); + inbox_server.abort(); +} + +#[tokio::test] +async fn send_failure_returns_bad_gateway_after_core_handling() { + let (inbox, mut requests, inbox_server) = + spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body_for_inbox(&inbox), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); assert_eq!( - core.state().delivery_targets()[0].inbox.as_str(), - "https://remote.example/users/bob/inbox" + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .len(), + 1 ); + requests.recv().await.expect("receive failed request"); + inbox_server.abort(); } #[tokio::test] @@ -236,6 +285,7 @@ async fn rejects_oversized_inbox_body() { #[tokio::test] async fn sqlite_storage_persists_followers_across_app_state_reopen() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let path = temporary_database_path("feder-runtime-server-test"); let mut config = test_config(); config.storage = StorageConfig::Sqlite { path: path.clone() }; @@ -244,11 +294,13 @@ async fn sqlite_storage_persists_followers_across_app_state_reopen() { router_with_state(state.clone()), "/users/alice/inbox", "application/activity+json", - follow_body(), + follow_body_for_inbox(&inbox), ) .await; assert_eq!(response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept request"); + inbox_server.abort(); drop(state); let mut config = test_config(); diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs new file mode 100644 index 0000000..419a9ad --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -0,0 +1,93 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::StatusCode; +use feder_core::{Action, Activity, SendActivity}; +use feder_runtime_server::send::ActivitySender; +use feder_vocab::{Create, Note, Reference}; + +use crate::common::spawn_inbox_server; + +fn create_note_send_action(inbox: &str) -> Action { + let actor_id = "https://local.example/users/alice" + .parse() + .expect("valid actor IRI"); + let note = Note::new( + "https://local.example/notes/1" + .parse() + .expect("valid note IRI"), + ); + let create = Create::new( + "https://local.example/activities/create-1" + .parse() + .expect("valid activity IRI"), + Reference::id(actor_id), + Reference::object(note), + ); + + Action::SendActivity(SendActivity { + activity: Activity::CreateNote(create), + inbox: inbox.parse().expect("valid inbox IRI"), + }) +} + +#[tokio::test] +async fn sends_create_note_action() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let actions = [create_note_send_action(&inbox)]; + + ActivitySender::new() + .expect("build activity sender") + .send_actions(&actions) + .await + .expect("send Create activity"); + + let request = requests.recv().await.expect("receive Create request"); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Create"); + assert_eq!(activity["actor"], "https://local.example/users/alice"); + assert_eq!(activity["object"]["type"], "Note"); + inbox_server.abort(); +} + +#[tokio::test] +async fn attempts_later_sends_after_failure() { + let (failed_inbox, mut failed_requests, failed_server) = + spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let (successful_inbox, mut successful_requests, successful_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let actions = [ + create_note_send_action(&failed_inbox), + create_note_send_action(&successful_inbox), + ]; + + let result = ActivitySender::new() + .expect("build activity sender") + .send_actions(&actions) + .await; + + assert!(result.is_err()); + failed_requests + .recv() + .await + .expect("receive failed request"); + successful_requests + .recv() + .await + .expect("receive later request"); + failed_server.abort(); + successful_server.abort(); +} diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index aa97a37..13e7d15 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -15,16 +15,58 @@ use std::sync::{Arc, Mutex}; -use axum::Router; +use axum::{ + Router, + body::Bytes, + http::{HeaderMap, StatusCode}, + routing::post, +}; use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; use feder_runtime_server::{ Error, app::{AppState, router_with_state}, config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, + send::ActivitySender, storage::{RuntimeStore, SqliteStore}, }; use feder_vocab::{Actor, CryptographicKey, Reference}; use iri_string::types::IriFragmentStr; +use tokio::{sync::mpsc, task::JoinHandle}; + +pub struct RecordedRequest { + pub headers: HeaderMap, + pub body: Bytes, +} + +pub async fn spawn_inbox_server( + response_status: StatusCode, +) -> (String, mpsc::Receiver, JoinHandle<()>) { + let (sender, receiver) = mpsc::channel(1); + let app = Router::new().route( + "/inbox", + post(move |headers: HeaderMap, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, body }) + .await + .expect("request receiver remains open"); + response_status + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind inbox server"); + let address = listener.local_addr().expect("inbox server address"); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve inbox endpoint"); + }); + + (format!("http://{address}/inbox"), receiver, task) +} pub fn test_config() -> RuntimeConfig { RuntimeConfig { @@ -72,11 +114,13 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); + let activity_sender = ActivitySender::new()?; Ok(AppState { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair: Arc::new(actor_key_pair), + activity_sender, local_actor: actor, username: config.username, handle_host: config.handle_host, diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 2aa8296..0b2690e 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -21,5 +21,7 @@ mod actor; mod app; #[path = "cases/inbox.rs"] mod inbox; +#[path = "cases/send.rs"] +mod send; #[path = "cases/webfinger.rs"] mod webfinger; From 9835dc9acc77fd128119579f8b0db42f8a39c93a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 14:05:32 +0900 Subject: [PATCH 08/42] Sign outgoing ActivityPub requests Create draft-Cavage RSA signatures in the portable core and apply them to outgoing ActivityPub requests using the persisted actor key and matching #main-key identifier. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 35 ++++- Cargo.toml | 3 +- crates/feder-core/Cargo.toml | 3 +- crates/feder-core/src/http_signatures.rs | 137 +++++++++++++++++- crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/README.md | 5 +- crates/feder-runtime-server/src/app.rs | 7 +- crates/feder-runtime-server/src/inbox.rs | 2 + crates/feder-runtime-server/src/send.rs | 67 ++++++++- .../feder-runtime-server/tests/cases/send.rs | 27 +++- .../feder-runtime-server/tests/common/mod.rs | 15 +- 11 files changed, 275 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bb4867..366bde9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -159,7 +168,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -204,6 +213,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -240,6 +258,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ + "block-buffer", "const-oid", "crypto-common", ] @@ -277,6 +296,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" name = "feder-core" version = "0.1.0" dependencies = [ + "base64", "feder-vocab", "rand_chacha", "rsa", @@ -290,6 +310,7 @@ dependencies = [ "axum", "feder-core", "feder-vocab", + "httpdate", "iri-string", "percent-encoding", "rand_core 0.6.4", @@ -1169,6 +1190,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2", "signature", "spki", "subtle", @@ -1415,6 +1437,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index e95e1c4..492eb2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,13 +18,14 @@ repository = "https://github.com/fedify-dev/feder" [workspace.dependencies] # Keep workspace crate versions in sync with [workspace.package].version. # Use `mise run bump-execute ` instead of editing these by hand. +base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } feder-core = { version = "0.1.0", path = "crates/feder-core" } feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" } feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } rand_chacha = { version = "0.3.1", default-features = false } rand_core = { version = "0.6.4", features = ["getrandom"] } -rsa = { version = "0.9.10", default-features = false, features = ["pem", "u64_digit"] } +rsa = { version = "0.9.10", default-features = false, features = ["pem", "sha2", "u64_digit"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" tower = { version = "0.5", features = ["util"]} diff --git a/crates/feder-core/Cargo.toml b/crates/feder-core/Cargo.toml index 2cd3458..ab5bb5b 100644 --- a/crates/feder-core/Cargo.toml +++ b/crates/feder-core/Cargo.toml @@ -9,9 +9,10 @@ homepage.workspace = true repository.workspace = true [features] -http-signatures = ["dep:rsa", "dep:zeroize"] +http-signatures = ["dep:base64", "dep:rsa", "dep:zeroize"] [dependencies] +base64 = { workspace = true, optional = true } feder-vocab.workspace = true rsa = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } diff --git a/crates/feder-core/src/http_signatures.rs b/crates/feder-core/src/http_signatures.rs index fb23dc5..6c9493d 100644 --- a/crates/feder-core/src/http_signatures.rs +++ b/crates/feder-core/src/http_signatures.rs @@ -1,12 +1,16 @@ //! Draft-Cavage HTTP Signature primitives. -use alloc::string::String; +use alloc::{format, string::String, vec::Vec}; use core::fmt; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use rsa::{ RsaPrivateKey, RsaPublicKey, + pkcs1v15::SigningKey, pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, rand_core::CryptoRngCore, + sha2::{Digest, Sha256}, + signature::{SignatureEncoding, Signer}, }; use zeroize::Zeroizing; @@ -105,6 +109,75 @@ pub fn generate_actor_key_pair( }) } +/// Creates an RFC 3230 SHA-256 digest header. +#[must_use] +pub fn create_sha256_digest_header(body: &[u8]) -> String { + let digest = Sha256::digest(body); + format!("SHA-256={}", STANDARD.encode(digest)) +} + +/// Signs a prepared HTTP request using the draft-Cavage header format. +/// +/// Header names must be supplied in the order in which they should appear in +/// the signature's `headers` parameter. +pub fn sign_draft_cavage( + key_pair: &ActorKeyPair, + key_id: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> Result { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let private_key = RsaPrivateKey::from_pkcs8_pem(key_pair.private_key_pem()) + .map_err(HttpSignatureError::InvalidPrivateKey)?; + let signing_key = SigningKey::::new(private_key); + let signature = signing_key.sign(signature_base.as_bytes()).to_bytes(); + let signed_headers = headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::>() + .join(" "); + + Ok(format!( + "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"{}\"", + STANDARD.encode(signature) + )) +} + +fn draft_cavage_signature_base( + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> String { + let mut lines = Vec::with_capacity(headers.len() + 1); + lines.push(format!( + "(request-target): {} {request_target}", + method.to_ascii_lowercase() + )); + lines.extend( + headers + .iter() + .map(|(name, value)| format!("{}: {}", name.to_ascii_lowercase(), value.trim())), + ); + lines.join("\n") +} + +/// Errors produced while creating an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureError { + InvalidPrivateKey(rsa::pkcs8::Error), +} + +impl fmt::Display for HttpSignatureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + } + } +} + +impl core::error::Error for HttpSignatureError {} + #[cfg(test)] mod tests { use alloc::string::ToString; @@ -112,6 +185,10 @@ mod tests { use rand_chacha::ChaCha20Rng; use rsa::rand_core::SeedableRng; use rsa::traits::PublicKeyParts; + use rsa::{ + pkcs1v15::{Signature, VerifyingKey}, + signature::Verifier, + }; use super::*; @@ -153,6 +230,64 @@ mod tests { assert!(!debug.contains(pair.private_key_pem())); } + #[test] + fn sha256_digest_matches_known_vector() { + assert_eq!( + create_sha256_digest_header(b"Hello, world!"), + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=" + ); + } + + #[test] + fn draft_cavage_signature_preserves_header_order() { + let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("load actor key pair fixture"); + let headers = [ + ("accept", "text/plain"), + ("content-type", "text/plain; charset=utf-8"), + ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), + ( + "digest", + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", + ), + ("host", "example.com"), + ]; + + let signature_header = + sign_draft_cavage(&pair, "https://example.com/key", "POST", "/", &headers) + .expect("sign request"); + + assert!(signature_header.starts_with( + "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) accept content-type date digest host\",signature=\"" + )); + let signature_prefix = signature_header_prefix(&headers); + let signature = signature_header + .strip_prefix(&signature_prefix) + .and_then(|value| value.strip_suffix('"')) + .expect("signature parameter"); + let signature = STANDARD.decode(signature).expect("base64 signature"); + let signature = Signature::try_from(signature.as_slice()).expect("RSA signature"); + let public_key = + RsaPublicKey::from_public_key_pem(pair.public_key_pem()).expect("parse public key"); + let verifying_key = VerifyingKey::::new(public_key); + let signature_base = draft_cavage_signature_base("POST", "/", &headers); + + verifying_key + .verify(signature_base.as_bytes(), &signature) + .expect("verify signature"); + } + + fn signature_header_prefix(headers: &[(&str, &str)]) -> String { + let signed_headers = headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::>() + .join(" "); + format!( + "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"" + ) + } + fn test_rng(seed: u8) -> ChaCha20Rng { ChaCha20Rng::from_seed([seed; 32]) } diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index f48b026..f6e6f90 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -13,6 +13,7 @@ feder-core = { workspace = true, features = ["http-signatures"] } feder-vocab.workspace = true iri-string.workspace = true axum = "0.8" +httpdate = "1" serde.workspace = true thiserror = "2" serde_json.workspace = true diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 44ab613..9a3d470 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -12,8 +12,9 @@ handle hosts. ActivityPub inbox handling for supported Follow activities is included. The runtime can use in-memory storage for tests and examples, or file-backed SQLite storage for persisted follower state. Outgoing `SendActivity` actions are sent -synchronously to recipient inboxes as ActivityPub JSON. HTTP signature creation -and verification are intentionally left to later issues. +synchronously to recipient inboxes as ActivityPub JSON signed with the actor's +draft-Cavage RSA key. HTTP signature verification is intentionally left to a +later issue. Example diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 57a036e..418cd35 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -66,17 +66,18 @@ impl AppState { IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), )); actor.set_public_key(Reference::object(CryptographicKey::new( - key_id, + key_id.clone(), actor.id.clone(), actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); - let activity_sender = ActivitySender::new()?; + let actor_key_pair = Arc::new(actor_key_pair); + let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; Ok(Self { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), - actor_key_pair: Arc::new(actor_key_pair), + actor_key_pair, activity_sender, local_actor: actor, username: config.username, diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 65f8fe1..c7a9972 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -124,7 +124,9 @@ pub async fn inbox( .map_err(|error| match error { SendError::Request(_) | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, SendError::BuildClient(_) + | SendError::InvalidInbox(_) | SendError::Serialize(_) + | SendError::Sign(_) | SendError::UnsupportedActivity => StatusCode::INTERNAL_SERVER_ERROR, })?; diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 6ada4ef..90c89b6 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -13,24 +13,44 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_core::{Action, Activity, SendActivity}; -use reqwest::{Client, StatusCode, redirect::Policy}; - +use std::{sync::Arc, time::SystemTime}; + +use feder_core::{ + Action, Activity, SendActivity, + http_signatures::{ + ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, + }, +}; +use reqwest::{ + Client, StatusCode, Url, + header::{CONTENT_TYPE, DATE, HOST}, + redirect::Policy, +}; + +/// Sends core `SendActivity` actions as signed ActivityPub HTTP requests. #[derive(Clone, Debug)] pub struct ActivitySender { client: Client, + key_pair: Arc, + key_id: String, } impl ActivitySender { - pub fn new() -> Result { + /// Creates an activity sender for one actor identity. + pub fn new(key_pair: Arc, key_id: String) -> Result { let client = Client::builder() .redirect(Policy::none()) .build() .map_err(SendError::BuildClient)?; - Ok(Self { client }) + Ok(Self { + client, + key_pair, + key_id, + }) } + /// Attempts every send action and returns the first error encountered. pub async fn send_actions(&self, actions: &[Action]) -> Result<(), SendError> { let mut first_error = None; @@ -53,11 +73,38 @@ impl ActivitySender { _ => return Err(SendError::UnsupportedActivity), } .map_err(SendError::Serialize)?; + let url = Url::parse(send.inbox.as_str()) + .map_err(|_| SendError::InvalidInbox(send.inbox.to_string()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(SendError::InvalidInbox(send.inbox.to_string())); + } + let mut host = url + .host() + .ok_or_else(|| SendError::InvalidInbox(send.inbox.to_string()))? + .to_string(); + if let Some(port) = url.port() { + host = format!("{host}:{port}"); + } + let date = httpdate::fmt_http_date(SystemTime::now()); + let digest = create_sha256_digest_header(&body); + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host.as_str()), + ]; + let signature = + sign_draft_cavage(&self.key_pair, &self.key_id, "POST", url.path(), &headers) + .map_err(SendError::Sign)?; let response = self .client - .post(send.inbox.as_str()) - .header(reqwest::header::CONTENT_TYPE, "application/activity+json") + .post(url) + .header(CONTENT_TYPE, "application/activity+json") + .header(DATE, date) + .header("Digest", digest) + .header(HOST, host) + .header("Signature", signature) .body(body) .send() .await @@ -82,6 +129,12 @@ pub enum SendError { #[error("failed to serialize activity")] Serialize(#[source] serde_json::Error), + #[error("invalid recipient inbox: {0}")] + InvalidInbox(String), + + #[error("failed to sign activity request")] + Sign(#[source] HttpSignatureError), + #[error("failed to send activity")] Request(#[source] reqwest::Error), diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index 419a9ad..9624037 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -15,10 +15,9 @@ use axum::http::StatusCode; use feder_core::{Action, Activity, SendActivity}; -use feder_runtime_server::send::ActivitySender; use feder_vocab::{Create, Note, Reference}; -use crate::common::spawn_inbox_server; +use crate::common::{spawn_inbox_server, test_activity_sender}; fn create_note_send_action(inbox: &str) -> Action { let actor_id = "https://local.example/users/alice" @@ -48,13 +47,28 @@ async fn sends_create_note_action() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let actions = [create_note_send_action(&inbox)]; - ActivitySender::new() - .expect("build activity sender") + test_activity_sender() .send_actions(&actions) .await .expect("send Create activity"); let request = requests.recv().await.expect("receive Create request"); + assert_eq!( + request.headers["host"], + inbox + .strip_prefix("http://") + .and_then(|value| value.strip_suffix("/inbox")) + .expect("inbox authority") + ); + assert!(httpdate::parse_http_date(request.headers["date"].to_str().unwrap()).is_ok()); + assert_eq!( + request.headers["digest"], + feder_core::http_signatures::create_sha256_digest_header(&request.body) + ); + let signature = request.headers["signature"].to_str().unwrap(); + assert!(signature.starts_with( + "keyId=\"https://local.example/users/alice#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) content-type date digest host\",signature=\"" + )); let activity: serde_json::Value = serde_json::from_slice(&request.body).expect("valid sent activity"); assert_eq!(activity["type"], "Create"); @@ -74,10 +88,7 @@ async fn attempts_later_sends_after_failure() { create_note_send_action(&successful_inbox), ]; - let result = ActivitySender::new() - .expect("build activity sender") - .send_actions(&actions) - .await; + let result = test_activity_sender().send_actions(&actions).await; assert!(result.is_err()); failed_requests diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 13e7d15..2038071 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -109,17 +109,18 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), )); actor.set_public_key(Reference::object(CryptographicKey::new( - key_id, + key_id.clone(), actor.id.clone(), actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); - let activity_sender = ActivitySender::new()?; + let actor_key_pair = Arc::new(actor_key_pair); + let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; Ok(AppState { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), - actor_key_pair: Arc::new(actor_key_pair), + actor_key_pair, activity_sender, local_actor: actor, username: config.username, @@ -149,3 +150,11 @@ fn fixture_actor_key_pair() -> Result ActivitySender { + ActivitySender::new( + Arc::new(fixture_actor_key_pair().expect("load actor key pair fixture")), + "https://local.example/users/alice#main-key".to_string(), + ) + .expect("build activity sender") +} From 1f11b4198e58cb81ce2ae1327b08e57041b31c7b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 14:43:14 +0900 Subject: [PATCH 09/42] Include query strings in signed request targets Sign the complete path and query for outgoing ActivityPub requests, and add regression coverage for inbox URLs containing query parameters. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/send.rs | 16 +++++++-- .../feder-runtime-server/tests/cases/send.rs | 35 ++++++++++++++++--- .../feder-runtime-server/tests/common/mod.rs | 7 ++-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 90c89b6..8a26d4f 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -93,9 +93,19 @@ impl ActivitySender { ("digest", digest.as_str()), ("host", host.as_str()), ]; - let signature = - sign_draft_cavage(&self.key_pair, &self.key_id, "POST", url.path(), &headers) - .map_err(SendError::Sign)?; + let mut request_target = url.path().to_string(); + if let Some(query) = url.query() { + request_target.push('?'); + request_target.push_str(query); + } + let signature = sign_draft_cavage( + &self.key_pair, + &self.key_id, + "POST", + &request_target, + &headers, + ) + .map_err(SendError::Sign)?; let response = self .client diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index 9624037..bae073a 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -14,7 +14,10 @@ // along with this program. If not, see . use axum::http::StatusCode; -use feder_core::{Action, Activity, SendActivity}; +use feder_core::{ + Action, Activity, SendActivity, + http_signatures::{ActorKeyPair, sign_draft_cavage}, +}; use feder_vocab::{Create, Note, Reference}; use crate::common::{spawn_inbox_server, test_activity_sender}; @@ -45,6 +48,7 @@ fn create_note_send_action(inbox: &str) -> Action { #[tokio::test] async fn sends_create_note_action() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let inbox = format!("{inbox}?shared=true"); let actions = [create_note_send_action(&inbox)]; test_activity_sender() @@ -53,11 +57,12 @@ async fn sends_create_note_action() { .expect("send Create activity"); let request = requests.recv().await.expect("receive Create request"); + assert_eq!(request.uri, "/inbox?shared=true"); assert_eq!( request.headers["host"], inbox .strip_prefix("http://") - .and_then(|value| value.strip_suffix("/inbox")) + .and_then(|value| value.split_once('/').map(|(authority, _)| authority)) .expect("inbox authority") ); assert!(httpdate::parse_http_date(request.headers["date"].to_str().unwrap()).is_ok()); @@ -66,9 +71,29 @@ async fn sends_create_note_action() { feder_core::http_signatures::create_sha256_digest_header(&request.body) ); let signature = request.headers["signature"].to_str().unwrap(); - assert!(signature.starts_with( - "keyId=\"https://local.example/users/alice#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) content-type date digest host\",signature=\"" - )); + let headers = [ + ( + "content-type", + request.headers["content-type"].to_str().unwrap(), + ), + ("date", request.headers["date"].to_str().unwrap()), + ("digest", request.headers["digest"].to_str().unwrap()), + ("host", request.headers["host"].to_str().unwrap()), + ]; + let key_pair = ActorKeyPair::from_pem( + include_str!("../fixtures/rsa-private-key.pem").to_string(), + include_str!("../fixtures/rsa-public-key.pem").to_string(), + ) + .expect("load actor key pair fixture"); + let expected_signature = sign_draft_cavage( + &key_pair, + "https://local.example/users/alice#main-key", + "POST", + "/inbox?shared=true", + &headers, + ) + .expect("sign captured request"); + assert_eq!(signature, expected_signature); let activity: serde_json::Value = serde_json::from_slice(&request.body).expect("valid sent activity"); assert_eq!(activity["type"], "Create"); diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 2038071..74edaad 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex}; use axum::{ Router, body::Bytes, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, Uri}, routing::post, }; use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; @@ -35,6 +35,7 @@ use tokio::{sync::mpsc, task::JoinHandle}; pub struct RecordedRequest { pub headers: HeaderMap, + pub uri: Uri, pub body: Bytes, } @@ -44,11 +45,11 @@ pub async fn spawn_inbox_server( let (sender, receiver) = mpsc::channel(1); let app = Router::new().route( "/inbox", - post(move |headers: HeaderMap, body: Bytes| { + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { let sender = sender.clone(); async move { sender - .send(RecordedRequest { headers, body }) + .send(RecordedRequest { headers, uri, body }) .await .expect("request receiver remains open"); response_status From 943647181f6c92c7ec2ef65fad1060e2a1e9d7db Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 16:10:21 +0900 Subject: [PATCH 10/42] Harden outgoing ActivityPub delivery Block private and special-use inbox destinations using validated DNS resolution, with an explicit option for local development. Configure connection and total request timeouts to prevent stalled deliveries. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 2 + crates/feder-runtime-server/Cargo.toml | 3 + crates/feder-runtime-server/src/app.rs | 6 +- crates/feder-runtime-server/src/config.rs | 12 ++ crates/feder-runtime-server/src/inbox.rs | 4 +- crates/feder-runtime-server/src/lib.rs | 3 +- .../src/outbound_network.rs | 147 ++++++++++++++++++ crates/feder-runtime-server/src/send.rs | 28 +++- .../feder-runtime-server/tests/cases/send.rs | 49 +++++- .../feder-runtime-server/tests/common/mod.rs | 14 +- examples/single-user-server/src/main.rs | 5 +- 11 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 crates/feder-runtime-server/src/outbound_network.rs diff --git a/Cargo.lock b/Cargo.lock index 366bde9..0f6ef02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,6 +311,7 @@ dependencies = [ "feder-core", "feder-vocab", "httpdate", + "ipnet", "iri-string", "percent-encoding", "rand_core 0.6.4", @@ -321,6 +322,7 @@ dependencies = [ "thiserror", "tokio", "tower", + "url", ] [[package]] diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index f6e6f90..81adfbd 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -14,6 +14,7 @@ feder-vocab.workspace = true iri-string.workspace = true axum = "0.8" httpdate = "1" +ipnet = "2.11.0" serde.workspace = true thiserror = "2" serde_json.workspace = true @@ -21,6 +22,8 @@ percent-encoding = "2.3.2" rand_core.workspace = true reqwest = { version = "0.13.1", default-features = false, features = ["rustls"] } rusqlite = { version = "0.40.1", features = ["bundled"] } +tokio = { version = "1", features = ["net"] } +url = "2" [dev-dependencies] serde_json.workspace = true diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 418cd35..bcf6130 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -72,7 +72,11 @@ impl AppState { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); - let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; + let activity_sender = ActivitySender::new( + actor_key_pair.clone(), + key_id.to_string(), + config.outbound_address_policy, + )?; Ok(Self { core: Arc::new(Mutex::new(core)), diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs index 918e18d..8ee8725 100644 --- a/crates/feder-runtime-server/src/config.rs +++ b/crates/feder-runtime-server/src/config.rs @@ -23,6 +23,17 @@ pub enum InboxAuthPolicy { AllowUnsignedInsecureDev, } +/// Controls which network addresses may receive outgoing activities. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutboundAddressPolicy { + /// Allows only publicly routable destination addresses. + #[default] + PublicOnly, + + /// Allows private and special-use destinations. This disables SSRF protection. + AllowPrivateAddress, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum StorageConfig { InMemory, @@ -37,5 +48,6 @@ pub struct RuntimeConfig { pub username: String, pub handle_host: String, pub inbox_auth_policy: InboxAuthPolicy, + pub outbound_address_policy: OutboundAddressPolicy, pub storage: StorageConfig, } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index c7a9972..28676e0 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -122,7 +122,9 @@ pub async fn inbox( .send_actions(&result.actions) .await .map_err(|error| match error { - SendError::Request(_) | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, + SendError::PrivateInboxAddress { .. } + | SendError::Request(_) + | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, SendError::BuildClient(_) | SendError::InvalidInbox(_) | SendError::Serialize(_) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 7fc5da5..488c5ec 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -18,10 +18,11 @@ pub mod app; pub mod config; pub mod error; pub mod inbox; +mod outbound_network; pub mod send; pub mod storage; pub mod webfinger; pub use app::{AppState, build_router}; -pub use config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; +pub use config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}; pub use error::Error; diff --git a/crates/feder-runtime-server/src/outbound_network.rs b/crates/feder-runtime-server/src/outbound_network.rs new file mode 100644 index 0000000..15967b9 --- /dev/null +++ b/crates/feder-runtime-server/src/outbound_network.rs @@ -0,0 +1,147 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + io, + net::{IpAddr, SocketAddr}, + sync::LazyLock, + time::Duration, +}; + +use ipnet::IpNet; +use reqwest::{ + Client, Url, + dns::{Addrs, Name, Resolve, Resolving}, + redirect::Policy, +}; + +use crate::config::OutboundAddressPolicy; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +const NON_PUBLIC_NETWORK_CIDRS: &[&str] = &[ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "100:0:0:1::/64", + "2001::/23", + "2001:db8::/32", + "2002::/16", + "3fff::/20", + "5f00::/16", + "fc00::/7", + "fe80::/10", + "ff00::/8", +]; + +static NON_PUBLIC_NETWORKS: LazyLock> = LazyLock::new(|| { + NON_PUBLIC_NETWORK_CIDRS + .iter() + .map(|cidr| cidr.parse().expect("hardcoded network CIDR is valid")) + .collect() +}); + +pub(crate) fn build_client(policy: OutboundAddressPolicy) -> Result { + Client::builder() + .dns_resolver(PublicDnsResolver { policy }) + .redirect(Policy::none()) + .no_proxy() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() +} + +pub(crate) fn validate_literal_host( + url: &Url, + policy: OutboundAddressPolicy, +) -> Result<(), IpAddr> { + if policy == OutboundAddressPolicy::AllowPrivateAddress { + return Ok(()); + } + + match url.host() { + Some(url::Host::Ipv4(address)) => validate_public_address(address.into()), + Some(url::Host::Ipv6(address)) => validate_public_address(address.into()), + Some(url::Host::Domain(_)) | None => Ok(()), + } +} + +#[derive(Clone, Copy, Debug)] +struct PublicDnsResolver { + policy: OutboundAddressPolicy, +} + +impl Resolve for PublicDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_string(); + let policy = self.policy; + + Box::pin(async move { + let addresses = tokio::net::lookup_host((host.as_str(), 0)) + .await? + .collect::>(); + if addresses.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("{host} resolved to no addresses"), + ) + .into()); + } + if policy == OutboundAddressPolicy::PublicOnly { + for address in &addresses { + validate_public_address(address.ip()).map_err(|blocked| { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{host} resolved to non-public address {blocked}"), + ) + })?; + } + } + + Ok(Box::new(addresses.into_iter()) as Addrs) + }) + } +} + +fn validate_public_address(address: IpAddr) -> Result<(), IpAddr> { + if let IpAddr::V6(address) = address + && let Some(mapped) = address.to_ipv4_mapped() + { + return validate_public_address(mapped.into()); + } + let is_public = !NON_PUBLIC_NETWORKS + .iter() + .any(|network| network.contains(&address)); + + if is_public { Ok(()) } else { Err(address) } +} diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 8a26d4f..07ed21e 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -15,6 +15,7 @@ use std::{sync::Arc, time::SystemTime}; +use crate::{config::OutboundAddressPolicy, outbound_network}; use feder_core::{ Action, Activity, SendActivity, http_signatures::{ @@ -24,7 +25,6 @@ use feder_core::{ use reqwest::{ Client, StatusCode, Url, header::{CONTENT_TYPE, DATE, HOST}, - redirect::Policy, }; /// Sends core `SendActivity` actions as signed ActivityPub HTTP requests. @@ -33,20 +33,24 @@ pub struct ActivitySender { client: Client, key_pair: Arc, key_id: String, + address_policy: OutboundAddressPolicy, } impl ActivitySender { /// Creates an activity sender for one actor identity. - pub fn new(key_pair: Arc, key_id: String) -> Result { - let client = Client::builder() - .redirect(Policy::none()) - .build() - .map_err(SendError::BuildClient)?; + pub fn new( + key_pair: Arc, + key_id: String, + address_policy: OutboundAddressPolicy, + ) -> Result { + let client = + outbound_network::build_client(address_policy).map_err(SendError::BuildClient)?; Ok(Self { client, key_pair, key_id, + address_policy, }) } @@ -78,6 +82,12 @@ impl ActivitySender { if !matches!(url.scheme(), "http" | "https") { return Err(SendError::InvalidInbox(send.inbox.to_string())); } + outbound_network::validate_literal_host(&url, self.address_policy).map_err(|address| { + SendError::PrivateInboxAddress { + inbox: send.inbox.to_string(), + address, + } + })?; let mut host = url .host() .ok_or_else(|| SendError::InvalidInbox(send.inbox.to_string()))? @@ -142,6 +152,12 @@ pub enum SendError { #[error("invalid recipient inbox: {0}")] InvalidInbox(String), + #[error("recipient inbox {inbox} resolves to non-public address {address}")] + PrivateInboxAddress { + inbox: String, + address: std::net::IpAddr, + }, + #[error("failed to sign activity request")] Sign(#[source] HttpSignatureError), diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index bae073a..fdf0750 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -18,9 +18,10 @@ use feder_core::{ Action, Activity, SendActivity, http_signatures::{ActorKeyPair, sign_draft_cavage}, }; +use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; use feder_vocab::{Create, Note, Reference}; -use crate::common::{spawn_inbox_server, test_activity_sender}; +use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; fn create_note_send_action(inbox: &str) -> Action { let actor_id = "https://local.example/users/alice" @@ -127,3 +128,49 @@ async fn attempts_later_sends_after_failure() { failed_server.abort(); successful_server.abort(); } + +#[tokio::test] +async fn blocks_literal_private_inbox_address() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let actions = [create_note_send_action(&inbox)]; + + let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) + .send_actions(&actions) + .await; + + assert!(matches!( + result, + Err(SendError::PrivateInboxAddress { address, .. }) if address.is_loopback() + )); + assert!(requests.try_recv().is_err()); + inbox_server.abort(); +} + +#[tokio::test] +async fn blocks_hostname_resolving_to_private_address() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let inbox = inbox.replacen("127.0.0.1", "localhost", 1); + let actions = [create_note_send_action(&inbox)]; + + let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) + .send_actions(&actions) + .await; + + assert!(matches!(result, Err(SendError::Request(_)))); + assert!(requests.try_recv().is_err()); + inbox_server.abort(); +} + +#[tokio::test] +async fn blocks_special_use_ipv6_inbox_addresses() { + let sender = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly); + + for address in ["100:0:0:1::1", "2001:2::1", "5f00::1"] { + let inbox = format!("http://[{address}]/inbox"); + let actions = [create_note_send_action(&inbox)]; + + let result = sender.send_actions(&actions).await; + + assert!(matches!(result, Err(SendError::PrivateInboxAddress { .. }))); + } +} diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 74edaad..ee47807 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -25,7 +25,7 @@ use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; use feder_runtime_server::{ Error, app::{AppState, router_with_state}, - config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, + config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}, send::ActivitySender, storage::{RuntimeStore, SqliteStore}, }; @@ -84,6 +84,7 @@ pub fn test_config() -> RuntimeConfig { username: "alice".to_string(), handle_host: "127.0.0.1:3000".to_string(), inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, + outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, storage: StorageConfig::InMemory, } } @@ -116,7 +117,11 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); - let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; + let activity_sender = ActivitySender::new( + actor_key_pair.clone(), + key_id.to_string(), + config.outbound_address_policy, + )?; Ok(AppState { core: Arc::new(Mutex::new(core)), @@ -153,9 +158,14 @@ fn fixture_actor_key_pair() -> Result ActivitySender { + test_activity_sender_with_policy(OutboundAddressPolicy::AllowPrivateAddress) +} + +pub fn test_activity_sender_with_policy(policy: OutboundAddressPolicy) -> ActivitySender { ActivitySender::new( Arc::new(fixture_actor_key_pair().expect("load actor key pair fixture")), "https://local.example/users/alice#main-key".to_string(), + policy, ) .expect("build activity sender") } diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs index 89ed860..d4d1f37 100644 --- a/examples/single-user-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -13,7 +13,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_runtime_server::{Error, InboxAuthPolicy, RuntimeConfig, StorageConfig, build_router}; +use feder_runtime_server::{ + Error, InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig, build_router, +}; fn default_local() -> RuntimeConfig { RuntimeConfig { @@ -32,6 +34,7 @@ fn default_local() -> RuntimeConfig { username: "alice".to_string(), handle_host: "127.0.0.1:3000".to_string(), inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, + outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, storage: StorageConfig::InMemory, } } From e0a72d9730d77fb7f5508d7857cb62c0a4cb2941 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 16:20:25 +0900 Subject: [PATCH 11/42] Rename outbound_network.rs to url.rs --- crates/feder-runtime-server/src/lib.rs | 2 +- crates/feder-runtime-server/src/send.rs | 7 +++---- .../src/{outbound_network.rs => url.rs} | 7 ++++--- 3 files changed, 8 insertions(+), 8 deletions(-) rename crates/feder-runtime-server/src/{outbound_network.rs => url.rs} (94%) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 488c5ec..30afbd6 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -18,9 +18,9 @@ pub mod app; pub mod config; pub mod error; pub mod inbox; -mod outbound_network; pub mod send; pub mod storage; +mod url; pub mod webfinger; pub use app::{AppState, build_router}; diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 07ed21e..a8fc26f 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -15,7 +15,7 @@ use std::{sync::Arc, time::SystemTime}; -use crate::{config::OutboundAddressPolicy, outbound_network}; +use crate::{config::OutboundAddressPolicy, url}; use feder_core::{ Action, Activity, SendActivity, http_signatures::{ @@ -43,8 +43,7 @@ impl ActivitySender { key_id: String, address_policy: OutboundAddressPolicy, ) -> Result { - let client = - outbound_network::build_client(address_policy).map_err(SendError::BuildClient)?; + let client = url::build_client(address_policy).map_err(SendError::BuildClient)?; Ok(Self { client, @@ -82,7 +81,7 @@ impl ActivitySender { if !matches!(url.scheme(), "http" | "https") { return Err(SendError::InvalidInbox(send.inbox.to_string())); } - outbound_network::validate_literal_host(&url, self.address_policy).map_err(|address| { + crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { SendError::PrivateInboxAddress { inbox: send.inbox.to_string(), address, diff --git a/crates/feder-runtime-server/src/outbound_network.rs b/crates/feder-runtime-server/src/url.rs similarity index 94% rename from crates/feder-runtime-server/src/outbound_network.rs rename to crates/feder-runtime-server/src/url.rs index 15967b9..f16c90a 100644 --- a/crates/feder-runtime-server/src/outbound_network.rs +++ b/crates/feder-runtime-server/src/url.rs @@ -26,6 +26,7 @@ use reqwest::{ dns::{Addrs, Name, Resolve, Resolving}, redirect::Policy, }; +use url::Host; use crate::config::OutboundAddressPolicy; @@ -90,9 +91,9 @@ pub(crate) fn validate_literal_host( } match url.host() { - Some(url::Host::Ipv4(address)) => validate_public_address(address.into()), - Some(url::Host::Ipv6(address)) => validate_public_address(address.into()), - Some(url::Host::Domain(_)) | None => Ok(()), + Some(Host::Ipv4(address)) => validate_public_address(address.into()), + Some(Host::Ipv6(address)) => validate_public_address(address.into()), + Some(Host::Domain(_)) | None => Ok(()), } } From 1b7cbc05d97e0cd83f8e388a04918b66c35f7eb9 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 17:32:52 +0900 Subject: [PATCH 12/42] Fetch ID-only Follow actors before core handling, persist their discovered inbox, and deliver the signed Accept activity. Reuse outbound network protections and enforce response type, size, and actor ID validation. Add an end-to-end test covering Mastodon-style actor resolution and Accept delivery. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 184 +++++++++++++++++- crates/feder-runtime-server/src/app.rs | 4 + crates/feder-runtime-server/src/error.rs | 3 + crates/feder-runtime-server/src/inbox.rs | 14 +- crates/feder-runtime-server/src/lib.rs | 1 + .../feder-runtime-server/tests/cases/inbox.rs | 115 ++++++++++- .../feder-runtime-server/tests/common/mod.rs | 3 + 7 files changed, 318 insertions(+), 6 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index 8ddfff0..edae040 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -19,8 +19,17 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; +use reqwest::{ + Client, StatusCode as HttpStatusCode, Url, + header::{ACCEPT, CONTENT_TYPE}, +}; +use serde::Deserialize; + +use crate::{app::AppState, config::OutboundAddressPolicy, url}; -use crate::app::AppState; +const MAX_ACTOR_BODY_SIZE: usize = 1_048_576; +const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; pub async fn actor( State(app_state): State, @@ -37,3 +46,176 @@ pub async fn actor( ) .into_response()) } + +/// Resolves remote ActivityPub actors for runtime protocol handling. +#[derive(Clone, Debug)] +pub struct ActorResolver { + client: Client, + address_policy: OutboundAddressPolicy, +} + +impl ActorResolver { + pub fn new(address_policy: OutboundAddressPolicy) -> Result { + let client = url::build_client(address_policy).map_err(ActorResolveError::BuildClient)?; + Ok(Self { + client, + address_policy, + }) + } + + pub async fn resolve_reference( + &self, + reference: &mut Reference, + ) -> Result<(), ActorResolveError> { + let Reference::Id(actor_id) = reference else { + return Ok(()); + }; + let actor_id = actor_id.clone(); + let actor = self.resolve(&actor_id).await?; + *reference = Reference::object(actor); + Ok(()) + } + + pub async fn resolve(&self, actor_id: &Iri) -> Result { + let url = Url::parse(actor_id.as_str()) + .map_err(|_| ActorResolveError::InvalidActorId(actor_id.to_string()))?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.host().is_none() + { + return Err(ActorResolveError::InvalidActorId(actor_id.to_string())); + } + url::validate_literal_host(&url, self.address_policy).map_err(|address| { + ActorResolveError::PrivateActorAddress { + actor: actor_id.to_string(), + address, + } + })?; + + let mut response = self + .client + .get(url) + .header(ACCEPT, ACTIVITYPUB_ACCEPT) + .send() + .await + .map_err(ActorResolveError::Request)?; + if !response.status().is_success() { + return Err(ActorResolveError::UnsuccessfulStatus { + actor: actor_id.to_string(), + status: response.status(), + }); + } + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !is_activitypub_content_type(content_type) { + return Err(ActorResolveError::UnsupportedContentType( + content_type.to_string(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_ACTOR_BODY_SIZE as u64) + { + return Err(ActorResolveError::ResponseTooLarge); + } + + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(ActorResolveError::Request)? { + if body.len().saturating_add(chunk.len()) > MAX_ACTOR_BODY_SIZE { + return Err(ActorResolveError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + let document: ActorDocument = + serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; + let actor = document.into_actor(); + if actor.id != *actor_id { + return Err(ActorResolveError::ActorIdMismatch { + requested: actor_id.to_string(), + returned: actor.id.to_string(), + }); + } + + Ok(actor) + } +} + +fn is_activitypub_content_type(content_type: &str) -> bool { + let media_type = content_type + .split_once(';') + .map_or(content_type, |(media_type, _)| media_type) + .trim(); + media_type.eq_ignore_ascii_case("application/activity+json") + || media_type.eq_ignore_ascii_case("application/ld+json") +} + +#[derive(Deserialize)] +struct ActorDocument { + #[serde(rename = "type")] + kind: ActorType, + id: Iri, + inbox: Iri, + outbox: Iri, + #[serde(rename = "preferredUsername")] + preferred_username: Option, + name: Option, + endpoints: Option, + #[serde(rename = "publicKey")] + public_key: Option>, +} + +impl ActorDocument { + fn into_actor(self) -> Actor { + Actor { + context: None, + kind: self.kind, + id: self.id, + inbox: self.inbox, + outbox: self.outbox, + preferred_username: self.preferred_username, + name: self.name, + endpoints: self.endpoints, + public_key: self.public_key, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ActorResolveError { + #[error("failed to build actor resolution HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("invalid remote actor ID: {0}")] + InvalidActorId(String), + + #[error("remote actor {actor} uses non-public address {address}")] + PrivateActorAddress { + actor: String, + address: std::net::IpAddr, + }, + + #[error("failed to fetch remote actor")] + Request(#[source] reqwest::Error), + + #[error("fetching remote actor {actor} returned {status}")] + UnsuccessfulStatus { + actor: String, + status: HttpStatusCode, + }, + + #[error("remote actor response has unsupported content type: {0}")] + UnsupportedContentType(String), + + #[error("remote actor response exceeds size limit")] + ResponseTooLarge, + + #[error("failed to deserialize remote actor")] + Deserialize(#[source] serde_json::Error), + + #[error("remote actor ID mismatch: requested {requested}, returned {returned}")] + ActorIdMismatch { requested: String, returned: String }, +} diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index bcf6130..ad11d97 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex}; use crate::Error; +use crate::actor::ActorResolver; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; @@ -36,6 +37,7 @@ pub struct AppState { pub core: Arc>, pub store: Arc>, pub actor_key_pair: Arc, + pub actor_resolver: ActorResolver, pub activity_sender: ActivitySender, pub local_actor: Actor, pub username: String, @@ -72,6 +74,7 @@ impl AppState { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); + let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; let activity_sender = ActivitySender::new( actor_key_pair.clone(), key_id.to_string(), @@ -82,6 +85,7 @@ impl AppState { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair, + actor_resolver, activity_sender, local_actor: actor, username: config.username, diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index c3aa76c..d405636 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -29,4 +29,7 @@ pub enum Error { #[error("activity sender setup failed")] ActivitySender(#[from] crate::send::SendError), + + #[error("actor resolver setup failed")] + ActorResolver(#[from] crate::actor::ActorResolveError), } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 28676e0..3aeee28 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -98,7 +98,19 @@ pub async fn inbox( if activity_type != Some("Follow") { return Ok(StatusCode::ACCEPTED.into_response()); } - let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let follows_local_actor = match &follow.object { + feder_vocab::Reference::Id(actor_id) => actor_id == &app_state.local_actor.id, + feder_vocab::Reference::Object(actor) => actor.id == app_state.local_actor.id, + }; + if !follows_local_actor { + return Ok(StatusCode::ACCEPTED.into_response()); + } + app_state + .actor_resolver + .resolve_reference(&mut follow.actor) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; let input = Input::received_follow(follow, accept_id); diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 30afbd6..975825b 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -23,6 +23,7 @@ pub mod storage; mod url; pub mod webfinger; +pub use actor::{ActorResolveError, ActorResolver}; pub use app::{AppState, build_router}; pub use config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}; pub use error::Error; diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index a46d3c5..1db1763 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -14,9 +14,10 @@ // along with this program. If not, see . use axum::{ - Router, - body::Body, - http::{Request, StatusCode, header::CONTENT_TYPE}, + Json, Router, + body::{Body, Bytes}, + http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, + routing::{get, post}, }; use feder_runtime_server::{ app::router_with_state, @@ -27,7 +28,8 @@ use serde_json::json; use tower::ServiceExt; use crate::common::{ - spawn_inbox_server, temporary_database_path, test_app_state, test_config, test_router, + RecordedRequest, spawn_inbox_server, temporary_database_path, test_app_state, test_config, + test_router, }; fn follow_body() -> Vec { @@ -51,6 +53,71 @@ fn follow_body_for_inbox(inbox: &str) -> Vec { .expect("serialize follow") } +fn id_only_follow_body(actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": actor_id, + "object": "http://127.0.0.1:3000/users/alice" + })) + .expect("serialize ID-only follow") +} + +async fn spawn_actor_server() -> ( + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind actor server"); + let address = listener.local_addr().expect("actor server address"); + let actor_id = format!("http://{address}/users/bob"); + let inbox = format!("http://{address}/inbox"); + let actor = json!({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { "toot": "http://joinmastodon.org/ns#" } + ], + "type": "Person", + "id": actor_id, + "inbox": inbox, + "outbox": format!("http://{address}/users/bob/outbox"), + "preferredUsername": "bob", + "endpoints": { "sharedInbox": inbox } + }); + let (sender, receiver) = tokio::sync::mpsc::channel(1); + let app = Router::new() + .route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } + }), + ) + .route( + "/inbox", + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + StatusCode::ACCEPTED + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve actor endpoint"); + }); + + (actor_id, receiver, task) +} + async fn post_inbox( app: Router, uri: &str, @@ -114,6 +181,46 @@ async fn valid_follow_reaches_core() { inbox_server.abort(); } +#[tokio::test] +async fn resolves_id_only_follower_and_sends_accept() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + id_only_follow_body(&actor_id), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let followers = state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers"); + assert_eq!(followers.len(), 1); + assert_eq!(followers[0].follower.as_str(), actor_id); + assert_eq!( + followers[0] + .inbox + .as_ref() + .expect("resolved inbox") + .as_str(), + format!("{}/inbox", actor_id.trim_end_matches("/users/bob")) + ); + + let request = requests.recv().await.expect("receive Accept request"); + assert!(request.headers.contains_key("signature")); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Accept"); + assert_eq!(activity["object"]["actor"]["id"], actor_id); + actor_server.abort(); +} + #[tokio::test] async fn send_failure_returns_bad_gateway_after_core_handling() { let (inbox, mut requests, inbox_server) = diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index ee47807..84664ed 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -24,6 +24,7 @@ use axum::{ use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; use feder_runtime_server::{ Error, + actor::ActorResolver, app::{AppState, router_with_state}, config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}, send::ActivitySender, @@ -117,6 +118,7 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); + let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; let activity_sender = ActivitySender::new( actor_key_pair.clone(), key_id.to_string(), @@ -127,6 +129,7 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair, + actor_resolver, activity_sender, local_actor: actor, username: config.username, From d4654b433249f6ba7761c700815bbeea23c874b2 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 18:05:11 +0900 Subject: [PATCH 13/42] Verify incoming HTTP signatures Validate Cavage RSA-SHA256 signatures, signed headers, request dates, body digests, actor ownership, and public-key identity before processing inbox activities. Add tests for valid, tampered, unsigned, and mismatched-actor requests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/http_signatures.rs | 88 +++++- crates/feder-runtime-server/src/inbox.rs | 250 +++++++++++++++++- .../feder-runtime-server/tests/cases/inbox.rs | 146 +++++++++- .../feder-runtime-server/tests/common/mod.rs | 2 +- 4 files changed, 469 insertions(+), 17 deletions(-) diff --git a/crates/feder-core/src/http_signatures.rs b/crates/feder-core/src/http_signatures.rs index 6c9493d..d06d56f 100644 --- a/crates/feder-core/src/http_signatures.rs +++ b/crates/feder-core/src/http_signatures.rs @@ -6,11 +6,11 @@ use core::fmt; use base64::{Engine as _, engine::general_purpose::STANDARD}; use rsa::{ RsaPrivateKey, RsaPublicKey, - pkcs1v15::SigningKey, + pkcs1v15::{Signature, SigningKey, VerifyingKey}, pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, rand_core::CryptoRngCore, sha2::{Digest, Sha256}, - signature::{SignatureEncoding, Signer}, + signature::{SignatureEncoding, Signer, Verifier}, }; use zeroize::Zeroizing; @@ -144,6 +144,32 @@ pub fn sign_draft_cavage( )) } +/// Verifies a draft-Cavage RSA-SHA256 signature over a prepared request. +/// +/// `headers` must contain the signed HTTP headers in their declared order, +/// excluding the `(request-target)` pseudo-header. +pub fn verify_draft_cavage( + public_key_pem: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], + signature: &str, +) -> Result<(), HttpSignatureVerificationError> { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let public_key = RsaPublicKey::from_public_key_pem(public_key_pem) + .map_err(HttpSignatureVerificationError::InvalidPublicKey)?; + let signature = STANDARD + .decode(signature) + .map_err(HttpSignatureVerificationError::InvalidSignatureEncoding)?; + let signature = Signature::try_from(signature.as_slice()) + .map_err(HttpSignatureVerificationError::InvalidSignature)?; + let verifying_key = VerifyingKey::::new(public_key); + + verifying_key + .verify(signature_base.as_bytes(), &signature) + .map_err(HttpSignatureVerificationError::Verification) +} + fn draft_cavage_signature_base( method: &str, request_target: &str, @@ -178,6 +204,30 @@ impl fmt::Display for HttpSignatureError { impl core::error::Error for HttpSignatureError {} +/// Errors produced while verifying an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureVerificationError { + InvalidPublicKey(rsa::pkcs8::spki::Error), + InvalidSignatureEncoding(base64::DecodeError), + InvalidSignature(rsa::signature::Error), + Verification(rsa::signature::Error), +} + +impl fmt::Display for HttpSignatureVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::InvalidSignatureEncoding(_) => { + formatter.write_str("invalid base64 signature encoding") + } + Self::InvalidSignature(_) => formatter.write_str("invalid RSA signature"), + Self::Verification(_) => formatter.write_str("HTTP signature verification failed"), + } + } +} + +impl core::error::Error for HttpSignatureVerificationError {} + #[cfg(test)] mod tests { use alloc::string::ToString; @@ -277,6 +327,40 @@ mod tests { .expect("verify signature"); } + #[test] + fn draft_cavage_signature_verifies_and_rejects_changed_headers() { + let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("load actor key pair fixture"); + let headers = [ + ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), + ( + "digest", + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", + ), + ("host", "example.com"), + ]; + let signature_header = + sign_draft_cavage(&pair, "https://example.com/key", "POST", "/inbox", &headers) + .expect("sign request"); + let signature = signature_header + .rsplit_once("signature=\"") + .and_then(|(_, signature)| signature.strip_suffix('"')) + .expect("signature parameter"); + + verify_draft_cavage(pair.public_key_pem(), "POST", "/inbox", &headers, signature) + .expect("verify request"); + assert!( + verify_draft_cavage( + pair.public_key_pem(), + "POST", + "/other-inbox", + &headers, + signature, + ) + .is_err() + ); + } + fn signature_header_prefix(headers: &[(&str, &str)]) -> String { let signed_headers = headers .iter() diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 3aeee28..46b0d1f 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -13,6 +13,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use std::{ + collections::{BTreeMap, HashSet}, + time::{Duration, SystemTime}, +}; + use axum::{ body::Bytes, extract::{Path, State}, @@ -20,8 +25,11 @@ use axum::{ response::{IntoResponse, Response}, }; -use feder_core::Input; -use feder_vocab::Follow; +use feder_core::{ + Input, + http_signatures::{create_sha256_digest_header, verify_draft_cavage}, +}; +use feder_vocab::{Actor, Follow, Iri, Reference}; use serde_json::{Value, from_slice, from_value}; use crate::app::AppState; @@ -29,6 +37,9 @@ use crate::config::InboxAuthPolicy; use crate::send::SendError; use crate::storage::RuntimeStore; +const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); +const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); + pub struct InboxRequest { pub username: String, pub headers: HeaderMap, @@ -51,11 +62,217 @@ fn accept_id_for_follow( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } -fn verify_inbox_request(app_state: &AppState, _req: &InboxRequest) -> Result<(), StatusCode> { +async fn verify_inbox_request( + app_state: &AppState, + req: &InboxRequest, +) -> Result, StatusCode> { match app_state.inbox_auth_policy { - InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(()), - InboxAuthPolicy::RequireSigned => Err(StatusCode::UNAUTHORIZED), + InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(None), + InboxAuthPolicy::RequireSigned => verify_signed_request(app_state, req).await.map(Some), + } +} + +async fn verify_signed_request( + app_state: &AppState, + req: &InboxRequest, +) -> Result { + let signature_header = req + .headers + .get("signature") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let signature = parse_signature_header(signature_header).ok_or(StatusCode::UNAUTHORIZED)?; + if signature.algorithm != "rsa-sha256" + || signature.signed_headers.first().map(String::as_str) != Some("(request-target)") + { + return Err(StatusCode::UNAUTHORIZED); + } + + let mut seen_headers = HashSet::new(); + let mut signed_headers = Vec::new(); + for name in signature.signed_headers.iter().skip(1) { + if name.starts_with('(') || !seen_headers.insert(name.as_str()) { + return Err(StatusCode::UNAUTHORIZED); + } + let values = req.headers.get_all(name).iter().collect::>(); + let [value] = values.as_slice() else { + return Err(StatusCode::UNAUTHORIZED); + }; + let value = value.to_str().map_err(|_| StatusCode::UNAUTHORIZED)?; + signed_headers.push((name.as_str(), value)); + } + if !["host", "date", "digest"] + .iter() + .all(|required| seen_headers.contains(required)) + { + return Err(StatusCode::UNAUTHORIZED); + } + + verify_request_date(&req.headers)?; + verify_request_digest(&req.headers, &req.body)?; + + let key_id: Iri = signature + .key_id + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let mut actor_url = + reqwest::Url::parse(key_id.as_str()).map_err(|_| StatusCode::UNAUTHORIZED)?; + actor_url.set_fragment(None); + let actor_id: Iri = actor_url + .as_str() + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let actor = app_state + .actor_resolver + .resolve(&actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + let Reference::Object(public_key) = + actor.public_key.as_ref().ok_or(StatusCode::UNAUTHORIZED)? + else { + return Err(StatusCode::UNAUTHORIZED); + }; + if public_key.id != key_id || public_key.owner != actor.id { + return Err(StatusCode::UNAUTHORIZED); + } + + let request_target = req + .uri + .path_and_query() + .map_or(req.uri.path(), |value| value.as_str()); + verify_draft_cavage( + &public_key.public_key_pem, + req.method.as_str(), + request_target, + &signed_headers, + &signature.signature, + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + Ok(actor) +} + +fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { + let date = headers + .get("date") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED) + .and_then(|value| httpdate::parse_http_date(value).map_err(|_| StatusCode::UNAUTHORIZED))?; + let now = SystemTime::now(); + if now + .duration_since(date) + .is_ok_and(|age| age > MAX_SIGNATURE_AGE) + || date + .duration_since(now) + .is_ok_and(|skew| skew > MAX_CLOCK_SKEW) + { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(()) +} + +fn verify_request_digest(headers: &HeaderMap, body: &[u8]) -> Result<(), StatusCode> { + let digest = headers + .get("digest") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let expected = create_sha256_digest_header(body); + let matches = digest.split(',').any(|entry| { + entry + .trim() + .split_once('=') + .is_some_and(|(algorithm, value)| { + algorithm.eq_ignore_ascii_case("sha-256") + && expected + .split_once('=') + .is_some_and(|(_, expected)| value == expected) + }) + }); + + if matches { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +struct ParsedSignature { + key_id: String, + algorithm: String, + signed_headers: Vec, + signature: String, +} + +fn parse_signature_header(header: &str) -> Option { + let mut parameters = BTreeMap::new(); + let mut remaining = header; + while !remaining.trim_start().is_empty() { + remaining = remaining.trim_start(); + let equals = remaining.find('=')?; + let name = remaining[..equals].trim(); + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return None; + } + remaining = &remaining[equals + 1..]; + let (value, rest) = parse_quoted_parameter(remaining.trim_start())?; + if parameters + .insert(name.to_ascii_lowercase(), value) + .is_some() + { + return None; + } + remaining = rest.trim_start(); + if remaining.is_empty() { + break; + } + remaining = remaining.strip_prefix(',')?; + } + + let key_id = parameters.remove("keyid")?; + let algorithm = parameters.remove("algorithm")?; + let signed_headers = parameters + .remove("headers")? + .split_ascii_whitespace() + .map(str::to_ascii_lowercase) + .collect::>(); + let signature = parameters.remove("signature")?; + if key_id.is_empty() || signed_headers.is_empty() || signature.is_empty() { + return None; } + + Some(ParsedSignature { + key_id, + algorithm: algorithm.to_ascii_lowercase(), + signed_headers, + signature, + }) +} + +fn parse_quoted_parameter(input: &str) -> Option<(String, &str)> { + let input = input.strip_prefix('"')?; + let mut value = String::new(); + let mut escaped = false; + for (index, character) in input.char_indices() { + if escaped { + value.push(character); + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + return Some((value, &input[index + character.len_utf8()..])); + } else if character.is_control() { + return None; + } else { + value.push(character); + } + } + + None } pub async fn inbox( @@ -88,7 +305,7 @@ pub async fn inbox( body, }; - verify_inbox_request(&app_state, &req)?; + let verified_actor = verify_inbox_request(&app_state, &req).await?; let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; @@ -106,11 +323,22 @@ pub async fn inbox( if !follows_local_actor { return Ok(StatusCode::ACCEPTED.into_response()); } - app_state - .actor_resolver - .resolve_reference(&mut follow.actor) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; + if let Some(actor) = verified_actor { + let actor_matches_signature = match &follow.actor { + Reference::Id(actor_id) => actor_id == &actor.id, + Reference::Object(follow_actor) => follow_actor.id == actor.id, + }; + if !actor_matches_signature { + return Err(StatusCode::UNAUTHORIZED); + } + follow.actor = Reference::object(actor); + } else { + app_state + .actor_resolver + .resolve_reference(&mut follow.actor) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + } let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; let input = Input::received_follow(follow, accept_id); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 1db1763..a4a17cd 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -19,6 +19,7 @@ use axum::{ http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, routing::{get, post}, }; +use feder_core::http_signatures::{create_sha256_digest_header, sign_draft_cavage}; use feder_runtime_server::{ app::router_with_state, config::{InboxAuthPolicy, StorageConfig}, @@ -28,8 +29,8 @@ use serde_json::json; use tower::ServiceExt; use crate::common::{ - RecordedRequest, spawn_inbox_server, temporary_database_path, test_app_state, test_config, - test_router, + RecordedRequest, fixture_actor_key_pair, spawn_inbox_server, temporary_database_path, + test_app_state, test_config, test_router, }; fn follow_body() -> Vec { @@ -85,7 +86,15 @@ async fn spawn_actor_server() -> ( "inbox": inbox, "outbox": format!("http://{address}/users/bob/outbox"), "preferredUsername": "bob", - "endpoints": { "sharedInbox": inbox } + "endpoints": { "sharedInbox": inbox }, + "publicKey": { + "id": format!("{actor_id}#main-key"), + "type": "CryptographicKey", + "owner": actor_id, + "publicKeyPem": fixture_actor_key_pair() + .expect("load actor key fixture") + .public_key_pem() + } }); let (sender, receiver) = tokio::sync::mpsc::channel(1); let app = Router::new() @@ -136,6 +145,47 @@ async fn post_inbox( .expect("response") } +async fn post_signed_inbox( + app: Router, + uri: &str, + actor_id: &str, + signed_body: &[u8], + delivered_body: impl Into, +) -> axum::response::Response { + let date = httpdate::fmt_http_date(std::time::SystemTime::now()); + let digest = create_sha256_digest_header(signed_body); + let host = "local.example"; + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host), + ]; + let signature = sign_draft_cavage( + &fixture_actor_key_pair().expect("load actor key fixture"), + &format!("{actor_id}#main-key"), + "POST", + uri, + &headers, + ) + .expect("sign inbox request"); + + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, "application/activity+json") + .header("date", date) + .header("digest", digest) + .header("host", host) + .header("signature", signature) + .body(delivered_body.into()) + .expect("valid request"), + ) + .await + .expect("response") +} + #[tokio::test] async fn valid_follow_reaches_core() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; @@ -221,6 +271,96 @@ async fn resolves_id_only_follower_and_sends_accept() { actor_server.abort(); } +#[tokio::test] +async fn verifies_signed_id_only_follow() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body(&actor_id); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .len(), + 1 + ); + requests.recv().await.expect("receive Accept request"); + actor_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_tampered_body() { + let (actor_id, _requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let signed_body = id_only_follow_body(&actor_id); + let delivered_body = id_only_follow_body("https://attacker.example/users/mallory"); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &actor_id, + &signed_body, + delivered_body, + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + actor_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_actor_different_from_key_owner() { + let (actor_id, _requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body("https://attacker.example/users/mallory"); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + actor_server.abort(); +} + #[tokio::test] async fn send_failure_returns_bad_gateway_after_core_handling() { let (inbox, mut requests, inbox_server) = diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 84664ed..196ed27 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -153,7 +153,7 @@ pub fn temporary_database_path(prefix: &str) -> std::path::PathBuf { )) } -fn fixture_actor_key_pair() -> Result { +pub fn fixture_actor_key_pair() -> Result { ActorKeyPair::from_pem( include_str!("../fixtures/rsa-private-key.pem").to_string(), include_str!("../fixtures/rsa-public-key.pem").to_string(), From d5fe01c7731f1adb92865ede7bbf2b7b7db077fd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 02:52:41 +0900 Subject: [PATCH 14/42] Resolve signing keys independently from actors Fetch signature key IDs directly and support standalone key documents as well as keys embedded in actor documents. Verify that the activity actor owns and advertises the resolved key before processing the request. Add coverage for signed Follows using an independent key URL. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 79 ++++++++++----- crates/feder-runtime-server/src/inbox.rs | 57 +++++++---- .../feder-runtime-server/tests/cases/inbox.rs | 99 ++++++++++++++++--- 3 files changed, 179 insertions(+), 56 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index edae040..e5a215c 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -77,18 +77,55 @@ impl ActorResolver { } pub async fn resolve(&self, actor_id: &Iri) -> Result { - let url = Url::parse(actor_id.as_str()) - .map_err(|_| ActorResolveError::InvalidActorId(actor_id.to_string()))?; + let body = self.fetch_document(actor_id).await?; + let document: ActorDocument = + serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; + let actor = document.into_actor(); + if actor.id != *actor_id { + return Err(ActorResolveError::ActorIdMismatch { + requested: actor_id.to_string(), + returned: actor.id.to_string(), + }); + } + + Ok(actor) + } + + pub(crate) async fn resolve_key( + &self, + key_id: &Iri, + ) -> Result { + let body = self.fetch_document(key_id).await?; + if let Ok(key) = serde_json::from_slice::(&body) + && key.id == *key_id + { + return Ok(key); + } + if let Ok(document) = serde_json::from_slice::(&body) + && let Some(Reference::Object(key)) = document.public_key + && key.id == *key_id + { + return Ok(*key); + } + + Err(ActorResolveError::KeyNotFound(key_id.to_string())) + } + + async fn fetch_document(&self, resource_id: &Iri) -> Result, ActorResolveError> { + let url = Url::parse(resource_id.as_str()) + .map_err(|_| ActorResolveError::InvalidResourceId(resource_id.to_string()))?; if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() || url.host().is_none() { - return Err(ActorResolveError::InvalidActorId(actor_id.to_string())); + return Err(ActorResolveError::InvalidResourceId( + resource_id.to_string(), + )); } url::validate_literal_host(&url, self.address_policy).map_err(|address| { - ActorResolveError::PrivateActorAddress { - actor: actor_id.to_string(), + ActorResolveError::PrivateResourceAddress { + resource: resource_id.to_string(), address, } })?; @@ -102,7 +139,7 @@ impl ActorResolver { .map_err(ActorResolveError::Request)?; if !response.status().is_success() { return Err(ActorResolveError::UnsuccessfulStatus { - actor: actor_id.to_string(), + resource: resource_id.to_string(), status: response.status(), }); } @@ -130,17 +167,8 @@ impl ActorResolver { } body.extend_from_slice(&chunk); } - let document: ActorDocument = - serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; - let actor = document.into_actor(); - if actor.id != *actor_id { - return Err(ActorResolveError::ActorIdMismatch { - requested: actor_id.to_string(), - returned: actor.id.to_string(), - }); - } - Ok(actor) + Ok(body) } } @@ -189,21 +217,21 @@ pub enum ActorResolveError { #[error("failed to build actor resolution HTTP client")] BuildClient(#[source] reqwest::Error), - #[error("invalid remote actor ID: {0}")] - InvalidActorId(String), + #[error("invalid remote ActivityPub resource ID: {0}")] + InvalidResourceId(String), - #[error("remote actor {actor} uses non-public address {address}")] - PrivateActorAddress { - actor: String, + #[error("remote ActivityPub resource {resource} uses non-public address {address}")] + PrivateResourceAddress { + resource: String, address: std::net::IpAddr, }, - #[error("failed to fetch remote actor")] + #[error("failed to fetch remote ActivityPub resource")] Request(#[source] reqwest::Error), - #[error("fetching remote actor {actor} returned {status}")] + #[error("fetching remote ActivityPub resource {resource} returned {status}")] UnsuccessfulStatus { - actor: String, + resource: String, status: HttpStatusCode, }, @@ -218,4 +246,7 @@ pub enum ActorResolveError { #[error("remote actor ID mismatch: requested {requested}, returned {returned}")] ActorIdMismatch { requested: String, returned: String }, + + #[error("remote ActivityPub document does not contain key {0}")] + KeyNotFound(String), } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 46b0d1f..d8cbcef 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -65,16 +65,23 @@ fn accept_id_for_follow( async fn verify_inbox_request( app_state: &AppState, req: &InboxRequest, + activity_actor_id: Option<&Iri>, ) -> Result, StatusCode> { match app_state.inbox_auth_policy { InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(None), - InboxAuthPolicy::RequireSigned => verify_signed_request(app_state, req).await.map(Some), + InboxAuthPolicy::RequireSigned => { + let activity_actor_id = activity_actor_id.ok_or(StatusCode::UNAUTHORIZED)?; + verify_signed_request(app_state, req, activity_actor_id) + .await + .map(Some) + } } } async fn verify_signed_request( app_state: &AppState, req: &InboxRequest, + activity_actor_id: &Iri, ) -> Result { let signature_header = req .headers @@ -115,24 +122,12 @@ async fn verify_signed_request( .key_id .parse() .map_err(|_| StatusCode::UNAUTHORIZED)?; - let mut actor_url = - reqwest::Url::parse(key_id.as_str()).map_err(|_| StatusCode::UNAUTHORIZED)?; - actor_url.set_fragment(None); - let actor_id: Iri = actor_url - .as_str() - .parse() - .map_err(|_| StatusCode::UNAUTHORIZED)?; - let actor = app_state + let public_key = app_state .actor_resolver - .resolve(&actor_id) + .resolve_key(&key_id) .await .map_err(|_| StatusCode::BAD_GATEWAY)?; - let Reference::Object(public_key) = - actor.public_key.as_ref().ok_or(StatusCode::UNAUTHORIZED)? - else { - return Err(StatusCode::UNAUTHORIZED); - }; - if public_key.id != key_id || public_key.owner != actor.id { + if public_key.owner != *activity_actor_id { return Err(StatusCode::UNAUTHORIZED); } @@ -149,9 +144,35 @@ async fn verify_signed_request( ) .map_err(|_| StatusCode::UNAUTHORIZED)?; + let actor = app_state + .actor_resolver + .resolve(activity_actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + let actor_owns_key = match actor.public_key.as_ref() { + Some(Reference::Id(advertised_key_id)) => advertised_key_id == &public_key.id, + Some(Reference::Object(advertised_key)) => { + advertised_key.id == public_key.id + && advertised_key.owner == actor.id + && advertised_key.public_key_pem == public_key.public_key_pem + } + None => false, + }; + if !actor_owns_key { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(actor) } +fn activity_actor_id(value: &Value) -> Option { + let actor = value.get("actor")?; + let actor_id = actor + .as_str() + .or_else(|| actor.get("id").and_then(Value::as_str))?; + actor_id.parse().ok() +} + fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { let date = headers .get("date") @@ -305,9 +326,9 @@ pub async fn inbox( body, }; - let verified_actor = verify_inbox_request(&app_state, &req).await?; - let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; + let activity_actor_id = activity_actor_id(&value); + let verified_actor = verify_inbox_request(&app_state, &req, activity_actor_id.as_ref()).await?; let activity_type = value.get("type").and_then(|value| value.as_str()); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index a4a17cd..cbb7f6b 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -69,13 +69,47 @@ async fn spawn_actor_server() -> ( String, tokio::sync::mpsc::Receiver, tokio::task::JoinHandle<()>, +) { + let (actor_id, _key_id, receiver, task) = spawn_actor_server_inner(false).await; + (actor_id, receiver, task) +} + +async fn spawn_actor_server_with_separate_key() -> ( + String, + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + spawn_actor_server_inner(true).await +} + +async fn spawn_actor_server_inner( + separate_key: bool, +) -> ( + String, + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, ) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind actor server"); let address = listener.local_addr().expect("actor server address"); let actor_id = format!("http://{address}/users/bob"); + let key_id = if separate_key { + format!("http://{address}/keys/1") + } else { + format!("{actor_id}#main-key") + }; let inbox = format!("http://{address}/inbox"); + let public_key = json!({ + "id": key_id, + "type": "CryptographicKey", + "owner": actor_id, + "publicKeyPem": fixture_actor_key_pair() + .expect("load actor key fixture") + .public_key_pem() + }); let actor = json!({ "@context": [ "https://www.w3.org/ns/activitystreams", @@ -87,14 +121,7 @@ async fn spawn_actor_server() -> ( "outbox": format!("http://{address}/users/bob/outbox"), "preferredUsername": "bob", "endpoints": { "sharedInbox": inbox }, - "publicKey": { - "id": format!("{actor_id}#main-key"), - "type": "CryptographicKey", - "owner": actor_id, - "publicKeyPem": fixture_actor_key_pair() - .expect("load actor key fixture") - .public_key_pem() - } + "publicKey": if separate_key { json!(key_id) } else { public_key.clone() } }); let (sender, receiver) = tokio::sync::mpsc::channel(1); let app = Router::new() @@ -105,6 +132,18 @@ async fn spawn_actor_server() -> ( async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } }), ) + .route( + "/keys/1", + get(move || { + let public_key = public_key.clone(); + async move { + ( + [(CONTENT_TYPE, "application/activity+json")], + Json(public_key), + ) + } + }), + ) .route( "/inbox", post(move |headers: HeaderMap, uri: Uri, body: Bytes| { @@ -124,7 +163,7 @@ async fn spawn_actor_server() -> ( .expect("serve actor endpoint"); }); - (actor_id, receiver, task) + (actor_id, key_id, receiver, task) } async fn post_inbox( @@ -148,7 +187,7 @@ async fn post_inbox( async fn post_signed_inbox( app: Router, uri: &str, - actor_id: &str, + key_id: &str, signed_body: &[u8], delivered_body: impl Into, ) -> axum::response::Response { @@ -163,7 +202,7 @@ async fn post_signed_inbox( ]; let signature = sign_draft_cavage( &fixture_actor_key_pair().expect("load actor key fixture"), - &format!("{actor_id}#main-key"), + key_id, "POST", uri, &headers, @@ -281,7 +320,39 @@ async fn verifies_signed_id_only_follow() { let response = post_signed_inbox( router_with_state(state.clone()), "/users/alice/inbox", - &actor_id, + &format!("{actor_id}#main-key"), + &body, + body.clone(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .len(), + 1 + ); + requests.recv().await.expect("receive Accept request"); + actor_server.abort(); +} + +#[tokio::test] +async fn verifies_signed_follow_with_independent_key_id() { + let (actor_id, key_id, mut requests, actor_server) = + spawn_actor_server_with_separate_key().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body(&actor_id); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, &body, body.clone(), ) @@ -313,7 +384,7 @@ async fn signed_follow_rejects_tampered_body() { let response = post_signed_inbox( router_with_state(state.clone()), "/users/alice/inbox", - &actor_id, + &format!("{actor_id}#main-key"), &signed_body, delivered_body, ) @@ -342,7 +413,7 @@ async fn signed_follow_rejects_actor_different_from_key_owner() { let response = post_signed_inbox( router_with_state(state.clone()), "/users/alice/inbox", - &actor_id, + &format!("{actor_id}#main-key"), &body, body.clone(), ) From 4192b14977653d97bf88709f2777d4e7c055505e Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 03:32:00 +0900 Subject: [PATCH 15/42] Accept public keys without an explicit type Default omitted public key types to CryptographicKey for compatibility with Mastodon actor documents. Cover the representation in vocabulary and signed Follow integration tests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/tests/cases/inbox.rs | 1 - crates/feder-vocab/src/lib.rs | 14 +++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index cbb7f6b..bd47ad1 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -104,7 +104,6 @@ async fn spawn_actor_server_inner( let inbox = format!("http://{address}/inbox"); let public_key = json!({ "id": key_id, - "type": "CryptographicKey", "owner": actor_id, "publicKeyPem": fixture_actor_key_pair() .expect("load actor key fixture") diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 9672ecd..0413ecb 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -233,7 +233,7 @@ pub enum CryptographicKeyType { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CryptographicKey { pub id: Iri, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub kind: CryptographicKeyType, pub owner: Iri, #[serde(rename = "publicKeyPem")] @@ -442,6 +442,18 @@ mod tests { use serde::de::DeserializeOwned; use serde_json::json; + #[test] + fn cryptographic_key_defaults_missing_type() { + let key: CryptographicKey = serde_json::from_value(serde_json::json!({ + "id": "https://example.com/users/alice#main-key", + "owner": "https://example.com/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----" + })) + .expect("deserialize key without type"); + + assert_eq!(key.kind, CryptographicKeyType::CryptographicKey); + } + fn roundtrip(value: &T) -> T where T: DeserializeOwned + Serialize, From 4691fe4cf5da0201351645c423b2548217807e9c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 17:25:47 +0900 Subject: [PATCH 16/42] Handle Undo Follow activities Add Undo vocabulary and core handling for follower removal. Validate that the Undo actor owns the embedded Follow, remove matching in-memory and SQLite follower state, and cover signed unfollow and forged Undo requests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 146 ++++++++++++++++++ crates/feder-runtime-server/README.md | 12 +- crates/feder-runtime-server/src/inbox.rs | 81 ++++++---- .../src/storage/sqlite.rs | 32 +++- .../feder-runtime-server/tests/cases/inbox.rs | 110 +++++++++++++ crates/feder-vocab/src/lib.rs | 30 ++++ .../tests/activitypub_serialization.rs | 24 ++- 7 files changed, 397 insertions(+), 38 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index e09b1e3..5957c09 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -55,6 +55,10 @@ impl FederCore { let actions = self.state.record_follow(input); HandleResult::new(actions) } + Input::ReceivedUndoFollow(input) => { + let actions = self.state.record_undo_follow(input); + HandleResult::new(actions) + } Input::UserCreateNote(input) => { let actions = self.state.record_created_note(input); HandleResult::new(actions) @@ -206,6 +210,45 @@ impl FederState { actions } + fn record_undo_follow(&mut self, input: ReceivedUndoFollow) -> Vec { + let undo = input.undo; + let Some(undo_actor) = reference_id(&undo.actor) else { + return Vec::new(); + }; + let vocab::Reference::Object(follow) = undo.object else { + return Vec::new(); + }; + let Some(follower) = reference_id(&follow.actor) else { + return Vec::new(); + }; + let Some(following) = reference_id(&follow.object) else { + return Vec::new(); + }; + + if undo_actor != follower || following != &self.local_actor.id { + return Vec::new(); + } + + let relation = Follower { + follower: follower.clone(), + following: following.clone(), + }; + self.followers.retain(|existing| existing != &relation); + if !self + .followers + .iter() + .any(|existing| existing.follower == *follower) + { + self.delivery_targets + .retain(|target| target.actor != *follower); + } + + Vec::from([Action::RemoveFollower(RemoveFollower { + follower: follower.clone(), + following: following.clone(), + })]) + } + fn record_created_note(&mut self, input: UserCreateNote) -> Vec { let Some(actor) = reference_id(&input.actor) else { return Vec::new(); @@ -270,6 +313,7 @@ impl HasId for vocab::Actor { #[non_exhaustive] pub enum Input { ReceivedFollow(ReceivedFollow), + ReceivedUndoFollow(ReceivedUndoFollow), UserCreateNote(UserCreateNote), } @@ -283,6 +327,12 @@ pub struct ReceivedFollow { pub accept_id: vocab::Iri, } +/// Runtime-provided data for handling a received Undo of a Follow. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReceivedUndoFollow { + pub undo: vocab::Undo, +} + /// Runtime-provided data for creating a local note. /// /// IDs and timestamps are inputs so the core does not depend on clocks, @@ -300,6 +350,10 @@ impl Input { pub fn received_follow(follow: vocab::Follow, accept_id: vocab::Iri) -> Self { Self::ReceivedFollow(ReceivedFollow { follow, accept_id }) } + + pub fn received_undo_follow(undo: vocab::Undo) -> Self { + Self::ReceivedUndoFollow(ReceivedUndoFollow { undo }) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -323,6 +377,7 @@ pub struct DeliveryTarget { #[non_exhaustive] pub enum Action { StoreFollower(StoreFollower), + RemoveFollower(RemoveFollower), StoreDeliveryTarget(StoreDeliveryTarget), StoreObject(StoreObject), SendActivity(SendActivity), @@ -334,6 +389,14 @@ pub struct StoreFollower { pub following: vocab::Reference, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoveFollower { + /// The remote actor ending the follower relation. + pub follower: vocab::Iri, + /// The local actor that was followed. + pub following: vocab::Iri, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoreDeliveryTarget { pub target: DeliveryTarget, @@ -409,6 +472,14 @@ mod tests { }) } + fn received_undo_follow(follow: vocab::Follow, actor_id: &str) -> Input { + Input::received_undo_follow(vocab::Undo::new( + iri("https://remote.example/activities/undo/1"), + vocab::Reference::id(iri(actor_id)), + vocab::Reference::object(follow), + )) + } + #[test] fn core_is_created_with_local_actor_state() { let core = core(); @@ -605,6 +676,81 @@ mod tests { assert!(core.state().delivery_targets().is_empty()); } + #[test] + fn received_undo_follow_removes_follower_and_delivery_target() { + 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.clone(), + "https://example.com/activities/accept/1", + )); + + let result = core.handle(received_undo_follow( + follow, + "https://remote.example/users/bob", + )); + + assert_eq!( + result.actions, + Vec::from([Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + })]) + ); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + } + + #[test] + fn received_undo_follow_rejects_actor_that_does_not_own_follow() { + 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.clone(), + "https://example.com/activities/accept/1", + )); + + let result = core.handle(received_undo_follow( + follow, + "https://remote.example/users/mallory", + )); + + assert!(result.is_empty()); + assert_eq!(core.state().followers().len(), 1); + assert_eq!(core.state().delivery_targets().len(), 1); + } + + #[test] + fn received_undo_follow_emits_idempotent_removal_action() { + 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 mut core = core(); + + let result = core.handle(received_undo_follow( + follow, + "https://remote.example/users/bob", + )); + + assert_eq!( + result.actions, + Vec::from([Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + })]) + ); + } + #[test] fn user_create_note_records_created_object_and_emits_store_action() { let input = UserCreateNote { diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 9a3d470..77c4d03 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -9,12 +9,12 @@ It provides a health check endpoint, WebFinger discovery, and a local actor route. The caller chooses concrete bind addresses, actor IRIs, usernames, and handle hosts. -ActivityPub inbox handling for supported Follow activities is included. The -runtime can use in-memory storage for tests and examples, or file-backed SQLite -storage for persisted follower state. Outgoing `SendActivity` actions are sent -synchronously to recipient inboxes as ActivityPub JSON signed with the actor's -draft-Cavage RSA key. HTTP signature verification is intentionally left to a -later issue. +ActivityPub inbox handling for Follow and embedded Undo(Follow) activities is +included. The runtime can use in-memory storage for tests and examples, or +file-backed SQLite storage for persisted follower state. Outgoing +`SendActivity` actions are sent synchronously to recipient inboxes as +ActivityPub JSON signed with the actor's draft-Cavage RSA key. Incoming inbox +requests can require verification with the same signature scheme. Example diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index d8cbcef..b041b8b 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -29,7 +29,7 @@ use feder_core::{ Input, http_signatures::{create_sha256_digest_header, verify_draft_cavage}, }; -use feder_vocab::{Actor, Follow, Iri, Reference}; +use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; use serde_json::{Value, from_slice, from_value}; use crate::app::AppState; @@ -173,6 +173,13 @@ fn activity_actor_id(value: &Value) -> Option { actor_id.parse().ok() } +fn actor_reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(actor_id) => actor_id, + Reference::Object(actor) => &actor.id, + } +} + fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { let date = headers .get("date") @@ -332,36 +339,50 @@ pub async fn inbox( let activity_type = value.get("type").and_then(|value| value.as_str()); - // Unsupported activity types will be ignored - if activity_type != Some("Follow") { - return Ok(StatusCode::ACCEPTED.into_response()); - } - let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - let follows_local_actor = match &follow.object { - feder_vocab::Reference::Id(actor_id) => actor_id == &app_state.local_actor.id, - feder_vocab::Reference::Object(actor) => actor.id == app_state.local_actor.id, - }; - if !follows_local_actor { - return Ok(StatusCode::ACCEPTED.into_response()); - } - if let Some(actor) = verified_actor { - let actor_matches_signature = match &follow.actor { - Reference::Id(actor_id) => actor_id == &actor.id, - Reference::Object(follow_actor) => follow_actor.id == actor.id, - }; - if !actor_matches_signature { - return Err(StatusCode::UNAUTHORIZED); + let input = match activity_type { + Some("Follow") => { + let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + if actor_reference_id(&follow.object) != &app_state.local_actor.id { + return Ok(StatusCode::ACCEPTED.into_response()); + } + if let Some(actor) = verified_actor { + if actor_reference_id(&follow.actor) != &actor.id { + return Err(StatusCode::UNAUTHORIZED); + } + follow.actor = Reference::object(actor); + } else { + app_state + .actor_resolver + .resolve_reference(&mut follow.actor) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + } + let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; + Input::received_follow(follow, accept_id) } - follow.actor = Reference::object(actor); - } else { - app_state - .actor_resolver - .resolve_reference(&mut follow.actor) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - } - let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; - let input = Input::received_follow(follow, accept_id); + Some("Undo") => { + let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let Reference::Object(follow) = &undo.object else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; + let undo_actor_id = actor_reference_id(&undo.actor); + if undo_actor_id != actor_reference_id(&follow.actor) { + return Err(StatusCode::UNAUTHORIZED); + } + if verified_actor + .as_ref() + .is_some_and(|actor| undo_actor_id != &actor.id) + { + return Err(StatusCode::UNAUTHORIZED); + } + if actor_reference_id(&follow.object) != &app_state.local_actor.id { + return Ok(StatusCode::ACCEPTED.into_response()); + } + Input::received_undo_follow(undo) + } + // Unsupported activity types will be ignored. + _ => return Ok(StatusCode::ACCEPTED.into_response()), + }; let result = { let mut core = app_state diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 6c8d0b6..16e42f1 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -106,6 +106,15 @@ impl RuntimeStore for SqliteStore { ], )?; } + Action::RemoveFollower(action) => { + tx.execute( + r#" + DELETE FROM followers + WHERE follower_actor_id = ?1 AND following_actor_id = ?2 + "#, + params![action.follower.as_str(), action.following.as_str()], + )?; + } Action::StoreDeliveryTarget(action) => { tx.execute( r#" @@ -263,7 +272,7 @@ fn parse_optional_iri(value: Option) -> Result, StoreError> #[cfg(test)] mod tests { - use feder_core::{Action, StoreFollower}; + use feder_core::{Action, RemoveFollower, StoreFollower}; use super::*; @@ -480,6 +489,27 @@ mod tests { assert_eq!(following, "https://example.com/users/alice"); } + #[test] + fn persist_actions_removes_follower() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + store + .persist_actions(&[store_follower_action()]) + .expect("persist follower action"); + + store + .persist_actions(&[Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + })]) + .expect("persist follower removal action"); + + let follower_count: i64 = store + .conn + .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) + .expect("query follower count"); + assert_eq!(follower_count, 0); + } + #[test] fn persist_actions_stores_embedded_follower_inbox() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index bd47ad1..0208e78 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -65,6 +65,22 @@ fn id_only_follow_body(actor_id: &str) -> Vec { .expect("serialize ID-only follow") } +fn undo_follow_body(actor_id: &str, follow_actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Undo", + "id": format!("{actor_id}/undo/1"), + "actor": actor_id, + "object": { + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": follow_actor_id, + "object": "http://127.0.0.1:3000/users/alice" + } + })) + .expect("serialize Undo Follow") +} + async fn spawn_actor_server() -> ( String, tokio::sync::mpsc::Receiver, @@ -372,6 +388,100 @@ async fn verifies_signed_follow_with_independent_key_id() { actor_server.abort(); } +#[tokio::test] +async fn signed_undo_follow_removes_persisted_follower() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let key_id = format!("{actor_id}#main-key"); + let follow_body = id_only_follow_body(&actor_id); + let follow_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &follow_body, + follow_body.clone(), + ) + .await; + assert_eq!(follow_response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept request"); + + let undo_body = undo_follow_body(&actor_id, &actor_id); + let undo_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &undo_body, + undo_body.clone(), + ) + .await; + + assert_eq!(undo_response.status(), StatusCode::ACCEPTED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + assert!( + state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers") + .is_empty() + ); + actor_server.abort(); +} + +#[tokio::test] +async fn signed_undo_follow_rejects_actor_that_does_not_own_follow() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let key_id = format!("{actor_id}#main-key"); + let follow_body = id_only_follow_body(&actor_id); + let follow_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &follow_body, + follow_body.clone(), + ) + .await; + assert_eq!(follow_response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept request"); + + let undo_body = undo_follow_body(&actor_id, "https://remote.example/users/mallory"); + let undo_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &undo_body, + undo_body.clone(), + ) + .await; + + assert_eq!(undo_response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers") + .len(), + 1 + ); + actor_server.abort(); +} + #[tokio::test] async fn signed_follow_rejects_tampered_body() { let (actor_id, _requests, actor_server) = spawn_actor_server().await; diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 0413ecb..12cb62e 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -173,6 +173,7 @@ macro_rules! activitystreams_type { activitystreams_type!(NoteType, Note); activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); +activitystreams_type!(UndoType, Undo); activitystreams_type!(CreateType, Create); /// A JSON-LD context represented by one or more IRIs. @@ -406,6 +407,35 @@ impl Accept { } } +/// A minimal Undo activity for a Follow. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Undo { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: UndoType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Undo { + #[must_use] + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: UndoType::default(), + id, + actor, + object, + } + } +} + /// A minimal Create activity for a concrete object type. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Create { diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 7d651d8..8c68179 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -15,7 +15,7 @@ use feder_vocab::{ ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, Reference, - References, SECURITY_CONTEXT, + References, SECURITY_CONTEXT, Undo, }; use serde_json::{Value, json}; @@ -124,6 +124,28 @@ fn accept_activity_can_embed_follow_activity() { ); } +#[test] +fn undo_activity_can_embed_follow_activity() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + let undo = Undo::new( + iri("https://remote.example/activities/undo/1"), + Reference::id(iri("https://remote.example/users/bob")), + Reference::object(follow), + ); + + assert_eq!( + serialize_to_value(undo), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Undo", + "id": "https://remote.example/activities/undo/1", + "actor": "https://remote.example/users/bob", + "object": incoming_follow_json() + }) + ); +} + #[test] fn local_note_serializes_as_create_activity() { let mut note = Note::new(iri("https://example.com/notes/1")); From 160e70ffad7f4b7ec5cd3d57bfe329307fce277f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 22:09:24 +0900 Subject: [PATCH 17/42] Add followers collection vocabulary Add the OrderedCollection vocabulary type and followers property for actors. Advertise the local actor's followers collection URI and preserve the property when resolving remote actors. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 2 ++ crates/feder-runtime-server/src/app.rs | 5 +++ .../feder-runtime-server/tests/cases/actor.rs | 4 +++ .../feder-runtime-server/tests/common/mod.rs | 5 +++ crates/feder-vocab/src/lib.rs | 35 +++++++++++++++++++ .../tests/activitypub_serialization.rs | 30 ++++++++++++++-- 6 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index e5a215c..d03198b 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -188,6 +188,7 @@ struct ActorDocument { id: Iri, inbox: Iri, outbox: Iri, + followers: Option, #[serde(rename = "preferredUsername")] preferred_username: Option, name: Option, @@ -204,6 +205,7 @@ impl ActorDocument { id: self.id, inbox: self.inbox, outbox: self.outbox, + followers: self.followers, preferred_username: self.preferred_username, name: self.name, endpoints: self.endpoints, diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index ad11d97..0c8b4a9 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -50,6 +50,11 @@ impl AppState { let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); + actor.followers = Some( + format!("{}/followers", actor.id.as_str().trim_end_matches('/')) + .parse() + .expect("appending a followers path preserves a valid actor IRI"), + ); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index 951c051..5c4e0b7 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -58,6 +58,10 @@ async fn returns_local_actor() { assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); + assert_eq!( + json["followers"], + "http://127.0.0.1:3000/users/alice/followers" + ); assert_eq!(json["preferredUsername"], "alice"); assert_eq!(json["name"], "alice"); assert_eq!( diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 196ed27..f837bda 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -94,6 +94,11 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); + actor.followers = Some( + format!("{}/followers", actor.id.as_str().trim_end_matches('/')) + .parse() + .expect("valid followers IRI"), + ); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 12cb62e..7591dfa 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -175,6 +175,7 @@ activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); activitystreams_type!(UndoType, Undo); activitystreams_type!(CreateType, Create); +activitystreams_type!(OrderedCollectionType, OrderedCollection); /// A JSON-LD context represented by one or more IRIs. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -263,6 +264,8 @@ pub struct Actor { pub id: Iri, pub inbox: Iri, pub outbox: Iri, + #[serde(skip_serializing_if = "Option::is_none")] + pub followers: Option, #[serde(rename = "preferredUsername", skip_serializing_if = "Option::is_none")] pub preferred_username: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -291,6 +294,7 @@ impl Actor { id, inbox, outbox, + followers: None, preferred_username: None, name: None, endpoints: None, @@ -465,6 +469,37 @@ impl Create { } } +/// A minimal ActivityStreams ordered collection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OrderedCollection { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: OrderedCollectionType, + pub id: Iri, + #[serde(rename = "totalItems")] + pub total_items: u64, + #[serde(rename = "orderedItems")] + pub ordered_items: Vec, +} + +impl OrderedCollection { + #[must_use] + pub fn new(id: Iri, total_items: u64, ordered_items: Vec) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: OrderedCollectionType::default(), + id, + total_items, + ordered_items, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 8c68179..1494636 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -14,8 +14,8 @@ // along with this program. If not, see . use feder_vocab::{ - ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, Reference, - References, SECURITY_CONTEXT, Undo, + ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, + OrderedCollection, Reference, References, SECURITY_CONTEXT, Undo, }; use serde_json::{Value, json}; @@ -146,6 +146,32 @@ fn undo_activity_can_embed_follow_activity() { ); } +#[test] +fn ordered_collection_serializes_actor_iris() { + let collection = OrderedCollection::new( + iri("https://example.com/users/alice/followers"), + 2, + vec![ + iri("https://remote.example/users/bob"), + iri("https://another.example/users/carol"), + ], + ); + + assert_eq!( + serialize_to_value(collection), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "OrderedCollection", + "id": "https://example.com/users/alice/followers", + "totalItems": 2, + "orderedItems": [ + "https://remote.example/users/bob", + "https://another.example/users/carol" + ] + }) + ); +} + #[test] fn local_note_serializes_as_create_activity() { let mut note = Note::new(iri("https://example.com/notes/1")); From 86a31010bed8c72792e4cbb4ba69a3b8778e225a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 22:26:09 +0900 Subject: [PATCH 18/42] Serve the followers collection Add a storage-backed followers handler and expose it through the runtime router. Return an ActivityPub OrderedCollection with current follower IDs and counts, and cover collection updates, response headers, and unknown users. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/README.md | 4 +- crates/feder-runtime-server/src/app.rs | 2 + crates/feder-runtime-server/src/followers.rs | 62 ++++++++ crates/feder-runtime-server/src/lib.rs | 1 + .../tests/cases/followers.rs | 133 ++++++++++++++++++ crates/feder-runtime-server/tests/runtime.rs | 2 + 6 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 crates/feder-runtime-server/src/followers.rs create mode 100644 crates/feder-runtime-server/tests/cases/followers.rs diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 77c4d03..17aed6e 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -6,8 +6,8 @@ systems. This crate builds an Axum router from caller-provided runtime configuration. It provides a health check endpoint, WebFinger discovery, and a local actor -route. The caller chooses concrete bind addresses, actor IRIs, usernames, and -handle hosts. +route with its followers collection. The caller chooses concrete bind +addresses, actor IRIs, usernames, and handle hosts. ActivityPub inbox handling for Follow and embedded Undo(Follow) activities is included. The runtime can use in-memory storage for tests and examples, or diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 0c8b4a9..7ac9cf4 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -18,6 +18,7 @@ use std::sync::{Arc, Mutex}; use crate::Error; use crate::actor::ActorResolver; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; +use crate::followers::followers; use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; use crate::webfinger::webfinger; @@ -111,6 +112,7 @@ pub fn router_with_state(state: AppState) -> Router { .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) + .route("/users/{username}/followers", get(followers)) .route("/users/{username}/inbox", post(inbox)) .layer(DefaultBodyLimit::max(1_048_576)) .with_state(state) diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs new file mode 100644 index 0000000..f951c18 --- /dev/null +++ b/crates/feder-runtime-server/src/followers.rs @@ -0,0 +1,62 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Json, + extract::{Path, State}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_vocab::OrderedCollection; + +use crate::{app::AppState, storage::RuntimeStore}; + +/// Return the local actor's followers as a one-shot ordered collection. +pub async fn followers( + State(app_state): State, + Path(username): Path, +) -> Result { + if username != app_state.username { + return Err(StatusCode::NOT_FOUND); + } + + let followers = app_state + .store + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .list_followers(&app_state.local_actor.id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let total_items = + u64::try_from(followers.len()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let ordered_items = followers + .into_iter() + .map(|follower| follower.follower) + .collect(); + let collection_id = app_state + .local_actor + .followers + .clone() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let collection = OrderedCollection::new(collection_id, total_items, ordered_items); + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(collection), + ) + .into_response()) +} diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 975825b..94154a8 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -17,6 +17,7 @@ pub mod actor; pub mod app; pub mod config; pub mod error; +pub mod followers; pub mod inbox; pub mod send; pub mod storage; diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs new file mode 100644 index 0000000..7abf6bd --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/followers.rs @@ -0,0 +1,133 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_core::{Action, RemoveFollower, StoreFollower}; +use feder_runtime_server::{app::router_with_state, storage::RuntimeStore}; +use feder_vocab::{Iri, Reference}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{test_app_state, test_config, test_router}; + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +async fn get_followers(app: axum::Router, username: &str) -> axum::response::Response { + app.oneshot( + Request::builder() + .uri(format!("/users/{username}/followers")) + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response") +} + +async fn response_json(response: axum::response::Response) -> Value { + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + serde_json::from_slice(&body).expect("valid JSON") +} + +fn store_follower(follower: &str) -> Action { + Action::StoreFollower(StoreFollower { + follower: Reference::id(iri(follower)), + following: Reference::id(iri("http://127.0.0.1:3000/users/alice")), + }) +} + +#[tokio::test] +async fn returns_empty_followers_collection_with_activitypub_headers() { + let response = get_followers(test_router(test_config()).expect("build router"), "alice").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + + let json = response_json(response).await; + assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); + assert_eq!(json["type"], "OrderedCollection"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice/followers"); + assert_eq!(json["totalItems"], 0); + assert_eq!(json["orderedItems"], serde_json::json!([])); +} + +#[tokio::test] +async fn returns_stored_followers_and_count() { + let state = test_app_state(test_config()).expect("build app state"); + state + .store + .lock() + .expect("store lock") + .persist_actions(&[ + store_follower("https://remote.example/users/carol"), + store_follower("https://remote.example/users/bob"), + ]) + .expect("persist followers"); + + let response = get_followers(router_with_state(state), "alice").await; + assert_eq!(response.status(), StatusCode::OK); + + let json = response_json(response).await; + assert_eq!(json["totalItems"], 2); + assert_eq!( + json["orderedItems"], + serde_json::json!([ + "https://remote.example/users/bob", + "https://remote.example/users/carol" + ]) + ); +} + +#[tokio::test] +async fn reflects_follower_removal() { + let state = test_app_state(test_config()).expect("build app state"); + { + let mut store = state.store.lock().expect("store lock"); + store + .persist_actions(&[store_follower("https://remote.example/users/bob")]) + .expect("persist follower"); + store + .persist_actions(&[Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: state.local_actor.id.clone(), + })]) + .expect("remove follower"); + } + + let response = get_followers(router_with_state(state), "alice").await; + assert_eq!(response.status(), StatusCode::OK); + + let json = response_json(response).await; + assert_eq!(json["totalItems"], 0); + assert_eq!(json["orderedItems"], serde_json::json!([])); +} + +#[tokio::test] +async fn rejects_unknown_username() { + let response = + get_followers(test_router(test_config()).expect("build router"), "unknown").await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 0b2690e..6a4bbdc 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -19,6 +19,8 @@ mod common; mod actor; #[path = "cases/app.rs"] mod app; +#[path = "cases/followers.rs"] +mod followers; #[path = "cases/inbox.rs"] mod inbox; #[path = "cases/send.rs"] From ac35ae56f3837c167c715f07fb618bc4af307853 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 23:49:09 +0900 Subject: [PATCH 19/42] Negotiate ActivityPub actor and follower responses Require an explicit ActivityPub-compatible Accept header for actor and followers collection requests. Return 406 for HTML, wildcard-only, missing, or unsupported Accept headers, and mark successful responses with Vary: Accept. Add coverage for supported media types, quality preferences, HTML requests, and requests without an ActivityPub Accept header. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/src/actor.rs | 13 +- crates/feder-runtime-server/src/followers.rs | 8 +- crates/feder-runtime-server/src/lib.rs | 1 + .../feder-runtime-server/src/negotiation.rs | 148 ++++++++++++++++++ .../feder-runtime-server/tests/cases/actor.rs | 35 +++++ .../tests/cases/followers.rs | 34 ++++ 8 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 crates/feder-runtime-server/src/negotiation.rs diff --git a/Cargo.lock b/Cargo.lock index 0f6ef02..3776a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,7 @@ dependencies = [ "httpdate", "ipnet", "iri-string", + "mime", "percent-encoding", "rand_core 0.6.4", "reqwest", diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 81adfbd..06a4aaf 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -15,6 +15,7 @@ iri-string.workspace = true axum = "0.8" httpdate = "1" ipnet = "2.11.0" +mime = "0.3" serde.workspace = true thiserror = "2" serde_json.workspace = true diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index d03198b..af03cf0 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -16,7 +16,7 @@ use axum::{ Json, extract::{Path, State}, - http::{StatusCode, header}, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; @@ -26,7 +26,7 @@ use reqwest::{ }; use serde::Deserialize; -use crate::{app::AppState, config::OutboundAddressPolicy, url}; +use crate::{app::AppState, config::OutboundAddressPolicy, negotiation::accepts_activitypub, url}; const MAX_ACTOR_BODY_SIZE: usize = 1_048_576; const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; @@ -34,14 +34,21 @@ const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json pub async fn actor( State(app_state): State, Path(username): Path, + headers: HeaderMap, ) -> Result { if username != app_state.username { return Err(StatusCode::NOT_FOUND); } + if !accepts_activitypub(&headers) { + return Err(StatusCode::NOT_ACCEPTABLE); + } let local_actor = app_state.local_actor.clone(); Ok(( - [(header::CONTENT_TYPE, "application/activity+json")], + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], Json(local_actor), ) .into_response()) diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs index f951c18..721846d 100644 --- a/crates/feder-runtime-server/src/followers.rs +++ b/crates/feder-runtime-server/src/followers.rs @@ -16,21 +16,25 @@ use axum::{ Json, extract::{Path, State}, - http::{StatusCode, header}, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use feder_vocab::OrderedCollection; -use crate::{app::AppState, storage::RuntimeStore}; +use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; /// Return the local actor's followers as a one-shot ordered collection. pub async fn followers( State(app_state): State, Path(username): Path, + headers: HeaderMap, ) -> Result { if username != app_state.username { return Err(StatusCode::NOT_FOUND); } + if !accepts_activitypub(&headers) { + return Err(StatusCode::NOT_ACCEPTABLE); + } let followers = app_state .store diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 94154a8..b41eb02 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -19,6 +19,7 @@ pub mod config; pub mod error; pub mod followers; pub mod inbox; +mod negotiation; pub mod send; pub mod storage; mod url; diff --git a/crates/feder-runtime-server/src/negotiation.rs b/crates/feder-runtime-server/src/negotiation.rs new file mode 100644 index 0000000..a185e4c --- /dev/null +++ b/crates/feder-runtime-server/src/negotiation.rs @@ -0,0 +1,148 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::{HeaderMap, header::ACCEPT}; +use mime::Mime; + +const ACTIVITYPUB_MEDIA_TYPES: &[&str] = &[ + "application/activity+json", + "application/ld+json", + "application/json", +]; +const HTML_MEDIA_TYPES: &[&str] = &["text/html", "application/xhtml+xml"]; + +struct MediaRange { + media_type: Mime, + quality: u16, + order: usize, +} + +pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { + let mut ranges = headers + .get_all(ACCEPT) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|value| value.trim().parse::().ok()) + .enumerate() + .filter_map(|(order, media_range)| { + let quality = media_range + .get_param("q") + .map_or(Some(1000), |value| parse_quality(value.as_str()))?; + (quality > 0).then_some(MediaRange { + media_type: media_range, + quality, + order, + }) + }) + .collect::>(); + + ranges.sort_by_key(|range| (std::cmp::Reverse(range.quality), range.order)); + + if ranges + .first() + .is_some_and(|range| HTML_MEDIA_TYPES.contains(&range.media_type.essence_str())) + { + return false; + } + + ranges + .iter() + .any(|range| ACTIVITYPUB_MEDIA_TYPES.contains(&range.media_type.essence_str())) +} + +fn parse_quality(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + match whole { + "0" => { + let padding = 3 - fraction.len(); + let fraction = fraction.parse::().unwrap_or(0); + Some(fraction * 10_u16.pow(u32::try_from(padding).ok()?)) + } + "1" if fraction.bytes().all(|byte| byte == b'0') => Some(1000), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers(accept: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(accept) = accept { + headers.insert(ACCEPT, HeaderValue::from_str(accept).expect("valid header")); + } + headers + } + + #[test] + fn accepts_explicit_activitypub_media_types() { + for accept in [ + Some("application/activity+json"), + Some("application/ld+json"), + Some("application/json"), + ] { + assert!(accepts_activitypub(&headers(accept)), "Accept: {accept:?}"); + } + } + + #[test] + fn rejects_implicit_html_or_unsupported_media_types() { + for accept in [ + "", + "*/*", + "application/*", + "text/html", + "application/xhtml+xml", + "image/png", + "application/activity+json;q=0", + ] { + assert!( + !accepts_activitypub(&headers(Some(accept))), + "Accept: {accept}" + ); + } + assert!(!accepts_activitypub(&headers(None))); + } + + #[test] + fn respects_quality_and_order() { + assert_eq!(parse_quality("0.9"), Some(900)); + assert_eq!(parse_quality("0.08"), Some(80)); + assert_eq!(parse_quality("1.000"), Some(1000)); + assert!(!accepts_activitypub(&headers(Some( + "application/activity+json;q=0.5, text/html;q=0.8" + )))); + assert!(accepts_activitypub(&headers(Some( + "application/activity+json;q=0.9, text/html;q=0.8" + )))); + assert!(!accepts_activitypub(&headers(Some( + "text/html, application/activity+json" + )))); + assert!(accepts_activitypub(&headers(Some( + "application/activity+json, text/html" + )))); + assert!(!accepts_activitypub(&headers(Some("text/html, */*")))); + assert!(!accepts_activitypub(&headers(Some( + "application/activity+json;q=0, application/ld+json;q=0, application/json;q=0, */*;q=1" + )))); + } +} diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index 5c4e0b7..d96093f 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -30,6 +30,7 @@ async fn returns_local_actor() { .oneshot( Request::builder() .uri("/users/alice") + .header(header::ACCEPT, "application/activity+json") .body(Body::empty()) .expect("valid request"), ) @@ -41,6 +42,7 @@ async fn returns_local_actor() { response.headers().get(header::CONTENT_TYPE).unwrap(), "application/activity+json" ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); let body = to_bytes(response.into_body(), 2048) .await @@ -75,6 +77,39 @@ async fn returns_local_actor() { ); } +#[tokio::test] +async fn rejects_actor_request_when_html_is_preferred() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice") + .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn rejects_actor_request_without_activitypub_accept() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} + #[tokio::test] async fn rejects_unknown_actor() { let app = test_router(test_config()).expect("build router"); diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs index 7abf6bd..cbdd014 100644 --- a/crates/feder-runtime-server/tests/cases/followers.rs +++ b/crates/feder-runtime-server/tests/cases/followers.rs @@ -33,6 +33,7 @@ async fn get_followers(app: axum::Router, username: &str) -> axum::response::Res app.oneshot( Request::builder() .uri(format!("/users/{username}/followers")) + .header(header::ACCEPT, "application/activity+json") .body(Body::empty()) .expect("valid request"), ) @@ -131,3 +132,36 @@ async fn rejects_unknown_username() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn rejects_followers_request_when_html_is_preferred() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice/followers") + .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn rejects_followers_request_without_activitypub_accept() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice/followers") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} From 284774f4dd52cc88a43cfbe3af51abf4f57fc038 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 01:00:10 +0900 Subject: [PATCH 20/42] Mark negotiated 406 responses with Vary Accept Add Vary: Accept to actor and followers responses that reject requests without an ActivityPub-compatible Accept header, preventing caches from reusing those responses across representations. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 2 +- crates/feder-runtime-server/src/followers.rs | 2 +- crates/feder-runtime-server/tests/cases/actor.rs | 2 ++ crates/feder-runtime-server/tests/cases/followers.rs | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index af03cf0..c74fa56 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -40,7 +40,7 @@ pub async fn actor( return Err(StatusCode::NOT_FOUND); } if !accepts_activitypub(&headers) { - return Err(StatusCode::NOT_ACCEPTABLE); + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } let local_actor = app_state.local_actor.clone(); diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs index 721846d..b42ac7e 100644 --- a/crates/feder-runtime-server/src/followers.rs +++ b/crates/feder-runtime-server/src/followers.rs @@ -33,7 +33,7 @@ pub async fn followers( return Err(StatusCode::NOT_FOUND); } if !accepts_activitypub(&headers) { - return Err(StatusCode::NOT_ACCEPTABLE); + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } let followers = app_state diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index d96093f..e2ed22d 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -92,6 +92,7 @@ async fn rejects_actor_request_when_html_is_preferred() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } #[tokio::test] @@ -108,6 +109,7 @@ async fn rejects_actor_request_without_activitypub_accept() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } #[tokio::test] diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs index cbdd014..bfeb6cb 100644 --- a/crates/feder-runtime-server/tests/cases/followers.rs +++ b/crates/feder-runtime-server/tests/cases/followers.rs @@ -148,6 +148,7 @@ async fn rejects_followers_request_when_html_is_preferred() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } #[tokio::test] @@ -164,4 +165,5 @@ async fn rejects_followers_request_without_activitypub_accept() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } From 97766307cac9d0719075b736b0d97fe454a55d75 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 11:01:27 +0900 Subject: [PATCH 21/42] Compare supported representations during content negotiation Ignore unsupported Accept media types when choosing between HTML and ActivityPub. Compare the best quality in each supported representation group and use header order to resolve equal preferences. Assisted-by: Codex:gpt-5.6-sol --- .../feder-runtime-server/src/negotiation.rs | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/feder-runtime-server/src/negotiation.rs b/crates/feder-runtime-server/src/negotiation.rs index a185e4c..0ab2177 100644 --- a/crates/feder-runtime-server/src/negotiation.rs +++ b/crates/feder-runtime-server/src/negotiation.rs @@ -29,8 +29,14 @@ struct MediaRange { order: usize, } +#[derive(Clone, Copy)] +struct Preference { + quality: u16, + order: usize, +} + pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { - let mut ranges = headers + let ranges = headers .get_all(ACCEPT) .iter() .filter_map(|value| value.to_str().ok()) @@ -49,18 +55,35 @@ pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { }) .collect::>(); - ranges.sort_by_key(|range| (std::cmp::Reverse(range.quality), range.order)); + let activitypub = preferred(ACTIVITYPUB_MEDIA_TYPES, &ranges); + let html = preferred(HTML_MEDIA_TYPES, &ranges); - if ranges - .first() - .is_some_and(|range| HTML_MEDIA_TYPES.contains(&range.media_type.essence_str())) - { - return false; + match (activitypub, html) { + (Some(activitypub), Some(html)) => prefers(activitypub, html), + (Some(_), None) => true, + _ => false, } +} +fn preferred(media_types: &[&str], ranges: &[MediaRange]) -> Option { ranges .iter() - .any(|range| ACTIVITYPUB_MEDIA_TYPES.contains(&range.media_type.essence_str())) + .filter(|range| media_types.contains(&range.media_type.essence_str())) + .map(|range| Preference { + quality: range.quality, + order: range.order, + }) + .reduce(|current, candidate| { + if prefers(candidate, current) { + candidate + } else { + current + } + }) +} + +fn prefers(left: Preference, right: Preference) -> bool { + left.quality > right.quality || (left.quality == right.quality && left.order < right.order) } fn parse_quality(value: &str) -> Option { @@ -145,4 +168,27 @@ mod tests { "application/activity+json;q=0, application/ld+json;q=0, application/json;q=0, */*;q=1" )))); } + + #[test] + fn ignores_unsupported_types_when_comparing_supported_representations() { + assert!(!accepts_activitypub(&headers(Some( + "image/png, application/activity+json;q=0.5, text/html;q=0.8" + )))); + assert!(accepts_activitypub(&headers(Some( + "image/png, application/activity+json;q=0.8, text/html;q=0.5" + )))); + assert!(accepts_activitypub(&headers(Some( + "image/png, application/activity+json;q=0.5" + )))); + } + + #[test] + fn compares_the_best_type_in_each_supported_representation() { + assert!(!accepts_activitypub(&headers(Some( + "text/html;q=0.2, application/xhtml+xml;q=0.9, application/activity+json;q=0.8" + )))); + assert!(accepts_activitypub(&headers(Some( + "text/html;q=0.8, application/activity+json;q=0.2, application/ld+json;q=0.9" + )))); + } } From ad8d6fea4d32e4ee6415c7d5856ccf13ba1fdb2c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 12:48:19 +0900 Subject: [PATCH 22/42] Expand Note addressing and representation fields Add to, cc, url, and mediaType fields to the Note vocabulary model. Preserve ActivityStreams scalar-or-array recipient serialization and omit empty or absent values. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-vocab/src/lib.rs | 12 ++++++++++++ .../feder-vocab/tests/activitypub_serialization.rs | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 7591dfa..2f6c2a1 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -329,10 +329,18 @@ pub struct Note { pub id: Iri, #[serde(rename = "attributedTo", skip_serializing_if = "Option::is_none")] pub attributed_to: Option>, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub to: References, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub cc: References, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, + #[serde(rename = "mediaType", skip_serializing_if = "Option::is_none")] + pub media_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub published: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, } impl Note { @@ -347,8 +355,12 @@ impl Note { kind: NoteType::default(), id, attributed_to: None, + to: References::new(), + cc: References::new(), content: None, + media_type: None, published: None, + url: None, } } } diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 1494636..633b9a8 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -176,8 +176,12 @@ fn ordered_collection_serializes_actor_iris() { fn local_note_serializes_as_create_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.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + note.cc = References::one(iri("https://example.com/users/alice/followers")); note.content = Some("Hello from Feder.".to_string()); + note.media_type = Some("text/html".to_string()); note.published = Some("2026-06-02T00:00:00Z".to_string()); + note.url = Some(iri("https://example.com/@alice/1")); let create = Create::new( iri("https://example.com/activities/create/1"), @@ -197,8 +201,12 @@ fn local_note_serializes_as_create_activity() { "type": "Note", "id": "https://example.com/notes/1", "attributedTo": "https://example.com/users/alice", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": "https://example.com/users/alice/followers", "content": "Hello from Feder.", - "published": "2026-06-02T00:00:00Z" + "mediaType": "text/html", + "published": "2026-06-02T00:00:00Z", + "url": "https://example.com/@alice/1" } }) ); From 1cd7c28db9ad10a724752c40a0f942e7b7ba1ab2 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 21:15:11 +0900 Subject: [PATCH 23/42] Persist ActivityPub objects in the runtime store Store Note objects emitted through StoreObject actions in SQLite using their IRI, object type, and serialized payload. Add typed object loading, idempotent replacement, and persistence across database reopen. Assisted-by: Codex:gpt-5.6-sol --- .../feder-runtime-server/src/storage/mod.rs | 10 +- .../src/storage/sqlite.rs | 177 +++++++++++++++++- 2 files changed, 183 insertions(+), 4 deletions(-) diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 740f057..673ff89 100644 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-runtime-server/src/storage/mod.rs @@ -16,7 +16,7 @@ pub mod sqlite; use feder_core::{ - Action, + Action, Object, http_signatures::{ActorKeyPair, KeyError}, }; use feder_vocab::Iri; @@ -48,6 +48,12 @@ pub enum StoreError { #[error("invalid IRI: {0}")] InvalidIri(String), + #[error("unsupported runtime object type")] + UnsupportedObjectType, + + #[error("unsupported stored object type: {0}")] + UnsupportedStoredObjectType(String), + #[error(transparent)] ActorKey(#[from] KeyError), } @@ -59,6 +65,8 @@ pub trait RuntimeStore { fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError>; + fn load_object(&self, object_id: &Iri) -> Result, StoreError>; + fn insert_actor_key_pair( &mut self, actor_id: &Iri, diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 16e42f1..cf861fb 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,8 +15,8 @@ use std::path::Path; -use feder_core::{Action, http_signatures::ActorKeyPair}; -use feder_vocab::{Actor, Iri, Reference}; +use feder_core::{Action, Object, http_signatures::ActorKeyPair}; +use feder_vocab::{Actor, Iri, Note, Reference}; use rusqlite::{Connection, OptionalExtension, params}; use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; @@ -63,6 +63,11 @@ impl SqliteStore { private_key_pem TEXT NOT NULL, public_key_pem TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS objects ( + object_id TEXT PRIMARY KEY NOT NULL, + object_type TEXT NOT NULL, + object_json TEXT NOT NULL + ); "#, )?; @@ -125,6 +130,19 @@ impl RuntimeStore for SqliteStore { params![action.target.actor.as_str(), action.target.inbox.as_str()], )?; } + Action::StoreObject(action) => { + let (object_id, object_type, object_json) = encode_object(&action.object)?; + tx.execute( + r#" + INSERT INTO objects (object_id, object_type, object_json) + VALUES (?1, ?2, ?3) + ON CONFLICT(object_id) DO UPDATE SET + object_type = excluded.object_type, + object_json = excluded.object_json + "#, + params![object_id.as_str(), object_type, object_json], + )?; + } _ => {} } } @@ -193,6 +211,25 @@ impl RuntimeStore for SqliteStore { .collect() } + fn load_object(&self, object_id: &Iri) -> Result, StoreError> { + let stored = self + .conn + .query_row( + r#" + SELECT object_type, object_json + FROM objects + WHERE object_id = ?1 + "#, + [object_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + stored + .map(|(object_type, object_json)| decode_object(&object_type, &object_json)) + .transpose() + } + fn insert_actor_key_pair( &mut self, actor_id: &Iri, @@ -236,6 +273,22 @@ impl RuntimeStore for SqliteStore { } } +fn encode_object(object: &Object) -> Result<(&Iri, &'static str, String), StoreError> { + match object { + Object::Note(note) => Ok((¬e.id, "Note", serde_json::to_string(note)?)), + _ => Err(StoreError::UnsupportedObjectType), + } +} + +fn decode_object(object_type: &str, object_json: &str) -> Result { + match object_type { + "Note" => Ok(Object::Note(serde_json::from_str::(object_json)?)), + object_type => Err(StoreError::UnsupportedStoredObjectType( + object_type.to_string(), + )), + } +} + fn actor_reference_id(reference: &Reference) -> &Iri { match reference { Reference::Id(id) => id, @@ -272,7 +325,7 @@ fn parse_optional_iri(value: Option) -> Result, StoreError> #[cfg(test)] mod tests { - use feder_core::{Action, RemoveFollower, StoreFollower}; + use feder_core::{Action, Object, RemoveFollower, StoreFollower, StoreObject}; use super::*; @@ -290,6 +343,16 @@ mod tests { }) } + fn store_note_action(content: &str) -> Action { + let mut note = Note::new(iri("https://example.com/users/alice/posts/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); + note.content = Some(content.to_string()); + + Action::StoreObject(StoreObject { + object: Object::Note(note), + }) + } + fn actor(id: &str) -> Actor { Actor::person( iri(id), @@ -371,6 +434,114 @@ mod tests { ); } + #[test] + fn open_in_memory_initializes_objects_table() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let columns: Vec = { + let mut stmt = store + .conn + .prepare("PRAGMA table_info(objects)") + .expect("prepare objects table info query"); + stmt.query_map([], |row| row.get("name")) + .expect("query objects table info") + .collect::>() + .expect("collect objects table columns") + }; + + assert_eq!( + columns, + vec![ + "object_id".to_string(), + "object_type".to_string(), + "object_json".to_string(), + ] + ); + } + + #[test] + fn persist_actions_stores_and_loads_note() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let action = store_note_action("Hello from Feder."); + + store + .persist_actions(core::slice::from_ref(&action)) + .expect("persist note action"); + let object = store + .load_object(&iri("https://example.com/users/alice/posts/1")) + .expect("load note") + .expect("stored note"); + + let Action::StoreObject(expected) = action else { + panic!("expected store object action"); + }; + assert_eq!(object, expected.object); + } + + #[test] + fn persist_actions_replaces_object_with_same_id() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + + store + .persist_actions(&[store_note_action("Original")]) + .expect("persist original note"); + store + .persist_actions(&[store_note_action("Updated")]) + .expect("replace note"); + + let object = store + .load_object(&iri("https://example.com/users/alice/posts/1")) + .expect("load note") + .expect("stored note"); + let Object::Note(note) = object else { + panic!("expected stored note"); + }; + assert_eq!(note.content.as_deref(), Some("Updated")); + } + + #[test] + fn load_object_returns_none_for_unknown_id() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let object = store + .load_object(&iri("https://example.com/users/alice/posts/unknown")) + .expect("load unknown object"); + + assert!(object.is_none()); + } + + #[test] + fn stored_note_persists_across_store_reopen() { + let path = std::env::temp_dir().join(format!( + "feder-object-test-{}-{}.sqlite3", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + + { + let mut store = SqliteStore::open(&path).expect("open SQLite store"); + store + .persist_actions(&[store_note_action("Persistent note")]) + .expect("persist note action"); + } + + let store = SqliteStore::open(&path).expect("reopen SQLite store"); + let object = store + .load_object(&iri("https://example.com/users/alice/posts/1")) + .expect("load persisted note") + .expect("persisted note"); + let Object::Note(note) = object else { + panic!("expected stored note"); + }; + assert_eq!(note.content.as_deref(), Some("Persistent note")); + + drop(store); + let _ = std::fs::remove_file(path); + } + #[test] fn actor_key_pair_roundtrips_for_actor() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); From db9b196785e4592e775e332a439e572399c175de Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 21:37:37 +0900 Subject: [PATCH 24/42] Serve persisted Notes through ActivityPub object routes Add the local post object route with ActivityPub content negotiation, SQLite-backed lookup, and coverage for persistence and error responses. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/app.rs | 2 + crates/feder-runtime-server/src/lib.rs | 1 + crates/feder-runtime-server/src/object.rs | 76 ++++++++ .../tests/cases/object.rs | 169 ++++++++++++++++++ crates/feder-runtime-server/tests/runtime.rs | 2 + 5 files changed, 250 insertions(+) create mode 100644 crates/feder-runtime-server/src/object.rs create mode 100644 crates/feder-runtime-server/tests/cases/object.rs diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 7ac9cf4..db6b22c 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -19,6 +19,7 @@ use crate::Error; use crate::actor::ActorResolver; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; use crate::followers::followers; +use crate::object::get_object; use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; use crate::webfinger::webfinger; @@ -113,6 +114,7 @@ pub fn router_with_state(state: AppState) -> Router { .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) .route("/users/{username}/followers", get(followers)) + .route("/users/{username}/posts/{id}", get(get_object)) .route("/users/{username}/inbox", post(inbox)) .layer(DefaultBodyLimit::max(1_048_576)) .with_state(state) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index b41eb02..777c0b0 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -20,6 +20,7 @@ pub mod error; pub mod followers; pub mod inbox; mod negotiation; +pub mod object; pub mod send; pub mod storage; mod url; diff --git a/crates/feder-runtime-server/src/object.rs b/crates/feder-runtime-server/src/object.rs new file mode 100644 index 0000000..d808ff6 --- /dev/null +++ b/crates/feder-runtime-server/src/object.rs @@ -0,0 +1,76 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_core::Object; +use feder_vocab::Iri; + +use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; + +/// Return a persisted local ActivityPub object. +pub async fn get_object( + State(app_state): State, + Path((username, post_id)): Path<(String, String)>, + headers: HeaderMap, +) -> Result { + if username != app_state.username { + return Err(StatusCode::NOT_FOUND); + } + + let object_id = note_id(&app_state.local_actor.id, &post_id)?; + let object = app_state + .store + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .load_object(&object_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let Object::Note(note) = object else { + return Err(StatusCode::NOT_FOUND); + }; + + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(note), + ) + .into_response()) +} + +fn note_id(actor_id: &Iri, post_id: &str) -> Result { + let mut url = + url::Url::parse(actor_id.as_str()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + url.set_query(None); + url.set_fragment(None); + url.path_segments_mut() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .pop_if_empty() + .push("posts") + .push(post_id); + url.as_str() + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} diff --git a/crates/feder-runtime-server/tests/cases/object.rs b/crates/feder-runtime-server/tests/cases/object.rs new file mode 100644 index 0000000..6d8512a --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/object.rs @@ -0,0 +1,169 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_core::{Action, Object, StoreObject}; +use feder_runtime_server::{app::router_with_state, config::StorageConfig, storage::RuntimeStore}; +use feder_vocab::{Iri, Note, Reference, References}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +fn stored_note() -> Note { + let mut note = Note::new(iri("http://127.0.0.1:3000/users/alice/posts/1")); + note.attributed_to = Some(Reference::id(iri("http://127.0.0.1:3000/users/alice"))); + note.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + note.cc = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); + note.content = Some("Hello from Feder.".to_string()); + note.media_type = Some("text/html".to_string()); + note.published = Some("2026-07-21T00:00:00Z".to_string()); + note.url = Some(note.id.clone()); + note +} + +fn router_with_note() -> Router { + let state = test_app_state(test_config()).expect("build app state"); + state + .store + .lock() + .expect("store lock") + .persist_actions(&[Action::StoreObject(StoreObject { + object: Object::Note(stored_note()), + })]) + .expect("persist note"); + router_with_state(state) +} + +async fn get_object(app: Router, uri: &str, accept: Option<&str>) -> axum::response::Response { + let mut request = Request::builder().uri(uri); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + + app.oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response") +} + +#[tokio::test] +async fn returns_stored_note_with_activitypub_headers() { + let response = get_object( + router_with_note(), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["type"], "Note"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice/posts/1"); + assert_eq!(json["content"], "Hello from Feder."); + assert_eq!(json["mediaType"], "text/html"); +} + +#[tokio::test] +async fn rejects_note_request_when_html_is_preferred() { + let response = get_object( + router_with_note(), + "/users/alice/posts/1", + Some("text/html, application/activity+json;q=0.8"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} + +#[tokio::test] +async fn rejects_note_request_without_activitypub_accept() { + let response = get_object(router_with_note(), "/users/alice/posts/1", None).await; + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} + +#[tokio::test] +async fn returns_not_found_for_unknown_note() { + let response = get_object( + router_with_note(), + "/users/alice/posts/unknown", + Some("text/html"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn returns_note_after_store_reopen() { + let path = temporary_database_path("feder-object-route-test"); + { + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(config).expect("build app state"); + state + .store + .lock() + .expect("store lock") + .persist_actions(&[Action::StoreObject(StoreObject { + object: Object::Note(stored_note()), + })]) + .expect("persist note"); + } + + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let response = get_object( + test_router(config).expect("reopen router"), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + + let _ = std::fs::remove_file(path); +} + +#[tokio::test] +async fn returns_not_found_for_unknown_username() { + let response = get_object( + router_with_note(), + "/users/bob/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 6a4bbdc..62f4457 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -23,6 +23,8 @@ mod app; mod followers; #[path = "cases/inbox.rs"] mod inbox; +#[path = "cases/object.rs"] +mod object; #[path = "cases/send.rs"] mod send; #[path = "cases/webfinger.rs"] From 998e6f95d8eb7d9ad95c24b0974a6a6f1dc9c7f3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 22:29:45 +0900 Subject: [PATCH 25/42] Coordinate local Note persistence and delivery in the runtime Add a runtime operation for local Note creation that applies storage actions before synchronously delivering Create activities. Reuse the same action pipeline for inbox processing and cover successful and failed delivery. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/error.rs | 8 +- crates/feder-runtime-server/src/inbox.rs | 35 ++---- crates/feder-runtime-server/src/lib.rs | 1 + crates/feder-runtime-server/src/operation.rs | 44 +++++++ .../tests/cases/operation.rs | 119 ++++++++++++++++++ crates/feder-runtime-server/tests/runtime.rs | 2 + 6 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 crates/feder-runtime-server/src/operation.rs create mode 100644 crates/feder-runtime-server/tests/cases/operation.rs diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index d405636..b0f8ec3 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -15,6 +15,12 @@ #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("runtime core state is unavailable")] + CoreStateUnavailable, + + #[error("runtime storage state is unavailable")] + StorageStateUnavailable, + #[error("failed to bind server socket")] Bind(#[source] std::io::Error), @@ -27,7 +33,7 @@ pub enum Error { #[error("actor key generation failed")] ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), - #[error("activity sender setup failed")] + #[error("activity sending failed")] ActivitySender(#[from] crate::send::SendError), #[error("actor resolver setup failed")] diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index b041b8b..252c446 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -32,10 +32,9 @@ use feder_core::{ use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; use serde_json::{Value, from_slice, from_value}; -use crate::app::AppState; use crate::config::InboxAuthPolicy; use crate::send::SendError; -use crate::storage::RuntimeStore; +use crate::{Error, app::AppState}; const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); @@ -384,34 +383,16 @@ pub async fn inbox( _ => return Ok(StatusCode::ACCEPTED.into_response()), }; - let result = { - let mut core = app_state - .core - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - core.handle(input) - }; - - app_state - .store - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .persist_actions(&result.actions) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - app_state - .activity_sender - .send_actions(&result.actions) + .handle_input(input) .await .map_err(|error| match error { - SendError::PrivateInboxAddress { .. } - | SendError::Request(_) - | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, - SendError::BuildClient(_) - | SendError::InvalidInbox(_) - | SendError::Serialize(_) - | SendError::Sign(_) - | SendError::UnsupportedActivity => StatusCode::INTERNAL_SERVER_ERROR, + Error::ActivitySender( + SendError::PrivateInboxAddress { .. } + | SendError::Request(_) + | SendError::UnsuccessfulStatus { .. }, + ) => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, })?; Ok(StatusCode::ACCEPTED.into_response()) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 777c0b0..9fa5339 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -21,6 +21,7 @@ pub mod followers; pub mod inbox; mod negotiation; pub mod object; +mod operation; pub mod send; pub mod storage; mod url; diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs new file mode 100644 index 0000000..19a885a --- /dev/null +++ b/crates/feder-runtime-server/src/operation.rs @@ -0,0 +1,44 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_core::{HandleResult, Input, UserCreateNote}; + +use crate::{Error, app::AppState, storage::RuntimeStore}; + +impl AppState { + /// Create, persist, and deliver a Note initiated by the local application. + /// + /// Persistence occurs before delivery. If delivery fails, this returns an + /// error while the created Note remains available from the runtime store. + pub async fn create_note(&self, input: UserCreateNote) -> Result { + self.handle_input(Input::UserCreateNote(input)).await + } + + pub(crate) async fn handle_input(&self, input: Input) -> Result { + let result = { + let mut core = self.core.lock().map_err(|_| Error::CoreStateUnavailable)?; + core.handle(input) + }; + + self.store + .lock() + .map_err(|_| Error::StorageStateUnavailable)? + .persist_actions(&result.actions)?; + + self.activity_sender.send_actions(&result.actions).await?; + + Ok(result) + } +} diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs new file mode 100644 index 0000000..58530fd --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -0,0 +1,119 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::StatusCode; +use feder_core::{Action, Input, Object, UserCreateNote}; +use feder_runtime_server::{Error, send::SendError, storage::RuntimeStore}; +use feder_vocab::{Actor, Follow, Iri, Reference}; + +use crate::common::{spawn_inbox_server, test_app_state, test_config}; + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +fn create_note_input() -> UserCreateNote { + UserCreateNote { + note_id: iri("http://127.0.0.1:3000/users/alice/posts/1"), + create_id: iri("http://127.0.0.1:3000/users/alice/activities/create/1"), + actor: Reference::id(iri("http://127.0.0.1:3000/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-07-21T00:00:00Z".to_string()), + } +} + +fn add_delivery_target(state: &feder_runtime_server::AppState, inbox: &str) { + let remote_actor_id = iri("https://remote.example/users/bob"); + let remote_actor = Actor::person( + remote_actor_id.clone(), + iri(inbox), + iri("https://remote.example/users/bob/outbox"), + ); + let follow = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::object(remote_actor), + Reference::id(state.local_actor.id.clone()), + ); + + let _ = state + .core + .lock() + .expect("core lock") + .handle(Input::received_follow( + follow, + iri("http://127.0.0.1:3000/users/alice/activities/accept/1"), + )); +} + +#[tokio::test] +async fn create_note_persists_and_delivers_the_core_actions() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let state = test_app_state(test_config()).expect("build app state"); + add_delivery_target(&state, &inbox); + + let result = state + .create_note(create_note_input()) + .await + .expect("create note"); + + assert_eq!(result.actions.len(), 2); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + assert!(matches!(result.actions[1], Action::SendActivity(_))); + + let stored = state + .store + .lock() + .expect("store lock") + .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) + .expect("load note") + .expect("stored note"); + let Object::Note(note) = stored else { + panic!("expected stored Note"); + }; + assert_eq!(note.content.as_deref(), Some("Hello from Feder.")); + + let request = requests.recv().await.expect("receive Create request"); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid Create activity"); + assert_eq!(activity["type"], "Create"); + assert_eq!(activity["object"]["id"], note.id.as_str()); + inbox_server.abort(); +} + +#[tokio::test] +async fn create_note_keeps_the_persisted_object_when_delivery_fails() { + let (inbox, mut requests, inbox_server) = + spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let state = test_app_state(test_config()).expect("build app state"); + add_delivery_target(&state, &inbox); + + let result = state.create_note(create_note_input()).await; + + assert!(matches!( + result, + Err(Error::ActivitySender(SendError::UnsuccessfulStatus { .. })) + )); + requests.recv().await.expect("receive Create request"); + assert!( + state + .store + .lock() + .expect("store lock") + .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) + .expect("load note") + .is_some() + ); + inbox_server.abort(); +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 62f4457..7e1d253 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -25,6 +25,8 @@ mod followers; mod inbox; #[path = "cases/object.rs"] mod object; +#[path = "cases/operation.rs"] +mod operation; #[path = "cases/send.rs"] mod send; #[path = "cases/webfinger.rs"] From e5a3ed0f90e2249154536dde62d93eab464006d9 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 00:16:43 +0900 Subject: [PATCH 26/42] Resolve Note recipients from persisted followers Remove the in-memory delivery target cache and resolve current follower inboxes from SQLite when delivering Create activities. Keep delivery synchronous, deduplicate shared inboxes, and refresh inbox data through repeated Follow persistence. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 273 ++++-------------- crates/feder-runtime-server/src/operation.rs | 48 ++- .../src/storage/sqlite.rs | 28 +- .../feder-runtime-server/tests/cases/inbox.rs | 9 +- .../tests/cases/operation.rs | 98 +++++-- 5 files changed, 181 insertions(+), 275 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 5957c09..bfa9978 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -85,7 +85,6 @@ impl FederConfig { pub struct FederState { local_actor: vocab::Actor, followers: Vec, - delivery_targets: Vec, objects: Vec, activities: Vec, } @@ -96,7 +95,6 @@ impl FederState { Self { local_actor: config.local_actor, followers: Vec::new(), - delivery_targets: Vec::new(), objects: Vec::new(), activities: Vec::new(), } @@ -112,15 +110,6 @@ impl FederState { &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 @@ -153,46 +142,17 @@ impl FederState { 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 })); - } + actions.push(Action::StoreFollower(StoreFollower { + follower: follow.actor.clone(), + following: follow.object.clone(), + })); - inbox = Some(actor.inbox.clone()); - } + let inbox = match &follow.actor { + vocab::Reference::Object(actor) => Some(actor.inbox.clone()), + vocab::Reference::Id(_) => None, + }; if let Some(inbox) = inbox { let accept = vocab::Accept::new( @@ -234,14 +194,6 @@ impl FederState { following: following.clone(), }; self.followers.retain(|existing| existing != &relation); - if !self - .followers - .iter() - .any(|existing| existing.follower == *follower) - { - self.delivery_targets - .retain(|target| target.actor != *follower); - } Vec::from([Action::RemoveFollower(RemoveFollower { follower: follower.clone(), @@ -275,16 +227,13 @@ impl FederState { 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 + Vec::from([ + Action::StoreObject(StoreObject { object }), + Action::SendActivityToFollowers(SendActivityToFollowers { + activity: Activity::CreateNote(create), + actor: self.local_actor.id.clone(), + }), + ]) } } @@ -362,25 +311,15 @@ pub struct Follower { 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), RemoveFollower(RemoveFollower), - StoreDeliveryTarget(StoreDeliveryTarget), StoreObject(StoreObject), SendActivity(SendActivity), + SendActivityToFollowers(SendActivityToFollowers), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -397,11 +336,6 @@ pub struct RemoveFollower { pub following: vocab::Iri, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreDeliveryTarget { - pub target: DeliveryTarget, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoreObject { pub object: Object, @@ -413,6 +347,14 @@ pub struct SendActivity { pub inbox: vocab::Iri, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SendActivityToFollowers { + /// Activity to deliver to the actor's current followers. + pub activity: Activity, + /// Local actor whose followers should receive the activity. + pub actor: vocab::Iri, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum Activity { @@ -489,7 +431,6 @@ mod tests { 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()); } @@ -508,7 +449,7 @@ mod tests { "https://example.com/activities/accept/1", )); - assert_eq!(result.actions.len(), 3); + assert_eq!(result.actions.len(), 2); assert_eq!( core.state().followers(), &[Follower { @@ -516,13 +457,6 @@ mod tests { 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 { @@ -530,17 +464,7 @@ mod tests { 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 { + let Action::SendActivity(send) = &result.actions[1] else { panic!("expected SendActivity action"); }; assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); @@ -563,7 +487,7 @@ mod tests { } #[test] - fn received_follow_updates_existing_delivery_target_by_actor() { + fn received_follow_refreshes_the_stored_follower_actor() { let mut core = core(); let first_follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), @@ -588,15 +512,17 @@ mod tests { "https://example.com/activities/accept/2", )); - assert_eq!(first_result.actions.len(), 3); + assert_eq!(first_result.actions.len(), 2); 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"), - }, + Action::StoreFollower(StoreFollower { + follower: vocab::Reference::object({ + let mut actor = actor("https://remote.example/users/bob"); + actor.inbox = iri("https://remote.example/inboxes/bob"); + actor + }), + following: vocab::Reference::id(iri("https://example.com/users/alice")), }) ); @@ -617,17 +543,10 @@ mod tests { 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() { + fn received_follow_with_actor_id_records_follower_without_accept_delivery() { let mut core = core(); let follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), @@ -654,7 +573,6 @@ mod tests { following: iri("https://example.com/users/alice"), }] ); - assert!(core.state().delivery_targets().is_empty()); } #[test] @@ -673,11 +591,10 @@ mod tests { assert!(result.is_empty()); assert!(core.state().followers().is_empty()); - assert!(core.state().delivery_targets().is_empty()); } #[test] - fn received_undo_follow_removes_follower_and_delivery_target() { + fn received_undo_follow_removes_follower() { let mut core = core(); let follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), @@ -702,7 +619,6 @@ mod tests { })]) ); assert!(core.state().followers().is_empty()); - assert!(core.state().delivery_targets().is_empty()); } #[test] @@ -725,7 +641,6 @@ mod tests { assert!(result.is_empty()); assert_eq!(core.state().followers().len(), 1); - assert_eq!(core.state().delivery_targets().len(), 1); } #[test] @@ -752,7 +667,7 @@ mod tests { } #[test] - fn user_create_note_records_created_object_and_emits_store_action() { + fn user_create_note_records_object_and_emits_followers_delivery() { let input = UserCreateNote { note_id: iri("https://example.com/notes/1"), create_id: iri("https://example.com/activities/create/1"), @@ -764,7 +679,7 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert_eq!(result.actions.len(), 1); + assert_eq!(result.actions.len(), 2); assert_eq!(core.state().objects().len(), 1); assert_eq!(core.state().activities().len(), 1); @@ -794,105 +709,20 @@ mod tests { 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"); + let Action::SendActivityToFollowers(send) = &result.actions[1] else { + panic!("expected followers delivery action"); }; - assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); - + assert_eq!(send.actor, iri("https://example.com/users/alice")); 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(); @@ -907,13 +737,9 @@ mod tests { "https://example.com/activities/accept/1", )); - assert_eq!(follow_result.actions.len(), 3); + assert_eq!(follow_result.actions.len(), 2); 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 { + let Action::SendActivity(accept_delivery) = &follow_result.actions[1] else { panic!("expected Accept delivery action"); }; assert_eq!( @@ -932,17 +758,16 @@ mod tests { 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"); + let Action::SendActivityToFollowers(create_delivery) = &create_result.actions[1] else { + panic!("expected followers delivery action"); }; assert_eq!( - create_delivery.inbox, - iri("https://remote.example/users/bob/inbox") + create_delivery.actor, + iri("https://example.com/users/alice") ); 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); } @@ -963,7 +788,7 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert_eq!(result.actions.len(), 1); + assert_eq!(result.actions.len(), 2); let Object::Note(note) = &core.state().objects()[0]; assert_eq!( diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index 19a885a..ee748ed 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -13,7 +13,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_core::{HandleResult, Input, UserCreateNote}; +use std::collections::HashSet; + +use feder_core::{ + Action, HandleResult, Input, SendActivity, SendActivityToFollowers, UserCreateNote, +}; use crate::{Error, app::AppState, storage::RuntimeStore}; @@ -32,13 +36,45 @@ impl AppState { core.handle(input) }; - self.store - .lock() - .map_err(|_| Error::StorageStateUnavailable)? - .persist_actions(&result.actions)?; + let delivery_actions = { + let mut store = self + .store + .lock() + .map_err(|_| Error::StorageStateUnavailable)?; + store.persist_actions(&result.actions)?; + resolve_delivery_actions(&*store, &result.actions)? + }; - self.activity_sender.send_actions(&result.actions).await?; + self.activity_sender.send_actions(&delivery_actions).await?; Ok(result) } } + +fn resolve_delivery_actions( + store: &impl RuntimeStore, + actions: &[Action], +) -> Result, Error> { + let mut resolved = Vec::new(); + + for action in actions { + match action { + Action::SendActivity(_) => resolved.push(action.clone()), + Action::SendActivityToFollowers(SendActivityToFollowers { activity, actor }) => { + let mut seen_inboxes = HashSet::new(); + for recipient in store.list_follower_recipients(actor)? { + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(Action::SendActivity(SendActivity { + activity: activity.clone(), + inbox, + })); + } + } + } + _ => {} + } + } + + Ok(resolved) +} diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index cf861fb..c00c369 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -120,16 +120,6 @@ impl RuntimeStore for SqliteStore { params![action.follower.as_str(), action.following.as_str()], )?; } - Action::StoreDeliveryTarget(action) => { - tx.execute( - r#" - UPDATE followers - SET inbox_url = ?2 - WHERE follower_actor_id = ?1 - "#, - params![action.target.actor.as_str(), action.target.inbox.as_str()], - )?; - } Action::StoreObject(action) => { let (object_id, object_type, object_json) = encode_object(&action.object)?; tx.execute( @@ -754,22 +744,20 @@ mod tests { } #[test] - fn persist_actions_updates_follower_inbox_from_delivery_target() { + fn persist_actions_updates_follower_inbox_from_repeated_follow() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); store .persist_actions(&[store_follower_action()]) .expect("persist ID-only follower action"); + let mut follower = actor("https://remote.example/users/bob"); + follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); store - .persist_actions(&[Action::StoreDeliveryTarget( - feder_core::StoreDeliveryTarget { - target: feder_core::DeliveryTarget { - actor: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/updated-inbox"), - }, - }, - )]) - .expect("persist delivery target action"); + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist repeated follower action"); let recipients = store .list_follower_recipients(&iri("https://example.com/users/alice")) diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 0208e78..3952ef6 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -265,9 +265,14 @@ async fn valid_follow_reaches_core() { core.state().followers()[0].following.as_str(), "http://127.0.0.1:3000/users/alice" ); - assert_eq!(core.state().delivery_targets().len(), 1); - assert_eq!(core.state().delivery_targets()[0].inbox.as_str(), inbox); } + let followers = state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers"); + assert_eq!(followers[0].inbox.as_ref().unwrap().as_str(), inbox); let request = requests.recv().await.expect("receive Accept request"); assert_eq!( diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 58530fd..6318fc4 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -14,11 +14,11 @@ // along with this program. If not, see . use axum::http::StatusCode; -use feder_core::{Action, Input, Object, UserCreateNote}; -use feder_runtime_server::{Error, send::SendError, storage::RuntimeStore}; -use feder_vocab::{Actor, Follow, Iri, Reference}; +use feder_core::{Action, Object, StoreFollower, UserCreateNote}; +use feder_runtime_server::{Error, config::StorageConfig, send::SendError, storage::RuntimeStore}; +use feder_vocab::{Actor, Iri, Reference}; -use crate::common::{spawn_inbox_server, test_app_state, test_config}; +use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; fn iri(value: &str) -> Iri { value.parse().expect("valid test IRI") @@ -34,34 +34,29 @@ fn create_note_input() -> UserCreateNote { } } -fn add_delivery_target(state: &feder_runtime_server::AppState, inbox: &str) { - let remote_actor_id = iri("https://remote.example/users/bob"); +fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, inbox: &str) { + let remote_actor_id = iri(remote_actor_id); let remote_actor = Actor::person( remote_actor_id.clone(), iri(inbox), - iri("https://remote.example/users/bob/outbox"), + iri(&format!("{remote_actor_id}/outbox")), ); - let follow = Follow::new( - iri("https://remote.example/activities/follow/1"), - Reference::object(remote_actor), - Reference::id(state.local_actor.id.clone()), - ); - - let _ = state - .core + state + .store .lock() - .expect("core lock") - .handle(Input::received_follow( - follow, - iri("http://127.0.0.1:3000/users/alice/activities/accept/1"), - )); + .expect("store lock") + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(remote_actor), + following: Reference::id(state.local_actor.id.clone()), + })]) + .expect("persist follower"); } #[tokio::test] async fn create_note_persists_and_delivers_the_core_actions() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let state = test_app_state(test_config()).expect("build app state"); - add_delivery_target(&state, &inbox); + store_follower(&state, "https://remote.example/users/bob", &inbox); let result = state .create_note(create_note_input()) @@ -70,7 +65,10 @@ async fn create_note_persists_and_delivers_the_core_actions() { assert_eq!(result.actions.len(), 2); assert!(matches!(result.actions[0], Action::StoreObject(_))); - assert!(matches!(result.actions[1], Action::SendActivity(_))); + assert!(matches!( + result.actions[1], + Action::SendActivityToFollowers(_) + )); let stored = state .store @@ -92,12 +90,32 @@ async fn create_note_persists_and_delivers_the_core_actions() { inbox_server.abort(); } +#[tokio::test] +async fn create_note_delivers_to_each_persisted_follower() { + let (bob_inbox, mut bob_requests, bob_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let (carol_inbox, mut carol_requests, carol_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let state = test_app_state(test_config()).expect("build app state"); + store_follower(&state, "https://remote.example/users/bob", &bob_inbox); + store_follower(&state, "https://another.example/users/carol", &carol_inbox); + + state + .create_note(create_note_input()) + .await + .expect("create note"); + + bob_requests.recv().await.expect("receive Bob delivery"); + carol_requests.recv().await.expect("receive Carol delivery"); + bob_server.abort(); + carol_server.abort(); +} + #[tokio::test] async fn create_note_keeps_the_persisted_object_when_delivery_fails() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; let state = test_app_state(test_config()).expect("build app state"); - add_delivery_target(&state, &inbox); + store_follower(&state, "https://remote.example/users/bob", &inbox); let result = state.create_note(create_note_input()).await; @@ -117,3 +135,37 @@ async fn create_note_keeps_the_persisted_object_when_delivery_fails() { ); inbox_server.abort(); } + +#[tokio::test] +async fn create_note_resolves_persisted_followers_after_restart() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let path = temporary_database_path("feder-create-note-recipients-test"); + let mut first_config = test_config(); + first_config.storage = StorageConfig::Sqlite { path: path.clone() }; + { + let state = test_app_state(first_config).expect("build app state"); + store_follower(&state, "https://remote.example/users/bob", &inbox); + } + + let mut second_config = test_config(); + second_config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(second_config).expect("reopen app state"); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + + state + .create_note(create_note_input()) + .await + .expect("create note after restart"); + + requests.recv().await.expect("receive Create request"); + inbox_server.abort(); + let _ = std::fs::remove_file(path); +} From ad07e50ae3be264e10528fc5400b3ab267762469 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 01:18:20 +0900 Subject: [PATCH 27/42] Address Note delivery recipients Populate Note addressing and metadata, mirror to and cc onto Create, and use one SendActivity action with typed inbox or follower recipients. Resolve follower recipients into concrete inbox deliveries at runtime. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 86 ++++++++++++++----- crates/feder-runtime-server/src/operation.rs | 39 ++++----- crates/feder-runtime-server/src/send.rs | 25 +++--- .../tests/cases/operation.rs | 23 ++++- .../feder-runtime-server/tests/cases/send.rs | 24 ++++-- crates/feder-vocab/src/lib.rs | 10 ++- .../tests/activitypub_serialization.rs | 6 +- 7 files changed, 153 insertions(+), 60 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index bfa9978..4be7dd7 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -163,7 +163,7 @@ impl FederState { actions.push(Action::SendActivity(SendActivity { activity: Activity::Accept(accept), - inbox, + recipients: Recipients::Inbox(inbox), })); } @@ -214,14 +214,20 @@ impl FederState { let mut note = vocab::Note::new(input.note_id); note.attributed_to = Some(actor.clone()); + note.to = input.to; + note.cc = input.cc; note.content = Some(input.content); + note.media_type = input.media_type; note.published = input.published; + note.url = input.url; - let create = vocab::Create::new( + let mut create = vocab::Create::new( input.create_id, actor, vocab::Reference::object(note.clone()), ); + create.to = note.to.clone(); + create.cc = note.cc.clone(); let object = Object::Note(note); self.objects.push(object.clone()); @@ -229,9 +235,9 @@ impl FederState { Vec::from([ Action::StoreObject(StoreObject { object }), - Action::SendActivityToFollowers(SendActivityToFollowers { + Action::SendActivity(SendActivity { activity: Activity::CreateNote(create), - actor: self.local_actor.id.clone(), + recipients: Recipients::Followers(self.local_actor.id.clone()), }), ]) } @@ -291,8 +297,12 @@ pub struct UserCreateNote { pub note_id: vocab::Iri, pub create_id: vocab::Iri, pub actor: vocab::Reference, + pub to: vocab::References, + pub cc: vocab::References, pub content: String, + pub media_type: Option, pub published: Option, + pub url: Option, } impl Input { @@ -319,7 +329,6 @@ pub enum Action { RemoveFollower(RemoveFollower), StoreObject(StoreObject), SendActivity(SendActivity), - SendActivityToFollowers(SendActivityToFollowers), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -344,15 +353,15 @@ pub struct StoreObject { #[derive(Clone, Debug, Eq, PartialEq)] pub struct SendActivity { pub activity: Activity, - pub inbox: vocab::Iri, + pub recipients: Recipients, } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SendActivityToFollowers { - /// Activity to deliver to the actor's current followers. - pub activity: Activity, - /// Local actor whose followers should receive the activity. - pub actor: vocab::Iri, +pub enum Recipients { + /// Deliver directly to this inbox. + Inbox(vocab::Iri), + /// Deliver to the current followers of this local actor. + Followers(vocab::Iri), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -467,7 +476,10 @@ mod tests { let Action::SendActivity(send) = &result.actions[1] else { panic!("expected SendActivity action"); }; - assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + assert_eq!( + send.recipients, + Recipients::Inbox(iri("https://remote.example/users/bob/inbox")) + ); let Activity::Accept(accept) = &send.activity else { panic!("expected Accept activity"); @@ -529,7 +541,10 @@ mod tests { let Action::SendActivity(send) = &second_result.actions[1] else { panic!("expected SendActivity action"); }; - assert_eq!(send.inbox, iri("https://remote.example/inboxes/bob")); + assert_eq!( + send.recipients, + Recipients::Inbox(iri("https://remote.example/inboxes/bob")) + ); let Activity::Accept(accept) = &send.activity else { panic!("expected Accept activity"); @@ -672,8 +687,12 @@ mod tests { 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")), + to: vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), + cc: vocab::References::one(iri("https://example.com/users/alice/followers")), content: "Hello from Feder.".to_string(), + media_type: Some("text/html".to_string()), published: Some("2026-06-10T00:00:00Z".to_string()), + url: Some(iri("https://example.com/@alice/1")), }; let mut core = core(); @@ -690,7 +709,17 @@ mod tests { Some(vocab::Reference::id(iri("https://example.com/users/alice"))) ); assert_eq!(note.content, Some("Hello from Feder.".to_string())); + assert_eq!( + note.to, + vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")) + ); + assert_eq!( + note.cc, + vocab::References::one(iri("https://example.com/users/alice/followers")) + ); + assert_eq!(note.media_type.as_deref(), Some("text/html")); assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); + assert_eq!(note.url, Some(iri("https://example.com/@alice/1"))); match &core.state().activities()[0] { Activity::CreateNote(create) => { @@ -699,6 +728,8 @@ mod tests { create.actor, vocab::Reference::id(iri("https://example.com/users/alice")) ); + assert_eq!(create.to, note.to); + assert_eq!(create.cc, note.cc); } Activity::Accept(_) => panic!("expected Create activity"), } @@ -709,10 +740,13 @@ mod tests { object: Object::Note(note.clone()), }) ); - let Action::SendActivityToFollowers(send) = &result.actions[1] else { + let Action::SendActivity(send) = &result.actions[1] else { panic!("expected followers delivery action"); }; - assert_eq!(send.actor, iri("https://example.com/users/alice")); + assert_eq!( + send.recipients, + Recipients::Followers(iri("https://example.com/users/alice")) + ); let Activity::CreateNote(create) = &send.activity else { panic!("expected Create activity"); }; @@ -743,8 +777,8 @@ mod tests { panic!("expected Accept delivery action"); }; assert_eq!( - accept_delivery.inbox, - iri("https://remote.example/users/bob/inbox") + accept_delivery.recipients, + Recipients::Inbox(iri("https://remote.example/users/bob/inbox")) ); assert!(matches!(accept_delivery.activity, Activity::Accept(_))); @@ -752,18 +786,22 @@ mod tests { 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")), + to: vocab::References::new(), + cc: vocab::References::new(), content: "Hello from Feder.".to_string(), + media_type: None, published: Some("2026-06-10T00:00:00Z".to_string()), + url: None, })); assert_eq!(create_result.actions.len(), 2); assert!(matches!(create_result.actions[0], Action::StoreObject(_))); - let Action::SendActivityToFollowers(create_delivery) = &create_result.actions[1] else { + let Action::SendActivity(create_delivery) = &create_result.actions[1] else { panic!("expected followers delivery action"); }; assert_eq!( - create_delivery.actor, - iri("https://example.com/users/alice") + create_delivery.recipients, + Recipients::Followers(iri("https://example.com/users/alice")) ); assert!(matches!(create_delivery.activity, Activity::CreateNote(_))); @@ -781,8 +819,12 @@ mod tests { note_id: iri("https://example.com/notes/1"), create_id: iri("https://example.com/activities/create/1"), actor: vocab::Reference::object(supplied_actor), + to: vocab::References::new(), + cc: vocab::References::new(), content: "Hello from Feder.".to_string(), + media_type: None, published: None, + url: None, }; let mut core = core(); @@ -811,8 +853,12 @@ mod tests { 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")), + to: vocab::References::new(), + cc: vocab::References::new(), content: "Hello from elsewhere.".to_string(), + media_type: None, published: Some("2026-06-10T00:00:00Z".to_string()), + url: None, }; let mut core = core(); diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index ee748ed..e803e46 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -15,9 +15,7 @@ use std::collections::HashSet; -use feder_core::{ - Action, HandleResult, Input, SendActivity, SendActivityToFollowers, UserCreateNote, -}; +use feder_core::{Action, HandleResult, Input, Recipients, SendActivity, UserCreateNote}; use crate::{Error, app::AppState, storage::RuntimeStore}; @@ -36,43 +34,44 @@ impl AppState { core.handle(input) }; - let delivery_actions = { + let deliveries = { let mut store = self .store .lock() .map_err(|_| Error::StorageStateUnavailable)?; store.persist_actions(&result.actions)?; - resolve_delivery_actions(&*store, &result.actions)? + resolve_outbound_deliveries(&*store, &result.actions)? }; - self.activity_sender.send_actions(&delivery_actions).await?; + self.activity_sender.send_actions(&deliveries).await?; Ok(result) } } -fn resolve_delivery_actions( +fn resolve_outbound_deliveries( store: &impl RuntimeStore, actions: &[Action], -) -> Result, Error> { +) -> Result, Error> { let mut resolved = Vec::new(); for action in actions { - match action { - Action::SendActivity(_) => resolved.push(action.clone()), - Action::SendActivityToFollowers(SendActivityToFollowers { activity, actor }) => { - let mut seen_inboxes = HashSet::new(); - for recipient in store.list_follower_recipients(actor)? { - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { - resolved.push(Action::SendActivity(SendActivity { - activity: activity.clone(), - inbox, - })); + if let Action::SendActivity(send) = action { + match &send.recipients { + Recipients::Inbox(_) => resolved.push(send.clone()), + Recipients::Followers(actor) => { + let mut seen_inboxes = HashSet::new(); + for recipient in store.list_follower_recipients(actor)? { + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(inbox), + }); + } } } } - _ => {} } } diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index a8fc26f..f588b5b 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -17,7 +17,7 @@ use std::{sync::Arc, time::SystemTime}; use crate::{config::OutboundAddressPolicy, url}; use feder_core::{ - Action, Activity, SendActivity, + Activity, Recipients, SendActivity, http_signatures::{ ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, }, @@ -54,12 +54,11 @@ impl ActivitySender { } /// Attempts every send action and returns the first error encountered. - pub async fn send_actions(&self, actions: &[Action]) -> Result<(), SendError> { + pub async fn send_actions(&self, actions: &[SendActivity]) -> Result<(), SendError> { let mut first_error = None; for action in actions { - if let Action::SendActivity(send) = action - && let Err(error) = self.send(send).await + if let Err(error) = self.send(action).await && first_error.is_none() { first_error = Some(error); @@ -76,20 +75,23 @@ impl ActivitySender { _ => return Err(SendError::UnsupportedActivity), } .map_err(SendError::Serialize)?; - let url = Url::parse(send.inbox.as_str()) - .map_err(|_| SendError::InvalidInbox(send.inbox.to_string()))?; + let Recipients::Inbox(inbox) = &send.recipients else { + return Err(SendError::UnresolvedRecipients); + }; + let url = + Url::parse(inbox.as_str()).map_err(|_| SendError::InvalidInbox(inbox.to_string()))?; if !matches!(url.scheme(), "http" | "https") { - return Err(SendError::InvalidInbox(send.inbox.to_string())); + return Err(SendError::InvalidInbox(inbox.to_string())); } crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { SendError::PrivateInboxAddress { - inbox: send.inbox.to_string(), + inbox: inbox.to_string(), address, } })?; let mut host = url .host() - .ok_or_else(|| SendError::InvalidInbox(send.inbox.to_string()))? + .ok_or_else(|| SendError::InvalidInbox(inbox.to_string()))? .to_string(); if let Some(port) = url.port() { host = format!("{host}:{port}"); @@ -131,7 +133,7 @@ impl ActivitySender { if !response.status().is_success() { return Err(SendError::UnsuccessfulStatus { - inbox: send.inbox.to_string(), + inbox: inbox.to_string(), status: response.status(), }); } @@ -168,4 +170,7 @@ pub enum SendError { #[error("activity type is not supported for sending")] UnsupportedActivity, + + #[error("activity recipients must resolve to an inbox before sending")] + UnresolvedRecipients, } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 6318fc4..cb54fc8 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -14,7 +14,7 @@ // along with this program. If not, see . use axum::http::StatusCode; -use feder_core::{Action, Object, StoreFollower, UserCreateNote}; +use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; use feder_runtime_server::{Error, config::StorageConfig, send::SendError, storage::RuntimeStore}; use feder_vocab::{Actor, Iri, Reference}; @@ -29,8 +29,12 @@ fn create_note_input() -> UserCreateNote { note_id: iri("http://127.0.0.1:3000/users/alice/posts/1"), create_id: iri("http://127.0.0.1:3000/users/alice/activities/create/1"), actor: Reference::id(iri("http://127.0.0.1:3000/users/alice")), + to: feder_vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), + cc: feder_vocab::References::one(iri("http://127.0.0.1:3000/users/alice/followers")), content: "Hello from Feder.".to_string(), + media_type: Some("text/html".to_string()), published: Some("2026-07-21T00:00:00Z".to_string()), + url: Some(iri("http://127.0.0.1:3000/@alice/1")), } } @@ -66,8 +70,9 @@ async fn create_note_persists_and_delivers_the_core_actions() { assert_eq!(result.actions.len(), 2); assert!(matches!(result.actions[0], Action::StoreObject(_))); assert!(matches!( - result.actions[1], - Action::SendActivityToFollowers(_) + &result.actions[1], + Action::SendActivity(send) + if matches!(&send.recipients, Recipients::Followers(_)) )); let stored = state @@ -86,7 +91,19 @@ async fn create_note_persists_and_delivers_the_core_actions() { let activity: serde_json::Value = serde_json::from_slice(&request.body).expect("valid Create activity"); assert_eq!(activity["type"], "Create"); + assert_eq!( + activity["to"], + "https://www.w3.org/ns/activitystreams#Public" + ); + assert_eq!( + activity["cc"], + "http://127.0.0.1:3000/users/alice/followers" + ); assert_eq!(activity["object"]["id"], note.id.as_str()); + assert_eq!(activity["object"]["to"], activity["to"]); + assert_eq!(activity["object"]["cc"], activity["cc"]); + assert_eq!(activity["object"]["mediaType"], "text/html"); + assert_eq!(activity["object"]["url"], "http://127.0.0.1:3000/@alice/1"); inbox_server.abort(); } diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index fdf0750..d874f18 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -15,7 +15,7 @@ use axum::http::StatusCode; use feder_core::{ - Action, Activity, SendActivity, + Activity, Recipients, SendActivity, http_signatures::{ActorKeyPair, sign_draft_cavage}, }; use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; @@ -23,7 +23,7 @@ use feder_vocab::{Create, Note, Reference}; use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; -fn create_note_send_action(inbox: &str) -> Action { +fn create_note_send_action(inbox: &str) -> SendActivity { let actor_id = "https://local.example/users/alice" .parse() .expect("valid actor IRI"); @@ -40,10 +40,10 @@ fn create_note_send_action(inbox: &str) -> Action { Reference::object(note), ); - Action::SendActivity(SendActivity { + SendActivity { activity: Activity::CreateNote(create), - inbox: inbox.parse().expect("valid inbox IRI"), - }) + recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), + } } #[tokio::test] @@ -103,6 +103,20 @@ async fn sends_create_note_action() { inbox_server.abort(); } +#[tokio::test] +async fn rejects_unresolved_follower_recipients() { + let mut action = create_note_send_action("https://remote.example/inbox"); + action.recipients = Recipients::Followers( + "https://local.example/users/alice" + .parse() + .expect("valid actor IRI"), + ); + + let result = test_activity_sender().send_actions(&[action]).await; + + assert!(matches!(result, Err(SendError::UnresolvedRecipients))); +} + #[tokio::test] async fn attempts_later_sends_after_failure() { let (failed_inbox, mut failed_requests, failed_server) = diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 2f6c2a1..a22cb34 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -462,6 +462,10 @@ pub struct Create { pub id: Iri, pub actor: Reference, pub object: Reference, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub to: References, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub cc: References, } impl Create { @@ -477,6 +481,8 @@ impl Create { id, actor, object, + to: References::new(), + cc: References::new(), } } } @@ -625,11 +631,13 @@ mod tests { note.content = Some("Hello, fediverse.".to_string()); note.published = Some("2026-05-29T06:30:00Z".to_string()); - let create = Create::new( + let mut create = Create::new( iri("https://example.com/activities/create/1"), Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); + create.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + create.cc = References::one(iri("https://example.com/users/alice/followers")); assert_eq!(roundtrip(&create), create); } diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 633b9a8..ba1bf3a 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -183,11 +183,13 @@ fn local_note_serializes_as_create_activity() { note.published = Some("2026-06-02T00:00:00Z".to_string()); note.url = Some(iri("https://example.com/@alice/1")); - let create = Create::new( + let mut create = Create::new( iri("https://example.com/activities/create/1"), Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); + create.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + create.cc = References::one(iri("https://example.com/users/alice/followers")); assert_eq!( serialize_to_value(create), @@ -196,6 +198,8 @@ fn local_note_serializes_as_create_activity() { "type": "Create", "id": "https://example.com/activities/create/1", "actor": "https://example.com/users/alice", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": "https://example.com/users/alice/followers", "object": { "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Note", From 2f37216535ec706b3271c6a0961d8d81c5fb74f3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 15:30:53 +0900 Subject: [PATCH 28/42] Add Follow type and signed sender --- crates/feder-core/src/lib.rs | 5 ++++- crates/feder-runtime-server/src/send.rs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 4be7dd7..35ed021 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -369,6 +369,7 @@ pub enum Recipients { pub enum Activity { Accept(vocab::Accept), CreateNote(vocab::Create), + Follow(vocab::Follow), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -731,7 +732,9 @@ mod tests { assert_eq!(create.to, note.to); assert_eq!(create.cc, note.cc); } - Activity::Accept(_) => panic!("expected Create activity"), + Activity::Accept(_) | Activity::Follow(_) => { + panic!("expected Create activity") + } } assert_eq!( diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index f588b5b..851598b 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -72,6 +72,7 @@ impl ActivitySender { let body = match &send.activity { Activity::Accept(activity) => serde_json::to_vec(activity), Activity::CreateNote(activity) => serde_json::to_vec(activity), + Activity::Follow(activity) => serde_json::to_vec(activity), _ => return Err(SendError::UnsupportedActivity), } .map_err(SendError::Serialize)?; From d34c1f8eeeabea2f61e19435a89cb15375f37394 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 15:31:23 +0900 Subject: [PATCH 29/42] Add Follow test Assisted-by: Codex:gpt-5.6-sol --- .../feder-runtime-server/tests/cases/send.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index d874f18..93bf867 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -19,7 +19,7 @@ use feder_core::{ http_signatures::{ActorKeyPair, sign_draft_cavage}, }; use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; -use feder_vocab::{Create, Note, Reference}; +use feder_vocab::{Create, Follow, Note, Reference}; use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; @@ -46,6 +46,29 @@ fn create_note_send_action(inbox: &str) -> SendActivity { } } +fn follow_send_action(inbox: &str) -> SendActivity { + let follow = Follow::new( + "https://local.example/activities/follow-1" + .parse() + .expect("valid activity IRI"), + Reference::id( + "https://local.example/users/alice" + .parse() + .expect("valid actor IRI"), + ), + Reference::id( + "https://remote.example/users/bob" + .parse() + .expect("valid actor IRI"), + ), + ); + + SendActivity { + activity: Activity::Follow(follow), + recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), + } +} + #[tokio::test] async fn sends_create_note_action() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; @@ -103,6 +126,27 @@ async fn sends_create_note_action() { inbox_server.abort(); } +#[tokio::test] +async fn sends_follow_action() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + + test_activity_sender() + .send_actions(&[follow_send_action(&inbox)]) + .await + .expect("send Follow activity"); + + let request = requests.recv().await.expect("receive Follow request"); + assert_eq!(request.uri, "/inbox"); + assert_eq!(request.headers["content-type"], "application/activity+json"); + assert!(request.headers.contains_key("signature")); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Follow"); + assert_eq!(activity["actor"], "https://local.example/users/alice"); + assert_eq!(activity["object"], "https://remote.example/users/bob"); + inbox_server.abort(); +} + #[tokio::test] async fn rejects_unresolved_follower_recipients() { let mut action = create_note_send_action("https://remote.example/inbox"); From d05b50472cdf4ab85fc32af5b023afa0c694248b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 19:03:17 +0900 Subject: [PATCH 30/42] Add sending activities when actor is given --- crates/feder-core/src/lib.rs | 1 + crates/feder-runtime-server/src/operation.rs | 62 ++++++++++++-------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 35ed021..6a48bbe 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -362,6 +362,7 @@ pub enum Recipients { Inbox(vocab::Iri), /// Deliver to the current followers of this local actor. Followers(vocab::Iri), + Actor(vocab::Iri), } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index e803e46..b14a6c3 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -33,47 +33,61 @@ impl AppState { let mut core = self.core.lock().map_err(|_| Error::CoreStateUnavailable)?; core.handle(input) }; - - let deliveries = { + { let mut store = self .store .lock() .map_err(|_| Error::StorageStateUnavailable)?; store.persist_actions(&result.actions)?; - resolve_outbound_deliveries(&*store, &result.actions)? }; + let deliveries = self.resolve_outbound_deliveries(&result.actions).await?; self.activity_sender.send_actions(&deliveries).await?; Ok(result) } -} -fn resolve_outbound_deliveries( - store: &impl RuntimeStore, - actions: &[Action], -) -> Result, Error> { - let mut resolved = Vec::new(); + async fn resolve_outbound_deliveries( + &self, + actions: &[Action], + ) -> Result, Error> { + let mut resolved = Vec::new(); - for action in actions { - if let Action::SendActivity(send) = action { - match &send.recipients { - Recipients::Inbox(_) => resolved.push(send.clone()), - Recipients::Followers(actor) => { - let mut seen_inboxes = HashSet::new(); - for recipient in store.list_follower_recipients(actor)? { - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(inbox), - }); + for action in actions { + if let Action::SendActivity(send) = action { + match &send.recipients { + Recipients::Inbox(_) => resolved.push(send.clone()), + Recipients::Followers(actor_id) => { + let mut seen_inboxes = HashSet::new(); + let recipients = { + let store = self + .store + .lock() + .map_err(|_| Error::StorageStateUnavailable)?; + store.list_follower_recipients(actor_id)? + }; + for recipient in recipients { + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(inbox), + }); + } } } + Recipients::Actor(actor_id) => { + let actor = self.actor_resolver.resolve(actor_id).await?; + + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(actor.inbox), + }) + } } } } - } - Ok(resolved) + Ok(resolved) + } } From 7c021a6b36e05016b427877f928a40fd78b5b50a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 19:34:40 +0900 Subject: [PATCH 31/42] Send activities to to and cc --- crates/feder-core/src/lib.rs | 45 ++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 6a48bbe..9163b05 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -25,6 +25,8 @@ pub use feder_vocab as vocab; #[cfg(feature = "http-signatures")] pub mod http_signatures; +const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; + /// Portable core state and decision logic. #[derive(Debug)] pub struct FederCore { @@ -229,18 +231,47 @@ impl FederState { create.to = note.to.clone(); create.cc = note.cc.clone(); + let recipients = note_recipients(&self.local_actor, ¬e); + let object = Object::Note(note); self.objects.push(object.clone()); self.activities.push(Activity::CreateNote(create.clone())); - Vec::from([ - Action::StoreObject(StoreObject { object }), - Action::SendActivity(SendActivity { - activity: Activity::CreateNote(create), - recipients: Recipients::Followers(self.local_actor.id.clone()), - }), - ]) + let mut actions = Vec::new(); + + actions.push(Action::StoreObject(StoreObject { object })); + + for recipient in recipients { + actions.push(Action::SendActivity(SendActivity { + activity: Activity::CreateNote(create.clone()), + recipients: recipient, + })); + } + + actions + } +} + +fn note_recipients(local_actor: &vocab::Actor, note: &vocab::Note) -> Vec { + let mut recipients = Vec::new(); + + for address in note.to.iter().chain(note.cc.iter()) { + let recipient = if address.as_str() == PUBLIC_COLLECTION { + // Public describes visibility. It cannot receive an activity. + continue; + } else if local_actor.followers.as_ref() == Some(address) { + Recipients::Followers(local_actor.id.clone()) + } else if address == &local_actor.id { + continue; + } else { + Recipients::Actor(address.clone()) + }; + + if !recipients.contains(&recipient) { + recipients.push(recipient); + } } + recipients } fn reference_id(reference: &vocab::Reference) -> Option<&vocab::Iri> From ef1cb71ee4be8e681823dbc605f4eb5210064382 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 19:52:38 +0900 Subject: [PATCH 32/42] Add to and cc delivery test Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 56 ++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 9163b05..0dc1d59 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -446,7 +446,9 @@ mod tests { } fn core() -> FederCore { - FederCore::new(FederConfig::new(actor("https://example.com/users/alice"))) + let mut local_actor = actor("https://example.com/users/alice"); + local_actor.followers = Some(iri("https://example.com/users/alice/followers")); + FederCore::new(FederConfig::new(local_actor)) } fn received_follow(follow: vocab::Follow, id: &str) -> Input { @@ -822,7 +824,7 @@ mod tests { create_id: iri("https://example.com/activities/create/1"), actor: vocab::Reference::id(iri("https://example.com/users/alice")), to: vocab::References::new(), - cc: vocab::References::new(), + cc: vocab::References::one(iri("https://example.com/users/alice/followers")), content: "Hello from Feder.".to_string(), media_type: None, published: Some("2026-06-10T00:00:00Z".to_string()), @@ -865,7 +867,8 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert_eq!(result.actions.len(), 2); + assert_eq!(result.actions.len(), 1); + assert!(matches!(result.actions[0], Action::StoreObject(_))); let Object::Note(note) = &core.state().objects()[0]; assert_eq!( @@ -882,6 +885,53 @@ mod tests { ); } + #[test] + fn user_create_note_emits_one_direct_delivery_for_duplicate_actor_addresses() { + let bob = iri("https://remote.example/users/bob"); + 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")), + to: vocab::References::one(bob.clone()), + cc: vocab::References::one(bob.clone()), + content: "Hello Bob.".to_string(), + media_type: None, + published: None, + url: None, + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 2); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + let Action::SendActivity(send) = &result.actions[1] else { + panic!("expected direct delivery action"); + }; + assert_eq!(send.recipients, Recipients::Actor(bob)); + } + + #[test] + fn user_create_note_does_not_deliver_to_public_collection() { + 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")), + to: vocab::References::one(iri(PUBLIC_COLLECTION)), + cc: vocab::References::new(), + content: "Hello everyone.".to_string(), + media_type: None, + published: None, + url: None, + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 1); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + } + #[test] fn user_create_note_for_non_local_actor_is_ignored() { let input = UserCreateNote { From 44156616c711709d5d80a7148fee26d1867f9670 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 21:41:57 +0900 Subject: [PATCH 33/42] Add public only guard when getting object --- crates/feder-core/src/lib.rs | 2 +- crates/feder-runtime-server/src/object.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 0dc1d59..449e123 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -25,7 +25,7 @@ pub use feder_vocab as vocab; #[cfg(feature = "http-signatures")] pub mod http_signatures; -const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; +pub const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; /// Portable core state and decision logic. #[derive(Debug)] diff --git a/crates/feder-runtime-server/src/object.rs b/crates/feder-runtime-server/src/object.rs index d808ff6..0a282b5 100644 --- a/crates/feder-runtime-server/src/object.rs +++ b/crates/feder-runtime-server/src/object.rs @@ -19,7 +19,7 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; -use feder_core::Object; +use feder_core::{Object, PUBLIC_COLLECTION}; use feder_vocab::Iri; use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; @@ -46,6 +46,14 @@ pub async fn get_object( return Err(StatusCode::NOT_FOUND); }; + if !note + .to + .iter() + .chain(note.cc.iter()) + .any(|recipient| recipient.as_str() == PUBLIC_COLLECTION) + { + return Err(StatusCode::NOT_FOUND); + } if !accepts_activitypub(&headers) { return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } From 0dffa37711d257a2d5368ef45d3f314e475136dd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 21:48:28 +0900 Subject: [PATCH 34/42] Add visibility tests Assisted-by: Codex:gpt-5.6-sol --- .../tests/cases/object.rs | 76 ++++++++++++++++++- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/feder-runtime-server/tests/cases/object.rs b/crates/feder-runtime-server/tests/cases/object.rs index 6d8512a..55729d3 100644 --- a/crates/feder-runtime-server/tests/cases/object.rs +++ b/crates/feder-runtime-server/tests/cases/object.rs @@ -18,7 +18,7 @@ use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; -use feder_core::{Action, Object, StoreObject}; +use feder_core::{Action, Object, PUBLIC_COLLECTION, StoreObject}; use feder_runtime_server::{app::router_with_state, config::StorageConfig, storage::RuntimeStore}; use feder_vocab::{Iri, Note, Reference, References}; use serde_json::Value; @@ -33,7 +33,7 @@ fn iri(value: &str) -> Iri { fn stored_note() -> Note { let mut note = Note::new(iri("http://127.0.0.1:3000/users/alice/posts/1")); note.attributed_to = Some(Reference::id(iri("http://127.0.0.1:3000/users/alice"))); - note.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + note.to = References::one(iri(PUBLIC_COLLECTION)); note.cc = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); note.content = Some("Hello from Feder.".to_string()); note.media_type = Some("text/html".to_string()); @@ -42,19 +42,23 @@ fn stored_note() -> Note { note } -fn router_with_note() -> Router { +fn router_with_stored_note(note: Note) -> Router { let state = test_app_state(test_config()).expect("build app state"); state .store .lock() .expect("store lock") .persist_actions(&[Action::StoreObject(StoreObject { - object: Object::Note(stored_note()), + object: Object::Note(note), })]) .expect("persist note"); router_with_state(state) } +fn router_with_note() -> Router { + router_with_stored_note(stored_note()) +} + async fn get_object(app: Router, uri: &str, accept: Option<&str>) -> axum::response::Response { let mut request = Request::builder().uri(uri); if let Some(accept) = accept { @@ -92,6 +96,70 @@ async fn returns_stored_note_with_activitypub_headers() { assert_eq!(json["mediaType"], "text/html"); } +#[tokio::test] +async fn returns_note_when_public_is_in_cc() { + let mut note = stored_note(); + note.to = References::one(iri("https://remote.example/users/bob")); + note.cc = References::one(iri(PUBLIC_COLLECTION)); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn returns_not_found_for_direct_note() { + let mut note = stored_note(); + note.to = References::one(iri("https://remote.example/users/bob")); + note.cc = References::new(); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn returns_not_found_for_followers_only_note() { + let mut note = stored_note(); + note.to = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); + note.cc = References::new(); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn returns_not_found_for_note_without_audience() { + let mut note = stored_note(); + note.to = References::new(); + note.cc = References::new(); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn rejects_note_request_when_html_is_preferred() { let response = get_object( From 0cbc265a0a822ec17b4e7e91f09ba44c2b9c71b3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 23:17:40 +0900 Subject: [PATCH 35/42] Clear stale shared inbox on actor refresh Assisted-by: Codex:gpt-5.6-sol --- .../src/storage/sqlite.rs | 83 +++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index c00c369..e24949c 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -86,6 +86,7 @@ impl RuntimeStore for SqliteStore { let following = actor_reference_id(&action.following); let inbox = actor_reference_inbox(&action.follower); let shared_inbox = actor_reference_shared_inbox(&action.follower); + let refresh_actor = matches!(&action.follower, Reference::Object(_)); tx.execute( r#" @@ -97,17 +98,21 @@ impl RuntimeStore for SqliteStore { ) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(follower_actor_id, following_actor_id) DO UPDATE SET - inbox_url = COALESCE(excluded.inbox_url, followers.inbox_url), - shared_inbox_url = COALESCE( - excluded.shared_inbox_url, - followers.shared_inbox_url - ) + inbox_url = CASE + WHEN ?5 THEN excluded.inbox_url + ELSE followers.inbox_url + END, + shared_inbox_url = CASE + WHEN ?5 THEN excluded.shared_inbox_url + ELSE followers.shared_inbox_url + END "#, params![ follower.as_str(), following.as_str(), inbox.map(|inbox| inbox.as_str()), shared_inbox.map(|shared_inbox| shared_inbox.as_str()), + refresh_actor, ], )?; } @@ -773,6 +778,74 @@ mod tests { ); } + #[test] + fn persist_actions_clears_removed_shared_inbox_from_embedded_actor() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(feder_vocab::Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + store + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist follower with shared inbox"); + + let mut updated_follower = actor("https://remote.example/users/bob"); + updated_follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); + store + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(updated_follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist follower without shared inbox"); + + let recipients = store + .list_follower_recipients(&iri("https://example.com/users/alice")) + .expect("list follower recipients"); + + assert_eq!( + recipients, + vec![StoredRecipient { + actor_id: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/updated-inbox"), + shared_inbox: None, + }] + ); + } + + #[test] + fn persist_actions_preserves_inboxes_from_id_only_repeated_follow() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(feder_vocab::Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + store + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist embedded follower"); + store + .persist_actions(&[store_follower_action()]) + .expect("persist ID-only repeated follower"); + + let recipients = store + .list_follower_recipients(&iri("https://example.com/users/alice")) + .expect("list follower recipients"); + + assert_eq!( + recipients, + vec![StoredRecipient { + actor_id: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + shared_inbox: Some(iri("https://remote.example/inbox")), + }] + ); + } + #[test] fn list_followers_returns_stored_followers() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); From 5866ea44836e54f1b776b29438936d57573cd42a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 01:28:55 +0900 Subject: [PATCH 36/42] Protect SQLite signing keys on Unix Create SQLite database files with owner-only permissions and restrict existing files before opening them. Document Linux as the currently supported runtime target. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/README.md | 15 +++- .../feder-runtime-server/src/storage/mod.rs | 3 + .../src/storage/sqlite.rs | 90 +++++++++++++++++++ examples/single-user-server/README.md | 14 +-- 4 files changed, 107 insertions(+), 15 deletions(-) diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 17aed6e..a1da47c 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -1,8 +1,7 @@ Feder Runtime Server ==================== -Reusable Axum/Tokio server integration for Feder on standard operating -systems. +Reusable Axum/Tokio server integration for Feder. This crate builds an Axum router from caller-provided runtime configuration. It provides a health check endpoint, WebFinger discovery, and a local actor @@ -17,6 +16,18 @@ ActivityPub JSON signed with the actor's draft-Cavage RSA key. Incoming inbox requests can require verification with the same signature scheme. +Platform support +---------------- + +This runtime currently targets Linux for development and deployment. Other +platforms may compile, but they are not currently supported. + +On Unix targets, file-backed SQLite databases are created with owner-only +permissions, and existing database files are restricted to owner-only +permissions when opened. This protects the actor signing keys stored in the +database. Equivalent Windows ACL hardening is not currently implemented. + + Example ------- diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 673ff89..b3757c6 100644 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-runtime-server/src/storage/mod.rs @@ -39,6 +39,9 @@ pub struct StoredRecipient { #[derive(Debug, thiserror::Error)] pub enum StoreError { + #[error("I/O error")] + Io(#[from] std::io::Error), + #[error("sqlite error")] Sqlite(#[from] rusqlite::Error), diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index e24949c..682e4db 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,6 +15,12 @@ use std::path::Path; +#[cfg(unix)] +use std::{ + fs::OpenOptions, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, +}; + use feder_core::{Action, Object, http_signatures::ActorKeyPair}; use feder_vocab::{Actor, Iri, Note, Reference}; use rusqlite::{Connection, OptionalExtension, params}; @@ -27,10 +33,16 @@ pub struct SqliteStore { impl SqliteStore { pub fn open(path: &Path) -> Result { + #[cfg(unix)] + let database_file = prepare_database_file(path)?; + let store = Self { conn: Connection::open(path)?, }; + #[cfg(unix)] + drop(database_file); + store.init()?; Ok(store) @@ -75,6 +87,23 @@ impl SqliteStore { } } +#[cfg(unix)] +fn prepare_database_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path)?; + + let mut permissions = file.metadata()?.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + + Ok(file) +} + impl RuntimeStore for SqliteStore { fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError> { let tx = self.conn.transaction()?; @@ -537,6 +566,67 @@ mod tests { let _ = std::fs::remove_file(path); } + #[cfg(unix)] + #[test] + fn open_creates_database_with_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = std::env::temp_dir().join(format!( + "feder-database-permissions-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + std::fs::create_dir(&temp_dir).expect("create temporary directory"); + let path = temp_dir.join("store.sqlite3"); + + let store = SqliteStore::open(&path).expect("open SQLite store"); + + let mode = std::fs::metadata(&path) + .expect("read database metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + + drop(store); + std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); + } + + #[cfg(unix)] + #[test] + fn open_restricts_existing_database_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = std::env::temp_dir().join(format!( + "feder-existing-database-permissions-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + std::fs::create_dir(&temp_dir).expect("create temporary directory"); + let path = temp_dir.join("store.sqlite3"); + std::fs::write(&path, []).expect("create permissive database file"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .expect("make database file permissive"); + + let store = SqliteStore::open(&path).expect("open SQLite store"); + + let mode = std::fs::metadata(&path) + .expect("read database metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + + drop(store); + std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); + } + #[test] fn actor_key_pair_roundtrips_for_actor() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md index b0a3297..e5527cc 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -7,24 +7,12 @@ Demo app using `feder-runtime-server` with one hardcoded local actor. Run --- -On Unix shells: +The example currently targets Linux: ~~~~ sh RUST_LOG=info cargo run -p single-user-server ~~~~ -On PowerShell: - -~~~~ powershell -$env:RUST_LOG = "info"; cargo run -p single-user-server -~~~~ - -On cmd.exe: - -~~~~ bat -set RUST_LOG=info && cargo run -p single-user-server -~~~~ - The demo actor is: ~~~~ text From fe967fe0871bedfba9e6035df9a3d1a533c35754 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 01:37:58 +0900 Subject: [PATCH 37/42] Continue delivery after actor resolution failures Resolve direct recipients independently so an unavailable actor does not prevent delivery to recipients that resolve successfully. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/operation.rs | 35 ++++++---- .../tests/cases/operation.rs | 69 ++++++++++++++++++- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index b14a6c3..f2934cc 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -17,13 +17,15 @@ use std::collections::HashSet; use feder_core::{Action, HandleResult, Input, Recipients, SendActivity, UserCreateNote}; -use crate::{Error, app::AppState, storage::RuntimeStore}; +use crate::{Error, actor::ActorResolveError, app::AppState, storage::RuntimeStore}; impl AppState { /// Create, persist, and deliver a Note initiated by the local application. /// - /// Persistence occurs before delivery. If delivery fails, this returns an - /// error while the created Note remains available from the runtime store. + /// Persistence occurs before delivery. Recipient resolution and delivery + /// continue independently after individual failures. If any attempt fails, + /// this returns an error while the created Note remains available from the + /// runtime store and successful deliveries remain completed. pub async fn create_note(&self, input: UserCreateNote) -> Result { self.handle_input(Input::UserCreateNote(input)).await } @@ -40,9 +42,13 @@ impl AppState { .map_err(|_| Error::StorageStateUnavailable)?; store.persist_actions(&result.actions)?; }; - let deliveries = self.resolve_outbound_deliveries(&result.actions).await?; + let (deliveries, actor_resolve_error) = + self.resolve_outbound_deliveries(&result.actions).await?; self.activity_sender.send_actions(&deliveries).await?; + if let Some(error) = actor_resolve_error { + return Err(error.into()); + } Ok(result) } @@ -50,8 +56,9 @@ impl AppState { async fn resolve_outbound_deliveries( &self, actions: &[Action], - ) -> Result, Error> { + ) -> Result<(Vec, Option), Error> { let mut resolved = Vec::new(); + let mut first_actor_resolve_error = None; for action in actions { if let Action::SendActivity(send) = action { @@ -77,17 +84,21 @@ impl AppState { } } Recipients::Actor(actor_id) => { - let actor = self.actor_resolver.resolve(actor_id).await?; - - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }) + match self.actor_resolver.resolve(actor_id).await { + Ok(actor) => resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(actor.inbox), + }), + Err(error) if first_actor_resolve_error.is_none() => { + first_actor_resolve_error = Some(error); + } + Err(_) => {} + } } } } } - Ok(resolved) + Ok((resolved, first_actor_resolve_error)) } } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index cb54fc8..87291f5 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -13,10 +13,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use axum::http::StatusCode; +use axum::{ + Json, Router, + http::{StatusCode, header}, + routing::get, +}; use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; -use feder_runtime_server::{Error, config::StorageConfig, send::SendError, storage::RuntimeStore}; +use feder_runtime_server::{ + Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, +}; use feder_vocab::{Actor, Iri, Reference}; +use tokio::task::JoinHandle; use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; @@ -56,6 +63,39 @@ fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, .expect("persist follower"); } +async fn spawn_actor_server(inbox: &str) -> (Iri, Iri, JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind actor server"); + let address = listener.local_addr().expect("actor server address"); + let actor_id = iri(&format!("http://{address}/users/bob")); + let missing_actor_id = iri(&format!("http://{address}/users/missing")); + let actor = Actor::person( + actor_id.clone(), + iri(inbox), + iri(&format!("http://{address}/users/bob/outbox")), + ); + let app = Router::new().route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { + ( + [(header::CONTENT_TYPE, "application/activity+json")], + Json(actor), + ) + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve actor endpoint"); + }); + + (actor_id, missing_actor_id, task) +} + #[tokio::test] async fn create_note_persists_and_delivers_the_core_actions() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; @@ -127,6 +167,31 @@ async fn create_note_delivers_to_each_persisted_follower() { carol_server.abort(); } +#[tokio::test] +async fn create_note_delivers_to_resolvable_actor_when_another_actor_cannot_be_resolved() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let (actor_id, missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; + let state = test_app_state(test_config()).expect("build app state"); + let mut input = create_note_input(); + input.to = feder_vocab::References::one(missing_actor_id); + input.cc = feder_vocab::References::one(actor_id); + + let result = state.create_note(input).await; + + assert!(matches!( + result, + Err(Error::ActorResolver( + ActorResolveError::UnsuccessfulStatus { .. } + )) + )); + requests + .recv() + .await + .expect("receive delivery for resolvable actor"); + actor_server.abort(); + inbox_server.abort(); +} + #[tokio::test] async fn create_note_keeps_the_persisted_object_when_delivery_fails() { let (inbox, mut requests, inbox_server) = From b13d924ca5d35bd75168eb9a1ea7bcd019e3b79f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 01:47:42 +0900 Subject: [PATCH 38/42] Deduplicate inboxes across outbound recipients Track resolved inboxes across follower, direct actor, and pre-resolved recipients so each inbox receives an activity only once. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/operation.rs | 20 +++++++++----- .../tests/cases/operation.rs | 26 ++++++++++++++++++- .../feder-runtime-server/tests/common/mod.rs | 2 +- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index f2934cc..5dcd24d 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -59,13 +59,17 @@ impl AppState { ) -> Result<(Vec, Option), Error> { let mut resolved = Vec::new(); let mut first_actor_resolve_error = None; + let mut seen_inboxes = HashSet::new(); for action in actions { if let Action::SendActivity(send) = action { match &send.recipients { - Recipients::Inbox(_) => resolved.push(send.clone()), + Recipients::Inbox(inbox) => { + if seen_inboxes.insert(inbox.clone()) { + resolved.push(send.clone()); + } + } Recipients::Followers(actor_id) => { - let mut seen_inboxes = HashSet::new(); let recipients = { let store = self .store @@ -85,10 +89,14 @@ impl AppState { } Recipients::Actor(actor_id) => { match self.actor_resolver.resolve(actor_id).await { - Ok(actor) => resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }), + Ok(actor) => { + if seen_inboxes.insert(actor.inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(actor.inbox), + }); + } + } Err(error) if first_actor_resolve_error.is_none() => { first_actor_resolve_error = Some(error); } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 87291f5..6d7f80c 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -23,7 +23,7 @@ use feder_runtime_server::{ Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, }; use feder_vocab::{Actor, Iri, Reference}; -use tokio::task::JoinHandle; +use tokio::{sync::mpsc::error::TryRecvError, task::JoinHandle}; use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; @@ -167,6 +167,30 @@ async fn create_note_delivers_to_each_persisted_follower() { carol_server.abort(); } +#[tokio::test] +async fn create_note_delivers_once_when_direct_actor_is_also_a_follower() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; + let state = test_app_state(test_config()).expect("build app state"); + store_follower(&state, actor_id.as_str(), &inbox); + let mut input = create_note_input(); + input.to = feder_vocab::References::one( + state + .local_actor + .followers + .clone() + .expect("local actor has followers collection"), + ); + input.cc = feder_vocab::References::one(actor_id); + + state.create_note(input).await.expect("create note"); + + requests.recv().await.expect("receive Create delivery"); + assert!(matches!(requests.try_recv(), Err(TryRecvError::Empty))); + actor_server.abort(); + inbox_server.abort(); +} + #[tokio::test] async fn create_note_delivers_to_resolvable_actor_when_another_actor_cannot_be_resolved() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index f837bda..399fc04 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -43,7 +43,7 @@ pub struct RecordedRequest { pub async fn spawn_inbox_server( response_status: StatusCode, ) -> (String, mpsc::Receiver, JoinHandle<()>) { - let (sender, receiver) = mpsc::channel(1); + let (sender, receiver) = mpsc::channel(2); let app = Router::new().route( "/inbox", post(move |headers: HeaderMap, uri: Uri, body: Bytes| { From 61449c03ed91837fa581a9e5be454571826327ae Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 02:35:22 +0900 Subject: [PATCH 39/42] Avoid duplicate delivery to direct followers Expand follower recipients first and track their actor IDs so directly addressed followers are not also sent to their personal inboxes. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/operation.rs | 83 +++++++++++-------- .../tests/cases/operation.rs | 53 +++++++++--- 2 files changed, 92 insertions(+), 44 deletions(-) diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index 5dcd24d..59c6471 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -59,49 +59,66 @@ impl AppState { ) -> Result<(Vec, Option), Error> { let mut resolved = Vec::new(); let mut first_actor_resolve_error = None; + let mut covered_actor_ids = HashSet::new(); let mut seen_inboxes = HashSet::new(); + // Expand followers first so direct recipients already covered by + // follower delivery are not also sent to their personal inbox. for action in actions { - if let Action::SendActivity(send) = action { - match &send.recipients { - Recipients::Inbox(inbox) => { - if seen_inboxes.insert(inbox.clone()) { - resolved.push(send.clone()); - } + let Action::SendActivity(send) = action else { + continue; + }; + let Recipients::Followers(actor_id) = &send.recipients else { + continue; + }; + let recipients = { + let store = self + .store + .lock() + .map_err(|_| Error::StorageStateUnavailable)?; + store.list_follower_recipients(actor_id)? + }; + for recipient in recipients { + covered_actor_ids.insert(recipient.actor_id); + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(inbox), + }); + } + } + } + + for action in actions { + let Action::SendActivity(send) = action else { + continue; + }; + match &send.recipients { + Recipients::Inbox(inbox) => { + if seen_inboxes.insert(inbox.clone()) { + resolved.push(send.clone()); } - Recipients::Followers(actor_id) => { - let recipients = { - let store = self - .store - .lock() - .map_err(|_| Error::StorageStateUnavailable)?; - store.list_follower_recipients(actor_id)? - }; - for recipient in recipients { - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { + } + Recipients::Followers(_) => {} + Recipients::Actor(actor_id) => { + if covered_actor_ids.contains(actor_id) { + continue; + } + match self.actor_resolver.resolve(actor_id).await { + Ok(actor) => { + covered_actor_ids.insert(actor_id.clone()); + if seen_inboxes.insert(actor.inbox.clone()) { resolved.push(SendActivity { activity: send.activity.clone(), - recipients: Recipients::Inbox(inbox), + recipients: Recipients::Inbox(actor.inbox), }); } } - } - Recipients::Actor(actor_id) => { - match self.actor_resolver.resolve(actor_id).await { - Ok(actor) => { - if seen_inboxes.insert(actor.inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }); - } - } - Err(error) if first_actor_resolve_error.is_none() => { - first_actor_resolve_error = Some(error); - } - Err(_) => {} + Err(error) if first_actor_resolve_error.is_none() => { + first_actor_resolve_error = Some(error); } + Err(_) => {} } } } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 6d7f80c..ca25836 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -22,7 +22,7 @@ use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; use feder_runtime_server::{ Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, }; -use feder_vocab::{Actor, Iri, Reference}; +use feder_vocab::{Actor, Endpoints, Iri, Reference}; use tokio::{sync::mpsc::error::TryRecvError, task::JoinHandle}; use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; @@ -46,12 +46,24 @@ fn create_note_input() -> UserCreateNote { } fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, inbox: &str) { + store_follower_with_shared_inbox(state, remote_actor_id, inbox, None); +} + +fn store_follower_with_shared_inbox( + state: &feder_runtime_server::AppState, + remote_actor_id: &str, + inbox: &str, + shared_inbox: Option<&str>, +) { let remote_actor_id = iri(remote_actor_id); - let remote_actor = Actor::person( + let mut remote_actor = Actor::person( remote_actor_id.clone(), iri(inbox), iri(&format!("{remote_actor_id}/outbox")), ); + remote_actor.endpoints = shared_inbox.map(|shared_inbox| Endpoints { + shared_inbox: Some(iri(shared_inbox)), + }); state .store .lock() @@ -168,27 +180,46 @@ async fn create_note_delivers_to_each_persisted_follower() { } #[tokio::test] -async fn create_note_delivers_once_when_direct_actor_is_also_a_follower() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; +async fn create_note_delivers_once_to_shared_inbox_when_direct_actor_is_also_a_follower() { + let (personal_inbox, mut personal_requests, personal_inbox_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let (shared_inbox, mut shared_requests, shared_inbox_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&personal_inbox).await; let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, actor_id.as_str(), &inbox); + store_follower_with_shared_inbox( + &state, + actor_id.as_str(), + &personal_inbox, + Some(&shared_inbox), + ); let mut input = create_note_input(); - input.to = feder_vocab::References::one( + input.to = feder_vocab::References::one(actor_id); + input.cc = feder_vocab::References::one( state .local_actor .followers .clone() .expect("local actor has followers collection"), ); - input.cc = feder_vocab::References::one(actor_id); state.create_note(input).await.expect("create note"); - requests.recv().await.expect("receive Create delivery"); - assert!(matches!(requests.try_recv(), Err(TryRecvError::Empty))); + shared_requests + .recv() + .await + .expect("receive shared inbox delivery"); + assert!(matches!( + shared_requests.try_recv(), + Err(TryRecvError::Empty) + )); + assert!(matches!( + personal_requests.try_recv(), + Err(TryRecvError::Empty) + )); actor_server.abort(); - inbox_server.abort(); + shared_inbox_server.abort(); + personal_inbox_server.abort(); } #[tokio::test] From 5180800c7693560871934117d164ed737cc2a41e Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 02:40:28 +0900 Subject: [PATCH 40/42] Parse inbox Content-Type as a media type Assisted-by: Codex:GPT-5.6-sol --- crates/feder-runtime-server/src/inbox.rs | 8 ++- .../feder-runtime-server/tests/cases/inbox.rs | 67 ++++++++++++++----- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 252c446..c46434f 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -30,6 +30,7 @@ use feder_core::{ http_signatures::{create_sha256_digest_header, verify_draft_cavage}, }; use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; +use mime::Mime; use serde_json::{Value, from_slice, from_value}; use crate::config::InboxAuthPolicy; @@ -38,6 +39,7 @@ use crate::{Error, app::AppState}; const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); +const ACTIVITYPUB_CONTENT_TYPES: &[&str] = &["application/activity+json", "application/ld+json"]; pub struct InboxRequest { pub username: String, @@ -316,10 +318,10 @@ pub async fn inbox( let content_type = headers .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) - .unwrap_or(""); + .and_then(|value| value.parse::().ok()); - if !content_type.starts_with("application/activity+json") - && !content_type.starts_with("application/ld+json") + if !content_type + .is_some_and(|media_type| ACTIVITYPUB_CONTENT_TYPES.contains(&media_type.essence_str())) { return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); } diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 3952ef6..c409a80 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -624,25 +624,58 @@ async fn rejects_unknown_inbox_actor() { #[tokio::test] async fn rejects_unsupported_content_type() { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", + for content_type in [ "application/json", - follow_body(), - ) - .await; + "application/activity+jsonp", + "not a media type", + ] { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + content_type, + follow_body(), + ) + .await; - assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); + assert_eq!( + response.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "Content-Type: {content_type}" + ); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } +} + +#[tokio::test] +async fn accepts_case_insensitive_content_type_with_parameters() { + for content_type in [ + "Application/Activity+JSON; Charset=UTF-8", + "Application/LD+JSON; Profile=\"https://www.w3.org/ns/activitystreams\"", + ] { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + content_type, + "{not json", + ) + .await; + + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "Content-Type: {content_type}" + ); + } } #[tokio::test] From cea6b890319bfb842cfe4c7dfaaa56f08d5695c3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 14:20:01 +0900 Subject: [PATCH 41/42] Bind inbox signatures to the configured host Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/inbox.rs | 95 ++++++++++++++++++- .../feder-runtime-server/tests/cases/inbox.rs | 50 +++++++++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index c46434f..0594c0d 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -21,7 +21,11 @@ use std::{ use axum::{ body::Bytes, extract::{Path, State}, - http::{HeaderMap, Method, StatusCode, Uri, header::CONTENT_TYPE}, + http::{ + HeaderMap, Method, StatusCode, Uri, + header::{CONTENT_TYPE, HOST}, + uri::Authority, + }, response::{IntoResponse, Response}, }; @@ -116,6 +120,11 @@ async fn verify_signed_request( return Err(StatusCode::UNAUTHORIZED); } + verify_request_host( + &req.headers, + &app_state.handle_host, + app_state.local_actor.inbox.scheme_str(), + )?; verify_request_date(&req.headers)?; verify_request_digest(&req.headers, &req.body)?; @@ -166,6 +175,55 @@ async fn verify_signed_request( Ok(actor) } +fn verify_request_host( + headers: &HeaderMap, + expected_host: &str, + inbox_scheme: &str, +) -> Result<(), StatusCode> { + let signed_host = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::UNAUTHORIZED)?; + let expected_host = expected_host + .parse::() + .ok() + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let default_port = if inbox_scheme.eq_ignore_ascii_case("http") { + Some(80) + } else if inbox_scheme.eq_ignore_ascii_case("https") { + Some(443) + } else { + None + }; + let signed_port = effective_port(&signed_host, default_port).ok_or(StatusCode::UNAUTHORIZED)?; + let expected_port = + effective_port(&expected_host, default_port).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + if signed_host + .host() + .eq_ignore_ascii_case(expected_host.host()) + && signed_port == expected_port + { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +fn effective_port(authority: &Authority, default_port: Option) -> Option> { + let suffix = authority.as_str().get(authority.host().len()..)?; + if suffix.is_empty() { + Some(default_port) + } else if suffix.starts_with(':') { + authority.port_u16().map(Some) + } else { + None + } +} + fn activity_actor_id(value: &Value) -> Option { let actor = value.get("actor")?; let actor_id = actor @@ -399,3 +457,38 @@ pub async fn inbox( Ok(StatusCode::ACCEPTED.into_response()) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers_with_host(host: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(HOST, HeaderValue::from_static(host)); + headers + } + + #[test] + fn request_host_accepts_case_and_default_port_equivalence() { + assert_eq!( + verify_request_host( + &headers_with_host("EXAMPLE.COM:443"), + "example.com", + "https" + ), + Ok(()) + ); + } + + #[test] + fn request_host_rejects_wrong_or_invalid_authorities() { + for host in ["other.example", "example.com:8443", "example.com:99999"] { + assert_eq!( + verify_request_host(&headers_with_host(host), "example.com", "https"), + Err(StatusCode::UNAUTHORIZED), + "Host: {host}" + ); + } + } +} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index c409a80..55fe467 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -205,10 +205,28 @@ async fn post_signed_inbox( key_id: &str, signed_body: &[u8], delivered_body: impl Into, +) -> axum::response::Response { + post_signed_inbox_with_host( + app, + uri, + key_id, + signed_body, + delivered_body, + "127.0.0.1:3000", + ) + .await +} + +async fn post_signed_inbox_with_host( + app: Router, + uri: &str, + key_id: &str, + signed_body: &[u8], + delivered_body: impl Into, + host: &str, ) -> axum::response::Response { let date = httpdate::fmt_http_date(std::time::SystemTime::now()); let digest = create_sha256_digest_header(signed_body); - let host = "local.example"; let headers = [ ("content-type", "application/activity+json"), ("date", date.as_str()), @@ -361,6 +379,36 @@ async fn verifies_signed_id_only_follow() { actor_server.abort(); } +#[tokio::test] +async fn signed_follow_rejects_host_for_another_authority() { + let (actor_id, _requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body(&actor_id); + let response = post_signed_inbox_with_host( + router_with_state(state.clone()), + "/users/alice/inbox", + &format!("{actor_id}#main-key"), + &body, + body.clone(), + "other.example", + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + actor_server.abort(); +} + #[tokio::test] async fn verifies_signed_follow_with_independent_key_id() { let (actor_id, key_id, mut requests, actor_server) = From 43340eeabf429554df15d11636f448f3272944c6 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 14:46:18 +0900 Subject: [PATCH 42/42] Return 202 Accepted before Follow-specific deserialization Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/inbox.rs | 8 +++++ .../feder-runtime-server/tests/cases/inbox.rs | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 0594c0d..47fcc07 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -420,6 +420,14 @@ pub async fn inbox( Input::received_follow(follow, accept_id) } Some("Undo") => { + if value + .get("object") + .and_then(|object| object.get("type")) + .and_then(Value::as_str) + != Some("Follow") + { + return Ok(StatusCode::ACCEPTED.into_response()); + } let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; let Reference::Object(follow) = &undo.object else { return Ok(StatusCode::ACCEPTED.into_response()); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 55fe467..4192167 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -783,6 +783,42 @@ async fn ignores_unsupported_activity_without_mutating_core() { ); } +#[tokio::test] +async fn ignores_undo_of_unsupported_activity_without_mutating_core() { + let state = test_app_state(test_config()).expect("build app state"); + let body = serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Undo", + "id": "https://remote.example/activities/undo-like-1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Like", + "id": "https://remote.example/activities/like-1", + "actor": "https://remote.example/users/bob", + "object": "http://127.0.0.1:3000/users/alice/notes/1" + } + })) + .expect("serialize Undo Like"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + #[tokio::test] async fn rejects_oversized_inbox_body() { let response = post_inbox(