Files
ipx/crates/directory/src/directory.rs
rays a55f08d4ff Tests for push passes, downloads, OPML round trips and directory refresh
Each against a local stand-in server: Apple's push service answering per token, a feed host
serving a login page and a torrent, a directory refresh of feeds that cannot be read. push.rs
takes the APNs address as a parameter so the test can point it at the stand-in. coverage.sh asks
for 8 GB free, not 9: its container is capped at 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:04:10 +00:00

266 lines
12 KiB
Rust

//! The running directory: its store, the tokens it accepts, the refresher that fetches its feeds, and
//! the reloader that picks up a changed config.
use crate::config::Token;
use crate::wire::{Listing, Me};
use crate::{config, store};
use reqwest::redirect::Policy;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use store::{Refreshed, Store};
use tokio::sync::Notify;
use tracing::Instrument;
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
pub const REFRESH_EVERY: i64 = 12 * 3600;
/// The running directory: its database, the tokens it accepts and the client its refresher
/// fetches feeds with.
pub struct Directory {
pub store: Store,
pub(crate) tokens: RwLock<Vec<Token>>,
pub(crate) client: reqwest::Client,
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
pub(crate) wake: Notify,
/// The public pages' copy of the list, and when it was read.
pub(crate) pages_list: tokio::sync::Mutex<Option<(Instant, Arc<Vec<Listing>>)>>,
}
/// How long the public pages show the same copy of the list.
pub(crate) const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
/// The directory as every handler and task holds it.
pub type Shared = Arc<Directory>;
impl Directory {
/// A directory on `store`, accepting `tokens`, whose refresher may reach the internal
/// addresses `fetch_internal` lists and no others.
pub fn new(
store: Store,
tokens: Vec<Token>,
fetch_internal: ipx_feed::guard::Allowed,
) -> Result<Shared, reqwest::Error> {
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.read_timeout(ipx_feed::FEED_SILENCE)
.redirect(Policy::none())
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
// The feeds are named by whoever holds a write token, and redirect where their
// publishers like: nothing on this network is fetched unless listed (#329).
.connector_layer(ipx_feed::guard::Guard::new(fetch_internal))
.build()?;
Ok(Arc::new(Self {
store,
tokens: RwLock::new(tokens),
client,
wake: Notify::new(),
pages_list: Default::default(),
}))
}
/// The whole list for the public pages, read from the database at most once a minute. Anyone
/// may ask for a page, and each asked the database for the whole catalogue on the pool the
/// refresher and every server's /v1 calls share; a query string of its own got past any
/// cache in front (#341). The lock is held through the read, so a burst waits on one query.
pub async fn pages_list(&self) -> anyhow::Result<Arc<Vec<Listing>>> {
let mut held = self.pages_list.lock().await;
if let Some((at, list)) = held.as_ref()
&& at.elapsed() < PAGES_LIST_FOR
{
return Ok(list.clone());
}
let list = Arc::new(self.store.list().await?);
*held = Some((Instant::now(), list.clone()));
Ok(list)
}
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
pub async fn set_tokens(&self, tokens: Vec<Token>) -> anyhow::Result<u64> {
let names: Vec<String> = tokens.iter().map(|t| t.name.clone()).collect();
*self.tokens.write().expect("a panic elsewhere poisoned this lock") = tokens;
self.store.keep_instances(&names).await
}
pub(crate) fn caller(&self, presented: &str) -> Option<Me> {
let h = config::hash(presented);
// Every token compared, whether or not one matched, so timing says nothing either way.
self.tokens.read().expect("a panic elsewhere poisoned this lock").iter().fold(None, |found, t| {
if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found }
})
}
/// Fetches one feed and stores what it found.
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref())
.await
{
Ok((ipx_feed::Fetched::NotModified, _)) => Refreshed::NotModified,
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
if ipx_feed::is_opml(&bytes) {
// ipx sends the feeds inside an OPML, never the OPML.
Refreshed::Failed("an OPML list, not a feed".into())
} else {
match ipx_feed::parse(&bytes) {
Ok(parsed) => Refreshed::Feed { parsed, etag, last_modified },
Err(e) => Refreshed::Failed(format!("{e:#}")),
}
}
}
Err(e) => Refreshed::Failed(format!("{e:#}")),
};
if let Refreshed::Failed(e) = &got {
crate::metrics::CHECKS_FAILED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
}
// ponytail: a feed that moved for good is still asked for at its old address, which
// redirects; follow the move when a feed's id can change without losing its counts.
self.store.save(&due.id, got).await
}
}
/// Fetches whatever is due, a few at a time, forever.
pub async fn refresher(dir: Shared) {
loop {
match dir.store.due(REFRESH_EVERY, 25).await {
Ok(due) => {
if !due.is_empty() {
// A round is a trace, each feed's check a span in it (#213).
let round = tracing::info_span!("refresh", feeds = due.len());
Instrument::instrument(
async {
// A few at a time: in turn, two publishers timing out held a round
// for a minute and more (#227).
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
let check = tracing::info_span!("feed", feed = %d.id);
async {
if let Err(e) = Instrument::instrument(dir.refresh(d), check).await {
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
}
}
})
.await;
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
tracing::info!(checked = due.len(), "feeds checked");
crate::metrics::CHECKED.fetch_add(due.len() as u64, std::sync::atomic::Ordering::Relaxed);
},
round,
)
.await;
continue;
}
}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not read which feeds are due"),
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
_ = dir.wake.notified() => {}
}
}
}
/// Reads the config file again whenever it changes, checked each minute. A file that fails to
/// load leaves the tokens as they were, so a typo locks nobody out.
pub async fn reloader(dir: Shared, path: PathBuf) {
let mtime = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
let mut last = mtime(&path);
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let now = mtime(&path);
if now == last {
continue;
}
last = now;
match config::Config::load(&path).and_then(|c| c.tokens()) {
Ok(tokens) => {
let n = tokens.len();
match dir.set_tokens(tokens).await {
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped")
}
}
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn scratch() -> Shared {
Directory::new(Store::scratch().await, vec![], Default::default()).unwrap()
}
#[tokio::test]
async fn the_refresher_fetches_nothing_on_this_network() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
let dir = scratch().await;
let err = dir.client.get(format!("http://{addr}/feed.xml")).send().await.unwrap_err();
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
}
#[tokio::test]
async fn the_public_pages_read_the_list_once_a_minute() {
let dir = scratch().await;
let first = dir.pages_list().await.unwrap();
assert!(Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()), "a second page asks the database nothing");
// Once the copy is a minute old, the next page reads the list again.
dir.pages_list.lock().await.as_mut().unwrap().0 -= PAGES_LIST_FOR;
assert!(!Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()));
}
#[tokio::test]
async fn a_refresh_lists_a_feed_and_not_one_that_could_not_be_read() {
use axum::routing::get;
let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel><title>Good Show</title>
<item><title>One</title><guid>g1</guid><enclosure url="https://x/1.mp3" type="audio/mpeg" length="1"/></item>
</channel></rss>"#;
let opml =
r#"<?xml version="1.0"?><opml version="2.0"><head/><body><outline xmlUrl="https://x/f"/></body></opml>"#;
let app = axum::Router::new()
.route("/good.xml", get(move || async move { rss }))
.route("/list.opml", get(move || async move { opml }))
.route("/garbled.xml", get(|| async { "<rss><channel" }))
.route("/gone.xml", get(|| async { axum::http::StatusCode::NOT_FOUND }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let allowed = ipx_feed::guard::Allowed::parse(&["127.0.0.1".into()]).unwrap();
let dir = Directory::new(Store::scratch().await, vec![], allowed).unwrap();
let paths = ["/good.xml", "/list.opml", "/garbled.xml", "/gone.xml"];
let entries: Vec<crate::SnapshotEntry> = paths
.iter()
.map(|p| crate::SnapshotEntry { url: format!("http://{at}{p}"), subscribers: 1, category: None })
.collect();
assert_eq!(dir.store.snapshot("tower", &entries).await.unwrap().accepted, 4);
let due = dir.store.due(REFRESH_EVERY, 10).await.unwrap();
assert_eq!(due.len(), 4);
for d in &due {
dir.refresh(d).await.unwrap();
}
assert!(dir.store.due(REFRESH_EVERY, 10).await.unwrap().is_empty(), "each was checked, failed or not");
let page = |path: &str| {
let d = due.iter().find(|d| d.url.ends_with(path)).unwrap();
dir.store.feed(&d.id)
};
let good = page("/good.xml").await.unwrap().unwrap();
assert_eq!(good.listing.title.as_deref(), Some("Good Show"));
assert!(good.listing.podcast && good.failing.is_none() && good.items.len() == 1);
for bad in ["/list.opml", "/garbled.xml", "/gone.xml"] {
// Never read, so never listed: a page of nothing is no use to anyone browsing.
assert!(page(bad).await.unwrap().is_none(), "{bad} is not listed");
}
}
}