feather-reader 0.4.4

A minimalist, atproto-native RSS/Atom reader in Rust — your feed subscriptions live in your own PDS.
Documentation
[package]
name = "feather-reader"
version = "0.4.4"
edition = "2021"
rust-version = "1.94"
description = "A minimalist, atproto-native RSS/Atom reader in Rust — your feed subscriptions live in your own PDS."
authors = ["Justin Stanley"]
license = "AGPL-3.0-only"
repository = "https://github.com/justin-stanley/feather-reader"
homepage = "https://feather-reader.com"
readme = "README.md"
keywords = ["rss", "atom", "atproto", "feed", "reader"]
categories = ["web-programming"]

# The crate ships a small library (shared types + module seams) and the server binary.
[lib]
name = "feather_reader"
path = "src/lib.rs"

[[bin]]
name = "featherreader"
path = "src/main.rs"

[dependencies]
# --- async runtime -------------------------------------------------------
tokio = { version = "1.45", features = ["full"] }

# --- web framework + middleware -----------------------------------------
# `multipart` powers the OPML file-upload import (POST /opml).
axum = { version = "0.8", features = ["multipart"] }
# fs = ServeDir/ServeFile for embedded/static assets; trace = request tracing
# layer; set-header = the global security-header layer (CSP + friends).
tower-http = { version = "0.7", features = ["fs", "trace", "set-header"] }

# --- HTTP client (feed fetch) -------------------------------------------
# default-features off to drop the OpenSSL/native-tls path; rustls keeps the
# static-binary story clean. gzip for polite/compressed feed fetches.
reqwest = { version = "0.13", default-features = false, features = ["rustls", "gzip", "json"] }

# --- feed parsing --------------------------------------------------------
feed-rs = "3.0"
# A specified hash for stored dedup keys (`feed::dedup_hasher`); std's
# `DefaultHasher` may change between Rust releases. Already in the tree via
# feed-rs and phf.
siphasher = "1"

# --- HTML sanitization (feeds are hostile input) -------------------------
ammonia = "4"

# --- local per-DID SQLite cache -----------------------------------------
# RUNTIME queries only (no compile-time `query!` macros, so no DATABASE_URL at
# build). sqlx 0.9 splits the old `runtime-tokio-rustls` feature into
# `runtime-tokio` + `tls-rustls-ring`. `sqlite-bundled` links SQLite into the
# binary for the single-file deploy story; `migrate` runs embedded migrations.
sqlx = { version = "0.9", default-features = false, features = [
    "runtime-tokio",
    "tls-rustls-ring",
    "sqlite-bundled",
    "migrate",
    "chrono",
    # `derive` pulls in sqlx-macros so the store's row structs can
    # `#[derive(FromRow)]` — the derive macro, not just the `FromRow` trait.
    "derive",
] }

# --- HTML templating (compile-time, embedded in the binary) --------------
# askama 0.16 dropped the separate `askama_axum` integration crate (deprecated,
# pinned to askama <0.13). `askama_web` provides the axum IntoResponse glue.
askama = "0.16"
askama_web = { version = "0.16", features = ["axum-0.8"] }

# --- serde ---------------------------------------------------------------
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# --- error handling ------------------------------------------------------
anyhow = "1"
thiserror = "2"

# --- tracing -------------------------------------------------------------
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

# --- time / dates --------------------------------------------------------
chrono = { version = "0.4", features = ["serde"] }

# --- URL parsing (feed autodiscovery, canonicalization) ------------------
url = "2"

# --- CSPRNG (opaque, unguessable session ids) ----------------------------
# Already in the dependency tree transitively; pinned here as a direct dep so
# the session-id minting reads from the OS CSPRNG rather than a home-rolled RNG.
getrandom = "0.4"

# --- at-rest encryption for OAuth secrets --------------------------------
# AES-256-GCM + SHA-256 for the `enc.v1.gcm.` codec that wraps OAuth tokens,
# DPoP key material and the confidential-client signing JWK before they touch
# the SQLite volume. `ring` is ALREADY in the tree (rustls pulls it); pinned as
# a direct dep rather than adding a second AEAD implementation. base64 0.22 is
# likewise already present transitively — pin 0.22, NOT 0.21, so the lock keeps
# a single copy (`deny.toml` sets multiple-versions = "warn", which would not
# catch a duplicate).
#
# **That single copy holds today and is about to stop holding.** The invariant
# is maintained by this comment and nothing else — the `warn` above is the
# reason, and it is deliberate. But the version to match is no longer one
# number: `reqwest` 0.13.5 requires base64 **0.23**, while `sqlx-core` 0.9.0 and
# `hyper-util` 0.1.20 still require 0.22. Whichever this is pinned to, the lock
# carries two copies from the moment reqwest is bumped (pending in the
# cargo-minor-patch group PR) — and it arrives through reqwest, without anyone
# editing this line.
#
# When that lands, expect the duplicate and do not chase it. The copy count is
# two either way — pinned at 0.22 we group with sqlx-core and hyper-util, at
# 0.23 with reqwest — so moving the pin buys nothing. There is no correctness
# dimension: this crate only encodes its own bytes to `String` via
# `general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}`, no base64
# type crosses into reqwest's API, and the output is byte-identical across
# versions. Leave it at 0.22 unless a concrete reason appears, and delete this
# paragraph once sqlx-core and hyper-util catch up and one copy is achievable
# again.
ring = "0.17"
base64 = "0.23"
# Constant-time comparison. ring's `verify_slices_are_equal` is deprecated as an
# internal-only helper; `subtle` is the maintained option and is already in the
# tree, so this adds no package to the lock either.
subtle = "2"

