Compare commits

...
Author SHA1 Message Date
archipelago 971d477795 Merge current main and harden paid-download delivery 2026-09-30 07:25:47 -04:00
ssmithxandClaude Opus 5.5 03e38d1ca3 test: regression tests for the paid-download fixes
- mint_client: a stub mint shows swap() sends the full v2 keyset id when
  given a cashuB short id, and leaves complete v1/v2 ids unchanged.
- fips::dial: the single-delivery decisions are now small functions
  (fips_answer_is_final, fips_retryable). Tests cover them and, against a
  silent local peer, check that a single-delivery request isn't resent
  after a timeout while an ordinary one still is.
- content_server: an unreadable paid file returns Unavailable before the
  payment gate runs, and a readable one still returns 402. Also covers
  ensure_readable's grant/reopen behaviour. The podman grant is replaced
  by a refusal under cfg(test) so results don't depend on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:12:16 +00:00
ssmithxandClaude Opus 5.5 e5fc99d66c fix(content): never charge for a file the seller can't serve or replay a spent token
After the keyset-id fix, a Minibits paid download still failed and the
buyer lost the sats. What happened, 2026-09-29, amishparadise:

1. The seller redeemed the token, then failed to read the file. It was a
   FileBrowser upload owned by the container subuid (100999) with mode
   0640. The handler mapped that Err to 404.
2. The buyer's FIPS dial treats 404 as "fall back to Tor" and resent the
   request with the same, now spent, token. The seller answered 402, and
   the buyer showed "seller doesn't accept your Cashu mint".

Fixes:
- serve_content checks the file is readable before the paid gate. If it
  isn't, it grants read with `podman unshare chmod a+r`, which matches
  the other shared files. If that also fails it returns Unavailable (503)
  without taking payment.
- The content handler returns 500 on internal errors and logs them,
  instead of a silent 404.
- New PeerRequest::single_delivery(), used for the paid download: the
  FIPS answer is final, FIPS retries only when it never connected, and
  there's no Tor replay once the request may have been delivered.
- The buyer shows the seller's error text for non-402 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:42:20 +00:00
ssmithxandClaude Opus 5.5 8b74803290 fix(ecash): repair short v2 keyset ids on every swap, not just receive
Paid cloud downloads paid with Minibits ecash were always rejected. The
buyer sends a cashuB token, which carries NUT-02 v2 keyset ids in their
8-byte short form. Minibits rotated its active keyset to a v2 id, and the
seller's verify_and_receive_payment called MintClient::swap directly,
skipping the short->full id repair that only receive_token applied. The
mint answered 422 ("ID length invalid"). The buyer then showed the
misleading "seller doesn't accept your Cashu mint" hint.

