//! Durable immutable media registration and node-owned rental windows. //! No RPC endpoint, publication, payment or legacy filename-share mutation. use crate::{ container::registration_pin, content_purchase::{Contract, Journal, SellerPhase}, identity::NodeIdentity, media_registration::{self, AuthorizedSelection, Intent, Limits, Receipt}, }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ fs::{File, OpenOptions}, io::{Read, Seek, SeekFrom, Write}, os::{ fd::AsRawFd, unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}, }, path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; const APP_ID: &str = "indeedhub-api"; const STORE: &str = "registered-media"; const MAX_RECORD: u64 = 64 * 1024; /// Assertions from the authenticated dashboard approval path, NOT a body that /// untrusted apps may submit. Bind the exact approved intent/selection in that /// path before calling this function; request origin alone is not approval. pub(crate) struct ApprovedSelection<'a> { pub authenticated_producer: &'a str, pub authenticated_project: &'a str, pub intent: &'a Intent, pub selection: &'a AuthorizedSelection, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Stamp { device: u64, inode: u64, size: u64, changed_seconds: i64, changed_nanos: i64, } impl Stamp { fn from_file(file: &File) -> Result { let m = file.metadata()?; anyhow::ensure!( m.is_file() && m.mode() & 0o222 == 0, "Registered snapshot is not immutable" ); Ok(Self { device: m.dev(), inode: m.ino(), size: m.len(), changed_seconds: m.ctime(), changed_nanos: m.ctime_nsec(), }) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Registered { version: u8, receipt: Receipt, terms_sha256: String, mime_type: String, stamp: Stamp, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Lease { version: u8, purchase_id: String, buyer_did: String, content_id: String, contract_hash: String, capability_hash: String, started_at: u64, expires_at: u64, } /// The adapter must recheck this deadline while streaming chunks, close the /// stream at expiry, and must not call open_paid for HTTP HEAD/preflight. pub(crate) struct OpenedMedia { pub file: File, pub size_bytes: u64, pub mime_type: String, pub started_at: u64, pub expires_at: u64, } impl OpenedMedia { pub fn still_authorized(&self, now: u64) -> bool { now >= self.started_at && now < self.expires_at } } fn uuid(value: &str) -> Result<()> { let parsed = uuid::Uuid::parse_str(value)?; anyhow::ensure!( parsed.to_string() == value && parsed.get_version_num() == 4 && parsed.get_variant() == uuid::Variant::RFC4122, "Invalid registration identifier" ); Ok(()) } fn registration_id(content_id: &str) -> Result<&str> { let id = content_id .strip_prefix("registered_") .context("Unknown registered content identifier")?; uuid(id)?; Ok(id) } fn hash(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) } fn selected_mime(selection: &AuthorizedSelection) -> Result<&'static str> { match selection .relative_path .extension() .and_then(|v| v.to_str()) .map(str::to_ascii_lowercase) .as_deref() { Some("mp4" | "m4v") => Ok("video/mp4"), Some("webm") => Ok("video/webm"), Some("mov") => Ok("video/quicktime"), _ => anyhow::bail!("Select an MP4, WebM or QuickTime video for this registration"), } } fn terms(receipt: &Receipt) -> Result { Ok(hash(&serde_json::to_vec(&serde_json::json!([ "archipelago.registered-media.terms.v1", receipt.node_did, receipt.app_audience, receipt.producer, receipt.project_id, receipt.content_id, receipt.sha256, receipt.size_bytes, receipt.price_sats, receipt.viewing_seconds, receipt.payment_methods ]))?)) } fn constant_equal(a: &str, b: &str) -> bool { let a = Sha256::digest(a.as_bytes()); let b = Sha256::digest(b.as_bytes()); a.iter() .zip(b.iter()) .fold(0u8, |diff, (a, b)| diff | (a ^ b)) == 0 } struct Held { path: PathBuf, dir: File, _key_lock: Option, } fn store(data_dir: &Path, create: bool) -> Result { let path = data_dir.join(STORE); if create { let mut builder = std::fs::DirBuilder::new(); builder.mode(0o700); if let Err(error) = builder.create(&path) { if error.kind() != std::io::ErrorKind::AlreadyExists { return Err(error.into()); } } File::open(data_dir)?.sync_all()?; } let dir = OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) .open(&path)?; let m = dir.metadata()?; anyhow::ensure!( m.uid() == unsafe { libc::geteuid() } && m.mode() & 0o077 == 0, "Registered media store must remain private" ); Ok(Held { path, dir, _key_lock: None, }) } fn lock(file: &File) -> Result<()> { let deadline = Instant::now() + Duration::from_secs(30); loop { if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { break; } 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, "Registered media is busy; retry the same operation" ); std::thread::sleep(Duration::from_millis(20)); } Ok(()) } fn held(data_dir: &Path, create: bool) -> Result { let held = store(data_dir, create)?; lock(&held.dir)?; Ok(held) } fn keyed(data_dir: &Path, purpose: &str, id: &str) -> Result { uuid(id)?; anyhow::ensure!( matches!(purpose, "verify" | "lease"), "Invalid registered media lock scope" ); let mut held = store(data_dir, false)?; let file = OpenOptions::new() .read(true) .write(true) .create(true) .mode(0o600) .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) .open(held.path.join(format!("{purpose}-{id}.lock")))?; let metadata = file.metadata()?; anyhow::ensure!( metadata.is_file() && metadata.uid() == unsafe { libc::geteuid() } && metadata.mode() & 0o077 == 0, "Invalid registered media lock storage" ); lock(&file)?; held._key_lock = Some(file); Ok(held) } fn read(path: &Path) -> Result> { let file = match OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) .open(path) { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), }; let m = file.metadata()?; anyhow::ensure!( m.is_file() && m.mode() & 0o077 == 0 && m.len() <= MAX_RECORD, "Invalid registered media record storage" ); let mut bytes = Vec::new(); file.take(MAX_RECORD + 1).read_to_end(&mut bytes)?; anyhow::ensure!( bytes.len() as u64 <= MAX_RECORD, "Registered media record too large" ); Ok(Some(serde_json::from_slice(&bytes).context( "Registered media state is damaged; preserve it", )?)) } fn persist(held: &Held, path: &Path, value: &T) -> Result<()> { let mut pending = tempfile::NamedTempFile::new_in(&held.path)?; pending.write_all(&serde_json::to_vec(value)?)?; pending.as_file().sync_all()?; pending .persist_noclobber(path) .map_err(|e| anyhow::anyhow!("Could not save registered media state: {}", e.error))?; held.dir.sync_all()?; Ok(()) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct VerifiedSnapshot { version: u8, registration_id: String, receipt_hash: String, sha256: String, stamp: Stamp, } fn verification(record: &Registered) -> Result { Ok(VerifiedSnapshot { version: 1, registration_id: record.receipt.request_id.clone(), receipt_hash: hash(&record.receipt.preimage()?), sha256: record.receipt.sha256.clone(), stamp: record.stamp.clone(), }) } fn persist_verified(held: &Held, record: &Registered) -> Result<()> { let expected = verification(record)?; let path = held .path .join(format!("verified-{}.json", record.receipt.request_id)); if let Some(saved) = read::(&path)? { anyhow::ensure!( saved == expected, "Immutable snapshot verification binding changed" ); } else if let Err(error) = persist(held, &path, &expected) { // Registration retry and missing-cache recovery use different narrow // locks. Accept an identical no-replace winner only after independently // flushing its file and directory; a failed fsync is never success. if read::(&path)?.as_ref() != Some(&expected) { return Err(error); } OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) .open(&path)? .sync_all()?; held.dir.sync_all()?; } Ok(()) } fn ensure_verified(data_dir: &Path, record: &Registered, file: &mut File) -> Result<()> { ensure_verified_for_use(data_dir, record, file, false) } fn ensure_verified_for_use( data_dir: &Path, record: &Registered, file: &mut File, first_use: bool, ) -> Result<()> { // This per-registration lock does not hold the mapping/global directory or // any buyer lease lock while hashing. Range opens can reuse the saved // verification, but a new lease always checks bytes before starting its clock: // same-size writes within a filesystem timestamp tick can share a stamp. let held = keyed(data_dir, "verify", &record.receipt.request_id)?; let path = held .path .join(format!("verified-{}.json", record.receipt.request_id)); let expected = verification(record)?; if let Some(saved) = read::(&path)? { anyhow::ensure!( saved == expected && Stamp::from_file(file)? == record.stamp, "Immutable snapshot verification binding changed" ); if !first_use { return Ok(()); } } // A new lease or missing cache requires the original signed byte hash. Never // manufacture a positive cache entry from metadata alone after restart. file.seek(SeekFrom::Start(0))?; let mut digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let count = file.read(&mut buffer)?; if count == 0 { break; } digest.update(&buffer[..count]); } anyhow::ensure!( hex::encode(digest.finalize()) == record.receipt.sha256 && Stamp::from_file(file)? == record.stamp, "Registered snapshot content changed" ); file.seek(SeekFrom::Start(0))?; persist_verified(&held, record) } fn verify( record: &Registered, pin: ®istration_pin::RegistrationPin, identity: &NodeIdentity, ) -> Result<()> { let r = &record.receipt; uuid(&r.request_id)?; let size: u64 = r.size_bytes.parse()?; anyhow::ensure!( record.version == 1 && r.version == 1 && registration_id(&r.content_id)? == r.request_id && r.node_did == pin.node_did && r.app_audience == pin.app_audience && r.size_bytes == size.to_string() && record.stamp.size == size && matches!( record.mime_type.as_str(), "video/mp4" | "video/webm" | "video/quicktime" ) && r.viewing_seconds > 0 && r.viewing_seconds <= 31_536_000 && record.terms_sha256 == terms(r)?, "Registered media binding changed" ); anyhow::ensure!( NodeIdentity::verify(&identity.pubkey_hex(), &r.preimage()?, &r.signature)?, "Registered media receipt signature failed" ); Ok(()) } fn open_snapshot(data_dir: &Path, record: &Registered) -> Result { use std::ffi::CString; use std::os::fd::FromRawFd; // Resolve from the configured data directory in one kernel operation. No // component can be replaced with a symlink between separate path checks. let id = registration_id(&record.receipt.content_id)?; let configured_root = data_dir .canonicalize() .context("Configured node data directory unavailable")?; let root = OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) .open(configured_root)?; let relative = CString::new(format!("media-registration/{id}/media"))?; #[repr(C)] struct OpenHow { flags: u64, mode: u64, resolve: u64, } let how = OpenHow { flags: (libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC) as u64, mode: 0, resolve: 0x08 | 0x04, // RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS }; let fd = unsafe { libc::syscall( libc::SYS_openat2, root.as_raw_fd(), relative.as_ptr(), &how, std::mem::size_of::(), ) }; anyhow::ensure!( fd >= 0, "Could not open immutable registered snapshot: {}", std::io::Error::last_os_error() ); let file = unsafe { File::from_raw_fd(fd as i32) }; anyhow::ensure!( Stamp::from_file(&file)? == record.stamp, "Registered immutable snapshot changed" ); Ok(file) } /// Blocking-worker API. Route must authenticate producer/project and obtain /// explicit operator consent before construction of ApprovedSelection. Returned /// receipt now has durable immutable serving terms; it is not yet advertised. pub(crate) fn register_approved_selection( data_dir: &Path, cloud_root: &Path, identity: &NodeIdentity, approved: &ApprovedSelection<'_>, now: u64, limits: &Limits<'_>, progress: impl FnMut(u64) -> Result<()>, ) -> Result { anyhow::ensure!( approved.authenticated_producer == approved.intent.producer && approved.authenticated_project == approved.intent.project_id, "Approved media owner or project changed" ); let mime_type = selected_mime(approved.selection)?.to_owned(); let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?; let prepared = media_registration::prepare( data_dir, cloud_root, identity, &media_registration::InstallationPin { node_did: pin.node_did.clone(), app_audience: pin.app_audience.clone(), }, approved.intent, approved.selection, now, limits, progress, )?; commit_prepared(data_dir, identity, &pin, prepared, mime_type) } fn commit_prepared( data_dir: &Path, identity: &NodeIdentity, pin: ®istration_pin::RegistrationPin, prepared: media_registration::PreparedRegistration, mime_type: String, ) -> Result { let record = Registered { version: 1, terms_sha256: terms(&prepared.receipt)?, mime_type, stamp: Stamp::from_file(&prepared.snapshot)?, receipt: prepared.receipt, }; verify(&record, &pin, identity)?; let held = held(data_dir, true)?; // prepare verified the bytes against the signed receipt before returning its // descriptor. Preserve that attestation before publishing the serving map. persist_verified(&held, &record)?; let path = held .path .join(format!("{}.json", record.receipt.request_id)); if let Some(saved) = read::(&path)? { anyhow::ensure!(saved == record, "Registered media operation changed"); } else { persist(&held, &path, &record)?; } Ok(record.receipt) } /// Intent-only resolution preserves original file selection; the request cannot /// choose a new path. Serving metadata is durable before recovered receipt return. pub(crate) fn resolve_registration( data_dir: &Path, identity: &NodeIdentity, intent: &Intent, authenticated_producer: &str, now: u64, limits: &Limits<'_>, ) -> Result { anyhow::ensure!( authenticated_producer == intent.producer, "Resolution producer changed" ); let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?; match media_registration::resolve( data_dir, identity, &media_registration::InstallationPin { node_did: pin.node_did.clone(), app_audience: pin.app_audience.clone(), }, intent, now, limits, )? { media_registration::Resolution::Prepared { prepared, selection, } => { let receipt = commit_prepared( data_dir, identity, &pin, prepared, selected_mime(&selection)?.into(), )?; Ok(serde_json::json!({"phase":"completed", "receipt":receipt})) } media_registration::Resolution::Retired(retirement) => { Ok(serde_json::json!({"phase":"retired", "retirement":retirement})) } media_registration::Resolution::Pending { request_id, expires_at, } => Ok( serde_json::json!({"phase":"pending", "requestId":request_id, "expiresAt":expires_at}), ), } } /// No request chooses app scope or storage path. This is an offer prerequisite, /// not advertisement: the future offer creator must authenticate the peer and /// bind all returned terms into the purchase contract before seller acceptance. pub(crate) fn registered_terms( data_dir: &Path, identity: &NodeIdentity, content_id: &str, ) -> Result<(Receipt, String)> { let id = registration_id(content_id)?; let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?; let held = held(data_dir, false)?; let record: Registered = read(&held.path.join(format!("{id}.json")))? .context("Registered content is unavailable")?; drop(held); verify(&record, &pin, identity)?; let mut file = open_snapshot(data_dir, &record)?; // A quote must not invite payment for altered bytes, including a same-tick // metadata collision. This scan completes before any offer is accepted. ensure_verified_for_use(data_dir, &record, &mut file, true)?; Ok((record.receipt, record.terms_sha256)) } /// Only authenticated peer GET/range routes may call this. The capability is /// checked against this node's durable seller journal; client receipts do not /// establish payment. A lease is persisted before any bytes can be returned. pub(crate) async fn open_paid( data_dir: PathBuf, identity: Arc, content_id: String, purchase_id: String, authenticated_buyer: String, capability: String, ) -> Result { uuid(&purchase_id)?; registration_id(&content_id)?; anyhow::ensure!( capability.len() == 64 && capability.bytes().all(|c| c.is_ascii_hexdigit()), "Invalid delivery capability" ); let (contract, saved_capability) = { let journal = Journal::open(&data_dir).await?; let record = journal .seller(&purchase_id) .await? .context("Seller settlement is not durable")?; anyhow::ensure!( record.contract.buyer_did == authenticated_buyer && record.contract.seller_did == identity.did_key()? && record.contract.content_id == content_id, "Paid content does not belong to this authenticated purchase" ); let receipt = match record.phase { SellerPhase::ReceiptSaved(receipt) => receipt, _ => anyhow::bail!("Seller receipt is not durable"), }; anyhow::ensure!( constant_equal(&capability, &receipt.capability), "Delivery capability does not match this purchase" ); (record.contract, receipt.capability) }; tokio::task::spawn_blocking(move || { open_settled(&data_dir, &identity, &contract, &saved_capability, || { u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock") }) }) .await? } fn open_settled( data_dir: &Path, identity: &NodeIdentity, contract: &Contract, capability: &str, now: impl Fn() -> Result, ) -> Result { let id = registration_id(&contract.content_id)?; let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?; let held = held(data_dir, false)?; let record: Registered = read(&held.path.join(format!("{id}.json")))? .context("Registered content is unavailable")?; drop(held); verify(&record, &pin, identity)?; anyhow::ensure!( contract.content_sha256 == record.receipt.sha256 && contract.content_size == record.stamp.size && contract.terms_sha256 == record.terms_sha256 && record.receipt.price_sats > 0 && record .receipt .payment_methods .iter() .any(|method| method == "cashu") && contract.minimum_net_sats == record.receipt.price_sats, "Settled purchase does not match registered immutable terms" ); // Open before recording a first use: unreadable or altered media does not // start a rental. No bytes leave this descriptor until the lease is durable. let mut file = open_snapshot(data_dir, &record)?; let lease_path = data_dir .join(STORE) .join(format!("lease-{}.json", contract.id)); let first_use = read::(&lease_path)?.is_none(); ensure_verified_for_use(data_dir, &record, &mut file, first_use)?; let held = keyed(data_dir, "lease", &contract.id)?; let path = held.path.join(format!("lease-{}.json", contract.id)); let contract_hash = contract.context_hash()?; let capability_hash = hash(capability.as_bytes()); let lease = if let Some(lease) = read::(&path)? { anyhow::ensure!( lease.version == 1 && lease.purchase_id == contract.id && lease.buyer_did == contract.buyer_did && lease.content_id == contract.content_id && lease.contract_hash == contract_hash && lease.capability_hash == capability_hash && lease.started_at.checked_add(record.receipt.viewing_seconds) == Some(lease.expires_at), "Persisted rental terms changed" ); lease } else { let now = now()?; let expires_at = now .checked_add(record.receipt.viewing_seconds) .context("Rental expiry overflow")?; let lease = Lease { version: 1, purchase_id: contract.id.clone(), buyer_did: contract.buyer_did.clone(), content_id: contract.content_id.clone(), contract_hash, capability_hash, started_at: now, expires_at, }; persist(&held, &path, &lease)?; lease }; let now = now()?; anyhow::ensure!( now >= lease.started_at && now < lease.expires_at, "Rental expired or node clock moved backwards" ); Ok(OpenedMedia { file, size_bytes: record.stamp.size, mime_type: record.mime_type, started_at: lease.started_at, expires_at: lease.expires_at, }) } #[cfg(test)] mod tests { use super::*; use crate::wallet::{ cashu::{CashuToken, Proof}, ecash::EcashNetwork, }; use std::os::unix::fs::PermissionsExt; use std::sync::atomic::AtomicBool; struct Fixture { root: tempfile::TempDir, identity: Arc, intent: Intent, selection: AuthorizedSelection, cancel: AtomicBool, } impl Fixture { async fn new() -> Self { let root = tempfile::tempdir().unwrap(); let identity_dir = root.path().join("identity"); std::fs::create_dir(&identity_dir).unwrap(); // Public fixed test key; never invoke node identity generation. std::fs::write(identity_dir.join("node_key"), [7u8; 32]).unwrap(); std::fs::set_permissions( identity_dir.join("node_key"), std::fs::Permissions::from_mode(0o600), ) .unwrap(); let identity = Arc::new(NodeIdentity::load_existing(&identity_dir).await.unwrap()); let pin = registration_pin::ensure_for_installation(root.path(), APP_ID, &identity).unwrap(); let cloud = root.path().join("cloud"); std::fs::create_dir(&cloud).unwrap(); std::fs::write(cloud.join("film.mp4"), b"original immutable movie").unwrap(); Self { root, identity, intent: Intent { version: 1, request_id: uuid::Uuid::new_v4().to_string(), nonce: "ab".repeat(32), app_audience: pin.app_audience, node_did: pin.node_did, producer: "cd".repeat(32), project_id: "film-project".into(), price_sats: 8, viewing_seconds: 60, created_at: 1000, expires_at: 1600, }, selection: AuthorizedSelection { relative_path: "film.mp4".into(), payment_methods: vec!["cashu".into()], }, cancel: AtomicBool::new(false), } } fn register(&self, now: u64) -> Result { register_approved_selection( self.root.path(), &self.root.path().join("cloud"), &self.identity, &ApprovedSelection { authenticated_producer: &self.intent.producer, authenticated_project: &self.intent.project_id, intent: &self.intent, selection: &self.selection, }, now, &Limits { max_bytes: 1_000_000, cancelled: &self.cancel, }, |_| Ok(()), ) } fn contract(&self, receipt: &Receipt) -> 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: self.identity.did_key().unwrap(), content_id: receipt.content_id.clone(), content_sha256: receipt.sha256.clone(), content_size: receipt.size_bytes.parse().unwrap(), terms_sha256: terms(receipt).unwrap(), network: EcashNetwork::Mainnet, mint_url: "https://fixture.invalid".into(), gross_token_sats: 8, minimum_net_sats: 8, offered_at: 1000, expires_at: 1600, } } async fn settle_fixture(&self, contract: &Contract) -> crate::content_purchase::Receipt { // Local journal state fixture only, no wallet/mint call or claimed // live settlement. Public open_paid must require this durable state. let key = bitcoin::secp256k1::SecretKey::from_slice(&[7; 32]).unwrap(); let point = bitcoin::secp256k1::PublicKey::from_secret_key( &bitcoin::secp256k1::Secp256k1::new(), &key, ) .to_string(); let token = CashuToken::new( &contract.mint_url, vec![Proof { id: "0011223344556677".into(), amount: 8, secret: "fixture-incoming".into(), c: point, }], ) .serialize() .unwrap(); let journal = Journal::open(self.root.path()).await.unwrap(); journal.prepare_seller(contract, 1200).await.unwrap(); journal .record_incoming_token(contract, &token) .await .unwrap(); journal.record_settlement(contract, 8).await.unwrap(); journal.issue_receipt(contract).await.unwrap() } } #[test] fn independent_nodejs_terms_preimage_and_digest_match() { let fixture: serde_json::Value = serde_json::from_str(include_str!("registered_media/fixtures/terms-v1.json")).unwrap(); let receipt: Receipt = serde_json::from_value(fixture["receipt"].clone()).unwrap(); assert_eq!( terms(&receipt).unwrap(), fixture["sha256"].as_str().unwrap() ); assert_eq!( hash(fixture["preimageUtf8"].as_str().unwrap().as_bytes()), fixture["sha256"].as_str().unwrap() ); let mut changed = receipt; changed.viewing_seconds += 1; assert_ne!( terms(&changed).unwrap(), fixture["sha256"].as_str().unwrap() ); } #[tokio::test] async fn approved_mapping_is_durable_and_retries_after_source_removal_preserve_terms() { let mut fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let mapping = fixture .root .path() .join(STORE) .join(format!("{}.json", receipt.request_id)); let original = std::fs::read(&mapping).unwrap(); // Model interruption between durable registration receipt and serving // mapping: retry reconstructs only the exact original immutable mapping. std::fs::rename(&mapping, fixture.root.path().join("fixture-mapping-backup")).unwrap(); assert_eq!(fixture.register(2000).unwrap(), receipt); assert_eq!(std::fs::read(&mapping).unwrap(), original); std::fs::remove_file(fixture.root.path().join("cloud/film.mp4")).unwrap(); assert_eq!(fixture.register(2000).unwrap(), receipt); assert_eq!(std::fs::read(&mapping).unwrap(), original); let found = registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap(); assert_eq!(found, (receipt.clone(), terms(&receipt).unwrap())); fixture.intent.price_sats = 9; assert!(fixture.register(1200).is_err()); assert_eq!(std::fs::read(&mapping).unwrap(), original); } #[tokio::test] async fn wrong_owner_and_unprovisioned_or_changed_app_pin_reject_before_snapshot() { let fixture = Fixture::new().await; let rejected = register_approved_selection( fixture.root.path(), &fixture.root.path().join("cloud"), &fixture.identity, &ApprovedSelection { authenticated_producer: "foreign", authenticated_project: &fixture.intent.project_id, intent: &fixture.intent, selection: &fixture.selection, }, 1100, &Limits { max_bytes: 1000, cancelled: &fixture.cancel, }, |_| Ok(()), ); assert!(rejected.is_err()); assert!(!fixture.root.path().join("media-registration").exists()); let mut wrong = fixture.intent.clone(); wrong.app_audience = uuid::Uuid::new_v4().to_string(); assert!(register_approved_selection( fixture.root.path(), &fixture.root.path().join("cloud"), &fixture.identity, &ApprovedSelection { authenticated_producer: &wrong.producer, authenticated_project: &wrong.project_id, intent: &wrong, selection: &fixture.selection }, 1100, &Limits { max_bytes: 1000, cancelled: &fixture.cancel }, |_| Ok(()) ) .is_err()); assert!(!fixture.root.path().join("media-registration").exists()); std::fs::remove_file( fixture .root .path() .join("app-registration-pins/indeedhub-api.json"), ) .unwrap(); assert!(fixture.register(1100).is_err()); assert!(!fixture.root.path().join("media-registration").exists()); } #[tokio::test] async fn rental_reopens_keep_first_window_and_reject_expiry_clock_rollback_and_changed_receipt() { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let contract = fixture.contract(&receipt); let first = open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2000), ) .unwrap(); assert_eq!((first.started_at, first.expires_at), (2000, 2060)); let mut again = open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2030), ) .unwrap(); assert_eq!((again.started_at, again.expires_at), (2000, 2060)); let mut bytes = Vec::new(); again.file.read_to_end(&mut bytes).unwrap(); assert_eq!(bytes, b"original immutable movie"); assert!(again.still_authorized(2059)); assert!(!again.still_authorized(2060)); assert!(!again.still_authorized(1999)); assert!(open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2060) ) .is_err()); assert!(open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(1999) ) .is_err()); assert!(open_settled( fixture.root.path(), &fixture.identity, &contract, &"cd".repeat(32), || Ok(2030) ) .is_err()); } #[tokio::test] async fn public_open_requires_matching_durable_settlement_and_peer_capability() { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let contract = fixture.contract(&receipt); assert!(open_paid( fixture.root.path().into(), fixture.identity.clone(), receipt.content_id.clone(), contract.id.clone(), contract.buyer_did.clone(), "ab".repeat(32) ) .await .is_err()); let settled = fixture.settle_fixture(&contract).await; assert!(open_paid( fixture.root.path().into(), fixture.identity.clone(), receipt.content_id.clone(), contract.id.clone(), contract.seller_did.clone(), settled.capability.clone() ) .await .is_err()); assert!(open_paid( fixture.root.path().into(), fixture.identity.clone(), receipt.content_id.clone(), contract.id.clone(), contract.buyer_did.clone(), "ab".repeat(32) ) .await .is_err()); assert!(!fixture .root .path() .join(STORE) .join(format!("lease-{}.json", contract.id)) .exists()); let opened = open_paid( fixture.root.path().into(), fixture.identity.clone(), receipt.content_id.clone(), contract.id.clone(), contract.buyer_did.clone(), settled.capability.clone(), ) .await .unwrap(); assert_eq!(opened.expires_at - opened.started_at, 60); assert_eq!(opened.size_bytes, 24); } #[tokio::test] async fn altered_snapshot_or_overflow_never_creates_first_rental() { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let contract = fixture.contract(&receipt); assert!(open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(u64::MAX) ) .is_err()); let lease = fixture .root .path() .join(STORE) .join(format!("lease-{}.json", contract.id)); assert!(!lease.exists()); let media = fixture .root .path() .join("media-registration") .join(&receipt.request_id) .join("media"); std::fs::set_permissions(&media, std::fs::Permissions::from_mode(0o600)).unwrap(); std::fs::write(&media, b"changed immutable movie!").unwrap(); std::fs::set_permissions(&media, std::fs::Permissions::from_mode(0o400)).unwrap(); assert!(open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2000) ) .is_err()); assert!(!lease.exists()); // Independently exercise SHA256 failure, not only the inode/ctime gate. // This local fixture updates only the unsigned filesystem stamp; the // original signed content hash remains unchanged and must still win. let mapping = fixture .root .path() .join(STORE) .join(format!("{}.json", receipt.request_id)); let mut record: Registered = read(&mapping).unwrap().unwrap(); record.stamp = Stamp::from_file(&File::open(&media).unwrap()).unwrap(); // Model an indistinguishable metadata stamp deterministically: both // unsigned cache/mapping stamps match, while signed bytes do not. A // positive metadata cache must not authorize an offer or first lease. std::fs::write(&mapping, serde_json::to_vec(&record).unwrap()).unwrap(); let verified = fixture .root .path() .join(STORE) .join(format!("verified-{}.json", receipt.request_id)); std::fs::write( &verified, serde_json::to_vec(&verification(&record).unwrap()).unwrap(), ) .unwrap(); assert!( registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).is_err() ); assert!(open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2000) ) .is_err()); assert!(!lease.exists()); std::fs::remove_file( fixture .root .path() .join(STORE) .join(format!("verified-{}.json", receipt.request_id)), ) .unwrap(); std::fs::write(&mapping, serde_json::to_vec(&record).unwrap()).unwrap(); let error = open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2000), ) .err() .unwrap(); assert!(error.to_string().contains("snapshot content changed")); assert!(!lease.exists()); } #[tokio::test] async fn concurrent_first_opens_share_one_persisted_window() { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let contract = fixture.contract(&receipt); let threads: Vec<_> = [2000u64, 2000] .into_iter() .map(|now| { let root = fixture.root.path().to_owned(); let identity = fixture.identity.clone(); let contract = contract.clone(); std::thread::spawn(move || { let opened = open_settled(&root, &identity, &contract, &"ab".repeat(32), || Ok(now)) .unwrap(); (opened.started_at, opened.expires_at) }) }) .collect(); let windows: Vec<_> = threads.into_iter().map(|t| t.join().unwrap()).collect(); assert_eq!(windows[0], windows[1]); assert_eq!(windows[0].1 - windows[0].0, 60); } #[tokio::test] async fn verification_and_purchase_locks_do_not_hold_other_content_or_mapping_locks() { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let verify_lock = keyed(fixture.root.path(), "verify", &receipt.request_id).unwrap(); let other_content = keyed( fixture.root.path(), "verify", &uuid::Uuid::new_v4().to_string(), ) .unwrap(); let purchase = keyed( fixture.root.path(), "lease", &uuid::Uuid::new_v4().to_string(), ) .unwrap(); let directory = held(fixture.root.path(), false).unwrap(); // All locks remain held at once: a blocked/hash-heavy registration does // not lock another video, another purchase, or the metadata directory. drop((verify_lock, other_content, purchase, directory)); } #[tokio::test] async fn registration_cache_survives_reconstruction_and_missing_cache_rehashes_before_rental() { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let contract = fixture.contract(&receipt); let cache = fixture .root .path() .join(STORE) .join(format!("verified-{}.json", receipt.request_id)); let original = std::fs::read(&cache).unwrap(); let record: Registered = read( &fixture .root .path() .join(STORE) .join(format!("{}.json", receipt.request_id)), ) .unwrap() .unwrap(); let mut file = open_snapshot(fixture.root.path(), &record).unwrap(); // A cached check does not scan or reposition the verified descriptor. file.seek(SeekFrom::Start(3)).unwrap(); ensure_verified(fixture.root.path(), &record, &mut file).unwrap(); assert_eq!(file.stream_position().unwrap(), 3); std::fs::remove_file(&cache).unwrap(); let opened = open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2000), ) .unwrap(); assert_eq!(opened.started_at, 2000); assert_eq!(std::fs::read(cache).unwrap(), original); } #[tokio::test] async fn mismatched_verification_cache_is_preserved_and_cannot_start_rental() { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let contract = fixture.contract(&receipt); let path = fixture .root .path() .join(STORE) .join(format!("verified-{}.json", receipt.request_id)); let mut cache: VerifiedSnapshot = read(&path).unwrap().unwrap(); cache.sha256 = "ff".repeat(32); let changed = serde_json::to_vec(&cache).unwrap(); std::fs::write(&path, &changed).unwrap(); assert!(open_settled( fixture.root.path(), &fixture.identity, &contract, &"ab".repeat(32), || Ok(2000) ) .is_err()); assert_eq!(std::fs::read(path).unwrap(), changed); assert!(!fixture .root .path() .join(STORE) .join(format!("lease-{}.json", contract.id)) .exists()); } #[tokio::test] async fn offer_preflight_rebuilds_verified_cache_without_creating_rental_and_rejects_corruption( ) { let fixture = Fixture::new().await; let receipt = fixture.register(1100).unwrap(); let path = fixture .root .path() .join(STORE) .join(format!("verified-{}.json", receipt.request_id)); let original = std::fs::read(&path).unwrap(); std::fs::remove_file(&path).unwrap(); let (terms, _) = registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap(); assert_eq!(terms, receipt); assert_eq!(std::fs::read(&path).unwrap(), original); assert!(std::fs::read_dir(fixture.root.path().join(STORE)) .unwrap() .all(|entry| !entry .unwrap() .file_name() .to_string_lossy() .starts_with("lease-"))); let mut cache: VerifiedSnapshot = read(&path).unwrap().unwrap(); cache.sha256 = "ff".repeat(32); std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap(); assert!( registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).is_err() ); } }