diff --git a/Cargo.lock b/Cargo.lock index 9a9f225..3776a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,35 @@ 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 = "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" @@ -69,12 +98,33 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" 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" @@ -94,6 +144,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -103,6 +155,131 @@ 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 0.3.0", + "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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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" +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 = [ + "block-buffer", + "const-oid", + "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" @@ -119,7 +296,11 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" name = "feder-core" version = "0.1.0" dependencies = [ + "base64", "feder-vocab", + "rand_chacha", + "rsa", + "zeroize", ] [[package]] @@ -129,13 +310,20 @@ dependencies = [ "axum", "feder-core", "feder-vocab", + "httpdate", + "ipnet", + "iri-string", + "mime", "percent-encoding", + "rand_core 0.6.4", + "reqwest", "rusqlite", "serde", "serde_json", "thiserror", "tokio", "tower", + "url", ] [[package]] @@ -168,6 +356,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" @@ -201,6 +395,43 @@ 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 = "getrandom" +version = "0.2.17" +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]] name = "hashbrown" version = "0.16.1" @@ -291,6 +522,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]] @@ -299,15 +546,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" @@ -323,6 +687,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" @@ -330,6 +753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -338,6 +762,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 +772,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" @@ -356,12 +789,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" @@ -397,7 +842,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -406,33 +851,132 @@ 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]] -name = "once_cell" -version = "1.21.4" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] [[package]] -name = "pin-project-lite" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" 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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -442,6 +986,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" @@ -451,6 +1052,67 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha", + "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]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "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]] name = "regex-automata" version = "0.4.14" @@ -468,6 +1130,76 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "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 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -493,6 +1225,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" @@ -505,6 +1327,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" @@ -571,6 +1440,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" @@ -586,6 +1466,32 @@ 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 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" @@ -616,7 +1522,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[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]] @@ -631,6 +1553,18 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -647,6 +1581,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" @@ -677,18 +1625,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]] @@ -702,6 +1676,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" @@ -718,6 +1702,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" @@ -792,12 +1794,48 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" 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" @@ -810,6 +1848,31 @@ 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 = "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" @@ -829,6 +1892,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" @@ -861,12 +1934,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" @@ -876,6 +1996,179 @@ 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" +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 = "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/Cargo.toml b/Cargo.toml index 3e2de24..492eb2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,13 +18,18 @@ 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", "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"]} +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..ab5bb5b 100644 --- a/crates/feder-core/Cargo.toml +++ b/crates/feder-core/Cargo.toml @@ -8,8 +8,17 @@ license.workspace = true homepage.workspace = true repository.workspace = true +[features] +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 } + +[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..d06d56f --- /dev/null +++ b/crates/feder-core/src/http_signatures.rs @@ -0,0 +1,378 @@ +//! Draft-Cavage HTTP Signature primitives. + +use alloc::{format, string::String, vec::Vec}; +use core::fmt; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs1v15::{Signature, SigningKey, VerifyingKey}, + pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, + rand_core::CryptoRngCore, + sha2::{Digest, Sha256}, + signature::{SignatureEncoding, Signer, Verifier}, +}; +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, + }) +} + +/// 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) + )) +} + +/// 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, + 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 {} + +/// 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; + + use rand_chacha::ChaCha20Rng; + use rsa::rand_core::SeedableRng; + use rsa::traits::PublicKeyParts; + use rsa::{ + pkcs1v15::{Signature, VerifyingKey}, + signature::Verifier, + }; + + 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())); + } + + #[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"); + } + + #[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() + .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-core/src/lib.rs b/crates/feder-core/src/lib.rs index 3e59dfa..449e123 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -22,6 +22,11 @@ use alloc::{string::String, vec::Vec}; pub use feder_vocab as vocab; +#[cfg(feature = "http-signatures")] +pub mod http_signatures; + +pub const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; + /// Portable core state and decision logic. #[derive(Debug)] pub struct FederCore { @@ -52,6 +57,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) @@ -78,7 +87,6 @@ impl FederConfig { pub struct FederState { local_actor: vocab::Actor, followers: Vec, - delivery_targets: Vec, objects: Vec, activities: Vec, } @@ -89,7 +97,6 @@ impl FederState { Self { local_actor: config.local_actor, followers: Vec::new(), - delivery_targets: Vec::new(), objects: Vec::new(), activities: Vec::new(), } @@ -105,15 +112,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 @@ -146,46 +144,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( @@ -196,13 +165,44 @@ impl FederState { actions.push(Action::SendActivity(SendActivity { activity: Activity::Accept(accept), - inbox, + recipients: Recipients::Inbox(inbox), })); } 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); + + 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(); @@ -216,32 +216,64 @@ 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 recipients = note_recipients(&self.local_actor, ¬e); let object = Object::Note(note); self.objects.push(object.clone()); self.activities.push(Activity::CreateNote(create.clone())); - let mut actions = Vec::from([Action::StoreObject(StoreObject { object })]); + let mut actions = Vec::new(); + + actions.push(Action::StoreObject(StoreObject { object })); - actions.extend(self.delivery_targets.iter().map(|target| { - Action::SendActivity(SendActivity { + for recipient in recipients { + actions.push(Action::SendActivity(SendActivity { activity: Activity::CreateNote(create.clone()), - inbox: target.inbox.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> where T: HasId, @@ -267,6 +299,7 @@ impl HasId for vocab::Actor { #[non_exhaustive] pub enum Input { ReceivedFollow(ReceivedFollow), + ReceivedUndoFollow(ReceivedUndoFollow), UserCreateNote(UserCreateNote), } @@ -280,6 +313,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, @@ -289,14 +328,22 @@ 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 { 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)] @@ -305,22 +352,12 @@ 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), - StoreDeliveryTarget(StoreDeliveryTarget), + RemoveFollower(RemoveFollower), StoreObject(StoreObject), SendActivity(SendActivity), } @@ -332,8 +369,11 @@ pub struct StoreFollower { } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreDeliveryTarget { - pub target: DeliveryTarget, +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)] @@ -344,7 +384,16 @@ 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 enum Recipients { + /// Deliver directly to this inbox. + Inbox(vocab::Iri), + /// Deliver to the current followers of this local actor. + Followers(vocab::Iri), + Actor(vocab::Iri), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -352,6 +401,7 @@ pub struct SendActivity { pub enum Activity { Accept(vocab::Accept), CreateNote(vocab::Create), + Follow(vocab::Follow), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -396,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 { @@ -406,6 +458,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(); @@ -415,7 +475,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()); } @@ -434,7 +493,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 { @@ -442,13 +501,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 { @@ -456,20 +508,13 @@ 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")); + assert_eq!( + send.recipients, + Recipients::Inbox(iri("https://remote.example/users/bob/inbox")) + ); let Activity::Accept(accept) = &send.activity else { panic!("expected Accept activity"); @@ -489,7 +534,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"), @@ -514,22 +559,27 @@ 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")), }) ); 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"); @@ -543,17 +593,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"), @@ -580,7 +623,6 @@ mod tests { following: iri("https://example.com/users/alice"), }] ); - assert!(core.state().delivery_targets().is_empty()); } #[test] @@ -599,23 +641,99 @@ mod tests { assert!(result.is_empty()); assert!(core.state().followers().is_empty()); - assert!(core.state().delivery_targets().is_empty()); } #[test] - fn user_create_note_records_created_object_and_emits_store_action() { + fn received_undo_follow_removes_follower() { + 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()); + } + + #[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); + } + + #[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_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"), 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(); 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); @@ -626,7 +744,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) => { @@ -635,8 +763,12 @@ 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(_) | Activity::Follow(_) => { + panic!("expected Create activity") } - Activity::Accept(_) => panic!("expected Create activity"), } assert_eq!( @@ -645,105 +777,23 @@ 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"); + panic!("expected followers delivery action"); }; - assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); - + assert_eq!( + send.recipients, + Recipients::Followers(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(); @@ -758,18 +808,14 @@ 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!( - 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(_))); @@ -777,23 +823,26 @@ 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::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()), + url: None, })); 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"); + panic!("expected followers delivery action"); }; assert_eq!( - create_delivery.inbox, - iri("https://remote.example/users/bob/inbox") + create_delivery.recipients, + Recipients::Followers(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); } @@ -807,14 +856,19 @@ 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(); let result = core.handle(Input::UserCreateNote(input)); assert_eq!(result.actions.len(), 1); + assert!(matches!(result.actions[0], Action::StoreObject(_))); let Object::Note(note) = &core.state().objects()[0]; assert_eq!( @@ -831,14 +885,65 @@ 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 { 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-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/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 7981fea..06a4aaf 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -9,18 +9,26 @@ homepage.workspace = true repository.workspace = true [dependencies] -feder-core.workspace = true +feder-core = { workspace = true, features = ["http-signatures"] } feder-vocab.workspace = true +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 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 -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..a1da47c 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -1,18 +1,31 @@ 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 -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 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. +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. + + +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/actor.rs b/crates/feder-runtime-server/src/actor.rs index a57ea15..c74fa56 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -16,87 +16,246 @@ 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}; +use reqwest::{ + Client, StatusCode as HttpStatusCode, Url, + header::{ACCEPT, CONTENT_TYPE}, +}; +use serde::Deserialize; + +use crate::{app::AppState, config::OutboundAddressPolicy, negotiation::accepts_activitypub, 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, Path(username): Path, + headers: HeaderMap, ) -> Result { if username != app_state.username { return Err(StatusCode::NOT_FOUND); } + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } 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()) } -#[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"); +/// Resolves remote ActivityPub actors for runtime protocol handling. +#[derive(Clone, Debug)] +pub struct ActorResolver { + client: Client, + address_policy: OutboundAddressPolicy, +} - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); +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, + }) + } - 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"); + 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(()) } - #[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"), - ) + pub async fn resolve(&self, actor_id: &Iri) -> Result { + 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::InvalidResourceId( + resource_id.to_string(), + )); + } + url::validate_literal_host(&url, self.address_policy).map_err(|address| { + ActorResolveError::PrivateResourceAddress { + resource: resource_id.to_string(), + address, + } + })?; + + let mut response = self + .client + .get(url) + .header(ACCEPT, ACTIVITYPUB_ACCEPT) + .send() .await - .expect("response"); + .map_err(ActorResolveError::Request)?; + if !response.status().is_success() { + return Err(ActorResolveError::UnsuccessfulStatus { + resource: resource_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); + } - assert_eq!(response.status(), StatusCode::NOT_FOUND); + Ok(body) } } + +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, + followers: Option, + #[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, + followers: self.followers, + 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 ActivityPub resource ID: {0}")] + InvalidResourceId(String), + + #[error("remote ActivityPub resource {resource} uses non-public address {address}")] + PrivateResourceAddress { + resource: String, + address: std::net::IpAddr, + }, + + #[error("failed to fetch remote ActivityPub resource")] + Request(#[source] reqwest::Error), + + #[error("fetching remote ActivityPub resource {resource} returned {status}")] + UnsuccessfulStatus { + resource: 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 }, + + #[error("remote ActivityPub document does not contain key {0}")] + KeyNotFound(String), +} diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 67d8181..db6b22c 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -16,19 +16,31 @@ use std::sync::{Arc, Mutex}; use crate::Error; +use crate::actor::ActorResolver; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; -use crate::storage::SqliteStore; +use crate::followers::followers; +use crate::object::get_object; +use crate::send::ActivitySender; +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_vocab::Actor; +use feder_core::{ + FederConfig, FederCore, + http_signatures::{ActorKeyPair, generate_actor_key_pair}, +}; +use feder_vocab::{Actor, CryptographicKey, Reference}; +use iri_string::types::IriFragmentStr; +use rand_core::OsRng; #[derive(Clone)] 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, pub handle_host: String, @@ -40,16 +52,48 @@ 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 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 + } + }; + 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.clone(), + actor.id.clone(), + actor_key_pair.public_key_pem().to_string(), + ))); + 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(), + config.outbound_address_policy, + )?; Ok(Self { 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, handle_host: config.handle_host, @@ -61,43 +105,21 @@ 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}/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)) + .with_state(state) } 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..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,25 +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, } - -#[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/error.rs b/crates/feder-runtime-server/src/error.rs index a031fa3..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), @@ -23,4 +29,13 @@ pub enum Error { #[error("storage failed")] Storage(#[from] crate::storage::StoreError), + + #[error("actor key generation failed")] + ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), + + #[error("activity sending failed")] + ActivitySender(#[from] crate::send::SendError), + + #[error("actor resolver setup failed")] + ActorResolver(#[from] crate::actor::ActorResolveError), } diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs new file mode 100644 index 0000000..b42ac7e --- /dev/null +++ b/crates/feder-runtime-server/src/followers.rs @@ -0,0 +1,66 @@ +// 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_vocab::OrderedCollection; + +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 Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + 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/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 83d9e14..47fcc07 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -13,20 +13,37 @@ // 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}, - http::{HeaderMap, Method, StatusCode, Uri, header::CONTENT_TYPE}, + http::{ + HeaderMap, Method, StatusCode, Uri, + header::{CONTENT_TYPE, HOST}, + uri::Authority, + }, 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, Undo}; +use mime::Mime; use serde_json::{Value, from_slice, from_value}; -use crate::app::AppState; use crate::config::InboxAuthPolicy; -use crate::storage::RuntimeStore; +use crate::send::SendError; +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, @@ -50,11 +67,299 @@ 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, + activity_actor_id: Option<&Iri>, +) -> Result, StatusCode> { match app_state.inbox_auth_policy { - InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(()), - InboxAuthPolicy::RequireSigned => Err(StatusCode::UNAUTHORIZED), + InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(None), + 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 + .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_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)?; + + let key_id: Iri = signature + .key_id + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let public_key = app_state + .actor_resolver + .resolve_key(&key_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + if public_key.owner != *activity_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)?; + + 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 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 + .as_str() + .or_else(|| actor.get("id").and_then(Value::as_str))?; + 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") + .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( @@ -71,10 +376,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); } @@ -87,331 +392,111 @@ pub async fn inbox( body, }; - verify_inbox_request(&app_state, &req)?; - 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()); - // Unsupported activity types will be ignored - if activity_type != Some("Follow") { - return Ok(StatusCode::ACCEPTED.into_response()); - } - let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; - let input = Input::received_follow(follow, accept_id); - - let result = { - let mut core = app_state - .core - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - core.handle(input) + 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) + } + 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()); + }; + 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()), }; app_state - .store - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .persist_actions(&result.actions) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .handle_input(input) + .await + .map_err(|error| match error { + Error::ActivitySender( + SendError::PrivateInboxAddress { .. } + | SendError::Request(_) + | SendError::UnsuccessfulStatus { .. }, + ) => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + })?; 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; + use super::*; + use axum::http::HeaderValue; - fn activity_json_headers() -> HeaderMap { + fn headers_with_host(host: &'static str) -> HeaderMap { let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/activity+json".parse().unwrap()); + headers.insert(HOST, HeaderValue::from_static(host)); 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); + #[test] + fn request_host_accepts_case_and_default_port_equivalence() { 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() + verify_request_host( + &headers_with_host("EXAMPLE.COM:443"), + "example.com", + "https" + ), + Ok(()) ); } - #[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); + #[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/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 598520f..9fa5339 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -17,10 +17,17 @@ pub mod actor; pub mod app; pub mod config; pub mod error; +pub mod followers; pub mod inbox; +mod negotiation; +pub mod object; +mod operation; +pub mod send; pub mod storage; +mod url; pub mod webfinger; +pub use actor::{ActorResolveError, ActorResolver}; 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/negotiation.rs b/crates/feder-runtime-server/src/negotiation.rs new file mode 100644 index 0000000..0ab2177 --- /dev/null +++ b/crates/feder-runtime-server/src/negotiation.rs @@ -0,0 +1,194 @@ +// 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, +} + +#[derive(Clone, Copy)] +struct Preference { + quality: u16, + order: usize, +} + +pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { + let 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::>(); + + let activitypub = preferred(ACTIVITYPUB_MEDIA_TYPES, &ranges); + let html = preferred(HTML_MEDIA_TYPES, &ranges); + + match (activitypub, html) { + (Some(activitypub), Some(html)) => prefers(activitypub, html), + (Some(_), None) => true, + _ => false, + } +} + +fn preferred(media_types: &[&str], ranges: &[MediaRange]) -> Option { + ranges + .iter() + .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 { + 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" + )))); + } + + #[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" + )))); + } +} diff --git a/crates/feder-runtime-server/src/object.rs b/crates/feder-runtime-server/src/object.rs new file mode 100644 index 0000000..0a282b5 --- /dev/null +++ b/crates/feder-runtime-server/src/object.rs @@ -0,0 +1,84 @@ +// 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, PUBLIC_COLLECTION}; +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 !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()); + } + + 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/src/operation.rs b/crates/feder-runtime-server/src/operation.rs new file mode 100644 index 0000000..59c6471 --- /dev/null +++ b/crates/feder-runtime-server/src/operation.rs @@ -0,0 +1,129 @@ +// 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::collections::HashSet; + +use feder_core::{Action, HandleResult, Input, Recipients, SendActivity, UserCreateNote}; + +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. 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 + } + + 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) + }; + { + let mut store = self + .store + .lock() + .map_err(|_| Error::StorageStateUnavailable)?; + store.persist_actions(&result.actions)?; + }; + 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) + } + + async fn resolve_outbound_deliveries( + &self, + actions: &[Action], + ) -> 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 { + 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(_) => {} + 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(actor.inbox), + }); + } + } + Err(error) if first_actor_resolve_error.is_none() => { + first_actor_resolve_error = Some(error); + } + Err(_) => {} + } + } + } + } + + Ok((resolved, first_actor_resolve_error)) + } +} diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs new file mode 100644 index 0000000..851598b --- /dev/null +++ b/crates/feder-runtime-server/src/send.rs @@ -0,0 +1,177 @@ +// 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, time::SystemTime}; + +use crate::{config::OutboundAddressPolicy, url}; +use feder_core::{ + Activity, Recipients, SendActivity, + http_signatures::{ + ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, + }, +}; +use reqwest::{ + Client, StatusCode, Url, + header::{CONTENT_TYPE, DATE, HOST}, +}; + +/// Sends core `SendActivity` actions as signed ActivityPub HTTP requests. +#[derive(Clone, Debug)] +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, + address_policy: OutboundAddressPolicy, + ) -> Result { + let client = url::build_client(address_policy).map_err(SendError::BuildClient)?; + + Ok(Self { + client, + key_pair, + key_id, + address_policy, + }) + } + + /// Attempts every send action and returns the first error encountered. + pub async fn send_actions(&self, actions: &[SendActivity]) -> Result<(), SendError> { + let mut first_error = None; + + for action in actions { + if let Err(error) = self.send(action).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), + Activity::Follow(activity) => serde_json::to_vec(activity), + _ => return Err(SendError::UnsupportedActivity), + } + .map_err(SendError::Serialize)?; + 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(inbox.to_string())); + } + crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { + SendError::PrivateInboxAddress { + inbox: inbox.to_string(), + address, + } + })?; + let mut host = url + .host() + .ok_or_else(|| SendError::InvalidInbox(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 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 + .post(url) + .header(CONTENT_TYPE, "application/activity+json") + .header(DATE, date) + .header("Digest", digest) + .header(HOST, host) + .header("Signature", signature) + .body(body) + .send() + .await + .map_err(SendError::Request)?; + + if !response.status().is_success() { + return Err(SendError::UnsuccessfulStatus { + inbox: 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("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), + + #[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, + + #[error("activity recipients must resolve to an inbox before sending")] + UnresolvedRecipients, +} diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 127f087..b3757c6 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, Object, + http_signatures::{ActorKeyPair, KeyError}, +}; use feder_vocab::Iri; pub use sqlite::SqliteStore; @@ -36,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), @@ -44,6 +50,15 @@ 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), } pub trait RuntimeStore { @@ -52,4 +67,14 @@ pub trait RuntimeStore { fn list_followers(&self, actor_id: &Iri) -> Result, StoreError>; 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, + 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 1172695..682e4db 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,9 +15,15 @@ use std::path::Path; -use feder_core::Action; -use feder_vocab::{Actor, Iri, Reference}; -use rusqlite::{Connection, params}; +#[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}; use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; @@ -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) @@ -58,6 +70,16 @@ 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 + ); + CREATE TABLE IF NOT EXISTS objects ( + object_id TEXT PRIMARY KEY NOT NULL, + object_type TEXT NOT NULL, + object_json TEXT NOT NULL + ); "#, )?; @@ -65,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()?; @@ -76,6 +115,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#" @@ -87,28 +127,44 @@ 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, ], )?; } - Action::StoreDeliveryTarget(action) => { + Action::RemoveFollower(action) => { tx.execute( r#" - UPDATE followers - SET inbox_url = ?2 - WHERE follower_actor_id = ?1 + DELETE FROM followers + WHERE follower_actor_id = ?1 AND following_actor_id = ?2 "#, - params![action.target.actor.as_str(), action.target.inbox.as_str()], + params![action.follower.as_str(), action.following.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], )?; } _ => {} @@ -178,6 +234,83 @@ 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, + 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 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 { @@ -216,10 +349,13 @@ fn parse_optional_iri(value: Option) -> Result, StoreError> #[cfg(test)] mod tests { - use feder_core::{Action, StoreFollower}; + use feder_core::{Action, Object, RemoveFollower, StoreFollower, StoreObject}; 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") } @@ -231,6 +367,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), @@ -239,6 +385,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 +433,297 @@ 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 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); + } + + #[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"); + 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"); @@ -303,6 +745,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"); @@ -361,7 +824,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]) @@ -376,22 +839,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")) @@ -407,6 +868,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"); diff --git a/crates/feder-runtime-server/src/url.rs b/crates/feder-runtime-server/src/url.rs new file mode 100644 index 0000000..f16c90a --- /dev/null +++ b/crates/feder-runtime-server/src/url.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 std::{ + io, + net::{IpAddr, SocketAddr}, + sync::LazyLock, + time::Duration, +}; + +use ipnet::IpNet; +use reqwest::{ + Client, Url, + dns::{Addrs, Name, Resolve, Resolving}, + redirect::Policy, +}; +use url::Host; + +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(Host::Ipv4(address)) => validate_public_address(address.into()), + Some(Host::Ipv6(address)) => validate_public_address(address.into()), + Some(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/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..e2ed22d --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -0,0 +1,130 @@ +// 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") + .header(header::ACCEPT, "application/activity+json") + .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" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + + 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"], + 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["followers"], + "http://127.0.0.1:3000/users/alice/followers" + ); + 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] +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); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} + +#[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); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} + +#[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..6fd8552 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/app.rs @@ -0,0 +1,82 @@ +// 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 feder_vocab::Reference; +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 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() + .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/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs new file mode 100644 index 0000000..bfeb6cb --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/followers.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::{ + 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")) + .header(header::ACCEPT, "application/activity+json") + .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); +} + +#[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); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} + +#[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); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} 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..4192167 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -0,0 +1,877 @@ +// 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, Router, + body::{Body, Bytes}, + 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}, + storage::RuntimeStore, +}; +use serde_json::json; +use tower::ServiceExt; + +use crate::common::{ + RecordedRequest, fixture_actor_key_pair, 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", + "id": "https://remote.example/activities/follow-1", + "actor": { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": "https://remote.example/users/bob", + "inbox": inbox, + "outbox": "https://remote.example/users/bob/outbox" + }, + "object": "http://127.0.0.1:3000/users/alice" + })) + .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") +} + +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, + 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, + "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", + { "toot": "http://joinmastodon.org/ns#" } + ], + "type": "Person", + "id": actor_id, + "inbox": inbox, + "outbox": format!("http://{address}/users/bob/outbox"), + "preferredUsername": "bob", + "endpoints": { "sharedInbox": inbox }, + "publicKey": if separate_key { json!(key_id) } else { public_key.clone() } + }); + 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( + "/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| { + 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, key_id, receiver, task) +} + +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") +} + +async fn post_signed_inbox( + app: Router, + uri: &str, + 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 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"), + key_id, + "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; + 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::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" + ); + } + 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!( + 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!( + activity["object"]["id"], + "https://remote.example/activities/follow-1" + ); + 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 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", + &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 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) = + 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(), + ) + .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_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; + 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", + &format!("{actor_id}#main-key"), + &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", + &format!("{actor_id}#main-key"), + &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) = + 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!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .len(), + 1 + ); + requests.recv().await.expect("receive failed request"); + inbox_server.abort(); +} + +#[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() { + for content_type in [ + "application/json", + "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, + "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] +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 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( + 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 (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() }; + 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_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(); + 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/object.rs b/crates/feder-runtime-server/tests/cases/object.rs new file mode 100644 index 0000000..55729d3 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/object.rs @@ -0,0 +1,237 @@ +// 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, 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; +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(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()); + note.published = Some("2026-07-21T00:00:00Z".to_string()); + note.url = Some(note.id.clone()); + note +} + +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(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 { + 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 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( + 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/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs new file mode 100644 index 0000000..ca25836 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -0,0 +1,308 @@ +// 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, Router, + http::{StatusCode, header}, + routing::get, +}; +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, 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}; + +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")), + 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")), + } +} + +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 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() + .expect("store lock") + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(remote_actor), + following: Reference::id(state.local_actor.id.clone()), + })]) + .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; + let state = test_app_state(test_config()).expect("build app state"); + store_follower(&state, "https://remote.example/users/bob", &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(send) + if matches!(&send.recipients, Recipients::Followers(_)) + )); + + 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["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(); +} + +#[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_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_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(actor_id); + input.cc = feder_vocab::References::one( + state + .local_actor + .followers + .clone() + .expect("local actor has followers collection"), + ); + + state.create_note(input).await.expect("create note"); + + 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(); + shared_inbox_server.abort(); + personal_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; + 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) = + spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let state = test_app_state(test_config()).expect("build app state"); + store_follower(&state, "https://remote.example/users/bob", &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(); +} + +#[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); +} 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..93bf867 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -0,0 +1,234 @@ +// 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::{ + Activity, Recipients, SendActivity, + http_signatures::{ActorKeyPair, sign_draft_cavage}, +}; +use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; +use feder_vocab::{Create, Follow, Note, Reference}; + +use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; + +fn create_note_send_action(inbox: &str) -> SendActivity { + 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), + ); + + SendActivity { + activity: Activity::CreateNote(create), + recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), + } +} + +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; + let inbox = format!("{inbox}?shared=true"); + let actions = [create_note_send_action(&inbox)]; + + test_activity_sender() + .send_actions(&actions) + .await + .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.split_once('/').map(|(authority, _)| authority)) + .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(); + 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"); + assert_eq!(activity["actor"], "https://local.example/users/alice"); + assert_eq!(activity["object"]["type"], "Note"); + 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"); + 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) = + 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 = test_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(); +} + +#[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/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..399fc04 --- /dev/null +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -0,0 +1,179 @@ +// 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, + body::Bytes, + http::{HeaderMap, StatusCode, Uri}, + routing::post, +}; +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, + 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 uri: Uri, + pub body: Bytes, +} + +pub async fn spawn_inbox_server( + response_status: StatusCode, +) -> (String, mpsc::Receiver, JoinHandle<()>) { + let (sender, receiver) = mpsc::channel(2); + let app = Router::new().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"); + 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 { + 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, + outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, + 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()); + 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()?, + 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 + } + }; + 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.clone(), + actor.id.clone(), + actor_key_pair.public_key_pem().to_string(), + ))); + 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(), + config.outbound_address_policy, + )?; + + Ok(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, + 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() + )) +} + +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(), + ) +} + +pub fn test_activity_sender() -> 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/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----- diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs new file mode 100644 index 0000000..7e1d253 --- /dev/null +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -0,0 +1,33 @@ +// 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/followers.rs"] +mod followers; +#[path = "cases/inbox.rs"] +mod inbox; +#[path = "cases/object.rs"] +mod object; +#[path = "cases/operation.rs"] +mod operation; +#[path = "cases/send.rs"] +mod send; +#[path = "cases/webfinger.rs"] +mod webfinger; diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 1c1bc16..a22cb34 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; @@ -170,7 +173,40 @@ macro_rules! activitystreams_type { activitystreams_type!(NoteType, Note); 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)] +#[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 { @@ -189,22 +225,55 @@ 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", default)] + 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, 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")] 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,20 +285,38 @@ 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, outbox, + followers: None, 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. @@ -242,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 { @@ -260,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, } } } @@ -324,6 +423,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 { @@ -334,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 { @@ -349,6 +481,39 @@ impl Create { id, actor, object, + to: References::new(), + cc: References::new(), + } + } +} + +/// 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, } } } @@ -360,6 +525,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, @@ -454,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/phase1_shapes.rs b/crates/feder-vocab/tests/activitypub_serialization.rs similarity index 61% rename from crates/feder-vocab/tests/phase1_shapes.rs rename to crates/feder-vocab/tests/activitypub_serialization.rs index 043f477..ba1bf3a 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, + OrderedCollection, Reference, References, SECURITY_CONTEXT, Undo, }; 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,39 +125,99 @@ fn accept_activity_can_embed_follow_activity() { } #[test] -fn local_note_can_shape_create_note_activity() { +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 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")); 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( + 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(create), + serialize_to_value(create), json!({ "@context": ACTIVITYSTREAMS_CONTEXT, "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", "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" } }) ); } #[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 +234,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"); 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 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, } } 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"