Move the repair into swap() so every caller is covered: payment verify,
streaming gate, send change, and cross-mint swaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:47:23 +00:00
5 changed files with 814 additions and 56 deletions
+23 -1
View File
@@ -162,11 +162,33 @@ impl ApiHandler {
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
),
)),
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
),
)),
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(hyper::Body::empty())
.unwrap()),
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
)),
// Not a 404: a paid request may already have been charged by the
// time this fails, and "not found" hid the real error entirely.
Err(e) => {
tracing::error!("Serving content {content_id} failed: {e:#}");
Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"text/plain",
hyper::Body::from("Failed to serve content"),
))
}
}
}
+35 -8
View File
@@ -43,6 +43,25 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
}
}
/// Only pass through the peer's bounded, printable explanation; refund status
/// is always determined locally and must never come from the peer's wording.
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
let reason = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned));
match reason {
Some(reason) if !reason.trim().is_empty() => {
let clean: String = reason
.chars()
.filter(|c| !c.is_control())
.take(240)
.collect();
format!("Seller response ({status}): {clean}")
}
_ => format!("Peer returned an error ({status})."),
}
}
/// Keep first purchases and cached repeats compatible with both existing clients.
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
use base64::Engine;
@@ -623,13 +642,14 @@ impl RpcHandler {
let path = format!("/content/{}", content_id);
// Surface a real reason instead of the generic sanitized error (#30):
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
// here means the peer is genuinely unreachable on both transports.
// A bearer token must not be replayed after an ambiguous delivery.
// A transport error can mean the seller received it without replying.
let (response, transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
.single_delivery()
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
@@ -642,7 +662,7 @@ impl RpcHandler {
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
"error": format!("The purchase could not be completed. {refund}")
}));
}
};
@@ -679,7 +699,7 @@ impl RpcHandler {
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({status}). {refund}")
"error": format!("{} {refund}", seller_error_message(status, &body))
}));
}
@@ -693,10 +713,17 @@ impl RpcHandler {
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(error) => {
tracing::warn!("paid download: response body failed: {error}");
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
}));
}
};
// Persist the purchase so it "stays unlocked" for this buyer: cache the
// bytes + metadata keyed by (onion, content_id). The gallery then renders
@@ -162,3 +162,20 @@ async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
assert_eq!(api.seen.lock().unwrap().len(), expected);
}
}
#[test]
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
assert!(message.starts_with("Seller response (503"));
assert!(message.ends_with("Cannot read file"));
assert!(!message.contains('\n') && !message.contains('\0'));
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
assert!(seller_error_message(status, &body).chars().count() < 300);
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
assert_eq!(
seller_error_message(status, body),
"Peer returned an error (503 Service Unavailable)."
);
}
}
+469 -38
View File
@@ -238,6 +238,11 @@ pub enum ServeResult {
Forbidden,
/// Content not found.
NotFound,
/// The catalog entry and file exist but this node can't read the file.
/// Returned before any payment is taken.
Unavailable,
/// Requested byte range cannot be served; no payment was taken.
RangeNotSatisfiable(u64),
}
/// Serve a content item by ID with access control and optional range request.
@@ -252,6 +257,39 @@ pub async fn serve_content(
range: Option<ByteRange>,
owner_session: bool,
) -> Result<ServeResult> {
serve_content_with(
data_dir,
id,
payment_token,
invoice_hash,
peer_did,
range,
owner_session,
|path, range, mime| prepare_content(data_dir, path, range, mime),
|token, amount| async move { verify_payment_token(data_dir, &token, amount).await },
)
.await
}
// Inject only the read and payment boundaries, so tests can prove ordering
// without mint access, file-permission assumptions or privileged commands.
async fn serve_content_with<R, RF, V, VF>(
data_dir: &Path,
id: &str,
payment_token: Option<&str>,
invoice_hash: Option<&str>,
peer_did: Option<&str>,
range: Option<ByteRange>,
owner_session: bool,
read: R,
verify: V,
) -> Result<ServeResult>
where
R: FnOnce(PathBuf, Option<ByteRange>, String) -> RF,
RF: std::future::Future<Output = Result<ServeResult>>,
V: FnOnce(String, u64) -> VF,
VF: std::future::Future<Output = bool>,
{
let catalog = load_catalog(data_dir).await?;
let item = match catalog.items.iter().find(|i| i.id == id) {
Some(i) => i,
@@ -314,6 +352,29 @@ pub async fn serve_content(
return Ok(ServeResult::NotFound);
}
// Refuse unauthorized viewers before opening or reading any bytes.
if !owner_session && matches!(item.access, AccessControl::PeersOnly) && !is_known_peer {
return Ok(ServeResult::Forbidden);
}
if !owner_session {
if let AccessControl::Paid { price_sats, .. } = &item.access {
if payment_token.is_none() && invoice_hash.is_none() {
return Ok(ServeResult::PaymentRequired(*price_sats));
}
}
}
// Finish all file I/O before consuming bearer payment. Merely opening then
// reopening after charging still lost payments on read errors or deletion.
let prepared = match read(file_path, range, item.mime_type.clone()).await {
Ok(result @ (ServeResult::Ok(..) | ServeResult::Partial { .. })) => result,
Ok(other) => return Ok(other),
Err(error) => {
warn!(content_id = %id, "Cannot prepare shared content: {error:#}");
return Ok(ServeResult::Unavailable);
}
};
// Check access control
if !owner_session {
match &item.access {
@@ -331,7 +392,7 @@ pub async fn serve_content(
"fedimint"
};
if method_accepted(&item.access, method)
&& verify_payment_token(data_dir, token, *price_sats).await
&& verify(token.to_owned(), *price_sats).await
{
authorized = true;
}
@@ -358,55 +419,127 @@ pub async fn serve_content(
}
}
let metadata = fs::metadata(&file_path)
.await
.context("Failed to read file metadata")?;
let total_size = metadata.len();
// Handle range request for streaming
if let Some(range) = range {
let start = range.start.min(total_size.saturating_sub(1));
let end = range
.end
.map(|e| e.min(total_size - 1))
.unwrap_or(total_size - 1);
if start > end || start >= total_size {
return Ok(ServeResult::NotFound);
Ok(prepared)
}
let len = (end - start + 1) as usize;
async fn prepare_content(
data_dir: &Path,
path: PathBuf,
range: Option<ByteRange>,
mime: String,
) -> Result<ServeResult> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(&file_path)
let mut file = match fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(&path)
.await
.context("Failed to open content file")?;
file.seek(std::io::SeekFrom::Start(start))
{
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
return slice_prepared_content(bytes, range, mime);
}
Err(error) => return Err(error).context("Opening shared content"),
};
let metadata = file.metadata().await?;
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
let total = metadata.len();
if let Some(range) = range {
let Some((start, end)) = checked_range(&range, total) else {
return Ok(ServeResult::RangeNotSatisfiable(total));
};
file.seek(std::io::SeekFrom::Start(start)).await?;
let len = usize::try_from(end - start + 1).context("Content range is too large")?;
let mut bytes = vec![0; len];
file.read_exact(&mut bytes)
.await
.context("Failed to seek")?;
let mut buf = vec![0u8; len];
file.read_exact(&mut buf)
.await
.context("Failed to read range")?;
debug!(
"Serving content '{}' range {}-{}/{} ({} bytes)",
id, start, end, total_size, len
);
.context("Reading shared content range")?;
return Ok(ServeResult::Partial {
bytes: buf,
mime_type: item.mime_type.clone(),
bytes,
mime_type: mime,
start,
end,
total: total_size,
total,
});
}
let bytes = fs::read(&file_path)
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.await
.context("Failed to read content file")?;
.context("Reading shared content")?;
Ok(ServeResult::Ok(bytes, mime))
}
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
let last = total.checked_sub(1)?;
let end = range.end.unwrap_or(last).min(last);
(range.start <= end && range.start < total).then_some((range.start, end))
}
fn slice_prepared_content(
bytes: Vec<u8>,
range: Option<ByteRange>,
mime: String,
) -> Result<ServeResult> {
let total = bytes.len() as u64;
match range {
None => Ok(ServeResult::Ok(bytes, mime)),
Some(range) => match checked_range(&range, total) {
Some((start, end)) => Ok(ServeResult::Partial {
bytes: bytes[start as usize..=end as usize].to_vec(),
mime_type: mime,
start,
end,
total,
}),
None => Ok(ServeResult::RangeNotSatisfiable(total)),
},
}
}
/// Read only an explicitly shared, regular file within FileBrowser storage.
/// Do not change its mode or grant world-readable access to paid/private data.
async fn filebrowser_read_path(data_dir: &Path, path: &Path) -> Result<PathBuf> {
let root = fs::canonicalize(data_dir.join("filebrowser")).await?;
let target = fs::canonicalize(path).await?;
anyhow::ensure!(
target.starts_with(&root) && target != root,
"Shared file is outside Files storage"
);
anyhow::ensure!(
fs::metadata(&target).await?.is_file(),
"Shared content is not a regular file"
);
Ok(target)
}
async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec<u8>> {
let path = filebrowser_read_path(data_dir, path).await?;
// Tests exercise the boundary explicitly; they never launch the host Podman.
#[cfg(test)]
{
let _ = path;
anyhow::bail!("Files namespace read disabled in unit tests")
}
#[cfg(not(test))]
{
let output = tokio::time::timeout(
std::time::Duration::from_secs(900),
tokio::process::Command::new("podman")
.args(["unshare", "cat", "--"])
.arg(path)
.kill_on_drop(true)
.output(),
)
.await
.context("Files namespace read timed out")??;
anyhow::ensure!(
output.status.success(),
"Files namespace read failed: {}",
output.status
);
Ok(output.stdout)
}
}
/// Result of attempting to serve a preview.
@@ -729,3 +862,301 @@ mod prune_missing_content_tests {
assert_eq!(reloaded.items[0].id, "present-item");
}
}
#[cfg(test)]
mod paid_read_order_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
async fn fixture(bytes: &[u8]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("content/files"))
.await
.unwrap();
fs::write(dir.path().join("content/files/test.bin"), bytes)
.await
.unwrap();
save_catalog(
dir.path(),
&ContentCatalog {
items: vec![ContentItem {
id: "paid".into(),
filename: "test.bin".into(),
mime_type: "application/octet-stream".into(),
size_bytes: bytes.len() as u64,
description: String::new(),
access: AccessControl::Paid {
price_sats: 10,
accepted: vec!["ecash".into()],
},
availability: Availability::AllPeers,
added_at: "2026-09-30".into(),
}],
},
)
.await
.unwrap();
dir
}
#[tokio::test]
async fn all_read_failures_precede_redemption_even_as_root() {
for kind in [
std::io::ErrorKind::PermissionDenied,
std::io::ErrorKind::UnexpectedEof,
std::io::ErrorKind::NotFound,
std::io::ErrorKind::Other,
] {
let dir = fixture(b"abc").await;
let charged = AtomicUsize::new(0);
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|_, _, _| async move { Err(std::io::Error::from(kind).into()) },
|_, _| async {
charged.fetch_add(1, Ordering::SeqCst);
true
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Unavailable));
assert_eq!(charged.load(Ordering::SeqCst), 0);
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 1);
}
}
#[tokio::test]
async fn deletion_during_payment_cannot_lose_prepared_bytes() {
let dir = fixture(b"original").await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, amount| {
assert_eq!(amount, 10);
async {
fs::remove_file(dir.path().join("content/files/test.bin"))
.await
.unwrap();
true
}
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"original"));
}
#[tokio::test]
async fn empty_out_of_bounds_and_reversed_ranges_never_charge() {
for (bytes, start, end) in [
(b"".as_slice(), 0, None),
(b"abc".as_slice(), 3, None),
(b"abc".as_slice(), 2, Some(1)),
] {
let dir = fixture(bytes).await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
Some(ByteRange { start, end }),
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("invalid range reached payment") },
)
.await
.unwrap();
assert!(
matches!(result, ServeResult::RangeNotSatisfiable(n) if n == bytes.len() as u64)
);
}
}
#[tokio::test]
async fn prepared_range_survives_file_change_while_payment_is_verified() {
let dir = fixture(b"abcdef").await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
Some(ByteRange {
start: 2,
end: Some(999),
}),
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async {
fs::write(dir.path().join("content/files/test.bin"), b"x")
.await
.unwrap();
true
},
)
.await
.unwrap();
assert!(
matches!(result, ServeResult::Partial { bytes, start: 2, end: 5, total: 6, .. } if bytes == b"cdef")
);
}
#[tokio::test]
async fn payment_denial_never_returns_prepared_content() {
let dir = fixture(b"secret").await;
let charged = AtomicUsize::new(0);
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async {
charged.fetch_add(1, Ordering::SeqCst);
false
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
assert_eq!(charged.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn missing_payment_and_peer_restrictions_precede_file_reads() {
let dir = fixture(b"secret").await;
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
false,
|_, _, _| async { panic!("unauthorized file read") },
|_, _| async { panic!("unexpected payment") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
let mut catalog = load_catalog(dir.path()).await.unwrap();
catalog.items[0].access = AccessControl::PeersOnly;
save_catalog(dir.path(), &catalog).await.unwrap();
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
false,
|_, _, _| async { panic!("unauthorized file read") },
|_, _| async { panic!("unexpected payment") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Forbidden));
}
#[tokio::test]
async fn owner_reads_paid_content_without_redemption() {
let dir = fixture(b"own file").await;
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
true,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("owner charged") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"own file"));
}
#[tokio::test]
async fn directory_in_place_of_file_does_not_charge() {
let dir = fixture(b"abc").await;
let path = dir.path().join("content/files/test.bin");
fs::remove_file(&path).await.unwrap();
fs::create_dir(&path).await.unwrap();
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("directory charged") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Unavailable));
}
#[tokio::test]
async fn files_namespace_read_is_scoped_to_regular_files_and_keeps_mode() {
use std::os::unix::fs::{symlink, PermissionsExt};
let dir = fixture(b"outside").await;
let root = dir.path().join("filebrowser");
fs::create_dir(&root).await.unwrap();
let inside = root.join("song");
fs::write(&inside, b"song").await.unwrap();
fs::set_permissions(&inside, std::fs::Permissions::from_mode(0o640))
.await
.unwrap();
assert_eq!(
filebrowser_read_path(dir.path(), &inside).await.unwrap(),
inside
);
assert_eq!(
fs::metadata(&inside).await.unwrap().permissions().mode() & 0o777,
0o640
);
let outside = dir.path().join("content/files/test.bin");
symlink(&outside, root.join("escape")).unwrap();
for path in [outside, root.join("escape"), root.clone()] {
assert!(filebrowser_read_path(dir.path(), &path).await.is_err());
}
}
#[test]
fn user_namespace_bytes_use_the_same_range_rules() {
assert!(matches!(
slice_prepared_content(
vec![],
Some(ByteRange {
start: 0,
end: None
}),
"x".into()
)
.unwrap(),
ServeResult::RangeNotSatisfiable(0)
));
assert!(
matches!(slice_prepared_content(b"abc".to_vec(), Some(ByteRange { start: 1, end: None }), "x".into()).unwrap(), ServeResult::Partial { bytes, start: 1, end: 2, total: 3, .. } if bytes == b"bc")
);
}
}
+268 -7
View File
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// Is this FIPS answer the final one, or should the request go again over
/// Tor? A single-delivery request already reached the peer, so any answer
/// is final: a Tor replay would carry the same (possibly spent) payload.
fn fips_answer_is_final(
pref: crate::settings::transport::TransportPref,
single_delivery: bool,
status: reqwest::StatusCode,
) -> bool {
pref == crate::settings::transport::TransportPref::Fips
|| single_delivery
|| !fips_should_fall_back(status)
}
/// May a failed FIPS attempt be sent again? Only a failed connect proves the
/// peer never saw it; a timeout can land after the request was delivered.
fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool {
e.is_connect() || (!single_delivery && e.is_timeout())
}
/// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips";
@@ -113,7 +132,21 @@ pub fn client() -> reqwest::Client {
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
/// is preserved so a cold hole-punched path still gets time to establish.
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
client_with_delivery_policy(timeout, false)
}
fn delivery_redirect_policy(single: bool) -> reqwest::redirect::Policy {
if single {
reqwest::redirect::Policy::none()
} else {
reqwest::redirect::Policy::default()
}
}
fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Client {
reqwest::Client::builder()
.no_proxy()
.redirect(delivery_redirect_policy(single))
.timeout(timeout)
.connect_timeout(Duration::from_secs(8))
.user_agent("archipelago-fips/1")
@@ -130,10 +163,18 @@ pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
/// robust". Only connect/timeout errors are retried (a real HTTP response,
/// including 4xx/5xx, is returned as-is for the caller to interpret).
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
send_with_retry_if(rb, |e| e.is_connect() || e.is_timeout()).await
}
/// [`send_with_retry`], retrying only on errors `retryable` accepts.
async fn send_with_retry_if(
rb: reqwest::RequestBuilder,
retryable: impl Fn(&reqwest::Error) -> bool,
) -> Result<reqwest::Response, reqwest::Error> {
let retry = rb.try_clone();
match rb.send().await {
Ok(resp) => Ok(resp),
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
Err(e) if retryable(&e) && retry.is_some() => {
// Brief pause so the hole-punch packets from the first attempt can
// traverse before we re-dial onto the warmed path.
tokio::time::sleep(Duration::from_millis(600)).await;
@@ -350,6 +391,9 @@ pub struct PeerRequest<'a> {
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
/// The request carries something that must reach the peer at most once
/// (a bearer ecash token). See [`PeerRequest::single_delivery`].
pub single_delivery: bool,
}
impl<'a> PeerRequest<'a> {
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
fips_timeout: None,
service: None,
record_data_dir: None,
single_delivery: false,
}
}
/// Never send this request twice. A paid download carries a bearer ecash
/// token that the seller redeems on first sight; replaying it over Tor
/// after FIPS already delivered it hands the seller a spent token, so the
/// buyer is charged and gets a 402 instead of the file (2026-09-29: FIPS
/// answered 404 after the seller redeemed, the Tor retry got 402).
///
/// With this set, whatever FIPS answers is final, the FIPS retry fires
/// only when the first attempt never connected, and Tor is used only when
/// FIPS could not have delivered the request. An attempt that may have
/// been delivered but timed out is an error, not a fallback.
pub fn single_delivery(mut self) -> Self {
self.single_delivery = true;
self
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
@@ -481,7 +541,7 @@ impl<'a> PeerRequest<'a> {
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
} else {
budget
};
let c = client_with_timeout(per_attempt);
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match tokio::time::timeout(budget, send_with_retry(rb)).await {
let single = self.single_delivery;
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
match tokio::time::timeout(budget, attempt).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
"FIPS POST failed after possible delivery; not replaying: {e}"
)),
Err(_) if single => Err(anyhow::anyhow!(
"FIPS POST exceeded its budget after possible delivery; not replaying"
)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
} else {
budget
};
let c = client_with_timeout(per_attempt);
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
let mut rb = c.get(&url);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match tokio::time::timeout(budget, send_with_retry(rb)).await {
let single = self.single_delivery;
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
match tokio::time::timeout(budget, attempt).await {
Ok(Ok(r)) => Ok(Some(r)),
// Anything but a failed connect may have reached the peer.
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
"FIPS GET {} failed after the request may have been delivered \
(not retrying over Tor): {}",
self.path,
e
)),
Err(_) if single => Err(anyhow::anyhow!(
"FIPS GET {} exceeded its {:?} budget after the request may have \
been delivered (not retrying over Tor)",
self.path,
budget
)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
.context("Invalid Tor SOCKS proxy URL")?;
reqwest::Client::builder()
.proxy(proxy)
.redirect(delivery_redirect_policy(self.single_delivery))
.timeout(self.timeout)
.build()
.context("Build Tor HTTP client")
@@ -759,4 +843,181 @@ mod tests {
let err = decode_response(0xAABB, &r, "x").unwrap_err();
assert!(err.to_string().contains("no AAAA"));
}
#[test]
fn a_single_delivery_answer_is_final_whatever_its_status() {
use crate::settings::transport::TransportPref;
use reqwest::StatusCode;
// Regression (2026-09-29): the seller redeemed a paid download's
// token, answered 404, and the Tor fallback replayed the spent token.
for status in [
StatusCode::NOT_FOUND,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::OK,
] {
assert!(fips_answer_is_final(TransportPref::Auto, true, status));
}
// Everything else keeps the existing fallback rules.
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::NOT_FOUND
));
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::BAD_GATEWAY
));
assert!(fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::PAYMENT_REQUIRED
));
assert!(fips_answer_is_final(
TransportPref::Fips,
false,
StatusCode::NOT_FOUND
));
}
/// A listener that accepts connections and never answers, counting them.
async fn silent_peer() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = seen.clone();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((stream, _)) = listener.accept().await {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
held.push(stream); // keep it open, never reply
}
});
(format!("http://{addr}/content/x"), seen)
}
#[tokio::test]
async fn a_single_delivery_request_is_not_resent_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e))
.await
.expect_err("peer never answers");
assert!(err.is_timeout());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn an_ordinary_request_is_still_retried_once_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await;
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_single_delivery_request_still_retries_a_refused_connect() {
// Nothing listening: the peer provably never saw the request.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let c = client_with_timeout(Duration::from_millis(500));
let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| {
fips_retryable(true, e)
})
.await
.expect_err("nothing listening");
assert!(err.is_connect());
assert!(fips_retryable(true, &err));
}
}
#[cfg(test)]
mod delivery_redirect_tests {
use super::*;
use hyper::{
service::{make_service_fn, service_fn},
Body, Response, Server,
};
use std::{
convert::Infallible,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
#[tokio::test]
async fn paid_bearer_request_does_not_follow_redirects_but_normal_get_does() {
let seen = Arc::new(AtomicUsize::new(0));
let counter = seen.clone();
let server = Server::bind(&([127, 0, 0, 1], 0).into());
let address = server.local_addr();
let service = make_service_fn(move |_| {
let counter = counter.clone();
async move {
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
let response = if request.uri().path() == "/first" {
Response::builder()
.status(302)
.header("Location", "/replay")
.body(Body::empty())
.unwrap()
} else {
Response::new(Body::from("replayed"))
};
Ok::<_, Infallible>(response)
}
}))
}
});
let task = tokio::spawn(server.serve(service));
let url = format!("http://{address}/first");
let response = client_with_delivery_policy(Duration::from_secs(2), true)
.get(&url)
.header("X-Payment-Token", "dummy-test-token")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
assert_eq!(seen.load(Ordering::SeqCst), 1);
let response = client_with_delivery_policy(Duration::from_secs(2), false)
.get(url)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(seen.load(Ordering::SeqCst), 3);
task.abort();
}
#[tokio::test]
async fn paid_request_is_not_resent_when_peer_disconnects_after_reading_it() {
use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let seen = Arc::new(AtomicUsize::new(0));
let counter = seen.clone();
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0; 4096];
let _ = stream.read(&mut buf).await;
counter.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
});
let c = client_with_delivery_policy(Duration::from_secs(2), true);
let error = send_with_retry_if(c.get(format!("http://{address}/")), |e| {
fips_retryable(true, e)
})
.await
.unwrap_err();
assert!(!error.is_connect());
assert_eq!(seen.load(Ordering::SeqCst), 1);
task.abort();
}
}