# --- P-256 for the OAuth confidential-client signing key -----------------
# The `jwk` feature is the reason for this dep: it gives first-class JWK
# import/export, so the key file stays in the SAME format the Node sidecar
# writes and a rollback can still read it. The alternative was hand-parsing
# PKCS#8 DER out of `ring` to recover the private scalar — more code, in the
# one place where a silent bug is worst.
#
# NOT `atproto-oauth`/`atproto-identity`: their `jwk`/`dpop`/`pkce` primitives
# are exactly what is wanted, but `reqwest` is a NON-optional dependency of
# both, so depending on either would compile a second HTTP stack (reqwest 0.12
# beside our 0.13) plus hickory-dns into the binary — a network path the SSRF
# guard does not cover — for functions that never touch the network. Version
# 0.13 is the same one `atproto-identity` itself pins.
#
# Cost, stated plainly: 20 pure crypto/encoding packages, and it newly
# duplicates `hmac` and `rand_core` (the RustCrypto 0.10-era stack beside the
# newer one already present). `deny.toml` only warns on multiple versions, so
# this is recorded here rather than left for it to catch.
p256 = { version = "0.13", features = ["jwk"] }
# --- DNS, for atproto handle resolution ----------------------------------
# Handle -> DID resolves by `_atproto.<handle>` TXT record, and DNS is the
# PREFERRED method per the handle spec -- not a fallback. This is not optional:
# a live PDS test found a real handle whose /.well-known/atproto-did returns 404
# and which resolves by TXT alone, so an HTTP-only client simply cannot log it
# in. `tokio::net::lookup_host` does A/AAAA only, so a resolver is required.
#
# Worth noting against the p256 comment above, which rejected
# atproto-identity partly for pulling hickory: the objection there was never
# hickory itself but reqwest 0.12 riding along with it -- a SECOND HTTP stack
# the SSRF guard does not cover. A DNS resolver adds no such path.
#
# Cost: 21 packages (incl. `moka`, `crossbeam-*`, `ipconfig`), and it newly
# duplicates `core-foundation`. default-features are already the minimum
# (`tokio` + `system-config`); using the host's own resolver configuration is
# the correct behaviour for handle resolution.
hickory-resolver = { version = "0.26.3", default-features = false, features = [
    "tokio",
    "system-config",
] }

# --- filesystem stat (startup disk-headroom check) -----------------------
# Already in the tree transitively; pinned as a direct dep so the startup
# watermark-vs-disk safety check can call `statvfs(3)` on unix to compare the
# configured DB-size watermark against the DB volume's actual free space. Unix
# only — the target deploys are Linux/Fly and the check no-ops elsewhere.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[dev-dependencies]
# `ServiceExt::oneshot` drives the axum router through a single request in the
# web-layer tests (cache-control headers, rate-limit 429, the pre-handshake
# login refusal, admin-mint authz). Already in the lock transitively; the `util`
# feature exposes `oneshot`.
tower = { version = "0.5", features = ["util"] }

# `#[tokio::test(start_paused = true)]` for the relay-walk budget test: it drives
# tokio's clock so a simulated slow relay costs no real time and the assertion is
# exact rather than timing-dependent. Same tokio crate as the runtime dependency
# above — this enables a feature, it does not add a package to the lock.
tokio = { version = "1.45", features = ["test-util"] }
# A TEST certificate authority, so the test HTTP server can speak real TLS.
#
# The OAuth issuer is required to be `https` (discovery.rs), and the SSRF guard
# refuses loopback — so there was no way to drive the real `complete` against a
# local server, and the adapter wiring between the tested seam and the network
# had no coverage at all. The alternative was a test-only escape from the https
# rule; a real CA SATISFIES the rule instead of suspending it, which is why this
# dependency is here rather than a `#[cfg(test)]` `if scheme != "https"` bypass.
#
# `ring` for the TEST SERVER. reqwest reads the process-installed default and
# falls back to aws-lc-rs, so installing ring in a test DOES affect clients built
# after it in the same binary; release builds install nothing and use aws-lc-rs.
# Both providers are already in the lock — `ring` is a direct dependency of this
# crate and `aws-lc-rs` arrives via rustls — so this adds neither.
rcgen = { version = "0.14.10", default-features = false, features = ["pem", "crypto", "ring"] }

# The server half of that. rustls 0.23 and tokio-rustls are ALREADY in the lock
# transitively via reqwest, so this promotes an existing crate to a direct dev
# dependency rather than adding a second TLS stack — verified: exactly one
# `rustls` package in Cargo.lock.
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }