Qualify durable purchase and media primitives and preserve app launch paths
This commit is contained in:
@@ -15,6 +15,8 @@ app:
|
|||||||
container_name: indeedhub-api
|
container_name: indeedhub-api
|
||||||
|
|
||||||
container:
|
container:
|
||||||
|
# Public identity pins only; registration/publication stay disabled by default.
|
||||||
|
media_registration_identity: true
|
||||||
image: source.archipelago-foundation.org/lfg2025/indeedhub-api:1.0.0
|
image: source.archipelago-foundation.org/lfg2025/indeedhub-api:1.0.0
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: indeedhub-net
|
network: indeedhub-net
|
||||||
|
|||||||
@@ -349,6 +349,18 @@ impl ApiHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(error) =
|
||||||
|
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
|
||||||
|
{
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(serde_json::to_vec(
|
||||||
|
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
|
||||||
|
)?),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let memo = format!("Archipelago peer file {content_id}");
|
let memo = format!("Archipelago peer file {content_id}");
|
||||||
match self
|
match self
|
||||||
.rpc_handler
|
.rpc_handler
|
||||||
@@ -468,6 +480,30 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Match the node wallet's existing sendcoins minimum before exposing a
|
||||||
|
// payable address for an amount its own payment flow cannot broadcast.
|
||||||
|
if let Err(error) = content_server::validate_onchain_payment_price(price_sats) {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(serde_json::to_vec(
|
||||||
|
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
|
||||||
|
)?),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) =
|
||||||
|
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
|
||||||
|
{
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(serde_json::to_vec(
|
||||||
|
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
|
||||||
|
)?),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
match self.rpc_handler.new_onchain_address().await {
|
match self.rpc_handler.new_onchain_address().await {
|
||||||
Ok(address) if !address.is_empty() => {
|
Ok(address) if !address.is_empty() => {
|
||||||
crate::content_invoice::record_pending_method(
|
crate::content_invoice::record_pending_method(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod blob;
|
mod blob;
|
||||||
mod cdp;
|
mod cdp;
|
||||||
mod content;
|
mod content;
|
||||||
|
mod registered_media;
|
||||||
mod dwn;
|
mod dwn;
|
||||||
mod model_proxy;
|
mod model_proxy;
|
||||||
mod node_message;
|
mod node_message;
|
||||||
@@ -584,6 +585,12 @@ impl ApiHandler {
|
|||||||
Self::handle_blob_download(&self.blob_store, p, &query_string).await
|
Self::handle_blob_download(&self.blob_store, p, &query_string).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Immutable registered rentals use durable seller receipts and their
|
||||||
|
// first-open window, never legacy mutable filename shares.
|
||||||
|
(Method::GET, p) if p.starts_with("/content/registered_") && p.contains("/rental/") => {
|
||||||
|
self.handle_registered_rental(p, &headers).await
|
||||||
|
}
|
||||||
|
|
||||||
// Content preview — degraded previews for paid content (no auth, no payment)
|
// Content preview — degraded previews for paid content (no auth, no payment)
|
||||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
|
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
|
||||||
Self::handle_content_preview(p, &self.config).await
|
Self::handle_content_preview(p, &self.config).await
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
//! Authenticated immutable rental streaming, separate from legacy mutable shares.
|
||||||
|
use super::{build_response, ApiHandler};
|
||||||
|
use crate::{content_server::ByteRange, identity::NodeIdentity, registered_media::OpenedMedia};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use hyper::{Body, HeaderMap, Response, StatusCode};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
|
fn route(path: &str) -> Result<(&str, &str)> {
|
||||||
|
let (content, purchase) = path
|
||||||
|
.strip_prefix("/content/")
|
||||||
|
.and_then(|value| value.split_once("/rental/"))
|
||||||
|
.context("Invalid rental route")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
content.starts_with("registered_") && !content.contains('/') && !purchase.contains('/'),
|
||||||
|
"Invalid rental identifiers"
|
||||||
|
);
|
||||||
|
let id = uuid::Uuid::parse_str(purchase)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
id.to_string() == purchase && id.get_version_num() == 4,
|
||||||
|
"Invalid purchase identifier"
|
||||||
|
);
|
||||||
|
Ok((content, purchase))
|
||||||
|
}
|
||||||
|
fn bounds(range: Option<ByteRange>, total: u64) -> Result<Option<(u64, u64)>> {
|
||||||
|
let Some(range) = range else {
|
||||||
|
anyhow::ensure!(total > 0, "Registered media is empty");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let last = total.checked_sub(1).context("Registered media is empty")?;
|
||||||
|
let (start, end) = match range {
|
||||||
|
ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)),
|
||||||
|
ByteRange::Suffix(count) => {
|
||||||
|
anyhow::ensure!(count > 0, "Invalid suffix range");
|
||||||
|
(total.saturating_sub(count), last)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
anyhow::ensure!(start <= end && start < total, "Invalid rental byte range");
|
||||||
|
Ok(Some((start, end)))
|
||||||
|
}
|
||||||
|
fn clock() -> u64 {
|
||||||
|
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
fn denied(message: &'static str) -> Response<Body> {
|
||||||
|
build_response(StatusCode::FORBIDDEN, "text/plain", Body::from(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
pub(super) async fn handle_registered_rental(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<Response<Body>> {
|
||||||
|
let (content, purchase) = match route(path) {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
Body::from("Invalid rental route"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||||
|
let buyer = match crate::content_auth::incoming(
|
||||||
|
headers,
|
||||||
|
&audience,
|
||||||
|
path,
|
||||||
|
chrono::Utc::now().timestamp(),
|
||||||
|
) {
|
||||||
|
Ok(Some(buyer)) => buyer,
|
||||||
|
_ => return Ok(denied("Authenticated node proof is required")),
|
||||||
|
};
|
||||||
|
let capability = match headers
|
||||||
|
.get("x-content-capability")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
{
|
||||||
|
Some(value) if value.len() == 64 => value.to_owned(),
|
||||||
|
_ => return Ok(denied("Original purchase capability is required")),
|
||||||
|
};
|
||||||
|
let requested_range = match headers.get("range") {
|
||||||
|
None => None,
|
||||||
|
Some(value) => match value
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.and_then(crate::content_server::parse_range_header)
|
||||||
|
{
|
||||||
|
Some(range) => Some(range),
|
||||||
|
None => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::RANGE_NOT_SATISFIABLE,
|
||||||
|
"text/plain",
|
||||||
|
Body::from("Invalid byte range"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let identity =
|
||||||
|
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
|
||||||
|
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
|
||||||
|
let data = self.config.data_dir.clone();
|
||||||
|
let selected = content.to_owned();
|
||||||
|
let key = identity.clone();
|
||||||
|
let metadata = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::registered_media::registered_terms(&data, &key, &selected)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let (receipt, _) = match metadata {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"text/plain",
|
||||||
|
Body::from("Registered content is unavailable"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total = receipt.size_bytes.parse::<u64>()?;
|
||||||
|
let range = match bounds(requested_range, total) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
|
.header("Content-Range", format!("bytes */{total}"))
|
||||||
|
.body(Body::from("Invalid byte range"))?)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// All malformed/out-of-bounds requests are rejected before first-open
|
||||||
|
// rental creation. No payment or new receipt is attempted by this route.
|
||||||
|
let opened = match crate::registered_media::open_paid(
|
||||||
|
self.config.data_dir.clone(),
|
||||||
|
identity,
|
||||||
|
content.into(),
|
||||||
|
purchase.into(),
|
||||||
|
buyer,
|
||||||
|
capability,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(opened) => opened,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(denied(
|
||||||
|
"This purchase is not settled, does not match, or its rental has expired",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rental_response(opened, range, Arc::new(clock)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn rental_response(
|
||||||
|
opened: OpenedMedia,
|
||||||
|
range: Option<(u64, u64)>,
|
||||||
|
now: Arc<dyn Fn() -> u64 + Send + Sync>,
|
||||||
|
) -> Result<Response<Body>> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
opened.still_authorized(now()),
|
||||||
|
"Rental expired before streaming"
|
||||||
|
);
|
||||||
|
let total = opened.size_bytes;
|
||||||
|
let started = opened.started_at;
|
||||||
|
let expires = opened.expires_at;
|
||||||
|
let (start, length) = range.map_or((0, total), |(start, end)| (start, end - start + 1));
|
||||||
|
let mut file = tokio::fs::File::from_std(opened.file);
|
||||||
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||||
|
let chunks = futures_util::stream::try_unfold(
|
||||||
|
(file, length, now),
|
||||||
|
move |(mut file, left, now)| async move {
|
||||||
|
if left == 0 {
|
||||||
|
return Ok::<_, std::io::Error>(None);
|
||||||
|
}
|
||||||
|
let instant = now();
|
||||||
|
if instant < started || instant >= expires {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"Rental window ended",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut bytes = vec![0; left.min(64 * 1024) as usize];
|
||||||
|
let count = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(expires - instant),
|
||||||
|
file.read(&mut bytes),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::TimedOut, "Rental window ended")
|
||||||
|
})??;
|
||||||
|
let instant = now();
|
||||||
|
if instant < started || instant >= expires {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"Rental window ended",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::UnexpectedEof,
|
||||||
|
"Registered snapshot ended early",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
bytes.truncate(count);
|
||||||
|
Ok(Some((bytes, (file, left - count as u64, now))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut response = Response::builder()
|
||||||
|
.status(if range.is_some() {
|
||||||
|
StatusCode::PARTIAL_CONTENT
|
||||||
|
} else {
|
||||||
|
StatusCode::OK
|
||||||
|
})
|
||||||
|
.header("Content-Type", opened.mime_type)
|
||||||
|
.header("Content-Length", length)
|
||||||
|
.header("Accept-Ranges", "bytes")
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.header("Cache-Control", "private, no-store")
|
||||||
|
.header("X-Rental-Expires-At", expires);
|
||||||
|
if let Some((start, end)) = range {
|
||||||
|
response = response.header("Content-Range", format!("bytes {start}-{end}/{total}"));
|
||||||
|
}
|
||||||
|
Ok(response.body(Body::wrap_stream(chunks))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use hyper::body::HttpBody;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
#[test]
|
||||||
|
fn invalid_routes_and_ranges_cannot_reach_rental_creation() {
|
||||||
|
let id = uuid::Uuid::new_v4();
|
||||||
|
assert!(route(&format!("/content/registered_{id}/rental/{id}")).is_ok());
|
||||||
|
for path in [
|
||||||
|
format!("/content/registered_{id}/rental/{id}/extra"),
|
||||||
|
format!("/content/../rental/{id}"),
|
||||||
|
format!("/content/registered_{id}/rental/not-a-purchase"),
|
||||||
|
] {
|
||||||
|
assert!(route(&path).is_err());
|
||||||
|
}
|
||||||
|
assert!(bounds(
|
||||||
|
Some(ByteRange::From {
|
||||||
|
start: 20,
|
||||||
|
end: None
|
||||||
|
}),
|
||||||
|
20
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(bounds(
|
||||||
|
Some(ByteRange::From {
|
||||||
|
start: 9,
|
||||||
|
end: Some(8)
|
||||||
|
}),
|
||||||
|
20
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
bounds(Some(ByteRange::Suffix(5)), 20).unwrap(),
|
||||||
|
Some((15, 19))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fn opened(size: u64) -> OpenedMedia {
|
||||||
|
let file = tempfile::tempfile().unwrap();
|
||||||
|
file.set_len(size).unwrap();
|
||||||
|
OpenedMedia {
|
||||||
|
file,
|
||||||
|
size_bytes: size,
|
||||||
|
mime_type: "video/mp4".into(),
|
||||||
|
started_at: 1000,
|
||||||
|
expires_at: 1060,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bounded_stream_stops_at_persisted_deadline_without_restarting_window() {
|
||||||
|
let clock = Arc::new(AtomicU64::new(1000));
|
||||||
|
let read_clock = clock.clone();
|
||||||
|
let mut response = rental_response(
|
||||||
|
opened(200_000),
|
||||||
|
None,
|
||||||
|
Arc::new(move || read_clock.load(Ordering::SeqCst)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.headers()["x-rental-expires-at"], "1060");
|
||||||
|
assert_eq!(
|
||||||
|
response.body_mut().data().await.unwrap().unwrap().len(),
|
||||||
|
64 * 1024
|
||||||
|
);
|
||||||
|
clock.store(1060, Ordering::SeqCst);
|
||||||
|
assert!(response.body_mut().data().await.unwrap().is_err());
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn suffix_response_has_exact_length_and_expired_or_rollback_stream_denies() {
|
||||||
|
let mut response = rental_response(opened(20), Some((15, 19)), Arc::new(|| 1000))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||||
|
assert_eq!(response.headers()["content-range"], "bytes 15-19/20");
|
||||||
|
assert_eq!(
|
||||||
|
hyper::body::to_bytes(response.body_mut())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
5
|
||||||
|
);
|
||||||
|
assert!(rental_response(opened(20), None, Arc::new(|| 1060))
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(rental_response(opened(20), None, Arc::new(|| 999))
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -896,6 +896,62 @@ impl RpcHandler {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owner-authenticated local recovery lookup. No mint, peer request or
|
||||||
|
/// wallet mutation occurs here, and private tokens/capabilities are omitted.
|
||||||
|
pub(super) async fn handle_content_payment_status(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.context("Missing payment lookup parameters")?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.context("Missing seller address")?;
|
||||||
|
let content_id = params
|
||||||
|
.get("content_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.context("Missing content identifier")?;
|
||||||
|
anyhow::ensure!(is_valid_v3_onion(onion), "Invalid seller address");
|
||||||
|
crate::content_owned::validate_identity(onion, content_id)?;
|
||||||
|
let peer =
|
||||||
|
crate::federation::load_unique_payment_peer(&self.config.data_dir, onion).await?;
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let buyer_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
let journal = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
|
||||||
|
let records = if let Some(id) = params.get("operation_id") {
|
||||||
|
let id = id
|
||||||
|
.as_str()
|
||||||
|
.context("Invalid purchase operation identifier")?;
|
||||||
|
match journal.buyer(id).await? {
|
||||||
|
Some(record) => {
|
||||||
|
anyhow::ensure!(
|
||||||
|
record.contract.buyer_did == buyer_did
|
||||||
|
&& record.contract.seller_did == peer.did
|
||||||
|
&& record.contract.content_id == content_id,
|
||||||
|
"Purchase belongs to another buyer, seller or content item"
|
||||||
|
);
|
||||||
|
vec![record]
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
journal
|
||||||
|
.find_buyers(&buyer_did, &peer.did, content_id)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
let attempts: Vec<_> = records
|
||||||
|
.iter()
|
||||||
|
.map(|record| record.public_status())
|
||||||
|
.collect();
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"state": if attempts.is_empty() { "unknown" } else { "recorded" },
|
||||||
|
"attempts": attempts,
|
||||||
|
// Absence is not evidence that a legacy payment failed/reclaimed.
|
||||||
|
"can_start_new_payment": false,
|
||||||
|
"legacy_recovery_unresolved": attempts.is_empty(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||||
/// paid item so the buyer can pay from any external wallet. Returns the
|
/// paid item so the buyer can pay from any external wallet. Returns the
|
||||||
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
|
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
|
||||||
@@ -943,9 +999,11 @@ impl RpcHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Ok(serde_json::json!({
|
let status = response.status();
|
||||||
"error": format!("Seller could not create an invoice ({}).", response.status())
|
let body = bounded_seller_error(response).await;
|
||||||
}));
|
return Ok(
|
||||||
|
serde_json::json!({ "error": seller_error_message(status, &body), "payment_started": false }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let body: serde_json::Value = response
|
let body: serde_json::Value = response
|
||||||
.json()
|
.json()
|
||||||
@@ -1196,9 +1254,11 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Ok(serde_json::json!({
|
let status = response.status();
|
||||||
"error": format!("Seller could not provide an address ({}).", response.status())
|
let body = bounded_seller_error(response).await;
|
||||||
}));
|
return Ok(
|
||||||
|
serde_json::json!({ "error": seller_error_message(status, &body), "payment_started": false }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let body: serde_json::Value = response
|
let body: serde_json::Value = response
|
||||||
.json()
|
.json()
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ impl RpcHandler {
|
|||||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||||
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
|
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
|
||||||
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
|
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
|
||||||
|
"content.payment-status" => self.handle_content_payment_status(params).await,
|
||||||
"content.owned-list" => self.handle_content_owned_list().await,
|
"content.owned-list" => self.handle_content_owned_list().await,
|
||||||
"content.owned-get" => self.handle_content_owned_get(params).await,
|
"content.owned-get" => self.handle_content_owned_get(params).await,
|
||||||
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||||
|
|||||||
@@ -512,6 +512,82 @@ mod lifecycle_regression_tests {
|
|||||||
assert_eq!(main.lan_config.as_deref(), Some("kept"));
|
assert_eq!(main.lan_config.as_deref(), Some("kept"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
|
||||||
|
let mut entry = installing_fixture();
|
||||||
|
entry.state = PackageState::Stopped;
|
||||||
|
entry.installed = Some(InstalledPackageDataEntry {
|
||||||
|
current_dependents: HashMap::new(),
|
||||||
|
current_dependencies: HashMap::new(),
|
||||||
|
last_backup: None,
|
||||||
|
interface_addresses: HashMap::from([(
|
||||||
|
"main".into(),
|
||||||
|
InterfaceAddress {
|
||||||
|
lan_address: Some("https://localhost:7443/old?gate=retained#position".into()),
|
||||||
|
tor_address: "existing.onion".into(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
status: ServiceStatus::Stopped,
|
||||||
|
});
|
||||||
|
let manifest = serde_json::json!({"app":{"interfaces":{"main":{
|
||||||
|
"type":"ui", "port":80, "protocol":"http", "path":"/browse"
|
||||||
|
}}}});
|
||||||
|
for _ in 0..2 {
|
||||||
|
apply_manifest_value(&manifest, &mut entry);
|
||||||
|
let main = &entry.installed.as_ref().unwrap().interface_addresses["main"];
|
||||||
|
assert_eq!(
|
||||||
|
main.lan_address.as_deref(),
|
||||||
|
Some("https://localhost:7443/browse?gate=retained#position")
|
||||||
|
);
|
||||||
|
assert_eq!(main.tor_address, "existing.onion");
|
||||||
|
assert_eq!(entry.state, PackageState::Stopped);
|
||||||
|
assert_eq!(entry.ui_ready, Some(false));
|
||||||
|
}
|
||||||
|
entry
|
||||||
|
.installed
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.interface_addresses
|
||||||
|
.get_mut("main")
|
||||||
|
.unwrap()
|
||||||
|
.lan_address = None;
|
||||||
|
apply_manifest_value(&manifest, &mut entry);
|
||||||
|
assert!(
|
||||||
|
entry.installed.as_ref().unwrap().interface_addresses["main"]
|
||||||
|
.lan_address
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_entry_path_preserves_authority_and_optional_query() {
|
||||||
|
assert_eq!(
|
||||||
|
manifest_launch_path("http://localhost:7475", "/browse"),
|
||||||
|
Some("http://localhost:7475/browse".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest_launch_path(
|
||||||
|
"https://localhost:7443/old?keep=1#old",
|
||||||
|
"/browse?view=all#new"
|
||||||
|
),
|
||||||
|
Some("https://localhost:7443/browse?view=all#new".into())
|
||||||
|
);
|
||||||
|
for path in [
|
||||||
|
"https://foreign.invalid/",
|
||||||
|
"//foreign.invalid/",
|
||||||
|
"/\\foreign.invalid/",
|
||||||
|
"browse",
|
||||||
|
"/bad\npath",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
manifest_launch_path("https://localhost:7443", path).is_none(),
|
||||||
|
"{path:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(manifest_launch_path("", "/browse").is_none());
|
||||||
|
assert!(manifest_launch_path("file:///tmp/local", "/browse").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn btcpay_aliases_share_one_package_without_promoting_dependencies() {
|
fn btcpay_aliases_share_one_package_without_promoting_dependencies() {
|
||||||
for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] {
|
for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] {
|
||||||
@@ -761,6 +837,38 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
|
|||||||
entry.manifest.tier = Some(tier);
|
entry.manifest.tier = Some(tier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Runtime discovery owns origins, ports and availability; the reviewed
|
||||||
|
// manifest owns the UI's entry path. Otherwise every scan resets a declared
|
||||||
|
// /browse or /admin entry point to the container root.
|
||||||
|
if let (Some(installed), Some(interfaces)) = (
|
||||||
|
entry.installed.as_mut(),
|
||||||
|
app.get("interfaces").and_then(|v| v.as_object()),
|
||||||
|
) {
|
||||||
|
for (id, address) in &mut installed.interface_addresses {
|
||||||
|
let Some(interface) = interfaces.get(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if interface
|
||||||
|
.get("type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("ui")
|
||||||
|
!= "ui"
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(path) = interface.get("path").and_then(|v| v.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(lan) = address.lan_address.as_mut() {
|
||||||
|
if let Some(updated) = manifest_launch_path(lan, path) {
|
||||||
|
*lan = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(updated) = manifest_launch_path(&address.tor_address, path) {
|
||||||
|
address.tor_address = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Once installed, the scanner owns UI detection (including companion UIs).
|
// Once installed, the scanner owns UI detection (including companion UIs).
|
||||||
// Only seed classification while there is no observed runtime package.
|
// Only seed classification while there is no observed runtime package.
|
||||||
if entry.installed.is_some() {
|
if entry.installed.is_some() {
|
||||||
@@ -790,6 +898,33 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a local entry path without changing the scanner-confirmed authority.
|
||||||
|
fn manifest_launch_path(address: &str, path: &str) -> Option<String> {
|
||||||
|
if !path.starts_with('/')
|
||||||
|
|| path.starts_with("//")
|
||||||
|
|| path.contains('\\')
|
||||||
|
|| path.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = reqwest::Url::parse(address).ok()?;
|
||||||
|
if !matches!(base.scheme(), "http" | "https") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut updated = base.join(path).ok()?;
|
||||||
|
if updated.origin() != base.origin() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A plain path must not discard an existing gate/deep-link query.
|
||||||
|
if !path.contains('?') {
|
||||||
|
updated.set_query(base.query());
|
||||||
|
}
|
||||||
|
if !path.contains('#') {
|
||||||
|
updated.set_fragment(base.fragment());
|
||||||
|
}
|
||||||
|
Some(updated.into())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||||
let mut meta = match app_id {
|
let mut meta = match app_id {
|
||||||
"bitcoin-core" => AppMetadata {
|
"bitcoin-core" => AppMetadata {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod npm;
|
|||||||
pub mod prod_orchestrator;
|
pub mod prod_orchestrator;
|
||||||
pub mod quadlet;
|
pub mod quadlet;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod registration_pin;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
pub mod ui_detection;
|
pub mod ui_detection;
|
||||||
|
|||||||
@@ -3915,6 +3915,27 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||||
|
if manifest.app.container.media_registration_identity {
|
||||||
|
// Only opted-in manifests read the existing appliance identity. A
|
||||||
|
// missing key/public-key mismatch is an error, never key generation.
|
||||||
|
let identity =
|
||||||
|
crate::identity::NodeIdentity::load_existing(&self.data_dir.join("identity"))
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.node_pubkey_hex().await? == identity.pubkey_hex(),
|
||||||
|
"Existing node public key disagrees with its signing identity"
|
||||||
|
);
|
||||||
|
let data_dir = self.data_dir.clone();
|
||||||
|
let app_id = manifest.app.id.clone();
|
||||||
|
let pin = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::container::registration_pin::ensure_for_installation(
|
||||||
|
&data_dir, &app_id, &identity,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("Application registration pin worker failed")??;
|
||||||
|
crate::container::registration_pin::apply_environment(manifest, &pin)?;
|
||||||
|
}
|
||||||
if manifest.app.id == "nginx-proxy-manager" {
|
if manifest.app.id == "nginx-proxy-manager" {
|
||||||
crate::container::npm::resolve_storage()
|
crate::container::npm::resolve_storage()
|
||||||
.await?
|
.await?
|
||||||
@@ -6433,6 +6454,100 @@ app:
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opted_in_media_registration_pins_preserve_audience_through_env_reconciliation() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mut orch =
|
||||||
|
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
|
||||||
|
orch.set_data_dir(root.path().to_owned());
|
||||||
|
orch.set_secrets_dir(root.path().join("secrets"));
|
||||||
|
let identity = crate::identity::NodeIdentity::load_or_create(&root.path().join("identity"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let key_before = tokio::fs::read(root.path().join("identity/node_key"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut app = pull_manifest("indeedhub-api", "fixture:1");
|
||||||
|
app.app.container.media_registration_identity = true;
|
||||||
|
app.app
|
||||||
|
.environment
|
||||||
|
.push("ARCHIPELAGO_REGISTRATION_AUDIENCE=untrusted-manifest-value".into());
|
||||||
|
orch.resolve_dynamic_env(&mut app).await.unwrap();
|
||||||
|
let first = app.app.environment.clone();
|
||||||
|
orch.resolve_dynamic_env(&mut app).await.unwrap();
|
||||||
|
assert_eq!(app.app.environment, first);
|
||||||
|
let pin = crate::container::registration_pin::load_existing(
|
||||||
|
root.path(),
|
||||||
|
"indeedhub-api",
|
||||||
|
&identity,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(first.contains(&format!(
|
||||||
|
"ARCHIPELAGO_REGISTRATION_NODE_PUBLIC_KEY={}",
|
||||||
|
identity.pubkey_hex()
|
||||||
|
)));
|
||||||
|
assert!(first.contains(&format!(
|
||||||
|
"ARCHIPELAGO_REGISTRATION_NODE_DID={}",
|
||||||
|
identity.did_key().unwrap()
|
||||||
|
)));
|
||||||
|
assert!(first.contains(&format!(
|
||||||
|
"ARCHIPELAGO_REGISTRATION_AUDIENCE={}",
|
||||||
|
pin.app_audience
|
||||||
|
)));
|
||||||
|
assert!(!first.iter().any(|entry| entry.contains("_ENABLED=")));
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(root.path().join("identity/node_key"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
key_before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unrelated_apps_do_not_require_identity_or_get_registration_pins() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mut orch =
|
||||||
|
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
|
||||||
|
orch.set_data_dir(root.path().to_owned());
|
||||||
|
orch.set_secrets_dir(root.path().join("secrets"));
|
||||||
|
let mut app = pull_manifest("ordinary-app", "fixture:1");
|
||||||
|
orch.resolve_dynamic_env(&mut app).await.unwrap();
|
||||||
|
assert!(!root.path().join("identity").exists());
|
||||||
|
assert!(!root.path().join("app-registration-pins").exists());
|
||||||
|
assert!(!app
|
||||||
|
.app
|
||||||
|
.environment
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.starts_with("ARCHIPELAGO_REGISTRATION_")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registration_pins_require_existing_matching_node_keys() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mut orch = ProdContainerOrchestrator::with_runtime(
|
||||||
|
rt.clone(),
|
||||||
|
PathBuf::from("/nonexistent-for-tests"),
|
||||||
|
);
|
||||||
|
orch.set_data_dir(root.path().to_owned());
|
||||||
|
orch.set_secrets_dir(root.path().join("secrets"));
|
||||||
|
let mut app = pull_manifest("indeedhub-api", "fixture:1");
|
||||||
|
app.app.container.media_registration_identity = true;
|
||||||
|
assert!(orch.resolve_dynamic_env(&mut app).await.is_err());
|
||||||
|
assert!(!root.path().join("identity").exists());
|
||||||
|
crate::identity::NodeIdentity::load_or_create(&root.path().join("identity"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(root.path().join("identity/node_key.pub"), [3u8; 32])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(orch.resolve_dynamic_env(&mut app).await.is_err());
|
||||||
|
assert!(!root.path().join("app-registration-pins").exists());
|
||||||
|
assert!(rt.calls().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn node_identity_pubkeys_placeholder_renders_the_signable_identities() {
|
async fn node_identity_pubkeys_placeholder_renders_the_signable_identities() {
|
||||||
// The owners must be exactly the identities the app signer offers:
|
// The owners must be exactly the identities the app signer offers:
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! Installer-owned public identity bindings for opted-in media-registration apps.
|
||||||
|
//! No identity generation, app enablement, signing permission or data deletion.
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use archipelago_container::AppManifest;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const ROOT: &str = "app-registration-pins";
|
||||||
|
const MAX_RECORD: u64 = 16 * 1024;
|
||||||
|
const ENV_NAMES: [&str; 3] = [
|
||||||
|
"ARCHIPELAGO_REGISTRATION_NODE_PUBLIC_KEY",
|
||||||
|
"ARCHIPELAGO_REGISTRATION_NODE_DID",
|
||||||
|
"ARCHIPELAGO_REGISTRATION_AUDIENCE",
|
||||||
|
];
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RegistrationPin {
|
||||||
|
version: u8,
|
||||||
|
pub app_id: String,
|
||||||
|
pub node_public_key: String,
|
||||||
|
pub node_did: String,
|
||||||
|
pub app_audience: String,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Marker {
|
||||||
|
version: u8,
|
||||||
|
app_id: String,
|
||||||
|
pin_sha256: String,
|
||||||
|
}
|
||||||
|
fn valid_app_id(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 128
|
||||||
|
&& !id.starts_with('-')
|
||||||
|
&& !id.ends_with('-')
|
||||||
|
&& !id.contains("--")
|
||||||
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|v| v.is_ascii_lowercase() || v.is_ascii_digit() || v == b'-')
|
||||||
|
}
|
||||||
|
fn paths(data_dir: &Path, app_id: &str) -> Result<(PathBuf, PathBuf, PathBuf)> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
valid_app_id(app_id),
|
||||||
|
"Invalid application registration scope"
|
||||||
|
);
|
||||||
|
let root = data_dir.join(ROOT);
|
||||||
|
Ok((
|
||||||
|
root.clone(),
|
||||||
|
root.join(format!("{app_id}.json")),
|
||||||
|
root.join(format!("{app_id}.initialized.json")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fn private_root(path: &Path) -> Result<File> {
|
||||||
|
let dir = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||||
|
.open(path)?;
|
||||||
|
let metadata = dir.metadata()?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
metadata.uid() == unsafe { libc::geteuid() } && metadata.mode() & 0o077 == 0,
|
||||||
|
"Application registration pins must remain private to the node service"
|
||||||
|
);
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
fn lock(root: &File) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
if unsafe { libc::flock(root.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let error = std::io::Error::last_os_error();
|
||||||
|
if !matches!(
|
||||||
|
error.kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
|
||||||
|
) {
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"Application registration provisioning is busy; retry the same install"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn read<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
|
||||||
|
let file = match OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
|
||||||
|
.open(path)
|
||||||
|
{
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error)
|
||||||
|
.context("Application registration pin is unavailable; preserve it for recovery")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let metadata = file.metadata()?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
metadata.is_file() && metadata.len() <= MAX_RECORD && metadata.mode() & 0o077 == 0,
|
||||||
|
"Invalid application registration pin storage"
|
||||||
|
);
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.take(MAX_RECORD + 1).read_to_end(&mut bytes)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
bytes.len() as u64 <= MAX_RECORD,
|
||||||
|
"Application registration pin is too large"
|
||||||
|
);
|
||||||
|
Ok(Some(serde_json::from_slice(&bytes).context(
|
||||||
|
"Damaged application registration pin; do not replace it",
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
fn persist<T: Serialize>(root: &Path, path: &Path, value: &T) -> Result<()> {
|
||||||
|
let bytes = serde_json::to_vec(value)?;
|
||||||
|
let mut pending = tempfile::NamedTempFile::new_in(root)?;
|
||||||
|
pending.write_all(&bytes)?;
|
||||||
|
pending.as_file().sync_all()?;
|
||||||
|
pending.persist_noclobber(path).map_err(|error| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Could not commit application registration pin: {}",
|
||||||
|
error.error
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
private_root(root)?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn validate(
|
||||||
|
pin: &RegistrationPin,
|
||||||
|
app_id: &str,
|
||||||
|
identity: &crate::identity::NodeIdentity,
|
||||||
|
) -> Result<()> {
|
||||||
|
let audience =
|
||||||
|
uuid::Uuid::parse_str(&pin.app_audience).context("Invalid saved application audience")?;
|
||||||
|
anyhow::ensure!(pin.version == 1 && pin.app_id == app_id
|
||||||
|
&& pin.node_public_key == identity.pubkey_hex() && pin.node_did == identity.did_key()?
|
||||||
|
&& audience.get_version_num() == 4 && audience.get_variant() == uuid::Variant::RFC4122
|
||||||
|
&& audience.to_string() == pin.app_audience,
|
||||||
|
"Application registration identity changed; preserve the existing pin and migrate explicitly");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn commitment(pin: &RegistrationPin) -> Result<String> {
|
||||||
|
Ok(hex::encode(Sha256::digest(serde_json::to_vec(pin)?)))
|
||||||
|
}
|
||||||
|
fn validate_marker(marker: &Marker, pin: &RegistrationPin) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
marker.version == 1 && marker.app_id == pin.app_id && marker.pin_sha256 == commitment(pin)?,
|
||||||
|
"Application registration initialization record changed; preserve both records"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installer-only provisioning. Run on a blocking worker. Existing data is never
|
||||||
|
/// replaced or repaired by generating another audience. Does not load/create keys.
|
||||||
|
pub fn ensure_for_installation(
|
||||||
|
data_dir: &Path,
|
||||||
|
app_id: &str,
|
||||||
|
identity: &crate::identity::NodeIdentity,
|
||||||
|
) -> Result<RegistrationPin> {
|
||||||
|
let (root, path, marker_path) = paths(data_dir, app_id)?;
|
||||||
|
let mut builder = std::fs::DirBuilder::new();
|
||||||
|
builder.mode(0o700);
|
||||||
|
if let Err(error) = builder.create(&root) {
|
||||||
|
if error.kind() != std::io::ErrorKind::AlreadyExists {
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let held = private_root(&root)?;
|
||||||
|
File::open(data_dir)?.sync_all()?;
|
||||||
|
lock(&held)?;
|
||||||
|
let marker: Option<Marker> = read(&marker_path)?;
|
||||||
|
let pin = match read::<RegistrationPin>(&path)? {
|
||||||
|
Some(pin) => {
|
||||||
|
validate(&pin, app_id, identity)?;
|
||||||
|
pin
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
anyhow::ensure!(marker.is_none(), "Previously provisioned application pin is missing; recover it instead of creating another audience");
|
||||||
|
let pin = RegistrationPin {
|
||||||
|
version: 1,
|
||||||
|
app_id: app_id.into(),
|
||||||
|
node_public_key: identity.pubkey_hex(),
|
||||||
|
node_did: identity.did_key()?,
|
||||||
|
app_audience: uuid::Uuid::new_v4().to_string(),
|
||||||
|
};
|
||||||
|
persist(&root, &path, &pin)?;
|
||||||
|
pin
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(marker) = marker {
|
||||||
|
validate_marker(&marker, &pin)?;
|
||||||
|
} else {
|
||||||
|
persist(
|
||||||
|
&root,
|
||||||
|
&marker_path,
|
||||||
|
&Marker {
|
||||||
|
version: 1,
|
||||||
|
app_id: app_id.into(),
|
||||||
|
pin_sha256: commitment(&pin)?,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
held.sync_all()?;
|
||||||
|
Ok(pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-side lookup never creates installation state. Require this before
|
||||||
|
/// accepting a native registration request for the installed application scope.
|
||||||
|
pub fn load_existing(
|
||||||
|
data_dir: &Path,
|
||||||
|
app_id: &str,
|
||||||
|
identity: &crate::identity::NodeIdentity,
|
||||||
|
) -> Result<RegistrationPin> {
|
||||||
|
let (root, path, marker_path) = paths(data_dir, app_id)?;
|
||||||
|
let held = private_root(&root)?;
|
||||||
|
lock(&held)?;
|
||||||
|
let pin: RegistrationPin =
|
||||||
|
read(&path)?.context("Application registration identity is not provisioned")?;
|
||||||
|
validate(&pin, app_id, identity)?;
|
||||||
|
let marker: Marker =
|
||||||
|
read(&marker_path)?.context("Application registration provisioning is incomplete")?;
|
||||||
|
validate_marker(&marker, &pin)?;
|
||||||
|
Ok(pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installer identity pins are authoritative. No flag enabling registration or
|
||||||
|
/// publication is set, and non-opted-in apps are not modified.
|
||||||
|
pub fn apply_environment(manifest: &mut AppManifest, pin: &RegistrationPin) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
manifest.app.container.media_registration_identity && manifest.app.id == pin.app_id,
|
||||||
|
"Registration pin does not belong to this opted-in application"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(!manifest.app.container.derived_env.iter().any(|entry| ENV_NAMES.contains(&entry.key.as_str()))
|
||||||
|
&& !manifest.app.container.secret_env.iter().any(|entry| ENV_NAMES.contains(&entry.key.as_str()))
|
||||||
|
&& !manifest.app.container.secret_env_refs.iter().any(|entry| ENV_NAMES.contains(&entry.env_key.as_str())),
|
||||||
|
"Registration identity variables cannot be replaced by derived or secret environment entries");
|
||||||
|
manifest.app.environment.retain(|entry| {
|
||||||
|
!entry
|
||||||
|
.split_once('=')
|
||||||
|
.is_some_and(|(key, _)| ENV_NAMES.contains(&key))
|
||||||
|
});
|
||||||
|
manifest.app.environment.extend([
|
||||||
|
format!("{}={}", ENV_NAMES[0], pin.node_public_key),
|
||||||
|
format!("{}={}", ENV_NAMES[1], pin.node_did),
|
||||||
|
format!("{}={}", ENV_NAMES[2], pin.app_audience),
|
||||||
|
]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
async fn fixture() -> (tempfile::TempDir, crate::identity::NodeIdentity) {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let identity = crate::identity::NodeIdentity::load_or_create(&root.path().join("identity"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(root, identity)
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn audience_is_stable_across_retries_reconstruction_and_other_apps_are_distinct() {
|
||||||
|
let (root, identity) = fixture().await;
|
||||||
|
assert!(load_existing(root.path(), "indeedhub-api", &identity).is_err());
|
||||||
|
assert!(!root.path().join(ROOT).exists());
|
||||||
|
let original_key = std::fs::read(root.path().join("identity/node_key")).unwrap();
|
||||||
|
let first = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||||
|
let reloaded = crate::identity::NodeIdentity::load_existing(&root.path().join("identity"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ensure_for_installation(root.path(), "indeedhub-api", &reloaded).unwrap(),
|
||||||
|
first
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
load_existing(root.path(), "indeedhub-api", &reloaded).unwrap(),
|
||||||
|
first
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
ensure_for_installation(root.path(), "another-app", &reloaded)
|
||||||
|
.unwrap()
|
||||||
|
.app_audience,
|
||||||
|
first.app_audience
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.path().join("identity/node_key")).unwrap(),
|
||||||
|
original_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_installers_persist_one_audience() {
|
||||||
|
let (root, identity) = fixture().await;
|
||||||
|
let identity = Arc::new(identity);
|
||||||
|
let workers: Vec<_> = (0..4)
|
||||||
|
.map(|_| {
|
||||||
|
let identity = identity.clone();
|
||||||
|
let path = root.path().to_owned();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
ensure_for_installation(&path, "indeedhub-api", &identity).unwrap()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let results: Vec<_> = workers
|
||||||
|
.into_iter()
|
||||||
|
.map(|worker| worker.join().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(results.iter().all(|pin| pin == &results[0]));
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn corrupt_missing_or_foreign_pin_is_preserved_without_rotation() {
|
||||||
|
let (root, identity) = fixture().await;
|
||||||
|
let first = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||||
|
let (_, path, marker) = paths(root.path(), "indeedhub-api").unwrap();
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let marker_bytes = std::fs::read(&marker).unwrap();
|
||||||
|
let (_other_root, other_identity) = fixture().await;
|
||||||
|
assert!(ensure_for_installation(root.path(), "indeedhub-api", &other_identity).is_err());
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||||
|
std::fs::write(&path, b"damaged").unwrap();
|
||||||
|
assert!(ensure_for_installation(root.path(), "indeedhub-api", &identity).is_err());
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"damaged");
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
assert!(ensure_for_installation(root.path(), "indeedhub-api", &identity).is_err());
|
||||||
|
assert!(!path.exists());
|
||||||
|
assert_eq!(std::fs::read(&marker).unwrap(), marker_bytes);
|
||||||
|
std::fs::write(&path, &bytes).unwrap();
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
load_existing(root.path(), "indeedhub-api", &identity).unwrap(),
|
||||||
|
first
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interrupted_marker_commit_finishes_with_the_same_pin() {
|
||||||
|
let (root, identity) = fixture().await;
|
||||||
|
let first = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||||
|
let (_, path, marker) = paths(root.path(), "indeedhub-api").unwrap();
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
std::fs::remove_file(marker).unwrap();
|
||||||
|
assert!(load_existing(root.path(), "indeedhub-api", &identity).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap(),
|
||||||
|
first
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_pin_symlink_or_manifest_override_is_followed() {
|
||||||
|
let (root, identity) = fixture().await;
|
||||||
|
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
|
||||||
|
let (_, path, _) = paths(root.path(), "indeedhub-api").unwrap();
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
std::os::unix::fs::symlink(root.path().join("identity/node_key"), &path).unwrap();
|
||||||
|
assert!(ensure_for_installation(root.path(), "indeedhub-api", &identity).is_err());
|
||||||
|
let mut manifest = AppManifest::parse("app:\n id: indeedhub-api\n name: Fixture\n version: '1'\n container:\n image: fixture:1\n media_registration_identity: true\n environment:\n - ARCHIPELAGO_REGISTRATION_AUDIENCE=body-value\n").unwrap();
|
||||||
|
apply_environment(&mut manifest, &pin).unwrap();
|
||||||
|
let first = manifest.app.environment.clone();
|
||||||
|
apply_environment(&mut manifest, &pin).unwrap();
|
||||||
|
assert_eq!(manifest.app.environment, first);
|
||||||
|
assert!(first.contains(&format!(
|
||||||
|
"ARCHIPELAGO_REGISTRATION_AUDIENCE={}",
|
||||||
|
pin.app_audience
|
||||||
|
)));
|
||||||
|
assert!(!first.iter().any(|entry| entry.contains("_ENABLED=")));
|
||||||
|
manifest.app.container.media_registration_identity = false;
|
||||||
|
assert!(apply_environment(&mut manifest, &pin).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,27 @@ impl Contract {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accepted seller liability has no automatic expiry or garbage collection.
|
||||||
|
/// The seller must retain immutable snapshot eligibility until settlement or an
|
||||||
|
/// explicit future cancellation/refund protocol resolves this commitment.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct Acceptance {
|
||||||
|
pub contract_hash: String,
|
||||||
|
pub accepted_at: i64,
|
||||||
|
}
|
||||||
|
impl Acceptance {
|
||||||
|
fn validate(&self, contract: &Contract) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.contract_hash == contract.context_hash()?
|
||||||
|
&& self.accepted_at >= contract.offered_at
|
||||||
|
&& self.accepted_at < contract.expires_at,
|
||||||
|
"Seller acceptance does not match the offer"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Private delivery capability; do not log or expose it to another buyer.
|
/// Private delivery capability; do not log or expose it to another buyer.
|
||||||
/// The wire layer must authenticate this receipt before a buyer stores it.
|
/// The wire layer must authenticate this receipt before a buyer stores it.
|
||||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -206,6 +227,7 @@ impl PreparedToken {
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub(crate) enum BuyerPhase {
|
pub(crate) enum BuyerPhase {
|
||||||
Intent,
|
Intent,
|
||||||
|
AcceptanceSaved,
|
||||||
TokenPrepared,
|
TokenPrepared,
|
||||||
ReceiptSaved,
|
ReceiptSaved,
|
||||||
Delivered,
|
Delivered,
|
||||||
@@ -215,10 +237,34 @@ pub(crate) enum BuyerPhase {
|
|||||||
pub(crate) struct BuyerRecord {
|
pub(crate) struct BuyerRecord {
|
||||||
pub contract: Contract,
|
pub contract: Contract,
|
||||||
pub phase: BuyerPhase,
|
pub phase: BuyerPhase,
|
||||||
|
acceptance: Option<Acceptance>,
|
||||||
token: Option<PreparedToken>,
|
token: Option<PreparedToken>,
|
||||||
receipt: Option<Receipt>,
|
receipt: Option<Receipt>,
|
||||||
}
|
}
|
||||||
impl BuyerRecord {
|
impl BuyerRecord {
|
||||||
|
pub fn public_status(&self) -> serde_json::Value {
|
||||||
|
let (state, settlement_confirmed, delivered) = match self.phase {
|
||||||
|
BuyerPhase::Intent => ("intent", false, false),
|
||||||
|
BuyerPhase::AcceptanceSaved => ("accepted_payment_unconfirmed", false, false),
|
||||||
|
BuyerPhase::TokenPrepared => ("token_prepared_settlement_unconfirmed", false, false),
|
||||||
|
BuyerPhase::ReceiptSaved => ("settled_delivery_pending", true, false),
|
||||||
|
BuyerPhase::Delivered => ("delivered", true, true),
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"operation_id": self.contract.id,
|
||||||
|
"content_id": self.contract.content_id,
|
||||||
|
"seller_did": self.contract.seller_did,
|
||||||
|
"state": state,
|
||||||
|
"gross_sats": self.contract.gross_token_sats,
|
||||||
|
"minimum_net_sats": self.contract.minimum_net_sats,
|
||||||
|
"settlement_confirmed": settlement_confirmed,
|
||||||
|
"amount_received": self.receipt().map(|receipt| receipt.amount_received),
|
||||||
|
"delivered": delivered,
|
||||||
|
"recovery_required": !delivered,
|
||||||
|
"can_start_new_payment": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn token(&self) -> Option<&str> {
|
pub fn token(&self) -> Option<&str> {
|
||||||
self.token.as_ref().map(|token| token.encoded.as_str())
|
self.token.as_ref().map(|token| token.encoded.as_str())
|
||||||
}
|
}
|
||||||
@@ -227,6 +273,9 @@ impl BuyerRecord {
|
|||||||
}
|
}
|
||||||
fn validate(&self) -> Result<()> {
|
fn validate(&self) -> Result<()> {
|
||||||
self.contract.validate()?;
|
self.contract.validate()?;
|
||||||
|
if let Some(acceptance) = &self.acceptance {
|
||||||
|
acceptance.validate(&self.contract)?;
|
||||||
|
}
|
||||||
if let Some(token) = &self.token {
|
if let Some(token) = &self.token {
|
||||||
token.validate(&self.contract)?;
|
token.validate(&self.contract)?;
|
||||||
}
|
}
|
||||||
@@ -235,10 +284,14 @@ impl BuyerRecord {
|
|||||||
}
|
}
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
match self.phase {
|
match self.phase {
|
||||||
BuyerPhase::Intent => self.token.is_none() && self.receipt.is_none(),
|
BuyerPhase::Intent =>
|
||||||
BuyerPhase::TokenPrepared => self.token.is_some() && self.receipt.is_none(),
|
self.acceptance.is_none() && self.token.is_none() && self.receipt.is_none(),
|
||||||
|
BuyerPhase::AcceptanceSaved =>
|
||||||
|
self.acceptance.is_some() && self.token.is_none() && self.receipt.is_none(),
|
||||||
|
BuyerPhase::TokenPrepared =>
|
||||||
|
self.acceptance.is_some() && self.token.is_some() && self.receipt.is_none(),
|
||||||
BuyerPhase::ReceiptSaved | BuyerPhase::Delivered =>
|
BuyerPhase::ReceiptSaved | BuyerPhase::Delivered =>
|
||||||
self.token.is_some() && self.receipt.is_some(),
|
self.acceptance.is_some() && self.token.is_some() && self.receipt.is_some(),
|
||||||
},
|
},
|
||||||
"Invalid buyer purchase transition"
|
"Invalid buyer purchase transition"
|
||||||
);
|
);
|
||||||
@@ -256,11 +309,27 @@ pub(crate) enum SellerPhase {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub(crate) struct SellerRecord {
|
pub(crate) struct SellerRecord {
|
||||||
pub contract: Contract,
|
pub contract: Contract,
|
||||||
|
pub accepted_at: i64,
|
||||||
|
token_hash: Option<String>,
|
||||||
pub phase: SellerPhase,
|
pub phase: SellerPhase,
|
||||||
}
|
}
|
||||||
impl SellerRecord {
|
impl SellerRecord {
|
||||||
|
pub fn acceptance(&self) -> Result<Acceptance> {
|
||||||
|
Ok(Acceptance {
|
||||||
|
contract_hash: self.contract.context_hash()?,
|
||||||
|
accepted_at: self.accepted_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
fn validate(&self) -> Result<()> {
|
fn validate(&self) -> Result<()> {
|
||||||
self.contract.validate()?;
|
self.contract.validate()?;
|
||||||
|
self.acceptance()?.validate(&self.contract)?;
|
||||||
|
if let Some(token_hash) = &self.token_hash {
|
||||||
|
anyhow::ensure!(valid_hash(token_hash), "Invalid seller token hash");
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
matches!(self.phase, SellerPhase::Intent) || self.token_hash.is_some(),
|
||||||
|
"Seller token was not durably bound"
|
||||||
|
);
|
||||||
match &self.phase {
|
match &self.phase {
|
||||||
SellerPhase::Intent => (),
|
SellerPhase::Intent => (),
|
||||||
SellerPhase::Settled { amount_received } => {
|
SellerPhase::Settled { amount_received } => {
|
||||||
@@ -458,16 +527,73 @@ impl Journal {
|
|||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
/// Discover existing node-owned intent after browser storage loss. The
|
||||||
|
/// journal lock makes this lookup and prepare_buyer's duplicate guard one
|
||||||
|
/// serialized decision; caller-supplied fresh UUIDs cannot bypass it.
|
||||||
|
pub async fn find_buyers(
|
||||||
|
&self,
|
||||||
|
buyer_did: &str,
|
||||||
|
seller_did: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<Vec<BuyerRecord>> {
|
||||||
|
crate::identity::pubkey_bytes_from_did_key(buyer_did)?;
|
||||||
|
crate::identity::pubkey_bytes_from_did_key(seller_did)?;
|
||||||
|
let mut entries = fs::read_dir(&self.directory).await?;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut count = 0usize;
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
let Some(id) = name
|
||||||
|
.strip_prefix("buyer-")
|
||||||
|
.and_then(|v| v.strip_suffix(".json"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
count += 1;
|
||||||
|
anyhow::ensure!(
|
||||||
|
count <= 10000,
|
||||||
|
"Purchase recovery index requires maintenance; do not pay again"
|
||||||
|
);
|
||||||
|
let record = self
|
||||||
|
.buyer(id)
|
||||||
|
.await?
|
||||||
|
.context("Purchase recovery disappeared")?;
|
||||||
|
if record.contract.buyer_did == buyer_did
|
||||||
|
&& record.contract.seller_did == seller_did
|
||||||
|
&& record.contract.content_id == content_id
|
||||||
|
{
|
||||||
|
result.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort_by(|a, b| a.contract.id.cmp(&b.contract.id));
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn prepare_buyer(&self, contract: &Contract, now: i64) -> Result<BuyerRecord> {
|
pub async fn prepare_buyer(&self, contract: &Contract, now: i64) -> Result<BuyerRecord> {
|
||||||
contract.validate()?;
|
contract.validate()?;
|
||||||
if let Some(record) = self.buyer(&contract.id).await? {
|
if let Some(record) = self.buyer(&contract.id).await? {
|
||||||
anyhow::ensure!(&record.contract == contract, "Buyer purchase terms changed");
|
anyhow::ensure!(&record.contract == contract, "Buyer purchase terms changed");
|
||||||
return Ok(record);
|
return Ok(record);
|
||||||
}
|
}
|
||||||
|
let pending = self
|
||||||
|
.find_buyers(
|
||||||
|
&contract.buyer_did,
|
||||||
|
&contract.seller_did,
|
||||||
|
&contract.content_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
pending
|
||||||
|
.iter()
|
||||||
|
.all(|record| record.phase == BuyerPhase::Delivered),
|
||||||
|
"An existing purchase must be recovered before a new operation is created"
|
||||||
|
);
|
||||||
contract.validate_new_at(now)?;
|
contract.validate_new_at(now)?;
|
||||||
let record = BuyerRecord {
|
let record = BuyerRecord {
|
||||||
contract: contract.clone(),
|
contract: contract.clone(),
|
||||||
phase: BuyerPhase::Intent,
|
phase: BuyerPhase::Intent,
|
||||||
|
acceptance: None,
|
||||||
token: None,
|
token: None,
|
||||||
receipt: None,
|
receipt: None,
|
||||||
};
|
};
|
||||||
@@ -486,6 +612,8 @@ impl Journal {
|
|||||||
contract.validate_new_at(now)?;
|
contract.validate_new_at(now)?;
|
||||||
let record = SellerRecord {
|
let record = SellerRecord {
|
||||||
contract: contract.clone(),
|
contract: contract.clone(),
|
||||||
|
accepted_at: now,
|
||||||
|
token_hash: None,
|
||||||
phase: SellerPhase::Intent,
|
phase: SellerPhase::Intent,
|
||||||
};
|
};
|
||||||
self.write("seller", &contract.id, &record).await?;
|
self.write("seller", &contract.id, &record).await?;
|
||||||
@@ -510,6 +638,56 @@ impl Journal {
|
|||||||
);
|
);
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
/// The transport layer must verify response provenance before passing the
|
||||||
|
/// verified seller DID. A claimed DID or client mint timestamp is insufficient.
|
||||||
|
pub async fn record_acceptance(
|
||||||
|
&self,
|
||||||
|
contract: &Contract,
|
||||||
|
acceptance: &Acceptance,
|
||||||
|
verified_seller_did: &str,
|
||||||
|
) -> Result<BuyerRecord> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
verified_seller_did == contract.seller_did,
|
||||||
|
"Acceptance is from another seller"
|
||||||
|
);
|
||||||
|
let mut record = self.bound_buyer(contract).await?;
|
||||||
|
acceptance.validate(contract)?;
|
||||||
|
if let Some(previous) = &record.acceptance {
|
||||||
|
anyhow::ensure!(previous == acceptance, "Seller acceptance changed");
|
||||||
|
return Ok(record);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
record.phase == BuyerPhase::Intent,
|
||||||
|
"Buyer acceptance phase changed"
|
||||||
|
);
|
||||||
|
record.acceptance = Some(acceptance.clone());
|
||||||
|
record.phase = BuyerPhase::AcceptanceSaved;
|
||||||
|
record.validate()?;
|
||||||
|
self.write("buyer", &contract.id, &record).await?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
/// Bind exact incoming bytes before wallet settlement. Receipt/status replay
|
||||||
|
/// remains possible without bearer token bytes, but settlement cannot change them.
|
||||||
|
pub async fn record_incoming_token(
|
||||||
|
&self,
|
||||||
|
contract: &Contract,
|
||||||
|
encoded: &str,
|
||||||
|
) -> Result<SellerRecord> {
|
||||||
|
let mut record = self.bound_seller(contract).await?;
|
||||||
|
let token = PreparedToken::new(contract, encoded.into())?;
|
||||||
|
if let Some(previous) = &record.token_hash {
|
||||||
|
anyhow::ensure!(previous == &token.sha256, "Seller incoming token changed");
|
||||||
|
return Ok(record);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
matches!(record.phase, SellerPhase::Intent),
|
||||||
|
"Seller settlement phase changed"
|
||||||
|
);
|
||||||
|
record.token_hash = Some(token.sha256);
|
||||||
|
record.validate()?;
|
||||||
|
self.write("seller", &contract.id, &record).await?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
/// Call only with the original correlated recoverable-send result.
|
/// Call only with the original correlated recoverable-send result.
|
||||||
pub async fn record_token(&self, contract: &Contract, encoded: &str) -> Result<BuyerRecord> {
|
pub async fn record_token(&self, contract: &Contract, encoded: &str) -> Result<BuyerRecord> {
|
||||||
let mut record = self.bound_buyer(contract).await?;
|
let mut record = self.bound_buyer(contract).await?;
|
||||||
@@ -522,8 +700,8 @@ impl Journal {
|
|||||||
return Ok(record);
|
return Ok(record);
|
||||||
}
|
}
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
record.phase == BuyerPhase::Intent,
|
record.phase == BuyerPhase::AcceptanceSaved,
|
||||||
"Cannot prepare token in this phase"
|
"Authenticated seller acceptance is not durable"
|
||||||
);
|
);
|
||||||
record.token = Some(token);
|
record.token = Some(token);
|
||||||
record.phase = BuyerPhase::TokenPrepared;
|
record.phase = BuyerPhase::TokenPrepared;
|
||||||
@@ -710,7 +888,19 @@ mod tests {
|
|||||||
assert!(journal.record_token(&contract, &encoded).await.is_err());
|
assert!(journal.record_token(&contract, &encoded).await.is_err());
|
||||||
assert!(journal.record_settlement(&contract, 7).await.is_err());
|
assert!(journal.record_settlement(&contract, 7).await.is_err());
|
||||||
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
||||||
journal.prepare_seller(&contract, 1500).await.unwrap();
|
let seller = journal.prepare_seller(&contract, 1500).await.unwrap();
|
||||||
|
journal
|
||||||
|
.record_acceptance(
|
||||||
|
&contract,
|
||||||
|
&&seller.acceptance().unwrap(),
|
||||||
|
&contract.seller_did,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
journal
|
||||||
|
.record_incoming_token(&contract, &token(&contract, "seller-original"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(journal.issue_receipt(&contract).await.is_err());
|
assert!(journal.issue_receipt(&contract).await.is_err());
|
||||||
journal.record_token(&contract, &encoded).await.unwrap();
|
journal.record_token(&contract, &encoded).await.unwrap();
|
||||||
journal.record_settlement(&contract, 7).await.unwrap();
|
journal.record_settlement(&contract, 7).await.unwrap();
|
||||||
@@ -769,7 +959,19 @@ mod tests {
|
|||||||
let contract = contract();
|
let contract = contract();
|
||||||
let journal = Journal::open(root.path()).await.unwrap();
|
let journal = Journal::open(root.path()).await.unwrap();
|
||||||
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
||||||
journal.prepare_seller(&contract, 1500).await.unwrap();
|
let seller = journal.prepare_seller(&contract, 1500).await.unwrap();
|
||||||
|
journal
|
||||||
|
.record_acceptance(
|
||||||
|
&contract,
|
||||||
|
&&seller.acceptance().unwrap(),
|
||||||
|
&contract.seller_did,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
journal
|
||||||
|
.record_incoming_token(&contract, &token(&contract, "seller-original"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
journal
|
journal
|
||||||
.record_token(&contract, &token(&contract, "first"))
|
.record_token(&contract, &token(&contract, "first"))
|
||||||
.await
|
.await
|
||||||
@@ -870,6 +1072,15 @@ mod tests {
|
|||||||
let contract = contract();
|
let contract = contract();
|
||||||
let mut journal = Journal::open(root.path()).await.unwrap();
|
let mut journal = Journal::open(root.path()).await.unwrap();
|
||||||
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
||||||
|
let seller = journal.prepare_seller(&contract, 1500).await.unwrap();
|
||||||
|
journal
|
||||||
|
.record_acceptance(
|
||||||
|
&contract,
|
||||||
|
&&seller.acceptance().unwrap(),
|
||||||
|
&contract.seller_did,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let reached = std::sync::Arc::new(tokio::sync::Notify::new());
|
let reached = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||||
let resume = std::sync::Arc::new(tokio::sync::Notify::new());
|
let resume = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||||
journal.before_commit = Some((reached.clone(), resume.clone()));
|
journal.before_commit = Some((reached.clone(), resume.clone()));
|
||||||
@@ -886,7 +1097,7 @@ mod tests {
|
|||||||
let journal = Journal::open(root.path()).await.unwrap();
|
let journal = Journal::open(root.path()).await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
journal.buyer(&contract.id).await.unwrap().unwrap().phase,
|
journal.buyer(&contract.id).await.unwrap().unwrap().phase,
|
||||||
BuyerPhase::Intent
|
BuyerPhase::AcceptanceSaved
|
||||||
);
|
);
|
||||||
let current_token = token(&contract, "current");
|
let current_token = token(&contract, "current");
|
||||||
journal
|
journal
|
||||||
@@ -906,4 +1117,74 @@ mod tests {
|
|||||||
assert!(!entry.file_name().to_string_lossy().ends_with(".tmp"));
|
assert!(!entry.file_name().to_string_lossy().ends_with(".tmp"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn node_lookup_retains_pending_purchase_when_client_generates_a_fresh_id() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let first = contract();
|
||||||
|
let journal = Journal::open(root.path()).await.unwrap();
|
||||||
|
journal.prepare_buyer(&first, 1100).await.unwrap();
|
||||||
|
let mut duplicate = first.clone();
|
||||||
|
duplicate.id = uuid::Uuid::new_v4().to_string();
|
||||||
|
assert!(journal.prepare_buyer(&duplicate, 1100).await.is_err());
|
||||||
|
assert!(journal.buyer(&duplicate.id).await.unwrap().is_none());
|
||||||
|
let records = journal
|
||||||
|
.find_buyers(&first.buyer_did, &first.seller_did, &first.content_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].contract, first);
|
||||||
|
duplicate.content_id = "other-file".into();
|
||||||
|
journal.prepare_buyer(&duplicate, 1100).await.unwrap();
|
||||||
|
let records = journal
|
||||||
|
.find_buyers(&first.buyer_did, &first.seller_did, &first.content_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
let mut bytes = fs::read(journal.path("buyer", &first.id).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
bytes[0] = b'!';
|
||||||
|
fs::write(journal.path("buyer", &first.id).unwrap(), bytes)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(journal
|
||||||
|
.find_buyers(&first.buyer_did, &first.seller_did, &first.content_id)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_purchase_status_never_exposes_token_or_delivery_capability() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let contract = contract();
|
||||||
|
let journal = Journal::open(root.path()).await.unwrap();
|
||||||
|
let buyer = journal.prepare_buyer(&contract, 1100).await.unwrap();
|
||||||
|
assert_eq!(buyer.public_status()["state"], "intent");
|
||||||
|
let seller = journal.prepare_seller(&contract, 1100).await.unwrap();
|
||||||
|
journal
|
||||||
|
.record_acceptance(
|
||||||
|
&contract,
|
||||||
|
&seller.acceptance().unwrap(),
|
||||||
|
&contract.seller_did,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let encoded = token(&contract, "private-status-proof");
|
||||||
|
let buyer = journal.record_token(&contract, &encoded).await.unwrap();
|
||||||
|
let status = buyer.public_status();
|
||||||
|
assert_eq!(status["state"], "token_prepared_settlement_unconfirmed");
|
||||||
|
assert_eq!(status["settlement_confirmed"], false);
|
||||||
|
assert!(!status.to_string().contains(&encoded));
|
||||||
|
journal
|
||||||
|
.record_incoming_token(&contract, &encoded)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
journal.record_settlement(&contract, 8).await.unwrap();
|
||||||
|
let receipt = journal.issue_receipt(&contract).await.unwrap();
|
||||||
|
let buyer = journal.record_receipt(&contract, &receipt).await.unwrap();
|
||||||
|
let status = buyer.public_status();
|
||||||
|
assert_eq!(status["state"], "settled_delivery_pending");
|
||||||
|
assert_eq!(status["amount_received"], 8);
|
||||||
|
assert_eq!(status["can_start_new_payment"], false);
|
||||||
|
assert!(!status.to_string().contains(&receipt.capability));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Purchase/wallet sequencing only. The caller handles authenticated transport,
|
||||||
|
//! node-side pending-operation lookup and retained immutable content snapshots.
|
||||||
|
//! No legacy purchase RPC should call this until those boundaries are connected.
|
||||||
|
use crate::{
|
||||||
|
content_purchase::{BuyerPhase, Contract, Journal, Receipt, SellerPhase},
|
||||||
|
wallet::ecash,
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Buyer Intent and authenticated AcceptanceSaved must already be durable.
|
||||||
|
/// This function never allocates a purchase UUID or accepts a client mint time.
|
||||||
|
pub(crate) async fn prepare_buyer_token(data_dir: &Path, contract: &Contract) -> Result<String> {
|
||||||
|
contract.validate()?;
|
||||||
|
{
|
||||||
|
let journal = Journal::open(data_dir).await?;
|
||||||
|
let record = journal
|
||||||
|
.buyer(&contract.id)
|
||||||
|
.await?
|
||||||
|
.context("Buyer intent is not durable")?;
|
||||||
|
anyhow::ensure!(&record.contract == contract, "Buyer purchase terms changed");
|
||||||
|
if let Some(token) = record.token() {
|
||||||
|
return Ok(token.to_owned());
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
record.phase == BuyerPhase::AcceptanceSaved,
|
||||||
|
"Authenticated seller acceptance is not durable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Deadline is enforced inside the wallet after recovering original results,
|
||||||
|
// immediately before a fresh exact send or mint POST. Do not pre-reject an
|
||||||
|
// expired contract here: a previous wallet commit may need to be recovered.
|
||||||
|
let token = ecash::send_token_recoverable_before(
|
||||||
|
data_dir,
|
||||||
|
&contract.id,
|
||||||
|
contract.network,
|
||||||
|
&contract.mint_url,
|
||||||
|
contract.gross_token_sats,
|
||||||
|
&contract.context_hash()?,
|
||||||
|
contract.expires_at,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let journal = Journal::open(data_dir).await?;
|
||||||
|
let record = journal.record_token(contract, &token).await?;
|
||||||
|
Ok(record
|
||||||
|
.token()
|
||||||
|
.context("Prepared buyer token is missing")?
|
||||||
|
.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller verified the buyer's signed exact-body request and bound it to this
|
||||||
|
/// contract. Seller acceptance must predate offer expiry; an existing accepted
|
||||||
|
/// liability honors delayed first settlement, not merely an already minted claim.
|
||||||
|
pub(crate) async fn settle_seller_token(
|
||||||
|
data_dir: &Path,
|
||||||
|
contract: &Contract,
|
||||||
|
encoded: &str,
|
||||||
|
verified_buyer_did: &str,
|
||||||
|
) -> Result<Receipt> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
verified_buyer_did == contract.buyer_did,
|
||||||
|
"Payment is from another buyer"
|
||||||
|
);
|
||||||
|
contract.validate()?;
|
||||||
|
{
|
||||||
|
let journal = Journal::open(data_dir).await?;
|
||||||
|
// Missing acceptance rejects, regardless of current time; this endpoint
|
||||||
|
// cannot manufacture seller acceptance from an arriving payment token.
|
||||||
|
let record = journal.record_incoming_token(contract, encoded).await?;
|
||||||
|
match record.phase {
|
||||||
|
SellerPhase::ReceiptSaved(receipt) => return Ok(receipt),
|
||||||
|
SellerPhase::Settled { .. } => return journal.issue_receipt(contract).await,
|
||||||
|
SellerPhase::Intent => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let received = ecash::receive_token_recoverable(
|
||||||
|
data_dir,
|
||||||
|
&contract.id,
|
||||||
|
contract.network,
|
||||||
|
&contract.mint_url,
|
||||||
|
encoded,
|
||||||
|
contract.minimum_net_sats,
|
||||||
|
&contract.context_hash()?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let journal = Journal::open(data_dir).await?;
|
||||||
|
journal.record_settlement(contract, received).await?;
|
||||||
|
journal.issue_receipt(contract).await
|
||||||
|
}
|
||||||
@@ -115,23 +115,6 @@ async fn save_catalog_unlocked(data_dir: &Path, catalog: &ContentCatalog) -> Res
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes `id` from the on-disk catalog. Best-effort: a failure here just
|
|
||||||
/// means the entry gets pruned again next time it's requested, so errors are
|
|
||||||
/// logged rather than propagated.
|
|
||||||
async fn prune_missing_content_entry(data_dir: &Path, id: &str) {
|
|
||||||
let _lock = CATALOG_WRITES.lock().await;
|
|
||||||
let Ok(mut catalog) = load_catalog(data_dir).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let before = catalog.items.len();
|
|
||||||
catalog.items.retain(|i| i.id != id);
|
|
||||||
if catalog.items.len() != before {
|
|
||||||
if let Err(e) = save_catalog_unlocked(data_dir, &catalog).await {
|
|
||||||
warn!(error = %e, content_id = %id, "failed to save catalog after pruning missing content entry");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the full filesystem path for a content item.
|
/// Get the full filesystem path for a content item.
|
||||||
/// Checks the dedicated content/files/ directory first, then falls back to the
|
/// Checks the dedicated content/files/ directory first, then falls back to the
|
||||||
/// FileBrowser data directory (where users manage files via the web UI).
|
/// FileBrowser data directory (where users manage files via the web UI).
|
||||||
@@ -155,6 +138,46 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
|
|||||||
primary
|
primary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_onchain_payment_price(price_sats: u64) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
price_sats >= 546,
|
||||||
|
"On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file."
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only preflight before issuing a payable invoice/address. This catches
|
||||||
|
/// missing/replaced files but does not substitute for an immutable purchase
|
||||||
|
/// snapshot: later delivery must still preserve the original accepted contract.
|
||||||
|
pub(crate) async fn ensure_payment_source_available(
|
||||||
|
data_dir: &Path,
|
||||||
|
item: &ContentItem,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = content_file_path(data_dir, item);
|
||||||
|
let canonical = fs::canonicalize(&path)
|
||||||
|
.await
|
||||||
|
.context("The shared file is currently unavailable; no payment request was created")?;
|
||||||
|
let mut inside_root = false;
|
||||||
|
for root in [data_dir.join(CONTENT_DIR), data_dir.join("filebrowser")] {
|
||||||
|
if let Ok(root) = fs::canonicalize(root).await {
|
||||||
|
inside_root |= canonical.starts_with(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::ensure!(inside_root, "The shared file is outside the content roots");
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.read(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
||||||
|
let file = options
|
||||||
|
.open(&canonical)
|
||||||
|
.await
|
||||||
|
.context("The shared file cannot be opened; no payment request was created")?;
|
||||||
|
let metadata = file.metadata().await?;
|
||||||
|
anyhow::ensure!(metadata.is_file() && metadata.len() > 0 && metadata.len() == item.size_bytes,
|
||||||
|
"The shared file has changed or is unavailable; refresh its catalog before accepting payment");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a content item to the catalog.
|
/// Add a content item to the catalog.
|
||||||
///
|
///
|
||||||
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
|
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
|
||||||
@@ -404,20 +427,10 @@ where
|
|||||||
|
|
||||||
let file_path = content_file_path(data_dir, item);
|
let file_path = content_file_path(data_dir, item);
|
||||||
if !file_path.exists() {
|
if !file_path.exists() {
|
||||||
// The catalog entry survived (it's a separate JSON file) but its
|
// A disconnected mount, moved file or permission failure is not an
|
||||||
// backing file is gone — most likely lost in an unrelated data-dir
|
// instruction to unshare content or erase its purchase metadata.
|
||||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
warn!(content_id = %id, "Shared content is temporarily unavailable; catalog retained");
|
||||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
return Ok(ServeResult::Unavailable);
|
||||||
// Leaving the entry in place would keep advertising it as available
|
|
||||||
// to every peer forever, each hitting the exact same dead end this
|
|
||||||
// one just did. Prune it so it stops being offered.
|
|
||||||
warn!(
|
|
||||||
content_id = %id,
|
|
||||||
filename = %item.filename,
|
|
||||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
|
||||||
);
|
|
||||||
prune_missing_content_entry(data_dir, id).await;
|
|
||||||
return Ok(ServeResult::NotFound);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refuse unauthorized viewers before opening or reading any bytes.
|
// Refuse unauthorized viewers before opening or reading any bytes.
|
||||||
@@ -981,11 +994,11 @@ mod faststart_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod prune_missing_content_tests {
|
mod unavailable_content_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serve_content_prunes_catalog_entry_whose_file_is_missing() {
|
async fn unavailable_file_retains_identity_and_recovers_when_storage_returns() {
|
||||||
// Simulates a catalog entry that outlived its backing file (a shared
|
// Simulates a catalog entry that outlived its backing file (a shared
|
||||||
// filebrowser file lost in an unrelated data-dir reset, 2026-07-01) —
|
// filebrowser file lost in an unrelated data-dir reset, 2026-07-01) —
|
||||||
// every peer request for it would otherwise 404 forever with no way
|
// every peer request for it would otherwise 404 forever with no way
|
||||||
@@ -1010,17 +1023,24 @@ mod prune_missing_content_tests {
|
|||||||
let result = serve_content(data_dir, "missing-item", None, None, None, None, false)
|
let result = serve_content(data_dir, "missing-item", None, None, None, None, false)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(result, ServeResult::NotFound));
|
assert!(matches!(result, ServeResult::Unavailable));
|
||||||
|
|
||||||
let reloaded = load_catalog(data_dir).await.unwrap();
|
let reloaded = load_catalog(data_dir).await.unwrap();
|
||||||
assert!(
|
assert_eq!(reloaded.items.len(), 1);
|
||||||
reloaded.items.is_empty(),
|
assert_eq!(reloaded.items[0].id, "missing-item");
|
||||||
"stale entry should have been pruned after the 404"
|
fs::create_dir_all(data_dir.join("filebrowser"))
|
||||||
);
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fs::write(data_dir.join("filebrowser/gone.mp4"), b"recovered")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = serve_content(data_dir, "missing-item", None, None, None, None, false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"recovered"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serve_content_leaves_other_entries_untouched_when_pruning() {
|
async fn unavailable_file_does_not_rewrite_any_catalog_entries() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let data_dir = dir.path();
|
let data_dir = dir.path();
|
||||||
let missing = ContentItem {
|
let missing = ContentItem {
|
||||||
@@ -1062,8 +1082,9 @@ mod prune_missing_content_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let reloaded = load_catalog(data_dir).await.unwrap();
|
let reloaded = load_catalog(data_dir).await.unwrap();
|
||||||
assert_eq!(reloaded.items.len(), 1);
|
assert_eq!(reloaded.items.len(), 2);
|
||||||
assert_eq!(reloaded.items[0].id, "present-item");
|
assert_eq!(reloaded.items[0].id, "missing-item");
|
||||||
|
assert_eq!(reloaded.items[1].id, "present-item");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1817,3 +1838,62 @@ mod preview_boundary_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod payment_source_tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn onchain_quote_preserves_wallet_minimum_boundary() {
|
||||||
|
assert!(validate_onchain_payment_price(545).is_err());
|
||||||
|
assert!(validate_onchain_payment_price(546).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn payment_preflight_rejects_missing_changed_directory_and_escaped_sources() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let item = ContentItem {
|
||||||
|
id: "paid".into(),
|
||||||
|
filename: "Photos/clip.mp4".into(),
|
||||||
|
mime_type: "video/mp4".into(),
|
||||||
|
size_bytes: 4,
|
||||||
|
description: String::new(),
|
||||||
|
access: AccessControl::Paid {
|
||||||
|
price_sats: 2,
|
||||||
|
accepted: vec![],
|
||||||
|
},
|
||||||
|
availability: Availability::AllPeers,
|
||||||
|
added_at: String::new(),
|
||||||
|
};
|
||||||
|
assert!(ensure_payment_source_available(root.path(), &item)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
fs::create_dir_all(root.path().join("filebrowser/Photos"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let path = root.path().join("filebrowser/Photos/clip.mp4");
|
||||||
|
fs::write(&path, b"film").await.unwrap();
|
||||||
|
ensure_payment_source_available(root.path(), &item)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fs::write(&path, b"changed").await.unwrap();
|
||||||
|
assert!(ensure_payment_source_available(root.path(), &item)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
fs::remove_file(&path).await.unwrap();
|
||||||
|
fs::create_dir(&path).await.unwrap();
|
||||||
|
assert!(ensure_payment_source_available(root.path(), &item)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
fs::remove_dir(&path).await.unwrap();
|
||||||
|
let outside = tempfile::tempdir().unwrap();
|
||||||
|
let outside_file = outside.path().join("film");
|
||||||
|
fs::write(&outside_file, b"film").await.unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
std::os::unix::fs::symlink(&outside_file, &path).unwrap();
|
||||||
|
assert!(ensure_payment_source_available(root.path(), &item)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
//! Retained versioned snapshots for explicitly shared Cloud files. Reuses the
|
||||||
|
//! confined descriptor and durable-copy primitives of media registration.
|
||||||
|
//! Call from spawn_blocking; ownership/visibility is authenticated by the caller.
|
||||||
|
use crate::media_registration::{self as io, Limits, SourceStamp};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
os::unix::{
|
||||||
|
fs::{MetadataExt, PermissionsExt},
|
||||||
|
io::AsRawFd,
|
||||||
|
},
|
||||||
|
path::Path,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Record {
|
||||||
|
version: u8,
|
||||||
|
content_id: String,
|
||||||
|
source: SourceStamp,
|
||||||
|
snapshot: SourceStamp,
|
||||||
|
sha256: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Envelope {
|
||||||
|
record: Record,
|
||||||
|
checksum: String,
|
||||||
|
}
|
||||||
|
fn read_manifest(version: &File) -> Result<Option<Record>> {
|
||||||
|
let Some(saved) = io::read_record::<Envelope>(version, "snapshot.json")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
saved.checksum == hash(&serde_json::to_vec(&saved.record)?),
|
||||||
|
"Snapshot metadata is damaged; preserve accepted purchases"
|
||||||
|
);
|
||||||
|
Ok(Some(saved.record))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Snapshot {
|
||||||
|
pub file: File,
|
||||||
|
pub key: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash(bytes: &[u8]) -> String {
|
||||||
|
hex::encode(Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only callers holding a catalog item selected from this node's owner-managed
|
||||||
|
/// catalog may call this. Paths are confined to the supplied configured root.
|
||||||
|
/// max_total_bytes counts incomplete files too; failed quotes cannot fill disk.
|
||||||
|
pub(crate) fn prepare(
|
||||||
|
data_dir: &Path,
|
||||||
|
cloud_root: &Path,
|
||||||
|
content_id: &str,
|
||||||
|
relative: &Path,
|
||||||
|
limits: &Limits<'_>,
|
||||||
|
max_total_bytes: u64,
|
||||||
|
minimum_free_bytes: u64,
|
||||||
|
mut progress: impl FnMut(u64) -> Result<()>,
|
||||||
|
) -> Result<Snapshot> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
!content_id.is_empty()
|
||||||
|
&& content_id.len() <= 256
|
||||||
|
&& content_id
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c)),
|
||||||
|
"Invalid shared content identity"
|
||||||
|
);
|
||||||
|
let data = io::open_directory(&data_dir.canonicalize()?)?;
|
||||||
|
let root = io::private_directory(&data, "content-snapshots")?;
|
||||||
|
// One copy admission decision at a time also makes quota accounting exact.
|
||||||
|
io::lock_operation(&root, limits, Instant::now() + Duration::from_secs(30))?;
|
||||||
|
let cloud = io::open_directory(&cloud_root.canonicalize()?)?;
|
||||||
|
let mut source = io::open_cloud_file(&cloud, relative)?;
|
||||||
|
let source_stamp = SourceStamp::read(&source)?;
|
||||||
|
let size = source.metadata()?.len();
|
||||||
|
anyhow::ensure!(
|
||||||
|
size > 0 && size <= limits.max_bytes,
|
||||||
|
"Shared file exceeds snapshot limits"
|
||||||
|
);
|
||||||
|
let key = hash(&serde_json::to_vec(&(
|
||||||
|
"shared-content-snapshot-v1",
|
||||||
|
content_id,
|
||||||
|
relative,
|
||||||
|
&source_stamp,
|
||||||
|
))?);
|
||||||
|
let mut versions = 0usize;
|
||||||
|
let mut existing_version = false;
|
||||||
|
for entry in std::fs::read_dir(format!("/proc/self/fd/{}", root.as_raw_fd()))? {
|
||||||
|
let entry = entry?;
|
||||||
|
versions += 1;
|
||||||
|
existing_version |= entry.file_name().to_str() == Some(&key);
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
existing_version || versions < 4096,
|
||||||
|
"Shared snapshot version budget is full; retain existing purchases"
|
||||||
|
);
|
||||||
|
let version = io::private_directory(&root, &key)?;
|
||||||
|
if let Some(record) = read_manifest(&version)? {
|
||||||
|
anyhow::ensure!(
|
||||||
|
record.version == 1 && record.content_id == content_id && record.source == source_stamp,
|
||||||
|
"Shared snapshot identity changed; preserve it for recovery"
|
||||||
|
);
|
||||||
|
let file = io::open_at(&version, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
SourceStamp::read(&file)? == record.snapshot
|
||||||
|
&& file.metadata()?.mode() & 0o7777 == 0o400
|
||||||
|
&& file.metadata()?.len() == record.size,
|
||||||
|
"Retained shared snapshot changed; preserve accepted purchases"
|
||||||
|
);
|
||||||
|
return Ok(Snapshot {
|
||||||
|
file,
|
||||||
|
key,
|
||||||
|
sha256: record.sha256,
|
||||||
|
size: record.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A crash can publish immutable bytes before the small manifest commit.
|
||||||
|
// Recover those exact bytes by checking both held descriptors; never replace
|
||||||
|
// an existing media file or manufacture a hash from a path/size alone.
|
||||||
|
match io::open_at(&version, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
let stamp = SourceStamp::read(&file)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
file.metadata()?.mode() & 0o7777 == 0o400 && file.metadata()?.len() == size,
|
||||||
|
"Incomplete retained snapshot; preserve it for recovery"
|
||||||
|
);
|
||||||
|
let (snapshot_hash, snapshot_size) =
|
||||||
|
io::hash_file(&mut file, None, limits, &mut progress)?;
|
||||||
|
let (source_hash, source_size) =
|
||||||
|
io::hash_file(&mut source, None, limits, &mut progress)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
snapshot_size == size
|
||||||
|
&& source_size == size
|
||||||
|
&& snapshot_hash == source_hash
|
||||||
|
&& SourceStamp::read(&source)? == source_stamp
|
||||||
|
&& SourceStamp::read(&file)? == stamp,
|
||||||
|
"Interrupted snapshot does not match the selected shared version"
|
||||||
|
);
|
||||||
|
let record = Record {
|
||||||
|
version: 1,
|
||||||
|
content_id: content_id.into(),
|
||||||
|
source: source_stamp,
|
||||||
|
snapshot: stamp,
|
||||||
|
sha256: snapshot_hash.clone(),
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
let checksum = hash(&serde_json::to_vec(&record)?);
|
||||||
|
io::save_record(&version, "snapshot.json", &Envelope { record, checksum })?;
|
||||||
|
use std::io::{Seek, SeekFrom};
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
return Ok(Snapshot {
|
||||||
|
file,
|
||||||
|
key,
|
||||||
|
sha256: snapshot_hash,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error)
|
||||||
|
if error
|
||||||
|
.downcast_ref::<std::io::Error>()
|
||||||
|
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) =>
|
||||||
|
{
|
||||||
|
()
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
let used = storage_bytes(&root)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
used.checked_add(size)
|
||||||
|
.and_then(|v| v.checked_add(128 * 1024))
|
||||||
|
.is_some_and(|total| total <= max_total_bytes),
|
||||||
|
"Shared snapshot storage budget is full; no new purchase accepted"
|
||||||
|
);
|
||||||
|
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
|
||||||
|
anyhow::ensure!(
|
||||||
|
unsafe { libc::fstatvfs(root.as_raw_fd(), stat.as_mut_ptr()) } == 0,
|
||||||
|
"Could not verify snapshot disk space"
|
||||||
|
);
|
||||||
|
let stat = unsafe { stat.assume_init() };
|
||||||
|
let free = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
|
||||||
|
anyhow::ensure!(
|
||||||
|
free >= size.saturating_add(minimum_free_bytes),
|
||||||
|
"Not enough free storage for a retained purchase snapshot"
|
||||||
|
);
|
||||||
|
let (name, mut destination) = io::temporary(&version)?;
|
||||||
|
let mut temporary = Temporary {
|
||||||
|
directory: &version,
|
||||||
|
name: name.clone(),
|
||||||
|
};
|
||||||
|
let (sha256, copied) =
|
||||||
|
io::hash_file(&mut source, Some(&mut destination), limits, &mut progress)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
copied == size && SourceStamp::read(&source)? == source_stamp,
|
||||||
|
"Shared file changed during snapshot creation; no purchase accepted"
|
||||||
|
);
|
||||||
|
destination.set_permissions(std::fs::Permissions::from_mode(0o400))?;
|
||||||
|
destination.sync_all()?;
|
||||||
|
io::publish_file(&version, &name, "media")?;
|
||||||
|
temporary.name.clear();
|
||||||
|
let file = io::open_at(&version, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0)?;
|
||||||
|
let record = Record {
|
||||||
|
version: 1,
|
||||||
|
content_id: content_id.into(),
|
||||||
|
source: source_stamp,
|
||||||
|
snapshot: SourceStamp::read(&file)?,
|
||||||
|
sha256: sha256.clone(),
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
let checksum = hash(&serde_json::to_vec(&record)?);
|
||||||
|
io::save_record(&version, "snapshot.json", &Envelope { record, checksum })?;
|
||||||
|
Ok(Snapshot {
|
||||||
|
file,
|
||||||
|
key,
|
||||||
|
sha256,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open retained accepted bytes even after the original Cloud source vanishes.
|
||||||
|
/// Caller supplies the durable contract's identity/hash/size, not a browser path.
|
||||||
|
pub(crate) fn open_matching(
|
||||||
|
data_dir: &Path,
|
||||||
|
content_id: &str,
|
||||||
|
sha256: &str,
|
||||||
|
size: u64,
|
||||||
|
) -> Result<Snapshot> {
|
||||||
|
let data = io::open_directory(&data_dir.canonicalize()?)?;
|
||||||
|
let root = io::open_at(
|
||||||
|
&data,
|
||||||
|
"content-snapshots",
|
||||||
|
libc::O_RDONLY | libc::O_DIRECTORY,
|
||||||
|
0,
|
||||||
|
)?;
|
||||||
|
let mut count = 0usize;
|
||||||
|
for entry in std::fs::read_dir(format!("/proc/self/fd/{}", root.as_raw_fd()))? {
|
||||||
|
let entry = entry?;
|
||||||
|
count += 1;
|
||||||
|
anyhow::ensure!(count <= 10000, "Snapshot index requires maintenance");
|
||||||
|
let key = entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.context("Invalid snapshot version")?
|
||||||
|
.to_owned();
|
||||||
|
anyhow::ensure!(
|
||||||
|
key.len() == 64 && key.bytes().all(|v| v.is_ascii_hexdigit()),
|
||||||
|
"Unexpected snapshot version"
|
||||||
|
);
|
||||||
|
let version = io::open_at(&root, &key, libc::O_RDONLY | libc::O_DIRECTORY, 0)?;
|
||||||
|
let Some(record) = read_manifest(&version)? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if record.content_id != content_id || record.sha256 != sha256 || record.size != size {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let file = io::open_at(&version, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
record.version == 1
|
||||||
|
&& SourceStamp::read(&file)? == record.snapshot
|
||||||
|
&& file.metadata()?.mode() & 0o7777 == 0o400,
|
||||||
|
"Accepted snapshot changed; retain purchase for recovery"
|
||||||
|
);
|
||||||
|
return Ok(Snapshot {
|
||||||
|
file,
|
||||||
|
key,
|
||||||
|
sha256: record.sha256,
|
||||||
|
size: record.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
anyhow::bail!("Accepted content snapshot is unavailable; retain purchase for recovery")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn storage_bytes(directory: &File) -> Result<u64> {
|
||||||
|
let mut total = 0u64;
|
||||||
|
for entry in std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_str().context("Invalid snapshot filename")?;
|
||||||
|
let metadata = std::fs::symlink_metadata(entry.path())?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
!metadata.file_type().is_symlink(),
|
||||||
|
"Snapshot storage contains an unexpected symlink"
|
||||||
|
);
|
||||||
|
let size = if metadata.is_dir() {
|
||||||
|
storage_bytes(&io::open_at(
|
||||||
|
directory,
|
||||||
|
name,
|
||||||
|
libc::O_RDONLY | libc::O_DIRECTORY,
|
||||||
|
0,
|
||||||
|
)?)?
|
||||||
|
} else {
|
||||||
|
anyhow::ensure!(metadata.is_file(), "Unexpected snapshot storage entry");
|
||||||
|
metadata.len()
|
||||||
|
};
|
||||||
|
total = total
|
||||||
|
.checked_add(size)
|
||||||
|
.context("Snapshot storage accounting overflow")?;
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Temporary<'a> {
|
||||||
|
directory: &'a File,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
impl Drop for Temporary<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(name) = std::ffi::CString::new(self.name.as_str()) {
|
||||||
|
if !self.name.is_empty() {
|
||||||
|
unsafe {
|
||||||
|
libc::unlinkat(self.directory.as_raw_fd(), name.as_ptr(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{io::Read, sync::atomic::AtomicBool};
|
||||||
|
#[test]
|
||||||
|
fn quotes_reuse_one_version_and_accepted_bytes_survive_source_replacement() {
|
||||||
|
let data = tempfile::tempdir().unwrap();
|
||||||
|
let cloud = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(cloud.path().join("film.mp4"), b"old film").unwrap();
|
||||||
|
let cancelled = AtomicBool::new(false);
|
||||||
|
let limits = Limits {
|
||||||
|
max_bytes: 1024,
|
||||||
|
cancelled: &cancelled,
|
||||||
|
};
|
||||||
|
let first = prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film.mp4"),
|
||||||
|
&limits,
|
||||||
|
1024 * 1024,
|
||||||
|
0,
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut copied = 0;
|
||||||
|
let replay = prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film.mp4"),
|
||||||
|
&limits,
|
||||||
|
1024 * 1024,
|
||||||
|
0,
|
||||||
|
|bytes| {
|
||||||
|
copied = bytes;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(replay.key, first.key);
|
||||||
|
assert_eq!(copied, 0, "a new buyer quote must not recopy the file");
|
||||||
|
std::fs::write(cloud.path().join("film.mp4"), b"new movie").unwrap();
|
||||||
|
let next = prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film.mp4"),
|
||||||
|
&limits,
|
||||||
|
1024 * 1024,
|
||||||
|
0,
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(first.key, next.key);
|
||||||
|
std::fs::remove_file(cloud.path().join("film.mp4")).unwrap();
|
||||||
|
let mut retained = open_matching(data.path(), "film", &first.sha256, first.size).unwrap();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
retained.file.read_to_end(&mut bytes).unwrap();
|
||||||
|
assert_eq!(bytes, b"old film");
|
||||||
|
assert!(open_matching(data.path(), "other", &first.sha256, first.size).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn quotas_cancellation_and_source_mutation_never_issue_a_snapshot() {
|
||||||
|
let data = tempfile::tempdir().unwrap();
|
||||||
|
let cloud = tempfile::tempdir().unwrap();
|
||||||
|
let path = cloud.path().join("film");
|
||||||
|
std::fs::write(&path, vec![7u8; 100000]).unwrap();
|
||||||
|
let cancelled = AtomicBool::new(false);
|
||||||
|
let limits = Limits {
|
||||||
|
max_bytes: 200000,
|
||||||
|
cancelled: &cancelled,
|
||||||
|
};
|
||||||
|
assert!(prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film"),
|
||||||
|
&limits,
|
||||||
|
50000,
|
||||||
|
0,
|
||||||
|
|_| Ok(())
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film"),
|
||||||
|
&limits,
|
||||||
|
1024 * 1024,
|
||||||
|
0,
|
||||||
|
|_| {
|
||||||
|
std::fs::write(&path, b"changed")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
assert!(prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film"),
|
||||||
|
&limits,
|
||||||
|
1024 * 1024,
|
||||||
|
0,
|
||||||
|
|_| Ok(())
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn interrupted_manifest_commit_recovers_same_snapshot_without_replacing_bytes() {
|
||||||
|
let data = tempfile::tempdir().unwrap();
|
||||||
|
let cloud = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(cloud.path().join("film"), b"film").unwrap();
|
||||||
|
let cancelled = AtomicBool::new(false);
|
||||||
|
let limits = Limits {
|
||||||
|
max_bytes: 1024,
|
||||||
|
cancelled: &cancelled,
|
||||||
|
};
|
||||||
|
let first = prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film"),
|
||||||
|
&limits,
|
||||||
|
1024 * 1024,
|
||||||
|
0,
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let inode = first.file.metadata().unwrap().ino();
|
||||||
|
std::fs::remove_file(
|
||||||
|
data.path()
|
||||||
|
.join("content-snapshots")
|
||||||
|
.join(&first.key)
|
||||||
|
.join("snapshot.json"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let restored = prepare(
|
||||||
|
data.path(),
|
||||||
|
cloud.path(),
|
||||||
|
"film",
|
||||||
|
Path::new("film"),
|
||||||
|
&limits,
|
||||||
|
1024 * 1024,
|
||||||
|
0,
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(restored.file.metadata().unwrap().ino(), inode);
|
||||||
|
assert_eq!(restored.sha256, first.sha256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub(crate) use invites::notify_join;
|
|||||||
// Crate-internal: peer-joined resolves the granted trust level by matching
|
// Crate-internal: peer-joined resolves the granted trust level by matching
|
||||||
// the acceptor's invite_token against our stored outgoing invites.
|
// the acceptor's invite_token against our stored outgoing invites.
|
||||||
pub(crate) use storage::load_invites;
|
pub(crate) use storage::load_invites;
|
||||||
|
pub(crate) use storage::load_unique_payment_peer;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||||
|
|||||||
@@ -71,6 +71,36 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
|||||||
load_nodes_inner(data_dir).await
|
load_nodes_inner(data_dir).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve payment identity from persisted records before display deduplication
|
||||||
|
/// can merge fields from different identities sharing an address.
|
||||||
|
pub(crate) async fn load_unique_payment_peer(
|
||||||
|
data_dir: &Path,
|
||||||
|
onion: &str,
|
||||||
|
) -> Result<FederatedNode> {
|
||||||
|
let _guard = FEDERATION_STORE_LOCK.lock().await;
|
||||||
|
let content = fs::read(data_dir.join(FEDERATION_DIR).join(NODES_FILE))
|
||||||
|
.await
|
||||||
|
.context("Could not read payment peer bindings")?;
|
||||||
|
let file: NodesFile =
|
||||||
|
serde_json::from_slice(&content).context("Invalid payment peer bindings")?;
|
||||||
|
let target = onion.strip_suffix(".onion").unwrap_or(onion);
|
||||||
|
anyhow::ensure!(!target.is_empty(), "Missing payment peer address");
|
||||||
|
let mut matches = file
|
||||||
|
.nodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|peer| peer.onion.strip_suffix(".onion").unwrap_or(&peer.onion) == target);
|
||||||
|
let peer = matches.next().context("Payment peer binding is missing")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
matches.next().is_none(),
|
||||||
|
"Payment peer binding is ambiguous"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
crate::identity::did_key_from_pubkey_hex(&peer.pubkey)? == peer.did,
|
||||||
|
"Payment peer identity does not match its public key"
|
||||||
|
);
|
||||||
|
Ok(peer)
|
||||||
|
}
|
||||||
|
|
||||||
/// Lock-free body of `load_nodes`. Callers that already hold
|
/// Lock-free body of `load_nodes`. Callers that already hold
|
||||||
/// `FEDERATION_STORE_LOCK` (i.e. other functions in this module composing a
|
/// `FEDERATION_STORE_LOCK` (i.e. other functions in this module composing a
|
||||||
/// multi-step critical section) must call this instead of `load_nodes` to
|
/// multi-step critical section) must call this instead of `load_nodes` to
|
||||||
|
|||||||
@@ -511,6 +511,66 @@ impl<'a> PeerRequest<'a> {
|
|||||||
self.authenticate_content(data_dir).await?.send_get().await
|
self.authenticate_content(data_dir).await?.send_get().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a purchase request with the exact serialized bytes bound to its peer
|
||||||
|
/// proof. The expected seller must match the existing authenticated binding.
|
||||||
|
/// An ambiguous reply is recovered by the purchase journal, never by this
|
||||||
|
/// transport replaying a token through another route.
|
||||||
|
pub(crate) async fn send_content_json<B: serde::Serialize>(
|
||||||
|
self,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
expected_seller: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
||||||
|
let (request, encoded) = self
|
||||||
|
.prepare_content_json(data_dir, expected_seller, body)
|
||||||
|
.await?;
|
||||||
|
request.send_encoded_json(&encoded).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_content_json<B: serde::Serialize>(
|
||||||
|
mut self,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
expected_seller: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<(Self, Vec<u8>)> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.path.starts_with("/content/purchase/"),
|
||||||
|
"Not a purchase route"
|
||||||
|
);
|
||||||
|
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
peer.did == expected_seller,
|
||||||
|
"Purchase seller does not match the authenticated peer binding"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
self.fips_npub.is_some_and(|npub| !npub.is_empty())
|
||||||
|
&& peer.fips_npub.as_deref() == self.fips_npub,
|
||||||
|
"Purchase mesh route does not match the authenticated peer binding"
|
||||||
|
);
|
||||||
|
let identity =
|
||||||
|
crate::identity::NodeIdentity::load_existing(&data_dir.join("identity")).await?;
|
||||||
|
let encoded = serde_json::to_vec(body).context("Encode purchase request")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
encoded.len() <= 1024 * 1024,
|
||||||
|
"Purchase request is too large"
|
||||||
|
);
|
||||||
|
let proof = crate::content_auth::sign_request(
|
||||||
|
&identity,
|
||||||
|
expected_seller,
|
||||||
|
&hyper::Method::POST,
|
||||||
|
self.path,
|
||||||
|
&encoded,
|
||||||
|
chrono::Utc::now().timestamp(),
|
||||||
|
)?;
|
||||||
|
self.headers
|
||||||
|
.retain(|(name, _)| !name.eq_ignore_ascii_case(crate::content_auth::REQUEST_HEADER));
|
||||||
|
self.headers
|
||||||
|
.push((crate::content_auth::REQUEST_HEADER, proof));
|
||||||
|
self.single_delivery = true;
|
||||||
|
self.require_fips = true;
|
||||||
|
Ok((self, encoded))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn header(mut self, name: &'a str, value: impl Into<String>) -> Self {
|
pub fn header(mut self, name: &'a str, value: impl Into<String>) -> Self {
|
||||||
self.headers.push((name, value.into()));
|
self.headers.push((name, value.into()));
|
||||||
self
|
self
|
||||||
@@ -537,12 +597,21 @@ impl<'a> PeerRequest<'a> {
|
|||||||
pub async fn send_json<B: serde::Serialize>(
|
pub async fn send_json<B: serde::Serialize>(
|
||||||
&self,
|
&self,
|
||||||
body: &B,
|
body: &B,
|
||||||
|
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
||||||
|
let encoded = serde_json::to_vec(body).context("Encode peer JSON request")?;
|
||||||
|
self.send_encoded_json(&encoded).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize once: FIPS attempts and any permitted fallback use identical bytes.
|
||||||
|
async fn send_encoded_json(
|
||||||
|
&self,
|
||||||
|
body: &[u8],
|
||||||
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
||||||
use crate::settings::transport::TransportPref;
|
use crate::settings::transport::TransportPref;
|
||||||
let pref = self.preference().await;
|
let pref = self.preference().await;
|
||||||
// FIPS-only or Auto: try FIPS first.
|
// FIPS-only or Auto: try FIPS first.
|
||||||
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
|
||||||
match self.try_fips_post_json(body).await? {
|
match self.try_fips_post_bytes(body).await? {
|
||||||
Some(resp) => {
|
Some(resp) => {
|
||||||
// Use the FIPS reply unless it's one a Tor retry could
|
// Use the FIPS reply unless it's one a Tor retry could
|
||||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||||
@@ -574,7 +643,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let resp = self.send_tor_post_json(body).await?;
|
let resp = self.send_tor_post_bytes(body).await?;
|
||||||
self.spawn_record(crate::transport::TransportKind::Tor);
|
self.spawn_record(crate::transport::TransportKind::Tor);
|
||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
@@ -618,10 +687,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_fips_post_json<B: serde::Serialize>(
|
async fn try_fips_post_bytes(&self, body: &[u8]) -> Result<Option<reqwest::Response>> {
|
||||||
&self,
|
|
||||||
body: &B,
|
|
||||||
) -> Result<Option<reqwest::Response>> {
|
|
||||||
let Some(npub) = self.fips_npub else {
|
let Some(npub) = self.fips_npub else {
|
||||||
telemetry::record_fallback(FallbackReason::NoNpub);
|
telemetry::record_fallback(FallbackReason::NoNpub);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -657,7 +723,10 @@ impl<'a> PeerRequest<'a> {
|
|||||||
budget
|
budget
|
||||||
};
|
};
|
||||||
let c = client_with_delivery_policy(per_attempt, self.single_delivery || self.require_fips);
|
let c = client_with_delivery_policy(per_attempt, self.single_delivery || self.require_fips);
|
||||||
let mut rb = c.post(&url).json(body);
|
let mut rb = c
|
||||||
|
.post(&url)
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(body.to_vec());
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
@@ -770,10 +839,13 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_tor_post_json<B: serde::Serialize>(&self, body: &B) -> Result<reqwest::Response> {
|
async fn send_tor_post_bytes(&self, body: &[u8]) -> Result<reqwest::Response> {
|
||||||
let url = self.tor_url();
|
let url = self.tor_url();
|
||||||
let client = self.tor_client()?;
|
let client = self.tor_client()?;
|
||||||
let mut rb = client.post(&url).json(body);
|
let mut rb = client
|
||||||
|
.post(&url)
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(body.to_vec());
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
@@ -815,6 +887,120 @@ impl<'a> PeerRequest<'a> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
struct Counted(AtomicUsize);
|
||||||
|
impl serde::Serialize for Counted {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_u64(self.0.fetch_add(1, Ordering::SeqCst) as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let identity = crate::identity::NodeIdentity::load_or_create(&dir.path().join("identity"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let seller = crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap();
|
||||||
|
tokio::fs::create_dir(dir.path().join("federation"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let peer = serde_json::json!({"did":seller,"pubkey":hex::encode([7;32]),"onion":"seller.onion",
|
||||||
|
"trust_level":"trusted","added_at":"2026-10-06T00:00:00Z","fips_npub":"npub-test-seller"});
|
||||||
|
tokio::fs::write(
|
||||||
|
dir.path().join("federation/nodes.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({"nodes":[peer.clone()]})).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = Counted(AtomicUsize::new(0));
|
||||||
|
let path = "/content/purchase/settle";
|
||||||
|
let (request, bytes) = PeerRequest::new(Some("npub-test-seller"), "seller.onion", path)
|
||||||
|
.prepare_content_json(dir.path(), &seller, &body)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.0.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(bytes, b"0");
|
||||||
|
assert!(request.require_fips && request.single_delivery);
|
||||||
|
let mut headers = hyper::HeaderMap::new();
|
||||||
|
for (key, value) in request.headers {
|
||||||
|
headers.insert(
|
||||||
|
hyper::header::HeaderName::from_bytes(key.as_bytes()).unwrap(),
|
||||||
|
value.parse().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
crate::content_auth::authenticate_request(
|
||||||
|
&headers,
|
||||||
|
&seller,
|
||||||
|
&hyper::Method::POST,
|
||||||
|
path,
|
||||||
|
&bytes,
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
identity.did_key().unwrap()
|
||||||
|
);
|
||||||
|
assert!(crate::content_auth::authenticate_request(
|
||||||
|
&headers,
|
||||||
|
&seller,
|
||||||
|
&hyper::Method::POST,
|
||||||
|
path,
|
||||||
|
b"1",
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
for (npub, did) in [
|
||||||
|
(Some("wrong-route"), seller.as_str()),
|
||||||
|
(None, seller.as_str()),
|
||||||
|
(Some("npub-test-seller"), "wrong-seller"),
|
||||||
|
] {
|
||||||
|
assert!(PeerRequest::new(npub, "seller.onion", path)
|
||||||
|
.prepare_content_json(dir.path(), did, &body)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(body.0.load(Ordering::SeqCst), 1);
|
||||||
|
tokio::fs::write(
|
||||||
|
dir.path().join("federation/nodes.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({"nodes":[peer.clone(),peer.clone()]})).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
PeerRequest::new(Some("npub-test-seller"), "seller.onion", path)
|
||||||
|
.prepare_content_json(dir.path(), &seller, &body)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let mut conflicting = peer.clone();
|
||||||
|
conflicting["did"] = serde_json::json!(crate::identity::did_key_from_pubkey_hex(
|
||||||
|
&hex::encode([8; 32])
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
conflicting["pubkey"] = serde_json::json!(hex::encode([8; 32]));
|
||||||
|
conflicting["onion"] = serde_json::json!("seller");
|
||||||
|
let mut wrong_key = peer.clone();
|
||||||
|
wrong_key["pubkey"] = serde_json::json!(hex::encode([8; 32]));
|
||||||
|
for nodes in [
|
||||||
|
serde_json::json!([peer, conflicting]),
|
||||||
|
serde_json::json!([wrong_key]),
|
||||||
|
] {
|
||||||
|
tokio::fs::write(
|
||||||
|
dir.path().join("federation/nodes.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({"nodes": nodes})).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
PeerRequest::new(Some("npub-test-seller"), "seller.onion", path)
|
||||||
|
.prepare_content_json(dir.path(), &seller, &body)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(body.0.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn required_media_never_falls_back_when_peer_has_no_fips_identity() {
|
async fn required_media_never_falls_back_when_peer_has_no_fips_identity() {
|
||||||
let request = PeerRequest::new(None, "unreachable.onion", "/content/video").require_fips();
|
let request = PeerRequest::new(None, "unreachable.onion", "/content/video").require_fips();
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ mod content_indeehub;
|
|||||||
mod content_invoice;
|
mod content_invoice;
|
||||||
mod content_owned;
|
mod content_owned;
|
||||||
mod content_purchase;
|
mod content_purchase;
|
||||||
|
mod content_purchase_executor;
|
||||||
|
mod content_snapshot;
|
||||||
mod media_stream;
|
mod media_stream;
|
||||||
mod media_registration;
|
mod media_registration;
|
||||||
|
mod registered_media;
|
||||||
mod prepared_media;
|
mod prepared_media;
|
||||||
mod content_server;
|
mod content_server;
|
||||||
mod crash_recovery;
|
mod crash_recovery;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ pub struct PreparedRegistration {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct SourceStamp {
|
pub(crate) struct SourceStamp {
|
||||||
device: u64,
|
device: u64,
|
||||||
inode: u64,
|
inode: u64,
|
||||||
size: u64,
|
size: u64,
|
||||||
@@ -130,7 +130,7 @@ struct SourceStamp {
|
|||||||
changed_nanos: i64,
|
changed_nanos: i64,
|
||||||
}
|
}
|
||||||
impl SourceStamp {
|
impl SourceStamp {
|
||||||
fn read(file: &File) -> Result<Self> {
|
pub(crate) fn read(file: &File) -> Result<Self> {
|
||||||
let m = file.metadata()?;
|
let m = file.metadata()?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
m.is_file(),
|
m.is_file(),
|
||||||
@@ -254,7 +254,12 @@ fn fd_result(fd: libc::c_int) -> Result<File> {
|
|||||||
// SAFETY: the successful syscall returned a newly owned descriptor.
|
// SAFETY: the successful syscall returned a newly owned descriptor.
|
||||||
Ok(unsafe { File::from_raw_fd(fd) })
|
Ok(unsafe { File::from_raw_fd(fd) })
|
||||||
}
|
}
|
||||||
fn open_at(dir: &File, name: &str, flags: libc::c_int, mode: libc::mode_t) -> Result<File> {
|
pub(crate) fn open_at(
|
||||||
|
dir: &File,
|
||||||
|
name: &str,
|
||||||
|
flags: libc::c_int,
|
||||||
|
mode: libc::mode_t,
|
||||||
|
) -> Result<File> {
|
||||||
let name = CString::new(name)?;
|
let name = CString::new(name)?;
|
||||||
// SAFETY: descriptor and NUL-terminated name remain alive through the call.
|
// SAFETY: descriptor and NUL-terminated name remain alive through the call.
|
||||||
fd_result(unsafe {
|
fd_result(unsafe {
|
||||||
@@ -266,7 +271,7 @@ fn open_at(dir: &File, name: &str, flags: libc::c_int, mode: libc::mode_t) -> Re
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn open_directory(path: &Path) -> Result<File> {
|
pub(crate) fn open_directory(path: &Path) -> Result<File> {
|
||||||
let path = c_path(path)?;
|
let path = c_path(path)?;
|
||||||
// SAFETY: path is a valid NUL-terminated string.
|
// SAFETY: path is a valid NUL-terminated string.
|
||||||
fd_result(unsafe {
|
fd_result(unsafe {
|
||||||
@@ -276,7 +281,7 @@ fn open_directory(path: &Path) -> Result<File> {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn private_directory(parent: &File, name: &str) -> Result<File> {
|
pub(crate) fn private_directory(parent: &File, name: &str) -> Result<File> {
|
||||||
let c_name = CString::new(name)?;
|
let c_name = CString::new(name)?;
|
||||||
// SAFETY: valid directory descriptor and string; no existing data is replaced.
|
// SAFETY: valid directory descriptor and string; no existing data is replaced.
|
||||||
let result = unsafe { libc::mkdirat(parent.as_raw_fd(), c_name.as_ptr(), 0o700) };
|
let result = unsafe { libc::mkdirat(parent.as_raw_fd(), c_name.as_ptr(), 0o700) };
|
||||||
@@ -298,7 +303,7 @@ fn private_directory(parent: &File, name: &str) -> Result<File> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn open_cloud_file(root: &File, relative: &Path) -> Result<File> {
|
pub(crate) fn open_cloud_file(root: &File, relative: &Path) -> Result<File> {
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
struct OpenHow {
|
struct OpenHow {
|
||||||
flags: u64,
|
flags: u64,
|
||||||
@@ -340,7 +345,7 @@ fn open_cloud_file(root: &File, relative: &Path) -> Result<File> {
|
|||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn open_cloud_file(_root: &File, _relative: &Path) -> Result<File> {
|
pub(crate) fn open_cloud_file(_root: &File, _relative: &Path) -> Result<File> {
|
||||||
anyhow::bail!("Safe Cloud registration currently requires Linux openat2")
|
anyhow::bail!("Safe Cloud registration currently requires Linux openat2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +356,7 @@ fn cancelled(limits: &Limits<'_>) -> Result<()> {
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn lock_operation(dir: &File, limits: &Limits<'_>, deadline: Instant) -> Result<()> {
|
pub(crate) fn lock_operation(dir: &File, limits: &Limits<'_>, deadline: Instant) -> Result<()> {
|
||||||
loop {
|
loop {
|
||||||
cancelled(limits)?;
|
cancelled(limits)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@@ -371,7 +376,10 @@ fn lock_operation(dir: &File, limits: &Limits<'_>, deadline: Instant) -> Result<
|
|||||||
std::thread::sleep(Duration::from_millis(20));
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn read_record<T: serde::de::DeserializeOwned>(dir: &File, name: &str) -> Result<Option<T>> {
|
pub(crate) fn read_record<T: serde::de::DeserializeOwned>(
|
||||||
|
dir: &File,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<T>> {
|
||||||
let file = match open_at(dir, name, libc::O_RDONLY | libc::O_NONBLOCK, 0) {
|
let file = match open_at(dir, name, libc::O_RDONLY | libc::O_NONBLOCK, 0) {
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(error)
|
Err(error)
|
||||||
@@ -398,7 +406,7 @@ fn read_record<T: serde::de::DeserializeOwned>(dir: &File, name: &str) -> Result
|
|||||||
"Damaged registration record; preserve it for recovery",
|
"Damaged registration record; preserve it for recovery",
|
||||||
)?))
|
)?))
|
||||||
}
|
}
|
||||||
fn temporary(dir: &File) -> Result<(String, File)> {
|
pub(crate) fn temporary(dir: &File) -> Result<(String, File)> {
|
||||||
let name = format!("pending-{}", uuid::Uuid::new_v4());
|
let name = format!("pending-{}", uuid::Uuid::new_v4());
|
||||||
Ok((
|
Ok((
|
||||||
name.clone(),
|
name.clone(),
|
||||||
@@ -410,7 +418,7 @@ fn temporary(dir: &File) -> Result<(String, File)> {
|
|||||||
)?,
|
)?,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
fn publish_file(dir: &File, temporary: &str, final_name: &str) -> Result<()> {
|
pub(crate) fn publish_file(dir: &File, temporary: &str, final_name: &str) -> Result<()> {
|
||||||
let from = CString::new(temporary)?;
|
let from = CString::new(temporary)?;
|
||||||
let to = CString::new(final_name)?;
|
let to = CString::new(final_name)?;
|
||||||
// linkat publishes without replacing an existing destination. Both names
|
// linkat publishes without replacing an existing destination. Both names
|
||||||
@@ -434,7 +442,7 @@ fn publish_file(dir: &File, temporary: &str, final_name: &str) -> Result<()> {
|
|||||||
dir.sync_all()?;
|
dir.sync_all()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn save_record<T: Serialize>(dir: &File, name: &str, value: &T) -> Result<()> {
|
pub(crate) fn save_record<T: Serialize>(dir: &File, name: &str, value: &T) -> Result<()> {
|
||||||
let bytes = serde_json::to_vec(value)?;
|
let bytes = serde_json::to_vec(value)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
bytes.len() as u64 <= MAX_RECORD_BYTES,
|
bytes.len() as u64 <= MAX_RECORD_BYTES,
|
||||||
@@ -446,7 +454,7 @@ fn save_record<T: Serialize>(dir: &File, name: &str, value: &T) -> Result<()> {
|
|||||||
publish_file(dir, &temporary, name)
|
publish_file(dir, &temporary, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_file(
|
pub(crate) fn hash_file(
|
||||||
file: &mut File,
|
file: &mut File,
|
||||||
mut output: Option<&mut File>,
|
mut output: Option<&mut File>,
|
||||||
limits: &Limits<'_>,
|
limits: &Limits<'_>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"description": "Independent Node.js ordered-array SHA256 fixture using the public registration test vector.",
|
||||||
|
"receipt": {
|
||||||
|
"version": 1,
|
||||||
|
"requestId": "00000000-0000-4000-8000-000000000001",
|
||||||
|
"nonce": "abababababababababababababababababababababababababababababababab",
|
||||||
|
"appAudience": "fixture-indeehub",
|
||||||
|
"nodeDid": "did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z",
|
||||||
|
"producer": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
|
||||||
|
"projectId": "fixture-project",
|
||||||
|
"priceSats": 15,
|
||||||
|
"viewingSeconds": 3600,
|
||||||
|
"expiresAt": 1600,
|
||||||
|
"contentId": "registered_00000000-0000-4000-8000-000000000001",
|
||||||
|
"sha256": "bbe573fdac96c464b67257a97c8667713c124c80927c01bab75b7178f4c0a661",
|
||||||
|
"sizeBytes": "23",
|
||||||
|
"paymentMethods": [
|
||||||
|
"cashu",
|
||||||
|
"lightning-cashu"
|
||||||
|
],
|
||||||
|
"issuedAt": 1000,
|
||||||
|
"signature": "9cb11fca6a946b38049402e8833992130922140f6bb5f5cc3b0806bc9865657be084c3b6fcfa1e2aa31f78f81ba44df5f32d824caa1c9f926f8d90f6578a350e"
|
||||||
|
},
|
||||||
|
"preimageUtf8": "[\"archipelago.registered-media.terms.v1\",\"did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z\",\"fixture-indeehub\",\"cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd\",\"fixture-project\",\"registered_00000000-0000-4000-8000-000000000001\",\"bbe573fdac96c464b67257a97c8667713c124c80927c01bab75b7178f4c0a661\",\"23\",15,3600,[\"cashu\",\"lightning-cashu\"]]",
|
||||||
|
"sha256": "a0d104ece82fd9d2094dbe4e6fd8285889ccb030407c42d9098d3c74faf02608"
|
||||||
|
}
|
||||||
@@ -813,6 +813,61 @@ pub async fn send_token_recoverable(
|
|||||||
mint_url: &str,
|
mint_url: &str,
|
||||||
amount_sats: u64,
|
amount_sats: u64,
|
||||||
context_hash: &str,
|
context_hash: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
send_token_recoverable_with_deadline(
|
||||||
|
data_dir,
|
||||||
|
operation_id,
|
||||||
|
network,
|
||||||
|
mint_url,
|
||||||
|
amount_sats,
|
||||||
|
context_hash,
|
||||||
|
None,
|
||||||
|
|| chrono::Utc::now().timestamp(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover original results after expiry, but never initiate a fresh exact send
|
||||||
|
/// or swap POST after the immutable purchase deadline. The caller binds this
|
||||||
|
/// deadline into context_hash and must already have seller acceptance saved.
|
||||||
|
pub async fn send_token_recoverable_before(
|
||||||
|
data_dir: &Path,
|
||||||
|
operation_id: &str,
|
||||||
|
network: EcashNetwork,
|
||||||
|
mint_url: &str,
|
||||||
|
amount_sats: u64,
|
||||||
|
context_hash: &str,
|
||||||
|
expires_at: i64,
|
||||||
|
) -> Result<String> {
|
||||||
|
anyhow::ensure!(expires_at > 0, "Invalid purchase spend deadline");
|
||||||
|
send_token_recoverable_with_deadline(
|
||||||
|
data_dir,
|
||||||
|
operation_id,
|
||||||
|
network,
|
||||||
|
mint_url,
|
||||||
|
amount_sats,
|
||||||
|
context_hash,
|
||||||
|
Some(expires_at),
|
||||||
|
|| chrono::Utc::now().timestamp(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
fn ensure_fresh_payment_allowed(expires_at: Option<i64>, now: i64) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
expires_at.is_none_or(|deadline| now < deadline),
|
||||||
|
"The offer expired; recover the original purchase instead of starting another payment"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn send_token_recoverable_with_deadline(
|
||||||
|
data_dir: &Path,
|
||||||
|
operation_id: &str,
|
||||||
|
network: EcashNetwork,
|
||||||
|
mint_url: &str,
|
||||||
|
amount_sats: u64,
|
||||||
|
context_hash: &str,
|
||||||
|
expires_at: Option<i64>,
|
||||||
|
now: impl Fn() -> i64 + Send + Sync,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
use super::send_journal::{Binding, Journal, Outcome, Phase, Request};
|
use super::send_journal::{Binding, Journal, Outcome, Phase, Request};
|
||||||
let held = super::mutation::guard(data_dir).await?;
|
let held = super::mutation::guard(data_dir).await?;
|
||||||
@@ -837,6 +892,7 @@ pub async fn send_token_recoverable(
|
|||||||
);
|
);
|
||||||
record
|
record
|
||||||
} else {
|
} else {
|
||||||
|
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||||
anyhow::ensure!(amount_sats > 0, "Payment amount must be positive");
|
anyhow::ensure!(amount_sats > 0, "Payment amount must be positive");
|
||||||
let wallet = load_wallet(data_dir).await?;
|
let wallet = load_wallet(data_dir).await?;
|
||||||
let (indices, excess) = wallet
|
let (indices, excess) = wallet
|
||||||
@@ -868,6 +924,7 @@ pub async fn send_token_recoverable(
|
|||||||
);
|
);
|
||||||
Request::Swap(prepared)
|
Request::Swap(prepared)
|
||||||
};
|
};
|
||||||
|
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||||
journal.prepare(binding.clone(), request).await?
|
journal.prepare(binding.clone(), request).await?
|
||||||
};
|
};
|
||||||
if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) {
|
if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) {
|
||||||
@@ -875,7 +932,10 @@ pub async fn send_token_recoverable(
|
|||||||
}
|
}
|
||||||
journal.reserve_wallet(&binding).await?;
|
journal.reserve_wallet(&binding).await?;
|
||||||
let (send, change) = match &record.request {
|
let (send, change) = match &record.request {
|
||||||
Request::Exact { proofs } => (proofs.clone(), vec![]),
|
Request::Exact { proofs } => {
|
||||||
|
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||||
|
(proofs.clone(), vec![])
|
||||||
|
}
|
||||||
Request::Swap(prepared) => {
|
Request::Swap(prepared) => {
|
||||||
// All recovery material is already durable. Do not derive new
|
// All recovery material is already durable. Do not derive new
|
||||||
// outputs or release reservations after an ambiguous response.
|
// outputs or release reservations after an ambiguous response.
|
||||||
@@ -901,6 +961,7 @@ pub async fn send_token_recoverable(
|
|||||||
"This payment is still pending at the mint; do not pay again"
|
"This payment is still pending at the mint; do not pay again"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||||
client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!(
|
client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!(
|
||||||
"The mint did not confirm this payment; retry this same operation to recover it"))?
|
"The mint did not confirm this payment; retry this same operation to recover it"))?
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ pub mod nut13;
|
|||||||
pub mod profits;
|
pub mod profits;
|
||||||
mod send_journal;
|
mod send_journal;
|
||||||
mod receive_journal;
|
mod receive_journal;
|
||||||
|
|
||||||
|
pub(crate) mod purchase_fee_plan;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ struct Mint {
|
|||||||
lose_swap_reply: Arc<std::sync::atomic::AtomicBool>,
|
lose_swap_reply: Arc<std::sync::atomic::AtomicBool>,
|
||||||
restore_reply: Arc<Mutex<Option<Value>>>,
|
restore_reply: Arc<Mutex<Option<Value>>>,
|
||||||
state_reply: Arc<Mutex<Option<Value>>>,
|
state_reply: Arc<Mutex<Option<Value>>>,
|
||||||
|
// Per-fixture clock advancement proves the spend deadline is checked after
|
||||||
|
// mint preflight; no process-global clock or timing-sensitive sleep.
|
||||||
|
restore_clock: Arc<Mutex<Option<(Arc<std::sync::atomic::AtomicI64>, i64)>>>,
|
||||||
}
|
}
|
||||||
impl Drop for Mint {
|
impl Drop for Mint {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -65,6 +68,8 @@ impl Mint {
|
|||||||
let restore_override = restore_reply.clone();
|
let restore_override = restore_reply.clone();
|
||||||
let state_reply = Arc::new(Mutex::new(None::<Value>));
|
let state_reply = Arc::new(Mutex::new(None::<Value>));
|
||||||
let state_override = state_reply.clone();
|
let state_override = state_reply.clone();
|
||||||
|
let restore_clock = Arc::new(Mutex::new(None::<(Arc<std::sync::atomic::AtomicI64>, i64)>));
|
||||||
|
let advance_clock = restore_clock.clone();
|
||||||
let service = make_service_fn(move |_| {
|
let service = make_service_fn(move |_| {
|
||||||
let seen = seen.clone();
|
let seen = seen.clone();
|
||||||
let rejection = rejection.clone();
|
let rejection = rejection.clone();
|
||||||
@@ -73,6 +78,7 @@ impl Mint {
|
|||||||
let lose_reply = lose_reply.clone();
|
let lose_reply = lose_reply.clone();
|
||||||
let restore_override = restore_override.clone();
|
let restore_override = restore_override.clone();
|
||||||
let state_override = state_override.clone();
|
let state_override = state_override.clone();
|
||||||
|
let advance_clock = advance_clock.clone();
|
||||||
async move {
|
async move {
|
||||||
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
||||||
let seen = seen.clone();
|
let seen = seen.clone();
|
||||||
@@ -82,6 +88,7 @@ impl Mint {
|
|||||||
let lose_reply = lose_reply.clone();
|
let lose_reply = lose_reply.clone();
|
||||||
let restore_override = restore_override.clone();
|
let restore_override = restore_override.clone();
|
||||||
let state_override = state_override.clone();
|
let state_override = state_override.clone();
|
||||||
|
let advance_clock = advance_clock.clone();
|
||||||
async move {
|
async move {
|
||||||
let mut status = 200;
|
let mut status = 200;
|
||||||
let body = match req.uri().path() {
|
let body = match req.uri().path() {
|
||||||
@@ -164,6 +171,11 @@ impl Mint {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"/v1/restore" => {
|
"/v1/restore" => {
|
||||||
|
if let Some((clock, deadline)) =
|
||||||
|
advance_clock.lock().unwrap().as_ref()
|
||||||
|
{
|
||||||
|
clock.store(*deadline, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
let body: Value = serde_json::from_slice(
|
let body: Value = serde_json::from_slice(
|
||||||
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||||
)
|
)
|
||||||
@@ -219,6 +231,7 @@ impl Mint {
|
|||||||
lose_swap_reply,
|
lose_swap_reply,
|
||||||
restore_reply,
|
restore_reply,
|
||||||
state_reply,
|
state_reply,
|
||||||
|
restore_clock,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async fn wallet(&self) -> tempfile::TempDir {
|
async fn wallet(&self) -> tempfile::TempDir {
|
||||||
@@ -486,10 +499,17 @@ async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_mi
|
|||||||
}
|
}
|
||||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
||||||
} else {
|
} else {
|
||||||
assert!(matches!(
|
if !exists {
|
||||||
result,
|
assert!(matches!(result, ServeResult::Unavailable));
|
||||||
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
assert!(content_server::load_catalog(seller.path())
|
||||||
));
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.id == "paid-test"));
|
||||||
|
} else {
|
||||||
|
assert!(matches!(result, ServeResult::PaymentRequired(_)));
|
||||||
|
}
|
||||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||||
assert!(mint.requests.lock().unwrap().is_empty());
|
assert!(mint.requests.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
@@ -1574,3 +1594,342 @@ async fn recoverable_receive_unspent_retry_reuses_exact_request_and_waits_for_ve
|
|||||||
assert_eq!(wallet.transactions.len(), 1);
|
assert_eq!(wallet.transactions.len(), 1);
|
||||||
assert_eq!(wallet.receive_commits.len(), 1);
|
assert_eq!(wallet.receive_commits.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_deadline_rejects_fresh_exact_send_but_recovers_prior_committed_token() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mint = "https://unused-mint.invalid";
|
||||||
|
let mut wallet = WalletState::default();
|
||||||
|
wallet.mint_url = mint.into();
|
||||||
|
wallet.add_proofs(mint, vec![proof(ACTIVE, 8)]);
|
||||||
|
save_wallet(root.path(), &wallet).await.unwrap();
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let context = "ab".repeat(32);
|
||||||
|
assert!(send_token_recoverable_before(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
mint,
|
||||||
|
8,
|
||||||
|
&context,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||||
|
// Fixture a prior completed send; an expired caller must recover its result,
|
||||||
|
// not require another payment or credit the old proofs back to the purse.
|
||||||
|
let original =
|
||||||
|
send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
send_token_recoverable_before(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
mint,
|
||||||
|
8,
|
||||||
|
&context,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
original
|
||||||
|
);
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn expired_purchase_recovers_issued_swap_but_never_posts_still_unspent_inputs() {
|
||||||
|
for issued in [false, true] {
|
||||||
|
let mint = Mint::start(0, if issued { None } else { Some(503) }).await;
|
||||||
|
let root = mint.wallet().await;
|
||||||
|
let mut wallet = load_wallet(root.path()).await.unwrap();
|
||||||
|
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||||
|
save_wallet(root.path(), &wallet).await.unwrap();
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let context = "ab".repeat(32);
|
||||||
|
mint.lose_swap_reply
|
||||||
|
.store(issued, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
assert!(send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&context
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let result = send_token_recoverable_before(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&context,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if issued {
|
||||||
|
assert_eq!(
|
||||||
|
CashuToken::deserialize(&result.unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.total_amount(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
|
||||||
|
} else {
|
||||||
|
assert!(result.unwrap_err().to_string().contains("expired"));
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||||
|
assert!(load_wallet(root.path())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.proofs
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.reserved));
|
||||||
|
}
|
||||||
|
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_executor_recovers_prior_buyer_spend_and_delayed_accepted_seller_settlement() {
|
||||||
|
use crate::content_purchase::{BuyerPhase, Contract, Journal};
|
||||||
|
use crate::content_purchase_executor::{prepare_buyer_token, settle_seller_token};
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let buyer = mint.wallet().await;
|
||||||
|
let seller = mint.wallet().await;
|
||||||
|
let contract = Contract {
|
||||||
|
version: 1,
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([1; 32])).unwrap(),
|
||||||
|
seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([2; 32])).unwrap(),
|
||||||
|
content_id: "film-1".into(),
|
||||||
|
content_sha256: "ab".repeat(32),
|
||||||
|
content_size: 1024,
|
||||||
|
terms_sha256: "cd".repeat(32),
|
||||||
|
network: EcashNetwork::Mainnet,
|
||||||
|
mint_url: mint.url.clone(),
|
||||||
|
gross_token_sats: 8,
|
||||||
|
minimum_net_sats: 8,
|
||||||
|
offered_at: 1000,
|
||||||
|
expires_at: 2000,
|
||||||
|
};
|
||||||
|
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||||
|
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||||
|
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||||
|
// An expired offer without durable seller acceptance cannot redeem a token
|
||||||
|
// or create settlement state, even when the token and buyer are otherwise valid.
|
||||||
|
let unaccepted_token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
|
||||||
|
.serialize()
|
||||||
|
.unwrap();
|
||||||
|
let seller_wallet_before = std::fs::read(seller.path().join("wallet/ecash.json")).ok();
|
||||||
|
let rejected = settle_seller_token(
|
||||||
|
seller.path(),
|
||||||
|
&contract,
|
||||||
|
&unaccepted_token,
|
||||||
|
&contract.buyer_did,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(rejected
|
||||||
|
.to_string()
|
||||||
|
.contains("Seller intent is not durable"));
|
||||||
|
assert!(mint.requests.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(seller.path().join("wallet/ecash.json")).ok(),
|
||||||
|
seller_wallet_before
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let journal = Journal::open(seller.path()).await.unwrap();
|
||||||
|
assert!(journal.seller(&contract.id).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
// Deterministically fixture durable acceptance before the historical deadline.
|
||||||
|
let accepted = {
|
||||||
|
let journal = Journal::open(seller.path()).await.unwrap();
|
||||||
|
journal
|
||||||
|
.prepare_seller(&contract, 1500)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.acceptance()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let journal = Journal::open(buyer.path()).await.unwrap();
|
||||||
|
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
||||||
|
assert!(journal
|
||||||
|
.record_acceptance(&contract, &accepted, &contract.buyer_did)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
journal
|
||||||
|
.record_acceptance(&contract, &accepted, &contract.seller_did)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// Fixture a prior wallet commit, interrupted before the buyer journal saved
|
||||||
|
// its token. The actual executor must recover that result after expiry.
|
||||||
|
let original = send_token_recoverable(
|
||||||
|
buyer.path(),
|
||||||
|
&contract.id,
|
||||||
|
contract.network,
|
||||||
|
&mint.url,
|
||||||
|
8,
|
||||||
|
&contract.context_hash().unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
prepare_buyer_token(buyer.path(), &contract).await.unwrap(),
|
||||||
|
original
|
||||||
|
);
|
||||||
|
assert!(mint.requests.lock().unwrap().is_empty());
|
||||||
|
mint.lose_swap_reply
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
assert!(
|
||||||
|
settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||||
|
let receipt = settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
== receipt
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
settle_seller_token(seller.path(), &contract, &original, &contract.seller_did)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let altered = CashuToken::new(&mint.url, vec![proof(V2, 8)])
|
||||||
|
.serialize()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
settle_seller_token(seller.path(), &contract, &altered, &contract.buyer_did)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||||
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
||||||
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 8);
|
||||||
|
let journal = Journal::open(buyer.path()).await.unwrap();
|
||||||
|
journal.record_receipt(&contract, &receipt).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
journal.buyer(&contract.id).await.unwrap().unwrap().phase,
|
||||||
|
BuyerPhase::ReceiptSaved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_expiring_during_mint_preflight_never_reserves_or_posts_swap() {
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let root = mint.wallet().await;
|
||||||
|
let mut wallet = load_wallet(root.path()).await.unwrap();
|
||||||
|
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||||
|
save_wallet(root.path(), &wallet).await.unwrap();
|
||||||
|
let wallet_before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||||
|
let clock = Arc::new(AtomicI64::new(1000));
|
||||||
|
*mint.restore_clock.lock().unwrap() = Some((clock.clone(), 2000));
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let error = send_token_recoverable_with_deadline(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&"ab".repeat(32),
|
||||||
|
Some(2000),
|
||||||
|
|| clock.load(Ordering::SeqCst),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
clock.load(Ordering::SeqCst),
|
||||||
|
2000,
|
||||||
|
"preflight must have completed"
|
||||||
|
);
|
||||||
|
assert!(error.to_string().contains("expired"));
|
||||||
|
assert!(
|
||||||
|
mint.requests.lock().unwrap().is_empty(),
|
||||||
|
"no swap POST after expiry"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||||
|
wallet_before
|
||||||
|
);
|
||||||
|
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||||
|
let held = crate::wallet::mutation::guard(root.path()).await.unwrap();
|
||||||
|
assert!(crate::wallet::send_journal::Journal::new(&held)
|
||||||
|
.load(&id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append to wallet/payment_tests.rs when the next payment-plan batch is applied.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_planned_inputs_do_not_reselect_wallet_coins_or_post_to_mint() {
|
||||||
|
use crate::wallet::{mutation, send_journal};
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mut wallet = WalletState::default();
|
||||||
|
wallet.mint_url = mint.url.clone();
|
||||||
|
let original = proof(ACTIVE, 8);
|
||||||
|
wallet.add_proofs(&mint.url, vec![original.clone()]);
|
||||||
|
save_wallet(root.path(), &wallet).await.unwrap();
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let context = "ab".repeat(32);
|
||||||
|
let prepared = MintClient::new(&mint.url)
|
||||||
|
.unwrap()
|
||||||
|
.prepare_swap_at_least(&[original], &[4, 4], 4)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
{
|
||||||
|
let guard = mutation::guard(root.path()).await.unwrap();
|
||||||
|
send_journal::Journal::new(&guard)
|
||||||
|
.prepare(
|
||||||
|
send_journal::Binding {
|
||||||
|
id: id.clone(),
|
||||||
|
network: EcashNetwork::Mainnet,
|
||||||
|
mint_url: mint.url.clone(),
|
||||||
|
amount_sats: 4,
|
||||||
|
context_hash: context.clone(),
|
||||||
|
},
|
||||||
|
send_journal::Request::Swap(prepared),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// Simulate a selected proof becoming unavailable before reservation. Other
|
||||||
|
// sufficient coins exist, but the accepted immutable plan cannot select them.
|
||||||
|
wallet.proofs.clear();
|
||||||
|
let mut replacement = proof(ACTIVE, 8);
|
||||||
|
replacement.secret = "replacement-not-in-plan".into();
|
||||||
|
wallet.add_proofs(&mint.url, vec![replacement]);
|
||||||
|
save_wallet(root.path(), &wallet).await.unwrap();
|
||||||
|
let before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||||
|
assert!(send_token_recoverable(
|
||||||
|
root.path(),
|
||||||
|
&id,
|
||||||
|
EcashNetwork::Mainnet,
|
||||||
|
&mint.url,
|
||||||
|
4,
|
||||||
|
&context
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
assert!(mint.requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
//! Quote the actual outgoing proof shape; never assume every exact-send proof
|
||||||
|
//! belongs to the current active keyset. The caller supplies mint-verified fees.
|
||||||
|
use super::cashu::{matches_stored_keyset_id, CashuToken, KeysetInfo, Proof};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct KeysetPlan {
|
||||||
|
pub keyset_id: String,
|
||||||
|
pub denominations: Vec<u64>,
|
||||||
|
pub input_fee_ppk: u64,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct FeePlan {
|
||||||
|
pub mint_url: String,
|
||||||
|
pub keysets: Vec<KeysetPlan>,
|
||||||
|
pub gross_sats: u64,
|
||||||
|
pub fee_sats: u64,
|
||||||
|
pub net_sats: u64,
|
||||||
|
}
|
||||||
|
impl FeePlan {
|
||||||
|
pub fn from_shape(mint_url: &str, mut keysets: Vec<KeysetPlan>) -> Result<Self> {
|
||||||
|
let parsed = reqwest::Url::parse(mint_url)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
matches!(parsed.scheme(), "http" | "https")
|
||||||
|
&& parsed.host_str().is_some()
|
||||||
|
&& parsed.username().is_empty()
|
||||||
|
&& parsed.password().is_none()
|
||||||
|
&& parsed.query().is_none()
|
||||||
|
&& parsed.fragment().is_none()
|
||||||
|
&& parsed.to_string().trim_end_matches('/') == mint_url,
|
||||||
|
"Invalid canonical payment mint"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
!keysets.is_empty() && keysets.len() <= 64,
|
||||||
|
"Invalid payment keyset plan"
|
||||||
|
);
|
||||||
|
keysets.sort_by(|a, b| a.keyset_id.cmp(&b.keyset_id));
|
||||||
|
let mut identifiers = HashSet::new();
|
||||||
|
let mut count = 0usize;
|
||||||
|
let mut gross = 0u64;
|
||||||
|
let mut fee_ppk = 0u64;
|
||||||
|
for group in &mut keysets {
|
||||||
|
anyhow::ensure!(
|
||||||
|
matches!(group.keyset_id.len(), 16 | 66)
|
||||||
|
&& hex::decode(&group.keyset_id).is_ok()
|
||||||
|
&& group.keyset_id == group.keyset_id.to_ascii_lowercase()
|
||||||
|
&& identifiers.insert(group.keyset_id.clone()),
|
||||||
|
"Invalid or duplicate payment keyset"
|
||||||
|
);
|
||||||
|
// Full v2 identities are required in a plan; compact wire IDs only
|
||||||
|
// match an already known unambiguous group at validation time.
|
||||||
|
anyhow::ensure!(
|
||||||
|
group.keyset_id.len() != 16 || !group.keyset_id.starts_with("01"),
|
||||||
|
"Payment plan requires full v2 keyset identity"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(!group.denominations.is_empty(), "Empty payment proof group");
|
||||||
|
group.denominations.sort_unstable();
|
||||||
|
count = count
|
||||||
|
.checked_add(group.denominations.len())
|
||||||
|
.context("Proof count overflow")?;
|
||||||
|
anyhow::ensure!(count <= 1024, "Too many outgoing payment proofs");
|
||||||
|
for amount in &group.denominations {
|
||||||
|
anyhow::ensure!(amount.is_power_of_two(), "Invalid payment denomination");
|
||||||
|
gross = gross
|
||||||
|
.checked_add(*amount)
|
||||||
|
.context("Payment amount overflow")?;
|
||||||
|
fee_ppk = fee_ppk
|
||||||
|
.checked_add(group.input_fee_ppk)
|
||||||
|
.context("Payment fee overflow")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fee = fee_ppk.div_ceil(1000);
|
||||||
|
let net = gross
|
||||||
|
.checked_sub(fee)
|
||||||
|
.context("Payment fees exceed outgoing value")?;
|
||||||
|
anyhow::ensure!(net > 0, "Payment has no net value");
|
||||||
|
Ok(Self {
|
||||||
|
mint_url: mint_url.into(),
|
||||||
|
keysets,
|
||||||
|
gross_sats: gross,
|
||||||
|
fee_sats: fee,
|
||||||
|
net_sats: net,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
&Self::from_shape(&self.mint_url, self.keysets.clone())? == self,
|
||||||
|
"Payment fee plan is inconsistent or noncanonical"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn commitment(&self) -> Result<String> {
|
||||||
|
self.validate()?;
|
||||||
|
let groups: Vec<_> = self
|
||||||
|
.keysets
|
||||||
|
.iter()
|
||||||
|
.map(|group| {
|
||||||
|
serde_json::json!([group.keyset_id, group.input_fee_ppk, group.denominations])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(hex::encode(Sha256::digest(serde_json::to_vec(
|
||||||
|
&serde_json::json!([
|
||||||
|
"content-payment-fee-plan-v1",
|
||||||
|
self.mint_url,
|
||||||
|
self.gross_sats,
|
||||||
|
self.fee_sats,
|
||||||
|
self.net_sats,
|
||||||
|
groups
|
||||||
|
]),
|
||||||
|
)?)))
|
||||||
|
}
|
||||||
|
/// Check authoritative mint metadata at acceptance, and again immediately
|
||||||
|
/// before a fresh swap POST. Inactive old proofs may still be exact-spent;
|
||||||
|
/// a prepared output keyset must remain active for a new issuance.
|
||||||
|
pub fn verify_mint_keysets(
|
||||||
|
&self,
|
||||||
|
keysets: &[KeysetInfo],
|
||||||
|
require_active_output: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.validate()?;
|
||||||
|
for group in &self.keysets {
|
||||||
|
let mut matching = keysets
|
||||||
|
.iter()
|
||||||
|
.filter(|keyset| keyset.id.eq_ignore_ascii_case(&group.keyset_id));
|
||||||
|
let keyset = matching
|
||||||
|
.next()
|
||||||
|
.context("Quoted payment keyset is missing")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
matching.next().is_none()
|
||||||
|
&& keyset.unit == "sat"
|
||||||
|
&& keyset.input_fee_ppk == group.input_fee_ppk
|
||||||
|
&& (!require_active_output || keyset.active),
|
||||||
|
"Quoted payment keyset or fees changed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn validate_proofs(&self, proofs: &[Proof]) -> Result<()> {
|
||||||
|
self.validate()?;
|
||||||
|
let mut actual: BTreeMap<String, Vec<u64>> = BTreeMap::new();
|
||||||
|
let mut secrets = HashSet::new();
|
||||||
|
for proof in proofs {
|
||||||
|
anyhow::ensure!(
|
||||||
|
secrets.insert(proof.secret.as_str()),
|
||||||
|
"Duplicate outgoing proof"
|
||||||
|
);
|
||||||
|
let mut groups = self
|
||||||
|
.keysets
|
||||||
|
.iter()
|
||||||
|
.filter(|group| matches_stored_keyset_id(&proof.id, &group.keyset_id));
|
||||||
|
let group = groups.next().context("Outgoing proof keyset changed")?;
|
||||||
|
anyhow::ensure!(groups.next().is_none(), "Ambiguous compact outgoing keyset");
|
||||||
|
actual
|
||||||
|
.entry(group.keyset_id.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(proof.amount);
|
||||||
|
}
|
||||||
|
for amounts in actual.values_mut() {
|
||||||
|
amounts.sort_unstable();
|
||||||
|
}
|
||||||
|
let expected: BTreeMap<_, _> = self
|
||||||
|
.keysets
|
||||||
|
.iter()
|
||||||
|
.map(|group| (group.keyset_id.clone(), group.denominations.clone()))
|
||||||
|
.collect();
|
||||||
|
anyhow::ensure!(
|
||||||
|
actual == expected,
|
||||||
|
"Outgoing proof count or denominations changed"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn validate_token(&self, encoded: &str) -> Result<()> {
|
||||||
|
let token = CashuToken::deserialize(encoded)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
token.token.len() == 1 && token.token[0].mint.trim_end_matches('/') == self.mint_url,
|
||||||
|
"Outgoing payment mint changed"
|
||||||
|
);
|
||||||
|
self.validate_proofs(&token.token[0].proofs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn group(id: &str, denominations: Vec<u64>, fee: u64) -> KeysetPlan {
|
||||||
|
KeysetPlan {
|
||||||
|
keyset_id: id.into(),
|
||||||
|
denominations,
|
||||||
|
input_fee_ppk: fee,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn proof(id: &str, amount: u64, secret: &str) -> Proof {
|
||||||
|
Proof {
|
||||||
|
id: id.into(),
|
||||||
|
amount,
|
||||||
|
secret: secret.into(),
|
||||||
|
c: "unused-in-shape-validation".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn mixed_keysets_quote_actual_proof_counts_and_round_fee_once() {
|
||||||
|
let first = "0011223344556677";
|
||||||
|
let second = "008899aabbccddee";
|
||||||
|
let plan = FeePlan::from_shape(
|
||||||
|
"https://mint.invalid",
|
||||||
|
vec![group(first, vec![4, 4], 400), group(second, vec![2], 100)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((plan.gross_sats, plan.fee_sats, plan.net_sats), (10, 1, 9));
|
||||||
|
let proofs = vec![
|
||||||
|
proof(first, 4, "a"),
|
||||||
|
proof(first, 4, "b"),
|
||||||
|
proof(second, 2, "c"),
|
||||||
|
];
|
||||||
|
plan.validate_proofs(&proofs).unwrap();
|
||||||
|
assert!(plan
|
||||||
|
.validate_proofs(&[proof(first, 8, "a"), proof(second, 2, "c")])
|
||||||
|
.is_err());
|
||||||
|
assert!(plan
|
||||||
|
.validate_proofs(&[
|
||||||
|
proof(first, 4, "a"),
|
||||||
|
proof(first, 4, "a"),
|
||||||
|
proof(second, 2, "c")
|
||||||
|
])
|
||||||
|
.is_err());
|
||||||
|
let mut changed = plan.clone();
|
||||||
|
changed.keysets[0].input_fee_ppk += 1000;
|
||||||
|
assert!(changed.validate().is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn compact_collision_and_changed_fee_quote_reject_before_token_transfer() {
|
||||||
|
let first = format!("01{}", "11".repeat(32));
|
||||||
|
let mut second = first.clone();
|
||||||
|
second.replace_range(64..66, "22");
|
||||||
|
let plan = FeePlan::from_shape(
|
||||||
|
"https://mint.invalid",
|
||||||
|
vec![group(&first, vec![4], 0), group(&second, vec![4], 0)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
plan.validate_proofs(&[proof(&first, 4, "a"), proof(&second, 4, "b")])
|
||||||
|
.unwrap();
|
||||||
|
assert!(plan
|
||||||
|
.validate_proofs(&[proof(&first[..16], 4, "a"), proof(&second, 4, "b")])
|
||||||
|
.is_err());
|
||||||
|
let mut keysets: Vec<_> = [&first, &second]
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| KeysetInfo {
|
||||||
|
id: id.clone(),
|
||||||
|
unit: "sat".into(),
|
||||||
|
active: true,
|
||||||
|
input_fee_ppk: 0,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
plan.verify_mint_keysets(&keysets, true).unwrap();
|
||||||
|
keysets[0].active = false;
|
||||||
|
assert!(plan.verify_mint_keysets(&keysets, true).is_err());
|
||||||
|
plan.verify_mint_keysets(&keysets, false).unwrap();
|
||||||
|
keysets[0].input_fee_ppk = 1;
|
||||||
|
assert!(plan.verify_mint_keysets(&keysets, false).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn fee_commitment_matches_shared_ordered_array_fixture() {
|
||||||
|
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||||
|
"../../../../tests/fixtures/purchase-fee-plan-v1.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let plan: FeePlan = serde_json::from_value(fixture["plan"].clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
plan.commitment().unwrap(),
|
||||||
|
fixture["sha256"].as_str().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(Sha256::digest(
|
||||||
|
fixture["preimage"].as_str().unwrap().as_bytes()
|
||||||
|
)),
|
||||||
|
fixture["sha256"].as_str().unwrap()
|
||||||
|
);
|
||||||
|
let mut reversed = plan.keysets.clone();
|
||||||
|
reversed.reverse();
|
||||||
|
assert_eq!(
|
||||||
|
FeePlan::from_shape(&plan.mint_url, reversed)
|
||||||
|
.unwrap()
|
||||||
|
.commitment()
|
||||||
|
.unwrap(),
|
||||||
|
plan.commitment().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,12 @@ impl HookStep {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ContainerConfig {
|
pub struct ContainerConfig {
|
||||||
|
/// Opt in to installer-provisioned public node identity and stable app audience
|
||||||
|
/// for the reviewed media-registration bridge. Does not enable registration,
|
||||||
|
/// publication or signing permissions. Missing/corrupt existing pins fail.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub media_registration_identity: bool,
|
||||||
|
|
||||||
/// Pull source. Mutually exclusive with `build`. Exactly one of the two must be present.
|
/// Pull source. Mutually exclusive with `build`. Exactly one of the two must be present.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
@@ -1981,6 +1987,24 @@ app:
|
|||||||
assert!(AppManifest::parse(yaml).is_err());
|
assert!(AppManifest::parse(yaml).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_registration_identity_is_explicit_and_preserved() {
|
||||||
|
let ordinary = AppManifest::parse("app:\n id: fixture\n name: Fixture\n version: '1'\n container:\n image: fixture:1\n").unwrap();
|
||||||
|
assert!(!ordinary.app.container.media_registration_identity);
|
||||||
|
assert!(!serde_yaml::to_string(&ordinary)
|
||||||
|
.unwrap()
|
||||||
|
.contains("media_registration_identity"));
|
||||||
|
let opted_in = AppManifest::parse("app:\n id: fixture\n name: Fixture\n version: '1'\n container:\n image: fixture:1\n media_registration_identity: true\n").unwrap();
|
||||||
|
assert!(opted_in.app.container.media_registration_identity);
|
||||||
|
assert!(
|
||||||
|
AppManifest::parse(&serde_yaml::to_string(&opted_in).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.app
|
||||||
|
.container
|
||||||
|
.media_registration_identity
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_manifest_parse() {
|
fn test_manifest_parse() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -2473,6 +2497,7 @@ app:
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolve_derived_env_renders_host_facts() {
|
fn resolve_derived_env_renders_host_facts() {
|
||||||
let c = ContainerConfig {
|
let c = ContainerConfig {
|
||||||
|
media_registration_identity: false,
|
||||||
image: Some("x:latest".to_string()),
|
image: Some("x:latest".to_string()),
|
||||||
image_signature: None,
|
image_signature: None,
|
||||||
pull_policy: "if-not-present".to_string(),
|
pull_policy: "if-not-present".to_string(),
|
||||||
@@ -2532,6 +2557,7 @@ app:
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolve_secret_env_reads_from_provider() {
|
fn resolve_secret_env_reads_from_provider() {
|
||||||
let c = ContainerConfig {
|
let c = ContainerConfig {
|
||||||
|
media_registration_identity: false,
|
||||||
image: Some("x:latest".to_string()),
|
image: Some("x:latest".to_string()),
|
||||||
image_signature: None,
|
image_signature: None,
|
||||||
pull_policy: "if-not-present".to_string(),
|
pull_policy: "if-not-present".to_string(),
|
||||||
@@ -2580,6 +2606,7 @@ app:
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolve_secret_env_rejects_empty_value() {
|
fn resolve_secret_env_rejects_empty_value() {
|
||||||
let c = ContainerConfig {
|
let c = ContainerConfig {
|
||||||
|
media_registration_identity: false,
|
||||||
image: Some("x:latest".to_string()),
|
image: Some("x:latest".to_string()),
|
||||||
image_signature: None,
|
image_signature: None,
|
||||||
pull_policy: "if-not-present".to_string(),
|
pull_policy: "if-not-present".to_string(),
|
||||||
@@ -2616,6 +2643,7 @@ app:
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolve_secret_env_skips_missing_or_empty_optional_entries() {
|
fn resolve_secret_env_skips_missing_or_empty_optional_entries() {
|
||||||
let c = ContainerConfig {
|
let c = ContainerConfig {
|
||||||
|
media_registration_identity: false,
|
||||||
image: Some("x:latest".to_string()),
|
image: Some("x:latest".to_string()),
|
||||||
image_signature: None,
|
image_signature: None,
|
||||||
pull_policy: "if-not-present".to_string(),
|
pull_policy: "if-not-present".to_string(),
|
||||||
|
|||||||
@@ -151,3 +151,49 @@ subsequent combined isolated Rust run also passed its independent golden fixture
|
|||||||
test, establishing agreement across both implementations. Node caller/serving
|
test, establishing agreement across both implementations. Node caller/serving
|
||||||
integration and live acceptance remain open. No fixture or Rust source changed
|
integration and live acceptance remain open. No fixture or Rust source changed
|
||||||
between the frozen compilation inputs and the successful run.
|
between the frozen compilation inputs and the successful run.
|
||||||
|
|
||||||
|
## Immutable serving and rental caller work
|
||||||
|
|
||||||
|
The next local source connects explicit producer/project approval to the fixed
|
||||||
|
`indeedhub-api` installation pin and stores a separate immutable serving mapping
|
||||||
|
before returning the signed registration receipt. It does not insert filenames
|
||||||
|
into the legacy mutable share catalog. Its canonical ordered-array terms hash has
|
||||||
|
an independently generated Node.js fixture. Original Cloud source deletion does
|
||||||
|
not change the saved snapshot, registration receipt or rental terms.
|
||||||
|
|
||||||
|
A peer GET to `/content/{registered_id}/rental/{purchase_uuid}` now requires the
|
||||||
|
existing node proof signed for that exact path and the original
|
||||||
|
`X-Content-Capability`. The node verifies its own durable seller `ReceiptSaved`
|
||||||
|
record, authenticated buyer, immutable content/hash/size/terms and settlement
|
||||||
|
amount. Invalid ranges reject before a rental starts. First successful durable
|
||||||
|
stream authorization records one rental window; reopen and range requests keep
|
||||||
|
that same deadline. Stream reads use bounded 64 KiB buffers, reject clock rollback
|
||||||
|
and stop new reads at expiry. HEAD and preflight do not start a window.
|
||||||
|
|
||||||
|
This is a server access window, not DRM. Bytes already delivered cannot be
|
||||||
|
revoked. A crash after the lease is fsynced but before the first network byte still
|
||||||
|
consumes elapsed time: local persistence and remote byte delivery are not one
|
||||||
|
atomic operation. The player must display the persisted expiry and reuse the
|
||||||
|
original purchase/capability on reconnect, not create another payment.
|
||||||
|
|
||||||
|
Seven registered-media tests and three route/stream tests passed in
|
||||||
|
`/tmp/archy-registration-rental-batch-tests.log`; its overall result was 1,834
|
||||||
|
passed, one failed legacy missing-file expectation, and five existing skips.
|
||||||
|
All 383 captured source/build/fixture hashes remained unchanged. The expectation
|
||||||
|
was corrected separately and awaits the next full run. Do not describe that batch
|
||||||
|
as a clean combined suite or live playback acceptance.
|
||||||
|
|
||||||
|
The subsequent, not-yet-tested performance refinement persists the already
|
||||||
|
verified snapshot's signed hash and inode/device/ctime/size attestation during
|
||||||
|
registration. Ordinary first-open/range requests reuse it. A missing cache streams
|
||||||
|
the original signed hash under a per-registration lock; buyer leases use separate
|
||||||
|
per-purchase locks. Large verification reads do not hold the global metadata lock
|
||||||
|
or start a rental while queued. An identical concurrent cache writer is accepted
|
||||||
|
only after exact readback and file/directory fsync. Three additional tests cover
|
||||||
|
lock independence, cache reconstruction and mismatched-cache refusal.
|
||||||
|
|
||||||
|
Owner-session/CSRF approval RPC, producer-signed exact selection, native Cloud
|
||||||
|
picker/consent bridge, app receipt consumption, and player reconnect/expiry UI are
|
||||||
|
being connected next. Their source is not yet deployed; app registration and
|
||||||
|
publication flags remain disabled pending complete qualification. No real payment,
|
||||||
|
announcement, media publication or node deployment was performed by this work.
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Installer-owned IndeeHub registration identity
|
||||||
|
|
||||||
|
Status: source compiled and eight installer/orchestrator tests passed in the
|
||||||
|
combined isolated backend run. That run has one unrelated FIPS binding test
|
||||||
|
failure (1,821 passed, one failed, five existing skips), so combined qualification
|
||||||
|
is not clean. The separate container manifest test passed. No deployment
|
||||||
|
or actual-node acceptance. This adds an installation prerequisite; it does not
|
||||||
|
enable registration or publication.
|
||||||
|
|
||||||
|
The IndeedHub API manifest explicitly opts in with
|
||||||
|
`container.media_registration_identity: true`. The field defaults to false and
|
||||||
|
is omitted from serialized ordinary manifests. Only opted-in apps read the
|
||||||
|
existing node identity or receive registration identity variables.
|
||||||
|
|
||||||
|
The production install/reconcile environment chokepoint loads the existing node
|
||||||
|
key, verifies its derived public key matches `identity/node_key.pub`, and asks the
|
||||||
|
private installation store for a stable pin. Missing or mismatching identity
|
||||||
|
files fail; no node key is generated, repaired or replaced. The helper runs on a
|
||||||
|
blocking worker and uses bounded cross-process locking plus durable no-replace
|
||||||
|
writes.
|
||||||
|
|
||||||
|
The public identity values injected are:
|
||||||
|
|
||||||
|
- `ARCHIPELAGO_REGISTRATION_NODE_PUBLIC_KEY`: exact existing Ed25519 public key.
|
||||||
|
- `ARCHIPELAGO_REGISTRATION_NODE_DID`: its matching did:key identity.
|
||||||
|
- `ARCHIPELAGO_REGISTRATION_AUDIENCE`: one persisted UUID for this installed app.
|
||||||
|
|
||||||
|
These are public identity bindings, not secret signing keys. They are derived
|
||||||
|
from authenticated installation state. Manifest plaintext duplicates are replaced
|
||||||
|
by the authoritative pins; derived/secret-backed attempts to override these names
|
||||||
|
are rejected. Existing native-signing user identities are unrelated to this node
|
||||||
|
receipt-signing identity. `NODE_IDENTITY_PUBKEYS` is not used for this purpose.
|
||||||
|
No `_ENABLED` setting is written. No unrelated app receives these values.
|
||||||
|
|
||||||
|
## Persistence and recovery
|
||||||
|
|
||||||
|
The node keeps private 0600 records beneath the 0700 directory
|
||||||
|
`<node-data>/app-registration-pins/`:
|
||||||
|
|
||||||
|
- `<app-id>.json`: version, app ID, node public key/DID and stable app audience.
|
||||||
|
- `<app-id>.initialized.json`: version, app ID and canonical pin-record SHA256.
|
||||||
|
|
||||||
|
Provisioning returns only after both files and their directory are fsynced.
|
||||||
|
Interrupted initial provisioning after pin persistence finishes its marker on
|
||||||
|
retry without changing the audience. A missing pin with a surviving marker is
|
||||||
|
an error requiring recovery of the original pin, not permission to generate a
|
||||||
|
replacement. Damaged, unreadable, symlinked or mismatched files remain untouched.
|
||||||
|
A node identity change also requires an explicit migration; it does not silently
|
||||||
|
repin an existing app.
|
||||||
|
|
||||||
|
`load_existing` is the future bridge/RPC lookup: it never creates files or app
|
||||||
|
identity. It requires matching complete persisted records. The bridge must bind
|
||||||
|
the actual installed frontend/backend relationship: IndeedHub's UI app is
|
||||||
|
`indeedhub`, while its API and this stored registration scope are `indeedhub-api`.
|
||||||
|
Arbitrary apps must not select another installed scope by sending its name.
|
||||||
|
|
||||||
|
The audience survives app upgrade, ordinary restart, repeated reconciliation,
|
||||||
|
failed install retry and reinstall that preserves the app database. This change
|
||||||
|
contains no pin deletion API or uninstall hook. It does not alter uninstall
|
||||||
|
choices, persistent app data, original Cloud files, wallet data or outstanding
|
||||||
|
media snapshots/rentals.
|
||||||
|
|
||||||
|
Explicit full data deletion is a separate operator decision. A future reset
|
||||||
|
workflow must coordinate deletion of app database/intents and pin state, and must
|
||||||
|
preserve or resolve outstanding node media/payment obligations before allowing a
|
||||||
|
new audience. Removing only one pin file is not a supported reset. Merely
|
||||||
|
uninstalling/reinstalling an app must not rotate its audience. If all installation
|
||||||
|
records are externally destroyed, this store cannot distinguish that from a
|
||||||
|
first installation; restore them along with the original app database rather than
|
||||||
|
re-enabling registration against a new audience.
|
||||||
|
|
||||||
|
## Prepared local checks
|
||||||
|
|
||||||
|
The patch adds five isolated store/env tests, three production-orchestrator
|
||||||
|
chokepoint tests and one manifest test. They cover stable retry/reload, distinct
|
||||||
|
app audiences, concurrent provisioning, key preservation, corruption/missing-pin
|
||||||
|
and foreign-key rejection, interrupted marker completion, symlink rejection,
|
||||||
|
authoritative/idempotent environment rendering, no implicit enablement, unrelated
|
||||||
|
apps without identity, and existing-key/public-key consistency. Fixtures use
|
||||||
|
private temporary directories and mock runtime calls only.
|
||||||
|
|
||||||
|
The combined run used `scripts/test-backend-isolated.sh`; all five store/env and
|
||||||
|
three orchestrator tests passed. Log:
|
||||||
|
`/tmp/archy-indeehub-executor-combined-tests.log`. All 377 captured source/build
|
||||||
|
input hashes remained unchanged through the run; provenance is recorded in
|
||||||
|
`/tmp/archy-indeehub-executor-combined-provenance.json`. The single failure is
|
||||||
|
`fips::dial::tests::purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer`.
|
||||||
|
Do not treat this as a clean combined suite. The container crate manifest test
|
||||||
|
still needs its scoped isolated run in the next coordinated slot. There is no
|
||||||
|
node deployment, catalog publication or app registration from this change.
|
||||||
|
|
||||||
|
Qualification update: the scoped isolated container run passed
|
||||||
|
`manifest::tests::media_registration_identity_is_explicit_and_preserved` (one
|
||||||
|
passed, zero failed, 82 filtered), with all 11 container/workspace input hashes
|
||||||
|
unchanged. Log: `/tmp/archy-registration-container-manifest-test.log`. The later
|
||||||
|
combined backend batch passed all installer tests again; its overall 1,834 passed,
|
||||||
|
one failed legacy missing-file expectation and five existing skips is not a clean
|
||||||
|
combined suite. Source remains undeployed.
|
||||||
|
|
||||||
|
## Combined qualification after resumed integration
|
||||||
|
|
||||||
|
The full isolated backend rerun passed 1,848 tests with zero failures and five
|
||||||
|
existing skips. All 385 recorded source, build and fixture hashes remained
|
||||||
|
unchanged. Evidence: `/tmp/archy-qualified-candidate-backend-rerun-tests.log` and
|
||||||
|
`/tmp/archy-qualified-candidate-backend-rerun-provenance.json`. Earlier failed
|
||||||
|
runs remain recorded. This qualifies the current backend primitives and Browse
|
||||||
|
path repair locally; production build and live deployment checks are next.
|
||||||
|
Complete purchase callers, app registration/publication and timed playback
|
||||||
|
acceptance remain open. No real payment or public publication was performed.
|
||||||
@@ -277,3 +277,22 @@ leave animation, and the song catalog loaded after bridge initialization. Hidden
|
|||||||
iframe checks now use interval polling because animation-frame polling stops
|
iframe checks now use interval polling because animation-frame polling stops
|
||||||
when hidden. Timeouts were not increased. These browser passes do not establish
|
when hidden. Timeouts were not increased. These browser passes do not establish
|
||||||
physical companion acceptance or replace the remaining lifecycle checks.
|
physical companion acceptance or replace the remaining lifecycle checks.
|
||||||
|
|
||||||
|
Publication review must also inventory the Yaya demo's visual assets in local
|
||||||
|
platform commits. A node-restricted promotion does not make a tracked asset or
|
||||||
|
Git commit private after publication. This worktree is not authorization to push
|
||||||
|
private demo source or derived private assets to either public mirror; retain
|
||||||
|
private delivery and prepare a separately reviewed public-safe contribution if
|
||||||
|
those assets are not approved for public distribution. No source publication was
|
||||||
|
performed during the resumed deployment and qualification work.
|
||||||
|
|
||||||
|
## Combined qualification after resumed integration
|
||||||
|
|
||||||
|
The full isolated backend rerun passed 1,848 tests with zero failures and five
|
||||||
|
existing skips. All 385 recorded source, build and fixture hashes remained
|
||||||
|
unchanged. Evidence: `/tmp/archy-qualified-candidate-backend-rerun-tests.log` and
|
||||||
|
`/tmp/archy-qualified-candidate-backend-rerun-provenance.json`. Earlier failed
|
||||||
|
runs remain recorded. This qualifies the current backend primitives and Browse
|
||||||
|
path repair locally; production build and live deployment checks are next.
|
||||||
|
Complete purchase callers, app registration/publication and timed playback
|
||||||
|
acceptance remain open. No real payment or public publication was performed.
|
||||||
|
|||||||
@@ -581,3 +581,23 @@ unchanged backend/session/app containers and rollback at
|
|||||||
Evidence: `/tmp/archy-payment-switch-dev-deploy.log`. Framework is reachable and
|
Evidence: `/tmp/archy-payment-switch-dev-deploy.log`. Framework is reachable and
|
||||||
healthy but runs a different backend/UI revision; this incremental deployment
|
healthy but runs a different backend/UI revision; this incremental deployment
|
||||||
has not changed it.
|
has not changed it.
|
||||||
|
|
||||||
|
Read-only real-attempt validation: Yaya's existing `lnd.paymentstatus` RPC returns
|
||||||
|
`failed`, `Insufficient channel balance`, and 2 sats for the operator's reported
|
||||||
|
Lightning attempt. This verifies the deployed compatibility UI's recovery lookup
|
||||||
|
against the actual existing backend, rather than only fixtures. Private evidence:
|
||||||
|
`/tmp/archy-payment-switch-incident/yaya-failed-lightning-rpc-result.private.json`.
|
||||||
|
The check did not pay, retry a payment, change a receipt, or infer seller invoice
|
||||||
|
cancellation. Browser interaction with the operator's own saved attempt remains
|
||||||
|
separate from this read-only RPC verification.
|
||||||
|
|
||||||
|
## Combined qualification after resumed integration
|
||||||
|
|
||||||
|
The full isolated backend rerun passed 1,848 tests with zero failures and five
|
||||||
|
existing skips. All 385 recorded source, build and fixture hashes remained
|
||||||
|
unchanged. Evidence: `/tmp/archy-qualified-candidate-backend-rerun-tests.log` and
|
||||||
|
`/tmp/archy-qualified-candidate-backend-rerun-provenance.json`. Earlier failed
|
||||||
|
runs remain recorded. This qualifies the current backend primitives and Browse
|
||||||
|
path repair locally; production build and live deployment checks are next.
|
||||||
|
Complete purchase callers, app registration/publication and timed playback
|
||||||
|
acceptance remain open. No real payment or public publication was performed.
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"plan": {
|
||||||
|
"mint_url": "https://mint.invalid",
|
||||||
|
"keysets": [
|
||||||
|
{
|
||||||
|
"keyset_id": "0011223344556677",
|
||||||
|
"denominations": [
|
||||||
|
4,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"input_fee_ppk": 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyset_id": "008899aabbccddee",
|
||||||
|
"denominations": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"input_fee_ppk": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gross_sats": 10,
|
||||||
|
"fee_sats": 1,
|
||||||
|
"net_sats": 9
|
||||||
|
},
|
||||||
|
"preimage": "[\"content-payment-fee-plan-v1\",\"https://mint.invalid\",10,1,9,[[\"0011223344556677\",400,[4,4]],[\"008899aabbccddee\",100,[2]]]]",
|
||||||
|
"sha256": "ae9985299ee707ac4a7ce6b1a5d358c64ac2a69e2481e587b1e66dbcb9a27b0a"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user