aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2026-10-25 09:08:01 +0100
committerMartin Fischer <martin@push-f.com>2026-10-25 19:10:47 +0100
commit41212a7a15198e53d205252a02069a7dd6ddadeb (patch)
tree38a24eebf956b2453675ac1cefec925faf09de02
initial commit
-rw-r--r--.envrc1
-rw-r--r--.gitignore3
-rw-r--r--Cargo.lock2148
-rw-r--r--Cargo.toml19
-rw-r--r--README.md16
-rw-r--r--default.nix18
-rw-r--r--service.nix122
-rw-r--r--shell.nix12
-rw-r--r--src/bin/delete-recent.rs128
-rw-r--r--src/bots/fdroid/index.rs97
-rw-r--r--src/bots/fdroid/mod.rs572
-rw-r--r--src/bots/mod.rs1
-rw-r--r--src/main.rs224
-rw-r--r--src/report.rs57
14 files changed, 3418 insertions, 0 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..1d953f4
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use nix
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f4973f6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+/result
+*.json
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..0132ddc
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2148 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "async-compression"
+version = "0.4.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "atrium-api"
+version = "0.25.8"
+source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b"
+dependencies = [
+ "atrium-common",
+ "atrium-xrpc",
+ "chrono",
+ "http",
+ "ipld-core",
+ "langtag",
+ "regex",
+ "serde",
+ "serde_bytes",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "trait-variant",
+]
+
+[[package]]
+name = "atrium-common"
+version = "0.1.4"
+source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b"
+dependencies = [
+ "dashmap",
+ "lru",
+ "moka",
+ "thiserror",
+ "tokio",
+ "trait-variant",
+ "web-time",
+]
+
+[[package]]
+name = "atrium-xrpc"
+version = "0.12.4"
+source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b"
+dependencies = [
+ "http",
+ "serde",
+ "serde_html_form",
+ "serde_json",
+ "thiserror",
+ "trait-variant",
+]
+
+[[package]]
+name = "atrium-xrpc-client"
+version = "0.5.15"
+source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b"
+dependencies = [
+ "atrium-xrpc",
+ "reqwest",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base-x"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
+
+[[package]]
+name = "base256emoji"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c"
+dependencies = [
+ "const-str",
+ "match-lookup",
+]
+
+[[package]]
+name = "base45"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "bsky-news"
+version = "0.0.0"
+dependencies = [
+ "atrium-api",
+ "bsky-sdk",
+ "chrono",
+ "clap",
+ "imagesize",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "bsky-sdk"
+version = "0.1.24"
+source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b"
+dependencies = [
+ "anyhow",
+ "atrium-api",
+ "atrium-xrpc-client",
+ "chrono",
+ "psl",
+ "regex",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "trait-variant",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
+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 = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "cid"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971"
+dependencies = [
+ "multibase",
+ "multihash",
+ "serde",
+ "serde_bytes",
+ "unsigned-varint",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "compression-codecs"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
+dependencies = [
+ "compression-core",
+ "flate2",
+ "memchr",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
+
+[[package]]
+name = "const-str"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
+
+[[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 = "crc32fast"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "data-encoding-macro"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8"
+dependencies = [
+ "data-encoding",
+ "data-encoding-macro-internal",
+]
+
+[[package]]
+name = "data-encoding-macro-internal"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead"
+dependencies = [
+ "data-encoding",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+ "zlib-rs",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
+
+[[package]]
+name = "icu_properties"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
+
+[[package]]
+name = "icu_provider"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "imagesize"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
+
+[[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 = "ipld-core"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda"
+dependencies = [
+ "cid",
+ "serde",
+ "serde_bytes",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "langtag"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed60c85f254d6ae8450cec15eedd921efbc4d1bdf6fcf6202b9a58b403f6f805"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "match-lookup"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "549e39695cc0b640f3cb378053832db3d2133422d49e8dcae5c866a2aaf1f730"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "moka"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
+dependencies = [
+ "async-lock",
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "event-listener",
+ "futures-util",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
+[[package]]
+name = "multibase"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6"
+dependencies = [
+ "base-x",
+ "base256emoji",
+ "base45",
+ "data-encoding",
+ "data-encoding-macro",
+]
+
+[[package]]
+name = "multihash"
+version = "0.19.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447"
+dependencies = [
+ "serde",
+ "unsigned-varint",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "openssl"
+version = "0.10.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[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 = "psl"
+version = "2.1.245"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46cce9bc841c0eeb395275a775d11e3cc91b904e6ab370a9a42369e111b3038e"
+dependencies = [
+ "psl-types",
+]
+
+[[package]]
+name = "psl-types"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[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 = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "schannel"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[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 = "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_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[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.9",
+]
+
+[[package]]
+name = "serde_html_form"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
+dependencies = [
+ "form_urlencoded",
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[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.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.54.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "async-compression",
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "trait-variant"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unsigned-varint"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
+dependencies = [
+ "getrandom",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "want"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[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 = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "yoke"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[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..3a890a6
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "bsky-news"
+edition = "2024"
+default-run = "bsky-news"
+
+[dependencies]
+# using branch because https://github.com/atrium-rs/atrium/pull/346 hasn't landed yet
+atrium-api = { git = "https://github.com/atrium-rs/atrium", branch = "update/lexicon-5c3b7c9c8" }
+bsky-sdk = { git = "https://github.com/atrium-rs/atrium", branch = "update/lexicon-5c3b7c9c8" }
+chrono = "0.4"
+clap = { version = "4.5", features = ["derive"] }
+imagesize = { version = "0.15", default-features = false, features = ["jpeg", "png"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+reqwest = { version = "0.12", default-features = false, features = ["json"] }
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+tracing = "0.1"
+tracing-subscriber = "0.3"
+unicode-segmentation = "1"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2a46a97
--- /dev/null
+++ b/README.md
@@ -0,0 +1,16 @@
+# bsky-news bots
+
+* [@f-droid.news.push-f.com] reports:
+ * new apps in the official [F-Droid] repo as soon as they become available
+
+
+[@f-droid.news.push-f.com]: https://bsky.app/profile/f-droid.news.push-f.com
+[F-Droid]: https://f-droid.org/
+
+## Development
+
+Delete all posts created by the configured account during the last hour:
+
+```
+cargo run --bin delete-recent
+```
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..3466ecc
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,18 @@
+{ pkgs ? import <nixpkgs> { } }:
+
+pkgs.rustPlatform.buildRustPackage {
+ pname = "bsky-news";
+ version = "0.0.0";
+
+ src = pkgs.lib.cleanSource ./.;
+
+ nativeBuildInputs = [ pkgs.pkg-config ];
+ buildInputs = [ pkgs.openssl ];
+
+ cargoLock = {
+ lockFile = ./Cargo.lock;
+ outputHashes = {
+ "atrium-api-0.25.8" = "sha256-mxeM5QnY/rJ8tR7bepBXAA2hG6Xzz7d+hQUwDAu1blE=";
+ };
+ };
+}
diff --git a/service.nix b/service.nix
new file mode 100644
index 0000000..37d567e
--- /dev/null
+++ b/service.nix
@@ -0,0 +1,122 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+
+let
+ inherit (lib) mkEnableOption mkIf mkMerge mkOption types;
+
+ cfg = config.services.bsky-news;
+
+ mkService = botName: bot: operation: {
+ description = "Bluesky ${botName} ${operation} job";
+ after = [ "network-online.target" ];
+ wants = [ "network-online.target" ];
+
+ environment.BSKY_CREDENTIALS_FILE = "%d/bsky";
+
+ serviceConfig = {
+ Type = "oneshot";
+ ExecStart = lib.escapeShellArgs [
+ (lib.getExe' cfg.package "bsky-news")
+ botName
+ operation
+ ];
+
+ LoadCredential = "bsky:${bot.credentialsFile}";
+ DynamicUser = true;
+ StateDirectory = "bsky-news-${botName}";
+ WorkingDirectory = "%S/bsky-news-${botName}";
+ UMask = "0077";
+
+ NoNewPrivileges = true;
+ PrivateTmp = true;
+ ProtectHome = true;
+ ProtectSystem = "strict";
+ ProtectClock = true;
+ ProtectKernelLogs = true;
+ ProtectKernelModules = true;
+ ProtectKernelTunables = true;
+ RestrictSUIDSGID = true;
+ };
+ };
+
+ mkTimer = unitName: schedule: randomizedDelaySec: {
+ description = "Run ${unitName} periodically";
+ wantedBy = [ "timers.target" ];
+
+ timerConfig = {
+ OnCalendar = schedule;
+ Persistent = true;
+ RandomizedDelaySec = randomizedDelaySec;
+ Unit = "${unitName}.service";
+ };
+ };
+
+ servicesForBot = botName: bot:
+ let
+ breakingUnit = "bsky-news-${botName}-breaking";
+ in
+ {
+ ${breakingUnit} = mkService botName bot "breaking";
+ };
+
+ timersForBot = botName: bot:
+ let
+ breakingUnit = "bsky-news-${botName}-breaking";
+ in
+ {
+ ${breakingUnit} = mkTimer breakingUnit bot.breakingNews.schedule bot.breakingNews.randomizedDelaySec;
+ };
+in
+{
+ options.services.bsky-news = {
+ enable = mkEnableOption "periodic Bluesky news bots";
+
+ package = mkOption {
+ type = types.package;
+ default = pkgs.callPackage ./default.nix { };
+ defaultText = lib.literalExpression "pkgs.callPackage ./default.nix { }";
+ description = "The bsky-news package to run.";
+ };
+
+ bots = mkOption {
+ default = { };
+ description = "Bluesky bot accounts and their schedules.";
+ type = types.attrsOf (types.submodule ({ ... }: {
+ options = {
+ credentialsFile = mkOption {
+ type = types.str;
+ description = ''
+ Path to a credentials file, normally an agenix secret. It is
+ exposed to the process through systemd's credential directory;
+ BSKY_CREDENTIALS_FILE contains the resulting path.
+ '';
+ };
+
+ breakingNews = {
+ schedule = mkOption {
+ type = types.str;
+ default = "*:0/10";
+ description = "systemd OnCalendar expression for breaking news.";
+ };
+
+ randomizedDelaySec = mkOption {
+ type = types.str;
+ default = "30s";
+ description = "Maximum random delay applied to breaking-news runs.";
+ };
+ };
+
+ };
+ }));
+ };
+ };
+
+ config = mkIf cfg.enable {
+ systemd.services = mkMerge (lib.mapAttrsToList servicesForBot cfg.bots);
+ systemd.timers = mkMerge (lib.mapAttrsToList timersForBot cfg.bots);
+ };
+}
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..e778eff
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,12 @@
+{ pkgs ? import <nixpkgs> {} }:
+
+pkgs.mkShell {
+ packages = with pkgs; [
+ cargo
+ rustc
+ rustfmt
+ clippy
+ openssl
+ pkg-config
+ ];
+}
diff --git a/src/bin/delete-recent.rs b/src/bin/delete-recent.rs
new file mode 100644
index 0000000..5486555
--- /dev/null
+++ b/src/bin/delete-recent.rs
@@ -0,0 +1,128 @@
+use std::{env, fs};
+
+use atrium_api::{
+ app::bsky::feed::post::RecordData,
+ com::atproto::repo::list_records::{OutputData, ParametersData},
+ types::{
+ TryFromUnknown,
+ string::{AtIdentifier, Datetime},
+ },
+};
+use bsky_sdk::BskyAgent;
+use chrono::{Duration, Utc};
+use clap::Parser;
+use serde::Deserialize;
+
+#[derive(Debug, Parser)]
+#[command(about = "Delete posts created by the account during the last hour")]
+struct Args {
+ /// Print matching posts without deleting them.
+ #[arg(long)]
+ dry: bool,
+}
+
+#[derive(Deserialize)]
+struct Credentials {
+ identifier: String,
+ password: String,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), String> {
+ tracing_subscriber::fmt::init();
+
+ let args = Args::parse();
+ let credentials = load_credentials()?;
+ let repo: AtIdentifier = credentials
+ .identifier
+ .parse()
+ .map_err(|error| format!("invalid Bluesky identifier: {error}"))?;
+ let cutoff = Datetime::new((Utc::now() - Duration::hours(1)).fixed_offset());
+
+ let agent = BskyAgent::builder()
+ .build()
+ .await
+ .map_err(|error| format!("failed to create Bluesky client: {error}"))?;
+ agent
+ .login(credentials.identifier, credentials.password)
+ .await
+ .map_err(|error| format!("failed to log in to Bluesky: {error}"))?;
+
+ let mut cursor = None;
+ let mut posts = Vec::new();
+ let mut reached_cutoff = false;
+
+ while !reached_cutoff {
+ let output = agent
+ .api
+ .com
+ .atproto
+ .repo
+ .list_records(
+ ParametersData {
+ collection: "app.bsky.feed.post".parse().unwrap(),
+ cursor,
+ limit: Some(100.try_into().unwrap()),
+ repo: repo.clone(),
+ // listRecords is newest-first by default.
+ reverse: None,
+ }
+ .into(),
+ )
+ .await
+ .map_err(|error| format!("failed to list posts: {error}"))?;
+
+ let OutputData {
+ records,
+ cursor: next_cursor,
+ } = output.data;
+ for record in records {
+ let record = record.data;
+ let post = RecordData::try_from_unknown(record.value)
+ .map_err(|error| format!("failed to parse post {}: {error}", record.uri))?;
+ if post.created_at < cutoff {
+ reached_cutoff = true;
+ break;
+ }
+ posts.push((record.uri, post.text));
+ }
+
+ cursor = next_cursor;
+ if cursor.is_none() {
+ break;
+ }
+ }
+
+ if posts.is_empty() {
+ println!("No posts from the last hour.");
+ return Ok(());
+ }
+
+ for (uri, text) in &posts {
+ if args.dry {
+ println!("Would delete {uri}: {text}");
+ } else {
+ tracing::info!(%uri, "deleting post");
+ agent
+ .delete_record(uri)
+ .await
+ .map_err(|error| format!("failed to delete {uri}: {error}"))?;
+ println!("Deleted {uri}: {text}");
+ }
+ }
+
+ if args.dry {
+ println!("{} post(s) would be deleted.", posts.len());
+ } else {
+ println!("Deleted {} post(s).", posts.len());
+ }
+
+ Ok(())
+}
+
+fn load_credentials() -> Result<Credentials, String> {
+ let path = env::var("BSKY_CREDENTIALS_FILE")
+ .map_err(|_| "BSKY_CREDENTIALS_FILE is not set".to_owned())?;
+ let data = fs::read(&path).map_err(|error| format!("failed to read {path}: {error}"))?;
+ serde_json::from_slice(&data).map_err(|error| format!("failed to parse {path}: {error}"))
+}
diff --git a/src/bots/fdroid/index.rs b/src/bots/fdroid/index.rs
new file mode 100644
index 0000000..61cc44d
--- /dev/null
+++ b/src/bots/fdroid/index.rs
@@ -0,0 +1,97 @@
+//! Types for the F-Droid repository index.
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Deserializer};
+
+pub(super) const PHONE_DEVICE_TYPE: &str = "phone";
+pub(super) const EN_LOCALE: &str = "en-US";
+
+#[derive(Debug, Deserialize)]
+pub(super) struct Index {
+ pub(super) repo: Repo,
+ pub(super) packages: HashMap<String, Package>,
+}
+
+#[derive(Debug, Deserialize)]
+pub(super) struct Repo {
+ pub(super) timestamp: u64,
+}
+
+#[derive(Debug, Deserialize)]
+pub(super) struct Package {
+ pub(super) metadata: Metadata,
+ /// Releases keyed by the APK's SHA-256 digest.
+ pub(super) versions: HashMap<String, Version>,
+}
+
+#[derive(Debug, Deserialize)]
+pub(super) struct Version {
+ /// Anti-feature explanations keyed first by anti-feature name and then by locale.
+ #[serde(default, rename = "antiFeatures")]
+ pub(super) anti_features: HashMap<AntiFeature, HashMap<String, String>>,
+}
+
+/// An [anti-feature](https://f-droid.org/docs/Anti-Features/).
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub(super) enum AntiFeature {
+ Ads,
+ DisabledAlgorithm,
+ KnownVuln,
+ NoSourceSince,
+ NonFreeAdd,
+ NonFreeAssets,
+ NonFreeDep,
+ NonFreeNet,
+ TetheredNet,
+ Tracking,
+ Other(String),
+}
+
+impl<'de> Deserialize<'de> for AntiFeature {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let name = String::deserialize(deserializer)?;
+ Ok(match name.as_str() {
+ "Ads" => Self::Ads,
+ "DisabledAlgorithm" => Self::DisabledAlgorithm,
+ "KnownVuln" => Self::KnownVuln,
+ "NoSourceSince" => Self::NoSourceSince,
+ "NonFreeAdd" => Self::NonFreeAdd,
+ "NonFreeAssets" => Self::NonFreeAssets,
+ "NonFreeDep" => Self::NonFreeDep,
+ "NonFreeNet" => Self::NonFreeNet,
+ "TetheredNet" => Self::TetheredNet,
+ "Tracking" => Self::Tracking,
+ _ => {
+ tracing::warn!("observed unknown F-Droid anti-feature {name}");
+ Self::Other(name)
+ }
+ })
+ }
+}
+
+#[derive(Debug, Deserialize)]
+pub(super) struct Metadata {
+ pub(super) added: u64,
+ /// App names keyed by locale. Every package has an `en-US` name.
+ #[serde(default)]
+ pub(super) name: HashMap<String, String>,
+ /// Short app descriptions keyed by locale.
+ #[serde(default)]
+ pub(super) summary: HashMap<String, String>,
+ /// Screenshots keyed first by device type and then by locale.
+ #[serde(default)]
+ pub(super) screenshots: HashMap<String, HashMap<String, Vec<Screenshot>>>,
+}
+
+#[derive(Debug, Deserialize)]
+pub(super) struct Screenshot {
+ /// Path to the screenshot relative to the repository address.
+ pub(super) name: String,
+ /// Screenshot file size in bytes, retained for enforcing upload limits.
+ #[allow(dead_code)]
+ pub(super) size: u64,
+}
diff --git a/src/bots/fdroid/mod.rs b/src/bots/fdroid/mod.rs
new file mode 100644
index 0000000..c0e7198
--- /dev/null
+++ b/src/bots/fdroid/mod.rs
@@ -0,0 +1,572 @@
+//! F-Droid
+
+mod index;
+
+use atrium_api::app::bsky::richtext::facet::{ByteSliceData, LinkData, MainData, MainFeaturesItem};
+use atrium_api::types::Union;
+use reqwest::{
+ StatusCode,
+ header::{ETAG, IF_NONE_MATCH},
+};
+use serde::{Deserialize, Serialize};
+use unicode_segmentation::UnicodeSegmentation;
+
+use self::index::{AntiFeature, EN_LOCALE, Index, PHONE_DEVICE_TYPE, Package, Screenshot};
+use crate::{
+ MAX_IMAGE_SIZE, MAX_POST_GRAPHEMES, MAX_POST_IMAGES, http_client,
+ report::{Post, PostImage, Report},
+};
+
+const REPOSITORY_URL: &str = "https://f-droid.org/repo";
+const PACKAGE_URL_PREFIX: &str = "https://f-droid.org/packages/";
+const ANTI_FEATURES_EXCLUDING_APPS: &[AntiFeature] =
+ &[AntiFeature::KnownVuln, AntiFeature::NoSourceSince];
+
+#[derive(Debug, Default, Deserialize, Serialize)]
+pub struct BreakingState {
+ pub last_repo_timestamp: Option<u64>,
+ pub index_etag: Option<String>,
+}
+
+pub struct Breaking;
+
+impl Report for Breaking {
+ type State = BreakingState;
+
+ const STATE_FILE: &'static str = "fdroid-breaking.json";
+
+ async fn generate(state: Self::State) -> Result<(Vec<Post>, Self::State), String> {
+ let mut request = http_client().get(format!("{REPOSITORY_URL}/index-v2.json"));
+ if let Some(etag) = state.index_etag.as_deref() {
+ request = request.header(IF_NONE_MATCH, etag);
+ }
+
+ let response = request
+ .send()
+ .await
+ .map_err(|error| format!("failed to fetch F-Droid index: {error}"))?;
+ if response.status() == StatusCode::NOT_MODIFIED {
+ return Ok((Vec::new(), state));
+ }
+
+ let response = response
+ .error_for_status()
+ .map_err(|error| format!("failed to fetch F-Droid index: {error}"))?;
+ let index_etag = response
+ .headers()
+ .get(ETAG)
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
+ let index = response
+ .json::<Index>()
+ .await
+ .map_err(|error| format!("failed to parse F-Droid index: {error}"))?;
+
+ let Some(timestamp) = state.last_repo_timestamp else {
+ // We don't know the timestamp of the last index version.
+ tracing::info!(
+ timestamp = index.repo.timestamp,
+ "recording initial repository timestamp; will announce new apps with the next index version"
+ );
+ let new_state = BreakingState {
+ last_repo_timestamp: Some(index.repo.timestamp),
+ index_etag,
+ };
+ return Ok((Vec::new(), new_state));
+ };
+
+ if index.repo.timestamp < timestamp {
+ let error = format!(
+ "F-Droid repository timestamp moved backwards from {timestamp} to {}",
+ index.repo.timestamp
+ );
+ tracing::error!("{error}");
+ return Err(error);
+ }
+
+ let mut apps: Vec<_> = index
+ .packages
+ .iter()
+ .filter_map(|(id, package)| {
+ (package.metadata.added > timestamp)
+ .then(|| App::from_package(id.as_str(), package))
+ })
+ .collect();
+
+ tracing::info!(
+ timestamp = index.repo.timestamp,
+ new_apps = apps.len(),
+ total_apps = index.packages.len(),
+ "building posts"
+ );
+
+ apps.sort_unstable_by(|left, right| {
+ left.name
+ .to_lowercase()
+ .cmp(&right.name.to_lowercase())
+ .then_with(|| left.id.cmp(right.id))
+ });
+
+ Ok((
+ download_images(build_announcement_posts(&apps)).await?,
+ BreakingState {
+ last_repo_timestamp: Some(index.repo.timestamp),
+ index_etag,
+ },
+ ))
+ }
+}
+
+struct App<'a> {
+ id: &'a str,
+ name: &'a str,
+ summary: &'a str,
+ screenshot: Option<&'a Screenshot>,
+ anti_features: Vec<AntiFeature>,
+}
+
+impl<'a> App<'a> {
+ fn from_package(id: &'a str, package: &'a Package) -> Self {
+ Self {
+ id,
+ name: package
+ .metadata
+ .name
+ .get(EN_LOCALE)
+ .map_or(id, String::as_str),
+ summary: package
+ .metadata
+ .summary
+ .get(EN_LOCALE)
+ .map_or("", String::as_str),
+ screenshot: package
+ .metadata
+ .screenshots
+ .get(PHONE_DEVICE_TYPE)
+ .and_then(|screenshots| screenshots.get(EN_LOCALE))
+ .and_then(|screenshots| {
+ screenshots
+ .iter()
+ // We could resize images but realistically they should fit.
+ .find(|screenshot| screenshot.size < MAX_IMAGE_SIZE)
+ }),
+ anti_features: package
+ .versions
+ .values()
+ .flat_map(|version| version.anti_features.keys())
+ // "Tethered network service" means an app depends on a network service,
+ // which isn't easy to self-host. There's a lot of such network services out there
+ // be it OpenStreetMap or a public transit API ... not much apps can do about that.
+ // So we just disregard this anti-feature in this bot.
+ .filter(|&anti_feature| anti_feature != &AntiFeature::TetheredNet)
+ .cloned()
+ .collect(),
+ }
+ }
+}
+
+fn build_announcement_posts(apps: &[App<'_>]) -> Vec<PendingPost> {
+ let apps: Vec<_> = apps
+ .iter()
+ .filter(|app| {
+ let Some(anti_feature) = ANTI_FEATURES_EXCLUDING_APPS
+ .iter()
+ .find(|anti_feature| app.anti_features.contains(anti_feature))
+ else {
+ return true;
+ };
+
+ tracing::info!(
+ app_id = app.id,
+ app_name = app.name,
+ "omitting F-Droid app due to anti-feature {anti_feature:?}"
+ );
+ false
+ })
+ .collect();
+
+ if apps.is_empty() {
+ return Vec::new();
+ }
+
+ let mut builders = vec![PostBuilder::new(format!(
+ "{} new F-Droid packages are available:",
+ apps.len()
+ ))];
+
+ let (without_anti_features, with_anti_features): (Vec<_>, Vec<_>) = apps
+ .into_iter()
+ .partition(|app| app.anti_features.is_empty());
+ let (with_screenshots, without_screenshots): (Vec<_>, Vec<_>) = without_anti_features
+ .into_iter()
+ .partition(|app| app.screenshot.is_some());
+
+ for app in with_screenshots {
+ let name = truncate(app.name, MAX_POST_GRAPHEMES);
+ let screenshot = app.screenshot.unwrap();
+ let image = PendingImage {
+ url: format!("{REPOSITORY_URL}{}", screenshot.name),
+ // Using the summary for the alt text isn't great
+ // but the Bluesky lexicon provides no alternative.
+ alt: format!("{}: {}", app.name, app.summary),
+ };
+
+ let post = builders.last().unwrap();
+ let separator = if post.text.is_empty() {
+ ""
+ } else if post.images.is_empty() {
+ "\n"
+ } else {
+ ", "
+ };
+ if !post.can_fit(separator, &name) || post.images.len() == MAX_POST_IMAGES {
+ builders.push(PostBuilder::new(String::new()));
+ }
+
+ let post = builders.last_mut().unwrap();
+ let separator = if post.text.is_empty() {
+ ""
+ } else if post.images.is_empty() {
+ "\n"
+ } else {
+ ", "
+ };
+ post.push_app(separator, &name, &name, app.id, Some(image));
+ }
+
+ for app in without_screenshots {
+ push_bullet_app(&mut builders, app);
+ }
+
+ if let Some((first, rest)) = with_anti_features.split_first() {
+ const ANTI_FEATURES_HEADING: &str =
+ "New apps with an anti-feature (other than tethered network):";
+ let line_limit =
+ MAX_POST_GRAPHEMES - grapheme_count(ANTI_FEATURES_HEADING) - grapheme_count("\n");
+ let (name, line) = app_line(first, line_limit);
+
+ // Keep anti-feature apps in their own section, starting at a post boundary.
+ builders.push(PostBuilder::new(ANTI_FEATURES_HEADING.to_owned()));
+ builders
+ .last_mut()
+ .unwrap()
+ .push_app("\n", &line, &name, first.id, None);
+
+ for app in rest {
+ push_bullet_app(&mut builders, app);
+ }
+ }
+
+ builders.into_iter().map(PostBuilder::finish).collect()
+}
+
+fn push_bullet_app(builders: &mut Vec<PostBuilder>, app: &App<'_>) {
+ let (name, line) = app_line(app, MAX_POST_GRAPHEMES);
+ let post = builders.last().unwrap();
+ let separator = if post.text.is_empty() { "" } else { "\n" };
+ if !post.can_fit(separator, &line) {
+ builders.push(PostBuilder::new(String::new()));
+ }
+
+ let post = builders.last_mut().unwrap();
+ let separator = if post.text.is_empty() { "" } else { "\n" };
+ post.push_app(separator, &line, &name, app.id, None);
+}
+
+/// Build a bullet that fits within `limit`, preserving as much of the summary as possible.
+fn app_line(app: &App<'_>, limit: usize) -> (String, String) {
+ let prefix = "* ";
+ let anti_features = anti_feature_annotation(&app.anti_features);
+ let suffix = ": ";
+ let fixed = grapheme_count(prefix)
+ + grapheme_count(app.name)
+ + grapheme_count(&anti_features)
+ + grapheme_count(suffix);
+
+ if fixed <= limit {
+ let summary = truncate(app.summary, limit - fixed);
+ (
+ app.name.to_owned(),
+ format!("{prefix}{}{anti_features}{suffix}{summary}", app.name),
+ )
+ } else {
+ let reserved = grapheme_count(prefix) + grapheme_count(&anti_features);
+ let name = truncate(app.name, limit.saturating_sub(reserved));
+ let line = format!("{prefix}{name}{anti_features}");
+ (name, line)
+ }
+}
+
+fn anti_feature_annotation(anti_features: &[AntiFeature]) -> String {
+ if anti_features.is_empty() {
+ return String::new();
+ }
+
+ let mut icons: Vec<_> = anti_features
+ .iter()
+ .map(|anti_feature| match anti_feature {
+ AntiFeature::Tracking => (0, "๐Ÿ‘๏ธ"),
+ AntiFeature::Ads => (1, "๐Ÿชง"),
+ AntiFeature::NonFreeNet => (2, "โ˜๏ธ"),
+ AntiFeature::NonFreeAssets => (3, "๐Ÿ–ผ๏ธ"),
+ AntiFeature::NonFreeAdd => (4, "๐Ÿงฉ"),
+ AntiFeature::NonFreeDep => (5, "๐Ÿ“ฆ"),
+ AntiFeature::DisabledAlgorithm => (6, "๐Ÿ”“"), // shouldn't happen for new apps
+ AntiFeature::Other(_) => (7, "โ“"),
+ AntiFeature::KnownVuln | AntiFeature::NoSourceSince | AntiFeature::TetheredNet => {
+ unreachable!()
+ }
+ })
+ .collect();
+ icons.sort_unstable_by_key(|(order, _)| *order);
+ icons.dedup_by_key(|(order, _)| *order);
+
+ format!(
+ " ({})",
+ icons
+ .into_iter()
+ .map(|(_, icon)| icon)
+ .collect::<Vec<_>>()
+ .join("")
+ )
+}
+
+fn truncate(text: &str, limit: usize) -> String {
+ let length = grapheme_count(text);
+ if length <= limit {
+ return text.to_owned();
+ }
+ if limit == 0 {
+ return String::new();
+ }
+
+ let mut shortened: String = text.graphemes(true).take(limit - 1).collect();
+ shortened.push('โ€ฆ');
+ shortened
+}
+
+fn grapheme_count(text: &str) -> usize {
+ text.graphemes(true).count()
+}
+
+struct PendingPost {
+ text: String,
+ facets: Vec<atrium_api::app::bsky::richtext::facet::Main>,
+ images: Vec<PendingImage>,
+}
+
+struct PendingImage {
+ url: String,
+ alt: String,
+}
+
+struct PostBuilder {
+ text: String,
+ graphemes: usize,
+ facets: Vec<atrium_api::app::bsky::richtext::facet::Main>,
+ images: Vec<PendingImage>,
+}
+
+impl PostBuilder {
+ fn new(text: String) -> Self {
+ let graphemes = grapheme_count(&text);
+ Self {
+ text,
+ graphemes,
+ facets: Vec::new(),
+ images: Vec::new(),
+ }
+ }
+
+ fn can_fit(&self, separator: &str, text: &str) -> bool {
+ self.graphemes + grapheme_count(separator) + grapheme_count(text) <= MAX_POST_GRAPHEMES
+ }
+
+ fn push_app(
+ &mut self,
+ separator: &str,
+ line: &str,
+ name: &str,
+ id: &str,
+ image: Option<PendingImage>,
+ ) {
+ self.text.push_str(separator);
+ let byte_start = self.text.len() + line.find(name).unwrap_or(0);
+ self.text.push_str(line);
+ let byte_end = byte_start + name.len();
+ self.graphemes += grapheme_count(separator) + grapheme_count(line);
+
+ self.facets.push(
+ MainData {
+ features: vec![Union::Refs(MainFeaturesItem::Link(Box::new(
+ LinkData {
+ uri: format!("{PACKAGE_URL_PREFIX}{id}/"),
+ }
+ .into(),
+ )))],
+ index: ByteSliceData {
+ byte_start,
+ byte_end,
+ }
+ .into(),
+ }
+ .into(),
+ );
+ if let Some(image) = image {
+ self.images.push(image);
+ }
+ }
+
+ fn finish(self) -> PendingPost {
+ PendingPost {
+ text: self.text,
+ facets: self.facets,
+ images: self.images,
+ }
+ }
+}
+
+async fn download_images(pending_posts: Vec<PendingPost>) -> Result<Vec<Post>, String> {
+ let client = http_client();
+ let mut posts = Vec::with_capacity(pending_posts.len());
+
+ for (post_index, post) in pending_posts.into_iter().enumerate() {
+ let mut images = Vec::with_capacity(post.images.len());
+ for image in post.images {
+ let data = client
+ .get(&image.url)
+ .send()
+ .await
+ .and_then(reqwest::Response::error_for_status)
+ .map_err(|error| {
+ format!(
+ "failed to download image {} for post {}: {error}",
+ image.url,
+ post_index + 1
+ )
+ })?
+ .bytes()
+ .await
+ .map_err(|error| {
+ format!(
+ "failed to download image {} for post {}: {error}",
+ image.url,
+ post_index + 1
+ )
+ })?;
+ images.push(PostImage {
+ data: data.to_vec(),
+ alt: image.alt,
+ });
+ }
+ posts.push(Post {
+ text: post.text,
+ facets: post.facets,
+ images,
+ });
+ }
+
+ Ok(posts)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn builds_announcement_posts() {
+ assert!(build_announcement_posts(&[]).is_empty());
+
+ let screenshot = Screenshot {
+ name: "/screenshots/camera.png".to_owned(),
+ size: 123,
+ };
+ let apps = [
+ App {
+ id: "org.example.camera",
+ name: "Camera ๐Ÿ“ท",
+ summary: "Take photos",
+ screenshot: Some(&screenshot),
+ anti_features: Vec::new(),
+ },
+ App {
+ id: "org.example.notes",
+ name: "Notes",
+ summary: "Take notes",
+ screenshot: None,
+ anti_features: Vec::new(),
+ },
+ App {
+ id: "org.example.tracker",
+ name: "Tracker",
+ summary: "Tracks users",
+ screenshot: None,
+ anti_features: vec![
+ AntiFeature::Other("UnknownFeature".to_owned()),
+ AntiFeature::Tracking,
+ AntiFeature::NonFreeNet,
+ AntiFeature::NonFreeDep,
+ AntiFeature::NonFreeAssets,
+ AntiFeature::NonFreeAdd,
+ AntiFeature::DisabledAlgorithm,
+ AntiFeature::Ads,
+ ],
+ },
+ App {
+ id: "org.example.vulnerable",
+ name: "Vulnerable",
+ summary: "Has a known vulnerability",
+ screenshot: None,
+ anti_features: vec![AntiFeature::KnownVuln],
+ },
+ App {
+ id: "org.example.abandoned",
+ name: "Abandoned",
+ summary: "No longer has source available",
+ screenshot: None,
+ anti_features: vec![AntiFeature::NoSourceSince],
+ },
+ ];
+
+ let posts = build_announcement_posts(&apps);
+
+ assert_eq!(posts.len(), 2);
+ assert_eq!(
+ posts[0].text,
+ "3 new F-Droid packages are available:\n\
+ Camera ๐Ÿ“ท\n\
+ * Notes: Take notes"
+ );
+ assert_eq!(
+ posts[1].text,
+ "New apps with an anti-feature (other than tethered network):\n\
+ * Tracker (๐Ÿ‘๏ธ๐Ÿชงโ˜๏ธ๐Ÿ–ผ๏ธ๐Ÿงฉ๐Ÿ“ฆ๐Ÿ”“โ“): Tracks users"
+ );
+ assert_eq!(posts[0].images.len(), 1);
+ assert_eq!(
+ posts[0].images[0].url,
+ "https://f-droid.org/repo/screenshots/camera.png"
+ );
+ assert_eq!(posts[0].images[0].alt, "Camera ๐Ÿ“ท: Take photos");
+ assert!(posts[1].images.is_empty());
+
+ let expected_links = [
+ (&posts[0], "Camera ๐Ÿ“ท", "org.example.camera"),
+ (&posts[0], "Notes", "org.example.notes"),
+ (&posts[1], "Tracker", "org.example.tracker"),
+ ];
+ for (post, name, id) in expected_links {
+ let facet = post
+ .facets
+ .iter()
+ .find(|facet| &post.text[facet.index.byte_start..facet.index.byte_end] == name)
+ .unwrap();
+ match facet.features.as_slice() {
+ [Union::Refs(MainFeaturesItem::Link(link))] => {
+ assert_eq!(link.uri, format!("{PACKAGE_URL_PREFIX}{id}/"));
+ }
+ features => panic!("expected one link feature, got {features:?}"),
+ }
+ }
+ }
+}
diff --git a/src/bots/mod.rs b/src/bots/mod.rs
new file mode 100644
index 0000000..63414d3
--- /dev/null
+++ b/src/bots/mod.rs
@@ -0,0 +1 @@
+pub mod fdroid;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0a5b264
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,224 @@
+mod bots;
+mod report;
+
+use std::{env, fs, num::NonZeroU64};
+
+use atrium_api::app::bsky::embed::defs::AspectRatioData;
+use atrium_api::app::bsky::embed::gallery::{ImageData, MainData as GalleryData, MainItemsItem};
+use atrium_api::app::bsky::feed::post::{RecordData, RecordEmbedRefs, ReplyRefData};
+use atrium_api::com::atproto::repo::strong_ref::{Main as StrongRef, MainData as StrongRefData};
+use atrium_api::types::{Union, string::Datetime};
+use bsky_sdk::BskyAgent;
+use clap::{Parser, ValueEnum};
+use report::{Post, Report};
+use serde::Deserialize;
+
+pub(crate) const MAX_POST_GRAPHEMES: usize = 300;
+pub(crate) const MAX_POST_IMAGES: usize = 10;
+pub(crate) const MAX_IMAGE_SIZE: u64 = 2_000_000;
+
+#[derive(Debug, Parser)]
+struct Args {
+ bot: Bot,
+ report_kind: ReportKind,
+
+ #[arg(long)]
+ dry: bool,
+}
+
+#[derive(Clone, Debug, ValueEnum)]
+enum Bot {
+ #[value(name = "fdroid")]
+ FDroid,
+}
+
+#[derive(Clone, Debug, ValueEnum)]
+enum ReportKind {
+ Breaking,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), String> {
+ tracing_subscriber::fmt::init();
+
+ let args = Args::parse();
+ let credentials = (!args.dry).then(load_credentials).transpose()?;
+
+ let (posts, update_state) = match (args.bot, args.report_kind) {
+ (Bot::FDroid, ReportKind::Breaking) => bots::fdroid::Breaking::report().await?,
+ };
+
+ if args.dry {
+ let post_count = posts.len();
+ if posts.is_empty() {
+ println!("No posts to publish.");
+ }
+ for (index, post) in posts.into_iter().enumerate() {
+ if index > 0 {
+ println!();
+ }
+ println!("Post {}/{}:\n{}", index + 1, post_count, post.text);
+ for image in post.images {
+ println!("Image: {}", image.alt);
+ }
+ }
+ return Ok(());
+ }
+
+ if !posts.is_empty() {
+ publish(posts, credentials.unwrap()).await?;
+ }
+
+ // Do not advance the state unless every post was published successfully.
+ update_state()
+}
+
+#[derive(Deserialize)]
+struct Credentials {
+ identifier: String,
+ password: String,
+}
+
+fn load_credentials() -> Result<Credentials, String> {
+ let path = env::var("BSKY_CREDENTIALS_FILE").map_err(|_| {
+ "BSKY_CREDENTIALS_FILE is not set; use --dry to run without publishing".to_owned()
+ })?;
+ let data = fs::read(&path).map_err(|error| format!("failed to read {path}: {error}"))?;
+ serde_json::from_slice(&data).map_err(|error| format!("failed to parse {path}: {error}"))
+}
+
+async fn publish(posts: Vec<Post>, credentials: Credentials) -> Result<(), String> {
+ let agent = BskyAgent::builder()
+ .build()
+ .await
+ .map_err(|error| format!("failed to create Bluesky client: {error}"))?;
+ agent
+ .login(credentials.identifier, credentials.password)
+ .await
+ .map_err(|error| format!("failed to log in to Bluesky: {error}"))?;
+
+ let post_count = posts.len();
+ let image_count: usize = posts.iter().map(|post| post.images.len()).sum();
+ let mut uploaded_image_count = 0;
+ let mut records = Vec::with_capacity(post_count);
+
+ for (index, post) in posts.into_iter().enumerate() {
+ let embed = if post.images.is_empty() {
+ None
+ } else {
+ let mut images = Vec::with_capacity(post.images.len());
+ for (image_index, image) in post.images.into_iter().enumerate() {
+ let dimensions = imagesize::blob_size(&image.data).map_err(|error| {
+ format!(
+ "failed to determine dimensions of image {} for post {}: {error}",
+ image_index + 1,
+ index + 1
+ )
+ })?;
+ let aspect_ratio = AspectRatioData {
+ width: NonZeroU64::new(dimensions.width as u64).ok_or_else(|| {
+ format!(
+ "image {} for post {} has zero width",
+ image_index + 1,
+ index + 1
+ )
+ })?,
+ height: NonZeroU64::new(dimensions.height as u64).ok_or_else(|| {
+ format!(
+ "image {} for post {} has zero height",
+ image_index + 1,
+ index + 1
+ )
+ })?,
+ }
+ .into();
+ uploaded_image_count += 1;
+ tracing::info!(
+ image = uploaded_image_count,
+ images = image_count,
+ "uploading image"
+ );
+ let blob = agent
+ .api
+ .com
+ .atproto
+ .repo
+ .upload_blob(image.data)
+ .await
+ .map_err(|error| {
+ format!(
+ "failed to upload image {} for post {}: {error}",
+ image_index + 1,
+ index + 1
+ )
+ })?;
+ images.push(Union::Refs(MainItemsItem::Image(Box::new(
+ ImageData {
+ alt: image.alt,
+ aspect_ratio,
+ image: blob.data.blob,
+ }
+ .into(),
+ ))));
+ }
+ Some(Union::Refs(RecordEmbedRefs::AppBskyEmbedGalleryMain(
+ Box::new(GalleryData { items: images }.into()),
+ )))
+ };
+
+ records.push(RecordData {
+ created_at: Datetime::now(),
+ text: post.text,
+ facets: Some(post.facets),
+ embed,
+ entities: None,
+ labels: None,
+ langs: None,
+ reply: None,
+ tags: None,
+ });
+ }
+
+ let mut root: Option<StrongRef> = None;
+ let mut parent: Option<StrongRef> = None;
+ for (index, mut record) in records.into_iter().enumerate() {
+ if let Some(parent) = parent.as_ref() {
+ record.reply = Some(
+ ReplyRefData {
+ parent: parent.clone(),
+ root: root.as_ref().unwrap().clone(),
+ }
+ .into(),
+ );
+ }
+
+ tracing::info!(post = index + 1, posts = post_count, "creating post");
+ let output = agent
+ .create_record(record)
+ .await
+ .map_err(|error| format!("failed to publish post {}: {error}", index + 1))?;
+ tracing::info!(
+ post = index + 1,
+ posts = post_count,
+ uri = %output.uri,
+ cid = ?output.cid,
+ "created post"
+ );
+ let reference: StrongRef = StrongRefData {
+ uri: output.uri.clone(),
+ cid: output.cid.clone(),
+ }
+ .into();
+ root.get_or_insert_with(|| reference.clone());
+ parent = Some(reference);
+ }
+
+ Ok(())
+}
+
+pub(crate) fn http_client() -> reqwest::Client {
+ reqwest::Client::builder()
+ .user_agent(env!("CARGO_PKG_NAME"))
+ .build()
+ .unwrap()
+}
diff --git a/src/report.rs b/src/report.rs
new file mode 100644
index 0000000..d776469
--- /dev/null
+++ b/src/report.rs
@@ -0,0 +1,57 @@
+use std::{fs, io, path::Path};
+
+use atrium_api::app::bsky::richtext::facet::Main as Facet;
+use serde::{Serialize, de::DeserializeOwned};
+
+pub(crate) struct Post {
+ pub text: String,
+ pub facets: Vec<Facet>,
+ /// Images to upload before creating the post record.
+ pub images: Vec<PostImage>,
+}
+
+pub(crate) struct PostImage {
+ pub data: Vec<u8>,
+ pub alt: String,
+}
+
+pub(crate) trait Report: Sized {
+ type State: Default + DeserializeOwned + Serialize;
+
+ const STATE_FILE: &'static str;
+
+ async fn generate(state: Self::State) -> Result<(Vec<Post>, Self::State), String>;
+
+ async fn report() -> Result<(Vec<Post>, impl FnOnce() -> Result<(), String>), String> {
+ let state_path = Path::new(Self::STATE_FILE);
+ let state = load_state(state_path)?;
+ let (posts, new_state) = Self::generate(state).await?;
+ Ok((posts, move || save_state(state_path, &new_state)))
+ }
+}
+
+fn load_state<T>(path: &Path) -> Result<T, String>
+where
+ T: Default + DeserializeOwned,
+{
+ match fs::read(path) {
+ Ok(data) => serde_json::from_slice(&data)
+ .map_err(|error| format!("failed to parse {}: {error}", path.display())),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(T::default()),
+ Err(error) => Err(format!("failed to read {}: {error}", path.display())),
+ }
+}
+
+fn save_state<T>(path: &Path, state: &T) -> Result<(), String>
+where
+ T: Serialize,
+{
+ let data = serde_json::to_vec_pretty(state)
+ .map_err(|error| format!("failed to serialize {}: {error}", path.display()))?;
+ let temporary_path = path.with_extension("json.tmp");
+
+ fs::write(&temporary_path, data)
+ .map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?;
+ fs::rename(&temporary_path, path)
+ .map_err(|error| format!("failed to replace {}: {error}", path.display()))
+}