diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 3f112fa..0000000 --- a/.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -.git -.jj -.venv -.pytest_cache -__pycache__ -*.egg-info -*.key -*.db -*.db-wal -*.db-shm -data -tests -docker diff --git a/.gitignore b/.gitignore index faf5ebb..63f17c4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,13 @@ # Gateway identity secret *.key -# Python +# Rust build output +/target/ + +# Python harness caches __pycache__/ .venv/ -*.egg-info/ .pytest_cache/ + +# Nix build output +/result diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6c71ab8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1005 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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 = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fumi-core" +version = "0.1.0" +dependencies = [ + "chacha20poly1305", + "data-encoding", + "ed25519-dalek", + "hkdf", + "hmac", + "rand_core", + "rusqlite", + "serde", + "serde_json", + "sha2", + "snow", + "x25519-dalek", +] + +[[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", + "libc", + "wasi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mail-parser" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86" +dependencies = [ + "hashify", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pneumatube" +version = "0.1.0" +dependencies = [ + "data-encoding", + "fumi-core", + "mail-parser", + "rustls", + "rustls-native-certs", + "webpki-roots", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +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 = [ + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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 = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..36f6541 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "pneumatube" +version = "0.1.0" +edition = "2021" +description = "Gateway bridging a smolmail account and an SMTP mailbox" +license = "Apache-2.0" + +[dependencies] +# The smolmail protocol lives here: Noise_NX transport, sealed mail, accept +# tokens, the local store. A sibling checkout is expected at ../fumi, the +# same convention the deployment tooling documents. +fumi-core = { path = "../fumi/core" } +mail-parser = "0.11" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-native-certs = "0.8" +webpki-roots = "1" +data-encoding = "2" + +[profile.release] +lto = true diff --git a/Dockerfile b/Dockerfile index a73aebc..8a4f5d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,29 @@ -FROM python:3.13-slim +# The build needs the fumi source for the Cargo path dependency +# (../fumi/core), so the build context is the PARENT directory: +# podman build -f pneumatube/Dockerfile .. +# (docker-compose.yml already sets context ".."). Only the source files are +# COPYed explicitly, not the whole directories: the context root is not +# this repo, so a .dockerignore here does not apply, and sibling target/ +# and ./data trees must stay out of the image layers. +# The fumi CLI ships in the image too, so account bootstrap needs nothing +# but this image: +# docker compose run --rm pneumatube \ +# fumi --key /data/gateway.key --db /data/gateway.db keygen +FROM rust:1-slim-bookworm AS build +WORKDIR /src +COPY fumi/Cargo.toml fumi/Cargo.lock fumi/ +COPY fumi/core fumi/core +COPY fumi/cli fumi/cli +COPY pneumatube/Cargo.toml pneumatube/Cargo.lock pneumatube/ +COPY pneumatube/src pneumatube/src +WORKDIR /src/pneumatube +RUN cargo build --release --bin pneumatube +RUN cargo build --release --manifest-path ../fumi/Cargo.toml --bin fumi -RUN pip install --no-cache-dir uv +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/pneumatube/target/release/pneumatube /usr/local/bin/pneumatube +COPY --from=build /src/fumi/target/release/fumi /usr/local/bin/fumi -WORKDIR /app -COPY pyproject.toml uv.lock ./ -COPY src ./src -RUN uv sync --frozen --no-dev - -CMD ["uv", "run", "pneumatube"] +CMD ["pneumatube"] diff --git a/README.md b/README.md index 3c2dc37..4ce7160 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # pneumatube -A gateway bridging a smolmail account and an SMTP mailbox: smolmail messages carrying an `Email-To` frontmatter field are relayed to that address over SMTP, and mail sent to `@` is relayed as a smolmail message to `` if that user is registered on the configured smolmail server. See `src/pneumatube/__main__.py` for the full environment variable list, `docker-compose.yml`/`docker/postfix/` for a Postfix-fronted container deployment, and `flake.nix` for a NixOS deployment with no containers. +A gateway bridging a smolmail account and an SMTP mailbox: smolmail messages carrying an `Email-To` frontmatter field are relayed to that address over SMTP, and mail sent to `@` is relayed as a smolmail message to `` if that user is registered on the configured smolmail server. See `src/main.rs` and `src/config.rs` for the full environment variable list, `docker-compose.yml`/`docker/postfix/` for a Postfix-fronted container deployment, and `flake.nix` for a NixOS deployment with no containers. + +The implementation is Rust on [fumi-core](../fumi) — the smolmail client library that also powers the `fumi` CLI — reached as a Cargo path dependency on a sibling checkout (`../fumi/core`, the same convention the nix flake and container build spell out). The email side is self-contained: a hand-rolled LMTP listener, a minimal outbound SMTP client (STARTTLS and AUTH LOGIN included, rustls underneath), and a `mail-parser` based body extractor. The gateway's own smolmail state (`gateway.db`) is a fumi store, so every fumi CLI command works against it for bootstrap and manual review. ## Using the gateway as a registered smolmail user -This assumes a smolmail account already exists, and that the gateway's smolmail address (e.g. `gateway@smol.example.org`) and the email domain it answers to (e.g. `example.com`) are known — the operator can provide both. Everything below uses the reference client, `smolmail.py`, the same way it's used for anything else. +This assumes a smolmail account already exists, and that the gateway's smolmail address (e.g. `gateway@smol.example.org`) and the email domain it answers to (e.g. `example.com`) are known — the operator can provide both. Everything below uses `fumi`, the same way it's used for anything else. ### Sending email through the gateway @@ -12,7 +14,7 @@ Send the gateway a message with an `Email-To` frontmatter field naming where it ``` echo "Hi there, this is coming in from smolmail." | \ - smolmail.py send gateway@smol.example.org --subject "Hello" --header "Email-To: someone@example.com" + fumi send gateway@smol.example.org --subject "Hello" --header "Email-To: someone@example.com" ``` A few things that aren't obvious from the outside: @@ -26,8 +28,8 @@ A few things that aren't obvious from the outside: Mail sent to `@example.com` reaches that registered user as a smolmail message, but by default only once the user has accepted the gateway: ``` -smolmail.py accept gateway@smol.example.org -echo "hi" | smolmail.py send gateway@smol.example.org +fumi accept gateway@smol.example.org +echo "hi" | fumi send gateway@smol.example.org ``` Both steps matter, not just the first. Accepting the gateway only changes the user's own server's tiering for mail from it; the gateway has no way to know that happened until it sees it reflected in a message from the user (same §5.8 mechanism as above), which means sending it at least one message. Until both are done, inbound email addressed to that user is rejected outright — not queued, not quietly demoted to some low-priority folder, just refused before it's even accepted. @@ -40,32 +42,26 @@ Once that's done, the smolmail messages received this way won't say who original ### Unit tests -`uv run pytest` (or `devbox run test`) — 27/27 passing. Covers address validation and header-injection rejection (`smtp_client`), frontmatter sanitization (`email_to_smol`), MIME body extraction, the accepted-recipient gate (`email_to_smol`), per-recipient LMTP reply assembly and the accepted-recipient RCPT reject (`smtp_server`), the Postfix `tcp_table(5)` lookup protocol including the same gate (`lookup_server`), and failure isolation plus the accepted-sender tier gate in the smolmail → email poll loop (`smol_to_email`). +`cargo test` (or `devbox run test`) — 32 passing, offline (stubs plus fumi's in-memory store). Covers address validation including the CR/LF/NUL/unicode-whitespace injection battery and the CR/LF header-value backstop (`smtp_client`), frontmatter sanitization, MIME body extraction including the malformed-MIME battery (empty, binary, 50-deep nesting, declared-huge-missing-body) (`email_to_smol`), the accepted-recipient token gate including default-port normalization against a real fumi store (`email_to_smol`), per-recipient LMTP reply assembly, the accepted-recipient RCPT reject, command parsing and dot-unstuffing (`smtp_server`), the Postfix `tcp_table(5)` lookup protocol including the same gate and `%XX` quoting round-trips (`lookup_server`), and failure isolation plus the accepted-sender tier selection in the smolmail → email poll loop (`smol_to_email`). ### Stress test (`scripts/stress_test.py`, `devbox run stress`) -Concurrent load against pneumatube directly, and through the real Postfix container rig. +Concurrent load against pneumatube directly, and through the real Postfix container rig (`devbox run stress-postfix`). The throwaway server is `bunshin`; the load-driving sender and recipient accounts use the reference `smolmail.py` client in-process, which doubles as an interop check of the Python reference client against the Rust gateway and Rust server. Needs `bunshin` and `fumi` binaries on PATH and a `smolmail` checkout next to this repo. -- **Healthy at low-to-moderate concurrency**: ~5 concurrent senders sustains ~140 msg/s with zero errors on both directions. -- **Found and fixed**: `EmailToSmolBridge.user_exists` resolved a recipient's identity at RCPT time and discarded the result, so `forward()` resolved the same username again moments later — doubling the network round trips (each a full TCP + Noise handshake, smolmail.py has no connection pooling) for every message. Fixed by caching the resolved identity as a contact, the same trust-on-first-use pattern smolmail.py's own CLI already uses. -- **A real scaling ceiling, not a pneumatube bug**: at concurrency ≈30, throughput collapses to mass client-side timeouts. The likely cause (via code inspection, not a clean isolated reproduction): smolmail's reference server (`smolmaild.py`) uses Python's `socketserver.ThreadingTCPServer` with its default backlog of 5, uncorrected, compounding with pneumatube's own single-worker-thread serialization (`worker.py`, by design, for sqlite thread-safety). Worth pinning down further before relying on sustained throughput much above a handful of messages/sec against the reference server. -- Both targets pass cleanly at healthy concurrency: correctness verified (every expected message delivered exactly once, no loss or duplication) on the direct path and through the full Postfix → LMTP → pneumatube path. +- **Direct target, default concurrency 20 (200 messages per phase)**: both phases correct (133 ok / 67 rejected exactly as constructed; 200/200 relayed to the outbound sink, no loss or duplication). Email → smolmail sustains ~88 msg/s — each accepted recipient paying a live connect + Noise handshake + RESOLVE at RCPT time — and smolmail → email SENDs land at ~335 msg/s. +- **No serialization point left**: the Python implementation serialized every smolmail operation onto one worker thread (sqlite thread-affinity of the vendored client). fumi's store is `Send + Sync` (mutex + WAL) and every dial is its own Noise session, so the Rust port's concurrency is capped only by the smolmail server on the other end. The old concurrency ≈30 collapse against the reference `smolmaild.py` (ThreadingTCPServer backlog of 5) does not reproduce against `bunshin`: concurrency 30 completes both phases with zero errors and full drain. +- The poll loop relays one message per SMTP conversation, sequentially by design; the harness's drain deadline is sized for that (~150 ms per relay against a local sink). ### Security test (`scripts/security_test.py`, `devbox run security`) -11 checks: 8 PASS (defense verified), 3 RISK (confirmed, accepted design tradeoffs — not bugs, but worth knowing they're real), 0 BUG. +Live checks against a throwaway rig (same fixtures as the stress test): 3 PASS (defense verified), 3 RISK (confirmed, accepted design tradeoffs — not bugs, but worth knowing they're real), 0 BUG. The unit-style checks the Python version ran in this script live in `cargo test` now (see Unit tests above). **PASS:** -- Address validation (`smtp_client.validate`) rejects every CR/LF/NUL injection variant tried, not just the literal `\r\n` case. -- The frontmatter sanitizer (`email_to_smol._single_line`) strips CR/LF from every payload tried. -- Python's `EmailMessage` rejects embedded CR/LF in header values — confirmed as a real backstop, since `smtp_client.send()` has no explicit sanitizer of its own and otherwise relies entirely on smolmail's frontmatter format structurally preventing multi-line values. -- `extract_text` survives a battery of malformed MIME (empty messages, non-UTF8 bytes, deeply nested multipart, multipart with no text part) without raising or stalling. -- **Found and fixed**: the smolmail → email poll loop had no per-message failure isolation — unlike the email → smolmail side (`smtp_server.py`, already correct), one message raising an exception `_relay()`'s own except clause didn't cover would propagate out of `poll_once()` entirely. Since the poisoned message is only ever deleted on success, it would sort first on every subsequent poll and jam the entire direction forever. Not confirmed reachable via the documented frontmatter format today, but fixed regardless as defense-in-depth with a regression test proving later messages still get processed after an earlier one fails. -- **Found and fixed**: the smolmail → email direction relayed *any* sender by default, including first-contact senders with no accept token — confirmed live (15/15 first-contact sends reached the outbound sink). Fixed with `PNEUMATUBE_REQUIRE_ACCEPTED_SENDER` (default `1`): `poll_once()` now polls only the main/accepted tier (SPEC.md §5.8), so a sender the gateway account hasn't explicitly accepted is left alone. Re-verified live: 0/15 first-contact sends reached the sink with the new default. Note the gateway never replies to smolmail senders, so accepting someone is a two-step process — `smolmail.py accept
` alone doesn't retroactively unblock a message already sitting in the local inbox, and the sender's *next* message won't carry a valid token either until the operator also sends them one message back (that's how the token actually reaches them, per the protocol). -- **Found and fixed**: `Email-From` (the email → smolmail direction's frontmatter field carrying the original email's `MAIL FROM`) has been removed entirely. `MAIL FROM` is never authenticated, so forwarding it into a field that looks like gateway-attested metadata let any email sender forge an apparently-trustworthy "from" inside a signed smolmail message. Re-verified live with a forged `security-team@trusted-bank.example`: the relayed message now carries no field reflecting it at all. -- **Found and fixed**: the email → smolmail direction relayed to *any* registered recipient by default, with no throttling and no way for a recipient to opt out. Fixed with `PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT` (default `1`): reuses the real smolmail accept-token mechanism (SPEC.md §5.8) rather than inventing a new one — pneumatube already needs to know locally whether it holds a token for a recipient to attach a valid accept MAC on `cmd_send`, and that same fact is exactly "has this recipient accepted the gateway". A recipient who hasn't is now rejected live at RCPT TO (an all-or-nothing gate, not a demotion: without it, the message would still land in the recipient's own requests tier; with it, they get nothing at all). Checked in both `handle_RCPT` and the Postfix lookup service, so Postfix itself rejects live rather than generating a bounce later. Re-verified live: a registered-but-never-accepted recipient is now rejected with `outcome=rejected`, where it previously always succeeded. +- The smolmail → email direction relays no first-contact sender by default: 0/15 never-before-seen senders with `Email-To` fields reached the outbound sink with `PNEUMATUBE_REQUIRE_ACCEPTED_SENDER` at its default. +- The email → smolmail direction rejects a registered-but-never-accepted recipient live at RCPT TO (outcome=rejected, not queued-then-bounced) with `PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT` at its default. +- A forged `MAIL FROM` (`security-team@trusted-bank.example`) leaves no trace in the relayed message's frontmatter — the email → smolmail direction carries no `Email-From` field at all, since `MAIL FROM` is never authenticated. **RISK (confirmed, accepted tradeoffs):** - The Postfix lookup service unambiguously reveals which usernames exist, with no auth or rate limiting of its own — correct and necessary for `check_recipient_access` to work, but only safe because it's loopback-only. This requirement is load-bearing, not cosmetic: never expose `PNEUMATUBE_LOOKUP_HOST`/`PNEUMATUBE_LOOKUP_PORT` beyond Postfix. -- **Open relay, smolmail → email, opt-in**: setting `PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0` deliberately restores the open-bridge behavior above (confirmed live: 15/15 first-contact sends relayed) — an accepted tradeoff for an operator who wants a public bridge, not a default. -- **Open relay, email → smolmail, opt-in**: setting `PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0` deliberately restores the "relay to anyone" behavior above (confirmed live: 15/15 distinct-sender messages relayed to an unaccepted recipient) — same tradeoff, operator's explicit choice. Note there is still no *per-sender* throttling in either mode: the gate is per-recipient (have they accepted the gateway at all), not per-sender, so an accepted recipient remains reachable by any external sender once opted in. +- **Open relay, smolmail → email, opt-in**: setting `PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0` deliberately restores the open-bridge behavior (confirmed live: 15/15 first-contact sends relayed) — an accepted tradeoff for an operator who wants a public bridge, not a default. +- **Open relay, email → smolmail, opt-in**: setting `PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0` deliberately restores the "relay to anyone" behavior (confirmed live: 15/15 distinct-sender messages relayed to an unaccepted recipient) — same tradeoff, operator's explicit choice. Note there is still no *per-sender* throttling in either mode: the gate is per-recipient (have they accepted the gateway at all), not per-sender, so an accepted recipient remains reachable by any external sender once opted in. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..959eecc --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,740 @@ +# Third-Party Notices + +pneumatube incorporates the third-party components listed below. Components are grouped by license, each with the copyright notice that applies to it, followed by the full license text. A component listed under several licenses is multi-licensed under those options; any one of them may be chosen. All components are redistributed unmodified from their published sources. + +The Rust crates listed are all crates resolved by `Cargo.lock`, including transitive dependencies, which are statically linked into the pneumatube binary. +The SQLite C library is bundled in source form by the `libsqlite3-sys` crate and is public domain (see the final section). +The libraries marked "vendored source" are pinned nix flake inputs; each is identified by its upstream repository and pinned revision. + +## Apache License 2.0 + +- aead 0.5.2 - Copyright (c) 2019 The RustCrypto Project Developers Copyright (c) 2019 MobileCoin, LLC +- aes 0.8.4 - Copyright (c) 2018 Artyom Pavlov +- aes-gcm 0.10.3 - Copyright (c) 2019 The RustCrypto Project Developers +- ahash 0.8.12 - Copyright (c) 2018 Tom Kaitchuck +- base64ct 1.8.3 - Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) Copyright (c) 2021-2025 The RustCrypto Project Developers +- bitflags 2.13.2 - Copyright (c) 2014 The Rust Project Developers +- blake2 0.10.6 - Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros Copyright (c) 2017 Artyom Pavlov +- block-buffer 0.10.4 - Copyright (c) 2018-2019 The RustCrypto Project Developers +- cc 1.6.0 - Copyright (c) 2014 Alex Crichton +- cfg-if 1.0.5 - Copyright (c) 2014 Alex Crichton +- chacha20 0.9.1 - Copyright (c) 2019-2023 The RustCrypto Project Developers +- chacha20poly1305 0.10.1 - Copyright (c) 2019 The RustCrypto Project Developers +- cipher 0.4.4 - Copyright (c) 2016-2020 RustCrypto Developers +- const-oid 0.9.6 - Copyright (c) 2020-2022 The RustCrypto Project Developers +- core-foundation 0.10.1 - Copyright (c) 2012-2013 Mozilla Foundation +- core-foundation-sys 0.8.7 - Copyright (c) 2012-2013 Mozilla Foundation +- cpufeatures 0.2.17 - Copyright (c) 2020-2025 The RustCrypto Project Developers +- crypto-common 0.1.7 - Copyright (c) 2021 RustCrypto Developers +- ctr 0.9.2 - Copyright (c) 2018-2022 RustCrypto Developers Copyright (c) 2018 Artyom Pavlov +- curve25519-dalek-derive 0.1.1 - The curve25519-dalek-derive developers +- der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers +- digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov +- ed25519 2.2.3 - Copyright 2018-2022 RustCrypto Developers +- equivalent 1.0.2 - Copyright (c) 2016--2023 +- fallible-iterator 0.3.0 - Copyright (c) 2015 The rust-openssl-verify Developers +- fallible-streaming-iterator 0.1.9 - Copyright (c) 2016 The fallible-streaming-iterator Developers +- fiat-crypto 0.2.9 - Copyright 2015-2020 the fiat-crypto authors (see the AUTHORS file) +- find-msvc-tools 0.1.14 - Copyright (c) 2014 Alex Crichton +- fumi-core 0.1.0 - The fumi-core developers +- getrandom 0.2.17 - Copyright (c) 2018-2024 The rust-random Project Developers Copyright (c) 2014 The Rust Project Developers +- ghash 0.5.1 - Copyright (c) 2019 RustCrypto Developers +- hashbrown 0.14.5 - Copyright (c) 2016 Amanieu d'Antras +- hashbrown 0.17.1 - Copyright (c) 2016 Amanieu d'Antras +- hashify 0.2.9 - Stalwart Labs +- hashlink 0.9.1 - kyren +- hkdf 0.12.4 - Copyright (c) 2015-2018 Vlad Filippov Copyright (c) 2018-2021 RustCrypto Developers +- hmac 0.12.1 - Copyright (c) 2017 Artyom Pavlov +- indexmap 2.14.2 - Copyright (c) 2016--2017 +- inout 0.1.4 - Copyright (c) 2022 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov +- itoa 1.0.18 - David Tolnay +- libc 0.2.190 - Copyright (c) The Rust Project Developers +- log 0.4.34 - Copyright (c) 2014 The Rust Project Developers +- mail-parser 0.11.9 - Stalwart Labs +- once_cell 1.21.4 - Aleksey Kladov +- opaque-debug 0.3.1 - Copyright (c) 2018-2024 The RustCrypto Project Developers +- openssl-probe 0.2.1 - Copyright (c) 2014 Alex Crichton +- pkcs8 0.10.2 - Copyright (c) 2020-2023 The RustCrypto Project Developers +- pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton +- poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers +- polyval 0.6.2 - Copyright (c) 2019-2023 RustCrypto Developers +- proc-macro2 1.0.107 - David Tolnay , Alex Crichton +- quote 1.0.47 - David Tolnay +- rand_core 0.6.4 - Copyright 2018 Developers of the Rand project Copyright (c) 2014 The Rust Project Developers +- ring 0.17.14 - The ring developers +- rustc_version 0.4.1 - Copyright (c) 2016 The Rust Project Developers +- rustls 0.23.45 - Copyright (c) 2016 Joseph Birr-Pixton +- rustls-native-certs 0.8.4 - Copyright (c) 2016 Joseph Birr-Pixton +- rustls-pki-types 1.15.1 - Copyright 2023 Dirkjan Ochtman +- security-framework 3.7.0 - Copyright (c) 2015 Steven Fackler +- security-framework-sys 2.17.0 - Copyright (c) 2015 Steven Fackler +- semver 1.0.28 - David Tolnay +- serde 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_core 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_derive 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_json 1.0.151 - Erick Tryzelaar , David Tolnay +- sha2 0.10.9 - Copyright (c) 2006-2009 Graydon Hoare Copyright (c) 2009-2013 Mozilla Foundation Copyright (c) 2016 Artyom Pavlov +- shlex 2.0.1 - Copyright 2015 Nicholas Allegra (comex). +- signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers +- smallvec 1.16.2 - Copyright (c) 2018 The Servo Project Developers +- snow 0.9.6 - Copyright (c) 2021 Jake McGinty +- spki 0.7.3 - Copyright (c) 2021-2023 The RustCrypto Project Developers +- syn 2.0.119 - David Tolnay +- syn 3.0.6 - David Tolnay +- typenum 1.20.1 - Copyright 2014 Paho Lurie-Gregg +- unicode-ident 1.0.26 - David Tolnay +- universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers +- vcpkg 0.2.15 - Copyright (c) 2017 Jim McGrath +- version_check 0.9.5 - Copyright (c) 2017-2018 Sergio Benitez +- wasi 0.11.1+wasi-snapshot-preview1 - The Cranelift Project Developers +- windows-link 0.2.1 - The windows-link developers +- windows-sys 0.52.0 - Microsoft +- windows-sys 0.61.2 - The windows-sys developers +- windows-targets 0.52.6 - Microsoft +- windows_aarch64_gnullvm 0.52.6 - Microsoft +- windows_aarch64_msvc 0.52.6 - Microsoft +- windows_i686_gnu 0.52.6 - Microsoft +- windows_i686_gnullvm 0.52.6 - Microsoft +- windows_i686_msvc 0.52.6 - Microsoft +- windows_x86_64_gnu 0.52.6 - Microsoft +- windows_x86_64_gnullvm 0.52.6 - Microsoft +- windows_x86_64_msvc 0.52.6 - Microsoft +- zerocopy 0.8.59 - Copyright 2023 The Fuchsia Authors +- zerocopy-derive 0.8.59 - Copyright 2023 The Fuchsia Authors +- zeroize 1.9.0 - Copyright (c) 2018-2026 The RustCrypto Project Developers +- zeroize_derive 1.5.0 - Copyright (c) 2019-2026 The RustCrypto Project Developers +- fumi-core (vendored source, https://github.com/randogoth/fumi, rev 8872ec6c98c0) + +``` +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 randogoth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +``` + +## MIT License + +- aead 0.5.2 - Copyright (c) 2019 The RustCrypto Project Developers Copyright (c) 2019 MobileCoin, LLC +- aes 0.8.4 - Copyright (c) 2018 Artyom Pavlov +- aes-gcm 0.10.3 - Copyright (c) 2019 The RustCrypto Project Developers +- ahash 0.8.12 - Copyright (c) 2018 Tom Kaitchuck +- base64ct 1.8.3 - Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) Copyright (c) 2021-2025 The RustCrypto Project Developers +- bitflags 2.13.2 - Copyright (c) 2014 The Rust Project Developers +- blake2 0.10.6 - Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros Copyright (c) 2017 Artyom Pavlov +- block-buffer 0.10.4 - Copyright (c) 2018-2019 The RustCrypto Project Developers +- cc 1.6.0 - Copyright (c) 2014 Alex Crichton +- cfg-if 1.0.5 - Copyright (c) 2014 Alex Crichton +- chacha20 0.9.1 - Copyright (c) 2019-2023 The RustCrypto Project Developers +- chacha20poly1305 0.10.1 - Copyright (c) 2019 The RustCrypto Project Developers +- cipher 0.4.4 - Copyright (c) 2016-2020 RustCrypto Developers +- const-oid 0.9.6 - Copyright (c) 2020-2022 The RustCrypto Project Developers +- core-foundation 0.10.1 - Copyright (c) 2012-2013 Mozilla Foundation +- core-foundation-sys 0.8.7 - Copyright (c) 2012-2013 Mozilla Foundation +- cpufeatures 0.2.17 - Copyright (c) 2020-2025 The RustCrypto Project Developers +- crypto-common 0.1.7 - Copyright (c) 2021 RustCrypto Developers +- ctr 0.9.2 - Copyright (c) 2018-2022 RustCrypto Developers Copyright (c) 2018 Artyom Pavlov +- curve25519-dalek-derive 0.1.1 - The curve25519-dalek-derive developers +- data-encoding 2.11.1 - Copyright (c) 2015-2020 Julien Cretin Copyright (c) 2017-2020 Google Inc. +- der 0.7.10 - Copyright (c) 2020-2023 The RustCrypto Project Developers +- digest 0.10.7 - Copyright (c) 2017 Artyom Pavlov +- ed25519 2.2.3 - Copyright (c) 2018-2023 RustCrypto Developers +- equivalent 1.0.2 - Copyright (c) 2016--2023 +- fallible-iterator 0.3.0 - Copyright (c) 2015 The rust-openssl-verify Developers +- fallible-streaming-iterator 0.1.9 - Copyright (c) 2016 The fallible-streaming-iterator Developers +- fiat-crypto 0.2.9 - Copyright (c) 2015-2020 the fiat-crypto authors (see the AUTHORS file). +- find-msvc-tools 0.1.14 - Copyright (c) 2014 Alex Crichton +- generic-array 0.14.7 - Copyright (c) 2015 Bartłomiej Kamiński +- getrandom 0.2.17 - Copyright (c) 2018-2024 The rust-random Project Developers Copyright (c) 2014 The Rust Project Developers +- ghash 0.5.1 - Copyright (c) 2019 RustCrypto Developers +- hashbrown 0.14.5 - Copyright (c) 2016 Amanieu d'Antras +- hashbrown 0.17.1 - Copyright (c) 2016 Amanieu d'Antras +- hashify 0.2.9 - Stalwart Labs +- hashlink 0.9.1 - kyren +- hkdf 0.12.4 - Copyright (c) 2015-2018 Vlad Filippov Copyright (c) 2018-2021 RustCrypto Developers +- hmac 0.12.1 - Copyright (c) 2017 Artyom Pavlov +- indexmap 2.14.2 - Copyright (c) 2016--2017 +- inout 0.1.4 - Copyright (c) 2022 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov +- itoa 1.0.18 - David Tolnay +- libc 0.2.190 - Copyright (c) The Rust Project Developers +- libsqlite3-sys 0.30.1 - Copyright (c) 2014-2021 The rusqlite developers +- log 0.4.34 - Copyright (c) 2014 The Rust Project Developers +- mail-parser 0.11.9 - Stalwart Labs +- memchr 2.8.3 - Copyright (c) 2015 Andrew Gallant +- once_cell 1.21.4 - Aleksey Kladov +- opaque-debug 0.3.1 - Copyright (c) 2018-2024 The RustCrypto Project Developers +- openssl-probe 0.2.1 - Copyright (c) 2014 Alex Crichton +- pkcs8 0.10.2 - Copyright (c) 2020-2023 The RustCrypto Project Developers +- pkg-config 0.3.34 - Copyright (c) 2014 Alex Crichton +- poly1305 0.8.0 - Copyright (c) 2015-2019 RustCrypto Developers +- polyval 0.6.2 - Copyright (c) 2019-2023 RustCrypto Developers +- proc-macro2 1.0.107 - David Tolnay , Alex Crichton +- quote 1.0.47 - David Tolnay +- rand_core 0.6.4 - Copyright 2018 Developers of the Rand project Copyright (c) 2014 The Rust Project Developers +- rusqlite 0.32.1 - Copyright (c) 2014-2021 The rusqlite developers +- rustc_version 0.4.1 - Copyright (c) 2016 The Rust Project Developers +- rustls 0.23.45 - Copyright (c) 2016 Joseph Birr-Pixton +- rustls-native-certs 0.8.4 - Copyright (c) 2016 Joseph Birr-Pixton +- rustls-pki-types 1.15.1 - Copyright (c) 2023 Dirkjan Ochtman +- schannel 0.1.29 - Steven Fackler , Steffen Butzer +- security-framework 3.7.0 - Copyright (c) 2015 Steven Fackler +- security-framework-sys 2.17.0 - Copyright (c) 2015 Steven Fackler +- semver 1.0.28 - David Tolnay +- serde 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_core 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_derive 1.0.229 - Erick Tryzelaar , David Tolnay +- serde_json 1.0.151 - Erick Tryzelaar , David Tolnay +- sha2 0.10.9 - Copyright (c) 2006-2009 Graydon Hoare Copyright (c) 2009-2013 Mozilla Foundation Copyright (c) 2016 Artyom Pavlov +- shlex 2.0.1 - Copyright (c) 2015 Nicholas Allegra (comex). +- signature 2.2.0 - Copyright (c) 2018-2023 RustCrypto Developers +- smallvec 1.16.2 - Copyright (c) 2018 The Servo Project Developers +- snow 0.9.6 - Copyright (c) 2021 Jake McGinty +- spki 0.7.3 - Copyright (c) 2021-2023 The RustCrypto Project Developers +- syn 2.0.119 - David Tolnay +- syn 3.0.6 - David Tolnay +- typenum 1.20.1 - Copyright (c) 2014 Paho Lurie-Gregg +- unicode-ident 1.0.26 - David Tolnay +- universal-hash 0.5.1 - Copyright (c) 2019-2020 RustCrypto Developers +- vcpkg 0.2.15 - Copyright (c) 2017 Jim McGrath +- version_check 0.9.5 - Copyright (c) 2017-2018 Sergio Benitez +- wasi 0.11.1+wasi-snapshot-preview1 - The Cranelift Project Developers +- windows-link 0.2.1 - The windows-link developers +- windows-sys 0.52.0 - Microsoft +- windows-sys 0.61.2 - The windows-sys developers +- windows-targets 0.52.6 - Microsoft +- windows_aarch64_gnullvm 0.52.6 - Microsoft +- windows_aarch64_msvc 0.52.6 - Microsoft +- windows_i686_gnu 0.52.6 - Microsoft +- windows_i686_gnullvm 0.52.6 - Microsoft +- windows_i686_msvc 0.52.6 - Microsoft +- windows_x86_64_gnu 0.52.6 - Microsoft +- windows_x86_64_gnullvm 0.52.6 - Microsoft +- windows_x86_64_msvc 0.52.6 - Microsoft +- zerocopy 0.8.59 - Copyright 2023 The Fuchsia Authors +- zerocopy-derive 0.8.59 - Copyright 2023 The Fuchsia Authors +- zeroize 1.9.0 - Copyright (c) 2018-2026 The RustCrypto Project Developers +- zeroize_derive 1.5.0 - Copyright (c) 2019-2026 The RustCrypto Project Developers +- zmij 1.0.23 - David Tolnay + +``` +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +``` + +## BSD 3-Clause License + +- curve25519-dalek 4.1.3 - Copyright (c) 2016-2021 isis agora lovecruft. All rights reserved. Copyright (c) 2016-2021 Henry de Valence. All rights reserved. +- ed25519-dalek 2.2.0 - Copyright (c) 2017-2019 isis agora lovecruft. All rights reserved. +- subtle 2.6.1 - Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved. +- x25519-dalek 2.0.1 - Copyright (c) 2017-2021 isis agora lovecruft. All rights reserved. Copyright (c) 2019-2021 DebugSteven. All rights reserved. + +``` +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +======================================================================== + +Portions of curve25519-dalek were originally derived from Adam Langley's +Go ed25519 implementation, found at , +under the following licence: + +======================================================================== + + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +## BSD 2-Clause License + +- zerocopy 0.8.59 - Copyright 2019 The Fuchsia Authors. +- zerocopy-derive 0.8.59 - Copyright 2019 The Fuchsia Authors. + +``` +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +## BSD 1-Clause License + +- fiat-crypto 0.2.9 - Copyright (c) 2015-2020 the fiat-crypto authors (see the AUTHORS file) All rights reserved. + +``` +The BSD 1-Clause License (BSD-1-Clause) + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +THIS SOFTWARE IS PROVIDED BY the fiat-crypto authors "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Berkeley Software Design, +Inc. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +## The Unlicense + +- memchr 2.8.3 - Andrew Gallant , bluss + +``` +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to +``` + +## Unicode License v3 + +- unicode-ident 1.0.26 (Unicode license applies in addition to MIT OR Apache-2.0) - Copyright © 1991-2023 Unicode, Inc. + +``` +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. +``` + +## LLVM Exception (to the Apache License 2.0) + +The component above may additionally be used under Apache-2.0 or MIT; when used under Apache-2.0, the license text above is modified by the following exception: + +- wasi 0.11.1+wasi-snapshot-preview1 (when used under Apache-2.0) - The Cranelift Project Developers + +``` +---- LLVM Exceptions to the Apache 2.0 License ---- + + As an exception, if, as a result of your compiling your source code, portions + of this Software are embedded into an Object form of such source code, you + may redistribute such embedded portions in such Object form without complying + with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + + In addition, if you combine or link compiled forms of this Software with + software that is licensed under the GPLv2 ("Combined Software") and if a + court of competent jurisdiction determines that the patent provision (Section + 3), the indemnity provision (Section 9) or other Section of the License + conflicts with the conditions of the GPLv2, you may retroactively and + prospectively choose to deem waived or otherwise exclude such Section(s) of + the License, but only in their entirety and only with respect to the Combined + Software. +``` + +## ISC License + +- ring 0.17.14 - The ring developers +- rustls 0.23.45 - The rustls developers +- rustls-native-certs 0.8.4 - The rustls-native-certs developers +- rustls-webpki 0.103.15 - The rustls-webpki developers +- untrusted 0.9.0 - Brian Smith + +``` +ISC License + +Copyright + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +``` + +## Community Data License Agreement Permissive 2.0 + +- webpki-roots 1.0.9 - The webpki-roots developers + +``` +Community Data License Agreement - Permissive - Version 2.0 + +This is the Community Data License Agreement - Permissive, Version 2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree as follows: + +1. Provision of the Data + +1.1. A Data Recipient may use, modify, and share the Data made available by Data Provider(s) under this agreement if that Data Recipient follows the terms of this agreement. + +1.2. This agreement does not impose any restriction on a Data Recipient's use, modification, or sharing of any portions of the Data that are in the public domain or that may be used, modified, or shared under any other legal exception or limitation. + +2. Conditions for Sharing Data + +2.1. A Data Recipient may share Data, with or without modifications, so long as the Data Recipient makes available the text of this agreement with the shared Data. + +3. No Restrictions on Results + +3.1. This agreement does not impose any restriction or obligations with respect to the use, modification, or sharing of Results. + +4. No Warranty; Limitation of Liability + +4.1. All Data Recipients receive the Data subject to the following terms: + +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +5. Definitions + +5.1. "Data" means the material received by a Data Recipient under this agreement. + +5.2. "Data Provider" means any person who is the source of Data provided under this agreement and in reliance on a Data Recipient's agreement to its terms. + +5.3. "Data Recipient" means any person who receives Data directly or indirectly from a Data Provider and agrees to the terms of this agreement. + +5.4. "Results" means any outcome obtained by computational analysis of Data, including for example machine learning models and models' insights. +``` + +## SQLite (public domain) + +SQLite 3.46.0 is bundled in source form by the `libsqlite3-sys` crate (the crate itself is listed with the crates above). The SQLite authors have placed the code in the public domain; the notice accompanying the SQLite distribution: + +``` +The author disclaims copyright to the source code. Although no +laws apply, the author would appreciate it if you did not remove +this notice and attributed the source to the SQLite project. +``` diff --git a/devbox.json b/devbox.json index fe7cf07..f4566b4 100644 --- a/devbox.json +++ b/devbox.json @@ -1,18 +1,38 @@ { "packages": [ + "rustc@latest", + "cargo@latest", + "gcc@latest", + "cargo-audit@latest", "python313@latest", "uv@latest" ], "shell": { - "init_hook": [ - "uv sync" - ], "scripts": { - "sync": ["uv sync"], - "test": ["uv run pytest"], - "run": ["uv run pneumatube"], - "stress": ["uv run python scripts/stress_test.py"], - "security": ["uv run python scripts/security_test.py"] + "build": [ + "cargo build --release" + ], + "test": [ + "cargo test" + ], + "check": [ + "cargo check --workspace && cargo clippy --workspace && cargo fmt --check" + ], + "run": [ + "cargo run --release" + ], + "stress": [ + "cargo build --release && uv run scripts/stress_test.py --target direct" + ], + "stress-postfix": [ + "cargo build --release && uv run scripts/stress_test.py --target postfix" + ], + "security": [ + "cargo build --release && uv run scripts/security_test.py" + ], + "audit-deps": [ + "cargo audit" + ] } } } diff --git a/docker-compose.yml b/docker-compose.yml index 88bf077..c120467 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,25 +7,27 @@ # # Requires PNEUMATUBE_SMOLMAIL_DOMAIN and PNEUMATUBE_SMTP_RELAY_HOST set in # the environment or a .env file. The gateway's own smolmail account must -# already be registered against that server before pneumatube can run — the -# same bootstrap as any smolmail client, using the mounted sibling checkout -# and ./data as --key/--db: +# already be registered against that server before pneumatube can run — +# the same bootstrap as any smolmail client, using the fumi CLI that ships +# inside the image and ./data as --key/--db: # # docker compose run --rm pneumatube \ -# uv run /smolmail/smolmail.py --key /data/gateway.key --db /data/gateway.db keygen +# fumi --key /data/gateway.key --db /data/gateway.db keygen # docker compose run --rm pneumatube \ -# uv run /smolmail/smolmail.py --key /data/gateway.key --db /data/gateway.db trust +# fumi --key /data/gateway.key --db /data/gateway.db trust # docker compose run --rm pneumatube \ -# uv run /smolmail/smolmail.py --key /data/gateway.key --db /data/gateway.db register @ +# fumi --key /data/gateway.key --db /data/gateway.db register @ services: pneumatube: - build: . + # The build context is the parent directory, because Cargo path-depends + # on the sibling fumi checkout (../fumi/core) for the smolmail protocol. + build: + context: .. + dockerfile: pneumatube/Dockerfile volumes: - - ../smolmail:/smolmail:ro - ./data:/data environment: - PNEUMATUBE_SMOLMAIL_REPO: /smolmail PNEUMATUBE_SMOLMAIL_KEY: /data/gateway.key PNEUMATUBE_SMOLMAIL_DB: /data/gateway.db PNEUMATUBE_SMOLMAIL_DOMAIN: ${PNEUMATUBE_SMOLMAIL_DOMAIN:?set the smolmail server address} diff --git a/flake.nix b/flake.nix index 0341a80..b2af158 100644 --- a/flake.nix +++ b/flake.nix @@ -4,50 +4,43 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; - smolmail = { - url = "git+ssh://git@code.randogoth.com:2222/randogoth/smolmail.git"; + fumi = { + url = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git"; flake = false; }; }; - outputs = { self, nixpkgs, flake-utils, smolmail }: + outputs = { self, nixpkgs, flake-utils, fumi }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; - python = pkgs.python313; - # smolmail.py is a `uv run --script` file with PEP 723 inline deps, - # not an installed package -- loaded by path at runtime (vendor.py), - # same as the devbox workflow's sibling checkout. smolmailEnv gives - # the `smolmail` CLI a working interpreter for one-time account - # bootstrap (keygen/trust/register) without needing uv on the host. - smolmailEnv = python.withPackages (ps: [ ps.pynacl ps.noiseprotocol ]); - smolmailCli = pkgs.writeShellScriptBin "smolmail" '' - exec ${smolmailEnv}/bin/python3 ${smolmail}/smolmail.py "$@" - ''; - - pneumatubePkg = python.pkgs.buildPythonApplication { + pneumatubePkg = pkgs.rustPlatform.buildRustPackage { pname = "pneumatube"; version = "0.1.0"; - pyproject = true; src = ./.; - build-system = [ python.pkgs.setuptools python.pkgs.wheel ]; - dependencies = with python.pkgs; [ pynacl noiseprotocol aiosmtpd ]; - # No unit tests need the real smolmail checkout; the ones that do - # (test_smtp_server, test_lookup_server) stub the bridge instead. - nativeCheckInputs = [ python.pkgs.pytestCheckHook ]; + cargoLock.lockFile = ./Cargo.lock; + # Cargo.toml path-depends on a sibling fumi checkout (../fumi/core), + # the same convention the devbox/docker workflows document. The + # build cannot see the sibling, so the flake input's checkout is + # placed inside the source and the path rewritten to match; the + # lockfile entry of a path dependency carries no checksum, so it + # stays valid for either location. + postPatch = '' + substituteInPlace Cargo.toml \ + --replace-fail 'path = "../fumi/core"' 'path = "fumi/core"' + mkdir -p fumi + cp -r ${fumi}/core fumi/core + ''; + # The unit tests are offline (stubs and an in-memory store). + doCheck = true; }; in { packages.default = pneumatubePkg; - packages.smolmail = smolmailCli; - apps.smolmail = { - type = "app"; - program = "${smolmailCli}/bin/smolmail"; - }; devShells.default = pkgs.mkShell { - packages = [ python pkgs.uv ]; + packages = with pkgs; [ cargo rustc rustfmt clippy sqlite ]; }; } ) // { @@ -66,16 +59,6 @@ description = "pneumatube package to run."; }; - smolmailSrc = mkOption { - type = types.path; - default = smolmail; - description = '' - smolmail checkout providing smolmail.py, loaded by path at - runtime (PNEUMATUBE_SMOLMAIL_REPO) rather than installed as - a package. - ''; - }; - smolmailDomain = mkOption { type = types.str; description = '' @@ -173,8 +156,14 @@ description = "pneumatube smolmail/SMTP gateway"; after = [ "network.target" ] ++ lib.optional cfg.postfix.enable "postfix.service"; wantedBy = [ "multi-user.target" ]; + # The gateway's own smolmail account (key + store) is + # bootstrapped with the fumi CLI against these paths, the + # same way any smolmail client registers an account: + # fumi --key /var/lib/pneumatube/gateway.key \ + # --db /var/lib/pneumatube/gateway.db keygen + # fumi ... trust + # fumi ... register @ environment = { - PNEUMATUBE_SMOLMAIL_REPO = "${cfg.smolmailSrc}"; PNEUMATUBE_SMOLMAIL_KEY = "${stateDir}/gateway.key"; PNEUMATUBE_SMOLMAIL_DB = "${stateDir}/gateway.db"; PNEUMATUBE_SMOLMAIL_DOMAIN = cfg.smolmailDomain; diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7281200..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,26 +0,0 @@ -[project] -name = "pneumatube" -version = "0.1.0" -description = "Gateway bridging a smolmail account and an SMTP mailbox" -requires-python = ">=3.11" -dependencies = [ - "pynacl>=1.5", - "noiseprotocol>=0.3.1", - "aiosmtpd>=1.4", -] - -[project.scripts] -pneumatube = "pneumatube.__main__:main" - -[dependency-groups] -dev = ["pytest>=8.0"] - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/scripts/security_test.py b/scripts/security_test.py index 2867d58..654cfd2 100644 --- a/scripts/security_test.py +++ b/scripts/security_test.py @@ -1,58 +1,57 @@ #!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["aiosmtpd>=1.4", "pynacl>=1.5", "noiseprotocol>=0.3.1"] +# /// """Adversarial testing for pneumatube, run against your own deployment. -Covers (see each test_* function's docstring for detail): - - injection fuzzing of the Email-To / subject paths - - malformed-MIME resilience of extract_text - - per-message failure isolation in the smolmail -> email poll loop - - username enumeration via the Postfix lookup service's own protocol +Covers (see each check's docstring for detail): - abuse/open-relay behaviour and the accept-gate mitigations for each direction (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER, _RECIPIENT), including their explicit opt-out modes + - username enumeration via the Postfix lookup service's own protocol - sender spoofing: confirms a forged MAIL FROM is never forwarded into the relayed smolmail message (email -> smolmail carries no Email-From field at all, precisely because MAIL FROM is unauthenticated) +The unit-style checks the Python version used to run here (address +validation fuzz, the frontmatter sanitizer, the CR/LF header backstop, +malformed-MIME resilience, poll-loop failure isolation) live in the Rust +implementation's `cargo test` suite now -- see the tests in +src/smtp_client.rs, src/email_to_smol.rs and src/smol_to_email.rs. + Each check prints PASS (a defense verified), RISK (confirmed, accepted design tradeoff -- not a bug, but worth knowing it's real), or BUG (unexpected, worth fixing). Usage: - uv run python scripts/security_test.py - uv run python scripts/security_test.py --skip-live # unit-style checks only + uv run scripts/security_test.py -Needs a `smolmail` checkout next to this repo for the live checks (same -default pneumatube itself uses: PNEUMATUBE_SMOLMAIL_REPO, default -"../smolmail"). +Needs a `bunshin` and a `fumi` binary on PATH (or --server-bin/--fumi-bin), +a built pneumatube (--pneumatube-bin, default target/release/pneumatube), +and a `smolmail` checkout next to this repo for the load-driving client +(--smolmail-repo, default "../smolmail"). """ from __future__ import annotations import argparse import contextlib -import email as email_module -import logging import os -import subprocess -import sys +import socket import tempfile import time from dataclasses import dataclass -from email.message import EmailMessage from pathlib import Path +from urllib.parse import unquote from aiosmtpd.controller import Controller -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from stress_test import ( # noqa: E402 - REPO_ROOT, CountingSink, SmolAccount, SmolServer, free_port, make_account, - send_one_lmtp, send_one_smol, start_smolmaild, wait_for_port, +from stress_test import ( + REPO_ROOT, CountingSink, free_port, gateway_env, + load_smolmail, make_account, make_gateway_account, send_one_lmtp, + send_one_smol, start_bunshin, start_pneumatube, stop_proc, ) -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) -from pneumatube import smtp_client, vendor # noqa: E402 -from pneumatube.email_to_smol import EmailToSmolBridge, _single_line # noqa: E402 -from pneumatube.lookup_server import _lookup # noqa: E402 - @dataclass class Finding: @@ -74,255 +73,19 @@ def record(check: str, kind: str, detail: str) -> None: FINDINGS.append(f) -# --- injection fuzzing (no live infra needed) ----------------------------------- +def lookup_get(host: str, port: int, key: str) -> str: + with socket.create_connection((host, port), timeout=10) as s: + s.sendall(f"get {key}\n".encode()) + return s.recv(4096).decode().strip() -INJECTION_PAYLOADS = [ - ("bare-lf", "evil@example.com\nX-Injected: 1"), - ("bare-cr", "evil@example.com\rX-Injected: 1"), - ("crlf", "evil@example.com\r\nBcc: evil@example.com"), - ("nul-byte", "evil@example.com\x00X-Injected: 1"), - ("unicode-line-sep", "evil@example.com
X-Injected: 1"), - ("unicode-para-sep", "evil@example.com
X-Injected: 1"), - ("very-long", "a" * 10_000 + "@example.com"), -] - - -def test_address_validation_fuzz() -> None: - """smtp_client.validate() is the only gate between an attacker-supplied - Email-To frontmatter value and smtplib.SMTP's RCPT TO -- it must reject - every CR/LF variant, not just the literal \\r\\n case the unit tests - already cover.""" - failures = [] - for name, payload in INJECTION_PAYLOADS: - try: - smtp_client.validate(payload) - # unicode-line-sep/para-sep and very-long aren't CR/LF and may - # legitimately pass the regex; only bare-lf/bare-cr/crlf/nul-byte - # must always be rejected. - if name in ("bare-lf", "bare-cr", "crlf", "nul-byte"): - failures.append(name) - except smtp_client.InvalidAddress: - pass - record("address validation fuzz", "pass" if not failures else "bug", - "all CR/LF/NUL variants rejected" if not failures - else f"accepted dangerous payloads: {failures}") - - -def test_single_line_fuzz() -> None: - """email_to_smol._single_line() is what stops a forged email Subject - from injecting extra smolmail frontmatter fields (SPEC.md S5.5: one - `Key: value` per line) into an otherwise-trusted message. (MAIL FROM no - longer flows into frontmatter at all -- see email_to_smol.py's module - docstring -- but Subject still does.)""" - failures = [] - for name, payload in INJECTION_PAYLOADS: - cleaned = _single_line(payload) - if "\n" in cleaned or "\r" in cleaned: - failures.append(name) - record("frontmatter sanitizer fuzz", "pass" if not failures else "bug", - "no payload left a raw CR/LF in the sanitized value" if not failures - else f"raw CR/LF survived sanitization: {failures}") - - -def test_outbound_header_injection() -> None: - """Even though frontmatter values can't structurally contain a raw - newline (smolmail.py's parser splits the body into single lines before a - value is ever extracted), smtp_client.send() has no explicit sanitizer - of its own -- it currently relies entirely on that upstream guarantee - plus Python's EmailMessage rejecting embedded CR/LF. Confirm the second - layer actually holds, since the first is an implicit dependency on - vendored code outside this project.""" - try: - m = EmailMessage() - m["Subject"] = "hi\r\nBcc: evil@attacker.example" - rejected = False - except ValueError: - rejected = True - record("stdlib header-injection backstop", "pass" if rejected else "bug", - "EmailMessage rejects embedded CR/LF in header values (confirmed)" if rejected - else "EmailMessage accepted an embedded CR/LF -- smtp_client.send() has no " - "sanitizer of its own and would forward it verbatim") - - -# --- malformed MIME resilience (no live infra needed) --------------------------- - - -def test_malformed_mime_resilience() -> None: - """extract_text() runs on every inbound email before any validation -- - it must not crash or hang on adversarial MIME, since a single crash - taking down the LMTP handler would be a trivial DoS against every other - in-flight recipient in the same message.""" - cases = { - "empty": b"", - "no-body": b"Subject: hi\r\n\r\n", - "non-utf8-bytes": b"Subject: hi\r\nContent-Type: text/plain\r\n\r\n\xff\xfe\x00\x01", - "multipart-no-text-part": ( - b"Content-Type: multipart/mixed; boundary=X\r\n\r\n--X\r\n" - b"Content-Type: application/octet-stream\r\n\r\nbinary\r\n--X--\r\n"), - "deeply-nested-multipart": _nested_multipart(50), - "declared-huge-missing-body": ( - b"Content-Type: text/plain\r\nContent-Length: 999999999\r\n\r\nshort"), - } - failures = [] - for name, raw in cases.items(): - try: - message = email_module.message_from_bytes(raw) - t0 = time.monotonic() - EmailToSmolBridge.extract_text(message) - if time.monotonic() - t0 > 2.0: - failures.append(f"{name} (slow: possible algorithmic DoS)") - except Exception as exc: - failures.append(f"{name} ({exc!r})") - record("malformed MIME resilience", "pass" if not failures else "bug", - "all cases handled without raising or stalling" if not failures - else f"crashed or stalled on: {failures}") - - -def _nested_multipart(depth: int) -> bytes: - body = b"leaf" - for i in range(depth): - boundary = f"B{i}".encode() - body = (b"Content-Type: multipart/mixed; boundary=" + boundary + b"\r\n\r\n--" - + boundary + b"\r\n" + body + b"\r\n--" + boundary + b"--\r\n") - return b"Content-Type: multipart/mixed; boundary=B0\r\n\r\n--B0\r\n" + body + b"\r\n--B0--\r\n" - - -# --- per-message failure isolation (no live infra needed) ---------------------- - - -def test_poll_loop_isolates_failures() -> None: - """A message that makes _relay() raise anything other than - InvalidAddress/OSError must not block every message behind it in the - smolmail -> email direction -- see smol_to_email.py's poll_once() for - why (a poisoned message is never deleted, so it sorts first forever).""" - from types import SimpleNamespace - - from pneumatube.smol_to_email import SmolToEmailBridge - - class StubSmol: - SmolError = RuntimeError - - def cmd_fetch(self, args, store): - pass - - class StubStore: - def mail(self, box): - return [(b"poison", b"", 0, None), (b"ok", b"", 1, None)] - - bridge = SmolToEmailBridge(StubSmol(), StubStore(), account=None, - config=SimpleNamespace(smolmail_key="k", smolmail_db="d", - require_accepted_sender=True)) - relayed = [] - - def fake_relay(mid, envelope): - if mid == b"poison": - raise ValueError("simulated") - relayed.append(mid) - - bridge._relay = fake_relay - # poll_once() logs the simulated failure via logger.exception(), which is - # the whole point of the fix -- quiet it here so it doesn't look like an - # unhandled crash in this script's own output. - logging.getLogger("pneumatube.smol_to_email").setLevel(logging.CRITICAL) - try: - bridge.poll_once() - record("poll loop failure isolation", "pass" if relayed == [b"ok"] else "bug", - "later messages still processed after an earlier one raised" if relayed == [b"ok"] - else "poll_once stopped after the first failure -- queue-jam DoS") - except Exception as exc: - record("poll loop failure isolation", "bug", - f"poll_once raised instead of isolating the failure: {exc!r}") - - -# --- username enumeration via the lookup service (no live infra needed) -------- - - -def test_lookup_enumeration() -> None: - """The Postfix lookup service (lookup_server.py) answers definitively - per username with no authentication and no rate limiting of its own -- - by design, it's meant to be reachable only from Postfix on a private - network. Confirm that property so the deployment docs' "loopback only" - requirement is understood as load-bearing, not cosmetic.""" - import asyncio - - class StubBridge: - def user_exists(self, username): - return username in ("alice", "bob") - - def recipient_accepted_gateway(self, username): - return True - - class InlineWorker: - async def run(self, fn, *args): - return fn(*args) - - from types import SimpleNamespace - config = SimpleNamespace(email_domain="gateway.example", require_accepted_recipient=True) - bridge, worker = StubBridge(), InlineWorker() - - # tcp_table(5) status is "200" either way (it means "I have an answer"); - # the OK-vs-reject distinction Postfix actually acts on is in the value. - results = {} - for name in ("alice", "bob", "carol", "admin", "root", "postmaster"): - reply = asyncio.run(_lookup(bridge, worker, config, f"{name}@gateway.example")) - _code, _, value = reply.partition(" ") - results[name] = value == "OK" - distinguishable = results["alice"] and results["bob"] and not any( - results[n] for n in ("carol", "admin", "root", "postmaster")) - record("lookup service enumeration", "risk" if distinguishable else "bug", - "the lookup protocol unambiguously reveals which usernames exist -- expected, " - "it must for check_recipient_access to work, but this is only safe because the " - "service is loopback-only; exposing it beyond Postfix would be a username " - "enumeration oracle" if distinguishable else - "lookup responses did not distinguish known from unknown usernames as expected " - "-- check_recipient_access would misbehave") - - -# --- live tests: abuse/throttling and sender spoofing --------------------------- - - -def _start_pneumatube(smolmail_repo: Path, gateway: SmolAccount, server: SmolServer, - sink_port: int, require_accepted_sender: bool = True, - require_accepted_recipient: bool = True - ) -> tuple[subprocess.Popen, int]: - listen_port = free_port() - env = {**os.environ, - "PNEUMATUBE_SMOLMAIL_REPO": str(smolmail_repo), - "PNEUMATUBE_SMOLMAIL_KEY": str(gateway.key_path), - "PNEUMATUBE_SMOLMAIL_DB": str(gateway.db_path), - "PNEUMATUBE_SMOLMAIL_DOMAIN": f"{server.host}:{server.port}", - "PNEUMATUBE_EMAIL_DOMAIN": "gateway.example", - "PNEUMATUBE_FETCH_INTERVAL": "2", - "PNEUMATUBE_REQUIRE_ACCEPTED_SENDER": "1" if require_accepted_sender else "0", - "PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT": "1" if require_accepted_recipient else "0", - "PNEUMATUBE_LISTEN_PROTOCOL": "lmtp", - "PNEUMATUBE_LISTEN_HOST": "127.0.0.1", - "PNEUMATUBE_LISTEN_PORT": str(listen_port), - "PNEUMATUBE_LOOKUP_HOST": "127.0.0.1", - "PNEUMATUBE_LOOKUP_PORT": str(free_port()), - "PNEUMATUBE_SMTP_RELAY_HOST": "127.0.0.1", - "PNEUMATUBE_SMTP_RELAY_PORT": str(sink_port), - "PNEUMATUBE_SMTP_RELAY_STARTTLS": "0", - "PNEUMATUBE_GATEWAY_FROM_ADDRESS": "gateway@gateway.example"} - proc = subprocess.Popen(["uv", "run", "pneumatube"], cwd=REPO_ROOT, env=env, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - wait_for_port("127.0.0.1", listen_port) - return proc, listen_port - - -def _stop(proc: subprocess.Popen) -> None: - proc.terminate() - with contextlib.suppress(Exception): - proc.wait(timeout=5) - - -def run_live_tests(smolmail_repo: Path) -> None: +def run_live_tests(args: argparse.Namespace) -> None: + smolmail_repo = Path(args.smolmail_repo).resolve() + smol = load_smolmail(smolmail_repo) with tempfile.TemporaryDirectory(prefix="pneumatube-sectest-") as tmp: tmp_dir = Path(tmp) - server = start_smolmaild(smolmail_repo, tmp_dir, rate_connections=6000, rate_sends=6000) - smol = vendor.load(smolmail_repo) - gateway = make_account(smol, tmp_dir, "gatewayuser", server) + server = start_bunshin(args.server_bin, tmp_dir, rate_connections=6000, rate_sends=6000) + gateway = make_gateway_account(args.fumi_bin, tmp_dir, "gatewayuser", server) victim = make_account(smol, tmp_dir, "victim", server) spoof_victim = make_account(smol, tmp_dir, "spoofvictim", server) @@ -331,13 +94,16 @@ def run_live_tests(smolmail_repo: Path) -> None: sink_controller = Controller(sink, hostname="127.0.0.1", port=sink_port) sink_controller.start() - # -- smolmail -> email, default config (require_accepted_sender=True) -- - # 15 messages from 15 DIFFERENT, never-before-seen sender identities - # (no accept token, no prior relationship), each addressed to a - # different external destination. None should reach the sink. - proc, listen_port = _start_pneumatube(smolmail_repo, gateway, server, sink_port, - require_accepted_sender=True) + # -- default config: both accept gates on -- + listen_port, lookup_port = free_port(), free_port() + env = gateway_env(gateway, server, require_accepted_sender=True, + require_accepted_recipient=True) + proc = start_pneumatube(args.pneumatube_bin, env, listen_port, lookup_port, sink_port) try: + # 15 messages from 15 DIFFERENT, never-before-seen sender + # identities (no accept token, no prior relationship), each + # addressed to a different external destination. None should + # reach the sink. senders = [make_account(smol, tmp_dir, f"stranger{i}", server) for i in range(15)] for i, sender in enumerate(senders): send_one_smol(smol, sender, gateway.address, f"dest{i}@external.example", @@ -349,9 +115,8 @@ def run_live_tests(smolmail_repo: Path) -> None: f"{delivered}/{len(senders)} first-contact messages reached the outbound " "sink with PNEUMATUBE_REQUIRE_ACCEPTED_SENDER at its default (1) -- " + ("none did, as intended" if delivered == 0 else - "some did; the accept gate in smol_to_email.py is not holding")) + "some did; the accept gate in smol_to_email is not holding")) - # -- email -> smolmail, default config (require_accepted_recipient=True) -- # "victim" is registered on the smolmail server (so user_exists # passes) but has never accepted the gateway (no token held) -- # the all-or-nothing gate should reject this live, not queue it. @@ -363,18 +128,48 @@ def run_live_tests(smolmail_repo: Path) -> None: f"outcome={outcome} with PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT at its " "default (1) for a registered-but-never-accepted recipient -- " + ("rejected live at RCPT TO, as intended" if outcome == "rejected" else - "was not rejected; the gate in handle_RCPT/_lookup is not holding")) + "was not rejected; the gate in the LMTP handler and lookup service " + "is not holding")) + + # The lookup service answers definitively per username with no + # authentication and no rate limiting of its own -- by design, + # it's meant to be reachable only from Postfix on a private + # network. Confirm that property so the deployment docs' + # "loopback only" requirement is understood as load-bearing, + # not cosmetic. Both accept-gate replies ("550 5.7.1 ...") and + # "550 5.1.1 no such user" reveal which usernames exist, just + # as an "OK" would; the OK-vs-reject distinction Postfix acts + # on is a different axis from the exists-or-not distinction an + # enumerator wants. + results = {} + for name in ("victim", "spoofvictim", "carol", "admin", "root", "postmaster"): + reply = lookup_get("127.0.0.1", lookup_port, f"{name}@gateway.example") + # The reply's action text is %XX-quoted on the wire; a + # known username answers anything but "no such user". + action = unquote(reply.split(" ", 1)[1]) if " " in reply else reply + results[name] = action != "550 5.1.1 no such user" + distinguishable = (results["victim"] and results["spoofvictim"] + and not any(results[n] for n in + ("carol", "admin", "root", "postmaster"))) + record("lookup service enumeration", "risk" if distinguishable else "bug", + "the lookup protocol unambiguously reveals which usernames exist -- " + "expected, it must for check_recipient_access to work, but this is " + "only safe because the service is loopback-only; exposing it beyond " + "Postfix would be a username enumeration oracle" if distinguishable else + "lookup responses did not distinguish known from unknown usernames as " + "expected -- check_recipient_access would misbehave") finally: - _stop(proc) + stop_proc(proc) # -- both directions, explicit opt-in to the open-bridge mode -- # Confirms both flags at 0 still do what they document: restore # "relay anyone" for an operator who deliberately wants it. victim # and spoof_victim never accepted the gateway, so this also doubles # as the opt-out confirmation for the recipient gate. - proc, listen_port = _start_pneumatube(smolmail_repo, gateway, server, sink_port, - require_accepted_sender=False, - require_accepted_recipient=False) + listen_port, lookup_port = free_port(), free_port() + env = gateway_env(gateway, server, require_accepted_sender=False, + require_accepted_recipient=False) + proc = start_pneumatube(args.pneumatube_bin, env, listen_port, lookup_port, sink_port) try: senders = [make_account(smol, tmp_dir, f"openbridge{i}", server) for i in range(15)] for i, sender in enumerate(senders): @@ -409,9 +204,9 @@ def run_live_tests(smolmail_repo: Path) -> None: # -- sender spoofing -- # Own recipient account, untouched by the open-relay tests above, # so finding the message isn't a race against unrelated mail. - # email_to_smol.py forwards no claim about the original sender at - # all (see its module docstring) specifically because MAIL FROM - # is unauthenticated; confirm a forged one doesn't leak through + # email_to_smol forwards no claim about the original sender at + # all (see its module docs) specifically because MAIL FROM is + # unauthenticated; confirm a forged one doesn't leak through # into any frontmatter field of the relayed message. forged = "security-team@trusted-bank.example" outcome, _lat = send_one_lmtp( @@ -446,34 +241,29 @@ def run_live_tests(smolmail_repo: Path) -> None: f"the forged MAIL FROM {forged!r} leaked into the relayed message's " f"frontmatter: {opened['fields']}") finally: - _stop(proc) + stop_proc(proc) sink_controller.stop() - _stop(server.process) + stop_proc(server.process) def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--skip-live", action="store_true", - help="run only the unit-style checks (no subprocess/network)") parser.add_argument("--smolmail-repo", default="../smolmail") + parser.add_argument("--server-bin", default="bunshin") + parser.add_argument("--fumi-bin", default="fumi") + parser.add_argument("--pneumatube-bin", default=str(REPO_ROOT / "target/release/pneumatube")) args = parser.parse_args() - test_address_validation_fuzz() - test_single_line_fuzz() - test_outbound_header_injection() - test_malformed_mime_resilience() - test_poll_loop_isolates_failures() - test_lookup_enumeration() - - if not args.skip_live: - run_live_tests(Path(args.smolmail_repo).resolve()) + run_live_tests(args) print() bugs = [f for f in FINDINGS if f.kind == "bug"] risks = [f for f in FINDINGS if f.kind == "risk"] print(f"{len(FINDINGS)} check(s): {len(FINDINGS) - len(bugs) - len(risks)} pass, " f"{len(risks)} confirmed accepted risk(s), {len(bugs)} bug(s)") + if bugs: + raise SystemExit(1) if __name__ == "__main__": diff --git a/scripts/stress_test.py b/scripts/stress_test.py index cdb7997..36a94d0 100644 --- a/scripts/stress_test.py +++ b/scripts/stress_test.py @@ -1,38 +1,51 @@ #!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["aiosmtpd>=1.4", "pynacl>=1.5", "noiseprotocol>=0.3.1"] +# /// """Concurrent load test for pneumatube. Two targets: direct Talks to a `pneumatube` subprocess directly over LMTP/SMTP, bypassing Postfix. Isolates pneumatube's own concurrency - behaviour -- in particular, SmolWorker (worker.py) serializes - every smolmail operation onto one thread, so this is where that - design's throughput ceiling actually shows up. + behaviour: with the Rust port there is no serialization point + (fumi's store is Send + Sync and every dial is its own Noise + session), so the ceiling here belongs to the smolmail server. postfix Drives the same load through the real docker-compose.yml rig (Postfix -> LMTP -> pneumatube), to confirm nothing changes with a real MTA in front. Needs podman (or docker) + compose. -Both targets spin up a throwaway smolmaild, register a gateway account plus -N recipient/sender accounts against it, generate concurrent load, and verify -every expected message actually arrived exactly once -- throughput numbers -are meaningless if messages were silently dropped or duplicated. +Both targets spin up a throwaway bunshin (the Rust smolmail server), a +gateway account plus N recipient/sender accounts against it, generate +concurrent load, and verify every expected message actually arrived +exactly once -- throughput numbers are meaningless if messages were +silently dropped or duplicated. + +The sender and recipient accounts are driven in-process through the +reference smolmail.py client, which doubles as an interop check: the +Python reference client against the Rust gateway and Rust server. Usage: - uv run python scripts/stress_test.py --target direct - uv run python scripts/stress_test.py --target postfix - uv run python scripts/stress_test.py --target both --concurrency 40 + uv run scripts/stress_test.py --target direct + uv run scripts/stress_test.py --target postfix + uv run scripts/stress_test.py --target both --concurrency 40 -Needs a `smolmail` checkout next to this repo (same default pneumatube -itself uses: PNEUMATUBE_SMOLMAIL_REPO, default "../smolmail"). +Needs a `bunshin` and a `fumi` binary on PATH (or --server-bin/--fumi-bin), +and a `smolmail` checkout next to this repo for the load-driving client +(--smolmail-repo, default "../smolmail"). """ from __future__ import annotations import argparse +import base64 import contextlib +import importlib.util import os import shutil import smtplib import socket +import sqlite3 import subprocess import sys import tempfile @@ -44,10 +57,24 @@ from pathlib import Path from aiosmtpd.controller import Controller -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) -from pneumatube import vendor # noqa: E402 - REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_PORT = 1961 + + +def load_smolmail(repo_path: Path): + """Loads the sibling reference client as a library. smolmail.py is a + single-file `uv run --script` program, not an installed package, so it + is loaded by path from its checkout; this script's inline dependencies + (pynacl, noiseprotocol) are declared in the PEP 723 block above so the + exec'd module resolves its imports.""" + module_path = repo_path / "smolmail.py" + if not module_path.is_file(): + raise FileNotFoundError(f"no smolmail.py at {module_path}; pass --smolmail-repo") + spec = importlib.util.spec_from_file_location("smolmail", module_path) + module = importlib.util.module_from_spec(spec) + sys.modules["smolmail"] = module + spec.loader.exec_module(module) + return module def free_port() -> int: @@ -111,42 +138,30 @@ class SmolServer: process: subprocess.Popen | None = None -def parse_pubkey(text: str) -> str: - for line in text.splitlines(): - if "public key" in line: - return line.strip().rsplit(" ", 1)[-1] - raise RuntimeError(f"no public key line in output:\n{text}") - - -def start_smolmaild(smolmail_repo: Path, tmp_dir: Path, rate_connections: int, - rate_sends: int) -> SmolServer: +def start_bunshin(binary: str, tmp_dir: Path, rate_connections: int, + rate_sends: int) -> SmolServer: port = free_port() key_path = tmp_dir / "server.key" - subprocess.run( - ["uv", "run", str(smolmail_repo / "smolmaild.py"), "keygen", "--key", str(key_path)], - cwd=smolmail_repo, check=True, capture_output=True, text=True) - process = subprocess.Popen( - ["uv", "run", str(smolmail_repo / "smolmaild.py"), "serve", - "--key", str(key_path), "--db", str(tmp_dir / "server.db"), - "--host", "127.0.0.1", "--port", str(port), - # smolmaild's connection/send rate limits are keyed by source IP - # (SPEC.md S10); every sender in this script shares one IP, so the - # defaults (120 conn/min, 60 sends/min) would measure the rate - # limiter, not pneumatube, unless raised for a many-sender test. - "--rate-connections", str(rate_connections), "--rate-sends", str(rate_sends)], - cwd=smolmail_repo, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + keygen = subprocess.run([binary, "keygen", "--key", str(key_path)], + capture_output=True, text=True, check=True) pubkey = None - deadline = time.monotonic() + 15 - while time.monotonic() < deadline: - line = process.stdout.readline() - if not line: - break - if "server public key" in line: - pubkey = line.strip().rsplit(" ", 1)[-1] + for line in keygen.stdout.splitlines(): + # "public key: ", not the trailing "Publish the public key..." + # sentence, which would otherwise match a looser contains-check. + if line.startswith("public key"): + pubkey = line.split()[-1] break if pubkey is None: - process.kill() - raise RuntimeError("smolmaild never printed its public key") + raise RuntimeError(f"no public key line in bunshin keygen output:\n{keygen.stdout}") + process = subprocess.Popen( + [binary, "serve", "--key", str(key_path), "--db", str(tmp_dir / "server.db"), + "--host", "127.0.0.1", "--port", str(port), + # bunshin's connection/send rate limits are keyed by source IP; + # every sender in this script shares one IP, so the defaults would + # measure the rate limiter, not pneumatube, unless raised for a + # many-sender test. + "--rate-connections", str(rate_connections), "--rate-sends", str(rate_sends)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) wait_for_port("127.0.0.1", port) return SmolServer("127.0.0.1", port, pubkey, process) @@ -160,14 +175,11 @@ class SmolAccount: def make_account(smol, tmp_dir: Path, name: str, server: SmolServer) -> SmolAccount: - """Keygen + trust + register, reusing smolmail.py's own CLI commands - directly (the same functions the gateway itself calls) rather than - reimplementing the protocol or shelling out per account. - - A bare TCP connect (wait_for_port) can succeed slightly before a - containerized smolmaild can actually complete a Noise handshake, so - register is retried a few times rather than treating that race as fatal. - """ + """Keygen + trust + register, reusing smolmail.py's own CLI functions + directly (the reference client) rather than reimplementing the + protocol or shelling out per account. Registered with retries, since a + bare TCP connect can succeed slightly before the server completes + Noise handshakes.""" key_path, db_path = tmp_dir / f"{name}.key", tmp_dir / f"{name}.db" store = smol.Store(str(db_path)) smol.cmd_keygen(argparse.Namespace(key=str(key_path), force=True), store) @@ -187,14 +199,48 @@ def make_account(smol, tmp_dir: Path, name: str, server: SmolServer) -> SmolAcco return SmolAccount(name, key_path, db_path, address) -def repoint_gateway_account(smol, gateway: SmolAccount, host: str, port: int, - pubkey_b32: str) -> None: +def make_gateway_account(fumi_bin: str, tmp_dir: Path, name: str, server: SmolServer) -> SmolAccount: + """The gateway's own state is a fumi store, so it is bootstrapped with + the fumi CLI: keygen + trust + register, retried for the same + slow-to-handshake reason as make_account.""" + key_path, db_path = tmp_dir / f"{name}.key", tmp_dir / f"{name}.db" + run = lambda *args: subprocess.run( + [fumi_bin, "--key", str(key_path), "--db", str(db_path), *args], + capture_output=True, text=True) + run("keygen") + trust = run("trust", f"{server.host}:{server.port}", server.pubkey_b32) + if trust.returncode != 0: + raise RuntimeError(f"fumi trust failed: {trust.stdout}{trust.stderr}") + address = f"{name}@{server.host}:{server.port}" + for attempt in range(20): + register = run("register", address) + if register.returncode == 0: + return SmolAccount(name, key_path, db_path, address) + if attempt == 19: + raise RuntimeError(f"fumi register failed: {register.stdout}{register.stderr}") + time.sleep(1.0) + raise RuntimeError("unreachable") + + +def repoint_gateway_account(gateway: SmolAccount, host: str, port: int, + pubkey_b32: str, fumi_bin: str) -> None: """Point the gateway's own remembered server connection at a different - host string (e.g. a compose service name) without a real round trip -- - `trust` only ever stores a pin locally (smolmail.py's own cmd_trust).""" - store = smol.Store(str(gateway.db_path)) - smol.cmd_trust(argparse.Namespace(host=host, key_b32=pubkey_b32, force=True), store) - store.set_account(smol.Address(gateway.username, host, port)) + host string (e.g. a compose service name) without a real round trip: + `fumi trust` only ever stores a pin locally, and the account row is + rewritten directly in the store (fumi schema v2: one state row, pins + keyed by host[:port]).""" + trust = subprocess.run( + [fumi_bin, "--key", str(gateway.key_path), "--db", str(gateway.db_path), + "trust", f"{host}:{port}", pubkey_b32], + capture_output=True, text=True) + if trust.returncode != 0: + raise RuntimeError(f"fumi trust failed: {trust.stdout}{trust.stderr}") + con = sqlite3.connect(gateway.db_path) + try: + con.execute("UPDATE state SET host = ?, port = ? WHERE id = 1", (host, port)) + con.commit() + finally: + con.close() # --- outbound SMTP sink (stands in for the real relay) ------------------------- @@ -283,6 +329,49 @@ def run_concurrent(report: Report, concurrency: int, jobs: list) -> list[bool]: return valid_flags +def gateway_env(gateway: SmolAccount, server: SmolServer, *, + require_accepted_sender: bool = False, + require_accepted_recipient: bool = False, + **extra: str) -> dict[str, str]: + env = {**os.environ, + "PNEUMATUBE_SMOLMAIL_KEY": str(gateway.key_path), + "PNEUMATUBE_SMOLMAIL_DB": str(gateway.db_path), + "PNEUMATUBE_SMOLMAIL_DOMAIN": f"{server.host}:{server.port}", + "PNEUMATUBE_EMAIL_DOMAIN": "gateway.example", + "PNEUMATUBE_FETCH_INTERVAL": "2", + # The accept-gate defaults live here, not in the docstring's + # "throughput, not gates" comment: the stress test relaxes them + # because its throwaway accounts never go through the accept + # dance, while the security test needs each mode explicitly. + "PNEUMATUBE_REQUIRE_ACCEPTED_SENDER": "1" if require_accepted_sender else "0", + "PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT": "1" if require_accepted_recipient else "0", + "PNEUMATUBE_LISTEN_PROTOCOL": "lmtp", + "PNEUMATUBE_LISTEN_HOST": "127.0.0.1", + "PNEUMATUBE_SMTP_RELAY_STARTTLS": "0", + "PNEUMATUBE_GATEWAY_FROM_ADDRESS": "gateway@gateway.example"} + env.update(extra) + return env + + +def start_pneumatube(binary: str, env: dict[str, str], listen_port: int, + lookup_port: int, sink_port: int) -> subprocess.Popen: + env.update({"PNEUMATUBE_LISTEN_PORT": str(listen_port), + "PNEUMATUBE_LOOKUP_HOST": "127.0.0.1", + "PNEUMATUBE_LOOKUP_PORT": str(lookup_port), + "PNEUMATUBE_SMTP_RELAY_HOST": "127.0.0.1", + "PNEUMATUBE_SMTP_RELAY_PORT": str(sink_port)}) + proc = subprocess.Popen([binary], cwd=REPO_ROOT, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + wait_for_port("127.0.0.1", listen_port) + return proc + + +def stop_proc(proc: subprocess.Popen) -> None: + proc.terminate() + with contextlib.suppress(Exception): + proc.wait(timeout=5) + + # --- direct target --------------------------------------------------------------- @@ -290,12 +379,12 @@ def run_direct(args: argparse.Namespace) -> None: smolmail_repo = Path(args.smolmail_repo).resolve() with tempfile.TemporaryDirectory(prefix="pneumatube-stress-") as tmp: tmp_dir = Path(tmp) - print(f"[direct] starting smolmaild in {tmp_dir}") - server = start_smolmaild(smolmail_repo, tmp_dir, args.server_rate_connections, - args.server_rate_sends) - smol = vendor.load(smolmail_repo) + print(f"[direct] starting bunshin in {tmp_dir}") + server = start_bunshin(args.server_bin, tmp_dir, args.server_rate_connections, + args.server_rate_sends) + smol = load_smolmail(smolmail_repo) - gateway = make_account(smol, tmp_dir, "gatewayuser", server) + gateway = make_gateway_account(args.fumi_bin, tmp_dir, "gatewayuser", server) recipients = [make_account(smol, tmp_dir, f"recipient{i}", server) for i in range(args.recipients)] senders = [make_account(smol, tmp_dir, f"sender{i}", server) @@ -309,32 +398,11 @@ def run_direct(args: argparse.Namespace) -> None: sink_controller.start() listen_port, lookup_port = free_port(), free_port() - env = {**os.environ, - "PNEUMATUBE_SMOLMAIL_REPO": str(smolmail_repo), - "PNEUMATUBE_SMOLMAIL_KEY": str(gateway.key_path), - "PNEUMATUBE_SMOLMAIL_DB": str(gateway.db_path), - "PNEUMATUBE_SMOLMAIL_DOMAIN": f"{server.host}:{server.port}", - "PNEUMATUBE_EMAIL_DOMAIN": "gateway.example", - "PNEUMATUBE_FETCH_INTERVAL": "2", - # Throughput, not the accept-gate features, is this script's - # concern -- the throwaway recipient/sender accounts here - # never go through the accept dance with the gateway. - "PNEUMATUBE_REQUIRE_ACCEPTED_SENDER": "0", - "PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT": "0", - "PNEUMATUBE_LISTEN_PROTOCOL": "lmtp", - "PNEUMATUBE_LISTEN_HOST": "127.0.0.1", - "PNEUMATUBE_LISTEN_PORT": str(listen_port), - "PNEUMATUBE_LOOKUP_HOST": "127.0.0.1", - "PNEUMATUBE_LOOKUP_PORT": str(lookup_port), - "PNEUMATUBE_SMTP_RELAY_HOST": "127.0.0.1", - "PNEUMATUBE_SMTP_RELAY_PORT": str(sink_port), - "PNEUMATUBE_SMTP_RELAY_STARTTLS": "0", - "PNEUMATUBE_GATEWAY_FROM_ADDRESS": "gateway@gateway.example"} + env = gateway_env(gateway, server, PNEUMATUBE_LOOKUP_HOST="127.0.0.1", + PNEUMATUBE_LOOKUP_PORT=str(lookup_port)) print("[direct] starting pneumatube") - proc = subprocess.Popen(["uv", "run", "pneumatube"], cwd=REPO_ROOT, env=env, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + proc = start_pneumatube(args.pneumatube_bin, env, listen_port, lookup_port, sink_port) try: - wait_for_port("127.0.0.1", listen_port) wait_for_port("127.0.0.1", lookup_port) # Phase A: email -> smolmail, concurrent LMTP, mixed valid/invalid. @@ -362,20 +430,16 @@ def run_direct(args: argparse.Namespace) -> None: report_b = Report("smolmail -> email (SEND + poll drain)") run_concurrent(report_b, args.concurrency, jobs) - drain_deadline = time.monotonic() + max(30.0, total * 0.05) + drain_deadline = time.monotonic() + max(60.0, total * 0.25) while sink.count() < report_b.succeeded and time.monotonic() < drain_deadline: time.sleep(0.5) drained = sink.count() print(f" drained {drained}/{report_b.succeeded} into the outbound sink " f"({'PASS' if drained == report_b.succeeded else 'FAIL: lost or stuck messages'})") finally: - proc.terminate() - with contextlib.suppress(Exception): - proc.wait(timeout=5) + stop_proc(proc) sink_controller.stop() - server.process.terminate() - with contextlib.suppress(Exception): - server.process.wait(timeout=5) + stop_proc(server.process) # --- postfix target --------------------------------------------------------------- @@ -385,13 +449,18 @@ def podman(*cmd: str, **kwargs) -> subprocess.CompletedProcess: return subprocess.run(["podman", *cmd], text=True, capture_output=True, **kwargs) +# The reference smolmaild runs in a container only for this rig; the uv +# image ships a python that can resolve its PEP 723 inline dependencies. +UV_IMAGE = "ghcr.io/astral-sh/uv:python3.13-bookworm-slim" + + def run_postfix(args: argparse.Namespace) -> None: if shutil.which("podman") is None: print("[postfix] podman not found; skipping", file=sys.stderr) return smolmail_repo = Path(args.smolmail_repo).resolve() - smol = vendor.load(smolmail_repo) + smol = load_smolmail(smolmail_repo) network = "pneumatube-stress-net" env = {**os.environ, "PNEUMATUBE_SMOLMAIL_DOMAIN": "smolmail-dev:1961", @@ -419,18 +488,23 @@ def run_postfix(args: argparse.Namespace) -> None: server_dir = tmp_dir / "server" server_dir.mkdir() keygen = podman("run", "--rm", "-v", f"{smolmail_repo}:/smolmail:ro", - "-v", f"{server_dir}:/data", - "localhost/pneumatube_pneumatube:latest", - "uv", "run", "/smolmail/smolmaild.py", "keygen", "--key", "/data/server.key") + "-v", f"{server_dir}:/data", UV_IMAGE, + "uv", "run", "/smolmail/smolmaild.py", "keygen", + "--key", "/data/server.key") keygen.check_returncode() - pubkey = parse_pubkey(keygen.stdout) + pubkey = None + for line in keygen.stdout.splitlines(): + if "public key" in line and "Publish" not in line: + pubkey = line.strip().rsplit(" ", 1)[-1] + if pubkey is None: + raise RuntimeError(f"no public key in smolmaild keygen output:\n{keygen.stdout}") host_port = free_port() podman("run", "-d", "--name", "pneumatube-stress-smolmaild", "--network", network, "--network-alias", "smolmail-dev", "-p", f"{host_port}:1961", "-v", f"{smolmail_repo}:/smolmail:ro", "-v", f"{server_dir}:/data", - "localhost/pneumatube_pneumatube:latest", + UV_IMAGE, "uv", "run", "/smolmail/smolmaild.py", "serve", "--key", "/data/server.key", "--db", "/data/server.db", "--host", "0.0.0.0", "--port", "1961", @@ -439,12 +513,21 @@ def run_postfix(args: argparse.Namespace) -> None: wait_for_port("127.0.0.1", host_port) server = SmolServer("127.0.0.1", host_port, pubkey) - gateway = make_account(smol, tmp_dir, "gatewayuser", server) + gateway = make_gateway_account(args.fumi_bin, tmp_dir, "gatewayuser", server) recipients = [make_account(smol, tmp_dir, f"recipient{i}", server) for i in range(args.recipients)] - repoint_gateway_account(smol, gateway, "smolmail-dev", 1961, pubkey) + # The gateway db was created against 127.0.0.1:, which + # only exists on the host; the pneumatube container resolves the + # compose-network alias instead. + repoint_gateway_account(gateway, "smolmail-dev", 1961, pubkey, args.fumi_bin) (data_dir / "gateway.key").write_bytes(gateway.key_path.read_bytes()) (data_dir / "gateway.db").write_bytes(gateway.db_path.read_bytes()) + # A leftover journal from an earlier rig run must not be applied + # to the fresh db copy (WAL salts would not match a foreign db, + # but the files must not leak into the image either). + for name in ("gateway.db-wal", "gateway.db-shm"): + with contextlib.suppress(FileNotFoundError): + (data_dir / name).unlink() print("[postfix] starting pneumatube + postfix") subprocess.run(["podman", "compose", "up", "-d"], cwd=REPO_ROOT, env=env, check=True) @@ -495,9 +578,12 @@ def run_postfix(args: argparse.Namespace) -> None: subprocess.run(["podman", "compose", "down"], cwd=REPO_ROOT, env=env) podman("rm", "-f", "pneumatube-stress-smolmaild") podman("network", "rm", network) - with contextlib.suppress(Exception): - (data_dir / "gateway.key").unlink() - (data_dir / "gateway.db").unlink() + # The gateway db is a WAL-mode sqlite file; a stale -wal/-shm + # left behind would poison the next run's fresh db copy. + for name in ("gateway.key", "gateway.db", "gateway.db-wal", "gateway.db-shm"): + with contextlib.suppress(FileNotFoundError): + (data_dir / name).unlink() + with contextlib.suppress(OSError): data_dir.rmdir() @@ -512,13 +598,18 @@ def main() -> None: parser.add_argument("--recipients", type=int, default=10, help="number of pre-registered valid recipient accounts") parser.add_argument("--smolmail-repo", default="../smolmail") + parser.add_argument("--server-bin", default="bunshin", + help="bunshin binary for the throwaway test server") + parser.add_argument("--fumi-bin", default="fumi", + help="fumi binary used to bootstrap the gateway account") + parser.add_argument("--pneumatube-bin", default=str(REPO_ROOT / "target/release/pneumatube")) parser.add_argument("--server-rate-connections", type=int, default=6000, - help="smolmaild --rate-connections for the throwaway test server " - "(default 120/min would rate-limit this whole test, since every " - "sender shares one source IP; pass 120 to test against that " - "realistic default instead)") + help="server --rate-connections for the throwaway test server " + "(default 120/min would rate-limit this whole test, since " + "every sender shares one source IP; pass 120 to test against " + "that realistic default instead)") parser.add_argument("--server-rate-sends", type=int, default=6000, - help="smolmaild --rate-sends for the throwaway test server " + help="server --rate-sends for the throwaway test server " "(see --server-rate-connections; realistic default is 60)") args = parser.parse_args() diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c5bef05 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,117 @@ +//! Runtime configuration, read from environment variables. + +use std::path::PathBuf; + +/// Frontmatter is case-insensitive and flat (smolmail SPEC.md S5.5); this is +/// the only contract the smolmail -> email direction relies on. The reverse +/// direction deliberately carries no analogous custom field -- see +/// `email_to_smol`'s module docs for why. +pub const FRONTMATTER_EMAIL_KEY: &str = "Email-To"; + +pub const LMTP_DEFAULT_PORT: u16 = 2424; +pub const SMTP_DEFAULT_PORT: u16 = 1025; +pub const LOOKUP_DEFAULT_PORT: u16 = 2526; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListenProtocol { + /// Behind a real MTA (Postfix) that owns port 25 and hands accepted + /// mail to this listener as the final delivery agent. + Lmtp, + /// Local-dev fallback only: bound directly to the internet it has no + /// TLS, rate limiting or rDNS checks. + Smtp, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub smolmail_key: PathBuf, // gateway's own master secret file + pub smolmail_db: PathBuf, // gateway's own smolmail client state + pub smolmail_domain: String, // smolmail server address in "user@..." addresses + pub email_domain: String, // domain this gateway accepts inbound mail for + pub fetch_interval: u64, // seconds between smolmail inbox polls + + pub listen_protocol: ListenProtocol, + pub listen_host: String, + pub listen_port: u16, + + /// Postfix tcp_table(5) lookup service backing check_recipient_access, + /// so an unknown user is rejected live during RCPT TO instead of + /// bouncing after Postfix has already accepted the message (lmtp mode + /// only). + pub lookup_host: String, + pub lookup_port: u16, + + pub smtp_relay_host: String, + pub smtp_relay_port: u16, + pub smtp_relay_user: Option, + pub smtp_relay_password: Option, + pub smtp_relay_starttls: bool, + + /// From: header used on mail forwarded to SMTP. + pub gateway_from_address: String, + + /// If true (the default), only relay smolmail messages from senders the + /// gateway account has accepted (SPEC.md S5.8) -- a first-contact sender + /// with no accept token lands in the requests tier and is left alone. + /// Set to false to run an intentionally open bridge instead. + pub require_accepted_sender: bool, + + /// If true (the default), only relay inbound email to a smolmail user + /// who has accepted the gateway (SPEC.md S5.8) -- an all-or-nothing + /// RCPT-time reject, not a demotion. See `email_to_smol`'s module docs. + pub require_accepted_recipient: bool, +} + +fn env(name: &str) -> Result { + std::env::var(name).map_err(|_| format!("missing required environment variable {name}")) +} + +fn env_or(name: &str, default: &str) -> String { + std::env::var(name).unwrap_or_else(|_| default.to_string()) +} + +fn int_env(name: &str, default: u64) -> Result { + match std::env::var(name) { + Ok(value) => value + .parse() + .map_err(|_| format!("{name} is not an integer: {value:?}")), + Err(_) => Ok(default), + } +} + +pub fn load() -> Result { + let listen_protocol = match env_or("PNEUMATUBE_LISTEN_PROTOCOL", "lmtp").as_str() { + "lmtp" => ListenProtocol::Lmtp, + "smtp" => ListenProtocol::Smtp, + other => { + return Err(format!( + "PNEUMATUBE_LISTEN_PROTOCOL must be one of (lmtp, smtp), got {other:?}" + )) + } + }; + let default_port = match listen_protocol { + ListenProtocol::Lmtp => LMTP_DEFAULT_PORT, + ListenProtocol::Smtp => SMTP_DEFAULT_PORT, + }; + + Ok(Config { + smolmail_key: PathBuf::from(env_or("PNEUMATUBE_SMOLMAIL_KEY", "gateway.key")), + smolmail_db: PathBuf::from(env_or("PNEUMATUBE_SMOLMAIL_DB", "gateway.db")), + smolmail_domain: env("PNEUMATUBE_SMOLMAIL_DOMAIN")?, + email_domain: env("PNEUMATUBE_EMAIL_DOMAIN")?, + fetch_interval: int_env("PNEUMATUBE_FETCH_INTERVAL", 30)?, + listen_protocol, + listen_host: env_or("PNEUMATUBE_LISTEN_HOST", "127.0.0.1"), + listen_port: int_env("PNEUMATUBE_LISTEN_PORT", default_port.into())? as u16, + lookup_host: env_or("PNEUMATUBE_LOOKUP_HOST", "127.0.0.1"), + lookup_port: int_env("PNEUMATUBE_LOOKUP_PORT", LOOKUP_DEFAULT_PORT.into())? as u16, + smtp_relay_host: env("PNEUMATUBE_SMTP_RELAY_HOST")?, + smtp_relay_port: int_env("PNEUMATUBE_SMTP_RELAY_PORT", 587)? as u16, + smtp_relay_user: std::env::var("PNEUMATUBE_SMTP_RELAY_USER").ok(), + smtp_relay_password: std::env::var("PNEUMATUBE_SMTP_RELAY_PASSWORD").ok(), + smtp_relay_starttls: env_or("PNEUMATUBE_SMTP_RELAY_STARTTLS", "1") != "0", + gateway_from_address: env("PNEUMATUBE_GATEWAY_FROM_ADDRESS")?, + require_accepted_sender: env_or("PNEUMATUBE_REQUIRE_ACCEPTED_SENDER", "1") != "0", + require_accepted_recipient: env_or("PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT", "1") != "0", + }) +} diff --git a/src/email_to_smol.rs b/src/email_to_smol.rs new file mode 100644 index 0000000..097589f --- /dev/null +++ b/src/email_to_smol.rs @@ -0,0 +1,314 @@ +//! email -> smolmail direction. +//! +//! Confirms a username is registered on the configured smolmail server, and +//! that they have accepted the gateway's own smolmail identity (SPEC.md +//! S5.8), before relaying an inbound email to them as a smolmail message. +//! This is an all-or-nothing gate, not a demotion: without it, the +//! gateway's SEND would still go through and land in the recipient's own +//! requests tier (still delivered, just quota-limited); with it, pneumatube +//! refuses the RCPT entirely, so an unaccepted recipient gets nothing at +//! all. Set PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 to fall back to that +//! softer, smolmail-native tiering instead. +//! +//! Acceptance is checked locally, with no network round trip: cmd_send +//! already needs to know whether it holds a token for the recipient to +//! decide whether to attach a valid accept MAC, and that same token's +//! presence is exactly "has this recipient accepted the gateway". The +//! recipient must do two things before this check passes, not one: accept +//! the gateway's address on their own smolmail account (which only affects +//! their own server's tiering), and then actually send the gateway +//! something -- the token only reaches pneumatube's local store via a +//! message's signed Accept frontmatter field (learn_token(), run at FETCH +//! time). Accepting alone does not make pneumatube aware of it. +//! +//! The relayed message deliberately carries no claim about the original +//! email sender: smolmail's own signature already tells the recipient this +//! message is authentically from the gateway's registered identity, which +//! is the only thing anyone here can vouch for. An email's MAIL FROM is +//! never authenticated (SMTP has no mechanism for that without SPF/DKIM +//! enforcement upstream, which this project does not configure), so +//! forwarding it into a frontmatter field that looks like gateway-attested +//! metadata would let any email sender forge an apparently-trustworthy +//! "from" inside a signed smolmail message -- a sender-spoofing vector, +//! not a feature. If the recipient needs to know who sent the original +//! email, that has to come from the body text itself, which is clearly +//! just quoted content rather than something the signature backs. + +use std::sync::Arc; + +use fumi_core::account::Account; +use fumi_core::address::Address; +use fumi_core::client::{self, SendDraft}; +use fumi_core::error::Error; +use fumi_core::store::Store; +use mail_parser::{MessageParser, PartType}; + +use crate::config::Config; +use crate::log_warn; + +/// Network timeout for every smolmail operation, in seconds. +pub const TIMEOUT: u64 = 30; + +/// The seam the email side (LMTP listener, Postfix lookup service) talks to, +/// so its logic is testable against stubs with no server behind them. +pub trait Bridge: Send + Sync { + /// Some(true)/Some(false) for a definite answer; None when resolving + /// itself failed (pinning/network), which is not the same as "no such + /// user" and must not be treated as a permanent bounce. + fn user_exists(&self, username: &str) -> Option; + fn recipient_accepted_gateway(&self, username: &str) -> bool; + fn forward(&self, username: &str, subject: &str, body: &str) -> Result<(), Error>; +} + +pub struct EmailToSmol { + store: Arc, + account: Arc, + config: Arc, +} + +impl EmailToSmol { + pub fn new(store: Arc, account: Arc, config: Arc) -> Self { + EmailToSmol { + store, + account, + config, + } + } + + /// Resolves the username live and caches the identity as a contact + /// (trust on first use, the same rules fumi's own CLI applies) so the + /// forward()'s SEND a moment later finds the stored contact instead of + /// paying for a second connect-and-RESOLVE round trip: each one is a + /// fresh TCP + Noise handshake. + pub fn user_exists(&self, username: &str) -> Option { + let addr = match Address::parse(&format!("{username}@{}", self.config.smolmail_domain)) { + // A username the smolmail grammar cannot express can never be + // registered, which is a definite answer, not a failure. + Err(_) => return Some(false), + Ok(addr) => addr, + }; + let result = (|| -> Result<(), Error> { + let mut session = client::connect(&self.store, &addr, true, TIMEOUT)?; + let (_identity, _change) = client::trust_key(&self.store, session.transport(), &addr)?; + session.close(); + Ok(()) + })(); + match result { + Ok(()) => Some(true), + Err(Error::UnknownUser(_)) => Some(false), + Err(error) => { + log_warn!("resolve for {username} failed: {error}"); + None + } + } + } + + /// Whether pneumatube holds an accept token for this recipient -- see + /// this module's docs for exactly what that does and doesn't mean. A + /// pure local lookup against the gateway's own store, no network. + /// + /// The store keys tokens by Address::short's normalized form, which + /// drops an explicit default port -- building the lookup key through + /// Address::parse (rather than string concatenation) matches that + /// normalization, so a configured "host:1961" still lines up. + pub fn recipient_accepted_gateway(&self, username: &str) -> bool { + let Ok(addr) = Address::parse(&format!("{username}@{}", self.config.smolmail_domain)) + else { + return false; + }; + self.store + .token_of(&addr.short()) + .map(|token| token.is_some()) + .unwrap_or(false) + } + + pub fn forward(&self, username: &str, subject: &str, body: &str) -> Result<(), Error> { + let addr = Address::parse(&format!("{username}@{}", self.config.smolmail_domain))?; + // The subject becomes a frontmatter value (SPEC.md S5.5: one + // `Key: value` per line); a bare CR/LF smuggled through the email + // Subject header would otherwise inject extra frontmatter fields. + let subject = single_line(subject); + let draft = SendDraft { + address: &addr, + text: body.to_string(), + subject: Some(subject.as_str()), + reply_to: None, + headers: &[], + anonymous: false, + no_pad: false, + }; + client::send(&self.store, &self.account, &draft, TIMEOUT)?; + Ok(()) + } +} + +impl Bridge for EmailToSmol { + fn user_exists(&self, username: &str) -> Option { + EmailToSmol::user_exists(self, username) + } + fn recipient_accepted_gateway(&self, username: &str) -> bool { + EmailToSmol::recipient_accepted_gateway(self, username) + } + fn forward(&self, username: &str, subject: &str, body: &str) -> Result<(), Error> { + EmailToSmol::forward(self, username, subject, body) + } +} + +/// Flattens any CR/LF to spaces: payloads that end up in single-line +/// frontmatter fields must not be able to inject extra fields. +pub fn single_line(text: &str) -> String { + text.replace(['\r', '\n'], " ").trim().to_string() +} + +/// The message body forwarded to smolmail: the first text part of the +/// email, with html converted to text when that is all there is. Never +/// panics or stalls on malformed input -- inbound email is hostile, and +/// this is the only parser it reaches. +pub fn extract_text(raw: &[u8]) -> String { + let Some(message) = MessageParser::default().parse(raw) else { + return String::from_utf8_lossy(raw).into_owned(); + }; + let count = message.text_body_count(); + // Prefer the first text/plain part, like the Python version's walk did; + // an html part only when no plain one exists. + for i in 0..count { + if let Some(part) = message.text_part(i as u32) { + if matches!(part.body, PartType::Text(_)) { + if let Some(text) = message.body_text(i) { + return text.into_owned(); + } + } + } + } + if let Some(text) = message.body_text(0) { + return text.into_owned(); + } + "(no plain-text part)".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ListenProtocol; + + fn store_with_token(address: &str) -> Store { + let store = Store::open_in_memory().unwrap(); + store.save_token(address, &[7u8; 32]).unwrap(); + store + } + + fn config(domain: &str) -> Config { + Config { + smolmail_key: "gateway.key".into(), + smolmail_db: "gateway.db".into(), + smolmail_domain: domain.to_string(), + email_domain: "gateway.example".to_string(), + fetch_interval: 30, + listen_protocol: ListenProtocol::Lmtp, + listen_host: "127.0.0.1".into(), + listen_port: 2424, + lookup_host: "127.0.0.1".into(), + lookup_port: 2526, + smtp_relay_host: "127.0.0.1".into(), + smtp_relay_port: 587, + smtp_relay_user: None, + smtp_relay_password: None, + smtp_relay_starttls: true, + gateway_from_address: "gateway@gateway.example".into(), + require_accepted_sender: true, + require_accepted_recipient: true, + } + } + + #[test] + fn recipient_accepted_gateway_true_when_token_held() { + let store = store_with_token("alice@smol.example"); + let bridge = EmailToSmol::new( + Arc::new(store), + Arc::new(Account::new([1u8; 32], 0).unwrap()), + Arc::new(config("smol.example")), + ); + assert!(bridge.recipient_accepted_gateway("alice")); + } + + #[test] + fn recipient_accepted_gateway_false_when_no_token() { + let store = Store::open_in_memory().unwrap(); + let bridge = EmailToSmol::new( + Arc::new(store), + Arc::new(Account::new([1u8; 32], 0).unwrap()), + Arc::new(config("smol.example")), + ); + assert!(!bridge.recipient_accepted_gateway("alice")); + } + + #[test] + fn recipient_accepted_gateway_normalizes_default_port() { + // A configured smolmail_domain carrying an explicit default port + // must still match a token stored under Address::short's + // normalized form (which drops it) -- naive string concatenation + // would never match. + let store = store_with_token("alice@smol.example"); + let bridge = EmailToSmol::new( + Arc::new(store), + Arc::new(Account::new([1u8; 32], 0).unwrap()), + Arc::new(config("smol.example:1961")), + ); + assert!(bridge.recipient_accepted_gateway("alice")); + } + + #[test] + fn single_line_strips_crlf() { + assert_eq!( + single_line("alice@example.org\r\nAccept: forged"), + "alice@example.org Accept: forged" + ); + } + + #[test] + fn extract_text_plain_message() { + let raw = b"Content-Type: text/plain; charset=utf-8\r\n\r\nhello there\r\n"; + assert_eq!(extract_text(raw), "hello there\r\n"); + } + + #[test] + fn extract_text_multipart_prefers_plain_part() { + let raw = b"Content-Type: multipart/alternative; boundary=\"X\"\r\n\ + \r\n--X\r\nContent-Type: text/plain\r\n\r\nplain body\r\n\ + --X\r\nContent-Type: text/html\r\n\r\n

html body

\r\n--X--\r\n"; + let text = extract_text(raw); + assert!(text.contains("plain body"), "{text}"); + } + + #[test] + fn extract_text_survives_malformed_input() { + // The security suite's battery: empty, binary, deeply nested + // multipart with no text part, a declared size with no body. No + // panic, no stall, always a String. + assert_eq!(extract_text(b""), ""); + assert!(!extract_text(&[0xff, 0xfe, 0x00, 0x01]).is_empty()); + let nested = b"Content-Type: multipart/mixed; boundary=\"A\"\r\n\ + \r\n--A\r\nContent-Type: multipart/mixed; boundary=\"B\"\r\n\ + \r\n--B\r\nContent-Type: application/octet-stream\r\n\r\n\x00\x01\ + \r\n--B--\r\n--A--\r\n"; + assert_eq!(extract_text(nested), "(no plain-text part)"); + let mut deep = b"Content-Type: text/plain\r\n\r\nleaf".to_vec(); + for i in 0..50 { + let b = format!("B{i}"); + deep = format!( + "Content-Type: multipart/mixed; boundary={b}\r\n\r\n--{b}\r\n{}", + String::from_utf8_lossy(&deep) + ) + .into_bytes(); + } + // The property under test is survival, not content: a malformed + // 50-deep nest must not panic or stall (algorithmic DoS). + let start = std::time::Instant::now(); + let _ = extract_text(&deep); + assert!(start.elapsed().as_secs() < 2, "50-deep multipart stalled"); + assert_eq!( + extract_text(b"Content-Type: text/plain\r\nContent-Length: 999999999\r\n\r\nshort"), + "short" + ); + } +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..7956e8f --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,94 @@ +//! Minimal stderr logging: " LEVEL: message", matching the line +//! format the Python implementation emitted. PNEUMATUBE_LOG sets the +//! minimum level (debug, info, warn, error; default info). + +use std::io::Write; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, +} + +static MIN_LEVEL: AtomicU8 = AtomicU8::new(1); + +pub fn init() { + let level = match std::env::var("PNEUMATUBE_LOG").as_deref() { + Ok("debug") => Level::Debug, + Ok("info") => Level::Info, + Ok("warn") | Ok("warning") => Level::Warn, + Ok("error") => Level::Error, + _ => Level::Info, + }; + MIN_LEVEL.store(level as u8, Ordering::Relaxed); +} + +fn timestamp() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let secs = millis.as_secs() as i64; + let frac = millis.subsec_millis(); + let days = secs.div_euclid(86_400); + let seconds_of_day = secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{frac:03}Z", + seconds_of_day / 3600, + seconds_of_day / 60 % 60, + seconds_of_day % 60 + ) +} + +/// Howard Hinnant's days-from-civil inverse, for date arithmetic without a +/// calendar dependency. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +pub fn emit(level: Level, args: std::fmt::Arguments<'_>) { + if (level as u8) < MIN_LEVEL.load(Ordering::Relaxed) { + return; + } + let name = match level { + Level::Debug => "DEBUG", + Level::Info => "INFO", + Level::Warn => "WARNING", + Level::Error => "ERROR", + }; + let mut stderr = std::io::stderr().lock(); + let _ = writeln!(stderr, "{} {}: {}", timestamp(), name, args); +} + +#[macro_export] +macro_rules! log_debug { + ($($arg:tt)*) => { $crate::logging::emit($crate::logging::Level::Debug, format_args!($($arg)*)) }; +} + +#[macro_export] +macro_rules! log_info { + ($($arg:tt)*) => { $crate::logging::emit($crate::logging::Level::Info, format_args!($($arg)*)) }; +} + +#[macro_export] +macro_rules! log_warn { + ($($arg:tt)*) => { $crate::logging::emit($crate::logging::Level::Warn, format_args!($($arg)*)) }; +} + +#[macro_export] +macro_rules! log_error { + ($($arg:tt)*) => { $crate::logging::emit($crate::logging::Level::Error, format_args!($($arg)*)) }; +} diff --git a/src/lookup_server.rs b/src/lookup_server.rs new file mode 100644 index 0000000..e0d97f4 --- /dev/null +++ b/src/lookup_server.rs @@ -0,0 +1,296 @@ +//! Postfix tcp_table(5) lookup service, backing check_recipient_access. +//! +//! Without this, Postfix's smtpd accepts a recipient into its queue before +//! ever asking pneumatube whether the username exists and has accepted the +//! gateway (SPEC.md S5.8) -- those checks only run later, when the queue +//! manager delivers over LMTP, so a reject becomes a backscatter-prone +//! bounce instead of a live SMTP-time reject. Wiring this service into +//! smtpd_recipient_restrictions moves the same checks rcpt() already does +//! to RCPT TO time, before Postfix commits to accepting the message. +//! +//! Protocol (tcp_table(5)): one request per line, `get `, where key +//! and the response text are %XX-quoted. A response code of 200 carries a +//! result that Postfix applies as an access(5) action (e.g. "OK" or +//! "550 ... text"); 500 means "no opinion, fall through to the next +//! restriction"; 400 is a temporary error (Postfix defers the command +//! rather than rejecting it). + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; + +use crate::config::Config; +use crate::email_to_smol::Bridge; +use crate::log_warn; + +/// The reply for one lookup key, without the trailing newline. +pub fn lookup_reply(bridge: &dyn Bridge, config: &Config, key: &str) -> String { + let Some((username, domain)) = key.split_once('@') else { + return "500 not found".to_string(); + }; + if !domain.eq_ignore_ascii_case(&config.email_domain) { + // Not a key shape we own (e.g. check_recipient_access's own + // fallback to a bare-domain lookup after a miss) -- no opinion. + return "500 not found".to_string(); + } + let username = username.to_lowercase(); + match bridge.user_exists(&username) { + None => return "400 temporary failure resolving recipient".to_string(), + Some(false) => return format!("200 {}", quote("550 5.1.1 no such user")), + Some(true) => {} + } + if config.require_accepted_recipient && !bridge.recipient_accepted_gateway(&username) { + return format!( + "200 {}", + quote("550 5.7.1 recipient has not accepted mail from this gateway") + ); + } + format!("200 {}", quote("OK")) +} + +/// %XX-encodes everything outside the unreserved set, like urllib's quote() +/// did: what the reply text carries must never smuggle a newline into the +/// line-oriented protocol. +pub fn quote(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for byte in text.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + out.push(*byte as char) + } + other => out.push_str(&format!("%{other:02X}")), + } + } + out +} + +/// Decodes %XX sequences; a malformed sequence is left as-is, like +/// urllib's unquote(). +pub fn unquote(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(value) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(value); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Serves until the process exits: one thread per connection, Postfix +/// keeps its lookup connections open. +pub fn serve(listener: TcpListener, bridge: Arc, config: Arc) { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let bridge = bridge.clone(); + let config = config.clone(); + std::thread::spawn(move || { + let _ = handle_connection(stream, bridge, config); + }); + } +} + +fn handle_connection( + stream: TcpStream, + bridge: Arc, + config: Arc, +) -> std::io::Result<()> { + let peer = stream.peer_addr()?; + let mut reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + let mut line = String::new(); + loop { + line.clear(); + let read = match reader.read_line(&mut line) { + Ok(read) => read, + Err(error) => { + log_warn!("lookup connection from {peer} failed: {error}"); + return Ok(()); + } + }; + if read == 0 { + return Ok(()); + } + if read > 8192 { + log_warn!("lookup connection from {peer}: request too long, closing"); + return Ok(()); + } + let request = line.trim_end_matches(['\r', '\n']); + if request.is_empty() { + continue; + } + let (command, rest) = request.split_once(' ').unwrap_or((request, "")); + if command != "get" { + writeln!(writer, "400 unsupported request")?; + continue; + } + let reply = lookup_reply(&*bridge, &config, &unquote(rest)); + writeln!(writer, "{reply}")?; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fumi_core::error::Error; + + struct StubBridge { + known: Vec<&'static str>, + accepted: Vec<&'static str>, + } + + impl Bridge for StubBridge { + fn user_exists(&self, username: &str) -> Option { + if username == "broken" { + return None; + } + Some(self.known.contains(&username)) + } + fn recipient_accepted_gateway(&self, username: &str) -> bool { + self.accepted.contains(&username) + } + fn forward(&self, _: &str, _: &str, _: &str) -> Result<(), Error> { + Ok(()) + } + } + + fn test_config(require_accepted_recipient: bool) -> Config { + Config { + smolmail_key: "k".into(), + smolmail_db: "d".into(), + smolmail_domain: "smol.example".into(), + email_domain: "gateway.example".to_string(), + fetch_interval: 30, + listen_protocol: crate::config::ListenProtocol::Lmtp, + listen_host: "127.0.0.1".into(), + listen_port: 2424, + lookup_host: "127.0.0.1".into(), + lookup_port: 2526, + smtp_relay_host: "127.0.0.1".into(), + smtp_relay_port: 587, + smtp_relay_user: None, + smtp_relay_password: None, + smtp_relay_starttls: true, + gateway_from_address: "gateway@gateway.example".into(), + require_accepted_sender: true, + require_accepted_recipient, + } + } + + fn code_and_text(reply: &str) -> (&str, String) { + let (code, rest) = reply.split_once(' ').unwrap_or((reply, "")); + (code, unquote(rest)) + } + + #[test] + fn lookup_known_user_returns_ok() { + let bridge = StubBridge { + known: vec!["alice"], + accepted: vec!["alice"], + }; + let reply = lookup_reply(&bridge, &test_config(true), "alice@gateway.example"); + let (code, text) = code_and_text(&reply); + assert_eq!(code, "200"); + assert_eq!(text, "OK"); + } + + #[test] + fn lookup_unknown_user_returns_reject_action() { + let bridge = StubBridge { + known: vec!["alice"], + accepted: vec!["alice"], + }; + let reply = lookup_reply(&bridge, &test_config(true), "nobody@gateway.example"); + let (code, text) = code_and_text(&reply); + assert_eq!(code, "200"); + assert_eq!(text, "550 5.1.1 no such user"); + } + + #[test] + fn lookup_resolve_failure_is_temporary() { + let bridge = StubBridge { + known: vec!["alice"], + accepted: vec!["alice"], + }; + let reply = lookup_reply(&bridge, &test_config(true), "broken@gateway.example"); + assert!(reply.starts_with("400 "), "{reply}"); + } + + #[test] + fn lookup_other_domain_has_no_opinion() { + let bridge = StubBridge { + known: vec!["alice"], + accepted: vec!["alice"], + }; + assert_eq!( + lookup_reply(&bridge, &test_config(true), "alice@other.example"), + "500 not found" + ); + } + + #[test] + fn lookup_key_without_at_is_no_opinion() { + let bridge = StubBridge { + known: vec![], + accepted: vec![], + }; + assert_eq!( + lookup_reply(&bridge, &test_config(true), "gateway.example"), + "500 not found" + ); + } + + #[test] + fn lookup_rejects_unaccepted_recipient_by_default() { + let bridge = StubBridge { + known: vec!["alice"], + accepted: vec![], + }; + let reply = lookup_reply(&bridge, &test_config(true), "alice@gateway.example"); + let (code, text) = code_and_text(&reply); + assert_eq!(code, "200"); + assert_eq!( + text, + "550 5.7.1 recipient has not accepted mail from this gateway" + ); + } + + #[test] + fn lookup_allows_unaccepted_recipient_when_disabled() { + let bridge = StubBridge { + known: vec!["alice"], + accepted: vec![], + }; + let reply = lookup_reply(&bridge, &test_config(false), "alice@gateway.example"); + let (code, text) = code_and_text(&reply); + assert_eq!(code, "200"); + assert_eq!(text, "OK"); + } + + #[test] + fn quoting_round_trips_and_encodes() { + assert_eq!(quote("OK"), "OK"); + assert_eq!( + quote("550 5.1.1 no such user"), + "550%205.1.1%20no%20such%20user" + ); + assert_eq!( + unquote("e("550 5.1.1 no such user")), + "550 5.1.1 no such user" + ); + // Malformed escapes survive untouched, like urllib's unquote(). + assert_eq!(unquote("a%2"), "a%2"); + assert_eq!(unquote("a%zz"), "a%zz"); + // A key carrying a newline cannot inject a second protocol line. + assert!(quote("a\r\nget evil").contains("%0D%0A")); + assert_eq!(unquote("alice%40gateway.example"), "alice@gateway.example"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bb5b800 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,143 @@ +//! pneumatube: a gateway bridging one smolmail account and an SMTP mailbox. +//! +//! smolmail -> email: a message the gateway receives with an Email-To +//! frontmatter field is relayed to that address over SMTP. By default this +//! only happens for senders the gateway account has accepted (SPEC.md +//! S5.8; PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0 to relay first-contact +//! senders too). +//! +//! email -> smolmail: mail sent to @PNEUMATUBE_EMAIL_DOMAIN is +//! relayed as a smolmail message to @PNEUMATUBE_SMOLMAIL_DOMAIN, +//! if that username is registered there and (by default) has accepted the +//! gateway (SPEC.md S5.8; PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 for the +//! softer, smolmail-native requests-tier demotion instead of an outright +//! reject -- see `email_to_smol`'s module docs). The relayed message +//! carries no claim about the original email sender -- an email's MAIL +//! FROM is unauthenticated, so forwarding it into the message would let +//! any sender forge an apparently-trustworthy "from" inside a signed +//! smolmail message. +//! +//! See `config` for the full list of environment variables, including +//! PNEUMATUBE_LISTEN_PROTOCOL (lmtp, behind a real MTA, or smtp for +//! local-dev only), PNEUMATUBE_LISTEN_HOST/PORT, and +//! PNEUMATUBE_LOOKUP_HOST/PORT. In lmtp mode, the lookup service answers +//! Postfix's check_recipient_access (a tcp_table(5) query backed by the +//! same RESOLVE check as rcpt()) so an unknown user is rejected live at +//! RCPT TO instead of bouncing after Postfix has already queued the +//! message. Before running, register the gateway's own smolmail account +//! the same way any smolmail client would, against the same --key/--db +//! paths this reads (PNEUMATUBE_SMOLMAIL_KEY / PNEUMATUBE_SMOLMAIL_DB): +//! +//! fumi --key gateway.key --db gateway.db keygen +//! fumi --key gateway.key --db gateway.db trust +//! fumi --key gateway.key --db gateway.db register @ + +mod config; +mod email_to_smol; +mod logging; +mod lookup_server; +mod smol_to_email; +mod smtp_client; +mod smtp_server; + +use std::net::TcpListener; +use std::sync::Arc; + +use fumi_core::account::Account; +use fumi_core::store::Store; + +use crate::config::ListenProtocol; +use crate::email_to_smol::{Bridge, EmailToSmol}; +use crate::smol_to_email::SmolToEmail; + +/// The master secret file holds exactly the 32 bytes fumi's keygen writes. +fn load_master(path: &std::path::Path) -> Result<[u8; 32], String> { + let bytes = std::fs::read(path) + .map_err(|_| format!("no identity at {}; run: fumi keygen", path.display()))?; + bytes.try_into().map_err(|v: Vec| { + format!( + "master secret at {} is {} bytes, expected 32", + path.display(), + v.len() + ) + }) +} + +fn main() { + logging::init(); + let config = match config::load() { + Ok(config) => config, + Err(message) => { + eprintln!("pneumatube: {message}"); + std::process::exit(2); + } + }; + if let Err(message) = run(config) { + eprintln!("pneumatube: {message}"); + std::process::exit(1); + } +} + +fn run(config: config::Config) -> Result<(), String> { + let store = Store::open(&config.smolmail_db) + .map_err(|error| format!("cannot open {}: {error}", config.smolmail_db.display()))?; + let master = load_master(&config.smolmail_key)?; + let account = Account::new(master, store.rotations().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + + let config = Arc::new(config); + let store = Arc::new(store); + let account = Arc::new(account); + let inbound: Arc = Arc::new(EmailToSmol::new( + store.clone(), + account.clone(), + config.clone(), + )); + let outbound = SmolToEmail::new(store.clone(), account.clone(), config.clone()); + + let smtp_listener = TcpListener::bind((config.listen_host.as_str(), config.listen_port)) + .map_err(|e| { + format!( + "cannot listen on {}:{}: {e}", + config.listen_host, config.listen_port + ) + })?; + log_info!( + "{proto} listener on {host}:{port}", + proto = match config.listen_protocol { + ListenProtocol::Lmtp => "LMTP", + ListenProtocol::Smtp => "SMTP", + }, + host = config.listen_host, + port = config.listen_port + ); + let smtp_bridge = inbound.clone(); + let smtp_config = config.clone(); + std::thread::spawn(move || smtp_server::serve(smtp_listener, smtp_bridge, smtp_config)); + + if config.listen_protocol == ListenProtocol::Lmtp { + // Only meaningful behind Postfix's check_recipient_access; the + // plain-SMTP dev mode already rejects unknown users live in rcpt(). + let listener = TcpListener::bind((config.lookup_host.as_str(), config.lookup_port)) + .map_err(|e| { + format!( + "cannot listen on {}:{}: {e}", + config.lookup_host, config.lookup_port + ) + })?; + log_info!( + "Postfix recipient-lookup service on {host}:{port}", + host = config.lookup_host, + port = config.lookup_port + ); + let lookup_bridge = inbound.clone(); + let lookup_config = config.clone(); + std::thread::spawn(move || lookup_server::serve(listener, lookup_bridge, lookup_config)); + } + + let poll_interval = config.fetch_interval; + loop { + outbound.poll_once(); + std::thread::sleep(std::time::Duration::from_secs(poll_interval)); + } +} diff --git a/src/pneumatube/__init__.py b/src/pneumatube/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pneumatube/__main__.py b/src/pneumatube/__main__.py deleted file mode 100644 index 14b7e9d..0000000 --- a/src/pneumatube/__main__.py +++ /dev/null @@ -1,122 +0,0 @@ -"""pneumatube: a gateway bridging one smolmail account and an SMTP mailbox. - -smolmail -> email: a message the gateway receives with an Email-To -frontmatter field is relayed to that address over SMTP. By default this only -happens for senders the gateway account has accepted (SPEC.md S5.8; -PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0 to relay first-contact senders too). - -email -> smolmail: mail sent to @PNEUMATUBE_EMAIL_DOMAIN is -relayed as a smolmail message to @PNEUMATUBE_SMOLMAIL_DOMAIN, if -that username is registered there and (by default) has accepted the gateway -(SPEC.md S5.8; PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 for the softer, -smolmail-native requests-tier demotion instead of an outright reject -- see -email_to_smol.py's module docstring). The relayed message carries no claim -about the original email sender -- an email's MAIL FROM is unauthenticated, -so forwarding it into the message would let any sender forge an -apparently-trustworthy "from" inside a signed smolmail message. - -See config.py for the full list of environment variables, including -PNEUMATUBE_LISTEN_PROTOCOL (lmtp, behind a real MTA, or smtp for local-dev -only), PNEUMATUBE_LISTEN_HOST/PORT, and PNEUMATUBE_LOOKUP_HOST/PORT. In lmtp -mode, the lookup service answers Postfix's check_recipient_access (a -tcp_table(5) query backed by the same RESOLVE check as handle_RCPT) so an -unknown user is rejected live at RCPT TO instead of bouncing after Postfix -has already queued the message. Before running, -register the gateway's own smolmail account the same way any smolmail client -would, against the same --key/--db paths this reads (PNEUMATUBE_SMOLMAIL_KEY -/ PNEUMATUBE_SMOLMAIL_DB): - - uv run /smolmail.py --key gateway.key --db gateway.db keygen - uv run /smolmail.py --key gateway.key --db gateway.db trust - uv run /smolmail.py --key gateway.key --db gateway.db register @ -""" -from __future__ import annotations - -import argparse -import asyncio -import logging - -from aiosmtpd.controller import Controller -from aiosmtpd.lmtp import LMTP -from aiosmtpd.smtp import SMTP as PlainSMTP - -from . import config as config_module -from . import lookup_server -from . import vendor -from .email_to_smol import EmailToSmolBridge -from .smol_to_email import SmolToEmailBridge -from .smtp_server import GatewayHandler -from .worker import SmolWorker - -logger = logging.getLogger(__name__) - - -class _GatewayController(Controller): - """A Controller that can speak either LMTP or plain SMTP, per config. - - Mirrors aiosmtpd's own pattern for an LMTP controller (aiosmtpd/tests/ - test_lmtp.py): override factory() to instantiate the chosen protocol - class instead of the default SMTP. - """ - - def __init__(self, handler, protocol_class, **kwargs) -> None: - self._protocol_class = protocol_class - super().__init__(handler, **kwargs) - - def factory(self): - return self._protocol_class(self.handler, **self.SMTP_kwargs) - - -async def _poll_forever(worker: SmolWorker, bridge: SmolToEmailBridge, interval: int) -> None: - while True: - try: - await worker.run(bridge.poll_once) - except Exception: - logger.exception("smolmail poll failed") - await asyncio.sleep(interval) - - -def _open_account(smol, cfg: config_module.Config): - # Runs on the worker thread: Store() binds a sqlite3 connection to - # whichever thread creates it (worker.py). - store = smol.Store(str(cfg.smolmail_db)) - account = smol.load_account(argparse.Namespace(key=str(cfg.smolmail_key)), store) - return store, account - - -async def _run(cfg: config_module.Config) -> None: - smol = vendor.load(cfg.smolmail_repo) - worker = SmolWorker() - store, account = await worker.run(_open_account, smol, cfg) - - outbound = SmolToEmailBridge(smol, store, account, cfg) - inbound = EmailToSmolBridge(smol, store, cfg) - - protocol_class = LMTP if cfg.listen_protocol == "lmtp" else PlainSMTP - controller = _GatewayController(GatewayHandler(inbound, worker, cfg), protocol_class, - hostname=cfg.listen_host, port=cfg.listen_port) - controller.start() - logger.info("%s listener on %s:%s", cfg.listen_protocol.upper(), cfg.listen_host, cfg.listen_port) - - tasks = [asyncio.create_task(_poll_forever(worker, outbound, cfg.fetch_interval))] - if cfg.listen_protocol == "lmtp": - # Only meaningful behind Postfix's check_recipient_access; the plain - # SMTP dev mode already rejects unknown users live in handle_RCPT. - tasks.append(asyncio.create_task(lookup_server.serve(inbound, worker, cfg))) - try: - await asyncio.gather(*tasks) - finally: - for task in tasks: - task.cancel() - controller.stop() - worker.shutdown() - - -def main() -> None: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - cfg = config_module.load() - asyncio.run(_run(cfg)) - - -if __name__ == "__main__": - main() diff --git a/src/pneumatube/config.py b/src/pneumatube/config.py deleted file mode 100644 index 85fdc1e..0000000 --- a/src/pneumatube/config.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Runtime configuration, read from environment variables.""" -from __future__ import annotations - -import os -from dataclasses import dataclass -from pathlib import Path - -# Frontmatter is case-insensitive and flat (smolmail SPEC.md S5.5); this is -# the only contract the smolmail -> email direction relies on. The reverse -# direction deliberately carries no analogous custom field -- see -# email_to_smol.py's module docstring for why. -FRONTMATTER_EMAIL_KEY = "Email-To" # smolmail -> email: where to deliver - - -def _env(name: str) -> str: - try: - return os.environ[name] - except KeyError: - raise RuntimeError(f"missing required environment variable {name}") from None - - -def _int(name: str, default: int) -> int: - return int(os.environ.get(name, default)) - - -_LISTEN_PROTOCOLS = ("lmtp", "smtp") -_LMTP_DEFAULT_PORT, _SMTP_DEFAULT_PORT = 2424, 1025 -_LOOKUP_DEFAULT_PORT = 2526 - - -@dataclass(frozen=True) -class Config: - smolmail_repo: Path # checkout of the sibling smolmail project - smolmail_key: Path # gateway's own master secret file - smolmail_db: Path # gateway's own smolmail client state - smolmail_domain: str # smolmail server address used in "user@..." addresses - email_domain: str # domain this gateway accepts inbound mail for - fetch_interval: int # seconds between smolmail inbox polls - - listen_protocol: str # "lmtp" (default; behind a real MTA) or "smtp" (local dev only) - listen_host: str - listen_port: int - - # Postfix tcp_table(5) lookup service backing check_recipient_access, so - # an unknown user is rejected live during RCPT TO instead of bouncing - # after Postfix has already accepted the message (lmtp mode only). - lookup_host: str - lookup_port: int - - smtp_relay_host: str - smtp_relay_port: int - smtp_relay_user: str | None - smtp_relay_password: str | None - smtp_relay_starttls: bool - - gateway_from_address: str # From: header used on mail forwarded to SMTP - - # If true (the default), only relay smolmail messages from senders the - # gateway account has accepted (SPEC.md S5.8) -- a first-contact sender - # with no accept token lands in the requests tier and is left alone. - # Set to false to run an intentionally open bridge instead. - require_accepted_sender: bool - - # If true (the default), only relay inbound email to a smolmail user who - # has accepted the gateway (SPEC.md S5.8) -- an all-or-nothing RCPT-time - # reject, not a demotion. See email_to_smol.py's module docstring. - require_accepted_recipient: bool - - -def load() -> Config: - listen_protocol = os.environ.get("PNEUMATUBE_LISTEN_PROTOCOL", "lmtp") - if listen_protocol not in _LISTEN_PROTOCOLS: - raise RuntimeError( - f"PNEUMATUBE_LISTEN_PROTOCOL must be one of {_LISTEN_PROTOCOLS}, got {listen_protocol!r}") - default_port = _LMTP_DEFAULT_PORT if listen_protocol == "lmtp" else _SMTP_DEFAULT_PORT - - return Config( - smolmail_repo=Path(os.environ.get("PNEUMATUBE_SMOLMAIL_REPO", "../smolmail")), - smolmail_key=Path(os.environ.get("PNEUMATUBE_SMOLMAIL_KEY", "gateway.key")), - smolmail_db=Path(os.environ.get("PNEUMATUBE_SMOLMAIL_DB", "gateway.db")), - smolmail_domain=_env("PNEUMATUBE_SMOLMAIL_DOMAIN"), - email_domain=_env("PNEUMATUBE_EMAIL_DOMAIN"), - fetch_interval=_int("PNEUMATUBE_FETCH_INTERVAL", 30), - listen_protocol=listen_protocol, - listen_host=os.environ.get("PNEUMATUBE_LISTEN_HOST", "127.0.0.1"), - listen_port=_int("PNEUMATUBE_LISTEN_PORT", default_port), - lookup_host=os.environ.get("PNEUMATUBE_LOOKUP_HOST", "127.0.0.1"), - lookup_port=_int("PNEUMATUBE_LOOKUP_PORT", _LOOKUP_DEFAULT_PORT), - smtp_relay_host=_env("PNEUMATUBE_SMTP_RELAY_HOST"), - smtp_relay_port=_int("PNEUMATUBE_SMTP_RELAY_PORT", 587), - smtp_relay_user=os.environ.get("PNEUMATUBE_SMTP_RELAY_USER"), - smtp_relay_password=os.environ.get("PNEUMATUBE_SMTP_RELAY_PASSWORD"), - smtp_relay_starttls=os.environ.get("PNEUMATUBE_SMTP_RELAY_STARTTLS", "1") != "0", - gateway_from_address=_env("PNEUMATUBE_GATEWAY_FROM_ADDRESS"), - require_accepted_sender=os.environ.get("PNEUMATUBE_REQUIRE_ACCEPTED_SENDER", "1") != "0", - require_accepted_recipient=os.environ.get( - "PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT", "1") != "0", - ) diff --git a/src/pneumatube/email_to_smol.py b/src/pneumatube/email_to_smol.py deleted file mode 100644 index eedf640..0000000 --- a/src/pneumatube/email_to_smol.py +++ /dev/null @@ -1,117 +0,0 @@ -"""email -> smolmail direction. - -Confirms a username is registered on the configured smolmail server, and -that they have accepted the gateway's own smolmail identity (SPEC.md S5.8), -before relaying an inbound email to them as a smolmail message. This is an -all-or-nothing gate, not a demotion: without it, the gateway's SEND would -still go through and land in the recipient's own requests tier (still -delivered, just quota-limited); with it, pneumatube refuses the RCPT -entirely, so an unaccepted recipient gets nothing at all. Set -PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 to fall back to that softer, -smolmail-native tiering instead. - -Acceptance is checked locally, with no network round trip: cmd_send already -needs to know whether it holds a token for the recipient (SELECT token FROM -tokens WHERE address = ?) to decide whether to attach a valid accept MAC, -and that same row's presence is exactly "has this recipient accepted the -gateway". The recipient must do two things before this check passes, not -one: accept the gateway's address on their own smolmail account (which only -affects their own server's tiering), and then actually send the gateway -something -- the token only reaches pneumatube's local `tokens` table via a -message's signed Accept frontmatter field (learn_token(), run at FETCH time). -Accepting alone does not make pneumatube aware of it. - -The relayed message deliberately carries no claim about the original email -sender: smolmail's own signature already tells the recipient this message -is authentically from the gateway's registered identity, which is the only -thing anyone here can vouch for. An email's MAIL FROM is never authenticated -(SMTP has no mechanism for that without SPF/DKIM enforcement upstream, which -this project does not configure), so forwarding it into a frontmatter field -that looks like gateway-attested metadata would let any email sender forge -an apparently-trustworthy "from" inside a signed smolmail message -- a -sender-spoofing vector, not a feature. If the recipient needs to know who -sent the original email, that has to come from the body text itself, which -is clearly just quoted content rather than something the signature backs. -""" -from __future__ import annotations - -import argparse -import logging -from email.message import Message -from types import ModuleType - -from .config import Config - -logger = logging.getLogger(__name__) - - -def _single_line(text: str) -> str: - return text.replace("\r", " ").replace("\n", " ").strip() - - -class EmailToSmolBridge: - def __init__(self, smol: ModuleType, store: object, config: Config) -> None: - self.smol = smol - self.store = store - self.config = config - - def user_exists(self, username: str) -> bool | None: - """True/False for a definite answer; None when resolving itself - failed (pinning/network), which is not the same as "no such user" - and should not be treated as a permanent bounce. - - Caches the resolved identity as a contact (trust-on-first-use, same - as smolmail.py's own trust_key) so forward()'s cmd_send -- called a - moment later for the same username -- hits that cache instead of - paying for a second full connect-and-RESOLVE round trip. Each one is - a fresh TCP + Noise handshake (smolmail.py has no connection - pooling), so skipping the duplicate roughly halves the network cost - of this direction's hot path. - """ - try: - addr = self.smol.Address.parse(f"{username}@{self.config.smolmail_domain}") - with self.smol.connect(self.store, addr, require_pin=True) as conn: - identity, _chain = self.smol.do_resolve(conn, username) - except self.smol.SmolError as exc: - if "unknown user" in str(exc): - return False - logger.warning("resolve for %s failed: %s", username, exc) - return None - self.store.save_contact(addr.short, identity, verified=False) - return True - - def recipient_accepted_gateway(self, username: str) -> bool: - """Whether pneumatube holds an accept token for this recipient -- - see this module's docstring for exactly what that does and doesn't - mean. A pure local lookup against the gateway's own db, no network. - - Addresses are stored (by learn_token()) in Address.short's - normalized form, which drops an explicit default port -- build the - lookup key the same way rather than concatenating smolmail_domain - directly, or a configured "host:1961" would never match. - """ - addr = self.smol.Address.parse(f"{username}@{self.config.smolmail_domain}") - return self.store.one("SELECT token FROM tokens WHERE address = ?", addr.short) is not None - - def forward(self, username: str, subject: str, body: str) -> None: - # Subject ends up as a frontmatter value (SPEC.md S5.5: one - # `Key: value` per line); a bare CR/LF smuggled through the email - # Subject header would otherwise let a sender inject extra - # frontmatter fields. - send_args = argparse.Namespace( - key=str(self.config.smolmail_key), db=str(self.config.smolmail_db), - address=f"{username}@{self.config.smolmail_domain}", - body=body, subject=_single_line(subject), reply_to=None, - header=[], anonymous=False, no_pad=False) - self.smol.cmd_send(send_args, self.store) - - @staticmethod - def extract_text(message: Message) -> str: - if not message.is_multipart(): - payload = message.get_payload(decode=True) or b"" - return payload.decode(message.get_content_charset() or "utf-8", "replace") - for part in message.walk(): - if part.get_content_type() == "text/plain" and not part.is_multipart(): - payload = part.get_payload(decode=True) or b"" - return payload.decode(part.get_content_charset() or "utf-8", "replace") - return "(no plain-text part)" diff --git a/src/pneumatube/lookup_server.py b/src/pneumatube/lookup_server.py deleted file mode 100644 index cb9c4e5..0000000 --- a/src/pneumatube/lookup_server.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Postfix tcp_table(5) lookup service, backing check_recipient_access. - -Without this, Postfix's smtpd accepts a recipient into its queue before ever -asking pneumatube whether the username exists and has accepted the gateway -(SPEC.md S5.8) — those checks only run later, when the queue manager -delivers over LMTP, so a reject becomes a backscatter-prone bounce instead -of a live SMTP-time reject. Wiring this service into -smtpd_recipient_restrictions moves the same checks handle_RCPT already does -to RCPT TO time, before Postfix commits to accepting the message. - -Protocol (tcp_table(5)): one request per line, `get `, where key and -the response text are %XX-quoted. A response code of 200 carries a result -that Postfix applies as an access(5) action (e.g. "OK" or "550 ... text"); -500 means "no opinion, fall through to the next restriction"; 400 is a -temporary error (Postfix defers the command rather than rejecting it). -""" -from __future__ import annotations - -import asyncio -import logging -from urllib.parse import quote, unquote - -from .config import Config -from .email_to_smol import EmailToSmolBridge -from .worker import SmolWorker - -logger = logging.getLogger(__name__) - - -async def _lookup(bridge: EmailToSmolBridge, worker: SmolWorker, config: Config, key: str) -> str: - username, _, domain = key.partition("@") - if domain.lower() != config.email_domain.lower(): - # Not a key shape we own (e.g. check_recipient_access's own fallback - # to a bare-domain lookup after a miss) — no opinion. - return "500 not found" - - username = username.lower() - exists = await worker.run(bridge.user_exists, username) - if exists is None: - return "400 temporary failure resolving recipient" - if not exists: - return "200 " + quote("550 5.1.1 no such user") - - if config.require_accepted_recipient: - accepted = await worker.run(bridge.recipient_accepted_gateway, username) - if not accepted: - return "200 " + quote("550 5.7.1 recipient has not accepted mail from this gateway") - - return "200 " + quote("OK") - - -async def _handle_connection(bridge, worker, config, reader, writer) -> None: - peer = writer.get_extra_info("peername") - try: - while True: - line = await reader.readline() - if not line: - break - request = line.decode("ascii", "replace").strip() - if not request: - continue - command, _, rest = request.partition(" ") - if command != "get": - writer.write(b"400 unsupported request\n") - await writer.drain() - continue - reply = await _lookup(bridge, worker, config, unquote(rest)) - writer.write(reply.encode("ascii") + b"\n") - await writer.drain() - except (ConnectionError, asyncio.IncompleteReadError): - pass - except Exception: - logger.exception("lookup connection from %s failed", peer) - finally: - writer.close() - - -async def serve(bridge: EmailToSmolBridge, worker: SmolWorker, config: Config) -> None: - server = await asyncio.start_server( - lambda r, w: _handle_connection(bridge, worker, config, r, w), - host=config.lookup_host, port=config.lookup_port) - logger.info("Postfix recipient-lookup service on %s:%s", config.lookup_host, config.lookup_port) - async with server: - await server.serve_forever() diff --git a/src/pneumatube/smol_to_email.py b/src/pneumatube/smol_to_email.py deleted file mode 100644 index d9a5d37..0000000 --- a/src/pneumatube/smol_to_email.py +++ /dev/null @@ -1,91 +0,0 @@ -"""smolmail -> email direction. - -Polls the gateway's own smolmail inbox and relays any message that carries -the Email-To frontmatter field (SPEC.md S5.5) to a real mailbox over SMTP. -A message without that field is left in the local inbox rather than dropped, -so it stays visible to `smolmail.py list`/`read` against the gateway's own -db for manual follow-up. - -By default (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=1, config.require_accepted_ -sender), only the main tier is relayed: a first-contact sender with no -accept token (SPEC.md S5.8) lands in the requests tier and is left alone, -so the gateway can't be used as an open relay by anyone who merely learns -its smolmail address. Setting the flag to false restores the earlier "relay -anyone" behavior for a deliberately open/public bridge. - -Unblocking a sender takes two steps, not one -- confirmed live, since it is -easy to assume `accept` alone is enough: - 1. `smolmail.py --key gateway.key --db gateway.db accept ` - records the acceptance locally and uploads the token to the server, but - does NOT retroactively reclassify a message already sitting in this - gateway's local inbox (its tier was decided at fetch time) or do - anything the sender can see. - 2. The sender only learns the token when it arrives in a reply's Accept - frontmatter field (SPEC.md S5.8) -- which pneumatube itself never sends, - since this direction only relays outward to SMTP. The operator must - send at least one message back from the gateway's own identity (e.g. - `echo hi | smolmail.py --key gateway.key --db gateway.db send
`) - before the sender's *next* message will actually carry a valid token. -""" -from __future__ import annotations - -import argparse -import logging -from types import ModuleType - -from . import smtp_client -from .config import Config, FRONTMATTER_EMAIL_KEY - -logger = logging.getLogger(__name__) - - -class SmolToEmailBridge: - def __init__(self, smol: ModuleType, store: object, account: object, config: Config) -> None: - self.smol = smol - self.store = store - self.account = account - self.config = config - self.fetch_args = argparse.Namespace( - key=str(config.smolmail_key), db=str(config.smolmail_db), reset=False, keep=False) - - def poll_once(self) -> None: - self.smol.cmd_fetch(self.fetch_args, self.store) - # "inbox" (main tier only) when gating on acceptance; "all" includes - # the requests tier too, for a deliberately open bridge. - box = "inbox" if self.config.require_accepted_sender else "all" - for mid, envelope, _received_at, _recipient in self.store.mail(box): - try: - self._relay(mid, envelope) - except Exception: - # One message's failure must not block every message behind - # it: _relay() only deletes on success, so a message that - # keeps raising here (e.g. smtp_client.send() rejecting a - # value _relay()'s own except clause doesn't cover) would - # otherwise jam this entire direction forever -- it always - # sorts first by received_at on the next poll. - logger.exception("failed to relay %s; left for manual review", mid.hex()[:16]) - - def _relay(self, mid: bytes, envelope: bytes) -> None: - try: - opened = self.smol.describe(self.store, envelope, self.account.keys) - except self.smol.SmolError as exc: - logger.warning("dropping unreadable %s: %s", mid.hex()[:16], exc) - self.store.run("DELETE FROM inbox WHERE id = ?", mid) - return - - to_addr = opened["fields"].get(FRONTMATTER_EMAIL_KEY.lower()) - if not to_addr: - logger.info("%s from %s has no %s field; left for manual review", - mid.hex()[:16], opened["from"], FRONTMATTER_EMAIL_KEY) - return - - try: - smtp_client.send(self.config, to_addr=to_addr, - subject=opened["subject"] or "(no subject)", - body=opened["text"], info_header=opened["from"]) - except (smtp_client.InvalidAddress, OSError) as exc: - logger.warning("could not relay %s to %r: %s", mid.hex()[:16], to_addr, exc) - return - - self.store.run("DELETE FROM inbox WHERE id = ?", mid) - logger.info("relayed %s to %s", mid.hex()[:16], to_addr) diff --git a/src/pneumatube/smtp_client.py b/src/pneumatube/smtp_client.py deleted file mode 100644 index 0879abd..0000000 --- a/src/pneumatube/smtp_client.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Outbound SMTP: relay a decrypted smolmail message to a real mailbox.""" -from __future__ import annotations - -import re -import smtplib -from email.message import EmailMessage - -from .config import Config - -# Deliberately conservative: this only needs to reject header-injection -# attempts and obvious garbage, not validate every RFC 5321 edge case. -_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") - - -class InvalidAddress(ValueError): - pass - - -def validate(address: str) -> str: - address = address.strip() - if "\n" in address or "\r" in address or not _EMAIL_RE.match(address): - raise InvalidAddress(address) - return address - - -def send(config: Config, *, to_addr: str, subject: str, body: str, info_header: str) -> None: - to_addr = validate(to_addr) - message = EmailMessage() - # Fixed, not derived from the smolmail sender: that sender's address - # isn't a deliverable email address and isn't authorized (SPF/DKIM) to - # send as anything but this one, operator-configured address. A - # per-sender plus-address under this same domain (e.g. - # "gateway+alice@example.com") would stay within that authorization - # while still distinguishing senders and could route a reply back to - # the originating smolmail identity -- a real option if useful later, - # just not implemented. - message["From"] = config.gateway_from_address - message["To"] = to_addr - message["Subject"] = subject - message["X-Smolmail-From"] = info_header - message.set_content(body) - - with smtplib.SMTP(config.smtp_relay_host, config.smtp_relay_port, timeout=30) as client: - if config.smtp_relay_starttls: - client.starttls() - if config.smtp_relay_user: - client.login(config.smtp_relay_user, config.smtp_relay_password or "") - client.send_message(message) diff --git a/src/pneumatube/smtp_server.py b/src/pneumatube/smtp_server.py deleted file mode 100644 index 7e667e9..0000000 --- a/src/pneumatube/smtp_server.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Inbound mail, accepting delivery for this gateway's email domain. - -By default (PNEUMATUBE_LISTEN_PROTOCOL=lmtp) this speaks LMTP on a loopback -port, meant to sit behind a real MTA (Postfix) that owns port 25, TLS and -abuse limits, and hands accepted mail to this listener as the final delivery -agent. The plain-SMTP mode is a local-dev fallback only: bound directly to -the internet it has no TLS, rate limiting or rDNS checks. - -handle_RCPT does a live smolmail RESOLVE per recipient, so an unknown -username is rejected inline during the conversation rather than bounced -later. By default it also requires the recipient to have accepted the -gateway (PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT; see email_to_smol.py's -module docstring) -- an all-or-nothing reject, not a demotion. handle_DATA -replies once per accepted recipient (joined by CRLF) instead of a single -pass/fail for the whole message, which is what makes LMTP's per-recipient -status semantics real rather than cosmetic. -""" -from __future__ import annotations - -import email as email_module -import logging - -from .config import Config -from .email_to_smol import EmailToSmolBridge -from .worker import SmolWorker - -logger = logging.getLogger(__name__) - - -class GatewayHandler: - def __init__(self, bridge: EmailToSmolBridge, worker: SmolWorker, config: Config) -> None: - self.bridge = bridge - self.worker = worker - self.config = config - - async def handle_RCPT(self, server, session, envelope, address, rcpt_options): - username, _, domain = address.partition("@") - username = username.lower() - if domain.lower() != self.config.email_domain.lower(): - return "550 relaying not permitted" - - exists = await self.worker.run(self.bridge.user_exists, username) - if exists is None: - return "451 temporary failure resolving recipient" - if not exists: - return "550 no such user" - - if self.config.require_accepted_recipient: - accepted = await self.worker.run(self.bridge.recipient_accepted_gateway, username) - if not accepted: - return "550 5.7.1 recipient has not accepted mail from this gateway" - - envelope.rcpt_tos.append(f"{username}@{domain}") - return "250 OK" - - async def handle_DATA(self, server, session, envelope): - message = email_module.message_from_bytes(envelope.content) - subject = str(message.get("Subject", "(no subject)")) - body = self.bridge.extract_text(message) - - replies = [] - for address in envelope.rcpt_tos: - username = address.split("@", 1)[0] - try: - await self.worker.run(self.bridge.forward, username, subject, body) - replies.append("250 2.1.5 delivered") - except Exception: - logger.exception("failed to forward mail for %s", username) - replies.append("451 4.3.0 temporary failure relaying to smolmail") - return "\r\n".join(replies) diff --git a/src/pneumatube/vendor.py b/src/pneumatube/vendor.py deleted file mode 100644 index 35969b7..0000000 --- a/src/pneumatube/vendor.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Loads the sibling smolmail reference client as a library. - -smolmail.py is a single-file `uv run --script` program, not an installed -package, so it is loaded by path from its checkout rather than declared as a -project dependency. Its own inline dependencies (pynacl, noiseprotocol) are -declared directly in this project's pyproject.toml so the exec'd module -resolves its imports normally. -""" -from __future__ import annotations - -import importlib.util -import sys -from pathlib import Path -from types import ModuleType - - -def load(repo_path: Path) -> ModuleType: - module_path = repo_path / "smolmail.py" - if not module_path.is_file(): - raise FileNotFoundError( - f"no smolmail.py at {module_path}; set PNEUMATUBE_SMOLMAIL_REPO " - "to a smolmail checkout") - spec = importlib.util.spec_from_file_location("smolmail", module_path) - module = importlib.util.module_from_spec(spec) - sys.modules["smolmail"] = module - spec.loader.exec_module(module) - return module diff --git a/src/pneumatube/worker.py b/src/pneumatube/worker.py deleted file mode 100644 index 407b0c6..0000000 --- a/src/pneumatube/worker.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Serializes all smolmail protocol work onto one dedicated thread. - -smolmail.py's Store wraps a sqlite3 connection bound to the thread that -created it, and its Noise session is one-operation-at-a-time per identity, so -sharing a single background thread for every smolmail call (fetch polling, -RESOLVE checks, SEND) is both required for correctness and a natural fit. -""" -from __future__ import annotations - -import asyncio -from concurrent.futures import ThreadPoolExecutor -from typing import Callable, TypeVar - -T = TypeVar("T") - - -class SmolWorker: - def __init__(self) -> None: - self._executor = ThreadPoolExecutor(max_workers=1) - - async def run(self, fn: Callable[..., T], *args: object) -> T: - loop = asyncio.get_running_loop() - return await loop.run_in_executor(self._executor, fn, *args) - - def shutdown(self) -> None: - self._executor.shutdown(wait=False) diff --git a/src/smol_to_email.rs b/src/smol_to_email.rs new file mode 100644 index 0000000..823b5f1 --- /dev/null +++ b/src/smol_to_email.rs @@ -0,0 +1,255 @@ +//! smolmail -> email direction. +//! +//! Polls the gateway's own smolmail inbox and relays any message that +//! carries the Email-To frontmatter field (SPEC.md S5.5) to a real mailbox +//! over SMTP. A message without that field is left in the local inbox +//! rather than dropped, so it stays visible to `fumi --db list/read` +//! against the gateway's own store for manual follow-up. +//! +//! By default (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=1), only the main tier +//! is relayed: a first-contact sender with no accept token (SPEC.md S5.8) +//! lands in the requests tier and is left alone, so the gateway can't be +//! used as an open relay by anyone who merely learns its smolmail address. +//! Setting the flag to false restores the earlier "relay anyone" behavior +//! for a deliberately open/public bridge. +//! +//! Unblocking a sender takes two steps, not one -- confirmed live, since +//! it is easy to assume `accept` alone is enough: +//! 1. `fumi --key gateway.key --db gateway.db accept ` +//! records the acceptance locally and uploads the token to the server, +//! but does NOT retroactively reclassify a message already sitting in +//! this gateway's local inbox (its tier was decided at fetch time) or +//! do anything the sender can see. +//! 2. The sender only learns the token when it arrives in a reply's Accept +//! frontmatter field (SPEC.md S5.8) -- which pneumatube itself never +//! sends, since this direction only relays outward to SMTP. The +//! operator must send at least one message back from the gateway's own +//! identity (e.g. `echo hi | fumi --key gateway.key --db gateway.db +//! send
`) before the sender's *next* message will actually +//! carry a valid token. + +use std::sync::Arc; + +use fumi_core::account::Account; +use fumi_core::client::{self, Described}; +use fumi_core::error::Error; +use fumi_core::store::{Store, Stored}; + +use crate::config::{Config, FRONTMATTER_EMAIL_KEY}; +use crate::email_to_smol::TIMEOUT; +use crate::smtp_client::{self, SendError}; +use crate::{log_error, log_info, log_warn}; + +pub struct SmolToEmail { + store: Arc, + account: Arc, + config: Arc, +} + +impl SmolToEmail { + pub fn new(store: Arc, account: Arc, config: Arc) -> Self { + SmolToEmail { + store, + account, + config, + } + } + + /// Fetch new mail, then relay everything relatable in the tier(s) this + /// gateway is configured to look at. + pub fn poll_once(&self) { + if let Err(error) = client::fetch(&self.store, &self.account, false, false, TIMEOUT) { + log_warn!("smolmail fetch failed: {error}"); + return; + } + let Ok(messages) = self.store.mail(self.folder()) else { + return; + }; + drain(messages, |stored| self.relay(stored)); + } + + /// "inbox" (main tier only) when gating on acceptance; "all" includes + /// the requests tier too, for a deliberately open bridge. + pub fn folder(&self) -> &'static str { + if self.config.require_accepted_sender { + "inbox" + } else { + "all" + } + } + + fn relay(&self, stored: &Stored) -> Result<(), Error> { + let id = hex_prefix(&stored.id); + let opened = match client::describe(&self.store, &self.account, stored) { + Ok(opened) => opened, + Err(error) => { + // Undecryptable here means it will never relay: drop the + // local copy so it does not sort first on every poll. + log_warn!("dropping unreadable {id}: {error}"); + self.store.delete_local("inbox", &stored.id)?; + return Ok(()); + } + }; + let to_addr = match opened.fields.get(&FRONTMATTER_EMAIL_KEY.to_lowercase()) { + Some(addr) => addr, + None => { + log_info!( + "{id} from {from} has no {FRONTMATTER_EMAIL_KEY} field; left for manual review", + from = opened.from + ); + return Ok(()); + } + }; + match smtp_client::send( + &self.config, + to_addr, + subject_of(&opened), + &opened.text, + &opened.from, + ) { + Ok(()) => {} + // An invalid recipient or an unreachable relay is no reason to + // drop or keep retrying forever: the message stays for manual + // review, like the Python version's (InvalidAddress, OSError) + // catch. + Err(SendError::Invalid(address)) => { + log_warn!("could not relay {id} to {address:?}: invalid address"); + return Ok(()); + } + Err(SendError::Relay(error)) => { + log_warn!("could not relay {id} to {to_addr:?}: {error}"); + return Ok(()); + } + // A CR/LF in a header value means the sender tried to inject + // headers into the outbound message: refuse outright, so the + // message is left for manual review rather than rewritten. + Err(SendError::InvalidHeader(value)) => { + return Err(Error::Other(format!( + "refusing to relay {id}: header value {value:?} carries CR/LF" + ))) + } + } + self.store.delete_local("inbox", &stored.id)?; + log_info!("relayed {id} to {to_addr}"); + Ok(()) + } +} + +fn subject_of(opened: &Described) -> &str { + if opened.subject.is_empty() { + "(no subject)" + } else { + &opened.subject + } +} + +/// Relays every message, one failure at a time: a message that keeps +/// failing is only ever deleted on success, so it would otherwise sort +/// first on every poll and jam this entire direction forever. This loop is +/// the regression-tested guarantee that it cannot. +pub fn drain(messages: Vec, mut relay: impl FnMut(&Stored) -> Result<(), Error>) { + for stored in messages { + if let Err(error) = relay(&stored) { + log_error!( + "failed to relay {id}: {error}; left for manual review", + id = hex_prefix(&stored.id) + ); + } + } +} + +/// First 16 hex chars of a message id, the form the Python version logged. +pub fn hex_prefix(id: &[u8; 32]) -> String { + data_encoding::HEXLOWER.encode(&id[..8]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stub_config(require_accepted_sender: bool) -> Config { + Config { + smolmail_key: "k".into(), + smolmail_db: "d".into(), + smolmail_domain: "smol.example".into(), + email_domain: "gateway.example".into(), + fetch_interval: 30, + listen_protocol: crate::config::ListenProtocol::Lmtp, + listen_host: "127.0.0.1".into(), + listen_port: 2424, + lookup_host: "127.0.0.1".into(), + lookup_port: 2526, + smtp_relay_host: "127.0.0.1".into(), + smtp_relay_port: 587, + smtp_relay_user: None, + smtp_relay_password: None, + smtp_relay_starttls: true, + gateway_from_address: "gateway@gateway.example".into(), + require_accepted_sender, + require_accepted_recipient: true, + } + } + + fn bridge(require_accepted_sender: bool) -> SmolToEmail { + let store = Store::open_in_memory().unwrap(); + let account = Account::new([1u8; 32], 0).unwrap(); + SmolToEmail::new( + Arc::new(store), + Arc::new(account), + Arc::new(stub_config(require_accepted_sender)), + ) + } + + #[test] + fn poll_folder_is_main_tier_only_by_default() { + // require_accepted_sender=true (the default) must poll "inbox" + // (main tier), not "all" -- "all" would include requests-tier mail + // from first-contact senders with no accept token (SPEC.md S5.8), + // which is exactly what this setting exists to keep the gateway + // from relaying. + assert_eq!(bridge(true).folder(), "inbox"); + } + + #[test] + fn poll_folder_is_all_tiers_when_open_bridge_enabled() { + assert_eq!(bridge(false).folder(), "all"); + } + + #[test] + fn drain_does_not_stop_after_one_message_fails() { + // A message that keeps raising out of relay() (e.g. a header value + // smtp_client rejects that relay()'s own handling doesn't cover) + // must not block every message behind it -- it always sorts first + // on the next poll, since only a successful relay deletes it. + let poison = Stored { + id: [1u8; 32], + envelope: vec![], + at: 0, + recipient: None, + kept: false, + }; + let ok = Stored { + id: [2u8; 32], + envelope: vec![], + at: 1, + recipient: None, + kept: false, + }; + let mut relayed = Vec::new(); + drain(vec![poison, ok], |stored| { + if stored.id == [1u8; 32] { + // A failure relay()'s own handling would not swallow. + Err(Error::Other("simulated failure".into())) + } else { + relayed.push(stored.id); + Ok(()) + } + }); + assert_eq!(relayed, vec![[2u8; 32]]); + } + + #[test] + fn hex_prefix_is_16_hex_chars() { + assert_eq!(hex_prefix(&[0xab; 32]), "abababababababab"); + } +} diff --git a/src/smtp_client.rs b/src/smtp_client.rs new file mode 100644 index 0000000..418b101 --- /dev/null +++ b/src/smtp_client.rs @@ -0,0 +1,369 @@ +//! Outbound SMTP: relay a decrypted smolmail message to a real mailbox. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::Arc; +use std::time::Duration; + +use data_encoding::BASE64; +use rustls::ClientConnection; + +use crate::config::Config; + +/// Deliberately conservative: this only needs to reject header-injection +/// attempts and obvious garbage, not validate every RFC 5321 edge case -- +/// the same contract the Python version's regex had. +pub fn validate(address: &str) -> Result { + let address = address.trim(); + let bad = || InvalidAddress(address.to_string()); + if address.contains('\r') || address.contains('\n') || address.chars().any(char::is_whitespace) + { + return Err(bad()); + } + let mut parts = address.split('@'); + let (local, domain) = (parts.next().unwrap_or(""), parts.next().ok_or_else(bad)?); + if parts.next().is_some() || local.is_empty() || domain.is_empty() { + return Err(bad()); + } + // One dot that is neither first nor last: [^\s@]+\.[^\s@]+ in domain + // position, minus the garbage nobody agrees on. + if !domain + .bytes() + .enumerate() + .any(|(i, b)| b == b'.' && i > 0 && i < domain.len() - 1) + { + return Err(bad()); + } + Ok(address.to_string()) +} + +#[derive(Debug)] +pub struct InvalidAddress(pub String); + +impl std::fmt::Display for InvalidAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?} is not a valid email address", self.0) + } +} + +impl std::error::Error for InvalidAddress {} + +#[derive(Debug)] +pub enum SendError { + /// The recipient address failed validation. + Invalid(InvalidAddress), + /// A header value carried CR/LF: refused outright rather than smuggled + /// through -- the backstop the Python EmailMessage used to provide, + /// since a bare CR/LF here would inject extra headers into the relayed + /// message. Callers treat this as a poison condition, not a retry. + InvalidHeader(String), + /// Reaching or talking to the relay failed (connect, TLS, or an SMTP + /// reply the conversation could not continue past). + Relay(String), +} + +fn relay_err(e: impl std::fmt::Display) -> SendError { + SendError::Relay(e.to_string()) +} + +/// A header value must be a single line. Non-ASCII becomes one RFC 2047 +/// encoded word, the way the Python implementation's header policy did. +fn header_value(value: &str) -> Result { + if value.contains('\r') || value.contains('\n') { + return Err(SendError::InvalidHeader(value.to_string())); + } + if value.is_ascii() { + Ok(value.to_string()) + } else { + Ok(format!("=?utf-8?b?{}?=", BASE64.encode(value.as_bytes()))) + } +} + +/// One reply from the relay: a code plus the text of all its lines. +struct Reply { + code: u16, + lines: Vec, +} + +enum Conn { + Plain(TcpStream), + // Boxed: ClientConnection is ~1KB, and a connection is only ever one + // variant at a time. + Tls(Box>), +} + +impl Read for Conn { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Conn::Plain(s) => s.read(buf), + Conn::Tls(s) => s.read(buf), + } + } +} + +impl Write for Conn { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Conn::Plain(s) => s.write(buf), + Conn::Tls(s) => s.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Conn::Plain(s) => s.flush(), + Conn::Tls(s) => s.flush(), + } + } +} + +const MAX_LINE: usize = 4096; + +fn read_line(conn: &mut Conn) -> Result { + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = conn.read(&mut byte).map_err(relay_err)?; + if n == 0 { + return Err(relay_err("connection closed mid-reply")); + } + if byte[0] == b'\n' { + break; + } + line.push(byte[0]); + if line.len() > MAX_LINE { + return Err(relay_err("reply line too long")); + } + } + if line.last() == Some(&b'\r') { + line.pop(); + } + Ok(String::from_utf8_lossy(&line).into_owned()) +} + +fn read_reply(conn: &mut Conn) -> Result { + let mut lines = Vec::new(); + loop { + let line = read_line(conn)?; + if line.len() < 3 || !line[..3].bytes().all(|b| b.is_ascii_digit()) { + return Err(relay_err(format!("malformed reply line: {line:?}"))); + } + let code: u16 = line[..3].parse().map_err(relay_err)?; + let more = line.as_bytes().get(3) == Some(&b'-'); + // "250-text" / "250 text" / a bare "250" all carry the text after + // the separator (or nothing). + let text_start = if more || line.len() == 3 { 3 } else { 4 }; + lines.push(line[text_start..].to_string()); + if !more { + return Ok(Reply { code, lines }); + } + if lines.len() > 100 { + return Err(relay_err("endless multiline reply")); + } + } +} + +fn command(conn: &mut Conn, text: &str) -> Result { + conn.write_all(text.as_bytes()) + .and_then(|_| conn.write_all(b"\r\n")) + .and_then(|_| conn.flush()) + .map_err(relay_err)?; + read_reply(conn) +} + +fn expect(conn: &mut Conn, text: &str, code: u16) -> Result { + let reply = command(conn, text)?; + if reply.code != code { + return Err(relay_err(format!( + "{} replied {} to {:?} (wanted {code})", + reply.code, + reply.lines.first().map(String::as_str).unwrap_or(""), + text + ))); + } + Ok(reply) +} + +fn tls_wrap(stream: TcpStream, host: &str) -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut roots = rustls::RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + for cert in native.certs { + roots.add(cert).map_err(relay_err)?; + } + if roots.is_empty() { + // No system trust store (a scratch container, say): fall back to + // the Mozilla set rather than shipping a broken relay. + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + } + let config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let name = rustls::pki_types::ServerName::try_from(host.to_string()) + .map_err(|e| relay_err(format!("{host:?} is not a valid TLS server name: {e}")))?; + let connection = ClientConnection::new(Arc::new(config), name).map_err(relay_err)?; + Ok(Conn::Tls(Box::new(rustls::StreamOwned::new( + connection, stream, + )))) +} + +/// Relay one message to the configured outbound relay, the way the Python +/// implementation's smtplib call did: optional STARTTLS, optional AUTH +/// LOGIN, then a single MAIL/RCPT/DATA transaction. +pub fn send( + config: &Config, + to_addr: &str, + subject: &str, + body: &str, + info_header: &str, +) -> Result<(), SendError> { + let to_addr = validate(to_addr).map_err(SendError::Invalid)?; + let subject = header_value(subject)?; + let info_header = header_value(info_header)?; + let (cte, body) = if subject.is_ascii() && info_header.is_ascii() && body.is_ascii() { + ("7bit", body.to_string()) + } else { + ("base64", BASE64.encode(body.as_bytes())) + }; + let message = format!( + "From: {}\r\nTo: {}\r\nSubject: {}\r\nX-Smolmail-From: {}\r\n\ + MIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\ + Content-Transfer-Encoding: {cte}\r\n\r\n{}\r\n", + config.gateway_from_address, to_addr, subject, info_header, body + ); + + let mut conn = Conn::Plain( + TcpStream::connect((config.smtp_relay_host.as_str(), config.smtp_relay_port)) + .map_err(relay_err)?, + ); + conn.set_timeouts(); + let greeting = read_reply(&mut conn)?; + if greeting.code != 220 { + return Err(relay_err(format!( + "relay greeted with {code}", + code = greeting.code + ))); + } + + let ehlo = expect(&mut conn, "EHLO pneumatube", 250)?; + if config.smtp_relay_starttls + && ehlo + .lines + .iter() + .any(|l| l.eq_ignore_ascii_case("STARTTLS")) + { + expect(&mut conn, "STARTTLS", 220)?; + conn = tls_wrap( + match conn { + Conn::Plain(stream) => stream, + Conn::Tls(_) => unreachable!("STARTTLS before any TLS stream exists"), + }, + &config.smtp_relay_host, + )?; + expect(&mut conn, "EHLO pneumatube", 250)?; + } + if let Some(user) = config.smtp_relay_user.as_deref() { + expect(&mut conn, "AUTH LOGIN", 334)?; + expect(&mut conn, &BASE64.encode(user.as_bytes()), 334)?; + let password = BASE64.encode( + config + .smtp_relay_password + .as_deref() + .unwrap_or("") + .as_bytes(), + ); + expect(&mut conn, &password, 235) + .map_err(|_| SendError::Relay("relay rejected the AUTH LOGIN credentials".into()))?; + } + expect( + &mut conn, + &format!("MAIL FROM:<{}>", config.gateway_from_address), + 250, + )?; + expect(&mut conn, &format!("RCPT TO:<{to_addr}>"), 250)?; + expect(&mut conn, "DATA", 354)?; + // Dot-stuffing: a body line beginning with "." would otherwise end the + // DATA block early and be interpreted as a command. + let mut data = String::new(); + for line in message.split("\r\n") { + if line.starts_with('.') { + data.push('.'); + } + data.push_str(line); + data.push_str("\r\n"); + } + data.push_str(".\r\n"); + conn.write_all(data.as_bytes()) + .and_then(|_| conn.flush()) + .map_err(relay_err)?; + let reply = read_reply(&mut conn)?; + if reply.code != 250 { + return Err(relay_err(format!( + "relay replied {} after DATA: {}", + reply.code, + reply.lines.first().map(String::as_str).unwrap_or("") + ))); + } + let _ = command(&mut conn, "QUIT"); + Ok(()) +} + +impl Conn { + fn set_timeouts(&mut self) { + let timeout = Some(Duration::from_secs(30)); + match self { + Conn::Plain(s) => { + let _ = s.set_read_timeout(timeout); + let _ = s.set_write_timeout(timeout); + } + Conn::Tls(s) => { + let _ = s.get_ref().set_read_timeout(timeout); + let _ = s.get_ref().set_write_timeout(timeout); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_accepts_plain_address() { + assert_eq!(validate(" bob@example.org ").unwrap(), "bob@example.org"); + } + + #[test] + fn validate_rejects_bad_addresses() { + // The security harness's injection battery: every CR/LF variant a + // frontmatter value could smuggle, plus the odd whitespace forms. + for address in [ + "not-an-email", + "bob@localhost", + "bob@example.org\r\nBcc: evil@example.org", + "bob@example.org\nX-Injected: 1", + "bob@example.org\rX-Injected: 1", + "evil@example.com\x00X-Injected: 1", + "evil@example.com\u{2028}X-Injected: 1", + "evil@example.com\u{2029}X-Injected: 1", + "bo b@example.org", + "bob@@example.org", + "@example.org", + ] { + assert!(validate(address).is_err(), "{address:?} should be rejected"); + } + // A very long address is garbage-shaped but carries no injection; + // the relay is the one who gets to say no to it. + assert!(validate(&format!("{}@example.com", "a".repeat(10_000))).is_ok()); + } + + #[test] + fn header_value_refuses_crlf_and_encodes_non_ascii() { + assert!(matches!( + header_value("alice@example.org\r\nAccept: forged"), + Err(SendError::InvalidHeader(_)) + )); + assert_eq!(header_value("plain").unwrap(), "plain"); + let encoded = header_value("grüße").unwrap(); + assert!(encoded.starts_with("=?utf-8?b?"), "{encoded}"); + } +} diff --git a/src/smtp_server.rs b/src/smtp_server.rs new file mode 100644 index 0000000..f5924ef --- /dev/null +++ b/src/smtp_server.rs @@ -0,0 +1,469 @@ +//! Inbound mail, accepting delivery for this gateway's email domain. +//! +//! By default (PNEUMATUBE_LISTEN_PROTOCOL=lmtp) this speaks LMTP on a +//! loopback port, meant to sit behind a real MTA (Postfix) that owns port +//! 25, TLS and abuse limits, and hands accepted mail to this listener as +//! the final delivery agent. The plain-SMTP mode is a local-dev fallback +//! only: bound directly to the internet it has no TLS, rate limiting or +//! rDNS checks. +//! +//! rcpt() does a live smolmail RESOLVE per recipient, so an unknown +//! username is rejected inline during the conversation rather than bounced +//! later. By default it also requires the recipient to have accepted the +//! gateway (PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT; see `email_to_smol`'s +//! module docs) -- an all-or-nothing reject, not a demotion. data() replies +//! once per accepted recipient (joined by CRLF) instead of a single +//! pass/fail for the whole message, which is what makes LMTP's +//! per-recipient status semantics real rather than cosmetic. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; + +use crate::config::{Config, ListenProtocol}; +use crate::email_to_smol::{extract_text, Bridge}; +use crate::log_warn; + +/// aiosmtpd's default, kept so an oversized message is refused rather than +/// buffered unbounded. +const DATA_SIZE_LIMIT: usize = 8 * 1024 * 1024; +const MAX_LINE: usize = 8192; + +/// One connection's state machine. Split from the socket loop so the RCPT +/// and DATA logic is unit-testable against a stub bridge. +pub struct Session { + pub mail_from: Option, + pub rcpt_tos: Vec, +} + +impl Session { + pub fn new() -> Self { + Session { + mail_from: None, + rcpt_tos: Vec::new(), + } + } + + pub fn rcpt(&mut self, bridge: &dyn Bridge, config: &Config, address: &str) -> String { + let Some((username, domain)) = address.split_once('@') else { + return "501 syntax: RCPT TO:
".to_string(); + }; + let username = username.to_lowercase(); + if !domain.eq_ignore_ascii_case(&config.email_domain) { + return "550 relaying not permitted".to_string(); + } + match bridge.user_exists(&username) { + None => return "451 temporary failure resolving recipient".to_string(), + Some(false) => return "550 no such user".to_string(), + Some(true) => {} + } + if config.require_accepted_recipient && !bridge.recipient_accepted_gateway(&username) { + return "550 5.7.1 recipient has not accepted mail from this gateway".to_string(); + } + self.rcpt_tos.push(address.to_string()); + "250 OK".to_string() + } + + /// Reads the DATA payload (dot-unstuffed) and relays it once per + /// accepted recipient, replying per recipient. + pub fn data(&mut self, bridge: &dyn Bridge, raw: &[u8]) -> String { + let subject = match MessageParserSubject::parse(raw) { + Some(subject) => subject, + None => "(no subject)".to_string(), + }; + let body = extract_text(raw); + let mut replies = Vec::new(); + for address in self.rcpt_tos.clone() { + let username = address.split('@').next().unwrap_or_default(); + match bridge.forward(username, &subject, &body) { + Ok(()) => replies.push("250 2.1.5 delivered".to_string()), + Err(error) => { + log_warn!("failed to forward mail for {username}: {error}"); + replies.push("451 4.3.0 temporary failure relaying to smolmail".to_string()); + } + } + } + self.mail_from = None; + self.rcpt_tos.clear(); + replies.join("\r\n") + } +} + +/// Subject header via mail-parser, kept in its own shim because the parser +/// borrows the raw bytes and the session only needs the one header. +struct MessageParserSubject; + +impl MessageParserSubject { + fn parse(raw: &[u8]) -> Option { + let message = mail_parser::MessageParser::default().parse(raw)?; + message.subject().map(str::to_string) + } +} + +/// Serves until the process exits: one thread per connection. Every smolmail +/// operation the handler performs is independent (fumi's store is Send + +/// Sync, each dial is its own Noise session), so unlike the Python version +/// there is no serialization point -- concurrency is capped only by the +/// smolmail server on the other end. +pub fn serve(listener: TcpListener, bridge: Arc, config: Arc) { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let bridge = bridge.clone(); + let config = config.clone(); + std::thread::spawn(move || { + let _ = handle_connection(stream, bridge, config); + }); + } +} + +fn handle_connection( + stream: TcpStream, + bridge: Arc, + config: Arc, +) -> std::io::Result<()> { + let peer = stream.peer_addr()?; + let mut reader = BufReader::with_capacity(16 * 1024, stream.try_clone()?); + let mut writer = stream; + let lmtp = config.listen_protocol == ListenProtocol::Lmtp; + writeln!( + writer, + "220 pneumatube {}", + if lmtp { "LMTP" } else { "ESMTP" } + )?; + + let mut session = Session::new(); + loop { + let mut line = String::new(); + let read = reader.read_line(&mut line)?; + if read == 0 || read > MAX_LINE { + break; + } + let request = line.trim_end_matches(['\r', '\n']); + let (command, rest) = request.split_once(' ').unwrap_or((request, "")); + match command.to_ascii_uppercase().as_str() { + "LHLO" if lmtp => { + session.mail_from = None; + session.rcpt_tos.clear(); + write!(writer, "250 pneumatube\r\n")?; + } + "HELO" => { + session.mail_from = None; + session.rcpt_tos.clear(); + write!(writer, "250 pneumatube\r\n")?; + } + "EHLO" => { + session.mail_from = None; + session.rcpt_tos.clear(); + write!(writer, "250-pneumatube\r\n250 8BITMIME\r\n")?; + } + "MAIL" => { + let Some(from) = address_arg(rest, "FROM:") else { + write!(writer, "501 syntax: MAIL FROM:
\r\n")?; + continue; + }; + // RFC 5321: MAIL resets any recipients already collected. + session.mail_from = Some(from); + session.rcpt_tos.clear(); + write!(writer, "250 OK\r\n")?; + } + "RCPT" => { + let Some(address) = address_arg(rest, "TO:") else { + write!(writer, "501 syntax: RCPT TO:
\r\n")?; + continue; + }; + let reply = if session.mail_from.is_none() { + "503 Error: need MAIL command first".to_string() + } else { + session.rcpt(&*bridge, &config, &address) + }; + write!(writer, "{reply}\r\n")?; + } + "DATA" => { + if session.mail_from.is_none() { + write!(writer, "503 Error: need MAIL command first\r\n")?; + continue; + } + if session.rcpt_tos.is_empty() { + write!(writer, "503 Error: need RCPT command first\r\n")?; + continue; + } + write!(writer, "354 End data with .\r\n")?; + match read_data(&mut reader) { + Ok(raw) => { + let reply = session.data(&*bridge, &raw); + write!(writer, "{reply}\r\n")?; + } + Err(error) => { + log_warn!("connection from {peer}: {error}"); + break; + } + } + } + "RSET" => { + session.mail_from = None; + session.rcpt_tos.clear(); + write!(writer, "250 OK\r\n")?; + } + "NOOP" => write!(writer, "250 OK\r\n")?, + "VRFY" => write!(writer, "502 VRFY is disabled\r\n")?, + "QUIT" => { + write!(writer, "221 pneumatube closing\r\n")?; + break; + } + other => write!(writer, "500 Error: command {other:?} not recognized\r\n")?, + } + } + Ok(()) +} + +/// Extracts `
` from "FROM:<...>" / "TO:<...>" argument text, +/// tolerating the angle brackets Postfix and smtplib both send. +fn address_arg(rest: &str, prefix: &str) -> Option { + let rest = rest.trim(); + let after = rest + .strip_prefix(prefix) + .or_else(|| rest.strip_prefix(&prefix.to_lowercase()))?; + let after = after.trim(); + let address = after + .strip_prefix('<') + .and_then(|inner| inner.strip_suffix('>')) + .unwrap_or(after); + if address.is_empty() { + None + } else { + Some(address.to_string()) + } +} + +/// Reads a DATA payload up to the dot terminator, un-stuffing leading dots. +fn read_data(reader: &mut impl BufRead) -> Result, String> { + let mut payload = Vec::new(); + let mut line = Vec::new(); + loop { + line.clear(); + read_crlf_line(reader, &mut line)?; + if line == b"." { + break; + } + if line.starts_with(b"..") { + // Dot-stuffed: strip the leading dot. + line.remove(0); + } + payload.extend_from_slice(&line); + payload.extend_from_slice(b"\r\n"); + if payload.len() > DATA_SIZE_LIMIT { + return Err("DATA exceeds size limit".to_string()); + } + } + Ok(payload) +} + +fn read_crlf_line(reader: &mut impl BufRead, line: &mut Vec) -> Result<(), String> { + let mut byte = [0u8; 1]; + loop { + let n = reader.read(&mut byte).map_err(|e| e.to_string())?; + if n == 0 { + return Err("connection closed mid-DATA".to_string()); + } + if byte[0] == b'\n' { + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(()); + } + line.push(byte[0]); + if line.len() > MAX_LINE { + return Err("DATA line too long".to_string()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fumi_core::error::Error; + + struct StubBridge { + known: Vec<&'static str>, + accepted: Vec<&'static str>, + forwarded: std::sync::Mutex>, + } + + impl StubBridge { + fn new() -> Self { + StubBridge { + known: vec!["alice", "bob", "broken"], + accepted: vec!["alice", "bob", "broken"], + forwarded: std::sync::Mutex::new(Vec::new()), + } + } + } + + impl Bridge for StubBridge { + fn user_exists(&self, username: &str) -> Option { + Some(self.known.contains(&username)) + } + fn recipient_accepted_gateway(&self, username: &str) -> bool { + self.accepted.contains(&username) + } + fn forward(&self, username: &str, _subject: &str, _body: &str) -> Result<(), Error> { + self.forwarded.lock().unwrap().push(username.to_string()); + if username == "broken" { + Err(Error::Unreachable { + host: "smol.example".into(), + port: 1961, + source: std::io::Error::new(std::io::ErrorKind::Other, "unreachable"), + }) + } else { + Ok(()) + } + } + } + + fn test_config(require_accepted_recipient: bool) -> Config { + Config { + smolmail_key: "k".into(), + smolmail_db: "d".into(), + smolmail_domain: "smol.example".into(), + email_domain: "gateway.example".to_string(), + fetch_interval: 30, + listen_protocol: ListenProtocol::Lmtp, + listen_host: "127.0.0.1".into(), + listen_port: 2424, + lookup_host: "127.0.0.1".into(), + lookup_port: 2526, + smtp_relay_host: "127.0.0.1".into(), + smtp_relay_port: 587, + smtp_relay_user: None, + smtp_relay_password: None, + smtp_relay_starttls: true, + gateway_from_address: "gateway@gateway.example".into(), + require_accepted_sender: true, + require_accepted_recipient, + } + } + + fn raw_message() -> Vec { + b"Subject: hi\r\n\r\nbody text\r\n".to_vec() + } + + #[test] + fn rcpt_rejects_wrong_domain() { + let bridge = StubBridge::new(); + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + let reply = session.rcpt(&bridge, &test_config(true), "alice@other.example"); + assert!(reply.starts_with("550"), "{reply}"); + } + + #[test] + fn rcpt_rejects_unknown_user() { + let mut bridge = StubBridge::new(); + bridge.known = vec![]; + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + let reply = session.rcpt(&bridge, &test_config(true), "ghost@gateway.example"); + assert!(reply.starts_with("550"), "{reply}"); + } + + #[test] + fn rcpt_rejects_unaccepted_recipient_by_default() { + // The all-or-nothing gate: a recipient who exists but has not + // accepted the gateway (no token held) must be rejected live, not + // silently demoted to the recipient's own requests tier. + let mut bridge = StubBridge::new(); + bridge.accepted = vec![]; + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + let reply = session.rcpt(&bridge, &test_config(true), "alice@gateway.example"); + assert!(reply.starts_with("550"), "{reply}"); + } + + #[test] + fn rcpt_allows_unaccepted_recipient_when_disabled() { + let mut bridge = StubBridge::new(); + bridge.accepted = vec![]; + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + let config = test_config(false); + let reply = session.rcpt(&bridge, &config, "alice@gateway.example"); + assert!(reply.starts_with("250"), "{reply}"); + assert_eq!(session.rcpt_tos, vec!["alice@gateway.example".to_string()]); + } + + #[test] + fn rcpt_accepts_known_accepted_recipient() { + let bridge = StubBridge::new(); + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + let reply = session.rcpt(&bridge, &test_config(true), "alice@gateway.example"); + assert!(reply.starts_with("250"), "{reply}"); + assert_eq!(session.rcpt_tos, vec!["alice@gateway.example".to_string()]); + } + + #[test] + fn data_replies_once_per_recipient() { + let bridge = StubBridge::new(); + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + session.rcpt_tos = vec![ + "alice@gateway.example".into(), + "broken@gateway.example".into(), + ]; + let reply = session.data(&bridge, &raw_message()); + let lines: Vec<&str> = reply.split("\r\n").collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].starts_with("250"), "{lines:?}"); + assert!(lines[1].starts_with("451"), "{lines:?}"); + assert_eq!( + *bridge.forwarded.lock().unwrap(), + vec!["alice".to_string(), "broken".to_string()] + ); + } + + #[test] + fn data_all_succeed() { + let bridge = StubBridge::new(); + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + session.rcpt_tos = vec!["alice@gateway.example".into(), "bob@gateway.example".into()]; + let reply = session.data(&bridge, &raw_message()); + assert_eq!(reply, "250 2.1.5 delivered\r\n250 2.1.5 delivered"); + } + + #[test] + fn rcpt_username_is_lowercased() { + let mut bridge = StubBridge::new(); + bridge.known = vec!["alice"]; + let mut session = Session::new(); + session.mail_from = Some("sender@realmail.example".into()); + let reply = session.rcpt(&bridge, &test_config(true), "ALICE@gateway.example"); + assert!(reply.starts_with("250"), "{reply}"); + } + + #[test] + fn address_arg_parses_forms() { + assert_eq!( + address_arg("FROM:", "FROM:").unwrap(), + "bob@example.org" + ); + assert_eq!( + address_arg("from:bob@example.org", "FROM:").unwrap(), + "bob@example.org" + ); + assert_eq!( + address_arg("TO:", "TO:").unwrap(), + "bob@example.org" + ); + assert!(address_arg("TO:<>", "TO:").is_none()); + assert!(address_arg("FROM:", "FROM:").is_none()); + } + + #[test] + fn read_data_unstuffs_dots_and_terminates() { + let wire = b"line one\r\n..stuffed starts with dot\r\n.\r\nNOOP should not appear\r\n"; + let mut reader = std::io::BufReader::new(&wire[..]); + let payload = read_data(&mut reader).unwrap(); + assert_eq!(payload, b"line one\r\n.stuffed starts with dot\r\n"); + } +} diff --git a/tests/test_email_to_smol.py b/tests/test_email_to_smol.py deleted file mode 100644 index 7fbae6a..0000000 --- a/tests/test_email_to_smol.py +++ /dev/null @@ -1,73 +0,0 @@ -from email.message import EmailMessage -from types import SimpleNamespace - -from pneumatube.email_to_smol import EmailToSmolBridge, _single_line - -_DEFAULT_PORT = 1961 - - -class _Address: - def __init__(self, user, host, port): - self.user, self.host, self.port = user, host, port - - @property - def short(self): - return f"{self.user}@{self.host}" + ("" if self.port == _DEFAULT_PORT else f":{self.port}") - - -class _StubAddressClass: - @staticmethod - def parse(text): - user, _, hostport = text.partition("@") - host, _, port = hostport.partition(":") - return _Address(user, host, int(port) if port else _DEFAULT_PORT) - - -class _StubSmol: - Address = _StubAddressClass - - -class _StubStore: - def __init__(self, tokens): - self.tokens = tokens - - def one(self, sql, address): - return (b"token",) if address in self.tokens else None - - -def test_recipient_accepted_gateway_true_when_token_held(): - bridge = EmailToSmolBridge(_StubSmol(), _StubStore({"alice@smol.example"}), - config=SimpleNamespace(smolmail_domain="smol.example")) - assert bridge.recipient_accepted_gateway("alice") is True - - -def test_recipient_accepted_gateway_false_when_no_token(): - bridge = EmailToSmolBridge(_StubSmol(), _StubStore(set()), - config=SimpleNamespace(smolmail_domain="smol.example")) - assert bridge.recipient_accepted_gateway("alice") is False - - -def test_recipient_accepted_gateway_normalizes_default_port(): - """A configured smolmail_domain carrying an explicit default port must - still match a token stored under Address.short's normalized form (which - drops it) -- naive string concatenation would never match.""" - bridge = EmailToSmolBridge(_StubSmol(), _StubStore({"alice@smol.example"}), - config=SimpleNamespace(smolmail_domain="smol.example:1961")) - assert bridge.recipient_accepted_gateway("alice") is True - - -def test_single_line_strips_crlf(): - assert _single_line("alice@example.org\r\nAccept: forged") == "alice@example.org Accept: forged" - - -def test_extract_text_plain_message(): - message = EmailMessage() - message.set_content("hello there") - assert EmailToSmolBridge.extract_text(message) == "hello there\n" - - -def test_extract_text_multipart_prefers_plain_part(): - message = EmailMessage() - message.set_content("plain body") - message.add_alternative("

html body

", subtype="html") - assert "plain body" in EmailToSmolBridge.extract_text(message) diff --git a/tests/test_lookup_server.py b/tests/test_lookup_server.py deleted file mode 100644 index a01fadb..0000000 --- a/tests/test_lookup_server.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -from types import SimpleNamespace -from urllib.parse import unquote - -import pytest - -from pneumatube.lookup_server import _lookup - - -class _StubBridge: - def __init__(self, known, accepted=None): - self.known = known - self.accepted = known if accepted is None else accepted - - def user_exists(self, username): - if username == "broken": - return None - return username in self.known - - def recipient_accepted_gateway(self, username): - return username in self.accepted - - -class _InlineWorker: - async def run(self, fn, *args): - return fn(*args) - - -def _config(**overrides): - defaults = {"email_domain": "gateway.example", "require_accepted_recipient": True} - return SimpleNamespace(**{**defaults, **overrides}) - - -def test_lookup_known_user_returns_ok(): - bridge = _StubBridge({"alice"}) - reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "alice@gateway.example")) - code, _, text = reply.partition(" ") - assert code == "200" - assert unquote(text) == "OK" - - -def test_lookup_unknown_user_returns_reject_action(): - bridge = _StubBridge({"alice"}) - reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "nobody@gateway.example")) - code, _, text = reply.partition(" ") - assert code == "200" - assert unquote(text) == "550 5.1.1 no such user" - - -def test_lookup_resolve_failure_is_temporary(): - bridge = _StubBridge({"alice"}) - reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "broken@gateway.example")) - assert reply.startswith("400 ") - - -def test_lookup_other_domain_has_no_opinion(): - bridge = _StubBridge({"alice"}) - reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "alice@other.example")) - assert reply == "500 not found" - - -def test_lookup_rejects_unaccepted_recipient_by_default(): - bridge = _StubBridge({"alice"}, accepted=set()) - reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "alice@gateway.example")) - code, _, text = reply.partition(" ") - assert code == "200" - assert unquote(text) == "550 5.7.1 recipient has not accepted mail from this gateway" - - -def test_lookup_allows_unaccepted_recipient_when_disabled(): - bridge = _StubBridge({"alice"}, accepted=set()) - config = _config(require_accepted_recipient=False) - reply = asyncio.run(_lookup(bridge, _InlineWorker(), config, "alice@gateway.example")) - code, _, text = reply.partition(" ") - assert code == "200" - assert unquote(text) == "OK" diff --git a/tests/test_smol_to_email.py b/tests/test_smol_to_email.py deleted file mode 100644 index fcf89c0..0000000 --- a/tests/test_smol_to_email.py +++ /dev/null @@ -1,65 +0,0 @@ -from types import SimpleNamespace - -from pneumatube.smol_to_email import SmolToEmailBridge - - -class _StubSmol: - SmolError = RuntimeError - - def cmd_fetch(self, args, store): - pass - - -class _StubStore: - def __init__(self, rows): - self._rows = rows - self.requested_box = None - - def mail(self, box): - self.requested_box = box - return self._rows - - -def _config(**overrides): - defaults = {"smolmail_key": "k", "smolmail_db": "d", "require_accepted_sender": True} - return SimpleNamespace(**{**defaults, **overrides}) - - -def test_poll_once_does_not_stop_after_one_message_fails(): - """A message that keeps raising out of _relay() (e.g. a value - smtp_client.send() rejects that _relay()'s own except clause doesn't - cover) must not block every message behind it -- it always sorts first - on the next poll, since only a successful _relay() deletes it.""" - rows = [(b"poison", b"", 0, None), (b"ok", b"", 1, None)] - bridge = SmolToEmailBridge(_StubSmol(), _StubStore(rows), account=None, config=_config()) - - relayed = [] - - def fake_relay(mid, envelope): - if mid == b"poison": - raise ValueError("simulated failure unrelated to InvalidAddress/OSError") - relayed.append(mid) - - bridge._relay = fake_relay - bridge.poll_once() - - assert relayed == [b"ok"] - - -def test_poll_once_uses_main_tier_only_by_default(): - """require_accepted_sender=True (the default) must poll "inbox" (main - tier), not "all" -- "all" would include requests-tier mail from - first-contact senders with no accept token (SPEC.md S5.8), which is - exactly what this setting exists to keep the gateway from relaying.""" - store = _StubStore([]) - bridge = SmolToEmailBridge(_StubSmol(), store, account=None, config=_config()) - bridge.poll_once() - assert store.requested_box == "inbox" - - -def test_poll_once_uses_all_tiers_when_open_bridge_enabled(): - store = _StubStore([]) - bridge = SmolToEmailBridge(_StubSmol(), store, account=None, - config=_config(require_accepted_sender=False)) - bridge.poll_once() - assert store.requested_box == "all" diff --git a/tests/test_smtp_client.py b/tests/test_smtp_client.py deleted file mode 100644 index a2b382a..0000000 --- a/tests/test_smtp_client.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from pneumatube.smtp_client import InvalidAddress, validate - - -def test_validate_accepts_plain_address(): - assert validate(" bob@example.org ") == "bob@example.org" - - -@pytest.mark.parametrize("address", [ - "not-an-email", - "bob@localhost", - "bob@example.org\r\nBcc: evil@example.org", - "bob@example.org\nX-Injected: 1", -]) -def test_validate_rejects_bad_addresses(address): - with pytest.raises(InvalidAddress): - validate(address) diff --git a/tests/test_smtp_server.py b/tests/test_smtp_server.py deleted file mode 100644 index e47eec4..0000000 --- a/tests/test_smtp_server.py +++ /dev/null @@ -1,117 +0,0 @@ -import asyncio -from types import SimpleNamespace - -import pytest - -from pneumatube.smtp_server import GatewayHandler - - -class _StubBridge: - def __init__(self, known=("alice", "bob", "broken"), accepted=("alice", "bob", "broken")): - self.forwarded = [] - self.known = set(known) - self.accepted = set(accepted) - - def forward(self, username, subject, body): - self.forwarded.append(username) - if username == "broken": - raise RuntimeError("smolmail server unreachable") - - def user_exists(self, username): - return username in self.known - - def recipient_accepted_gateway(self, username): - return username in self.accepted - - @staticmethod - def extract_text(message): - return message.get_payload(decode=True).decode("utf-8", "replace") - - -class _InlineWorker: - """Runs synchronously: handle_DATA's per-recipient logic doesn't depend - on real threading, just on worker.run's (fn, *args) -> result contract.""" - - async def run(self, fn, *args): - return fn(*args) - - -def _envelope(rcpt_tos): - return SimpleNamespace( - content=b"Subject: hi\r\n\r\nbody text\r\n", - mail_from="sender@realmail.example", - rcpt_tos=rcpt_tos, - ) - - -def _config(**overrides): - defaults = {"email_domain": "gateway.example", "require_accepted_recipient": True} - return SimpleNamespace(**{**defaults, **overrides}) - - -def test_handle_rcpt_rejects_wrong_domain(): - handler = GatewayHandler(_StubBridge(), _InlineWorker(), config=_config()) - reply = asyncio.run(handler.handle_RCPT(None, None, _envelope([]), - "alice@other.example", None)) - assert reply.startswith("550") - - -def test_handle_rcpt_rejects_unknown_user(): - handler = GatewayHandler(_StubBridge(known=()), _InlineWorker(), config=_config()) - reply = asyncio.run(handler.handle_RCPT(None, None, _envelope([]), - "ghost@gateway.example", None)) - assert reply.startswith("550") - - -def test_handle_rcpt_rejects_unaccepted_recipient_by_default(): - """The all-or-nothing gate: a recipient who exists but has not accepted - the gateway (no token held) must be rejected live, not silently demoted - to the recipient's own requests tier.""" - bridge = _StubBridge(known=("alice",), accepted=()) - handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) - reply = asyncio.run(handler.handle_RCPT(None, None, _envelope([]), - "alice@gateway.example", None)) - assert reply.startswith("550") - - -def test_handle_rcpt_allows_unaccepted_recipient_when_disabled(): - bridge = _StubBridge(known=("alice",), accepted=()) - handler = GatewayHandler(bridge, _InlineWorker(), - config=_config(require_accepted_recipient=False)) - envelope = _envelope([]) - reply = asyncio.run(handler.handle_RCPT(None, None, envelope, "alice@gateway.example", None)) - assert reply.startswith("250") - assert envelope.rcpt_tos == ["alice@gateway.example"] - - -def test_handle_rcpt_accepts_known_accepted_recipient(): - bridge = _StubBridge(known=("alice",), accepted=("alice",)) - handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) - envelope = _envelope([]) - reply = asyncio.run(handler.handle_RCPT(None, None, envelope, "alice@gateway.example", None)) - assert reply.startswith("250") - assert envelope.rcpt_tos == ["alice@gateway.example"] - - -def test_handle_data_replies_once_per_recipient(): - bridge = _StubBridge() - handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) - envelope = _envelope(["alice@gateway.example", "broken@gateway.example"]) - - reply = asyncio.run(handler.handle_DATA(None, None, envelope)) - lines = reply.split("\r\n") - - assert len(lines) == 2 - assert lines[0].startswith("250") - assert lines[1].startswith("451") - assert bridge.forwarded == ["alice", "broken"] - - -def test_handle_data_all_succeed(): - bridge = _StubBridge() - handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) - envelope = _envelope(["alice@gateway.example", "bob@gateway.example"]) - - reply = asyncio.run(handler.handle_DATA(None, None, envelope)) - - assert reply == "250 2.1.5 delivered\r\n250 2.1.5 delivered" diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 87ecc7f..0000000 --- a/uv.lock +++ /dev/null @@ -1,343 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "aiosmtpd" -version = "1.4.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "atpublic" }, - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/ca/b2b7cc880403ef24be77383edaadfcf0098f5d7b9ddbf3e2c17ef0a6af0d/aiosmtpd-1.4.6.tar.gz", hash = "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8", size = 152775, upload-time = "2024-05-18T11:37:50.029Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/39/d401756df60a8344848477d54fdf4ce0f50531f6149f3b8eaae9c06ae3dc/aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475", size = 154263, upload-time = "2024-05-18T11:37:47.877Z" }, -] - -[[package]] -name = "atpublic" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/da/105fb4e9e966f61eedef4cee081a99a8bf18792ad56aa64467618e8b23c0/atpublic-8.0.1.tar.gz", hash = "sha256:4cc00a2b8ea5645a268edc310667302fe1de2b91aba88d0bd634c0e6564f6ef4", size = 27401, upload-time = "2026-09-21T23:15:08.96Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/53/6864ee88ca91a6b1ecc0c0dff9fb6114628a416f3786e0dd80bddbce207f/atpublic-8.0.1-py3-none-any.whl", hash = "sha256:8696fe5b26ec7c8ea521cc8e5487495ba1d3530a9b9a9dc350c8f4f82848f77c", size = 11111, upload-time = "2026-09-21T23:15:08.112Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "cffi" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, - { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, - { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, - { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, - { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, - { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, - { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, - { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, - { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, - { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, - { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, - { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, - { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, - { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, - { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, - { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, - { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, - { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, - { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, - { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, - { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, - { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, - { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, - { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, - { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, - { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, - { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, - { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, - { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, - { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, - { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, - { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, - { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, - { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, - { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, - { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, - { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, - { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, - { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, - { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, - { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, - { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, - { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, - { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, - { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, - { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, - { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, - { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "50.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, - { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, - { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, - { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, - { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, - { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, - { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, - { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, - { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, - { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, - { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, - { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, - { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, - { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, - { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, - { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, - { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, - { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, - { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, - { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, - { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, - { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, - { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, - { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, - { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, - { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, - { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, - { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, - { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, - { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, - { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, - { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, - { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "noiseprotocol" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/17/fcf8a90dcf36fe00b475e395f34d92f42c41379c77b25a16066f63002f95/noiseprotocol-0.3.1.tar.gz", hash = "sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645", size = 16890, upload-time = "2020-11-25T19:06:48.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/e1/76e4694201d67b93a6f1644b2588b4a3d965419fe189416e3496cf415db5/noiseprotocol-0.3.1-py3-none-any.whl", hash = "sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111", size = 20546, upload-time = "2020-03-03T18:51:28.095Z" }, -] - -[[package]] -name = "packaging" -version = "26.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pneumatube" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiosmtpd" }, - { name = "noiseprotocol" }, - { name = "pynacl" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiosmtpd", specifier = ">=1.4" }, - { name = "noiseprotocol", specifier = ">=0.3.1" }, - { name = "pynacl", specifier = ">=1.5" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0" }] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pygments" -version = "2.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, -] - -[[package]] -name = "pynacl" -version = "1.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, - { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, - { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, - { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, - { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, - { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, - { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, - { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, - { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, - { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -]