feat: add smolboid-core feed format with payload-signature verification
This commit is contained in:
commit
fd3f9bcf24
13 changed files with 2531 additions and 0 deletions
745
Cargo.lock
generated
Normal file
745
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
# 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 = "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 = "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 = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[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 = "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 = "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 = "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 = "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 = "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 = "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 = "smolboid"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fumi-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smolboid-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smolboid-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chacha20poly1305",
|
||||
"fumi-core",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[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 = "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 = "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"
|
||||
4
Cargo.toml
Normal file
4
Cargo.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[workspace]
|
||||
members = ["core", "daemon"]
|
||||
default-members = ["core", "daemon"]
|
||||
resolver = "2"
|
||||
13
core/Cargo.toml
Normal file
13
core/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "smolboid-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "smolboid feed format: parsing, post rules and smolmail payload-signature verification"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
fumi-core = { path = "../../fumi/core", default-features = false }
|
||||
chacha20poly1305 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
161
core/src/envelope.rs
Normal file
161
core/src/envelope.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! Opening sealed smolmail envelopes the way a publisher needs: fumi's
|
||||
//! `unseal` verifies and discards, while a `# smol:mail` record must carry the
|
||||
//! ephemeral key and the payload signature, so this mirrors `message::unseal`
|
||||
//! (SPEC.md §5.1-5.3) and returns every field the record publishes.
|
||||
|
||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
use fumi_core::account::{verify_sig, Identity};
|
||||
use fumi_core::crypto::{agree, b32, ct_eq, hkdf_sha256};
|
||||
use fumi_core::error::Error;
|
||||
use fumi_core::transport::{
|
||||
ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_SEAL, PAYLOAD_HEADER,
|
||||
SIG_LEN,
|
||||
};
|
||||
|
||||
/// §5.3: a payload dated further ahead than this is rejected.
|
||||
pub const MAX_SKEW: i64 = 86400;
|
||||
pub const KEY_LEN: usize = 32;
|
||||
|
||||
/// An opened, verified payload plus the fields a record publishes.
|
||||
pub struct Opened {
|
||||
pub sender: [u8; KEY_LEN],
|
||||
/// The envelope's cleartext ephemeral key (§5.1); the signature covers it.
|
||||
pub epk: [u8; KEY_LEN],
|
||||
/// The payload's Ed25519 signature over the §5.3 signed bytes.
|
||||
pub sig: [u8; SIG_LEN],
|
||||
pub time: i64,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Opens an envelope addressed to one of `identities`, performing every check
|
||||
/// a receiver MUST make (§5.3): addressed to us, signature verifies against
|
||||
/// the carried sender, payload not dated too far ahead.
|
||||
pub fn open(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Opened, Error> {
|
||||
if envelope.len() < ENVELOPE_MIN {
|
||||
return Err(Error::Other("envelope too short".into()));
|
||||
}
|
||||
if &envelope[..4] != ENVELOPE_MAGIC {
|
||||
return Err(Error::Other("not a Smol Mail envelope".into()));
|
||||
}
|
||||
if envelope[4] != ENVELOPE_VERSION {
|
||||
return Err(Error::Other(format!(
|
||||
"unsupported envelope version {}",
|
||||
envelope[4]
|
||||
)));
|
||||
}
|
||||
let to: [u8; KEY_LEN] = envelope[5..37].try_into().unwrap();
|
||||
let epk: [u8; KEY_LEN] = envelope[37..69].try_into().unwrap();
|
||||
let sealed = &envelope[ENVELOPE_HEADER..];
|
||||
|
||||
let me = identities
|
||||
.iter()
|
||||
.find(|i| ct_eq(&i.pk(), &to))
|
||||
.ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"addressed to {}…, not one of our keys",
|
||||
&b32(&to)[..16]
|
||||
))
|
||||
})?;
|
||||
let shared = agree(me.x_priv(), &epk)?;
|
||||
let mut salt = Vec::with_capacity(2 * KEY_LEN);
|
||||
salt.extend_from_slice(&epk);
|
||||
salt.extend_from_slice(&to);
|
||||
let key = hkdf_sha256(&shared, &salt, LABEL_SEAL, 32);
|
||||
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let plaintext = cipher
|
||||
.decrypt(
|
||||
&Nonce::from([0u8; 12]),
|
||||
Payload {
|
||||
msg: sealed,
|
||||
aad: &envelope[..ENVELOPE_HEADER],
|
||||
},
|
||||
)
|
||||
.map_err(|_| Error::Other("decryption failed: wrong key or corrupt envelope".into()))?;
|
||||
|
||||
if plaintext.len() < PAYLOAD_HEADER + SIG_LEN || plaintext[0] != ENVELOPE_VERSION {
|
||||
return Err(Error::Other("unsupported payload version".into()));
|
||||
}
|
||||
let body_len = u32::from_be_bytes(plaintext[41..45].try_into().unwrap()) as usize;
|
||||
if PAYLOAD_HEADER + body_len + SIG_LEN > plaintext.len() {
|
||||
return Err(Error::Other(
|
||||
"payload body length exceeds the payload".into(),
|
||||
));
|
||||
}
|
||||
let sender: [u8; KEY_LEN] = plaintext[1..33].try_into().unwrap();
|
||||
let when = i64::from_be_bytes(plaintext[33..41].try_into().unwrap());
|
||||
let body = plaintext[PAYLOAD_HEADER..PAYLOAD_HEADER + body_len].to_vec();
|
||||
let sig: [u8; SIG_LEN] = plaintext[PAYLOAD_HEADER + body_len..PAYLOAD_HEADER + body_len + SIG_LEN]
|
||||
.try_into()
|
||||
.unwrap();
|
||||
|
||||
let mut signed = Vec::with_capacity(
|
||||
LABEL_SEAL.len() + 2 * KEY_LEN + PAYLOAD_HEADER + body.len(),
|
||||
);
|
||||
signed.extend_from_slice(fumi_core::transport::LABEL_MSG);
|
||||
signed.extend_from_slice(&to);
|
||||
signed.extend_from_slice(&epk);
|
||||
signed.extend_from_slice(&plaintext[..PAYLOAD_HEADER]);
|
||||
signed.extend_from_slice(&body);
|
||||
if !verify_sig(&sender, &sig, &signed) {
|
||||
return Err(Error::Other("signature does not verify".into()));
|
||||
}
|
||||
if when > now + MAX_SKEW {
|
||||
return Err(Error::Other("payload is dated in the future".into()));
|
||||
}
|
||||
Ok(Opened {
|
||||
sender,
|
||||
epk,
|
||||
sig,
|
||||
time: when,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fumi_core::account::{identity_seed, Identity};
|
||||
use fumi_core::message::{seal, unseal};
|
||||
|
||||
fn identity(master: u8, index: u32) -> Identity {
|
||||
Identity::from_seed(identity_seed(&[master; 32], index))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agrees_with_fumi_unseal_and_exposes_the_record_fields() {
|
||||
let recipient = identity(0x11, 0);
|
||||
let sender = identity(0x22, 0);
|
||||
let body = b"hello smolverse".to_vec();
|
||||
let envelope = seal(&sender, &recipient.pk(), &body, 1_760_000_000, false).unwrap();
|
||||
|
||||
let mine = open(std::slice::from_ref(&recipient), &envelope, 1_760_000_000).unwrap();
|
||||
assert_eq!(mine.sender, sender.pk());
|
||||
assert_eq!(mine.time, 1_760_000_000);
|
||||
assert_eq!(mine.body, body);
|
||||
|
||||
let theirs = unseal(&[recipient.clone()], &envelope, 1_760_000_000).unwrap();
|
||||
assert_eq!(theirs.sender, mine.sender);
|
||||
assert_eq!(theirs.time, mine.time);
|
||||
assert_eq!(theirs.body, mine.body);
|
||||
|
||||
// The exposed signature covers the exact §5.3 signed bytes.
|
||||
let signed = crate::verify::signed_bytes(
|
||||
&recipient.pk(),
|
||||
&mine.epk,
|
||||
&mine.sender,
|
||||
mine.time,
|
||||
&mine.body,
|
||||
);
|
||||
assert!(verify_sig(&mine.sender, &mine.sig, &signed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_envelopes_addressed_elsewhere() {
|
||||
let recipient = identity(0x11, 0);
|
||||
let other = identity(0x33, 0);
|
||||
let envelope = seal(&identity(0x22, 0), &other.pk(), b"x", 0, false).unwrap();
|
||||
assert!(open(&[recipient], &envelope, 0).is_err());
|
||||
}
|
||||
}
|
||||
427
core/src/feed.rs
Normal file
427
core/src/feed.rs
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
//! The feed file: parse, render and manipulate a `twtxt.txt` without losing
|
||||
//! bytes. Unknown lines are preserved verbatim, so store operations (delete,
|
||||
//! prune, append) never corrupt content this format does not know about.
|
||||
|
||||
use fumi_core::crypto::{b32, unb32};
|
||||
|
||||
use crate::rules::{validate_text, validate_timestamp, RuleError};
|
||||
|
||||
pub const KEY_LEN: usize = 32;
|
||||
pub const SIG_LEN: usize = 64;
|
||||
|
||||
/// A decoded `# smol:mail` line: the three values a verifier needs, in the
|
||||
/// unpadded lowercase base32 the smolmail ecosystem uses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MailLine {
|
||||
pub sender: [u8; KEY_LEN],
|
||||
pub epk: [u8; KEY_LEN],
|
||||
pub sig: [u8; SIG_LEN],
|
||||
}
|
||||
|
||||
impl MailLine {
|
||||
pub fn render(&self) -> String {
|
||||
format!(
|
||||
"# smol:mail {} {} {}",
|
||||
b32(&self.sender),
|
||||
b32(&self.epk),
|
||||
b32(&self.sig)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A publishable record: a post line or a `# smol:repost` line. Timestamp and
|
||||
/// text are kept exactly as written — the payload signature covers both.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Record {
|
||||
Post { timestamp: String, text: String },
|
||||
Repost { timestamp: String, args: String },
|
||||
}
|
||||
|
||||
impl Record {
|
||||
pub fn timestamp(&self) -> &str {
|
||||
match self {
|
||||
Record::Post { timestamp, .. } | Record::Repost { timestamp, .. } => timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text as it appears in the line: after the tab for a post, the
|
||||
/// repost args after the timestamp for a repost.
|
||||
pub fn text(&self) -> &str {
|
||||
match self {
|
||||
Record::Post { text, .. } => text,
|
||||
Record::Repost { args, .. } => args,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self) -> String {
|
||||
match self {
|
||||
Record::Post { timestamp, text } => format!("{timestamp}\t{text}"),
|
||||
Record::Repost { timestamp, args } => format!("# smol:repost {timestamp} {args}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Post rules 1, 3 and 4.
|
||||
pub fn validate(&self) -> Result<(), RuleError> {
|
||||
validate_timestamp(self.timestamp())?;
|
||||
validate_text(self.text())
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed record plus the `# smol:mail` line directly below it, if any.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub record: Record,
|
||||
pub mail: Option<MailLine>,
|
||||
}
|
||||
|
||||
/// Every line kind the parser distinguishes. Raw strings are kept so render
|
||||
/// is byte-faithful for lines this format did not generate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Item {
|
||||
Meta(String, String),
|
||||
Post(String, String),
|
||||
Repost(String, String),
|
||||
Mail(MailLine),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Feed {
|
||||
items: Vec<Item>,
|
||||
}
|
||||
|
||||
impl Feed {
|
||||
pub fn new() -> Feed {
|
||||
Feed::default()
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Feed {
|
||||
Feed {
|
||||
items: text.lines().map(parse_line).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Always newline-terminates every line, including the last.
|
||||
pub fn render(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for item in &self.items {
|
||||
match item {
|
||||
Item::Meta(key, value) => {
|
||||
out.push_str("# ");
|
||||
out.push_str(key);
|
||||
out.push_str(" = ");
|
||||
out.push_str(value);
|
||||
}
|
||||
Item::Post(timestamp, text) => {
|
||||
out.push_str(timestamp);
|
||||
out.push('\t');
|
||||
out.push_str(text);
|
||||
}
|
||||
Item::Repost(timestamp, args) => {
|
||||
out.push_str("# smol:repost ");
|
||||
out.push_str(timestamp);
|
||||
out.push(' ');
|
||||
out.push_str(args);
|
||||
}
|
||||
Item::Mail(mail) => out.push_str(&mail.render()),
|
||||
Item::Other(line) => out.push_str(line),
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Records in file order, each bound to the `# smol:mail` line directly
|
||||
/// below it. A `# smol:mail` line with no adjacent record is ignored.
|
||||
pub fn entries(&self) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < self.items.len() {
|
||||
let record = match &self.items[i] {
|
||||
Item::Post(timestamp, text) => Some(Record::Post {
|
||||
timestamp: timestamp.clone(),
|
||||
text: text.clone(),
|
||||
}),
|
||||
Item::Repost(timestamp, args) => Some(Record::Repost {
|
||||
timestamp: timestamp.clone(),
|
||||
args: args.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
match record {
|
||||
Some(record) => {
|
||||
let mail = match self.items.get(i + 1) {
|
||||
Some(Item::Mail(mail)) => Some(mail.clone()),
|
||||
_ => None,
|
||||
};
|
||||
i += 1 + usize::from(mail.is_some());
|
||||
entries.push(Entry { record, mail });
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn meta(&self, key: &str) -> Option<&str> {
|
||||
self.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Item::Meta(k, v) if k == key => Some(v.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn author(&self) -> Option<&str> {
|
||||
self.meta("smol:author")
|
||||
}
|
||||
|
||||
/// Every `smol:host_key` in order, oldest first; undecodable values are
|
||||
/// skipped, as a verifier cannot use them.
|
||||
pub fn host_keys(&self) -> Vec<[u8; KEY_LEN]> {
|
||||
self.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::Meta(k, v) if k == "smol:host_key" => key(v),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A feed claiming no author or carrying no host key is plain twtxt, not
|
||||
/// a broken smol feed (PLAN.md, Verification step 1).
|
||||
pub fn is_legacy(&self) -> bool {
|
||||
self.author().is_none() || self.host_keys().is_empty()
|
||||
}
|
||||
|
||||
/// Replaces the value of an existing metadata key, or inserts a new line
|
||||
/// at the top of the file among the metadata block.
|
||||
pub fn set_meta(&mut self, key: &str, value: &str) {
|
||||
for item in &mut self.items {
|
||||
if let Item::Meta(k, v) = item {
|
||||
if k == key {
|
||||
v.replace_range(.., value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let insert_at = self
|
||||
.items
|
||||
.iter()
|
||||
.take_while(|item| matches!(item, Item::Meta(_, _)))
|
||||
.count();
|
||||
self.items
|
||||
.insert(insert_at, Item::Meta(key.to_owned(), value.to_owned()));
|
||||
}
|
||||
|
||||
/// Post rule 2: timestamps are unique across posts and reposts.
|
||||
pub fn has_timestamp(&self, timestamp: &str) -> bool {
|
||||
self.items.iter().any(|item| match item {
|
||||
Item::Post(ts, _) | Item::Repost(ts, _) => ts == timestamp,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends a record and its signature line, post rule 5's append-only
|
||||
/// half. The caller has validated the record and checked uniqueness.
|
||||
pub fn append(&mut self, record: Record, mail: Option<MailLine>) {
|
||||
let mail_item = mail.map(Item::Mail);
|
||||
self.items.push(match record {
|
||||
Record::Post { timestamp, text } => Item::Post(timestamp, text),
|
||||
Record::Repost { timestamp, args } => Item::Repost(timestamp, args),
|
||||
});
|
||||
if let Some(mail_item) = mail_item {
|
||||
self.items.push(mail_item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the record and rejects a timestamp collision, then appends.
|
||||
/// The signature cannot be bumped, so a collision is an error the caller
|
||||
/// answers with an error reply (PLAN.md, post rule 2).
|
||||
pub fn append_checked(
|
||||
&mut self,
|
||||
record: Record,
|
||||
mail: Option<MailLine>,
|
||||
) -> Result<(), RuleError> {
|
||||
record.validate()?;
|
||||
if self.has_timestamp(record.timestamp()) {
|
||||
return Err(RuleError::DuplicateTimestamp);
|
||||
}
|
||||
self.append(record, mail);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the record with this timestamp and its `# smol:mail` line.
|
||||
/// Returns whether anything was removed.
|
||||
pub fn delete(&mut self, timestamp: &str) -> bool {
|
||||
let Some(index) = self.items.iter().position(|item| match item {
|
||||
Item::Post(ts, _) | Item::Repost(ts, _) => ts == timestamp,
|
||||
_ => false,
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let mut remove = 1;
|
||||
if matches!(self.items.get(index + 1), Some(Item::Mail(_))) {
|
||||
remove += 1;
|
||||
}
|
||||
self.items.drain(index..index + remove);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn key(value: &str) -> Option<[u8; KEY_LEN]> {
|
||||
let raw = unb32(value).ok()?;
|
||||
raw.try_into().ok()
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Item {
|
||||
if let Some(rest) = line.strip_prefix("# smol:mail ") {
|
||||
return match mail(rest) {
|
||||
Some(mail) => Item::Mail(mail),
|
||||
None => Item::Other(line.to_owned()),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("# smol:repost ") {
|
||||
return match rest.split_once(' ') {
|
||||
Some((timestamp, args)) if !args.is_empty() => {
|
||||
Item::Repost(timestamp.to_owned(), args.to_owned())
|
||||
}
|
||||
_ => Item::Other(line.to_owned()),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("# ") {
|
||||
return match rest.split_once(" = ") {
|
||||
Some((key, value)) if !key.is_empty() => Item::Meta(key.to_owned(), value.to_owned()),
|
||||
_ => Item::Other(line.to_owned()),
|
||||
};
|
||||
}
|
||||
if let Some((timestamp, text)) = line.split_once('\t') {
|
||||
return Item::Post(timestamp.to_owned(), text.to_owned());
|
||||
}
|
||||
Item::Other(line.to_owned())
|
||||
}
|
||||
|
||||
fn mail(args: &str) -> Option<MailLine> {
|
||||
let (sender, rest) = args.split_once(' ')?;
|
||||
let (epk, sig) = rest.split_once(' ')?;
|
||||
Some(MailLine {
|
||||
sender: key(sender)?,
|
||||
epk: key(epk)?,
|
||||
sig: unb32(sig).ok()?.try_into().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A 32-byte key is 52 base32 chars, a 64-byte signature 103 (the plan's
|
||||
// earlier 104 was a miscount: 104 nopad chars decode to 65 bytes).
|
||||
fn sample_feed() -> String {
|
||||
let key = "a".repeat(52);
|
||||
let sig = "a".repeat(103);
|
||||
format!(
|
||||
"# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = {key}\n\n2026-10-01T12:47:00Z\thello smolverse\n# smol:mail {key} {key} {sig}\nplain stray line\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_byte_faithfully() {
|
||||
let feed = sample_feed();
|
||||
assert_eq!(Feed::parse(&feed).render(), feed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_metadata() {
|
||||
let feed = Feed::parse(&sample_feed());
|
||||
assert_eq!(feed.nick_of(), Some("tobias"));
|
||||
assert_eq!(feed.author(), Some("tobias@smol.place"));
|
||||
assert_eq!(feed.host_keys().len(), 1);
|
||||
assert!(!feed.is_legacy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_legacy_without_author_or_host_key() {
|
||||
for text in [
|
||||
"# nick = tobias\n2026-10-01T12:47:00Z\thello\n",
|
||||
"# smol:author = tobias@smol.place\n2026-10-01T12:47:00Z\thello\n",
|
||||
] {
|
||||
assert!(Feed::parse(text).is_legacy(), "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_only_the_mail_line_directly_below() {
|
||||
let feed = Feed::parse(&sample_feed());
|
||||
let entries = feed.entries();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(entries[0].mail.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_orphan_and_malformed_mail_lines() {
|
||||
let key = "a".repeat(52);
|
||||
let sig = "a".repeat(103);
|
||||
let feed = Feed::parse(&format!(
|
||||
"# smol:author = a@b\n# smol:host_key = {key}\n2026-10-01T12:47:00Z\ttext\n# smol:mail {key} {key} {sig}\n2026-10-01T12:48:00Z\tsecond\n# smol:mail {key} {key} too-short\n"
|
||||
));
|
||||
let entries = feed.entries();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries[0].mail.is_some(), "adjacent line binds");
|
||||
assert!(entries[1].mail.is_none(), "malformed line is not a mail line");
|
||||
// The malformed line survives rendering for byte fidelity.
|
||||
assert!(feed.render().contains("too-short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_meta_replaces_and_inserts() {
|
||||
let mut feed = Feed::parse("# nick = tobias\n# url = u\n\n2026-10-01T12:47:00Z\thi\n");
|
||||
feed.set_meta("nick", "tobias2");
|
||||
assert_eq!(feed.meta("nick"), Some("tobias2"));
|
||||
feed.set_meta("smol:author", "tobias@smol.place");
|
||||
assert_eq!(
|
||||
feed.render(),
|
||||
"# nick = tobias2\n# url = u\n# smol:author = tobias@smol.place\n\n2026-10-01T12:47:00Z\thi\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_and_delete_keep_the_mail_line_together() {
|
||||
let mut feed = Feed::new();
|
||||
let record = Record::Post {
|
||||
timestamp: "2026-10-01T12:47:00Z".into(),
|
||||
text: "hello".into(),
|
||||
};
|
||||
assert!(feed.append_checked(record.clone(), None).is_ok());
|
||||
assert!(feed.has_timestamp("2026-10-01T12:47:00Z"));
|
||||
assert!(feed.delete("2026-10-01T12:47:00Z"));
|
||||
assert!(!feed.has_timestamp("2026-10-01T12:47:00Z"));
|
||||
assert!(!feed.delete("2026-10-01T12:47:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_rejects_rule_violations_and_collisions() {
|
||||
let mut feed = Feed::new();
|
||||
let ok = Record::Post {
|
||||
timestamp: "2026-10-01T12:47:00Z".into(),
|
||||
text: "hello".into(),
|
||||
};
|
||||
assert!(feed.append_checked(ok, None).is_ok());
|
||||
let dup = Record::Post {
|
||||
timestamp: "2026-10-01T12:47:00Z".into(),
|
||||
text: "again".into(),
|
||||
};
|
||||
assert!(feed.append_checked(dup, None).is_err());
|
||||
let bad_text = Record::Post {
|
||||
timestamp: "2026-10-01T12:48:00Z".into(),
|
||||
text: "a\tb".into(),
|
||||
};
|
||||
assert!(feed.append_checked(bad_text, None).is_err());
|
||||
}
|
||||
|
||||
impl Feed {
|
||||
fn nick_of(&self) -> Option<&str> {
|
||||
self.meta("nick")
|
||||
}
|
||||
}
|
||||
}
|
||||
18
core/src/lib.rs
Normal file
18
core/src/lib.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//! `smolboid-core` — the smolboid feed format (PLAN.md): parsing and
|
||||
//! rendering `twtxt.txt`, the post rules, canonical reconstruction of post
|
||||
//! and repost bodies, verification of `# smol:mail` records against the
|
||||
//! author's smolmail payload signature (SPEC.md §5.3), and opening sealed
|
||||
//! envelopes to expose the fields a record publishes. No I/O, so clients and
|
||||
//! the daemon link this crate directly.
|
||||
|
||||
pub mod envelope;
|
||||
pub mod feed;
|
||||
pub mod rules;
|
||||
pub mod time;
|
||||
pub mod verify;
|
||||
|
||||
pub use envelope::{open, Opened};
|
||||
pub use feed::{Entry, Feed, MailLine, Record};
|
||||
pub use rules::{validate_text, validate_timestamp, RuleError, MAX_BYTES, MAX_CODEPOINTS};
|
||||
pub use time::{parse as parse_timestamp, render as render_timestamp};
|
||||
pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State};
|
||||
95
core/src/rules.rs
Normal file
95
core/src/rules.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Post rules 1, 3 and 4 from PLAN.md, enforced at receipt by the daemon and
|
||||
//! usable by any author-side tool.
|
||||
|
||||
pub const MAX_CODEPOINTS: usize = 160;
|
||||
pub const MAX_BYTES: usize = 640;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RuleError {
|
||||
TooManyCodepoints,
|
||||
TooManyBytes,
|
||||
ForbiddenChar,
|
||||
BadTimestamp,
|
||||
DuplicateTimestamp,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuleError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let msg = match self {
|
||||
RuleError::TooManyCodepoints => "more than 160 Unicode code points",
|
||||
RuleError::TooManyBytes => "more than 640 bytes",
|
||||
RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028",
|
||||
RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision",
|
||||
RuleError::DuplicateTimestamp => "timestamp already exists in this feed",
|
||||
};
|
||||
f.write_str(msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuleError {}
|
||||
|
||||
/// Rules 3 and 4: at most 160 code points and 640 bytes, and no TAB, LF, CR
|
||||
/// or U+2028 — any of those would break the one-record-per-line invariant.
|
||||
/// The byte cap is mathematically implied by the codepoint cap over UTF-8 but
|
||||
/// is kept as an explicit guard.
|
||||
pub fn validate_text(text: &str) -> Result<(), RuleError> {
|
||||
if text.chars().count() > MAX_CODEPOINTS {
|
||||
return Err(RuleError::TooManyCodepoints);
|
||||
}
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err(RuleError::TooManyBytes);
|
||||
}
|
||||
if text.contains(['\t', '\n', '\r', '\u{2028}']) {
|
||||
return Err(RuleError::ForbiddenChar);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rule 1: RFC 3339 at second precision, rendered in UTC with `Z`.
|
||||
pub fn validate_timestamp(timestamp: &str) -> Result<(), RuleError> {
|
||||
if crate::time::parse(timestamp).is_none() {
|
||||
return Err(RuleError::BadTimestamp);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_a_short_post() {
|
||||
assert_eq!(validate_text("hello smolverse"), Ok(()));
|
||||
assert_eq!(validate_timestamp("2026-10-01T12:47:00Z"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_over_length_text() {
|
||||
let chars: String = std::iter::repeat('a').take(161).collect();
|
||||
assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints));
|
||||
|
||||
// 160 four-byte code points sit exactly on both caps.
|
||||
let four_byte: String = "🙂".repeat(160);
|
||||
assert_eq!(validate_text(&four_byte), Ok(()));
|
||||
let over: String = "🙂".repeat(161);
|
||||
assert_eq!(
|
||||
validate_text(&over),
|
||||
Err(RuleError::TooManyCodepoints),
|
||||
"codepoints are checked before bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_record_breaking_characters() {
|
||||
for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] {
|
||||
assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_z_timestamps() {
|
||||
for bad in ["2026-10-01T12:47:00+03:00", "not a date", ""] {
|
||||
assert_eq!(validate_timestamp(bad), Err(RuleError::BadTimestamp));
|
||||
}
|
||||
}
|
||||
}
|
||||
146
core/src/time.rs
Normal file
146
core/src/time.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//! RFC 3339 UTC rendering at second precision — the only timestamp form a
|
||||
//! smol feed carries, so a line timestamp round-trips to the sealed payload's
|
||||
//! Unix seconds and back without loss.
|
||||
|
||||
/// Renders Unix seconds as `YYYY-MM-DDTHH:MM:SSZ`, post rule 1.
|
||||
pub fn render(unix: i64) -> String {
|
||||
let days = unix.div_euclid(86_400);
|
||||
let secs = unix.rem_euclid(86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
|
||||
secs / 3600,
|
||||
(secs % 3600) / 60,
|
||||
secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Parses the exact form `render` produces; anything else is rejected,
|
||||
/// because the payload signature covers the timestamp as written.
|
||||
pub fn parse(timestamp: &str) -> Option<i64> {
|
||||
let b = timestamp.as_bytes();
|
||||
if b.len() != 20
|
||||
|| b[4] != b'-'
|
||||
|| b[7] != b'-'
|
||||
|| b[10] != b'T'
|
||||
|| b[13] != b':'
|
||||
|| b[16] != b':'
|
||||
|| b[19] != b'Z'
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let num = |range: std::ops::Range<usize>| -> Option<i64> {
|
||||
let digits = timestamp.get(range)?;
|
||||
if !digits.bytes().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some(digits.parse().ok()?)
|
||||
};
|
||||
let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
|
||||
let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
|
||||
if !(1..=12).contains(&mo) || h > 23 || mi > 59 || s > 59 {
|
||||
return None;
|
||||
}
|
||||
let days = days_from_civil(y, mo as u32, d as u32);
|
||||
// A calendar round-trip rejects impossible dates such as 2026-02-31.
|
||||
if civil_from_days(days) != (y, mo as u32, d as u32) {
|
||||
return None;
|
||||
}
|
||||
Some(days * 86_400 + h * 3600 + mi * 60 + s)
|
||||
}
|
||||
|
||||
/// UTC calendar parts of a Unix timestamp: (year, month, day, hour, minute,
|
||||
/// second). Callers that need a textual form of HTTP date reuse this rather
|
||||
/// than a calendar of their own.
|
||||
pub fn utc_parts(unix: i64) -> (i64, u32, u32, u32, u32, u32) {
|
||||
let days = unix.div_euclid(86_400);
|
||||
let secs = unix.rem_euclid(86_400);
|
||||
let (y, mo, d) = civil_from_days(days);
|
||||
(
|
||||
y,
|
||||
mo,
|
||||
d,
|
||||
(secs / 3600) as u32,
|
||||
((secs % 3600) / 60) as u32,
|
||||
(secs % 60) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Inverse of `utc_parts` with the same strictness as `parse`: impossible
|
||||
/// dates do not exist.
|
||||
pub fn from_parts(y: i64, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> Option<i64> {
|
||||
if !(1..=12).contains(&mo) || h > 23 || mi > 59 || s > 59 {
|
||||
return None;
|
||||
}
|
||||
let days = days_from_civil(y, mo, d);
|
||||
if civil_from_days(days) != (y, mo, d) {
|
||||
return None;
|
||||
}
|
||||
Some(days * 86_400 + i64::from(h) * 3600 + i64::from(mi) * 60 + i64::from(s))
|
||||
}
|
||||
|
||||
/// Howard Hinnant's civil-from-days: the standard dependency-free conversion
|
||||
/// from a day count since 1970-01-01 to a (year, month, day) triple.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 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)
|
||||
}
|
||||
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = y - i64::from(m <= 2);
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = if m > 2 { i64::from(m) - 3 } else { i64::from(m) + 9 };
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round_trip(unix: i64) {
|
||||
assert_eq!(parse(&render(unix)), Some(unix), "round trip {unix}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_the_plan_form() {
|
||||
assert_eq!(render(1_760_000_000), "2025-10-09T08:53:20Z");
|
||||
assert_eq!(render(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(render(-1), "1969-12-31T23:59:59Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_across_decades() {
|
||||
for unix in [-1, 0, 1, 951_782_400, 1_760_000_000, 4_102_444_800] {
|
||||
round_trip(unix);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_leap_days_only_when_real() {
|
||||
assert!(parse("2024-02-29T00:00:00Z").is_some());
|
||||
assert!(parse("2026-02-29T00:00:00Z").is_none());
|
||||
assert!(parse("2026-02-30T00:00:00Z").is_none());
|
||||
assert!(parse("2026-04-31T00:00:00Z").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_rfc3339_forms() {
|
||||
assert_eq!(parse("2026-10-01T12:47:00+03:00"), None, "offset form");
|
||||
assert_eq!(parse("2026-10-01T12:47:00.5Z"), None, "sub-second");
|
||||
assert_eq!(parse("2026-10-01 12:47:00Z"), None, "space separator");
|
||||
assert_eq!(parse("2026-10-01T12:47:00"), None, "missing Z");
|
||||
assert_eq!(parse("2026-10-01T24:00:00Z"), None, "hour 24");
|
||||
assert_eq!(parse(""), None);
|
||||
}
|
||||
}
|
||||
156
core/src/verify.rs
Normal file
156
core/src/verify.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! Verification of `# smol:mail` records: rebuild the sealed-payload signed
|
||||
//! bytes (SPEC.md §5.3) and check the author's signature against the record's
|
||||
//! own sender key, trying each `smol:host_key` as the recipient.
|
||||
|
||||
use fumi_core::account::verify_sig;
|
||||
use fumi_core::crypto::b32;
|
||||
use fumi_core::transport::{ENVELOPE_VERSION, LABEL_MSG};
|
||||
|
||||
use crate::feed::{Feed, Record};
|
||||
use crate::time;
|
||||
|
||||
/// The canonical Reply-To block a smolmail client emits on an ordinary send
|
||||
/// (§5.7): the only field a post or repost body may carry and still verify.
|
||||
pub fn reply_to_body(reply_to: &str, text: &str) -> String {
|
||||
format!("---\nReply-To: {reply_to}\n---\n{text}")
|
||||
}
|
||||
|
||||
/// The canonical repost body (PLAN.md): a frontmatter block whose only
|
||||
/// fields are the repost target and, optionally, the same Reply-To an
|
||||
/// ordinary send carries, in the client's emission order.
|
||||
pub fn repost_body(args: &str, reply_to: Option<&str>) -> String {
|
||||
let mut body = format!("---\nTwt-Repost: {args}\n");
|
||||
if let Some(reply_to) = reply_to {
|
||||
body.push_str(&format!("Reply-To: {reply_to}\n"));
|
||||
}
|
||||
body.push_str("---\n");
|
||||
body
|
||||
}
|
||||
|
||||
/// The bytes a `# smol:mail` signature covers (SPEC.md §5.3):
|
||||
/// `label || to || epk || version || sender || time || body_len || body`,
|
||||
/// with integers big-endian.
|
||||
pub fn signed_bytes(
|
||||
to: &[u8; 32],
|
||||
epk: &[u8; 32],
|
||||
sender: &[u8; 32],
|
||||
time: i64,
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut signed = Vec::with_capacity(LABEL_MSG.len() + 32 * 3 + 1 + 8 + 4 + body.len());
|
||||
signed.extend_from_slice(LABEL_MSG);
|
||||
signed.extend_from_slice(to);
|
||||
signed.extend_from_slice(epk);
|
||||
signed.push(ENVELOPE_VERSION);
|
||||
signed.extend_from_slice(sender);
|
||||
signed.extend_from_slice(&time.to_be_bytes());
|
||||
signed.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
signed.extend_from_slice(body);
|
||||
signed
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
/// Signature present and valid.
|
||||
Verified,
|
||||
/// No `# smol:mail` line below the record.
|
||||
Unsigned,
|
||||
/// Signature present but does not verify.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
State::Verified => "verified",
|
||||
State::Unsigned => "unsigned",
|
||||
State::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// The feed claims no `smol:author` or carries no `smol:host_key`: it is
|
||||
/// plain twtxt and its records are not judged (PLAN.md, Verification).
|
||||
pub legacy: bool,
|
||||
/// One state per entry, in file order.
|
||||
pub states: Vec<State>,
|
||||
}
|
||||
|
||||
/// The Reply-To an ordinary send carries, derived per record: the author
|
||||
/// address from the metadata, the sender key from the record itself, so a
|
||||
/// rotation changes nothing about reconstruction.
|
||||
fn derived_reply_to(author: &str, sender: &[u8; 32]) -> String {
|
||||
format!("smol://{}/{}", author, b32(sender))
|
||||
}
|
||||
|
||||
/// Verifies every entry of a feed. Each record's signature is tried against
|
||||
/// every `smol:host_key` and every canonical body form, so records survive a
|
||||
/// daemon rotation and whichever send form the author used.
|
||||
pub fn verify(feed: &Feed) -> Report {
|
||||
let host_keys = feed.host_keys();
|
||||
let legacy = feed.is_legacy();
|
||||
let author = feed.author().unwrap_or_default();
|
||||
let states = feed
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| match &entry.mail {
|
||||
None => State::Unsigned,
|
||||
Some(mail) => {
|
||||
if legacy {
|
||||
// Not a smol feed: nothing is forged, everything is legacy.
|
||||
return State::Unsigned;
|
||||
}
|
||||
let reply_to = derived_reply_to(&author, &mail.sender);
|
||||
let bodies: Vec<Vec<u8>> = match &entry.record {
|
||||
// A post is bare text or a Reply-To block plus the text.
|
||||
Record::Post { text, .. } => vec![
|
||||
text.clone().into_bytes(),
|
||||
reply_to_body(&reply_to, text).into_bytes(),
|
||||
],
|
||||
// A repost block may carry the Reply-To of an ordinary send.
|
||||
Record::Repost { args, .. } => vec![
|
||||
repost_body(args, None).into_bytes(),
|
||||
repost_body(args, Some(&reply_to)).into_bytes(),
|
||||
],
|
||||
};
|
||||
let Some(unix) = time::parse(entry.record.timestamp()) else {
|
||||
return State::Invalid;
|
||||
};
|
||||
host_keys
|
||||
.iter()
|
||||
.any(|to| {
|
||||
bodies.iter().any(|body| {
|
||||
let signed = signed_bytes(to, &mail.epk, &mail.sender, unix, body);
|
||||
verify_sig(&mail.sender, &mail.sig, &signed)
|
||||
})
|
||||
})
|
||||
.then_some(State::Verified)
|
||||
.unwrap_or(State::Invalid)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Report { legacy, states }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_body_forms() {
|
||||
assert_eq!(
|
||||
reply_to_body("smol://a@b/k", "hi"),
|
||||
"---\nReply-To: smol://a@b/k\n---\nhi"
|
||||
);
|
||||
assert_eq!(
|
||||
repost_body("@<d u> #abc", None),
|
||||
"---\nTwt-Repost: @<d u> #abc\n---\n"
|
||||
);
|
||||
assert_eq!(
|
||||
repost_body("@<d u> #abc", Some("smol://a@b/k")),
|
||||
"---\nTwt-Repost: @<d u> #abc\nReply-To: smol://a@b/k\n---\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
418
core/tests/vectors.rs
Normal file
418
core/tests/vectors.rs
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
//! Reference vectors pinning every verification state (PLAN.md step 3).
|
||||
//!
|
||||
//! Cases are built from fixed seeds; Ed25519 signing is deterministic, so a
|
||||
//! rebuilt case is byte-identical to `vectors.json`. The regular tests pin
|
||||
//! both directions — the JSON must verify to its recorded states, and the
|
||||
//! code must regenerate exactly the JSON's feeds. `write_vectors`
|
||||
//! regenerates the file after an intentional format change:
|
||||
//! `cargo test -- --ignored write_vectors`.
|
||||
|
||||
use fumi_core::account::{identity_seed, Identity};
|
||||
use fumi_core::crypto::b32;
|
||||
use smolboid_core::feed::{Feed, MailLine};
|
||||
use smolboid_core::verify::{reply_to_body, repost_body, signed_bytes, verify, State};
|
||||
use smolboid_core::time;
|
||||
|
||||
/// Distinct fixed masters: 0xA0/0xA1 are the daemon's identity keys across a
|
||||
/// rotation, 0xB0 a foreign instance, 0x5B the author.
|
||||
const HOST0: u8 = 0xA0;
|
||||
const HOST1: u8 = 0xA1;
|
||||
const FOREIGN: u8 = 0xB0;
|
||||
const AUTHOR: u8 = 0x5B;
|
||||
const EPK: [u8; 32] = [0xE7; 32];
|
||||
|
||||
fn identity(master: u8, index: u32) -> Identity {
|
||||
Identity::from_seed(identity_seed(&[master; 32], index))
|
||||
}
|
||||
|
||||
/// Signs a record the way smolmail seals it, so the feed carries the very
|
||||
/// signature that sealed the mail (PLAN.md, Signing).
|
||||
fn sign(sender: &Identity, to: [u8; 32], unix: i64, body: &str) -> MailLine {
|
||||
let sender_pk = sender.pk();
|
||||
MailLine {
|
||||
sender: sender_pk,
|
||||
epk: EPK,
|
||||
sig: sender.sign(&signed_bytes(&to, &EPK, &sender_pk, unix, body.as_bytes())),
|
||||
}
|
||||
}
|
||||
|
||||
fn post(sender: &Identity, to: [u8; 32], unix: i64, text: &str) -> String {
|
||||
let mail = sign(sender, to, unix, text);
|
||||
// The bare form of a post.
|
||||
format!(
|
||||
"{}\t{}\n{}\n",
|
||||
time::render(unix),
|
||||
text,
|
||||
mail.render()
|
||||
)
|
||||
}
|
||||
|
||||
fn post_with_reply_to(sender: &Identity, to: [u8; 32], unix: i64, reply_to: &str, text: &str) -> String {
|
||||
let mail = sign(sender, to, unix, &reply_to_body(reply_to, text));
|
||||
format!(
|
||||
"{}\t{}\n{}\n",
|
||||
time::render(unix),
|
||||
text,
|
||||
mail.render()
|
||||
)
|
||||
}
|
||||
|
||||
fn repost(sender: &Identity, to: [u8; 32], unix: i64, args: &str) -> String {
|
||||
let mail = sign(sender, to, unix, &repost_body(args, None));
|
||||
format!(
|
||||
"# smol:repost {} {}\n{}\n",
|
||||
time::render(unix),
|
||||
args,
|
||||
mail.render()
|
||||
)
|
||||
}
|
||||
|
||||
fn repost_with_reply_to(sender: &Identity, to: [u8; 32], unix: i64, reply_to: &str, args: &str) -> String {
|
||||
let mail = sign(sender, to, unix, &repost_body(args, Some(reply_to)));
|
||||
format!(
|
||||
"# smol:repost {} {}\n{}\n",
|
||||
time::render(unix),
|
||||
args,
|
||||
mail.render()
|
||||
)
|
||||
}
|
||||
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
feed: String,
|
||||
legacy: bool,
|
||||
states: Vec<State>,
|
||||
}
|
||||
|
||||
fn metadata(host_keys: &[[u8; 32]]) -> String {
|
||||
let mut meta = String::from(
|
||||
"# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n",
|
||||
);
|
||||
for key in host_keys {
|
||||
meta.push_str(&format!("# smol:host_key = {}\n", b32(key)));
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
|
||||
|
||||
fn cases() -> Vec<Case> {
|
||||
let host0 = identity(HOST0, 0).pk();
|
||||
let host1 = identity(HOST1, 0).pk();
|
||||
let foreign = identity(FOREIGN, 0).pk();
|
||||
let author = identity(AUTHOR, 0);
|
||||
let rotated = identity(AUTHOR, 1);
|
||||
let t1: i64 = 1_760_000_000;
|
||||
let t2 = t1 + 3600;
|
||||
let t3 = t2 + 3600;
|
||||
|
||||
let meta1 = metadata(&[host0]);
|
||||
|
||||
vec![
|
||||
Case {
|
||||
name: "verified_post",
|
||||
feed: format!("{}{}", meta1, post(&author, host0, t1, "hello smolverse")),
|
||||
legacy: false,
|
||||
states: vec![State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "unsigned_post",
|
||||
feed: format!(
|
||||
"{}{}\t{}\n",
|
||||
meta1,
|
||||
time::render(t1),
|
||||
"no signature line"
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Unsigned],
|
||||
},
|
||||
Case {
|
||||
name: "invalid_tampered_text",
|
||||
// Signed over "hello smolverse", published as "hello sm0lverse".
|
||||
feed: format!(
|
||||
"{}{}\t{}\n{}\n",
|
||||
meta1,
|
||||
time::render(t1),
|
||||
"hello sm0lverse",
|
||||
sign(&author, host0, t1, "hello smolverse").render()
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Invalid],
|
||||
},
|
||||
Case {
|
||||
name: "invalid_foreign_host",
|
||||
// Sealed to another instance: no host_key matches, so the record
|
||||
// cannot be laundered into this feed.
|
||||
feed: format!(
|
||||
"{}{}",
|
||||
meta1,
|
||||
post(&author, foreign, t1, "sealed elsewhere")
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Invalid],
|
||||
},
|
||||
Case {
|
||||
name: "invalid_bad_timestamp",
|
||||
feed: format!(
|
||||
"{}{}\t{}\n{}\n",
|
||||
meta1,
|
||||
"2026-13-40T99:99:99Z",
|
||||
"unparseable time",
|
||||
sign(&author, host0, t1, "unparseable time").render()
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Invalid],
|
||||
},
|
||||
Case {
|
||||
name: "legacy_feed",
|
||||
// No smol:author and no host_key: plain twtxt, records not judged.
|
||||
feed: format!(
|
||||
"# nick = tobias\n{}\t{}\n",
|
||||
time::render(t1),
|
||||
"just a legacy twt"
|
||||
),
|
||||
legacy: true,
|
||||
states: vec![State::Unsigned],
|
||||
},
|
||||
Case {
|
||||
name: "orphan_mail_line",
|
||||
feed: format!(
|
||||
"{}{}{}",
|
||||
meta1,
|
||||
post(&author, host0, t1, "with a signature"),
|
||||
format!(
|
||||
"# smol:mail {} {} {}\n",
|
||||
b32(&author.pk()),
|
||||
b32(&EPK),
|
||||
b32(&author.sign(&signed_bytes(
|
||||
&host0,
|
||||
&EPK,
|
||||
&author.pk(),
|
||||
t2,
|
||||
"orphaned".as_bytes()
|
||||
)))
|
||||
)
|
||||
),
|
||||
legacy: false,
|
||||
// The second mail line belongs to no record and is ignored.
|
||||
states: vec![State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "host_rotation",
|
||||
// Both eras verify because each host_key is tried in turn.
|
||||
feed: format!(
|
||||
"{}{}{}",
|
||||
metadata(&[host0, host1]),
|
||||
post(&author, host0, t1, "before the rotation"),
|
||||
post(&author, host1, t2, "after the rotation")
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Verified, State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "author_rotation",
|
||||
// Per-record sender keys: old posts stay valid under the old key.
|
||||
feed: format!(
|
||||
"{}{}{}",
|
||||
meta1,
|
||||
post(&author, host0, t1, "identity index 0"),
|
||||
post(&rotated, host0, t2, "identity index 1")
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Verified, State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "verified_repost",
|
||||
feed: format!(
|
||||
"{}{}",
|
||||
meta1,
|
||||
repost(
|
||||
&author,
|
||||
host0,
|
||||
t1,
|
||||
"@<dana https://smol.place/twtxt/dana.txt> #abc1234"
|
||||
)
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "invalid_repost_tampered",
|
||||
feed: format!(
|
||||
"{}{}",
|
||||
meta1,
|
||||
repost(
|
||||
&author,
|
||||
host0,
|
||||
t1,
|
||||
"@<dana https://smol.place/twtxt/dana.txt> #abc1234"
|
||||
)
|
||||
.replace("#abc1234", "#fedcba9") // signed over #abc1234
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Invalid],
|
||||
},
|
||||
Case {
|
||||
name: "dashed_post",
|
||||
// Text beginning with `---` is sent raw: a single line can never
|
||||
// open a §5.5 frontmatter block, so nothing is escaped.
|
||||
feed: format!(
|
||||
"{}{}",
|
||||
meta1,
|
||||
post(&author, host0, t1, "--- dashed opening")
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "reply_to_post",
|
||||
// An ordinary send carries a signed Reply-To block; the verifier
|
||||
// derives the same value from the author address and sender key.
|
||||
feed: format!(
|
||||
"{}{}",
|
||||
meta1,
|
||||
post_with_reply_to(
|
||||
&author,
|
||||
host0,
|
||||
t1,
|
||||
&format!("smol://tobias@smol.place/{}", b32(&author.pk())),
|
||||
"hello from an ordinary send"
|
||||
)
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "reply_to_key_mismatch_is_forged",
|
||||
// The signature covers a Reply-To naming a different key than
|
||||
// the record's sender, so no derived form reconstructs.
|
||||
feed: format!(
|
||||
"{}{}",
|
||||
meta1,
|
||||
post_with_reply_to(
|
||||
&author,
|
||||
host0,
|
||||
t1,
|
||||
&format!("smol://tobias@smol.place/{}", b32(&[0xEE; 32])),
|
||||
"signed with a foreign reply-to"
|
||||
)
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Invalid],
|
||||
},
|
||||
Case {
|
||||
name: "repost_with_reply_to",
|
||||
feed: format!(
|
||||
"{}{}",
|
||||
meta1,
|
||||
repost_with_reply_to(
|
||||
&author,
|
||||
host0,
|
||||
t1,
|
||||
&format!("smol://tobias@smol.place/{}", b32(&author.pk())),
|
||||
"@<dana https://smol.place/twtxt/dana.txt> #abc1234"
|
||||
)
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![State::Verified],
|
||||
},
|
||||
Case {
|
||||
name: "mixed_feed",
|
||||
feed: format!(
|
||||
"{}{}\t{}\n{}{}{}",
|
||||
meta1,
|
||||
time::render(t1),
|
||||
"unsigned legacy line",
|
||||
post(&author, host0, t2, "signed after it"),
|
||||
repost(&author, host0, t3, "@<nick url> #hash123"),
|
||||
post(&author, foreign, t1 + 7200, "signed to a foreign host")
|
||||
),
|
||||
legacy: false,
|
||||
states: vec![
|
||||
State::Unsigned,
|
||||
State::Verified,
|
||||
State::Verified,
|
||||
State::Invalid,
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cases_verify_to_their_expected_states() {
|
||||
for case in cases() {
|
||||
let report = verify(&Feed::parse(&case.feed));
|
||||
assert_eq!(report.legacy, case.legacy, "{}: legacy flag", case.name);
|
||||
assert_eq!(report.states, case.states, "{}: states", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_render_round_trips_every_case() {
|
||||
for case in cases() {
|
||||
assert_eq!(Feed::parse(&case.feed).render(), case.feed, "{}", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vectors_json_pins_the_generated_cases() {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/vectors.json");
|
||||
let pinned: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(path).expect("vectors.json present"))
|
||||
.expect("vectors.json parses");
|
||||
let pinned_cases = pinned["cases"].as_array().expect("cases array");
|
||||
let generated = cases();
|
||||
assert_eq!(
|
||||
pinned_cases.len(),
|
||||
generated.len(),
|
||||
"vectors.json and the code disagree on the case set"
|
||||
);
|
||||
|
||||
for (pinned, case) in pinned_cases.iter().zip(&generated) {
|
||||
assert_eq!(pinned["name"], case.name, "case order");
|
||||
assert_eq!(pinned["feed"], case.feed, "{}: feed bytes", case.name);
|
||||
assert_eq!(pinned["legacy"], case.legacy, "{}: legacy", case.name);
|
||||
let states: Vec<&str> = pinned["states"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().unwrap())
|
||||
.collect();
|
||||
let expected: Vec<&str> = case.states.iter().map(State::as_str).collect();
|
||||
assert_eq!(states, expected, "{}: states", case.name);
|
||||
|
||||
// The pinned feed must verify exactly as recorded.
|
||||
let report = verify(&Feed::parse(case.feed.as_str()));
|
||||
assert_eq!(report.legacy, case.legacy);
|
||||
assert_eq!(
|
||||
report
|
||||
.states
|
||||
.iter()
|
||||
.map(State::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
states
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "regenerates core/vectors.json after an intentional format change"]
|
||||
fn write_vectors() {
|
||||
let cases: Vec<serde_json::Value> = cases()
|
||||
.iter()
|
||||
.map(|case| {
|
||||
serde_json::json!({
|
||||
"name": case.name,
|
||||
"feed": case.feed,
|
||||
"legacy": case.legacy,
|
||||
"states": case.states.iter().map(State::as_str).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let doc = serde_json::json!({
|
||||
"comment": "smolboid-core verification vectors; regenerate with: cargo test -- --ignored write_vectors",
|
||||
"cases": cases,
|
||||
});
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/vectors.json");
|
||||
std::fs::write(path, serde_json::to_string_pretty(&doc).unwrap() + "\n").unwrap();
|
||||
}
|
||||
138
core/vectors.json
Normal file
138
core/vectors.json
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
{
|
||||
"cases": [
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\thello smolverse\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq ia6di7h2xao6qwr6ry2ikcwwv2wzf7lrqytr6w7iwglq43ltky4q5r36tnbjwu6nunjtc3nu3evonherkc6cawbii3tb3s6ciul7obi\n",
|
||||
"legacy": false,
|
||||
"name": "verified_post",
|
||||
"states": [
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tno signature line\n",
|
||||
"legacy": false,
|
||||
"name": "unsigned_post",
|
||||
"states": [
|
||||
"unsigned"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\thello sm0lverse\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq ia6di7h2xao6qwr6ry2ikcwwv2wzf7lrqytr6w7iwglq43ltky4q5r36tnbjwu6nunjtc3nu3evonherkc6cawbii3tb3s6ciul7obi\n",
|
||||
"legacy": false,
|
||||
"name": "invalid_tampered_text",
|
||||
"states": [
|
||||
"invalid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tsealed elsewhere\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq n24wqnx5zek6ijlntuzdfzoht7x3smwvbkwbar7mvmulqziozexkbhojwmpqmrmxamxhonhlmuguv45zwidf2p3rq53fgpqbsahwsby\n",
|
||||
"legacy": false,
|
||||
"name": "invalid_foreign_host",
|
||||
"states": [
|
||||
"invalid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2026-13-40T99:99:99Z\tunparseable time\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq konvdbrlvpoilkuljesxdqevmeszow2o7uoq4han4iciiieqs7wsegdhyuhl4ykw6b3lz4zpocwdspel26vjvp6jndeug2ng2i4h2ca\n",
|
||||
"legacy": false,
|
||||
"name": "invalid_bad_timestamp",
|
||||
"states": [
|
||||
"invalid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n2025-10-09T08:53:20Z\tjust a legacy twt\n",
|
||||
"legacy": true,
|
||||
"name": "legacy_feed",
|
||||
"states": [
|
||||
"unsigned"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\twith a signature\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq 4wygpwalnweej76ge746tzof52b33hbuewkoock44bvpjzqtbe2my3hq6vccvytbyzydhbtxcbti6x6nwiux57tgdozv3pamcpmueca\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq 45sk6einnju7xcphanrgm3mwtfw2xh32moholp46vgrt6lvp7r2ekyvxh7ka5pqfqfdbsunaw4i4jxvzmpkhhrvagdfwmktgnpceuby\n",
|
||||
"legacy": false,
|
||||
"name": "orphan_mail_line",
|
||||
"states": [
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:host_key = erufyonapvqh5r5kr7rkgi4lnqaigh33tz6cnnaledofy7feel7a\n2025-10-09T08:53:20Z\tbefore the rotation\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq mdlh7eb4uqim3stgtwywzgx4y45jc6kfbuzsxnab62ggsngijod6utgad366q6mm4gxic4qaedwmkr7g3gcv6n4ki4k7tuwxsq7r2dq\n2025-10-09T09:53:20Z\tafter the rotation\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq vrsqckvlsftuurdwtbneigdtnle5lkpcjthyzqhkantnjkjtsrguwqw367cnlrx4udmuntvjbj2jvcyyahp5uvtjb4q7idoiqjbveay\n",
|
||||
"legacy": false,
|
||||
"name": "host_rotation",
|
||||
"states": [
|
||||
"verified",
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tidentity index 0\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq l7mhywrghkgqtyfb5t4e4xgfmcpteqdurjagm3w55qomncirgdjf3h6dkr5lpw23tgoxag6vk4cmfrvz7k3chopenuvaexnosnpjeby\n2025-10-09T09:53:20Z\tidentity index 1\n# smol:mail ceitvjvvappgrm3hmogulwfkw3s43qvm5vcgk7c3pg7daonzreea 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq xzsg3owvd6fecwfq5tfscrrk7a7jhzimghbbgtv2ssxnzed2cakmekvzeawehto55uzcds7iqjfgl6nulylkohubywplamv6rrmzwbq\n",
|
||||
"legacy": false,
|
||||
"name": "author_rotation",
|
||||
"states": [
|
||||
"verified",
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:repost 2025-10-09T08:53:20Z @<dana https://smol.place/twtxt/dana.txt> #abc1234\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq t7zwas3yeane6ezyzg4cqnglyv5ccdaklqu6pk4ucrgyqjj2y6ghyzx57blg3dav42rkwrmmbgnas3pch4wkj3vkqbvo22exdreewby\n",
|
||||
"legacy": false,
|
||||
"name": "verified_repost",
|
||||
"states": [
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:repost 2025-10-09T08:53:20Z @<dana https://smol.place/twtxt/dana.txt> #fedcba9\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq t7zwas3yeane6ezyzg4cqnglyv5ccdaklqu6pk4ucrgyqjj2y6ghyzx57blg3dav42rkwrmmbgnas3pch4wkj3vkqbvo22exdreewby\n",
|
||||
"legacy": false,
|
||||
"name": "invalid_repost_tampered",
|
||||
"states": [
|
||||
"invalid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\t--- dashed opening\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq 6udoaeejb2ydygw5ld75bayflszlnevmr5kx5z3pv52dajmzj46owv3ejw6b22gs5bddqcvugyece354h5byl4vohfze6lspd2ihgbi\n",
|
||||
"legacy": false,
|
||||
"name": "dashed_post",
|
||||
"states": [
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\thello from an ordinary send\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq p6luqrmt62nvzqrhpchmtm3mcfydp5jn6fh4hioyueqmd66e6hmdgxne3iaspmo6rknvd5hv5xfaddrczjuu6h7dqkcdnkamnawviaa\n",
|
||||
"legacy": false,
|
||||
"name": "reply_to_post",
|
||||
"states": [
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tsigned with a foreign reply-to\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq ny4xpgadtesuz64z6c6dr7zmzknbd3gg5m7r276wrk7lk3znfah7nnrt4i3m5oryrklrpxhsknhouu2grffpvndr7cakorjb6io4yaq\n",
|
||||
"legacy": false,
|
||||
"name": "reply_to_key_mismatch_is_forged",
|
||||
"states": [
|
||||
"invalid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:repost 2025-10-09T08:53:20Z @<dana https://smol.place/twtxt/dana.txt> #abc1234\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq sumdf5lz5rk4falzxw7njfugqign32z6vvceprmlfreelaagcq66ctvkkooiqfipaayxzpjyzwsfjoy6bvmc2vvl4s5elj6kh67zaby\n",
|
||||
"legacy": false,
|
||||
"name": "repost_with_reply_to",
|
||||
"states": [
|
||||
"verified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tunsigned legacy line\n2025-10-09T09:53:20Z\tsigned after it\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq iossrjv4a3z7pa7pcfw6epv2sjhnnreoh4bvzs3txq5rnb3767giuqogbpeqkyxggju2fewfyvne7smr6gsumtz6z2f4r3fr6gm7uba\n# smol:repost 2025-10-09T10:53:20Z @<nick url> #hash123\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq jca43z6yttue5epkuplkbtimozmsk6zvgy44xbv65xjbvspm7pykriadwnhad7ay2rdnmiiq5vnwerijivaoambrlium223vremqkci\n2025-10-09T10:53:20Z\tsigned to a foreign host\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq fxbfyyjtv653e4fny25q2mwihwpxkdfs6zfpxtozto57ggidqpcxsaohrkxpe2pohj5cehjdr5qw2pbqmx5t4nesagpqjkhit7aj4bq\n",
|
||||
"legacy": false,
|
||||
"name": "mixed_feed",
|
||||
"states": [
|
||||
"unsigned",
|
||||
"verified",
|
||||
"verified",
|
||||
"invalid"
|
||||
]
|
||||
}
|
||||
],
|
||||
"comment": "smolboid-core verification vectors; regenerate with: cargo test -- --ignored write_vectors"
|
||||
}
|
||||
32
devbox.json
Normal file
32
devbox.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"packages": [
|
||||
"rustc@latest",
|
||||
"cargo@latest",
|
||||
"gcc@latest"
|
||||
],
|
||||
"shell": {
|
||||
"scripts": {
|
||||
"build": [
|
||||
"cargo build --workspace"
|
||||
],
|
||||
"test": [
|
||||
"cargo test --workspace"
|
||||
],
|
||||
"check": [
|
||||
"cargo check --workspace"
|
||||
],
|
||||
"vectors": [
|
||||
"cargo test -p smolboid-core --test vectors -- --ignored write_vectors"
|
||||
],
|
||||
"rig": [
|
||||
"bash tests/rig.sh"
|
||||
],
|
||||
"stress": [
|
||||
"bash tests/stress.sh"
|
||||
],
|
||||
"pentest": [
|
||||
"bash tests/pentest.sh"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
178
devbox.lock
Normal file
178
devbox.lock
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
{
|
||||
"lockfile_version": "1",
|
||||
"packages": {
|
||||
"cargo@latest": {
|
||||
"last_modified": "2026-08-27T07:16:00Z",
|
||||
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo",
|
||||
"source": "devbox-search",
|
||||
"version": "1.97.1",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gcc@latest": {
|
||||
"last_modified": "2026-08-27T07:16:00Z",
|
||||
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#gcc16",
|
||||
"source": "devbox-search",
|
||||
"version": "16.2.0",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/z3zd0lklg2b8705zmx11cq35crddbpj4-gcc-wrapper-16.2.0",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/pbj8r9p89ilvfklxrj3fyvqdadac0861-gcc-wrapper-16.2.0-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "info",
|
||||
"path": "/nix/store/g210v8iyk80zy476vp9d7nkgvv5s3i1r-gcc-wrapper-16.2.0-info"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/z3zd0lklg2b8705zmx11cq35crddbpj4-gcc-wrapper-16.2.0"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/d0a9bdlmsac38hi0kscqcnh2pn4439ss-gcc-wrapper-16.2.0",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/pyn8mm9fribahydk1bxby0c56r3qmfmb-gcc-wrapper-16.2.0-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "info",
|
||||
"path": "/nix/store/7vfckbslv452rdwwcqa5r5i4rq47m3rj-gcc-wrapper-16.2.0-info"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/d0a9bdlmsac38hi0kscqcnh2pn4439ss-gcc-wrapper-16.2.0"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/8pfs1i59j9zxy2c7h0wmb8vcda75pjxv-gcc-wrapper-16.2.0",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/pmnqpcv27gdk4nf3py76fcr2g42f2ybb-gcc-wrapper-16.2.0-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "info",
|
||||
"path": "/nix/store/nh1dnj05p8lygbd8hkvj07r1h5vvbbqf-gcc-wrapper-16.2.0-info"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/8pfs1i59j9zxy2c7h0wmb8vcda75pjxv-gcc-wrapper-16.2.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github:NixOS/nixpkgs/nixpkgs-unstable": {
|
||||
"last_modified": "2026-09-29T03:29:29Z",
|
||||
"resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D"
|
||||
},
|
||||
"rustc@latest": {
|
||||
"last_modified": "2026-08-27T07:16:00Z",
|
||||
"plugin_version": "0.0.1",
|
||||
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustc",
|
||||
"source": "devbox-search",
|
||||
"version": "1.97.1",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/grw7ifdixkdr4ya8bw5x0psjyfr5azva-rustc-wrapper-1.97.1-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/8qkha38sic70l51g4l7dmjrxnzqjrr6w-rustc-wrapper-1.97.1-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/hn340n5pi9p30j4bcxgzqgq8lmclg1by-rustc-wrapper-1.97.1-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/avbwjkw896rb3nl28kh8qv6kxyq5b9v2-rustc-wrapper-1.97.1-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "man",
|
||||
"path": "/nix/store/an2hvf5i7mq52mqdad9rad8mxkpksmq2-rustc-wrapper-1.97.1-man",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "doc",
|
||||
"path": "/nix/store/8svfp79q0gx10gcrsyxf1pw04q8jf0lp-rustc-wrapper-1.97.1-doc"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue