From 589092b954857fbd46f18766d6e77fa41e75eabe Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:44:37 +0000 Subject: [PATCH 01/18] feat(runtime): add provider invocation transfer rails Add the Runtime provider registry and gateway proxy support needed for object/content provider invocation, stream sessions, progress/cancel metadata, and provider-backed viewer handoff. This is the transport/control-plane slice. Concrete Library, content, Spaces, and package surfaces are committed separately so reviewers can separate runtime plumbing from product behavior. --- elastos/Cargo.lock | 31 + .../elastos-runtime/src/provider/mod.rs | 5 +- .../elastos-runtime/src/provider/registry.rs | 1473 ++++++++++++++++ elastos/crates/elastos-server/Cargo.toml | 1 + .../crates/elastos-server/src/api/gateway.rs | 49 +- .../src/api/gateway_provider_proxy.rs | 1553 ++++++++++++++++- .../elastos-server/src/api/viewer_gateway.rs | 388 +++- elastos/crates/elastos-server/src/lib.rs | 1 + elastos/crates/elastos-server/src/main.rs | 202 +++ .../crates/elastos-server/src/server_infra.rs | 1023 ++++++++++- 10 files changed, 4649 insertions(+), 77 deletions(-) diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index 5a02434b..c77bb4dd 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -171,6 +171,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arc-swap" @@ -1317,6 +1320,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1797,6 +1811,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "zip", ] [[package]] @@ -7376,6 +7391,22 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/elastos/crates/elastos-runtime/src/provider/mod.rs b/elastos/crates/elastos-runtime/src/provider/mod.rs index a8783292..81663a2d 100755 --- a/elastos/crates/elastos-runtime/src/provider/mod.rs +++ b/elastos/crates/elastos-runtime/src/provider/mod.rs @@ -11,7 +11,10 @@ mod registry; pub use bridge::{CapsuleProvider, ProviderBridge, ProviderConfig as BridgeProviderConfig}; pub use registry::{ - EntryType, Provider, ProviderError, ProviderRegistry, ResourceAction, ResourceResponse, + EntryType, Provider, ProviderByteRange, ProviderCarrierInvoker, ProviderCarrierRoute, + ProviderError, ProviderInvocation, ProviderInvocationTransport, ProviderProgress, + ProviderRegistry, ProviderStreamOptions, ProviderStreamRead, ProviderStreamSession, + ProviderTransfer, ResourceAction, ResourceResponse, }; // Re-export for use by external provider implementations diff --git a/elastos/crates/elastos-runtime/src/provider/registry.rs b/elastos/crates/elastos-runtime/src/provider/registry.rs index 66c8e20f..603adb83 100755 --- a/elastos/crates/elastos-runtime/src/provider/registry.rs +++ b/elastos/crates/elastos-runtime/src/provider/registry.rs @@ -8,9 +8,11 @@ //! exclusively: `elastos://did/*`, `elastos://peer/*`, `elastos://ai/*`. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::RwLock; +use base64::Engine as _; use elastos_common::localhost::{parse_localhost_path, parse_localhost_uri}; /// A resource request @@ -113,6 +115,289 @@ pub enum ProviderError { Io(std::io::Error), } +/// Provider-to-provider invocation transfer contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderTransfer { + /// JSON request/response envelope. + Json, + /// JSON envelope whose response may carry bounded byte arrays. + Bytes, + /// JSON envelope whose response carries bounded byte chunks. + Stream, +} + +impl ProviderTransfer { + pub fn as_str(self) -> &'static str { + match self { + ProviderTransfer::Json => "json", + ProviderTransfer::Bytes => "bytes", + ProviderTransfer::Stream => "stream", + } + } +} + +const PROVIDER_STREAM_SCHEMA: &str = "elastos.provider.stream/v1"; +const PROVIDER_STREAM_ENCODING: &str = "base64-chunks"; +const PROVIDER_STREAM_CHUNK_BYTES: usize = 64 * 1024; +const PROVIDER_STREAM_SESSION_SCHEMA: &str = "elastos.provider.stream-session/v1"; +const PROVIDER_STREAM_EVENT_SCHEMA: &str = "elastos.provider.stream-event/v1"; +static NEXT_PROVIDER_STREAM_ID: AtomicU64 = AtomicU64::new(1); + +/// Carrier route for Runtime-mediated provider-to-provider invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCarrierRoute { + /// Internal Carrier connect ticket. Receipts intentionally do not expose it. + pub connect_ticket: String, + /// Optional expected remote peer DID/node identity for audit and policy. + pub peer_did: Option, + /// Optional per-hop timeout. Runtime rejects zero-duration values. + pub timeout_ms: Option, +} + +/// Runtime transport used for provider-to-provider invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderInvocationTransport { + /// In-process Runtime provider registry. + Local, + /// Carrier provider plane. Apps never select this directly. + Carrier(ProviderCarrierRoute), +} + +impl ProviderInvocationTransport { + fn as_str(&self) -> &'static str { + match self { + ProviderInvocationTransport::Local => "runtime-local-provider-plane", + ProviderInvocationTransport::Carrier(_) => "carrier-provider-plane", + } + } + + fn carrier_route(&self) -> Option<&ProviderCarrierRoute> { + match self { + ProviderInvocationTransport::Local => None, + ProviderInvocationTransport::Carrier(route) => Some(route), + } + } +} + +impl Default for ProviderInvocationTransport { + fn default() -> Self { + Self::Local + } +} + +/// Optional byte range requested for a provider-to-provider transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderByteRange { + /// Inclusive start byte. + pub start: u64, + /// Inclusive end byte. `None` means open-ended. + pub end: Option, +} + +/// Optional progress receipt metadata for a provider-to-provider transfer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderProgress { + /// Runtime- or provider-owned request id used to correlate progress receipts. + pub request_id: String, + /// Expected byte count when known. + pub expected_bytes: Option, +} + +/// Runtime stream-session flow-control options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderStreamOptions { + /// Maximum chunk size returned by each `read_next` call. + pub chunk_size: usize, + /// Maximum chunks the consumer may request ahead of processing. Runtime + /// sessions currently enforce one explicit read per chunk. + pub max_in_flight_chunks: usize, +} + +impl Default for ProviderStreamOptions { + fn default() -> Self { + Self { + chunk_size: PROVIDER_STREAM_CHUNK_BYTES, + max_in_flight_chunks: 1, + } + } +} + +/// One Runtime-native stream read. +#[derive(Debug, Clone)] +pub struct ProviderStreamRead { + /// Runtime-owned stream session id. + pub session_id: String, + /// Zero-based chunk index. + pub index: usize, + /// Byte offset of this chunk in the stream. + pub offset: usize, + /// Chunk bytes. + pub bytes: Vec, + /// Whether this read completed the stream. + pub completed: bool, + /// Runtime progress event for this read. + pub progress: serde_json::Value, +} + +/// Runtime-owned provider stream session. +#[derive(Debug, Clone)] +pub struct ProviderStreamSession { + id: String, + source: String, + target: String, + op: String, + request_id: String, + bytes: Vec, + cursor: usize, + read_index: usize, + chunk_size: usize, + max_in_flight_chunks: usize, + cancelled: bool, + transfer_receipt: Option, +} + +impl ProviderStreamSession { + pub fn id(&self) -> &str { + &self.id + } + + pub fn total_bytes(&self) -> usize { + self.bytes.len() + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled + } + + pub fn receipt(&self) -> serde_json::Value { + serde_json::json!({ + "schema": PROVIDER_STREAM_SESSION_SCHEMA, + "session_id": self.id, + "source": self.source, + "target": self.target, + "op": self.op, + "request_id": self.request_id, + "total_bytes": self.bytes.len(), + "chunk_size": self.chunk_size, + "max_in_flight_chunks": self.max_in_flight_chunks, + "transport_native_stream": true, + "backpressure": "read_next", + "cancel_supported": true, + "progress_mode": "stream_events", + "status": if self.cancelled { + "cancelled" + } else if self.cursor >= self.bytes.len() { + "completed" + } else { + "open" + }, + "transfer": self.transfer_receipt, + }) + } + + pub fn read_next(&mut self) -> Result, ProviderError> { + if self.cancelled { + return Err(ProviderError::Provider(format!( + "provider stream session {} is cancelled", + self.id + ))); + } + if self.cursor >= self.bytes.len() { + return Ok(None); + } + let start = self.cursor; + let end = (start + self.chunk_size).min(self.bytes.len()); + self.cursor = end; + let index = self.read_index; + self.read_index += 1; + let completed = self.cursor >= self.bytes.len(); + let chunk = self.bytes[start..end].to_vec(); + let progress = self.progress_event( + index, + start, + chunk.len(), + if completed { "completed" } else { "progress" }, + ); + Ok(Some(ProviderStreamRead { + session_id: self.id.clone(), + index, + offset: start, + bytes: chunk, + completed, + progress, + })) + } + + pub fn cancel(&mut self) -> serde_json::Value { + self.cancelled = true; + self.progress_event(self.read_index, self.cursor, 0, "cancelled") + } + + pub fn drain_to_vec(&mut self) -> Result, ProviderError> { + let mut out = Vec::with_capacity(self.bytes.len().saturating_sub(self.cursor)); + while let Some(read) = self.read_next()? { + out.extend_from_slice(&read.bytes); + } + Ok(out) + } + + fn progress_event( + &self, + index: usize, + offset: usize, + bytes: usize, + status: &str, + ) -> serde_json::Value { + serde_json::json!({ + "schema": PROVIDER_STREAM_EVENT_SCHEMA, + "session_id": self.id, + "request_id": self.request_id, + "source": self.source, + "target": self.target, + "op": self.op, + "index": index, + "offset": offset, + "bytes": bytes, + "transferred_bytes": self.cursor, + "total_bytes": self.bytes.len(), + "status": status, + }) + } +} + +/// Typed provider-to-provider invocation routed by the Runtime registry. +#[derive(Debug, Clone)] +pub struct ProviderInvocation { + /// Provider initiating the call. + pub source: String, + /// Target provider scheme or sub-provider name. + pub target: String, + /// Target operation name for audit/debug validation. + pub op: String, + /// Raw request understood by the target provider. + pub request: serde_json::Value, + /// Expected transfer shape. + pub transfer: ProviderTransfer, + /// Optional byte range contract for bytes/stream transfers. + pub range: Option, + /// Optional progress receipt contract for large transfers. + pub progress: Option, + /// Runtime-owned provider transport. + pub transport: ProviderInvocationTransport, +} + +/// Runtime plug-in point for Carrier provider-plane transport. +#[async_trait::async_trait] +pub trait ProviderCarrierInvoker: Send + Sync { + /// Send an already Runtime-enveloped provider request to a remote Carrier peer. + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> Result; +} + impl std::fmt::Display for ProviderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -176,11 +461,13 @@ const RESERVED_SUB_NAMES: &[&str] = &[ "exit", "browser-engine", "wallet", + "library", "drm", "rights", "key", "decrypt", "availability", + "block-graph", ]; /// Registry of providers @@ -189,6 +476,8 @@ pub struct ProviderRegistry { providers: RwLock>>, /// Sub-providers for elastos:// hierarchical dispatch (e.g., elastos://peer/...) sub_providers: RwLock>>, + /// Optional Carrier transport for Runtime-mediated provider invocation. + carrier_invoker: RwLock>>, } impl ProviderRegistry { @@ -197,9 +486,15 @@ impl ProviderRegistry { Self { providers: RwLock::new(HashMap::new()), sub_providers: RwLock::new(HashMap::new()), + carrier_invoker: RwLock::new(None), } } + /// Register the Runtime-owned Carrier provider-plane invoker. + pub async fn set_carrier_invoker(&self, invoker: Arc) { + *self.carrier_invoker.write().await = Some(invoker); + } + /// Register a provider for its schemes pub async fn register(&self, provider: Arc) { let mut providers = self.providers.write().await; @@ -458,6 +753,122 @@ impl ProviderRegistry { Err(ProviderError::NoProvider(scheme.to_string())) } + /// Invoke one provider from another through an explicit Runtime contract. + /// + /// This is the non-app-visible provider plane. It keeps provider-to-provider + /// effects out of capsule UI code while giving future Carrier/streaming + /// transports one contract to replace instead of many ad hoc `send_raw` + /// call sites. + pub async fn invoke_provider( + &self, + invocation: ProviderInvocation, + ) -> Result { + if invocation.source.trim().is_empty() { + return Err(ProviderError::Provider( + "provider invocation requires source".to_string(), + )); + } + if invocation.target.trim().is_empty() { + return Err(ProviderError::Provider( + "provider invocation requires target".to_string(), + )); + } + if invocation.op.trim().is_empty() { + return Err(ProviderError::Provider( + "provider invocation requires op".to_string(), + )); + } + validate_provider_transfer_contract(&invocation)?; + let target_op = invocation + .request + .get("op") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if target_op != invocation.op { + return Err(ProviderError::Provider(format!( + "provider invocation op mismatch: envelope={}, request={}", + invocation.op, target_op + ))); + } + let mut request = invocation.request.clone(); + attach_provider_invocation_envelope(&mut request, &invocation)?; + let mut response = match invocation.transport.carrier_route() { + Some(route) => { + let invoker = self.carrier_invoker.read().await.clone().ok_or_else(|| { + ProviderError::Provider(format!( + "Carrier provider invocation requires registered Carrier invoker: {}", + provider_transfer_receipt(&invocation, "failed_closed") + )) + })?; + invoker + .invoke_carrier_provider(route, &invocation, request) + .await? + } + None => self.send_raw(&invocation.target, &request).await?, + }; + apply_provider_transfer_response(&mut response, &invocation)?; + attach_provider_transfer_receipt(&mut response, &invocation, "completed"); + Ok(response) + } + + /// Open a Runtime-native stream session for a provider `Stream` transfer. + /// + /// The target provider still speaks the normal provider envelope; Runtime + /// turns the validated stream payload into a typed read/cancel channel so + /// consumers can apply backpressure and observe progress without app-visible + /// provider authority. + pub async fn open_provider_stream( + &self, + mut invocation: ProviderInvocation, + options: ProviderStreamOptions, + ) -> Result { + invocation.transfer = ProviderTransfer::Stream; + let progress = invocation.progress.clone(); + let response = self.invoke_provider(invocation.clone()).await?; + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown provider stream error"); + return Err(ProviderError::Provider(format!( + "provider stream open failed: {message}" + ))); + } + let data = response + .get("data") + .and_then(|data| data.as_object()) + .ok_or_else(|| { + ProviderError::Provider( + "provider stream open requires response data object".to_string(), + ) + })?; + let bytes = provider_stream_response_bytes(data)?; + let chunk_size = options.chunk_size.clamp(1, PROVIDER_STREAM_CHUNK_BYTES); + let max_in_flight_chunks = options.max_in_flight_chunks.max(1); + let id = format!( + "provider-stream:{}", + NEXT_PROVIDER_STREAM_ID.fetch_add(1, Ordering::Relaxed) + ); + let request_id = progress + .as_ref() + .map(|progress| progress.request_id.clone()) + .unwrap_or_else(|| id.clone()); + Ok(ProviderStreamSession { + id, + source: invocation.source, + target: invocation.target, + op: invocation.op, + request_id, + bytes, + cursor: 0, + read_index: 0, + chunk_size, + max_in_flight_chunks, + cancelled: false, + transfer_receipt: response.get("_runtime_transfer").cloned(), + }) + } + fn is_webspaces_localhost_path(path: &str) -> bool { parse_localhost_uri(path) .or_else(|| parse_localhost_path(path)) @@ -466,6 +877,494 @@ impl ProviderRegistry { } } +fn validate_provider_transfer_contract( + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + if let Some(range) = invocation.range { + if matches!(invocation.transfer, ProviderTransfer::Json) { + return Err(ProviderError::Provider( + "provider byte range requires bytes or stream transfer".to_string(), + )); + } + if let Some(end) = range.end { + if end < range.start { + return Err(ProviderError::Provider(format!( + "provider byte range end {end} is before start {}", + range.start + ))); + } + } + } + if let Some(progress) = invocation.progress.as_ref() { + if progress.request_id.trim().is_empty() { + return Err(ProviderError::Provider( + "provider progress receipt requires request_id".to_string(), + )); + } + } + if let Some(route) = invocation.transport.carrier_route() { + if route.connect_ticket.trim().is_empty() { + return Err(ProviderError::Provider( + "Carrier provider invocation requires connect_ticket".to_string(), + )); + } + if route.timeout_ms == Some(0) { + return Err(ProviderError::Provider( + "Carrier provider invocation timeout_ms must be greater than zero".to_string(), + )); + } + } + Ok(()) +} + +fn apply_provider_transfer_response( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + match invocation.transfer { + ProviderTransfer::Json => Ok(()), + ProviderTransfer::Bytes => apply_provider_byte_range(response, invocation), + ProviderTransfer::Stream => apply_provider_stream_response(response, invocation), + } +} + +fn attach_provider_transfer_receipt( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, + status: &str, +) { + let Some(object) = response.as_object_mut() else { + return; + }; + object.insert( + "_runtime_transfer".to_string(), + provider_transfer_receipt(invocation, status), + ); +} + +fn attach_provider_invocation_envelope( + request: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + let Some(object) = request.as_object_mut() else { + return Err(ProviderError::Provider( + "provider invocation request must be a JSON object".to_string(), + )); + }; + for reserved in ["_runtime_invocation", "_runtime_transfer"] { + if object.contains_key(reserved) { + return Err(ProviderError::Provider(format!( + "provider invocation request must not predeclare runtime field {reserved}" + ))); + } + } + object.insert( + "_runtime_invocation".to_string(), + provider_invocation_envelope(invocation), + ); + Ok(()) +} + +fn provider_byte_range_bounds( + bytes_len: usize, + range: ProviderByteRange, +) -> Result<(usize, usize), ProviderError> { + let start = usize::try_from(range.start).map_err(|_| { + ProviderError::Provider("provider byte range start is too large".to_string()) + })?; + if start >= bytes_len { + return Err(ProviderError::Provider(format!( + "provider byte range start {} exceeds payload length {}", + range.start, bytes_len + ))); + } + let end = range + .end + .map(|end| { + usize::try_from(end).map_err(|_| { + ProviderError::Provider("provider byte range end is too large".to_string()) + }) + }) + .transpose()? + .map(|end| end.min(bytes_len.saturating_sub(1))) + .unwrap_or_else(|| bytes_len.saturating_sub(1)); + Ok((start, end)) +} + +fn apply_provider_byte_range( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + let Some(range) = invocation.range else { + return Ok(()); + }; + if invocation.transfer != ProviderTransfer::Bytes { + return Ok(()); + } + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + return Ok(()); + } + let data_value = response + .get_mut("data") + .and_then(|data| data.as_object_mut()) + .and_then(|data| data.get_mut("data")) + .ok_or_else(|| { + ProviderError::Provider( + "provider byte range requires response data.data base64 payload".to_string(), + ) + })?; + let encoded = data_value.as_str().ok_or_else(|| { + ProviderError::Provider( + "provider byte range requires response data.data base64 string".to_string(), + ) + })?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "provider byte range response has invalid base64 payload: {err}" + )) + })?; + let (start, end) = provider_byte_range_bounds(bytes.len(), range)?; + let sliced = &bytes[start..=end]; + if let Some(expected) = invocation + .progress + .as_ref() + .and_then(|progress| progress.expected_bytes) + { + if expected != sliced.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider byte range expected {expected} bytes but produced {}", + sliced.len() + ))); + } + } + *data_value = + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(sliced)); + Ok(()) +} + +fn apply_provider_stream_response( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + return Ok(()); + } + let mut bytes = { + let data = response + .get("data") + .and_then(|data| data.as_object()) + .ok_or_else(|| { + ProviderError::Provider("provider stream requires response data object".to_string()) + })?; + provider_stream_response_bytes(data)? + }; + if let Some(range) = invocation.range { + let (start, end) = provider_byte_range_bounds(bytes.len(), range)?; + bytes = bytes[start..=end].to_vec(); + } + if let Some(expected) = invocation + .progress + .as_ref() + .and_then(|progress| progress.expected_bytes) + { + if expected != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream expected {expected} bytes but produced {}", + bytes.len() + ))); + } + } + let data = response + .get_mut("data") + .and_then(|data| data.as_object_mut()) + .ok_or_else(|| { + ProviderError::Provider("provider stream requires response data object".to_string()) + })?; + if data.get("data").and_then(|value| value.as_str()).is_some() { + data.remove("data"); + } + data.insert("stream".to_string(), provider_stream_payload(&bytes)); + Ok(()) +} + +fn provider_stream_response_bytes( + data: &serde_json::Map, +) -> Result, ProviderError> { + if let Some(stream) = data.get("stream") { + return decode_provider_stream_payload(stream); + } + let encoded = data + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider( + "provider stream requires data.stream chunks or data.data base64 payload" + .to_string(), + ) + })?; + base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "provider stream response has invalid base64 payload: {err}" + )) + }) +} + +fn decode_provider_stream_payload(stream: &serde_json::Value) -> Result, ProviderError> { + let object = stream.as_object().ok_or_else(|| { + ProviderError::Provider("provider stream payload must be an object".to_string()) + })?; + let schema = object + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != PROVIDER_STREAM_SCHEMA { + return Err(ProviderError::Provider(format!( + "provider stream schema mismatch: expected {PROVIDER_STREAM_SCHEMA}, got {schema}" + ))); + } + let encoding = object + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != PROVIDER_STREAM_ENCODING { + return Err(ProviderError::Provider(format!( + "provider stream encoding mismatch: expected {PROVIDER_STREAM_ENCODING}, got {encoding}" + ))); + } + if !object + .get("completed") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Err(ProviderError::Provider( + "provider stream payload must be completed before response finalization".to_string(), + )); + } + let chunks = object + .get("chunks") + .and_then(|value| value.as_array()) + .ok_or_else(|| { + ProviderError::Provider("provider stream payload requires chunks array".to_string()) + })?; + let mut bytes = Vec::new(); + for (expected_index, chunk) in chunks.iter().enumerate() { + let chunk = chunk.as_object().ok_or_else(|| { + ProviderError::Provider(format!( + "provider stream chunk {expected_index} must be an object" + )) + })?; + let index = chunk + .get("index") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "provider stream chunk {expected_index} requires index" + )) + })?; + if index != expected_index as u64 { + return Err(ProviderError::Provider(format!( + "provider stream chunk index mismatch: expected {expected_index}, got {index}" + ))); + } + let offset = chunk + .get("offset") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider(format!("provider stream chunk {index} requires offset")) + })?; + if offset != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream chunk {index} offset mismatch: expected {}, got {offset}", + bytes.len() + ))); + } + let encoded = chunk + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "provider stream chunk {index} requires base64 data" + )) + })?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "provider stream chunk {index} has invalid base64 data: {err}" + )) + })?; + if let Some(length) = chunk.get("length").and_then(|value| value.as_u64()) { + if length != decoded.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream chunk {index} length {length} does not match decoded length {}", + decoded.len() + ))); + } + } + bytes.extend_from_slice(&decoded); + } + if let Some(total_bytes) = object.get("total_bytes").and_then(|value| value.as_u64()) { + if total_bytes != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream total_bytes {total_bytes} does not match decoded length {}", + bytes.len() + ))); + } + } + Ok(bytes) +} + +fn provider_stream_payload(bytes: &[u8]) -> serde_json::Value { + let mut offset = 0usize; + let chunks: Vec = bytes + .chunks(PROVIDER_STREAM_CHUNK_BYTES) + .enumerate() + .map(|(index, chunk)| { + let chunk_offset = offset; + offset += chunk.len(); + serde_json::json!({ + "index": index, + "offset": chunk_offset, + "length": chunk.len(), + "data": base64::engine::general_purpose::STANDARD.encode(chunk), + }) + }) + .collect(); + serde_json::json!({ + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "chunk_size": PROVIDER_STREAM_CHUNK_BYTES, + "total_bytes": bytes.len(), + "completed": true, + "chunks": chunks, + }) +} + +fn provider_stream_contract(invocation: &ProviderInvocation) -> Option { + (invocation.transfer == ProviderTransfer::Stream).then(|| { + serde_json::json!({ + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "chunk_size": PROVIDER_STREAM_CHUNK_BYTES, + "mode": "runtime_stream_session", + "transport_native": true, + "progress_mode": "stream_events", + "flow_control": { + "backpressure": "read_next", + "cancel": "supported", + "max_in_flight_chunks": 1 + }, + }) + }) +} + +fn provider_transfer_abi(invocation: &ProviderInvocation) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.provider.transfer-abi/v1", + "transfer": invocation.transfer.as_str(), + "transport": invocation.transport.as_str(), + "range_supported": !matches!(invocation.transfer, ProviderTransfer::Json), + "progress_supported": invocation.progress.is_some(), + "progress_mode": if matches!(invocation.transfer, ProviderTransfer::Stream) { + "stream_events" + } else if invocation.progress.is_some() { + "receipt_metadata" + } else { + "none" + }, + "transport_native_stream": matches!(invocation.transfer, ProviderTransfer::Stream), + "backpressure": match invocation.transfer { + ProviderTransfer::Stream => "read_next", + _ => "not_applicable", + }, + "cancel_supported": matches!(invocation.transfer, ProviderTransfer::Stream), + }) +} + +fn provider_transfer_receipt(invocation: &ProviderInvocation, status: &str) -> serde_json::Value { + let range = invocation.range.map(|range| { + serde_json::json!({ + "start": range.start, + "end": range.end, + }) + }); + let progress = invocation.progress.as_ref().map(|progress| { + serde_json::json!({ + "request_id": progress.request_id, + "expected_bytes": progress.expected_bytes, + }) + }); + let mut receipt = serde_json::json!({ + "schema": "elastos.provider.transfer/v1", + "source": invocation.source, + "target": invocation.target, + "op": invocation.op, + "capability": provider_invocation_capability(invocation), + "transport": invocation.transport.as_str(), + "carrier": provider_carrier_route_receipt(invocation), + "transfer": invocation.transfer.as_str(), + "range": range, + "progress": progress, + "abi": provider_transfer_abi(invocation), + "status": status, + }); + if let Some(stream) = provider_stream_contract(invocation) { + if let Some(object) = receipt.as_object_mut() { + object.insert("stream".to_string(), stream); + } + } + receipt +} + +fn provider_invocation_envelope(invocation: &ProviderInvocation) -> serde_json::Value { + let mut envelope = serde_json::json!({ + "schema": "elastos.provider.invocation/v1", + "source": invocation.source, + "target": invocation.target, + "op": invocation.op, + "capability": provider_invocation_capability(invocation), + "transport": invocation.transport.as_str(), + "carrier": provider_carrier_route_receipt(invocation), + "transfer": invocation.transfer.as_str(), + "range": invocation.range.map(|range| serde_json::json!({ + "start": range.start, + "end": range.end, + })), + "progress": invocation.progress.as_ref().map(|progress| serde_json::json!({ + "request_id": progress.request_id, + "expected_bytes": progress.expected_bytes, + })), + "abi": provider_transfer_abi(invocation), + }); + if let Some(stream) = provider_stream_contract(invocation) { + if let Some(object) = envelope.as_object_mut() { + object.insert("stream".to_string(), stream); + } + } + envelope +} + +fn provider_carrier_route_receipt(invocation: &ProviderInvocation) -> Option { + invocation.transport.carrier_route().map(|route| { + serde_json::json!({ + "route": "connect_ticket", + "peer_did": route.peer_did.as_deref(), + "timeout_ms": route.timeout_ms, + }) + }) +} + +fn provider_invocation_capability(invocation: &ProviderInvocation) -> String { + format!( + "provider:{}->{}:{}", + invocation.source, invocation.target, invocation.op + ) +} + impl Default for ProviderRegistry { fn default() -> Self { Self::new() @@ -539,6 +1438,150 @@ mod tests { } } + struct RawMockProvider; + + #[async_trait::async_trait] + impl Provider for RawMockProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider("raw mock only supports raw".into())) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["raw"] + } + + fn name(&self) -> &'static str { + "mock-raw" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + let op = request.get("op").and_then(|value| value.as_str()); + if op == Some("cat") { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode( + b"0123456789abcdefghijklmnopqrstuvwxyz", + ) + } + })); + } + if op == Some("stream_cat") { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + "stream": { + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "total_bytes": 36, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 10, + "data": base64::engine::general_purpose::STANDARD.encode( + b"0123456789", + ), + }, + { + "index": 1, + "offset": 10, + "length": 26, + "data": base64::engine::general_purpose::STANDARD.encode( + b"abcdefghijklmnopqrstuvwxyz", + ), + }, + ], + }, + } + })); + } + if op == Some("bad_stream") { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "stream": { + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "total_bytes": 4, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 99, + "data": base64::engine::general_purpose::STANDARD.encode( + b"oops", + ), + }, + ], + }, + } + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "op": request.get("op").cloned().unwrap_or(serde_json::Value::Null), + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + } + })) + } + } + + fn decode_test_stream_response(response: &serde_json::Value) -> Vec { + decode_provider_stream_payload(&response["data"]["stream"]).unwrap() + } + + #[derive(Default)] + struct MockCarrierInvoker { + requests: Mutex>, + } + + #[async_trait::async_trait] + impl ProviderCarrierInvoker for MockCarrierInvoker { + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> Result { + self.requests.lock().await.push(serde_json::json!({ + "connect_ticket": route.connect_ticket.as_str(), + "peer_did": route.peer_did.as_deref(), + "timeout_ms": route.timeout_ms, + "source": invocation.source.as_str(), + "target": invocation.target.as_str(), + "op": invocation.op.as_str(), + "request": request.clone(), + })); + Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode(b"0123456789"), + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + } + })) + } + } + #[test] fn test_parse_uri() { let (scheme, path) = @@ -622,6 +1665,435 @@ mod tests { assert!(matches!(result, Err(ProviderError::NoProvider(_)))); } + #[tokio::test] + async fn test_provider_invocation_routes_raw_request() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "ping".to_string(), + request: serde_json::json!({ "op": "ping" }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["op"], "ping"); + assert_eq!( + response["data"]["runtime_invocation"]["schema"], + "elastos.provider.invocation/v1" + ); + assert_eq!( + response["data"]["runtime_invocation"]["source"], + "content-provider" + ); + assert_eq!(response["data"]["runtime_invocation"]["target"], "raw"); + assert_eq!( + response["data"]["runtime_invocation"]["capability"], + "provider:content-provider->raw:ping" + ); + assert_eq!( + response["data"]["runtime_invocation"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!( + response["_runtime_transfer"]["schema"], + "elastos.provider.transfer/v1" + ); + assert_eq!( + response["_runtime_transfer"]["capability"], + "provider:content-provider->raw:ping" + ); + assert_eq!( + response["_runtime_transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["_runtime_transfer"]["transfer"], "json"); + assert_eq!(response["_runtime_transfer"]["status"], "completed"); + } + + #[tokio::test] + async fn test_provider_invocation_attaches_range_progress_transfer_receipt() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "cat".to_string(), + request: serde_json::json!({ "op": "cat" }), + transfer: ProviderTransfer::Bytes, + range: Some(ProviderByteRange { + start: 10, + end: Some(19), + }), + progress: Some(ProviderProgress { + request_id: "transfer:test".to_string(), + expected_bytes: Some(10), + }), + transport: ProviderInvocationTransport::Local, + }) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let sliced = base64::engine::general_purpose::STANDARD + .decode(response["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(sliced, b"abcdefghij"); + assert_eq!(response["_runtime_transfer"]["transfer"], "bytes"); + assert_eq!( + response["_runtime_transfer"]["capability"], + "provider:content-provider->raw:cat" + ); + assert_eq!( + response["_runtime_transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["_runtime_transfer"]["range"]["start"], 10); + assert_eq!(response["_runtime_transfer"]["range"]["end"], 19); + assert_eq!( + response["_runtime_transfer"]["progress"]["request_id"], + "transfer:test" + ); + assert_eq!( + response["_runtime_transfer"]["progress"]["expected_bytes"], + 10 + ); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_invalid_range_contract() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "cat".to_string(), + request: serde_json::json!({ "op": "cat" }), + transfer: ProviderTransfer::Bytes, + range: Some(ProviderByteRange { + start: 20, + end: Some(10), + }), + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("invalid range should fail closed"); + + assert!(err.to_string().contains("range end")); + } + + #[tokio::test] + async fn test_provider_invocation_stream_normalizes_range_progress_transfer_receipt() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "stream_cat".to_string(), + request: serde_json::json!({ "op": "stream_cat" }), + transfer: ProviderTransfer::Stream, + range: Some(ProviderByteRange { + start: 10, + end: Some(19), + }), + progress: Some(ProviderProgress { + request_id: "transfer:stream".to_string(), + expected_bytes: Some(10), + }), + transport: ProviderInvocationTransport::Local, + }) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert!(response["data"].get("data").is_none()); + assert_eq!(decode_test_stream_response(&response), b"abcdefghij"); + assert_eq!(response["data"]["stream"]["schema"], PROVIDER_STREAM_SCHEMA); + assert_eq!( + response["data"]["stream"]["encoding"], + PROVIDER_STREAM_ENCODING + ); + assert_eq!(response["data"]["stream"]["total_bytes"], 10); + assert_eq!( + response["data"]["runtime_invocation"]["stream"]["schema"], + PROVIDER_STREAM_SCHEMA + ); + assert_eq!( + response["data"]["runtime_invocation"]["stream"]["mode"], + "runtime_stream_session" + ); + assert_eq!( + response["data"]["runtime_invocation"]["abi"]["backpressure"], + "read_next" + ); + assert_eq!(response["_runtime_transfer"]["transfer"], "stream"); + assert_eq!( + response["_runtime_transfer"]["stream"]["schema"], + PROVIDER_STREAM_SCHEMA + ); + assert_eq!( + response["_runtime_transfer"]["abi"]["transport_native_stream"], + true + ); + assert_eq!( + response["_runtime_transfer"]["abi"]["progress_mode"], + "stream_events" + ); + assert_eq!( + response["_runtime_transfer"]["abi"]["cancel_supported"], + true + ); + assert_eq!(response["_runtime_transfer"]["range"]["start"], 10); + assert_eq!(response["_runtime_transfer"]["range"]["end"], 19); + assert_eq!( + response["_runtime_transfer"]["progress"]["request_id"], + "transfer:stream" + ); + assert_eq!( + response["_runtime_transfer"]["progress"]["expected_bytes"], + 10 + ); + } + + #[tokio::test] + async fn test_provider_stream_session_reads_partially_and_cancels() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let mut session = registry + .open_provider_stream( + ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "stream_cat".to_string(), + request: serde_json::json!({ "op": "stream_cat" }), + transfer: ProviderTransfer::Stream, + range: None, + progress: Some(ProviderProgress { + request_id: "transfer:session".to_string(), + expected_bytes: Some(36), + }), + transport: ProviderInvocationTransport::Local, + }, + ProviderStreamOptions { + chunk_size: 8, + max_in_flight_chunks: 1, + }, + ) + .await + .unwrap(); + + assert_eq!(session.total_bytes(), 36); + assert_eq!(session.receipt()["schema"], PROVIDER_STREAM_SESSION_SCHEMA); + assert_eq!(session.receipt()["backpressure"], "read_next"); + assert_eq!(session.receipt()["cancel_supported"], true); + assert_eq!(session.receipt()["progress_mode"], "stream_events"); + + let first = session.read_next().unwrap().unwrap(); + assert_eq!(first.session_id, session.id()); + assert_eq!(first.index, 0); + assert_eq!(first.offset, 0); + assert_eq!(first.bytes, b"01234567"); + assert!(!first.completed); + assert_eq!(first.progress["schema"], PROVIDER_STREAM_EVENT_SCHEMA); + assert_eq!(first.progress["status"], "progress"); + assert_eq!(first.progress["transferred_bytes"], 8); + + let cancel = session.cancel(); + assert_eq!(cancel["status"], "cancelled"); + assert!(session.is_cancelled()); + let err = session + .read_next() + .expect_err("cancelled session must not continue reading"); + assert!(err.to_string().contains("cancelled")); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_malformed_stream_payload() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "bad_stream".to_string(), + request: serde_json::json!({ "op": "bad_stream" }), + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("malformed stream should fail closed"); + + assert!(err.to_string().contains("provider stream chunk 0 length")); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_op_mismatch() { + let registry = ProviderRegistry::new(); + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "localhost".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ "op": "publish" }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("op mismatch should fail closed"); + + assert!(err.to_string().contains("op mismatch")); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_predeclared_runtime_metadata() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + for reserved in ["_runtime_invocation", "_runtime_transfer"] { + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "ping".to_string(), + request: serde_json::json!({ + "op": "ping", + reserved: { + "schema": "spoofed" + } + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("runtime metadata should be reserved"); + + assert!(err + .to_string() + .contains("provider invocation request must not predeclare runtime field")); + assert!(err.to_string().contains(reserved)); + } + } + + #[tokio::test] + async fn test_provider_invocation_carrier_requires_registered_invoker() { + let registry = ProviderRegistry::new(); + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ "op": "fetch" }), + transfer: ProviderTransfer::Bytes, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: "ticket-secret".to_string(), + peer_did: Some("did:key:zRemote".to_string()), + timeout_ms: Some(5_000), + }), + }) + .await + .expect_err("Carrier invocation without invoker should fail closed"); + + let error = err.to_string(); + assert!(error.contains("registered Carrier invoker")); + assert!(error.contains("carrier-provider-plane")); + assert!(error.contains("failed_closed")); + assert!(!error.contains("ticket-secret")); + } + + #[tokio::test] + async fn test_provider_invocation_carrier_routes_through_registered_invoker() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ "op": "fetch" }), + transfer: ProviderTransfer::Bytes, + range: Some(ProviderByteRange { + start: 2, + end: Some(5), + }), + progress: Some(ProviderProgress { + request_id: "carrier-transfer:test".to_string(), + expected_bytes: Some(4), + }), + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: "ticket-secret".to_string(), + peer_did: Some("did:key:zRemote".to_string()), + timeout_ms: Some(5_000), + }), + }) + .await + .unwrap(); + + let sliced = base64::engine::general_purpose::STANDARD + .decode(response["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(sliced, b"2345"); + assert_eq!( + response["data"]["runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + assert_eq!( + response["data"]["runtime_invocation"]["carrier"]["route"], + "connect_ticket" + ); + assert_eq!( + response["data"]["runtime_invocation"]["carrier"]["peer_did"], + "did:key:zRemote" + ); + assert_eq!( + response["_runtime_transfer"]["transport"], + "carrier-provider-plane" + ); + assert_eq!( + response["_runtime_transfer"]["carrier"]["route"], + "connect_ticket" + ); + assert_eq!( + response["_runtime_transfer"]["progress"]["request_id"], + "carrier-transfer:test" + ); + assert!(!response.to_string().contains("ticket-secret")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["connect_ticket"], "ticket-secret"); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["capability"], + "provider:content-provider->content:fetch" + ); + } + // --- elastos:// sub-dispatch tests --- #[test] @@ -674,6 +2146,7 @@ mod tests { "key", "decrypt", "availability", + "block-graph", ] { registry .register_sub_provider(name, Arc::new(MockProvider::new())) diff --git a/elastos/crates/elastos-server/Cargo.toml b/elastos/crates/elastos-server/Cargo.toml index 436be0be..d571679c 100644 --- a/elastos/crates/elastos-server/Cargo.toml +++ b/elastos/crates/elastos-server/Cargo.toml @@ -91,6 +91,7 @@ tempfile = "3.10" # Archive extraction (for capsule artifacts) flate2 = "1.0" tar = "0.4" +zip = { version = "2.4.2", default-features = false, features = ["deflate-flate2", "flate2"] } libc = "0.2" # Built-in Carrier transport (P2P for updates, gossip messaging, DHT discovery) diff --git a/elastos/crates/elastos-server/src/api/gateway.rs b/elastos/crates/elastos-server/src/api/gateway.rs index 606a038b..b6d77484 100644 --- a/elastos/crates/elastos-server/src/api/gateway.rs +++ b/elastos/crates/elastos-server/src/api/gateway.rs @@ -12,16 +12,19 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::documents::DocumentsClient; use axum::body::Bytes; -use axum::extract::{DefaultBodyLimit, Path, Query, State}; +use axum::extract::{DefaultBodyLimit, Path, Query, RawQuery, State}; use axum::http::{ - header::{AUTHORIZATION, CONTENT_TYPE, COOKIE, SET_COOKIE}, + header::{ + ACCEPT_RANGES, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, + CONTENT_TYPE, COOKIE, RANGE, SET_COOKIE, + }, HeaderMap, HeaderValue, StatusCode, }; use axum::response::{ sse::{Event as SseEvent, KeepAlive, Sse}, Html, IntoResponse, Redirect, Response, }; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, post, put}; use axum::Json; use axum::Router; use base64::Engine as _; @@ -92,6 +95,7 @@ use gateway_wallet::*; /// Maximum size for a single file fetched through the gateway (100 MB). const MAX_GATEWAY_FILE_SIZE: usize = 100 * 1024 * 1024; +const LIBRARY_UPLOAD_CHUNK_MAX_BYTES: usize = 768 * 1024; const GATEWAY_VERSION: &str = env!("ELASTOS_VERSION"); const MANAGED_WALLET_CHAIN_NAMESPACES: &[&str] = &[ "eip155:20", @@ -326,6 +330,35 @@ pub fn gateway_router(state: GatewayState) -> Router { "/api/browser/session/request/:request_id", get(super::browser_sessions::browser_session_request_status), ) + .route( + "/api/provider/object/events/stream", + get(gateway_library_events_stream), + ) + .route( + "/api/provider/object/download/raw", + get(gateway_library_download), + ) + .route( + "/api/provider/object/upload", + put(gateway_library_upload).layer(DefaultBodyLimit::max(MAX_GATEWAY_FILE_SIZE)), + ) + .route( + "/api/provider/object/upload/start", + post(gateway_library_upload_start), + ) + .route( + "/api/provider/object/upload/:upload_id/chunk", + put(gateway_library_upload_chunk) + .layer(DefaultBodyLimit::max(LIBRARY_UPLOAD_CHUNK_MAX_BYTES)), + ) + .route( + "/api/provider/object/upload/:upload_id/finish", + post(gateway_library_upload_finish), + ) + .route( + "/api/provider/object/upload/:upload_id", + delete(gateway_library_upload_cancel), + ) .route("/api/provider/:scheme/:op", post(gateway_provider_proxy)) .route("/release.json", get(serve_release_manifest)) .route("/release-head.json", get(serve_release_head)) @@ -586,6 +619,16 @@ pub fn gateway_router(state: GatewayState) -> Router { "/api/viewers/:viewer/content/:capsule", get(super::viewer_gateway::viewer_content), ) + .route( + "/api/viewers/:viewer/library-object", + get(super::viewer_gateway::viewer_library_object_get) + .post(super::viewer_gateway::viewer_library_object_post) + .put(super::viewer_gateway::viewer_library_object_put), + ) + .route( + "/api/viewers/:viewer/library-roots", + get(super::viewer_gateway::viewer_library_roots_get), + ) .route( "/api/viewers/:viewer/storage/:capsule/:scope/:name", get(super::viewer_gateway::viewer_storage_get) diff --git a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs index 9549a43b..26793199 100644 --- a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs +++ b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs @@ -1,5 +1,1277 @@ +use std::io::{Read, Seek, SeekFrom, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; + use super::*; +const LIBRARY_EVENTS_STREAM_KEEPALIVE_SECS: u64 = 15; +const LIBRARY_TRANSFER_RECEIPT_SCHEMA: &str = "elastos.object.transfer.receipt/v1"; +const LIBRARY_TRANSFER_REQUEST_ID_HEADER: &str = "x-elastos-request-id"; +const LIBRARY_TRANSFER_RECEIPT_HEADER: &str = "x-elastos-transfer-receipt"; +const LIBRARY_DOWNLOAD_STREAM_CHUNK_BYTES: usize = 64 * 1024; +const LIBRARY_UPLOAD_SESSION_SCHEMA: &str = "elastos.object.upload-session/v1"; +const LIBRARY_UPLOAD_SESSION_TTL_SECS: u64 = 24 * 60 * 60; +static LIBRARY_UPLOAD_SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Deserialize)] +pub(super) struct LibraryEventsStreamQuery { + #[serde(default)] + home_token: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct LibraryUploadQuery { + uri: String, + #[serde(default)] + if_revision: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct LibraryUploadStartBody { + uri: String, + #[serde(default)] + mime: Option, + #[serde(default)] + size_bytes: Option, + #[serde(default)] + if_revision: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LibraryUploadSession { + schema: String, + upload_id: String, + principal_id: String, + session_id: String, + uri: String, + #[serde(default)] + mime: Option, + #[serde(default)] + if_revision: Option, + #[serde(default)] + total_bytes: Option, + received_bytes: u64, + chunk_count: u64, + created_at: u64, + updated_at: u64, +} + +pub(super) async fn gateway_library_upload( + State(state): State, + headers: HeaderMap, + Query(query): Query, + body: Bytes, +) -> Response { + let uri = query.uri.trim(); + if uri.is_empty() { + return (StatusCode::BAD_REQUEST, "library upload uri is required").into_response(); + } + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + + let request_id = format!("object:upload:{}", now_ts()); + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "requested", + reason: "Library requested object provider operation upload", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mime = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let mut response = match crate::library::handle_library_upload_bytes_runtime( + &state.data_dir, + registry, + &context.principal_id, + uri, + mime, + query.if_revision.as_deref(), + &body, + ) + .await + { + Ok(value) => value, + Err(err) => serde_json::json!({ + "status": "error", + "code": "library_error", + "message": err.to_string(), + }), + }; + let completed = response.get("status").and_then(|value| value.as_str()) == Some("ok"); + let transfer_receipt = if completed { + let receipt = library_transfer_receipt( + "upload", + &request_id, + uri, + body.len(), + Some(body.len()), + None, + "completed", + ); + if let Some(data) = response + .get_mut("data") + .and_then(serde_json::Value::as_object_mut) + { + data.insert("request_id".to_string(), serde_json::json!(request_id)); + data.insert("receipt".to_string(), receipt.clone()); + } + Some(receipt) + } else { + None + }; + if completed { + crate::library::library_event_notifier().notify_waiters(); + } + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: if completed { + "object.provider.completed" + } else { + "object.provider.failed" + }, + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: if completed { "completed" } else { "failed" }, + reason: if completed { + "Library completed object provider operation upload" + } else { + "Library failed object provider operation upload" + }, + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mut response = Json(response).into_response(); + if let Some(receipt) = transfer_receipt.as_ref() { + insert_library_transfer_headers(response.headers_mut(), receipt); + } + response +} + +pub(super) async fn gateway_library_upload_start( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let uri = body.uri.trim(); + if uri.is_empty() { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "missing_uri", + "library upload uri is required", + ); + } + if let Some(size) = body.size_bytes { + if size as usize > MAX_GATEWAY_FILE_SIZE { + return library_upload_json_error( + StatusCode::PAYLOAD_TOO_LARGE, + "object_too_large", + format!( + "Library upload exceeds Runtime object upload limit of {} bytes", + MAX_GATEWAY_FILE_SIZE + ), + ); + } + } + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + + let upload_id = new_library_upload_session_id(); + let now = now_ts(); + let _ = cleanup_expired_library_upload_sessions(&state.data_dir, now); + let session = LibraryUploadSession { + schema: LIBRARY_UPLOAD_SESSION_SCHEMA.to_string(), + upload_id: upload_id.clone(), + principal_id: context.principal_id.clone(), + session_id: context.session_id.clone(), + uri: uri.to_string(), + mime: body + .mime + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + if_revision: body + .if_revision + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + total_bytes: body.size_bytes, + received_bytes: 0, + chunk_count: 0, + created_at: now, + updated_at: now, + }; + if let Err(err) = create_library_upload_session(&state.data_dir, &session) { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ); + } + let _ = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.upload_session_started", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &upload_id, + result: "started", + reason: "Library started chunked object provider upload session", + }, + ); + Json(serde_json::json!({ + "status": "ok", + "data": library_upload_session_status(&session), + })) + .into_response() +} + +pub(super) async fn gateway_library_upload_chunk( + State(state): State, + Path(upload_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if body.len() > LIBRARY_UPLOAD_CHUNK_MAX_BYTES { + return library_upload_json_error( + StatusCode::PAYLOAD_TOO_LARGE, + "chunk_too_large", + format!( + "Library upload chunk exceeds Runtime chunk limit of {} bytes", + LIBRARY_UPLOAD_CHUNK_MAX_BYTES + ), + ); + } + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let offset = match library_upload_offset_from_headers(&headers) { + Ok(offset) => offset, + Err(err) => { + return library_upload_json_error(StatusCode::BAD_REQUEST, "invalid_offset", err) + } + }; + let mut session = match read_library_upload_session(&state.data_dir, &upload_id) { + Ok(session) => session, + Err(err) => { + return library_upload_json_error( + StatusCode::NOT_FOUND, + "upload_session_not_found", + err.to_string(), + ) + } + }; + if let Err(err) = require_library_upload_session_context(&session, &context) { + return library_upload_json_error(StatusCode::FORBIDDEN, "upload_session_forbidden", err); + } + if offset != session.received_bytes { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_offset_mismatch", + format!( + "expected upload offset {}, got {}", + session.received_bytes, offset + ), + ); + } + let projected = session.received_bytes.saturating_add(body.len() as u64); + if projected as usize > MAX_GATEWAY_FILE_SIZE { + return library_upload_json_error( + StatusCode::PAYLOAD_TOO_LARGE, + "object_too_large", + format!( + "Library upload exceeds Runtime object upload limit of {} bytes", + MAX_GATEWAY_FILE_SIZE + ), + ); + } + if let Some(total) = session.total_bytes { + if projected > total { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_exceeds_declared_size", + "upload chunk exceeds declared total size", + ); + } + } + if let Err(err) = append_library_upload_chunk(&state.data_dir, &mut session, &body) { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ); + } + Json(serde_json::json!({ + "status": "ok", + "data": library_upload_session_status(&session), + })) + .into_response() +} + +pub(super) async fn gateway_library_upload_finish( + State(state): State, + Path(upload_id): Path, + headers: HeaderMap, +) -> Response { + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + let session = match read_library_upload_session(&state.data_dir, &upload_id) { + Ok(session) => session, + Err(err) => { + return library_upload_json_error( + StatusCode::NOT_FOUND, + "upload_session_not_found", + err.to_string(), + ) + } + }; + if let Err(err) = require_library_upload_session_context(&session, &context) { + return library_upload_json_error(StatusCode::FORBIDDEN, "upload_session_forbidden", err); + } + if let Some(total) = session.total_bytes { + if session.received_bytes != total { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_incomplete", + format!( + "upload received {} bytes but expected {}", + session.received_bytes, total + ), + ); + } + } + let bytes = match read_library_upload_session_bytes(&state.data_dir, &upload_id) { + Ok(bytes) => bytes, + Err(err) => { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ) + } + }; + if bytes.len() as u64 != session.received_bytes { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_incomplete", + "upload session byte count does not match received count", + ); + } + + let request_id = format!("object:upload:{}", now_ts()); + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "requested", + reason: "Library requested chunked object provider operation upload", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mut response = match crate::library::handle_library_upload_bytes_runtime( + &state.data_dir, + registry, + &context.principal_id, + &session.uri, + session.mime.as_deref(), + session.if_revision.as_deref(), + &bytes, + ) + .await + { + Ok(value) => value, + Err(err) => serde_json::json!({ + "status": "error", + "code": "library_error", + "message": err.to_string(), + }), + }; + let completed = response.get("status").and_then(|value| value.as_str()) == Some("ok"); + let transfer_receipt = if completed { + let receipt = library_chunked_upload_transfer_receipt(&request_id, &session); + if let Some(data) = response + .get_mut("data") + .and_then(serde_json::Value::as_object_mut) + { + data.insert("request_id".to_string(), serde_json::json!(request_id)); + data.insert("receipt".to_string(), receipt.clone()); + data.insert( + "upload_session".to_string(), + library_upload_session_status(&session), + ); + data.insert( + "browser_transport".to_string(), + serde_json::json!("http-chunk-session"), + ); + } + Some(receipt) + } else { + None + }; + if completed { + let _ = remove_library_upload_session(&state.data_dir, &upload_id); + crate::library::library_event_notifier().notify_waiters(); + } + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: if completed { + "object.provider.completed" + } else { + "object.provider.failed" + }, + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: if completed { "completed" } else { "failed" }, + reason: if completed { + "Library completed chunked object provider operation upload" + } else { + "Library failed chunked object provider operation upload" + }, + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mut response = Json(response).into_response(); + if let Some(receipt) = transfer_receipt.as_ref() { + insert_library_transfer_headers(response.headers_mut(), receipt); + } + response +} + +pub(super) async fn gateway_library_upload_cancel( + State(state): State, + Path(upload_id): Path, + headers: HeaderMap, +) -> Response { + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let session = match read_library_upload_session(&state.data_dir, &upload_id) { + Ok(session) => session, + Err(err) => { + return library_upload_json_error( + StatusCode::NOT_FOUND, + "upload_session_not_found", + err.to_string(), + ) + } + }; + if let Err(err) = require_library_upload_session_context(&session, &context) { + return library_upload_json_error(StatusCode::FORBIDDEN, "upload_session_forbidden", err); + } + if let Err(err) = remove_library_upload_session(&state.data_dir, &upload_id) { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ); + } + Json(serde_json::json!({ + "status": "ok", + "data": { + "schema": LIBRARY_UPLOAD_SESSION_SCHEMA, + "upload_id": upload_id, + "status": "cancelled", + }, + })) + .into_response() +} + +pub(super) async fn gateway_library_download( + State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, +) -> Response { + let mut uris = Vec::new(); + let mut archive_format_value = None; + for (key, value) in form_urlencoded::parse(raw_query.as_deref().unwrap_or_default().as_bytes()) + { + match key.as_ref() { + "uri" => { + let uri = value.trim().to_string(); + if !uri.is_empty() { + uris.push(uri); + } + } + "archive" => archive_format_value = Some(value.into_owned()), + _ => {} + } + } + if uris.is_empty() { + return (StatusCode::BAD_REQUEST, "library download uri is required").into_response(); + } + let archive_format = + match crate::library::LibraryArchiveFormat::parse(archive_format_value.as_deref()) { + Ok(format) => format, + Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(), + }; + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + + let request_id = format!("object:download:{}", now_ts()); + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "requested", + reason: "Library requested object provider operation download", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let receipt_uri = if uris.len() == 1 { + uris[0].clone() + } else { + format!("selection:{}", uris.len()) + }; + let download_result = if uris.len() == 1 { + crate::library::handle_library_download_bytes_runtime( + &state.data_dir, + registry, + &context.principal_id, + &uris[0], + archive_format, + ) + .await + } else { + crate::library::handle_library_download_selection_bytes_runtime( + &state.data_dir, + &context.principal_id, + &uris, + archive_format, + ) + .await + }; + let download = match download_result { + Ok(download) => download, + Err(err) => { + let _ = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.failed", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "failed", + reason: "Library failed object provider operation download", + }, + ); + return gateway_provider_error_response("object", err); + } + }; + + let (response, range_ok) = + library_download_response(download, headers.get(RANGE), &request_id, &receipt_uri); + if !range_ok { + let _ = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.failed", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "failed", + reason: "Library failed object provider operation download", + }, + ); + return response; + } + + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.completed", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "completed", + reason: "Library completed object provider operation download", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + response +} + +fn library_download_response( + download: crate::library::LibraryDownloadBytes, + range_header: Option<&HeaderValue>, + request_id: &str, + uri: &str, +) -> (Response, bool) { + let byte_range = match range_header { + Some(value) => match library_download_byte_range(value, download.bytes.len()) { + Ok(range) => Some(range), + Err(()) => { + return ( + library_download_range_not_satisfiable( + download.bytes.len(), + request_id, + uri, + value.to_str().unwrap_or_default(), + ), + false, + ) + } + }, + None => None, + }; + let total_bytes = download.bytes.len(); + let mut response = if let Some((start, end)) = byte_range { + let bytes = download.bytes[start..=end].to_vec(); + let mut response = Response::new(library_download_stream_body(bytes)); + *response.status_mut() = StatusCode::PARTIAL_CONTENT; + response.headers_mut().insert( + CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes {start}-{end}/{total_bytes}")) + .unwrap_or_else(|_| HeaderValue::from_static("bytes */0")), + ); + response.headers_mut().insert( + CONTENT_LENGTH, + HeaderValue::from_str(&(end - start + 1).to_string()) + .unwrap_or_else(|_| HeaderValue::from_static("0")), + ); + response + } else { + let length = total_bytes; + let mut response = Response::new(library_download_stream_body(download.bytes)); + response.headers_mut().insert( + CONTENT_LENGTH, + HeaderValue::from_str(&length.to_string()) + .unwrap_or_else(|_| HeaderValue::from_static("0")), + ); + response + }; + let headers = response.headers_mut(); + headers.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(&download.mime) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), + ); + headers.insert( + CONTENT_DISPOSITION, + HeaderValue::from_str(&library_download_content_disposition(&download.filename)) + .unwrap_or_else(|_| HeaderValue::from_static("attachment; filename=\"download\"")), + ); + let served_bytes = byte_range + .map(|(start, end)| end - start + 1) + .unwrap_or(total_bytes); + let receipt = library_transfer_receipt( + "download", + request_id, + uri, + served_bytes, + Some(total_bytes), + byte_range, + "completed", + ); + insert_library_transfer_headers(headers, &receipt); + (response, true) +} + +fn library_download_stream_body(bytes: Vec) -> axum::body::Body { + let bytes = Bytes::from(bytes); + let stream = futures_lite::stream::unfold((bytes, 0usize), |(bytes, offset)| async move { + if offset >= bytes.len() { + return None; + } + let end = (offset + LIBRARY_DOWNLOAD_STREAM_CHUNK_BYTES).min(bytes.len()); + let chunk = bytes.slice(offset..end); + Some((Ok::(chunk), (bytes, end))) + }); + axum::body::Body::from_stream(stream) +} + +fn library_download_range_not_satisfiable( + total_len: usize, + request_id: &str, + uri: &str, + requested_range: &str, +) -> Response { + let mut response = StatusCode::RANGE_NOT_SATISFIABLE.into_response(); + response + .headers_mut() + .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + response.headers_mut().insert( + CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes */{total_len}")) + .unwrap_or_else(|_| HeaderValue::from_static("bytes */0")), + ); + let receipt = serde_json::json!({ + "schema": LIBRARY_TRANSFER_RECEIPT_SCHEMA, + "op": "download", + "request_id": request_id, + "uri": uri, + "transport": "raw-body", + "status": "failed", + "bytes": 0, + "total_bytes": total_len, + "requested_range": requested_range, + "error": "range_not_satisfiable", + }); + insert_library_transfer_headers(response.headers_mut(), &receipt); + response +} + +fn library_transfer_receipt( + op: &str, + request_id: &str, + uri: &str, + bytes: usize, + total_bytes: Option, + range: Option<(usize, usize)>, + status: &str, +) -> serde_json::Value { + serde_json::json!({ + "schema": LIBRARY_TRANSFER_RECEIPT_SCHEMA, + "op": op, + "request_id": request_id, + "uri": uri, + "transport": if op == "download" { + "http-body-stream" + } else { + "raw-body" + }, + "stream": if op == "download" { + serde_json::json!({ + "schema": "elastos.object.download-stream/v1", + "mode": "response_body_chunks", + "chunk_size": LIBRARY_DOWNLOAD_STREAM_CHUNK_BYTES, + "backpressure": "http_body_poll", + "cancel": "drop_body", + "progress_mode": "transfer_receipt", + }) + } else { + serde_json::Value::Null + }, + "status": status, + "bytes": bytes, + "total_bytes": total_bytes, + "range": range.map(|(start, end)| serde_json::json!({ + "start": start, + "end": end, + })), + }) +} + +fn insert_library_transfer_headers(headers: &mut HeaderMap, receipt: &serde_json::Value) { + if let Some(request_id) = receipt + .get("request_id") + .and_then(serde_json::Value::as_str) + { + if let Ok(value) = HeaderValue::from_str(request_id) { + headers.insert(LIBRARY_TRANSFER_REQUEST_ID_HEADER, value); + } + } + if let Ok(value) = HeaderValue::from_str(&receipt.to_string()) { + headers.insert(LIBRARY_TRANSFER_RECEIPT_HEADER, value); + } +} + +fn new_library_upload_session_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = LIBRARY_UPLOAD_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("object-upload-{nanos}-{counter}") +} + +fn library_upload_sessions_dir(data_dir: &FsPath) -> PathBuf { + data_dir.join("Runtime").join("ObjectUploadSessions") +} + +fn library_upload_session_dir(data_dir: &FsPath, upload_id: &str) -> anyhow::Result { + if !library_upload_safe_id(upload_id) { + anyhow::bail!("invalid upload session id"); + } + Ok(library_upload_sessions_dir(data_dir).join(upload_id)) +} + +fn library_upload_safe_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 120 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn library_upload_session_metadata_path( + data_dir: &FsPath, + upload_id: &str, +) -> anyhow::Result { + Ok(library_upload_session_dir(data_dir, upload_id)?.join("session.json")) +} + +fn library_upload_session_data_path(data_dir: &FsPath, upload_id: &str) -> anyhow::Result { + Ok(library_upload_session_dir(data_dir, upload_id)?.join("payload.bin")) +} + +fn create_library_upload_session( + data_dir: &FsPath, + session: &LibraryUploadSession, +) -> anyhow::Result<()> { + let dir = library_upload_session_dir(data_dir, &session.upload_id)?; + std::fs::create_dir_all(&dir)?; + std::fs::File::create(dir.join("payload.bin"))?; + write_library_upload_session(data_dir, session) +} + +fn read_library_upload_session( + data_dir: &FsPath, + upload_id: &str, +) -> anyhow::Result { + let path = library_upload_session_metadata_path(data_dir, upload_id)?; + let bytes = std::fs::read(path)?; + serde_json::from_slice(&bytes).map_err(|err| anyhow::anyhow!("invalid upload session: {err}")) +} + +fn write_library_upload_session( + data_dir: &FsPath, + session: &LibraryUploadSession, +) -> anyhow::Result<()> { + let path = library_upload_session_metadata_path(data_dir, &session.upload_id)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(session)?)?; + std::fs::rename(tmp, path)?; + Ok(()) +} + +fn append_library_upload_chunk( + data_dir: &FsPath, + session: &mut LibraryUploadSession, + bytes: &[u8], +) -> anyhow::Result<()> { + let path = library_upload_session_data_path(data_dir, &session.upload_id)?; + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .read(true) + .open(&path)?; + file.seek(SeekFrom::Start(session.received_bytes))?; + file.write_all(bytes)?; + session.received_bytes = session.received_bytes.saturating_add(bytes.len() as u64); + session.chunk_count = session.chunk_count.saturating_add(1); + session.updated_at = now_ts(); + write_library_upload_session(data_dir, session) +} + +fn read_library_upload_session_bytes( + data_dir: &FsPath, + upload_id: &str, +) -> anyhow::Result> { + let path = library_upload_session_data_path(data_dir, upload_id)?; + let mut file = std::fs::File::open(path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) +} + +fn remove_library_upload_session(data_dir: &FsPath, upload_id: &str) -> anyhow::Result<()> { + let dir = library_upload_session_dir(data_dir, upload_id)?; + if dir.exists() { + std::fs::remove_dir_all(dir)?; + } + Ok(()) +} + +fn cleanup_expired_library_upload_sessions(data_dir: &FsPath, now: u64) -> anyhow::Result<()> { + let dir = library_upload_sessions_dir(data_dir); + let Ok(entries) = std::fs::read_dir(dir) else { + return Ok(()); + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(upload_id) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if !library_upload_safe_id(upload_id) { + continue; + } + let Ok(session) = read_library_upload_session(data_dir, upload_id) else { + continue; + }; + if now.saturating_sub(session.updated_at) > LIBRARY_UPLOAD_SESSION_TTL_SECS { + let _ = std::fs::remove_dir_all(path); + } + } + Ok(()) +} + +fn require_library_upload_session_context( + session: &LibraryUploadSession, + context: &HomeLaunchTokenContext, +) -> Result<(), String> { + if session.principal_id != context.principal_id || session.session_id != context.session_id { + return Err("upload session belongs to a different Runtime launch context".to_string()); + } + Ok(()) +} + +fn library_upload_offset_from_headers(headers: &HeaderMap) -> Result { + headers + .get("x-elastos-upload-offset") + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "missing x-elastos-upload-offset header".to_string())? + .parse::() + .map_err(|_| "invalid x-elastos-upload-offset header".to_string()) +} + +fn library_upload_session_status(session: &LibraryUploadSession) -> serde_json::Value { + serde_json::json!({ + "schema": LIBRARY_UPLOAD_SESSION_SCHEMA, + "upload_id": session.upload_id, + "uri": session.uri, + "received_bytes": session.received_bytes, + "total_bytes": session.total_bytes, + "chunk_count": session.chunk_count, + "chunk_size": LIBRARY_UPLOAD_CHUNK_MAX_BYTES, + "transport": "http-chunk-session", + "backpressure": "client_waits_for_chunk_ack", + "cancel": "DELETE /api/provider/object/upload/:upload_id", + }) +} + +fn library_chunked_upload_transfer_receipt( + request_id: &str, + session: &LibraryUploadSession, +) -> serde_json::Value { + let mut receipt = library_transfer_receipt( + "upload", + request_id, + &session.uri, + session.received_bytes as usize, + session.total_bytes.map(|value| value as usize), + None, + "completed", + ); + if let Some(receipt) = receipt.as_object_mut() { + receipt.insert( + "transport".to_string(), + serde_json::json!("http-chunk-session"), + ); + receipt.insert("stream".to_string(), library_upload_session_status(session)); + } + receipt +} + +fn library_upload_json_error( + status: StatusCode, + code: &str, + message: impl Into, +) -> Response { + let mut response = Json(serde_json::json!({ + "status": "error", + "code": code, + "message": message.into(), + })) + .into_response(); + *response.status_mut() = status; + response +} + +fn library_download_byte_range( + header: &HeaderValue, + total_len: usize, +) -> Result<(usize, usize), ()> { + let value = header.to_str().map_err(|_| ())?.trim(); + let Some(spec) = value.strip_prefix("bytes=") else { + return Err(()); + }; + if spec.contains(',') { + return Err(()); + } + let (start, end) = spec.split_once('-').ok_or(())?; + if total_len == 0 { + return Err(()); + } + if start.is_empty() { + let suffix_len = end.parse::().map_err(|_| ())?; + if suffix_len == 0 { + return Err(()); + } + let start = total_len.saturating_sub(suffix_len); + return Ok((start, total_len - 1)); + } + let start = start.parse::().map_err(|_| ())?; + if start >= total_len { + return Err(()); + } + let end = if end.is_empty() { + total_len - 1 + } else { + end.parse::().map_err(|_| ())?.min(total_len - 1) + }; + if start > end { + return Err(()); + } + Ok((start, end)) +} + +fn library_download_content_disposition(filename: &str) -> String { + let clean = filename + .chars() + .map(|ch| { + if ch.is_ascii() && !ch.is_ascii_control() && ch != '"' && ch != '\\' { + ch + } else { + '_' + } + }) + .collect::(); + let clean = clean.trim(); + if clean.is_empty() { + "attachment; filename=\"download\"".to_string() + } else { + format!("attachment; filename=\"{clean}\"") + } +} + +pub(super) async fn gateway_library_events_stream( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let headers = match library_events_stream_headers(headers, query.home_token.as_deref()) { + Ok(headers) => headers, + Err(err) => return gateway_provider_error_response("object", err), + }; + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + + let stream_state = LibraryEventsStreamState { + registry, + principal_id: context.principal_id, + cursor: String::new(), + initialized: false, + }; + let stream = futures_lite::stream::unfold(stream_state, |mut stream_state| async move { + loop { + match library_events_since_cursor( + &stream_state.registry, + &stream_state.principal_id, + &stream_state.cursor, + ) + .await + { + Ok((_events, cursor)) if !stream_state.initialized => { + stream_state.cursor = cursor; + stream_state.initialized = true; + } + Ok((events, cursor)) if !events.is_empty() => { + stream_state.cursor = cursor.clone(); + let event = library_events_sse_event(serde_json::json!({ + "schema": "elastos.library.events/v1", + "cursor": cursor, + "events": events, + })); + return Some((Ok::(event), stream_state)); + } + Ok(_) => {} + Err(err) => { + let event = library_events_sse_event(serde_json::json!({ + "schema": "elastos.library.events/v1", + "status": "error", + "message": err.to_string(), + "events": [], + })); + return Some((Ok::(event), stream_state)); + } + } + crate::library::library_event_notifier().notified().await; + } + }); + + let mut response = Sse::new(stream) + .keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(LIBRARY_EVENTS_STREAM_KEEPALIVE_SECS)) + .text("keepalive"), + ) + .into_response(); + let headers = response.headers_mut(); + headers.insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-transform"), + ); + headers.insert( + axum::http::HeaderName::from_static("x-accel-buffering"), + HeaderValue::from_static("no"), + ); + response +} + pub(super) async fn gateway_provider_proxy( State(state): State, Path((scheme, op)): Path<(String, String)>, @@ -11,6 +1283,41 @@ pub(super) async fn gateway_provider_proxy( "summary" | "get" => &[DOCUMENTS_CAPSULE_ID, LIBRARY_CAPSULE_ID], _ => &[DOCUMENTS_CAPSULE_ID], }, + "object" => match op.as_str() { + "roots" + | "list" + | "stat" + | "read" + | "download" + | "write" + | "mkdir" + | "rename" + | "move" + | "copy" + | "trash" + | "restore" + | "delete_permanently" + | "status" + | "sync" + | "extract_archive" + | "archive_entries" + | "archive_preview_entry" + | "archive_extract_entries" + | "compress_archive" + | "publish" + | "unpublish" + | "repair" + | "share" + | "shared_access" + | "events" => &[LIBRARY_CAPSULE_ID], + _ => { + return ( + StatusCode::NOT_FOUND, + "Gateway provider operation not found", + ) + .into_response() + } + }, "chain" => match op.as_str() { "networks" | "status" | "block_number" | "sync_health" | "node_lifecycle" => { &[SYSTEM_CAPSULE_ID] @@ -68,9 +1375,33 @@ pub(super) async fn gateway_provider_proxy( } }; request["op"] = serde_json::Value::String(op.clone()); - if scheme == "documents" || scheme == "net" { + if scheme == "documents" || scheme == "object" || scheme == "net" { request["principal_id"] = serde_json::Value::String(principal_id.clone()); } + if scheme == "object" && op == "shared_access" { + if let Some(object) = request.as_object_mut() { + object.remove("recipient_proof"); + if object + .get("recipient") + .and_then(serde_json::Value::as_str) + .map(str::trim) + == Some(principal_id.as_str()) + && context.proof_binding_id.is_some() + { + object.insert( + "recipient_proof".to_string(), + serde_json::json!({ + "schema": "elastos.library.recipient-proof/v1", + "source": "runtime-launch-grant", + "recipient": principal_id, + "principal_id": principal_id, + "proof_binding_id": context.proof_binding_id.as_deref().unwrap_or_default(), + "session_id": session_id, + }), + ); + } + } + } if scheme == "net" && op == "http" { return gateway_browser::gateway_browser_net_http(registry.as_ref(), &request).await; @@ -80,6 +1411,7 @@ pub(super) async fn gateway_provider_proxy( } let chain_lifecycle_audit = chain_lifecycle_effect_audit(&scheme, &op, &request); + let library_audit = (scheme == "object").then(|| format!("object:{op}:{}", now_ts())); if let Some(audit) = &chain_lifecycle_audit { if let Err(err) = append_provider_effect_audit( &state.data_dir, @@ -102,32 +1434,64 @@ pub(super) async fn gateway_provider_proxy( ); } } - - let response = match registry.send_raw(&scheme, &request).await { - Ok(value) - if scheme == "net" && value.get("status").and_then(|v| v.as_str()) == Some("error") => - { - let message = value - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("net provider unavailable"); + if let Some(request_id) = &library_audit { + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &principal_id, + session_id: &session_id, + request_id, + result: "requested", + reason: &format!("Library requested object provider operation {op}"), + }, + ) { return gateway_provider_error_response( &scheme, - anyhow::anyhow!("net provider unavailable: {}", message), + anyhow::anyhow!("object provider audit failed: {}", err), ); } - Ok(value) => value, - Err(err) if scheme == "net" => { - return gateway_provider_error_response( - &scheme, - anyhow::anyhow!("net provider unavailable: {}", err), - ) - } - Err(err) => serde_json::json!({ + } + + let response = if scheme == "object" + && (library_operation_needs_runtime_coordinator(&op) + || library_request_targets_webspace(&request)) + { + crate::library::handle_object_provider_runtime_request( + &state.data_dir, + Arc::clone(®istry), + &request, + ) + .await + } else { + match registry.send_raw(&scheme, &request).await { + Ok(value) + if scheme == "net" + && value.get("status").and_then(|v| v.as_str()) == Some("error") => + { + let message = value + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("net provider unavailable"); + return gateway_provider_error_response( + &scheme, + anyhow::anyhow!("net provider unavailable: {}", message), + ); + } + Ok(value) => value, + Err(err) if scheme == "net" => { + return gateway_provider_error_response( + &scheme, + anyhow::anyhow!("net provider unavailable: {}", err), + ) + } + Err(err) => serde_json::json!({ "status": "error", "code": "provider_error", - "message": err.to_string(), - }), + "message": err.to_string(), + }), + } }; if let Some(audit) = &chain_lifecycle_audit { @@ -159,6 +1523,153 @@ pub(super) async fn gateway_provider_proxy( ); } } + if let Some(request_id) = &library_audit { + let completed = response.get("status").and_then(|value| value.as_str()) == Some("ok"); + if completed && library_operation_emits_events(&op) { + crate::library::library_event_notifier().notify_waiters(); + } + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: if completed { + "object.provider.completed" + } else { + "object.provider.failed" + }, + principal_id: &principal_id, + session_id: &session_id, + request_id, + result: if completed { "completed" } else { "failed" }, + reason: &format!( + "Library {} object provider operation {op}", + if completed { "completed" } else { "failed" } + ), + }, + ) { + return gateway_provider_error_response( + &scheme, + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + } Json(response).into_response() } + +fn library_operation_emits_events(op: &str) -> bool { + matches!( + op, + "write" + | "mkdir" + | "rename" + | "move" + | "copy" + | "trash" + | "restore" + | "delete_permanently" + | "extract_archive" + | "archive_extract_entries" + | "compress_archive" + | "publish" + | "unpublish" + | "repair" + | "share" + ) +} + +fn library_operation_needs_runtime_coordinator(op: &str) -> bool { + matches!(op, "publish" | "unpublish" | "repair" | "sync") +} + +fn library_request_targets_webspace(request: &serde_json::Value) -> bool { + ["uri", "parent_uri", "target_uri", "target_parent_uri"] + .iter() + .filter_map(|field| request.get(field).and_then(|value| value.as_str())) + .map(str::trim) + .map(|value| value.trim_end_matches('/')) + .any(|value| { + value == "localhost://WebSpaces" + || value + .strip_prefix("localhost://WebSpaces/") + .is_some_and(|rest| !rest.is_empty()) + }) +} + +struct LibraryEventsStreamState { + registry: Arc, + principal_id: String, + cursor: String, + initialized: bool, +} + +fn library_events_stream_headers( + mut headers: HeaderMap, + query_token: Option<&str>, +) -> anyhow::Result { + if headers.get("x-elastos-home-token").is_none() { + if let Some(token) = query_token.map(str::trim).filter(|token| !token.is_empty()) { + headers.insert("x-elastos-home-token", HeaderValue::from_str(token)?); + } + } + Ok(headers) +} + +async fn library_events_since_cursor( + registry: &ProviderRegistry, + principal_id: &str, + cursor: &str, +) -> anyhow::Result<(Vec, String)> { + let response = registry + .send_raw( + "object", + &serde_json::json!({ + "op": "events", + "principal_id": principal_id, + "limit": 256, + }), + ) + .await + .map_err(|err| anyhow::anyhow!("object provider unavailable: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("library events failed"); + anyhow::bail!("{message}"); + } + let events = response + .get("data") + .and_then(|data| data.get("events")) + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let next_cursor = events + .last() + .and_then(|event| event.get("event_id")) + .and_then(|value| value.as_str()) + .unwrap_or(cursor) + .to_string(); + if cursor.is_empty() { + return Ok((events, next_cursor)); + } + let cursor_index = events.iter().position(|event| { + event + .get("event_id") + .and_then(|value| value.as_str()) + .is_some_and(|event_id| event_id == cursor) + }); + let filtered = if let Some(index) = cursor_index { + events.into_iter().skip(index + 1).collect() + } else { + events + }; + Ok((filtered, next_cursor)) +} + +fn library_events_sse_event(payload: serde_json::Value) -> SseEvent { + let data = serde_json::to_string(&payload).unwrap_or_else(|_| { + r#"{"schema":"elastos.library.events/v1","status":"error","events":[]}"#.to_string() + }); + SseEvent::default().event("library-events").data(data) +} diff --git a/elastos/crates/elastos-server/src/api/viewer_gateway.rs b/elastos/crates/elastos-server/src/api/viewer_gateway.rs index 0edce46a..61b454a2 100644 --- a/elastos/crates/elastos-server/src/api/viewer_gateway.rs +++ b/elastos/crates/elastos-server/src/api/viewer_gateway.rs @@ -1,16 +1,20 @@ use std::path::{Path as FsPath, PathBuf}; +use std::sync::Arc; use axum::body::Bytes; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use elastos_common::localhost::rooted_localhost_fs_path; -use serde::Serialize; +use elastos_runtime::auth::RuntimeAuditEventV1; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use super::gateway::{ - content_type, require_home_launch_token_for_any, viewer_object_shell_description, - viewer_object_shell_title, GatewayState, HomeLaunchTokenContext, + content_type, require_home_launch_token_for_any, require_home_launch_token_for_any_context, + viewer_object_shell_description, viewer_object_shell_title, GatewayState, + HomeLaunchTokenContext, }; #[derive(Debug, Serialize)] @@ -26,6 +30,46 @@ struct ViewerLibraryItem { entrypoint: String, } +#[derive(Debug, Deserialize)] +pub struct ViewerLibraryObjectQuery { + uri: String, + #[serde(default)] + stat_only: bool, + #[serde(default)] + entries: bool, + #[serde(default)] + preview_entry: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ViewerLibraryObjectWrite { + data: String, + #[serde(default)] + mime: Option, + #[serde(default)] + if_revision: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ViewerLibraryArchiveExtractEntries { + destination_uri: String, + entries: Vec, + #[serde(default)] + conflict_policy: Option, + #[serde(default)] + if_revision: Option, + #[serde(default)] + cancel: bool, +} + +struct ViewerLibraryObjectRequest { + uri: String, + stat_only: bool, + entries: bool, + preview_entry: Option, + write: Option, +} + pub async fn viewer_library_summary( State(state): State, Path(viewer): Path, @@ -110,6 +154,128 @@ pub async fn viewer_content( .into_response() } +pub async fn viewer_library_object_get( + State(state): State, + Path(viewer): Path, + Query(query): Query, + headers: HeaderMap, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + let context = match require_library_object_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_library_object( + &state, + &context, + &viewer, + ViewerLibraryObjectRequest { + uri: query.uri, + stat_only: query.stat_only, + entries: query.entries, + preview_entry: query.preview_entry, + write: None, + }, + ) + .await + { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + +pub async fn viewer_library_object_put( + State(state): State, + Path(viewer): Path, + Query(query): Query, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + if viewer != "documents" { + return ( + StatusCode::FORBIDDEN, + "viewer does not support Library object writes", + ) + .into_response(); + } + let context = match require_documents_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_library_object( + &state, + &context, + &viewer, + ViewerLibraryObjectRequest { + uri: query.uri, + stat_only: false, + entries: false, + preview_entry: None, + write: Some(input), + }, + ) + .await + { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + +pub async fn viewer_library_object_post( + State(state): State, + Path(viewer): Path, + Query(query): Query, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + let context = match require_library_object_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_library_archive_extract_entries(&state, &context, &viewer, &query.uri, input).await + { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + +pub async fn viewer_library_roots_get( + State(state): State, + Path(viewer): Path, + headers: HeaderMap, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + if viewer != "archive-manager" { + return ( + StatusCode::FORBIDDEN, + "viewer does not support Library destination roots", + ) + .into_response(); + } + let context = match require_library_object_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_object_provider_request(&state, &context, "roots", json!({})).await { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + pub async fn viewer_storage_get( State(state): State, Path((viewer, capsule, scope, name)): Path<(String, String, String, String)>, @@ -167,6 +333,215 @@ pub async fn viewer_storage_put( } } +async fn viewer_library_object( + state: &GatewayState, + context: &HomeLaunchTokenContext, + viewer: &str, + request: ViewerLibraryObjectRequest, +) -> anyhow::Result { + let stat = + viewer_object_provider_request(state, context, "stat", json!({ "uri": &request.uri })) + .await?; + ensure_viewer_can_view_library_object(&stat, viewer)?; + if request.stat_only { + return Ok(stat); + } + if request.entries { + if viewer != "archive-manager" { + anyhow::bail!("viewer does not support Library archive entry listing"); + } + return viewer_object_provider_request( + state, + context, + "archive_entries", + json!({ "uri": &request.uri }), + ) + .await; + } + if let Some(entry) = request.preview_entry { + if viewer != "archive-manager" { + anyhow::bail!("viewer does not support Library archive entry preview"); + } + return viewer_object_provider_request( + state, + context, + "archive_preview_entry", + json!({ "uri": &request.uri, "entry": entry }), + ) + .await; + } + if viewer != "documents" { + anyhow::bail!("viewer supports Library object metadata only"); + } + let payload = match request.write { + Some(write) => json!({ + "uri": &request.uri, + "data": write.data, + "mime": write.mime, + "if_revision": write.if_revision, + }), + None => json!({ "uri": &request.uri }), + }; + let op = if payload.get("data").is_some() { + "write" + } else { + "read" + }; + viewer_object_provider_request(state, context, op, payload).await +} + +async fn viewer_object_provider_request( + state: &GatewayState, + context: &HomeLaunchTokenContext, + op: &str, + mut request: Value, +) -> anyhow::Result { + let registry = state + .provider_registry + .as_ref() + .ok_or_else(|| anyhow::anyhow!("object provider unavailable"))?; + request["op"] = Value::String(op.to_string()); + request["principal_id"] = Value::String(context.principal_id.clone()); + let request_id = format!("viewer-library:{op}:{}", crate::auth::now_ts()); + append_viewer_library_audit( + &state.data_dir, + context, + &request_id, + "library.viewer.requested", + "requested", + &format!("Viewer requested Library object operation {op}"), + )?; + let response = crate::library::handle_object_provider_runtime_request( + &state.data_dir, + Arc::clone(registry), + &request, + ) + .await; + let completed = response.get("status").and_then(Value::as_str) == Some("ok"); + if completed && op == "write" { + crate::library::library_event_notifier().notify_waiters(); + } + append_viewer_library_audit( + &state.data_dir, + context, + &request_id, + if completed { + "library.viewer.completed" + } else { + "library.viewer.failed" + }, + if completed { "completed" } else { "failed" }, + &format!( + "Viewer {} Library object operation {op}", + if completed { "completed" } else { "failed" } + ), + )?; + Ok(response) +} + +async fn viewer_library_archive_extract_entries( + state: &GatewayState, + context: &HomeLaunchTokenContext, + viewer: &str, + uri: &str, + input: ViewerLibraryArchiveExtractEntries, +) -> anyhow::Result { + if viewer != "archive-manager" { + anyhow::bail!("viewer does not support Library archive extraction"); + } + let stat = + viewer_object_provider_request(state, context, "stat", json!({ "uri": uri })).await?; + ensure_viewer_can_view_library_object(&stat, viewer)?; + let response = viewer_object_provider_request( + state, + context, + "archive_extract_entries", + json!({ + "uri": uri, + "destination_uri": input.destination_uri, + "entries": input.entries, + "conflict_policy": input.conflict_policy, + "if_revision": input.if_revision, + "cancel": input.cancel, + }), + ) + .await?; + if response.get("status").and_then(Value::as_str) == Some("ok") { + crate::library::library_event_notifier().notify_waiters(); + } + Ok(response) +} + +fn ensure_viewer_can_view_library_object(response: &Value, viewer_id: &str) -> anyhow::Result<()> { + let object = response + .get("data") + .and_then(|data| data.get("object")) + .ok_or_else(|| anyhow::anyhow!("library object not found"))?; + let can_view = object + .get("viewers") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|viewer| viewer.get("id").and_then(Value::as_str) == Some(viewer_id)); + if !can_view { + anyhow::bail!("Library object is not viewable by {viewer_id}"); + } + Ok(()) +} + +fn require_library_object_viewer_context( + data_dir: &FsPath, + headers: &HeaderMap, + viewer: &str, +) -> anyhow::Result { + let viewer = clean_capsule_ref(viewer, "viewer")?; + if !super::browser_capsules::is_viewer_capsule(data_dir, &viewer) { + anyhow::bail!("viewer capsule not found"); + } + require_home_launch_token_for_any_context(data_dir, headers, &[viewer.as_str()]) +} + +fn require_documents_viewer_context( + data_dir: &FsPath, + headers: &HeaderMap, + viewer: &str, +) -> anyhow::Result { + let viewer = clean_capsule_ref(viewer, "viewer")?; + if viewer != "documents" || !super::browser_capsules::is_viewer_capsule(data_dir, &viewer) { + anyhow::bail!("viewer capsule not found"); + } + require_home_launch_token_for_any_context(data_dir, headers, &[viewer.as_str()]) +} + +fn append_viewer_library_audit( + data_dir: &FsPath, + context: &HomeLaunchTokenContext, + request_id: &str, + event_type: &str, + result: &str, + reason: &str, +) -> anyhow::Result<()> { + let now = crate::auth::now_ts(); + crate::auth::append_audit_event( + data_dir, + RuntimeAuditEventV1 { + schema: RuntimeAuditEventV1::SCHEMA.to_string(), + event_id: format!("audit:{event_type}:{request_id}:{now}"), + event_type: event_type.to_string(), + principal_id: Some(context.principal_id.clone()), + proof_binding_id: context.proof_binding_id.clone(), + session_id: Some(context.session_id.clone()), + challenge_id: Some(request_id.to_string()), + capsule_id: Some("documents".to_string()), + result: result.to_string(), + reason: reason.to_string(), + occurred_at: now, + signer_did: None, + signature: None, + }, + ) +} + struct ViewerStorageTarget { path: PathBuf, principal_id: String, @@ -303,6 +678,11 @@ fn viewer_error_response(err: anyhow::Error) -> Response { StatusCode::NOT_FOUND } else if text.contains("home launch token") { StatusCode::UNAUTHORIZED + } else if text.contains("does not support") + || text.contains("not viewable") + || text.contains("metadata only") + { + StatusCode::FORBIDDEN } else if text.contains("invalid") || text.contains("must not be empty") || text.contains("no storage grant") diff --git a/elastos/crates/elastos-server/src/lib.rs b/elastos/crates/elastos-server/src/lib.rs index e5f53ee7..b1fa0cdf 100644 --- a/elastos/crates/elastos-server/src/lib.rs +++ b/elastos/crates/elastos-server/src/lib.rs @@ -19,6 +19,7 @@ pub mod gateway_cmd; pub mod host_lock; pub mod init; pub mod ipfs; +pub mod library; pub mod local_http; pub mod notifications; pub mod operator_control; diff --git a/elastos/crates/elastos-server/src/main.rs b/elastos/crates/elastos-server/src/main.rs index 271d6ca7..5c89740c 100755 --- a/elastos/crates/elastos-server/src/main.rs +++ b/elastos/crates/elastos-server/src/main.rs @@ -778,6 +778,140 @@ pub(crate) enum SiteCommand { #[derive(Subcommand)] pub(crate) enum WebspaceCommand { + /// Show the persistent WebSpace mount table and built-in mounts + Mounts { + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show registered external WebSpace resolver adapters + Adapters { + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Register or update an external WebSpace resolver adapter + RegisterAdapter { + /// Resolver/provider identifier used by mounted WebSpaces + resolver: String, + /// Human label for operator UX + #[arg(long)] + label: Option, + /// Endpoint URI or provider-qualified route; credentials are redacted in status output + #[arg(long)] + endpoint_uri: Option, + /// Provider id that owns this adapter, when available + #[arg(long)] + provider: Option, + /// Adapter state: configured, connected, unavailable, or disabled + #[arg(long)] + state: Option, + /// Adapter capability label, repeatable; defaults to metadata_index + #[arg(long = "capability")] + capabilities: Vec, + /// Mark new mounts through this adapter mutable by default + #[arg(long)] + mutable_default: bool, + /// Operator-facing description + #[arg(long)] + description: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Remove an external WebSpace resolver adapter registration + UnregisterAdapter { + /// Resolver/provider identifier to remove + resolver: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Record a safe external WebSpace resolver adapter health check + CheckAdapter { + /// Resolver/provider identifier to check + resolver: String, + /// Health result: ok, failed, skipped, or unknown + #[arg(long)] + result: Option, + /// Optional adapter state override: configured, connected, unavailable, or disabled + #[arg(long)] + state: Option, + /// Short opaque failure code; credentials/messages are rejected + #[arg(long)] + error_code: Option, + /// Adapter capability label, repeatable; updates capabilities when supplied + #[arg(long = "capability")] + capabilities: Vec, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show WebSpace resolver metadata health without exposing provider credentials + Health { + /// Optional WebSpace moniker to inspect + moniker: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Persistently mount an external namespace under localhost://WebSpaces/ + Mount { + /// Local WebSpace moniker, for example Google + moniker: String, + /// External target URI, for example google://drive or https://example.com + target_uri: String, + /// Optional namespace URI override, for example google:// + #[arg(long)] + namespace_uri: Option, + /// Resolver/provider identifier required for live access + #[arg(long)] + resolver: Option, + /// Operator-facing description + #[arg(long)] + description: Option, + /// Mark the mount mutable. Content writes still require provider-to-provider support. + #[arg(long)] + mutable: bool, + /// Cache policy label, for example metadata-only + #[arg(long)] + cache_policy: Option, + /// Sync policy label, for example manual + #[arg(long)] + sync_policy: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Remove a persistent WebSpace mount + Unmount { + /// Local WebSpace moniker to remove + moniker: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Replace resolver-discovered child metadata for a mounted WebSpace + Index { + /// Local WebSpace moniker to index + moniker: String, + /// JSON file containing an array of { path, kind, target_uri?, resolver_state?, readonly?, description? } + entries_json: PathBuf, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Refresh resolver metadata for a WebSpace handle, optionally replacing its resolver index + Refresh { + /// Moniker or handle path to refresh, for example: Google + target: String, + /// Optional JSON file containing a replacement resolver index + #[arg(long)] + entries_json: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, /// List the currently known WebSpace monikers or the typed children under a mounted handle List { /// Optional moniker or handle path (for example: Elastos or Elastos/peer) @@ -794,6 +928,74 @@ pub(crate) enum WebspaceCommand { #[arg(long)] json: bool, }, + /// Persist or inspect the current provider-owned object head for a WebSpace handle + Head { + /// Moniker or handle path, for example: Elastos/content/ + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show metadata-cache status for a WebSpace handle + CacheStatus { + /// Moniker or handle path, for example: Google/Drive/file.pdf + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Refresh provider-owned metadata cache state for a WebSpace handle + Cache { + /// Moniker or handle path, for example: Google/Drive/file.pdf + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show sync/dirty status for a WebSpace handle + SyncStatus { + /// Moniker or handle path, for example: Google/Drive/file.pdf + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Mark provider-owned WebSpace metadata/fork head as synced + Sync { + /// Moniker or handle path, for example: ProjectFork + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Create a mutable metadata fork of a WebSpace handle under a new moniker + Fork { + /// Source moniker or handle path to fork + source: String, + /// New local WebSpace moniker + moniker: String, + /// Optional target URI override for the fork + #[arg(long)] + target_uri: Option, + /// Optional resolver/provider override + #[arg(long)] + resolver: Option, + /// Operator-facing description + #[arg(long)] + description: Option, + /// Keep the fork readonly instead of mutable + #[arg(long)] + readonly: bool, + /// Cache policy label, for example metadata-only + #[arg(long)] + cache_policy: Option, + /// Sync policy label, for example manual + #[arg(long)] + sync_policy: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] diff --git a/elastos/crates/elastos-server/src/server_infra.rs b/elastos/crates/elastos-server/src/server_infra.rs index 6a0a76b3..756c3545 100644 --- a/elastos/crates/elastos-server/src/server_infra.rs +++ b/elastos/crates/elastos-server/src/server_infra.rs @@ -4,8 +4,11 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use elastos_common::localhost::{ensure_file_backed_roots, file_backed_prefixes}; +use elastos_runtime::provider::{ + ProviderInvocation, ProviderInvocationTransport, ProviderTransfer, +}; use elastos_runtime::{capability, content, namespace, primitives, provider, session}; -use elastos_server::content::ContentProvider; +use elastos_server::content::{ContentProvider, ContentProviderExternalConfigs}; use elastos_server::documents::DocumentsProvider; use elastos_server::sources::{default_data_dir, local_session_owner}; use elastos_server::{api, fetcher, ownership}; @@ -24,6 +27,18 @@ pub(crate) struct ServerInfrastructure { pub(crate) host_helpers: Vec, } +const CONTENT_REPAIR_SCHEDULER_ENV: &str = "ELASTOS_CONTENT_REPAIR_SCHEDULER"; +const CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV: &str = "ELASTOS_CONTENT_REPAIR_INTERVAL_SECS"; +const CONTENT_REPAIR_SCHEDULER_LIMIT_ENV: &str = "ELASTOS_CONTENT_REPAIR_LIMIT"; +const CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV: &str = "ELASTOS_CONTENT_REPAIR_MAX_ATTEMPTS"; +const CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV: &str = "ELASTOS_CONTENT_REPAIR_FAILURE_BUDGET"; +const CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV: &str = "ELASTOS_CONTENT_REPAIR_INCLUDE_HEALTHY"; +const CONTENT_REPAIR_SCHEDULER_MIN_INTERVAL_SECS: u64 = 60; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_INTERVAL_SECS: u64 = 15 * 60; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_LIMIT: u64 = 10; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_MAX_ATTEMPTS: u64 = 3; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_FAILURE_BUDGET: u64 = 5; + pub(crate) async fn setup_server_infrastructure() -> anyhow::Result { setup_server_infrastructure_impl(true).await } @@ -69,9 +84,21 @@ async fn setup_server_infrastructure_impl( ensure_file_backed_roots(&data_dir).ok(); let provider_registry = Arc::new(provider::ProviderRegistry::new()); let mut managed_host_processes = Vec::new(); - let content_provider = Arc::new(ContentProvider::new( + let mut external_availability_registered = false; + let content_provider = Arc::new(ContentProvider::new_with_external_configs( data_dir.clone(), Arc::downgrade(&provider_registry), + ContentProviderExternalConfigs { + operator_alert_sink: content_operator_alert_sink_config_from_env(), + storage_market_admission: content_storage_market_admission_config_from_env(), + external_repair_fleet: content_external_repair_fleet_config_from_env(), + federated_operator_alert_exchange: + content_federated_operator_alert_exchange_config_from_env(), + federated_quota_ledger_exchange: + content_federated_quota_ledger_exchange_config_from_env(), + federated_abuse_control_exchange: + content_federated_abuse_control_exchange_config_from_env(), + }, )); provider_registry.register(content_provider.clone()).await; if let Err(err) = provider_registry @@ -87,6 +114,7 @@ async fn setup_server_infrastructure_impl( ))) .await; let device_key = elastos_identity::load_or_create_device_key(&data_dir)?; + let device_key_hex = hex::encode(device_key.as_ref()); let mut provider_cid = "sha256:unavailable".to_string(); let verify_provider_binary = |name: &str, path: &std::path::Path| -> anyhow::Result<()> { let checksum = crate::setup::verify_installed_component_binary(&data_dir, name, path)?; @@ -115,8 +143,6 @@ async fn setup_server_infrastructure_impl( hex::encode(elastos_runtime::signature::hash_content(&provider_bytes)) ); - let device_key_hex = hex::encode(device_key.as_ref()); - let config = provider::BridgeProviderConfig { base_path: data_dir.to_string_lossy().to_string(), allowed_paths: file_backed_prefixes(), @@ -172,26 +198,6 @@ async fn setup_server_infrastructure_impl( } } - if let Some(path) = crate::find_installed_provider_binary("webspace-provider") { - if let Err(e) = verify_provider_binary("webspace-provider", &path) { - tracing::warn!( - "Skipping webspace-provider due to verification failure: {}", - e - ); - } else { - match provider::ProviderBridge::spawn(&path, Default::default()).await { - Ok(bridge) => { - let provider: Arc = Arc::new( - provider::CapsuleProvider::with_scheme(Arc::new(bridge), "webspace"), - ); - provider_registry.register(provider).await; - tracing::info!("webspace-provider capsule from {}", path.display()); - } - Err(e) => tracing::warn!("Failed to spawn webspace-provider: {}", e), - } - } - } - let mut llama_endpoint: Option = None; if let Some(path) = crate::find_installed_provider_binary("llama-provider") { let mut llama_extra = serde_json::Map::new(); @@ -297,29 +303,6 @@ async fn setup_server_infrastructure_impl( } } - if let Some(path) = crate::find_installed_provider_binary("ipfs-provider") { - if let Err(e) = verify_provider_binary("ipfs-provider", &path) { - tracing::warn!("Skipping ipfs-provider due to verification failure: {}", e); - } else { - match provider::ProviderBridge::spawn(&path, Default::default()).await { - Ok(bridge) => { - let bridge = Arc::new(bridge); - let ipfs_provider: Arc = Arc::new( - provider::CapsuleProvider::with_scheme(Arc::clone(&bridge), "ipfs"), - ); - if let Err(e) = provider_registry - .register_sub_provider("ipfs", ipfs_provider) - .await - { - tracing::warn!("Failed to register elastos://ipfs sub-provider: {}", e); - } - tracing::info!("ipfs-provider capsule from {}", path.display()); - } - Err(e) => tracing::debug!("ipfs-provider unavailable: {}", e), - } - } - } - if let Some(availability_config) = availability_provider_config_from_env() { if let Some(path) = crate::find_installed_provider_binary("availability-provider") { if let Err(e) = verify_provider_binary("availability-provider", &path) { @@ -347,6 +330,8 @@ async fn setup_server_infrastructure_impl( "Failed to register elastos://availability sub-provider: {}", e ); + } else { + external_availability_registered = true; } tracing::info!("availability-provider capsule from {}", path.display()); } @@ -361,6 +346,169 @@ async fn setup_server_infrastructure_impl( } } + if let Some(path) = crate::find_installed_provider_binary("content-block-graph-provider") { + if let Err(e) = verify_provider_binary("content-block-graph-provider", &path) { + tracing::warn!( + "Skipping content-block-graph-provider due to verification failure: {}", + e + ); + } else { + let block_graph_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + extra: serde_json::json!({ + "backend": "kubo_coord" + }), + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, block_graph_config).await { + Ok(bridge) => { + let block_graph_provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(Arc::new(bridge), "block-graph"), + ); + if let Err(e) = provider_registry + .register_sub_provider("block-graph", block_graph_provider) + .await + { + tracing::warn!( + "Failed to register elastos://block-graph sub-provider: {}", + e + ); + } + tracing::info!( + "content-block-graph-provider capsule from {}", + path.display() + ); + } + Err(e) => tracing::warn!("Failed to spawn content-block-graph-provider: {}", e), + } + } + } else { + tracing::warn!( + "content-block-graph-provider binary is not installed; arbitrary DAG repair will fail closed" + ); + } + + if let Some(path) = crate::find_installed_provider_binary("object-provider") { + if let Err(e) = verify_provider_binary("object-provider", &path) { + tracing::warn!( + "Skipping {} due to verification failure: {}", + "object-provider", + e + ); + } else { + let object_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + allowed_paths: file_backed_prefixes(), + read_only: false, + encryption_key: device_key_hex.clone(), + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, object_config).await { + Ok(bridge) => { + let bridge = Arc::new(bridge); + let object_provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(bridge.clone(), "object"), + ); + provider_registry.register(object_provider).await; + tracing::info!( + "object-provider capsule from {} registered as object provider", + path.display() + ); + } + Err(e) => tracing::warn!("Failed to spawn object-provider: {}", e), + } + } + } else { + tracing::warn!( + "object-provider binary is not installed; Library object operations will fail closed" + ); + } + + if let Some(path) = crate::find_installed_provider_binary("webspace-provider") { + if let Err(e) = verify_provider_binary("webspace-provider", &path) { + tracing::warn!( + "Skipping webspace-provider due to verification failure: {}", + e + ); + } else { + let webspace_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + read_only: false, + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, webspace_config).await { + Ok(bridge) => { + let provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(Arc::new(bridge), "webspace"), + ); + provider_registry.register(provider).await; + tracing::info!("webspace-provider capsule from {}", path.display()); + } + Err(e) => tracing::warn!("Failed to spawn webspace-provider: {}", e), + } + } + } else { + tracing::warn!( + "webspace-provider binary is not installed; WebSpace roots will fail closed" + ); + } + + if let Some(path) = crate::find_installed_provider_binary("operator-drive-adapter") { + if let Err(e) = verify_provider_binary("operator-drive-adapter", &path) { + tracing::warn!( + "Skipping operator-drive-adapter due to verification failure: {}", + e + ); + } else { + let adapter_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + read_only: false, + extra: operator_drive_adapter_config_from_env(&data_dir) + .unwrap_or(serde_json::Value::Null), + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, adapter_config).await { + Ok(bridge) => { + let provider: Arc = + Arc::new(provider::CapsuleProvider::with_scheme( + Arc::new(bridge), + "operator-drive-adapter", + )); + provider_registry.register(provider).await; + tracing::info!("operator-drive-adapter capsule from {}", path.display()); + } + Err(e) => tracing::warn!("Failed to spawn operator-drive-adapter: {}", e), + } + } + } + + if let Some(path) = crate::find_installed_provider_binary("ipfs-provider") { + if let Err(e) = verify_provider_binary("ipfs-provider", &path) { + tracing::warn!("Skipping ipfs-provider due to verification failure: {}", e); + } else { + match provider::ProviderBridge::spawn(&path, Default::default()).await { + Ok(bridge) => { + let bridge = Arc::new(bridge); + let ipfs_provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(Arc::clone(&bridge), "ipfs"), + ); + if let Err(e) = provider_registry + .register_sub_provider("ipfs", ipfs_provider) + .await + { + tracing::warn!("Failed to register elastos://ipfs sub-provider: {}", e); + } + tracing::info!("ipfs-provider capsule from {}", path.display()); + } + Err(e) => tracing::warn!("ipfs-provider unavailable: {}", e), + } + } + } else { + tracing::warn!( + "ipfs-provider binary is not installed; elastos://content publish/fetch will fail closed" + ); + } + if let Some(path) = crate::find_installed_provider_binary("chain-provider") { if let Err(e) = verify_provider_binary("chain-provider", &path) { tracing::warn!("Skipping chain-provider due to verification failure: {}", e); @@ -621,14 +769,20 @@ async fn setup_server_infrastructure_impl( // Identity is DID (derived from device_key), not raw device_key. let (carrier_signing_key, carrier_did) = elastos_identity::derive_did(&device_key); { - match elastos_server::carrier::start_carrier_node( + match elastos_server::carrier::start_carrier_node_with_registry( &carrier_signing_key, &carrier_did, data_dir.clone(), + Some(Arc::downgrade(&provider_registry)), ) .await { Ok(carrier_node) => { + provider_registry + .set_carrier_invoker(Arc::new( + elastos_server::carrier::CarrierProviderInvoker::new(), + )) + .await; let gossip_provider: Arc = Arc::new(elastos_server::carrier::CarrierGossipProvider::new( carrier_node.gossip_state.clone(), @@ -639,6 +793,22 @@ async fn setup_server_infrastructure_impl( { tracing::warn!("Failed to register Carrier gossip provider: {}", e); } + if !external_availability_registered { + let availability_provider: Arc = + Arc::new( + elastos_server::carrier::CarrierAvailabilityProvider::with_provider_registry_data_dir_and_peer_attestation_exchange_config( + carrier_node.gossip_state.clone(), + Arc::downgrade(&provider_registry), + data_dir.clone(), + carrier_peer_attestation_exchange_config_from_env(), + )); + if let Err(e) = provider_registry + .register_sub_provider("availability", availability_provider) + .await + { + tracing::warn!("Failed to register Carrier availability provider: {}", e); + } + } // Hold the carrier node alive. Dropping it kills the endpoint. tokio::spawn(async move { let _node = carrier_node; @@ -654,6 +824,8 @@ async fn setup_server_infrastructure_impl( } } + maybe_spawn_content_repair_scheduler(provider_registry.clone()); + let namespace_path = data_dir.join("namespaces"); std::fs::create_dir_all(&namespace_path).ok(); let resolver_config = content::ResolverConfig { @@ -885,6 +1057,369 @@ fn availability_provider_config_from_env() -> Option { })) } +fn content_operator_alert_sink_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_federated_operator_alert_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_AUTHORIZATION") + { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_TIMEOUT_SECS") + { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_federated_quota_ledger_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_AUTHORIZATION") + { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_TIMEOUT_SECS") + { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_federated_abuse_control_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_AUTHORIZATION") + { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_TIMEOUT_SECS") + { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_storage_market_admission_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_external_repair_fleet_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn carrier_peer_attestation_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ContentRepairSchedulerConfig { + interval_secs: u64, + limit: u64, + max_attempts: u64, + failure_budget: u64, + include_healthy_check: bool, +} + +fn content_repair_scheduler_config_from_env() -> Option { + if !env_flag_enabled(CONTENT_REPAIR_SCHEDULER_ENV) { + return None; + } + Some(ContentRepairSchedulerConfig { + interval_secs: env_u64( + CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_INTERVAL_SECS, + ) + .max(CONTENT_REPAIR_SCHEDULER_MIN_INTERVAL_SECS), + limit: env_u64( + CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_LIMIT, + ) + .clamp(1, 100), + max_attempts: env_u64( + CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_MAX_ATTEMPTS, + ) + .clamp(1, 25), + failure_budget: env_u64( + CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_FAILURE_BUDGET, + ) + .clamp(1, 100), + include_healthy_check: env_flag_enabled(CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV), + }) +} + +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name) + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(default) +} + +fn maybe_spawn_content_repair_scheduler(registry: Arc) { + let Some(config) = content_repair_scheduler_config_from_env() else { + tracing::debug!( + "{} is disabled; content repair worker remains manual/operator-triggered", + CONTENT_REPAIR_SCHEDULER_ENV + ); + return; + }; + tracing::info!( + "content repair scheduler enabled: interval={}s limit={} max_attempts={} failure_budget={} include_healthy_check={}", + config.interval_secs, + config.limit, + config.max_attempts, + config.failure_budget, + config.include_healthy_check, + ); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(config.interval_secs)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + match invoke_content_repair_worker(®istry, config).await { + Ok(response) => { + let data = response.get("data").unwrap_or(&response); + tracing::debug!( + "content repair scheduler run completed: checked={} repaired={} failed={} skipped={}", + data.get("checked").and_then(|value| value.as_u64()).unwrap_or(0), + data.get("repaired").and_then(|value| value.as_u64()).unwrap_or(0), + data.get("failed").and_then(|value| value.as_u64()).unwrap_or(0), + data.get("skipped").and_then(|value| value.as_u64()).unwrap_or(0), + ); + } + Err(err) => { + tracing::warn!("content repair scheduler run failed: {}", err); + } + } + } + }); +} + +async fn invoke_content_repair_worker( + registry: &provider::ProviderRegistry, + config: ContentRepairSchedulerConfig, +) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "repair_worker".to_string(), + request: serde_json::json!({ + "op": "repair_worker", + "force": false, + "include_healthy_check": config.include_healthy_check, + "limit": config.limit, + "max_attempts": config.max_attempts, + "failure_budget": config.failure_budget, + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await +} + fn exit_provider_config_from_env(data_dir: &std::path::Path) -> Option { provider_config_from_env_or_file( data_dir, @@ -901,6 +1436,14 @@ fn browser_engine_adapter_config_from_env(data_dir: &std::path::Path) -> Option< ) } +fn operator_drive_adapter_config_from_env(data_dir: &std::path::Path) -> Option { + provider_config_from_env_or_file( + data_dir, + "ELASTOS_OPERATOR_DRIVE_ADAPTER_CONFIG", + "operator-drive-adapter.json", + ) +} + fn browser_local_exit_config_from_env(data_dir: &std::path::Path) -> Option { provider_config_from_env_or_file( data_dir, @@ -1007,6 +1550,369 @@ mod tests { assert_eq!(config["targets"][0]["authorization"], "Bearer secret"); } + #[test] + fn content_operator_alert_sink_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG", + "ELASTOS_CONTENT_OPERATOR_ALERT_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG", + r#"{"url":"https://alerts.example.invalid/content","authorization":"Bearer secret","timeout_secs":7}"#, + ); + + let config = content_operator_alert_sink_config_from_env().unwrap(); + assert_eq!(config["url"], "https://alerts.example.invalid/content"); + assert_eq!(config["authorization"], "Bearer secret"); + assert_eq!(config["timeout_secs"], 7); + } + + #[test] + fn content_operator_alert_sink_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG", + "ELASTOS_CONTENT_OPERATOR_ALERT_URL", + "ELASTOS_CONTENT_OPERATOR_ALERT_AUTHORIZATION", + "ELASTOS_CONTENT_OPERATOR_ALERT_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_OPERATOR_ALERT_URL", + "http://127.0.0.1:9799/content-alerts", + ); + std::env::set_var( + "ELASTOS_CONTENT_OPERATOR_ALERT_AUTHORIZATION", + "Bearer local", + ); + std::env::set_var("ELASTOS_CONTENT_OPERATOR_ALERT_TIMEOUT_SECS", "9"); + + let config = content_operator_alert_sink_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/content-alerts"); + assert_eq!(config["authorization"], "Bearer local"); + assert_eq!(config["timeout_secs"], 9); + } + + #[test] + fn content_federated_operator_alert_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG", + r#"{"url":"https://alerts.example.invalid/exchange","authorization":"Bearer exchange","timeout_secs":8}"#, + ); + + let config = content_federated_operator_alert_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://alerts.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer exchange"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_federated_operator_alert_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_AUTHORIZATION", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL", + "http://127.0.0.1:9799/alerts/exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_AUTHORIZATION", + "Bearer local-exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = content_federated_operator_alert_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/alerts/exchange"); + assert_eq!(config["authorization"], "Bearer local-exchange"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_federated_quota_ledger_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG", + r#"{"url":"https://quota.example.invalid/exchange","authorization":"Bearer quota","timeout_secs":8}"#, + ); + + let config = content_federated_quota_ledger_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://quota.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer quota"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_federated_quota_ledger_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_AUTHORIZATION", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL", + "http://127.0.0.1:9799/quota/exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_AUTHORIZATION", + "Bearer local-quota", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = content_federated_quota_ledger_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/quota/exchange"); + assert_eq!(config["authorization"], "Bearer local-quota"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_federated_abuse_control_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG", + r#"{"url":"https://abuse.example.invalid/exchange","authorization":"Bearer abuse","timeout_secs":8}"#, + ); + + let config = content_federated_abuse_control_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://abuse.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer abuse"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_federated_abuse_control_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_AUTHORIZATION", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL", + "http://127.0.0.1:9799/abuse/exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_AUTHORIZATION", + "Bearer local-abuse", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = content_federated_abuse_control_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/abuse/exchange"); + assert_eq!(config["authorization"], "Bearer local-abuse"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_storage_market_admission_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG", + r#"{"url":"https://market.example.invalid/admission","authorization":"Bearer market","timeout_secs":8}"#, + ); + + let config = content_storage_market_admission_config_from_env().unwrap(); + assert_eq!(config["url"], "https://market.example.invalid/admission"); + assert_eq!(config["authorization"], "Bearer market"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_storage_market_admission_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_AUTHORIZATION", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL", + "http://127.0.0.1:9799/market/admission", + ); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_AUTHORIZATION", + "Bearer local-market", + ); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_TIMEOUT_SECS", + "11", + ); + + let config = content_storage_market_admission_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/market/admission"); + assert_eq!(config["authorization"], "Bearer local-market"); + assert_eq!(config["timeout_secs"], 11); + } + + #[test] + fn content_external_repair_fleet_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG", + r#"{"url":"https://repair.example.invalid/dispatch","authorization":"Bearer fleet","timeout_secs":8}"#, + ); + + let config = content_external_repair_fleet_config_from_env().unwrap(); + assert_eq!(config["url"], "https://repair.example.invalid/dispatch"); + assert_eq!(config["authorization"], "Bearer fleet"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_external_repair_fleet_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_AUTHORIZATION", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL", + "http://127.0.0.1:9799/repair/dispatch", + ); + std::env::set_var( + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_AUTHORIZATION", + "Bearer local-fleet", + ); + std::env::set_var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_TIMEOUT_SECS", "11"); + + let config = content_external_repair_fleet_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/repair/dispatch"); + assert_eq!(config["authorization"], "Bearer local-fleet"); + assert_eq!(config["timeout_secs"], 11); + } + + #[test] + fn carrier_peer_attestation_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG", + r#"{"url":"https://attest.example.invalid/exchange","authorization":"Bearer attest","timeout_secs":8}"#, + ); + + let config = carrier_peer_attestation_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://attest.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer attest"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn carrier_peer_attestation_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_AUTHORIZATION", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL", + "http://127.0.0.1:9799/peer-attestation/exchange", + ); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_AUTHORIZATION", + "Bearer local-attest", + ); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = carrier_peer_attestation_exchange_config_from_env().unwrap(); + assert_eq!( + config["url"], + "http://127.0.0.1:9799/peer-attestation/exchange" + ); + assert_eq!(config["authorization"], "Bearer local-attest"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_repair_scheduler_is_opt_in() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + CONTENT_REPAIR_SCHEDULER_ENV, + CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, + CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, + CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, + CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, + CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV, + ]); + + assert!(content_repair_scheduler_config_from_env().is_none()); + } + + #[test] + fn content_repair_scheduler_config_clamps_operator_env() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + CONTENT_REPAIR_SCHEDULER_ENV, + CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, + CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, + CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, + CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, + CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV, + ]); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_ENV, "true"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, "5"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, "1000"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, "999"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, "999"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV, "yes"); + + let config = content_repair_scheduler_config_from_env().unwrap(); + assert_eq!( + config.interval_secs, + CONTENT_REPAIR_SCHEDULER_MIN_INTERVAL_SECS + ); + assert_eq!(config.limit, 100); + assert_eq!(config.max_attempts, 25); + assert_eq!(config.failure_budget, 100); + assert!(config.include_healthy_check); + } + #[test] fn exit_provider_config_prefers_explicit_json() { let _lock = ENV_LOCK.lock().unwrap(); @@ -1041,6 +1947,27 @@ mod tests { ); } + #[test] + fn operator_drive_adapter_config_prefers_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&["ELASTOS_OPERATOR_DRIVE_ADAPTER_CONFIG"]); + std::env::set_var( + "ELASTOS_OPERATOR_DRIVE_ADAPTER_CONFIG", + r#"{"operator_endpoint":{"url":"http://127.0.0.1:9797/operator-drive","authorization":"Bearer secret"}}"#, + ); + + let data_dir = crate::sources::default_data_dir(); + let config = operator_drive_adapter_config_from_env(&data_dir).unwrap(); + assert_eq!( + config["operator_endpoint"]["url"], + "http://127.0.0.1:9797/operator-drive" + ); + assert_eq!( + config["operator_endpoint"]["authorization"], + "Bearer secret" + ); + } + #[test] fn browser_local_exit_config_prefers_explicit_json() { let _lock = ENV_LOCK.lock().unwrap(); From 47600b847b7644e9ae0bfe24d49dfe02dbe5dca5 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:44:46 +0000 Subject: [PATCH 02/18] feat(library): add object-provider backed file manager Replace the static Library capsule with a PC2-familiar file-manager surface backed by the Runtime object-provider API. This includes source-split Library UI code, icons, navigation, selection, upload/download, rename/create/delete/trash, publish/share/status/properties hooks, and object CID metadata. Add the standalone object-provider capsule and boundary tests while keeping publish/share availability authority separated through Runtime/content-provider coordination. --- capsules/library/capsule.json | 5 +- capsules/library/icons/SOURCE.md | 12 + capsules/library/icons/arrow-left.svg | 8 + capsules/library/icons/arrow-right.svg | 8 + capsules/library/icons/arrow-up.svg | 2 + capsules/library/icons/file-audio.svg | 1 + capsules/library/icons/file-image.svg | 12 + capsules/library/icons/file-json.svg | 21 + capsules/library/icons/file-md.svg | 16 + capsules/library/icons/file-pdf.svg | 69 + capsules/library/icons/file-text.svg | 10 + capsules/library/icons/file-video.svg | 11 + capsules/library/icons/file-zip.svg | 20 + capsules/library/icons/file.svg | 9 + capsules/library/icons/folder.svg | 58 + capsules/library/icons/folders.svg | 58 + capsules/library/icons/layout-details.svg | 15 + capsules/library/icons/layout-icons.svg | 23 + capsules/library/icons/layout-list.svg | 15 + .../library/icons/sidebar-folder-desktop.svg | 8 + .../icons/sidebar-folder-documents.svg | 1 + .../library/icons/sidebar-folder-home.svg | 1 + .../library/icons/sidebar-folder-pictures.svg | 1 + .../library/icons/sidebar-folder-public.svg | 1 + .../library/icons/sidebar-folder-videos.svg | 1 + capsules/library/icons/sidebar-folder.svg | 1 + capsules/library/icons/trash-full.svg | 16 + capsules/library/icons/trash.svg | 12 + capsules/library/index.html | 872 +- capsules/library/library.css | 1432 ++++ capsules/library/src/actions.js | 563 ++ capsules/library/src/api.js | 256 + capsules/library/src/app.js | 910 ++ capsules/library/src/dialog.js | 921 ++ capsules/library/src/editor.js | 126 + capsules/library/src/events.js | 309 + capsules/library/src/menu.js | 158 + capsules/library/src/model.js | 209 + capsules/library/src/navigation.js | 152 + capsules/library/src/preview.js | 86 + capsules/library/src/realtime.js | 85 + capsules/library/src/render.js | 238 + capsules/library/src/selection.js | 103 + capsules/library/src/state.js | 182 + capsules/library/src/uploads.js | 54 + capsules/object-provider/Cargo.lock | 7385 +++++++++++++++++ capsules/object-provider/Cargo.toml | 31 + capsules/object-provider/capsule.json | 28 + capsules/object-provider/src/main.rs | 182 + elastos/crates/elastos-server/src/library.rs | 6551 +++++++++++++++ 50 files changed, 20423 insertions(+), 825 deletions(-) create mode 100644 capsules/library/icons/SOURCE.md create mode 100644 capsules/library/icons/arrow-left.svg create mode 100644 capsules/library/icons/arrow-right.svg create mode 100644 capsules/library/icons/arrow-up.svg create mode 100644 capsules/library/icons/file-audio.svg create mode 100644 capsules/library/icons/file-image.svg create mode 100644 capsules/library/icons/file-json.svg create mode 100644 capsules/library/icons/file-md.svg create mode 100644 capsules/library/icons/file-pdf.svg create mode 100644 capsules/library/icons/file-text.svg create mode 100644 capsules/library/icons/file-video.svg create mode 100644 capsules/library/icons/file-zip.svg create mode 100644 capsules/library/icons/file.svg create mode 100644 capsules/library/icons/folder.svg create mode 100644 capsules/library/icons/folders.svg create mode 100644 capsules/library/icons/layout-details.svg create mode 100644 capsules/library/icons/layout-icons.svg create mode 100644 capsules/library/icons/layout-list.svg create mode 100644 capsules/library/icons/sidebar-folder-desktop.svg create mode 100644 capsules/library/icons/sidebar-folder-documents.svg create mode 100644 capsules/library/icons/sidebar-folder-home.svg create mode 100644 capsules/library/icons/sidebar-folder-pictures.svg create mode 100644 capsules/library/icons/sidebar-folder-public.svg create mode 100644 capsules/library/icons/sidebar-folder-videos.svg create mode 100644 capsules/library/icons/sidebar-folder.svg create mode 100644 capsules/library/icons/trash-full.svg create mode 100644 capsules/library/icons/trash.svg create mode 100644 capsules/library/library.css create mode 100644 capsules/library/src/actions.js create mode 100644 capsules/library/src/api.js create mode 100644 capsules/library/src/app.js create mode 100644 capsules/library/src/dialog.js create mode 100644 capsules/library/src/editor.js create mode 100644 capsules/library/src/events.js create mode 100644 capsules/library/src/menu.js create mode 100644 capsules/library/src/model.js create mode 100644 capsules/library/src/navigation.js create mode 100644 capsules/library/src/preview.js create mode 100644 capsules/library/src/realtime.js create mode 100644 capsules/library/src/render.js create mode 100644 capsules/library/src/selection.js create mode 100644 capsules/library/src/state.js create mode 100644 capsules/library/src/uploads.js create mode 100644 capsules/object-provider/Cargo.lock create mode 100644 capsules/object-provider/Cargo.toml create mode 100644 capsules/object-provider/capsule.json create mode 100644 capsules/object-provider/src/main.rs create mode 100644 elastos/crates/elastos-server/src/library.rs diff --git a/capsules/library/capsule.json b/capsules/library/capsule.json index 7db851e0..afafe464 100644 --- a/capsules/library/capsule.json +++ b/capsules/library/capsule.json @@ -2,11 +2,14 @@ "schema": "elastos.capsule/v1", "name": "library", "version": "0.1.0", - "description": "Browse documents", + "description": "PC2-style Explorer for local files, folders, and published objects through the Runtime object provider", "role": "app", "type": "data", "author": "elastos", "entrypoint": "index.html", + "requires": [ + { "name": "object-provider", "kind": "capsule" } + ], "resources": { "memory_mb": 16, "gpu": false diff --git a/capsules/library/icons/SOURCE.md b/capsules/library/icons/SOURCE.md new file mode 100644 index 00000000..30400f08 --- /dev/null +++ b/capsules/library/icons/SOURCE.md @@ -0,0 +1,12 @@ +# Explorer Icon Assets + +These SVG assets are copied from the PC2 reference repo to preserve Explorer +visual parity while the Runtime implementation stays on typed provider rails. + +- Source repo: `Elacity/pc2.net` +- Source baseline: `main` at `a0a910158bd67666a6d3ea2a775ce09005ba7ae7` +- Source path: `src/gui/src/icons/` + +Only static icon assets are mirrored here. PC2 runtime modules, Puter authority +logic, generated bundles, direct IPFS/Kubo calls, and filesystem shortcuts are +not imported into ElastOS Runtime. diff --git a/capsules/library/icons/arrow-left.svg b/capsules/library/icons/arrow-left.svg new file mode 100644 index 00000000..a4a1fa03 --- /dev/null +++ b/capsules/library/icons/arrow-left.svg @@ -0,0 +1,8 @@ + + arrow left + + + + + \ No newline at end of file diff --git a/capsules/library/icons/arrow-right.svg b/capsules/library/icons/arrow-right.svg new file mode 100644 index 00000000..618b77c4 --- /dev/null +++ b/capsules/library/icons/arrow-right.svg @@ -0,0 +1,8 @@ + + arrow right + + + + + \ No newline at end of file diff --git a/capsules/library/icons/arrow-up.svg b/capsules/library/icons/arrow-up.svg new file mode 100644 index 00000000..2fa50eb6 --- /dev/null +++ b/capsules/library/icons/arrow-up.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/capsules/library/icons/file-audio.svg b/capsules/library/icons/file-audio.svg new file mode 100644 index 00000000..7194cdf5 --- /dev/null +++ b/capsules/library/icons/file-audio.svg @@ -0,0 +1 @@ +file audio \ No newline at end of file diff --git a/capsules/library/icons/file-image.svg b/capsules/library/icons/file-image.svg new file mode 100644 index 00000000..5c5482a7 --- /dev/null +++ b/capsules/library/icons/file-image.svg @@ -0,0 +1,12 @@ + + file image + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/file-json.svg b/capsules/library/icons/file-json.svg new file mode 100644 index 00000000..5d43dc9a --- /dev/null +++ b/capsules/library/icons/file-json.svg @@ -0,0 +1,21 @@ + + file json + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/file-md.svg b/capsules/library/icons/file-md.svg new file mode 100644 index 00000000..30ecde68 --- /dev/null +++ b/capsules/library/icons/file-md.svg @@ -0,0 +1,16 @@ + + file md + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/file-pdf.svg b/capsules/library/icons/file-pdf.svg new file mode 100644 index 00000000..73d14ca0 --- /dev/null +++ b/capsules/library/icons/file-pdf.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/capsules/library/icons/file-text.svg b/capsules/library/icons/file-text.svg new file mode 100644 index 00000000..8a5c3b09 --- /dev/null +++ b/capsules/library/icons/file-text.svg @@ -0,0 +1,10 @@ + + file text + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/file-video.svg b/capsules/library/icons/file-video.svg new file mode 100644 index 00000000..5edc2bb2 --- /dev/null +++ b/capsules/library/icons/file-video.svg @@ -0,0 +1,11 @@ + + file play + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/file-zip.svg b/capsules/library/icons/file-zip.svg new file mode 100644 index 00000000..85756e1d --- /dev/null +++ b/capsules/library/icons/file-zip.svg @@ -0,0 +1,20 @@ + + zipped file + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/file.svg b/capsules/library/icons/file.svg new file mode 100644 index 00000000..bbdbfbfc --- /dev/null +++ b/capsules/library/icons/file.svg @@ -0,0 +1,9 @@ + + file 2 + + + + + \ No newline at end of file diff --git a/capsules/library/icons/folder.svg b/capsules/library/icons/folder.svg new file mode 100644 index 00000000..3acd82d1 --- /dev/null +++ b/capsules/library/icons/folder.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/folders.svg b/capsules/library/icons/folders.svg new file mode 100644 index 00000000..93d871ae --- /dev/null +++ b/capsules/library/icons/folders.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/layout-details.svg b/capsules/library/icons/layout-details.svg new file mode 100644 index 00000000..819f065e --- /dev/null +++ b/capsules/library/icons/layout-details.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/layout-icons.svg b/capsules/library/icons/layout-icons.svg new file mode 100644 index 00000000..1b0cf570 --- /dev/null +++ b/capsules/library/icons/layout-icons.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/layout-list.svg b/capsules/library/icons/layout-list.svg new file mode 100644 index 00000000..e40011a0 --- /dev/null +++ b/capsules/library/icons/layout-list.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/capsules/library/icons/sidebar-folder-desktop.svg b/capsules/library/icons/sidebar-folder-desktop.svg new file mode 100644 index 00000000..24677a1e --- /dev/null +++ b/capsules/library/icons/sidebar-folder-desktop.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/capsules/library/icons/sidebar-folder-documents.svg b/capsules/library/icons/sidebar-folder-documents.svg new file mode 100644 index 00000000..9e0df0a5 --- /dev/null +++ b/capsules/library/icons/sidebar-folder-documents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/capsules/library/icons/sidebar-folder-home.svg b/capsules/library/icons/sidebar-folder-home.svg new file mode 100644 index 00000000..ce7ab669 --- /dev/null +++ b/capsules/library/icons/sidebar-folder-home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/capsules/library/icons/sidebar-folder-pictures.svg b/capsules/library/icons/sidebar-folder-pictures.svg new file mode 100644 index 00000000..89527967 --- /dev/null +++ b/capsules/library/icons/sidebar-folder-pictures.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/capsules/library/icons/sidebar-folder-public.svg b/capsules/library/icons/sidebar-folder-public.svg new file mode 100644 index 00000000..cd565307 --- /dev/null +++ b/capsules/library/icons/sidebar-folder-public.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/capsules/library/icons/sidebar-folder-videos.svg b/capsules/library/icons/sidebar-folder-videos.svg new file mode 100644 index 00000000..88a24aeb --- /dev/null +++ b/capsules/library/icons/sidebar-folder-videos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/capsules/library/icons/sidebar-folder.svg b/capsules/library/icons/sidebar-folder.svg new file mode 100644 index 00000000..a18678dd --- /dev/null +++ b/capsules/library/icons/sidebar-folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/capsules/library/icons/trash-full.svg b/capsules/library/icons/trash-full.svg new file mode 100644 index 00000000..cde1b517 --- /dev/null +++ b/capsules/library/icons/trash-full.svg @@ -0,0 +1,16 @@ + + trash-svg + + + + + + + + + + diff --git a/capsules/library/icons/trash.svg b/capsules/library/icons/trash.svg new file mode 100644 index 00000000..56d052d2 --- /dev/null +++ b/capsules/library/icons/trash.svg @@ -0,0 +1,12 @@ + + trash-svg + + + + + + diff --git a/capsules/library/index.html b/capsules/library/index.html index 0603685a..a5c8e084 100644 --- a/capsules/library/index.html +++ b/capsules/library/index.html @@ -3,847 +3,71 @@ - Library · ElastOS - + Explorer · ElastOS + - + diff --git a/capsules/library/library.css b/capsules/library/library.css new file mode 100644 index 00000000..fd2616c3 --- /dev/null +++ b/capsules/library/library.css @@ -0,0 +1,1432 @@ + :root { + color-scheme: light; + --bg: #f6f7f9; + --sidebar-bg: #f0f1f4; + --panel: #ffffff; + --panel-strong: #ffffff; + --panel-soft: #f3f4f6; + --line: rgba(60, 60, 67, 0.14); + --line-strong: rgba(60, 60, 67, 0.26); + --ink: #1d1d1f; + --muted: #6b6b6b; + --brand: #f6921a; + --brand-soft: rgba(246, 146, 26, 0.16); + --accent: #007aff; + --accent-soft: rgba(0, 122, 255, 0.11); + --accent-deep: #0064d2; + --good: #2f8f68; + --warn: #b57918; + --danger: #b14c5a; + --shadow: none; + --radius-xl: 0; + --radius-lg: 6px; + --radius-md: 6px; + --mono: "SFMono-Regular", "SF Mono", "Cascadia Code", "JetBrains Mono", monospace; + --explorer-list-columns: 30px minmax(260px, 1fr) 170px 86px 180px; + font-family: "Segoe UI", "Inter", "Helvetica Neue", sans-serif; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + color: var(--ink); + } + + button, + input, + select { + font: inherit; + } + + button { + color: inherit; + } + + .hidden { + display: none !important; + } + + .locked-shell { + min-height: 100vh; + min-height: 100dvh; + display: grid; + place-items: center; + padding: 24px; + } + + .locked-card, + .sidebar, + .main, + .dialog-card { + border: 1px solid var(--line); + background: var(--panel); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + } + + .locked-card { + max-width: 520px; + padding: 30px; + } + + .shell { + height: 100vh; + height: 100dvh; + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 0; + padding: 0; + overflow: hidden; + background: var(--panel); + } + + .sidebar { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; + padding: 15px 10px; + overflow-y: auto; + border: 0; + border-right: 0.5px solid #ccc; + background: var(--sidebar-bg); + border-radius: 0; + box-shadow: inset -2px 0 5px -2px rgba(0, 0, 0, 0.24); + } + + .main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 0; + background: var(--panel); + } + + .eyebrow { + margin: 0 0 5px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + } + + h1, + h2, + p { + margin: 0; + } + + h1 { + font-size: 1.45rem; + letter-spacing: -0.04em; + } + + h2 { + font-size: 1.18rem; + letter-spacing: -0.03em; + } + + .subtitle, + .muted { + color: var(--muted); + line-height: 1.45; + } + + .button-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + } + + .btn { + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(255, 255, 255, 0.84); + padding: 6px 10px; + cursor: pointer; + font-size: 0.84rem; + font-weight: 600; + transition: background 150ms ease, border-color 150ms ease; + white-space: nowrap; + } + + .btn:hover { + border-color: var(--line-strong); + } + + .btn-primary { + border-color: transparent; + background: var(--ink); + color: #f8fbff; + } + + .btn-danger { + color: var(--danger); + } + + .search { + width: 100%; + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(255, 255, 255, 0.82); + padding: 7px 9px; + color: var(--ink); + outline: none; + } + + .search:focus { + border-color: rgba(95, 118, 216, 0.45); + box-shadow: 0 0 0 3px rgba(95, 118, 216, 0.12); + } + + .places { + display: grid; + gap: 2px; + min-height: 0; + overflow: visible; + padding-right: 1px; + } + + .place { + width: 100%; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 7px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + margin: 1px 0 4px; + padding: 4px 5px; + cursor: pointer; + text-align: left; + color: var(--ink); + font-size: 14px; + user-select: none; + backface-visibility: hidden; + } + + .place[draggable="true"]:hover { + cursor: grab; + } + + .places[data-reordering="true"] .place, + .place.window-sidebar-item-dragging { + cursor: grabbing; + transition: none; + } + + .place.window-sidebar-item-dragging { + opacity: 0.72; + } + + .place[data-drop-position="before"] { + box-shadow: inset 0 2px 0 var(--accent); + } + + .place[data-drop-position="after"] { + box-shadow: inset 0 -2px 0 var(--accent); + } + + .place:hover, + .place[data-active="true"] { + border-color: transparent; + background: rgba(0, 0, 0, 0.05); + } + + .place[data-active="true"] { + color: var(--accent); + background: #e5e7eb; + } + + .place[data-active="true"] .place-icon img, + .place[data-active="true"] .place-icon svg { + filter: brightness(0) saturate(100%) invert(36%) sepia(99%) saturate(1942%) hue-rotate(192deg) brightness(101%) contrast(104%); + } + + .place-icon, + .file-icon { + display: grid; + place-items: center; + width: 18px; + height: 18px; + font-size: 15px; + filter: drop-shadow(0 0 0.2px rgba(51, 51, 51, 0.35)); + } + + .place-icon img, + .place-icon svg { + width: 16px; + height: 16px; + display: block; + } + + .inline-icon { + display: grid; + place-items: center; + pointer-events: none; + } + + .inline-icon img, + .inline-icon svg { + width: 100%; + height: 100%; + display: block; + } + + .place-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + } + + .window-sidebar-title { + margin: 0 0 5px; + padding-left: 1px; + color: var(--muted); + cursor: default; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .toolbar { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + border-bottom: 1px solid var(--line); + min-height: 48px; + padding: 5px 5px 5px 1px; + margin-bottom: 0; + background: #fafafa; + } + + .toolbar-left, + .toolbar-right { + min-width: 0; + display: flex; + gap: 6px; + align-items: center; + } + + .toolbar-left { + flex: 1 1 auto; + } + + .toolbar-right { + justify-content: flex-end; + flex: 0 1 auto; + } + + .toolbar .search { + width: 150px; + padding: 5px 8px; + } + + .navbar-btn { + width: 25px; + height: 25px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--ink); + cursor: pointer; + display: grid; + place-items: center; + margin: 0 2px; + font-size: 16px; + font-weight: 700; + line-height: 1; + } + + .navbar-btn:hover:not(:disabled) { + background: #e5e7eb; + } + + .navbar-btn:disabled { + cursor: default; + opacity: 0.38; + } + + .navbar-btn img, + .navbar-btn .inline-icon { + width: 17px; + height: 17px; + display: block; + pointer-events: none; + } + + .pathbar { + min-width: 180px; + flex: 1 1 auto; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 3px; + background: #fbfbfc; + min-height: 35px; + padding: 0 10px; + } + + .breadcrumbs { + display: flex; + flex-wrap: nowrap; + gap: 0; + align-items: center; + min-width: 0; + height: 33px; + overflow: hidden; + } + + .crumb { + border: 0; + background: transparent; + border-radius: 5px; + padding: 0 7px; + cursor: pointer; + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + font-size: 14px; + line-height: 33px; + } + + .crumb-current { + background: transparent; + color: var(--ink); + cursor: default; + } + + .path-seperator { + color: var(--muted); + cursor: default; + font-size: 13px; + line-height: 33px; + opacity: 0.7; + user-select: none; + } + + .view-controls { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .select { + border: 1px solid var(--line); + border-radius: 7px; + background: #fff; + padding: 5px 8px; + color: var(--ink); + font-weight: 600; + } + + .window-navbar-layout-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + border-radius: 6px; + background: #e5e7eb; + padding: 2px; + } + + .layout-toggle-segment { + width: 28px; + height: 26px; + display: grid; + place-items: center; + border: 0; + border-radius: 5px; + background: transparent; + cursor: pointer; + padding: 0; + } + + .layout-toggle-segment img, + .layout-toggle-segment .inline-icon { + width: 15px; + height: 15px; + display: block; + pointer-events: none; + } + + .layout-toggle-segment-active { + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + } + + .content { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 7px 0 12px; + background: #fff; + } + + .content[data-view="grid"] { + display: grid; + grid-template-columns: repeat(auto-fill, 120px); + align-content: start; + gap: 0; + padding: 5px 0 0; + } + + .content[data-view="list"] { + display: block; + align-content: start; + overflow-x: auto; + } + + .content[data-empty="true"] { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + place-items: center; + padding: 0; + } + + .item { + position: relative; + border: 0; + background: transparent; + border-radius: 3px; + cursor: default; + user-select: none; + min-width: 0; + } + + .content[data-view="grid"] .item { + display: grid; + grid-template-rows: 45px auto 18px; + gap: 4px; + justify-items: center; + width: 110px; + height: 90px; + min-height: 90px; + margin: 15px 5px 18px; + padding: 0; + text-align: center; + } + + .content[data-view="list"] .item { + display: grid; + grid-template-columns: var(--explorer-list-columns); + grid-template-rows: 24px; + gap: 0; + align-items: center; + min-width: 726px; + height: 24px; + padding: 0; + text-align: left; + } + + .content[data-view="list"] .item:nth-child(2n+3):not([data-selected="true"]) { + background: #f7f8fa; + } + + .content[data-view="list"] .item[data-selected="true"] { + background: var(--accent); + color: var(--ink); + } + + .content[data-view="grid"] .item[data-selected="true"] .item-name { + background: var(--brand); + color: #fff; + } + + .content[data-view="grid"] .item[data-selected="true"] .file-icon { + background: rgba(212, 212, 212, 0.19); + border-radius: 3px; + filter: drop-shadow(0 0 1px rgba(102, 102, 102, 1)); + } + + .content[data-view="grid"] .file-icon { + width: 45px; + height: 45px; + line-height: 1; + } + + .content[data-view="grid"] .file-icon img, + .content[data-view="grid"] .file-icon svg { + max-width: 45px; + max-height: 45px; + display: block; + } + + .content[data-view="list"] .file-icon { + grid-column: 1; + grid-row: 1; + justify-self: center; + width: 15px; + height: 15px; + } + + .content[data-view="list"] .file-icon img, + .content[data-view="list"] .file-icon svg { + max-width: 15px; + max-height: 15px; + display: block; + } + + .item-name { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + font-weight: 500; + border-radius: 4px; + padding: 2px 4px; + color: var(--ink); + } + + .content[data-view="grid"] .item-name { + max-width: 110px; + text-align: center; + } + + .content[data-view="list"] .item-name { + grid-column: 2; + grid-row: 1; + align-self: center; + max-width: 100%; + white-space: nowrap; + height: 24px; + line-height: 24px; + padding: 0 86px 0 0; + } + + .content[data-view="list"] .item[data-selected="true"] .item-name { + color: var(--ink); + } + + .content[data-view="list"] .item[data-selected="true"] .item-size, + .content[data-view="list"] .item[data-selected="true"] .item-date, + .content[data-view="list"] .item[data-selected="true"] .item-type { + color: var(--muted); + } + + .rename-input { + width: 100%; + border: 1px solid rgba(0, 122, 255, 0.45); + border-radius: 4px; + padding: 2px 4px; + color: var(--ink); + background: #fff; + outline: none; + text-align: inherit; + font-size: 13px; + font-weight: 500; + } + + .item-meta, + .item-size, + .item-date, + .item-type { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 12px; + } + + .content[data-view="grid"] .item-size, + .content[data-view="grid"] .item-date, + .content[data-view="grid"] .item-type { + display: none; + } + + .content[data-view="list"] .item-date { + grid-column: 3; + grid-row: 1; + padding: 0 10px; + } + + .content[data-view="list"] .item-size { + grid-column: 4; + grid-row: 1; + padding: 0 10px; + text-align: right; + } + + .content[data-view="list"] .item-type { + grid-column: 5; + grid-row: 1; + padding: 0 10px; + } + + .badges { + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .content[data-view="grid"] .badges { + grid-row: 3; + max-width: 110px; + align-self: start; + } + + .content[data-view="list"] .badges { + grid-column: 2; + grid-row: 1; + justify-self: end; + align-self: center; + max-width: 82px; + height: 24px; + margin-right: 8px; + overflow: hidden; + } + + .badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 5px; + background: rgba(60, 60, 67, 0.08); + color: var(--muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1.2; + white-space: nowrap; + } + + .badge-published { + background: rgba(47, 143, 104, 0.12); + color: var(--good); + } + + .badge-public-folder { + background: rgba(38, 112, 153, 0.12); + color: #0b5e8e; + } + + .badge-trash { + background: rgba(177, 76, 90, 0.12); + color: var(--danger); + } + + .badge-blocked { + background: rgba(181, 121, 24, 0.14); + color: var(--warn); + } + + .explore-table-headers { + display: grid; + grid-template-columns: var(--explorer-list-columns); + min-width: 726px; + height: 25px; + align-items: center; + border-bottom: 1px solid var(--line); + background: #f9fafb; + color: #555c61; + font-size: 12px; + line-height: 25px; + position: sticky; + top: -7px; + z-index: 1; + margin-top: -7px; + margin-bottom: 8px; + } + + .explore-table-headers-th { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .explore-table-headers-th--name { + grid-column: 1 / span 2; + padding-left: 45px; + } + + .explore-table-headers-th--modified { + grid-column: 3; + padding: 0 10px; + } + + .explore-table-headers-th--size { + grid-column: 4; + padding: 0 10px; + text-align: right; + } + + .explore-table-headers-th--type { + grid-column: 5; + padding: 0 10px; + } + + .empty { + box-sizing: border-box; + display: grid; + place-items: center; + min-height: 100%; + height: 100%; + width: 100%; + padding: 28px; + text-align: center; + color: var(--muted); + } + + .empty-inner { + max-width: 380px; + margin: 0 auto; + } + + .upload-progress { + display: grid; + gap: 8px; + margin: 8px 10px 0; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.62); + padding: 10px; + } + + .upload-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(110px, 24%); + gap: 10px; + align-items: center; + color: var(--muted); + font-size: 0.82rem; + } + + .upload-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ink); + font-weight: 700; + } + + .upload-meter { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: var(--accent-soft); + } + + .upload-fill { + width: var(--progress, 0%); + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--brand)); + transition: width 160ms ease; + } + + .statusbar { + flex: 0 0 auto; + margin-top: auto; + display: flex; + justify-content: space-between; + gap: 12px; + min-height: 38px; + border-top: 1px solid rgba(83, 103, 164, 0.1); + padding: 6px 12px 14px; + color: var(--muted); + font-size: 13px; + min-width: 0; + background: #fafafa; + user-select: none; + } + + .statusbar span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .context-menu { + position: fixed; + z-index: 80; + width: 218px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow: visible; + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: 7px; + background: rgba(248, 248, 248, 0.98); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22); + padding: 5px 0; + color: #1f2328; + white-space: normal; + user-select: none; + scrollbar-width: thin; + } + + .menu-entry { + position: relative; + } + + .menu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + border: 0; + border-radius: 0; + background: transparent; + min-height: 25px; + padding: 4px 12px 4px 18px; + cursor: default; + text-align: left; + font-size: 12px; + font-weight: 400; + line-height: 17px; + white-space: nowrap; + color: inherit; + } + + .menu-item:hover { + background: #0a84ff; + color: #fff; + } + + .menu-item-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .menu-item-mark { + flex: 0 0 auto; + color: rgba(31, 35, 40, 0.56); + font-size: 12px; + } + + .menu-item-arrow { + font-size: 15px; + line-height: 12px; + } + + .menu-item:hover .menu-item-mark { + color: rgba(255, 255, 255, 0.74); + } + + .menu-item[disabled], + .menu-item[disabled]:hover { + cursor: default; + opacity: 0.5; + background: transparent; + color: var(--ink); + } + + .menu-divider { + height: 1px; + margin: 4px 8px; + background: rgba(0, 0, 0, 0.12); + } + + .menu-divider hr { + display: none; + } + + .menu-submenu { + position: absolute; + top: -5px; + left: calc(100% - 4px); + display: none; + width: 218px; + max-width: calc(100vw - 16px); + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: 7px; + background: rgba(248, 248, 248, 0.98); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22); + padding: 5px 0; + color: #1f2328; + z-index: 81; + } + + .context-menu[data-submenu-side="left"] .menu-submenu { + left: auto; + right: calc(100% - 4px); + } + + .menu-entry:hover > .menu-submenu, + .menu-entry:focus-within > .menu-submenu, + .menu-entry[data-open="true"] > .menu-submenu { + display: block; + } + + .dialog-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + background: rgba(29, 36, 56, 0.18); + backdrop-filter: blur(4px); + padding: 18px; + } + + .dialog-card { + width: min(560px, 100%); + max-height: calc(100vh - 36px); + max-height: calc(100dvh - 36px); + padding: 20px; + display: grid; + gap: 14px; + overflow: auto; + overscroll-behavior: contain; + } + + .dialog-card form { + display: grid; + gap: 14px; + } + + .dialog-card-wide { + width: min(760px, 100%); + } + + .dialog-card-wide .details-json pre { + max-height: min(20vh, 180px); + } + + .properties-card.window-item-properties { + width: min(450px, 100%); + padding: 0; + display: block; + overflow: hidden; + background: rgb(241 242 246); + border-radius: 6px; + } + + .properties-window-title { + min-height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + color: #1d1d1f; + font-size: 0.83rem; + font-weight: 600; + border-bottom: 1px solid rgba(60, 60, 67, 0.16); + background: linear-gradient(180deg, #f8f8f9, #eceef3); + } + + .properties-window-title button { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 1px solid rgba(60, 60, 67, 0.2); + border-radius: 999px; + background: #fff; + color: #3f4650; + cursor: pointer; + line-height: 1; + } + + .item-props-tabview { + display: flex; + flex-direction: column; + min-height: 240px; + padding: 10px; + } + + .item-props-tab { + min-height: 36px; + white-space: nowrap; + } + + .item-props-tab-content { + display: none; + flex-grow: 1; + max-height: min(58vh, 430px); + margin-top: -1px; + padding: 5px 10px; + overflow: auto; + border: 1px solid #ccc; + border-radius: 3px; + background: #fff; + color: #333; + } + + .item-props-tab-content-selected { + display: block; + background: #fff; + } + + .item-props-tab-btn { + display: inline-block; + margin-right: 10px; + margin-bottom: -1px; + padding: 10px 15px; + cursor: pointer; + border: 1px solid transparent; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + color: #374653; + font-size: 0.84rem; + } + + .item-props-tab-btn:hover { + color: #1a1a1a; + } + + .item-props-tab-selected { + position: relative; + margin-bottom: -1px; + border: 1px solid #ccc; + border-bottom: none; + background: #fff; + color: #000; + } + + .item-props-tbl { + width: 100%; + border-collapse: collapse; + font-size: 13px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .item-props-tbl td { + padding: 0 0 10px; + vertical-align: top; + word-break: break-all; + } + + .item-prop-label { + width: 1%; + padding-right: 10px !important; + color: var(--muted); + font-weight: 500; + text-align: left; + white-space: nowrap; + } + + .item-prop-val { + color: var(--ink); + overflow-wrap: anywhere; + } + + .props-copy-value { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + } + + .props-copy-text { + flex: 1; + min-width: 0; + color: #1f2933; + font-family: var(--mono); + font-size: 11px; + line-height: 1.45; + white-space: normal; + word-break: break-all; + } + + .props-copy-btn { + width: 26px; + height: 24px; + display: inline-grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid #d1d5db; + border-radius: 3px; + background: #fff; + color: #374151; + cursor: pointer; + } + + .props-copy-btn:hover { + background: #f3f4f6; + } + + .props-copy-btn.copied { + border-color: #8fd19e; + background: #d4edda; + color: #155724; + } + + .item-prop-badge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.3; + } + + .item-prop-badge-public { + background: #d4edda; + color: #155724; + } + + .item-prop-badge-shared { + background: #dbeafe; + color: #1e3a8a; + } + + .item-prop-badge-staged { + background: #e0f2fe; + color: #075985; + } + + .item-prop-badge-private { + background: #eceff4; + color: #374151; + } + + .item-prop-badge-readonly { + background: #fff3cd; + color: #856404; + } + + .item-prop-badge-writable { + background: #e0f2fe; + color: #075985; + } + + .item-prop-badge-neutral { + background: #eceff4; + color: #374151; + } + + .properties-window-actions { + display: flex; + justify-content: flex-end; + padding: 0 10px 10px; + } + + .details { + display: grid; + gap: 8px; + font-family: var(--mono); + font-size: 0.84rem; + word-break: break-all; + } + + .details-json { + display: grid; + gap: 6px; + font-family: var(--mono); + font-size: 0.82rem; + color: var(--muted); + } + + .details-json pre { + max-height: min(28vh, 260px); + margin: 0; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.68); + padding: 10px; + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; + } + + .share-options { + display: grid; + gap: 8px; + } + + .share-option { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 10px; + align-items: start; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.72); + padding: 10px; + cursor: pointer; + } + + .share-option input { + margin-top: 2px; + } + + .share-option:has(input:disabled) { + cursor: not-allowed; + opacity: 0.62; + } + + .share-option span, + .dialog-field { + display: grid; + gap: 4px; + } + + .share-option small, + .dialog-hint, + .dialog-error { + color: var(--muted); + line-height: 1.4; + } + + .dialog-field span { + font-weight: 700; + font-size: 0.82rem; + } + + .dialog-field textarea { + width: 100%; + resize: vertical; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.84); + padding: 9px; + color: var(--ink); + font: inherit; + outline: none; + } + + .dialog-field textarea:focus { + border-color: rgba(0, 122, 255, 0.45); + box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1); + } + + .dialog-error { + color: var(--danger); + font-weight: 700; + } + + .preview-frame { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--panel-soft); + overflow: hidden; + } + + .preview-frame img, + .preview-frame video, + .preview-frame audio, + .preview-frame iframe { + display: block; + width: 100%; + border: 0; + } + + .preview-frame img, + .preview-frame video, + .preview-frame iframe { + max-height: min(62vh, 620px); + } + + .preview-frame img { + object-fit: contain; + background: #101014; + } + + .preview-text { + max-height: min(62vh, 620px); + margin: 0; + padding: 14px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--mono); + font-size: 0.82rem; + line-height: 1.55; + } + + @media (max-width: 980px) { + .shell { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + overflow: auto; + } + + .sidebar { + overflow: visible; + } + + .places { + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); + overflow: visible; + } + } + + @media (max-width: 720px) { + .shell { + gap: 8px; + padding: 4px; + } + + .sidebar, + .main, + .locked-card { + border-radius: 14px; + box-shadow: 0 10px 26px rgba(34, 49, 88, 0.08); + } + + .sidebar, + .main { + padding: 10px; + } + + .toolbar { + display: grid; + gap: 8px; + } + + .toolbar-left, + .toolbar-right { + flex-wrap: wrap; + } + + .pathbar { + order: 10; + width: 100%; + flex-basis: 100%; + } + + .view-controls { + justify-content: flex-start; + } + + .content[data-view="grid"] { + grid-template-columns: repeat(auto-fill, 120px); + } + + .content[data-view="list"] .item { + grid-template-columns: 30px minmax(0, 1fr); + min-width: 0; + } + + .content[data-view="list"] .item-size, + .content[data-view="list"] .item-date, + .content[data-view="list"] .item-type, + .content[data-view="list"] .badges { + display: none; + } + } diff --git a/capsules/library/src/actions.js b/capsules/library/src/actions.js new file mode 100644 index 00000000..cabfe329 --- /dev/null +++ b/capsules/library/src/actions.js @@ -0,0 +1,563 @@ +import { + baseName, + canPreviewObject, + childUri, + contentCid, + hasCapability, + inTrash, + isBlockedObject, + isDirectory, + isWebSpaceUri, + parentUri, + publishedCid, + viewerOptions, +} from "./model.js"; + +export function createLibraryActions({ + clearSelection, + closeSelf, + confirmDestructive, + currentFolderReadOnly, + deliverToTarget, + downloadObjectRaw, + loadCurrentFolder, + navigate, + openPublishedUri, + openTarget, + previewObject, + providerApi, + renderUploads, + selectedObjects, + setStatus, + setUploadProgress, + showMenuForObject, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + startCreateObject, + state, + uploadObject, +}) { + async function openObject(object) { + if (!object) return; + if (isDirectory(object)) { + await navigate(object.uri); + return; + } + if (isBlockedObject(object)) { + setStatus("This object is blocked because it is not encrypted for the protected principal root."); + showProperties(object); + return; + } + if (isAttachMode()) { + await attachObject(object); + return; + } + const viewer = viewerOptions(object)[0]; + if (viewer && openWithViewer(object, viewer.id)) { + return; + } + if (canPreviewObject(object)) { + await previewObject(object); + return; + } + setStatus("No installed viewer for this object."); + showProperties(object); + } + + function openWithViewer(object, viewer) { + if (!viewer) return false; + const cid = publishedCid(object); + if (object.published && cid) { + openPublishedUri("elastos://" + cid, viewer); + return true; + } + const query = { + objectUri: object.uri, + uri: object.uri, + name: object.name || "", + mime: object.mime || "application/octet-stream", + }; + const localCid = contentCid(object); + if (localCid) query.contentCid = localCid; + if (viewer === "archive-manager" && object.metadata?.archive_support) { + query.archiveSupport = JSON.stringify(object.metadata.archive_support); + } + return openTarget(viewer, query); + } + + async function attachObject(object) { + const cid = publishedCid(object); + if (!object.published || !cid) { + setStatus("Publish this object before attaching it."); + showMenuForObject(object, window.innerWidth / 2, 120); + return; + } + if (deliverToTarget(state.returnTarget, { + type: "chat-room:attach-library-item", + uri: "elastos://" + cid, + title: object.name || "", + objectUri: object.uri, + })) { + setStatus("Attached to Chat Room."); + window.setTimeout(closeSelf, 80); + return; + } + setStatus("Open Chat Room from Home."); + } + + async function createFolder() { + if (currentFolderReadOnly()) { + setStatus("This Space is read-only."); + return; + } + startCreateObject("directory"); + } + + async function uploadFiles(files) { + const list = Array.from(files || []); + if (!list.length) return; + if (currentFolderReadOnly()) { + setStatus("This Space is read-only."); + return; + } + const batch = Date.now().toString(36); + state.uploads = list.map((file, index) => ({ + id: `${batch}:${index}`, + name: file.name, + progress: 0, + status: "Queued", + })); + renderUploads(); + try { + for (let index = 0; index < list.length; index += 1) { + const file = list[index]; + const upload = state.uploads[index]; + try { + setStatus(`Uploading ${file.name}...`); + setUploadProgress(upload.id, { status: "Preparing", progress: 4 }); + await uploadObject({ + uri: childUri(state.currentUri, file.name), + file, + mime: file.type || "application/octet-stream", + onProgress: (fraction) => { + setUploadProgress(upload.id, { + status: "Uploading through Runtime provider", + progress: Math.max(4, Math.round(fraction * 96)), + }); + }, + }); + setUploadProgress(upload.id, { + status: "Committing through Runtime provider", + progress: 98, + }); + setUploadProgress(upload.id, { status: "Complete", progress: 100 }); + } catch (error) { + setUploadProgress(upload.id, { status: error?.message || "Upload failed" }); + throw error; + } + } + setStatus(`Uploaded ${list.length} file${list.length === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } finally { + window.setTimeout(() => { + if (state.uploads.every((upload) => upload.progress >= 100)) { + state.uploads = []; + renderUploads(); + } + }, 1_200); + } + } + + async function downloadObject(object) { + const data = await downloadObjectRaw({ uri: object.uri }); + saveDownloadBlob(data.blob, data.filename || object.name || "download"); + } + + async function downloadObjectAsZip(object) { + if (!isDirectory(object)) return; + const data = await downloadObjectRaw({ uri: object.uri, archive: "zip" }); + saveDownloadBlob(data.blob, data.filename || `${object.name || "Library"}.zip`); + setStatus(`Downloaded ${object.name} as ZIP.`); + } + + async function downloadSelectedObjects() { + const objects = downloadableSelectedObjects(); + if (objects.length < 2) return; + const data = await downloadObjectRaw({ uris: objects.map((object) => object.uri) }); + saveDownloadBlob(data.blob, data.filename || "Library Selection.tar.gz"); + setStatus(`Downloaded ${objects.length} selected objects.`); + } + + async function downloadSelectedObjectsAsZip() { + const objects = downloadableSelectedObjects(); + if (objects.length < 2) return; + const data = await downloadObjectRaw({ + uris: objects.map((object) => object.uri), + archive: "zip", + }); + saveDownloadBlob(data.blob, data.filename || "Library Selection.zip"); + setStatus(`Downloaded ${objects.length} selected objects as ZIP.`); + } + + async function compressObjectToZip(object) { + if (!object || !hasCapability(object, "compress_archive")) return; + setStatus(`Compressing ${object.name}...`); + await providerApi("compress_archive", { uri: object.uri, if_revision: object.revision }); + setStatus(`Compressed ${object.name} to ZIP.`); + await loadCurrentFolder(); + } + + async function compressSelectedObjectsToZip() { + const objects = compressibleSelectedObjects(); + if (objects.length < 2) return; + setStatus(`Compressing ${objects.length} selected objects...`); + await providerApi("compress_archive", { uris: objects.map((object) => object.uri) }); + setStatus(`Compressed ${objects.length} selected objects to ZIP.`); + await loadCurrentFolder(); + } + + async function extractArchiveObject(object) { + setStatus(`Extracting ${object.name}...`); + await providerApi("extract_archive", { uri: object.uri, if_revision: object.revision }); + setStatus(`Extracted ${object.name}.`); + await loadCurrentFolder(); + } + + function downloadableSelectedObjects() { + return selectedObjects().filter((object) => ( + object && + !isBlockedObject(object) && + !inTrash(object) && + !isWebSpaceUri(object.uri) && + hasCapability(object, "download") + )); + } + + function compressibleSelectedObjects() { + const objects = selectedObjects(); + const compressible = objects.filter((object) => ( + object && + !isBlockedObject(object) && + !inTrash(object) && + !isWebSpaceUri(object.uri) && + hasCapability(object, "compress_archive") + )); + if (compressible.length !== objects.length || compressible.length < 2) return []; + const parent = parentUri(compressible[0].uri); + return compressible.every((object) => parentUri(object.uri) === parent) ? compressible : []; + } + + function saveDownloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + async function publishObject(object) { + setStatus(`Publishing ${object.name}...`); + await providerApi("publish", { uri: object.uri, if_revision: object.revision }); + setStatus(`Published ${object.name}.`); + await loadCurrentFolder(); + } + + async function unpublishObject(object) { + setStatus(`Unpublishing ${object.name}...`); + await providerApi("unpublish", { uri: object.uri, if_revision: object.revision }); + setStatus(`Unpublished ${object.name}.`); + await loadCurrentFolder(); + } + + async function repairObject(object) { + setStatus(`Repairing availability for ${object.name}...`); + await providerApi("repair", { uri: object.uri }); + setStatus(`Repaired availability for ${object.name}.`); + await loadCurrentFolder(); + } + + async function showStatusObject(object) { + setStatus(`Checking availability for ${object.name}...`); + const status = await providerApi("status", { uri: object.uri }); + showObjectStatus(status); + setStatus(`Availability ready for ${object.name}.`); + } + + async function shareObject(object) { + const decision = await showShareDialog(object); + if (!decision) return; + const payload = { uri: object.uri, policy: decision.policy }; + if (decision.policy === "recipient_scoped") { + payload.recipients = decision.recipients; + } + const share = await providerApi("share", payload); + await copyText(share.uri, "share URI"); + showShareReceipt(share); + await loadCurrentFolder(); + } + + async function checkMyAccess(object) { + const principalId = principalIdFromHomeToken(state.homeToken); + if (!principalId) { + setStatus("A signed Home principal is required to check recipient access."); + return; + } + setStatus(`Checking access for ${object.name}...`); + const access = await providerApi("shared_access", { + uri: object.uri, + recipient: principalId, + }); + showSharedAccessReceipt(access); + setStatus(`Access check ready for ${object.name}.`); + } + + async function trashObject(object) { + await providerApi("trash", { uri: object.uri, if_revision: object.revision }); + setStatus(`Moved ${object.name} to Trash.`); + await loadCurrentFolder(); + } + + async function restoreObject(object) { + const name = object.name || baseName(object.uri); + const target = childUri(parentUri(parentUri(object.uri)), name); + await providerApi("restore", { uri: object.uri, target_uri: target, if_revision: object.revision }); + setStatus(`Restored ${name}.`); + await loadCurrentFolder(); + } + + async function deleteObject(object) { + const trash = inTrash(object); + const confirmed = await confirmDestructive({ + title: "Delete permanently?", + message: trash + ? `${object.name} will be removed from Trash and cannot be restored.` + : `${object.name} will be permanently removed from this provider-owned location. This cannot be restored.`, + confirmLabel: "Delete Permanently", + }); + if (!confirmed) return; + await providerApi("delete_permanently", { uri: object.uri, if_revision: object.revision }); + setStatus(`Deleted ${object.name}.`); + await loadCurrentFolder(); + } + + async function runBatchAction(label, objects, action) { + const list = objects.filter(Boolean); + if (!list.length) return; + setStatus(`${label} ${list.length} object${list.length === 1 ? "" : "s"}...`); + for (const object of list) { + await action(object); + } + clearSelection(false); + setStatus(`${label} ${list.length} object${list.length === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } + + async function publishSelectedObjects() { + const objects = selectedObjects().filter((object) => !isDirectory(object) && !inTrash(object) && !object.published && hasCapability(object, "publish")); + await runBatchAction("Published", objects, (object) => providerApi("publish", { + uri: object.uri, + if_revision: object.revision, + })); + } + + async function unpublishSelectedObjects() { + const objects = selectedObjects().filter((object) => !isDirectory(object) && !inTrash(object) && object.published && hasCapability(object, "unpublish")); + await runBatchAction("Unpublished", objects, (object) => providerApi("unpublish", { + uri: object.uri, + if_revision: object.revision, + })); + } + + async function trashSelectedObjects() { + const objects = selectedObjects().filter((object) => !inTrash(object) && hasCapability(object, "trash")); + await runBatchAction("Moved to Trash", objects, (object) => providerApi("trash", { + uri: object.uri, + if_revision: object.revision, + })); + } + + function setClipboard(op, objects) { + const uris = objects + .filter((object) => object && !isBlockedObject(object) && !inTrash(object) && hasCapability(object, op)) + .map((object) => object.uri); + state.clipboard = { + op, + uris, + }; + setStatus(`${op === "move" ? "Cut" : "Copied"} ${uris.length} object${uris.length === 1 ? "" : "s"}.`); + } + + function canPasteInto(targetParentUri) { + return !!( + targetParentUri && + !isWebSpaceUri(targetParentUri) && + state.clipboard.uris.length && + (state.clipboard.op === "copy" || state.clipboard.op === "move") + ); + } + + async function pasteClipboardTo(targetParentUri) { + if (!canPasteInto(targetParentUri)) return; + const uris = [...state.clipboard.uris]; + const op = state.clipboard.op; + for (const uri of uris) { + if (targetParentUri === uri || targetParentUri.startsWith(uri + "/")) { + continue; + } + await providerApi(op === "move" ? "move" : "copy", { + uri, + target_parent_uri: targetParentUri, + }); + } + if (op === "move") { + state.clipboard = { op: "", uris: [] }; + } + setStatus(`${op === "move" ? "Moved" : "Copied"} ${uris.length} object${uris.length === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } + + async function transferSelectedObjectsTo(targetParentUri, op) { + if (!targetParentUri || isWebSpaceUri(targetParentUri)) return; + const capability = op === "move" ? "move" : "copy"; + const objects = selectedObjects().filter((object) => ( + object && + !isBlockedObject(object) && + !inTrash(object) && + hasCapability(object, capability) + )); + let changed = 0; + for (const object of objects) { + if (targetParentUri === object.uri || targetParentUri.startsWith(object.uri + "/")) { + continue; + } + if (op === "move" && parentUri(object.uri) === targetParentUri) { + continue; + } + await providerApi(op, { + uri: object.uri, + target_parent_uri: targetParentUri, + if_revision: object.revision, + }); + changed += 1; + } + if (changed) { + setStatus(`${op === "move" ? "Moved" : "Copied"} ${changed} object${changed === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } + } + + async function moveSelectedObjectsTo(targetParentUri) { + await transferSelectedObjectsTo(targetParentUri, "move"); + } + + async function copySelectedObjectsTo(targetParentUri) { + await transferSelectedObjectsTo(targetParentUri, "copy"); + } + + async function createTextDocument() { + if (currentFolderReadOnly()) { + setStatus("This Space is read-only."); + return; + } + startCreateObject("file"); + } + + async function restoreSelectedObjects() { + const objects = selectedObjects().filter(inTrash); + await runBatchAction("Restored", objects, (object) => { + const name = object.name || baseName(object.uri); + return providerApi("restore", { + uri: object.uri, + target_uri: childUri(parentUri(parentUri(object.uri)), name), + if_revision: object.revision, + }); + }); + } + + async function deleteSelectedObjects() { + const objects = selectedObjects().filter((object) => ( + inTrash(object) || (!inTrash(object) && hasCapability(object, "delete_permanently")) + )); + if (!objects.length) return; + const allTrash = objects.every(inTrash); + const confirmed = await confirmDestructive({ + title: "Delete permanently?", + message: allTrash + ? `${objects.length} object${objects.length === 1 ? "" : "s"} will be removed from Trash and cannot be restored.` + : `${objects.length} object${objects.length === 1 ? "" : "s"} will be permanently removed from provider-owned locations. This cannot be restored.`, + confirmLabel: "Delete Permanently", + }); + if (!confirmed) return; + await runBatchAction("Deleted", objects, (object) => providerApi("delete_permanently", { + uri: object.uri, + if_revision: object.revision, + })); + } + + async function copyText(value, label) { + await navigator.clipboard.writeText(value); + setStatus(`Copied ${label}.`); + } + + function isAttachMode() { + return state.mode === "attach" && state.returnTarget === "chat-room"; + } + + function principalIdFromHomeToken(token) { + try { + const decoded = JSON.parse(atob(base64Padding(String(token || "").replace(/-/g, "+").replace(/_/g, "/")))); + return typeof decoded?.payload?.principal_id === "string" + ? decoded.payload.principal_id.trim() + : ""; + } catch { + return ""; + } + } + + function base64Padding(value) { + const remainder = value.length % 4; + return remainder ? value + "=".repeat(4 - remainder) : value; + } + + return { + attachObject, + canPasteInto, + checkMyAccess, + compressObjectToZip, + compressSelectedObjectsToZip, + copySelectedObjectsTo, + copyText, + createFolder, + createTextDocument, + deleteObject, + deleteSelectedObjects, + downloadObject, + downloadObjectAsZip, + downloadSelectedObjects, + downloadSelectedObjectsAsZip, + extractArchiveObject, + moveSelectedObjectsTo, + openObject, + openWithViewer, + pasteClipboardTo, + publishObject, + publishSelectedObjects, + repairObject, + restoreObject, + restoreSelectedObjects, + setClipboard, + shareObject, + showStatusObject, + trashObject, + trashSelectedObjects, + unpublishObject, + unpublishSelectedObjects, + uploadFiles, + }; +} diff --git a/capsules/library/src/api.js b/capsules/library/src/api.js new file mode 100644 index 00000000..4412be7a --- /dev/null +++ b/capsules/library/src/api.js @@ -0,0 +1,256 @@ +export function createLibraryRuntime({ getHomeToken }) { + const CHUNKED_UPLOAD_THRESHOLD_BYTES = 512 * 1024; + const CHUNKED_UPLOAD_BYTES = 512 * 1024; + const CHUNKED_UPLOAD_TRANSPORT = "http-chunk-session"; + + async function providerApi(op, payload) { + const response = await fetch("/api/provider/object/" + encodeURIComponent(op), { + method: "POST", + headers: { + "content-type": "application/json", + "x-elastos-home-token": getHomeToken(), + }, + body: JSON.stringify(payload || {}), + }); + const envelope = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(envelope.message || envelope.error || `Library request failed: ${response.status}`); + } + if (envelope.status === "error") { + throw new Error(envelope.message || "Runtime object provider failed."); + } + return envelope.data || envelope; + } + + function uploadObject({ uri, file, mime, onProgress }) { + if (file?.size > CHUNKED_UPLOAD_THRESHOLD_BYTES) { + return uploadObjectChunked({ uri, file, mime, onProgress }); + } + return uploadObjectRaw({ uri, file, mime, onProgress }); + } + + function uploadObjectRaw({ uri, file, mime, onProgress }) { + return new Promise((resolve, reject) => { + const url = new URL("/api/provider/object/upload", window.location.origin); + url.searchParams.set("uri", uri); + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url.pathname + url.search); + xhr.setRequestHeader("x-elastos-home-token", getHomeToken()); + xhr.setRequestHeader("content-type", mime || file?.type || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && typeof onProgress === "function") { + onProgress(event.loaded / Math.max(event.total, 1)); + } + }; + xhr.onerror = () => reject(new Error("Library upload failed before Runtime accepted the object.")); + xhr.onload = () => { + let envelope = {}; + try { + envelope = JSON.parse(xhr.responseText || "{}"); + } catch { + envelope = { message: xhr.responseText || "" }; + } + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(uploadFailureMessage(xhr, envelope))); + return; + } + if (envelope.status === "error") { + reject(new Error(envelope.message || "Runtime object provider upload failed.")); + return; + } + resolve(envelope.data || envelope); + }; + xhr.send(file); + }); + } + + async function uploadObjectChunked({ uri, file, mime, onProgress }) { + const contentType = mime || file?.type || "application/octet-stream"; + const start = await uploadJsonRequest("/api/provider/object/upload/start", { + uri, + mime: contentType, + size_bytes: file.size, + transport: CHUNKED_UPLOAD_TRANSPORT, + }); + const uploadId = start.upload_id; + try { + let offset = 0; + while (offset < file.size) { + const end = Math.min(offset + CHUNKED_UPLOAD_BYTES, file.size); + const chunk = file.slice(offset, end, contentType); + await uploadChunkRequest({ + uploadId, + offset, + chunk, + mime: contentType, + onProgress: (loaded) => { + if (typeof onProgress === "function") { + onProgress((offset + loaded) / Math.max(file.size, 1)); + } + }, + }); + offset = end; + if (typeof onProgress === "function") { + onProgress(offset / Math.max(file.size, 1)); + } + } + return await uploadJsonRequest(`/api/provider/object/upload/${encodeURIComponent(uploadId)}/finish`, null); + } catch (error) { + await cancelUploadSession(uploadId); + throw error; + } + } + + async function uploadJsonRequest(path, payload) { + const response = await fetch(path, { + method: "POST", + headers: { + "content-type": "application/json", + "x-elastos-home-token": getHomeToken(), + }, + body: payload ? JSON.stringify(payload) : undefined, + }); + const text = await response.text(); + const envelope = parseEnvelope(text); + if (!response.ok || envelope.status === "error") { + throw new Error(uploadFailureMessage(responseLike(response, text), envelope)); + } + return envelope.data || envelope; + } + + function uploadChunkRequest({ uploadId, offset, chunk, mime, onProgress }) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", `/api/provider/object/upload/${encodeURIComponent(uploadId)}/chunk`); + xhr.setRequestHeader("x-elastos-home-token", getHomeToken()); + xhr.setRequestHeader("x-elastos-upload-offset", String(offset)); + xhr.setRequestHeader("content-type", mime || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && typeof onProgress === "function") { + onProgress(event.loaded); + } + }; + xhr.onerror = () => reject(new Error("Library chunk upload failed before Runtime accepted the object.")); + xhr.onload = () => { + const envelope = parseEnvelope(xhr.responseText || ""); + if (xhr.status < 200 || xhr.status >= 300 || envelope.status === "error") { + reject(new Error(uploadFailureMessage(xhr, envelope))); + return; + } + resolve(envelope.data || envelope); + }; + xhr.send(chunk); + }); + } + + async function cancelUploadSession(uploadId) { + if (!uploadId) return; + try { + await fetch(`/api/provider/object/upload/${encodeURIComponent(uploadId)}`, { + method: "DELETE", + headers: { + "x-elastos-home-token": getHomeToken(), + }, + }); + } catch { + // Best-effort cleanup; Runtime also expires stale provider-private sessions on new upload starts. + } + } + + function parseEnvelope(text) { + try { + return JSON.parse(text || "{}"); + } catch { + return { message: text || "" }; + } + } + + function responseLike(response, text) { + return { + status: response.status, + responseText: text, + }; + } + + function uploadFailureMessage(xhr, envelope) { + const body = String(envelope.message || envelope.error || xhr.responseText || ""); + if (xhr.status === 413 || /request entity too large|nginx/i.test(body)) { + return "Upload rejected by the public gateway body-size limit before Runtime accepted the object. Large files use Runtime chunked upload sessions; if this appears during a chunked upload, increase the edge proxy limit for /api/provider/object/upload/*/chunk or lower the configured chunk size."; + } + return body || `Library upload failed: ${xhr.status}`; + } + + async function downloadObjectRaw({ uri, uris, archive }) { + const url = new URL("/api/provider/object/download/raw", window.location.origin); + const list = Array.isArray(uris) ? uris : [uri]; + for (const item of list) { + if (item) url.searchParams.append("uri", item); + } + if (archive) { + url.searchParams.set("archive", archive); + } + const response = await fetch(url.pathname + url.search, { + method: "GET", + headers: { + "x-elastos-home-token": getHomeToken(), + }, + }); + if (!response.ok) { + throw new Error((await response.text()) || `Library download failed: ${response.status}`); + } + const blob = await response.blob(); + return { + blob, + filename: filenameFromContentDisposition(response.headers.get("content-disposition")), + requestId: response.headers.get("x-elastos-request-id") || "", + receipt: transferReceiptFromHeader(response.headers.get("x-elastos-transfer-receipt")), + }; + } + + function transferReceiptFromHeader(header) { + try { + return header ? JSON.parse(header) : null; + } catch { + return null; + } + } + + function filenameFromContentDisposition(header) { + const match = String(header || "").match(/filename="([^"]+)"/); + return match ? match[1] : ""; + } + + function shellMessage(message) { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ ...message, homeToken: getHomeToken() }, window.location.origin); + return true; + } + return false; + } + + function openTarget(target, query) { + return shellMessage({ type: "home:open-target", target, query: query || {} }); + } + + function openPublishedUri(uri, preferredViewer) { + return shellMessage({ type: "home:open-uri", uri, preferredViewer: preferredViewer || "documents" }); + } + + function deliverToTarget(target, payload) { + return shellMessage({ type: "home:deliver-to-target", target, payload: payload || {} }); + } + + function closeSelf() { + return shellMessage({ type: "home:close-self" }); + } + + return { + providerApi, + uploadObject, + downloadObjectRaw, + openTarget, + openPublishedUri, + deliverToTarget, + closeSelf, + }; +} diff --git a/capsules/library/src/app.js b/capsules/library/src/app.js new file mode 100644 index 00000000..914fd726 --- /dev/null +++ b/capsules/library/src/app.js @@ -0,0 +1,910 @@ +import { + baseName, + contentCid, + escapeHtml, + hasCapability, + inTrash, + isBlockedObject, + isDirectory, + isWebSpaceUri, + parentUri, + publishedCid, + viewerOptions, +} from "./model.js"; +import { createLibraryRuntime } from "./api.js"; +import { createLibraryActions } from "./actions.js"; +import { createLibraryDialog } from "./dialog.js"; +import { createLibraryEditor } from "./editor.js"; +import { bindLibraryEvents } from "./events.js"; +import { createLibraryMenu } from "./menu.js"; +import { createLibraryNavigation } from "./navigation.js"; +import { createLibraryPreview } from "./preview.js"; +import { createLibraryRealtime } from "./realtime.js"; +import { createLibraryRenderer, iconPlaceholder } from "./render.js"; +import { createLibrarySelection } from "./selection.js"; +import { + MUTATING_PROVIDER_OPS, + cacheFolderListing, + createLibraryState, + setLibraryObjects, + visibleObjectsForState, +} from "./state.js"; +import { createLibraryUploads } from "./uploads.js"; + + const queryParams = new URLSearchParams(window.location.search); + const { state, perf } = createLibraryState({ + queryParams, + storage: localStorage, + perfTarget: (window.__libraryPerf = window.__libraryPerf || {}), + }); + const { + providerApi: runtimeProviderApi, + uploadObject, + downloadObjectRaw, + openTarget, + openPublishedUri, + deliverToTarget, + closeSelf, + } = createLibraryRuntime({ getHomeToken: () => state.homeToken }); + + const elements = { + lockedShell: document.getElementById("locked-shell"), + libraryShell: document.getElementById("library-shell"), + places: document.getElementById("places"), + backButton: document.getElementById("back-button"), + forwardButton: document.getElementById("forward-button"), + upButton: document.getElementById("up-button"), + uploadButton: document.getElementById("upload-button"), + newFolderButton: document.getElementById("new-folder-button"), + search: document.getElementById("search"), + currentTitle: document.getElementById("current-title"), + statusText: document.getElementById("status-text"), + refreshButton: document.getElementById("refresh-button"), + gridButton: document.getElementById("grid-button"), + listButton: document.getElementById("list-button"), + sortSelect: document.getElementById("sort-select"), + breadcrumbs: document.getElementById("breadcrumbs"), + content: document.getElementById("content"), + footerLeft: document.getElementById("footer-left"), + footerRight: document.getElementById("footer-right"), + fileInput: document.getElementById("file-input"), + uploadProgress: document.getElementById("upload-progress"), + contextMenu: document.getElementById("context-menu"), + dialog: document.getElementById("dialog"), + sidebar: document.querySelector(".sidebar"), + }; + let renderContent = () => {}; + let renderFooter = () => {}; + let scheduleContentRender = () => {}; + let syncContentViewMode = () => {}; + let syncViewButtons = () => {}; + let previewObject = async () => {}; + let revokePreviewUrl = () => {}; + let startCreateObject = () => {}; + let startRename = () => {}; + let startLibraryEventStream = () => {}; + let stopLibraryEventStream = () => {}; + let canPasteInto = () => false; + let checkMyAccess = async () => {}; + let compressObjectToZip = async () => {}; + let compressSelectedObjectsToZip = async () => {}; + let copySelectedObjectsTo = async () => {}; + let copyText = async () => {}; + let createFolder = async () => {}; + let createTextDocument = async () => {}; + let deleteObject = async () => {}; + let deleteSelectedObjects = async () => {}; + let downloadObject = async () => {}; + let downloadObjectAsZip = async () => {}; + let downloadSelectedObjects = async () => {}; + let downloadSelectedObjectsAsZip = async () => {}; + let extractArchiveObject = async () => {}; + let moveSelectedObjectsTo = async () => {}; + let openObject = async () => {}; + let openWithViewer = () => false; + let pasteClipboardTo = async () => {}; + let publishObject = async () => {}; + let publishSelectedObjects = async () => {}; + let repairObject = async () => {}; + let restoreObject = async () => {}; + let restoreSelectedObjects = async () => {}; + let setClipboard = () => {}; + let shareObject = async () => {}; + let showStatusObject = async () => {}; + let trashObject = async () => {}; + let trashSelectedObjects = async () => {}; + let unpublishObject = async () => {}; + let unpublishSelectedObjects = async () => {}; + let uploadFiles = async () => {}; + const { + clearSelection, + isSelected, + objectByUri, + prepareDragSelection, + selectAllVisible, + selectedObjects, + selectOnly, + selectRangeTo, + syncSelectionDom, + toggleSelected, + } = createLibrarySelection({ + content: elements.content, + renderFooter: () => renderFooter(), + state, + visibleObjects, + }); + const { + handleBrowserPopState, + installBrowserHistory, + navigate, + navigateBack, + navigateForward, + navigateUp, + syncNavigationButtons, + } = createLibraryNavigation({ + backButton: elements.backButton, + forwardButton: elements.forwardButton, + loadCurrentFolder, + parentUri, + rootForUri, + searchInput: elements.search, + setStatus, + state, + syncRouteChrome, + upButton: elements.upButton, + }); + const { + renderUploads, + setUploadProgress, + } = createLibraryUploads({ + container: elements.uploadProgress, + perf, + state, + }); + const { + hideMenu, + menuAction, + renderMenu, + } = createLibraryMenu({ + contextMenu: elements.contextMenu, + perf, + showError, + }); + const { + bindDialogEvents, + confirmDestructive, + hideDialog, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + } = createLibraryDialog({ + copyText: (...args) => copyText(...args), + dialog: elements.dialog, + hideMenu, + objectByUri, + onBeforeClose: () => revokePreviewUrl(), + }); + ({ + previewObject, + revokePreviewUrl, + } = createLibraryPreview({ + dialog: elements.dialog, + providerApi, + setStatus, + showProperties, + state, + })); + ({ + renderContent, + renderFooter, + scheduleContentRender, + syncContentViewMode, + syncViewButtons, + } = createLibraryRenderer({ + elements, + isSelected, + perf, + selectedObjects, + state, + visibleObjects, + })); + ({ + startCreateObject, + startRename, + } = createLibraryEditor({ + content: elements.content, + loadCurrentFolder, + providerApi, + renderContent, + setObjects, + setStatus, + showError, + state, + })); + ({ + startLibraryEventStream, + stopLibraryEventStream, + } = createLibraryRealtime({ + loadCurrentFolder, + parentUri, + showError, + state, + })); + ({ + canPasteInto, + checkMyAccess, + compressObjectToZip, + compressSelectedObjectsToZip, + copySelectedObjectsTo, + copyText, + createFolder, + createTextDocument, + deleteObject, + deleteSelectedObjects, + downloadObject, + downloadObjectAsZip, + downloadSelectedObjects, + downloadSelectedObjectsAsZip, + extractArchiveObject, + moveSelectedObjectsTo, + openObject, + openWithViewer, + pasteClipboardTo, + publishObject, + publishSelectedObjects, + repairObject, + restoreObject, + restoreSelectedObjects, + setClipboard, + shareObject, + showStatusObject, + trashObject, + trashSelectedObjects, + unpublishObject, + unpublishSelectedObjects, + uploadFiles, + } = createLibraryActions({ + clearSelection, + closeSelf, + confirmDestructive, + currentFolderReadOnly, + deliverToTarget, + downloadObjectRaw, + loadCurrentFolder, + navigate, + openPublishedUri, + openTarget, + previewObject, + providerApi, + renderUploads, + selectedObjects, + setStatus, + setUploadProgress, + showMenuForObject, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + startCreateObject, + state, + uploadObject, + })); + + function isAttachMode() { + return state.mode === "attach" && state.returnTarget === "chat-room"; + } + + function setStatus(text) { + elements.statusText.textContent = text; + elements.statusText.classList.toggle("hidden", !text); + } + + async function providerApi(op, payload) { + const result = await runtimeProviderApi(op, payload); + if (MUTATING_PROVIDER_OPS.has(op)) { + state.folderCache.clear(); + state.folderPrefetches.clear(); + perf.folderCacheSize = 0; + } + return result; + } + + function setObjects(objects) { + setLibraryObjects(state, objects); + } + + function rootForUri(uri) { + return activeRootForUri(uri) || state.roots[0] || null; + } + + function activeRootForUri(uri) { + const value = String(uri || ""); + return state.roots + .filter((root) => value === root.uri || value.startsWith(root.uri + "/")) + .sort((left, right) => right.uri.length - left.uri.length)[0] || null; + } + + function currentFolderReadOnly() { + if (!isWebSpaceUri(state.currentUri)) return false; + return state.currentObject?.metadata?.readonly !== false; + } + + function syncModeChrome() { + if (isAttachMode()) { + setStatus("Choose a published object for Chat Room."); + elements.uploadButton.textContent = "Upload"; + return; + } + setStatus("Ready."); + } + + async function loadRoots() { + const data = await providerApi("roots"); + state.roots = orderRoots(Array.isArray(data.roots) ? data.roots : []); + if (!state.currentUri && state.roots.length) { + const documents = state.roots.find((root) => root.id === "documents"); + const initialRoot = state.initialUri ? rootForUri(state.initialUri) : null; + state.currentUri = initialRoot ? state.initialUri : (documents || state.roots[0]).uri; + } + renderPlaces(); + } + + function orderRoots(roots) { + const rank = new Map(state.sidebarOrder.map((key, index) => [key, index])); + return roots + .map((root, index) => ({ root, index, key: rootKey(root) })) + .sort((left, right) => { + const leftRank = rank.has(left.key) ? rank.get(left.key) : Number.MAX_SAFE_INTEGER; + const rightRank = rank.has(right.key) ? rank.get(right.key) : Number.MAX_SAFE_INTEGER; + if (leftRank !== rightRank) return leftRank - rightRank; + return left.index - right.index; + }) + .map((entry) => entry.root); + } + + function rootKey(root) { + return root?.id || root?.uri || ""; + } + + function reorderPlace(sourceRootId, targetRootId, placement = "before") { + if (!sourceRootId || !targetRootId || sourceRootId === targetRootId) return; + const sourceIndex = state.roots.findIndex((root) => rootKey(root) === sourceRootId); + const targetIndex = state.roots.findIndex((root) => rootKey(root) === targetRootId); + if (sourceIndex < 0 || targetIndex < 0) return; + const previousTops = capturePlaceTops(); + const [source] = state.roots.splice(sourceIndex, 1); + const adjustedTargetIndex = state.roots.findIndex((root) => rootKey(root) === targetRootId); + const insertIndex = placement === "after" ? adjustedTargetIndex + 1 : adjustedTargetIndex; + state.roots.splice(insertIndex, 0, source); + state.sidebarOrder = state.roots.map(rootKey).filter(Boolean); + localStorage.setItem("library.sidebarOrder", JSON.stringify(state.sidebarOrder)); + renderPlaces({ animateFrom: previousTops }); + syncPlacesActive(); + setStatus("Sidebar order saved."); + } + + async function loadCurrentFolder(options = {}) { + if (!state.currentUri) return; + const loadSeq = ++state.loadSeq; + const uri = state.currentUri; + const cached = options.useCache ? state.folderCache.get(uri) : null; + let renderedCached = false; + let renderAfterFetch = true; + if (cached) { + perf.folderCacheHits += 1; + state.loading = false; + state.currentObject = cached.object || null; + setObjects(cached.objects); + state.selectedUris.clear(); + setStatus(`${state.objects.length} object${state.objects.length === 1 ? "" : "s"}.`); + renderAll(); + await runInitialObjectAction(); + renderedCached = true; + } else { + state.loading = true; + state.currentObject = null; + setStatus("Loading..."); + } + try { + const data = await providerApi("list", { uri }); + if (loadSeq !== state.loadSeq || uri !== state.currentUri) { + return; + } + const objects = Array.isArray(data.objects) ? data.objects : []; + const currentObject = data.object || null; + const nextCache = cacheFolderListing(state, perf, uri, objects, currentObject); + state.currentObject = currentObject; + if (renderedCached && cached.signature === nextCache.signature) { + renderAfterFetch = false; + setStatus(`${state.objects.length} object${state.objects.length === 1 ? "" : "s"}.`); + return; + } + setObjects(objects); + state.selectedUris.clear(); + setStatus(`${state.objects.length} object${state.objects.length === 1 ? "" : "s"}.`); + } finally { + if (loadSeq === state.loadSeq && uri === state.currentUri) { + state.loading = false; + if (renderAfterFetch) renderAll(); + if (renderAfterFetch) await runInitialObjectAction(); + } + } + } + + async function runInitialObjectAction() { + if (state.initialActionHandled || !state.initialObjectUri || !state.initialAction) { + return; + } + const object = objectByUri(state.initialObjectUri) + || (state.currentObject?.uri === state.initialObjectUri ? state.currentObject : null); + if (!object) { + return; + } + state.initialActionHandled = true; + selectOnly(object.uri); + if (state.initialAction === "properties") { + showProperties(object); + return; + } + if (state.initialAction === "download" && hasCapability(object, "download")) { + try { + await downloadObject(object); + } catch (error) { + showError(error); + } + } + } + + function prefetchFolder(uri) { + if (!uri || state.folderCache.has(uri) || state.folderPrefetches.has(uri)) { + return Promise.resolve(); + } + const promise = providerApi("list", { uri }) + .then((data) => cacheFolderListing(state, perf, uri, data.objects, data.object || null)) + .catch(() => null) + .finally(() => { + state.folderPrefetches.delete(uri); + }); + state.folderPrefetches.set(uri, promise); + return promise; + } + + function scheduleRootPrefetch() { + if (state.rootPrefetchStarted || !state.roots.length) return; + state.rootPrefetchStarted = true; + window.setTimeout(() => { + const roots = state.roots + .filter((root) => root.uri !== state.currentUri) + .map((root) => prefetchFolder(root.uri)); + Promise.all(roots).catch(() => null); + }, 50); + } + + function renderAll() { + syncPlacesActive(); + renderBreadcrumbs(); + renderContent(); + renderFooter(); + renderUploads(); + syncViewButtons(); + syncNavigationButtons(); + } + + function renderPlaces(options = {}) { + perf.renderPlacesCount += 1; + elements.places.innerHTML = ""; + const activeRoot = activeRootForUri(state.currentUri); + for (const root of state.roots) { + const active = activeRoot?.uri === root.uri; + const button = document.createElement("button"); + button.className = active ? "place window-sidebar-item window-sidebar-item-active" : "place window-sidebar-item"; + button.type = "button"; + button.dataset.uri = root.uri; + button.dataset.rootId = rootKey(root); + button.dataset.active = active ? "true" : "false"; + button.draggable = true; + button.title = "Drag to reorder"; + button.innerHTML = ` + ${iconPlaceholder(placeIcon(root.id), "place-icon window-sidebar-item-icon")} + ${escapeHtml(root.label)} + `; + elements.places.appendChild(button); + } + if (options.animateFrom) animatePlaceReorder(options.animateFrom); + } + + function capturePlaceTops() { + return new Map(Array.from(elements.places.querySelectorAll(".place[data-root-id]")) + .map((button) => [button.dataset.rootId, button.getBoundingClientRect().top])); + } + + function animatePlaceReorder(previousTops) { + if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) return; + const animated = []; + for (const button of elements.places.querySelectorAll(".place[data-root-id]")) { + const previousTop = previousTops.get(button.dataset.rootId); + if (typeof previousTop !== "number") continue; + const delta = previousTop - button.getBoundingClientRect().top; + if (Math.abs(delta) < 0.5) continue; + button.style.transition = "none"; + button.style.transform = `translateY(${delta}px)`; + button.style.willChange = "transform"; + animated.push(button); + } + if (!animated.length) return; + perf.sidebarReorderAnimationCount = (perf.sidebarReorderAnimationCount || 0) + 1; + window.requestAnimationFrame(() => { + for (const button of animated) { + button.style.transition = "transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1)"; + button.style.transform = ""; + button.addEventListener("transitionend", () => { + button.style.transition = ""; + button.style.willChange = ""; + }, { once: true }); + } + }); + } + + function syncPlacesActive() { + const activeRoot = activeRootForUri(state.currentUri); + for (const button of elements.places.querySelectorAll(".place[data-uri]")) { + const uri = button.dataset.uri || ""; + const active = activeRoot?.uri === uri; + button.className = active ? "place window-sidebar-item window-sidebar-item-active" : "place window-sidebar-item"; + button.dataset.active = active ? "true" : "false"; + } + } + + function syncRouteChrome() { + syncPlacesActive(); + renderBreadcrumbs(); + renderFooter(); + } + + function placeIcon(id) { + return { + home: "icons/sidebar-folder-home.svg", + desktop: "icons/sidebar-folder-desktop.svg", + documents: "icons/sidebar-folder-documents.svg", + pictures: "icons/sidebar-folder-pictures.svg", + videos: "icons/sidebar-folder-videos.svg", + downloads: "icons/sidebar-folder.svg", + public: "icons/sidebar-folder-public.svg", + webspaces: "icons/sidebar-folder.svg", + }[id] || "icons/sidebar-folder.svg"; + } + + function renderBreadcrumbs() { + elements.breadcrumbs.innerHTML = ""; + const root = rootForUri(state.currentUri); + if (!root) return; + const segments = state.currentUri === root.uri + ? [] + : state.currentUri.slice(root.uri.length + 1).split("/").filter(Boolean); + const rootButton = crumbButton(root.label, root.uri, segments.length === 0); + elements.breadcrumbs.appendChild(rootButton); + let cursor = root.uri; + for (let index = 0; index < segments.length; index += 1) { + cursor += "/" + segments[index]; + elements.breadcrumbs.appendChild(pathSeparator()); + elements.breadcrumbs.appendChild(crumbButton(decodeURIComponent(segments[index]), cursor, index === segments.length - 1)); + } + elements.currentTitle.textContent = segments.length ? decodeURIComponent(segments[segments.length - 1]) : root.label; + } + + function pathSeparator() { + const separator = document.createElement("span"); + separator.className = "path-seperator"; + separator.textContent = "/"; + return separator; + } + + function crumbButton(label, uri, current) { + const button = document.createElement("button"); + button.type = "button"; + button.className = current ? "crumb crumb-current" : "crumb"; + button.textContent = label; + button.dataset.uri = uri; + button.disabled = current; + return button; + } + + function visibleObjects() { + return visibleObjectsForState(state); + } + + function showMenuForObject(object, x, y) { + if (!isSelected(object.uri)) { + selectOnly(object.uri); + } + const selection = selectedObjects(); + if (selection.length > 1) { + showMenuForSelection(x, y); + return; + } + const actions = []; + if (isBlockedObject(object)) { + actions.push(menuAction("Properties", () => showProperties(object))); + renderMenu(actions, x, y); + return; + } + if (isDirectory(object)) { + actions.push(menuAction("Open", () => navigate(object.uri))); + if (!inTrash(object)) { + actions.push(menuAction("Open in New Window", () => openTarget("library", { uri: object.uri }))); + } + } else { + actions.push(menuAction("Open", () => openObject(object))); + const viewers = viewerOptions(object); + if (viewers.length) { + actions.push(menuAction("Open With", null, { + children: viewers.map((viewer) => menuAction(viewer.label || viewer.id, () => openWithViewer(object, viewer.id))), + })); + } + } + if (hasCapability(object, "download")) { + actions.push(menuAction("Download", () => downloadObject(object))); + if (isDirectory(object) && !isWebSpaceUri(object.uri)) { + actions.push(menuAction("Download as ZIP", () => downloadObjectAsZip(object))); + } + } + if (!inTrash(object) && hasCapability(object, "compress_archive")) { + actions.push(menuAction("Compress to ZIP", () => compressObjectToZip(object))); + } + if (!inTrash(object)) { + if (!isDirectory(object)) { + if (hasCapability(object, "extract_archive")) { + actions.push(menuAction("Extract Here", () => extractArchiveObject(object))); + } else if (isPolicyGatedArchive(object)) { + actions.push(menuAction("Archive Support", () => showArchiveSupport(object))); + } + if (object.published) { + actions.push(menuAction("Status", () => showStatusObject(object))); + if (hasCapability(object, "repair")) actions.push(menuAction("Repair", () => repairObject(object))); + if (object.shared) actions.push(menuAction("Check My Access", () => checkMyAccess(object))); + if (hasCapability(object, "share")) actions.push(menuAction("Share", () => shareObject(object))); + if (hasCapability(object, "unpublish")) actions.push(menuAction("Unpublish", () => unpublishObject(object))); + } else if (hasCapability(object, "publish")) { + actions.push(menuAction("Publish", () => publishObject(object))); + } + } + actions.push("-"); + if (hasCapability(object, "move")) actions.push(menuAction("Cut", () => setClipboard("move", [object]))); + if (hasCapability(object, "copy")) actions.push(menuAction("Copy", () => setClipboard("copy", [object]))); + if (isDirectory(object) && canPasteInto(object.uri)) { + actions.push(menuAction("Paste Into Folder", () => pasteClipboardTo(object.uri))); + } + actions.push("-"); + if (hasCapability(object, "trash")) actions.push(menuAction("Delete", () => trashObject(object))); + if (hasCapability(object, "delete_permanently")) actions.push(menuAction("Delete Permanently", () => deleteObject(object))); + if (hasCapability(object, "rename")) actions.push(menuAction("Rename", () => startRename(object))); + } + if (inTrash(object)) { + if (hasCapability(object, "restore")) actions.push(menuAction("Restore", () => restoreObject(object))); + if (hasCapability(object, "delete_permanently")) actions.push(menuAction("Delete Permanently", () => deleteObject(object))); + } + actions.push("-"); + const localContentCid = contentCid(object); + const publicCid = publishedCid(object); + if (localContentCid) { + actions.push(menuAction("Copy Content CID", () => copyText(localContentCid, "content CID"))); + } + if (object.published && publicCid) { + actions.push(menuAction("Copy Published Link", () => copyText("elastos://" + publicCid, "published link"))); + } + actions.push(menuAction("Properties", () => showProperties(object))); + renderMenu(actions, x, y); + } + + function isPolicyGatedArchive(object) { + return object?.metadata?.archive_support?.status === "policy_gated_unsupported_archive_family"; + } + + function showArchiveSupport(object) { + const support = object?.metadata?.archive_support || {}; + const family = support.family || "archive"; + const archiveViewer = viewerOptions(object).find((viewer) => viewer?.id === "archive-manager"); + if (archiveViewer && openWithViewer(object, archiveViewer.id)) { + setStatus(`Opening ${object.name || "archive"} in Archive Manager for ${family} policy inspection.`); + return; + } + setStatus(`${object.name || "Archive"} is a ${family} archive. Extraction is disabled pending dependency and release-policy review.`); + showProperties(object); + } + + function showPlaceMenu(uri, x, y) { + const root = state.roots.find((entry) => entry.uri === uri); + if (!root) { + hideMenu(); + return; + } + renderMenu([ + menuAction("Open", () => navigate(root.uri)), + menuAction("Open in New Window", () => openTarget("library", { uri: root.uri })), + ], x, y); + } + + function showMenuForSelection(x, y) { + const actions = []; + const objects = selectedObjects(); + const files = objects.filter((object) => !isDirectory(object) && !inTrash(object) && !isBlockedObject(object)); + const unpublished = files.filter((object) => !object.published); + const published = files.filter((object) => object.published); + const trash = objects.filter(inTrash); + const active = objects.filter((object) => !inTrash(object) && !isBlockedObject(object)); + const downloadable = active.filter((object) => !isWebSpaceUri(object.uri) && hasCapability(object, "download")); + const compressible = active.filter((object) => !isWebSpaceUri(object.uri) && hasCapability(object, "compress_archive")); + const permanentlyDeletable = trash.length || active.some((object) => hasCapability(object, "delete_permanently")); + if (downloadable.length > 1 && downloadable.length === active.length) { + actions.push(menuAction("Download Selected", downloadSelectedObjects)); + actions.push(menuAction("Download Selected as ZIP", downloadSelectedObjectsAsZip)); + } + if ( + compressible.length > 1 && + compressible.length === active.length && + compressible.every((object) => parentUri(object.uri) === parentUri(compressible[0].uri)) + ) { + actions.push(menuAction("Compress Selected to ZIP", compressSelectedObjectsToZip)); + } + if (unpublished.some((object) => hasCapability(object, "publish"))) actions.push(menuAction("Publish Selected", publishSelectedObjects)); + if (published.some((object) => hasCapability(object, "unpublish"))) actions.push(menuAction("Unpublish Selected", unpublishSelectedObjects)); + if (active.length) actions.push("-"); + if (active.some((object) => hasCapability(object, "move"))) actions.push(menuAction("Cut", () => setClipboard("move", active))); + if (active.some((object) => hasCapability(object, "copy"))) actions.push(menuAction("Copy", () => setClipboard("copy", active))); + if (active.some((object) => hasCapability(object, "trash"))) actions.push(menuAction("Delete", trashSelectedObjects)); + if (trash.length) actions.push(menuAction("Restore", restoreSelectedObjects)); + if (permanentlyDeletable) actions.push(menuAction("Delete Permanently", deleteSelectedObjects)); + renderMenu(actions, x, y); + } + + function showBackgroundMenu(x, y) { + const readOnly = currentFolderReadOnly(); + const actions = []; + actions.push(menuAction("Sort By", null, { + children: [ + menuAction("Name", () => setSort("name"), { checked: state.sort === "name" }), + menuAction("Date Modified", () => setSort("modified"), { checked: state.sort === "modified" }), + menuAction("Type", () => setSort("type"), { checked: state.sort === "type" }), + menuAction("Size", () => setSort("size"), { checked: state.sort === "size" }), + "-", + menuAction("Ascending", () => setSortOrder("asc"), { checked: state.sortOrder !== "desc" }), + menuAction("Descending", () => setSortOrder("desc"), { checked: state.sortOrder === "desc" }), + ], + })); + actions.push(menuAction("View", null, { + children: [ + menuAction("Icons", () => setView("grid"), { checked: state.view !== "list" }), + menuAction("Details", () => setView("list"), { checked: state.view === "list" }), + ], + })); + actions.push("-"); + actions.push(menuAction("Refresh", loadCurrentFolder)); + actions.push(menuAction("Show Hidden", () => toggleShowHidden(), { checked: state.showHidden })); + actions.push("-"); + if (!readOnly) { + actions.push(menuAction("New", null, { + children: [ + menuAction("Folder", createFolder), + menuAction("Text Document", createTextDocument), + ], + })); + actions.push("-"); + } + if (canPasteInto(state.currentUri)) actions.push(menuAction("Paste", () => pasteClipboardTo(state.currentUri))); + if (!readOnly) actions.push(menuAction("Upload Here", () => elements.fileInput.click())); + actions.push("-"); + actions.push(menuAction("Properties", () => showFolderProperties())); + renderMenu(actions, x, y); + } + + function showFolderProperties() { + const root = rootForUri(state.currentUri); + showProperties({ + uri: state.currentUri, + name: root?.uri === state.currentUri ? root.label : baseName(state.currentUri), + kind: "directory", + mime: "inode/directory", + size: 0, + modified_at: 0, + revision: "-", + availability: "local-only", + viewers: [], + }); + } + + function setSort(sort) { + state.sort = sort || "name"; + elements.sortSelect.value = state.sort; + localStorage.setItem("library.sort", state.sort); + scheduleContentRender(); + } + + function setSortOrder(order) { + state.sortOrder = order === "desc" ? "desc" : "asc"; + localStorage.setItem("library.sortOrder", state.sortOrder); + scheduleContentRender(); + } + + function toggleShowHidden() { + state.showHidden = !state.showHidden; + localStorage.setItem("library.showHidden", String(state.showHidden)); + scheduleContentRender(); + } + + function setView(view) { + state.view = view === "list" ? "list" : "grid"; + localStorage.setItem("library.view", state.view); + syncContentViewMode(); + syncViewButtons(); + renderFooter(); + } + + function showError(error) { + console.error(error); + setStatus(error && error.message ? error.message : "Explorer action failed."); + } + + function bindEvents() { + bindLibraryEvents({ + bindDialogEvents, + clearSelection, + copySelectedObjectsTo, + createFolder, + elements, + handleBrowserPopState, + hideDialog, + hideMenu, + loadCurrentFolder, + moveSelectedObjectsTo, + navigate, + navigateBack, + navigateForward, + navigateUp, + objectByUri, + openObject, + prepareDragSelection, + reorderPlace, + scheduleContentRender, + selectAllVisible, + selectedObjects, + selectOnly, + selectRangeTo, + setSort, + setView, + showBackgroundMenu, + showError, + showMenuForObject, + showPlaceMenu, + startRename, + state, + stopLibraryEventStream, + toggleSelected, + uploadFiles, + }); + } + + async function boot() { + if (!state.homeToken) { + elements.lockedShell.classList.remove("hidden"); + return; + } + elements.libraryShell.classList.remove("hidden"); + elements.content.dataset.view = state.view; + syncModeChrome(); + bindEvents(); + try { + await loadRoots(); + installBrowserHistory(); + await loadCurrentFolder(); + scheduleRootPrefetch(); + startLibraryEventStream(); + } catch (error) { + showError(error); + elements.content.innerHTML = `

Could not load Explorer

${escapeHtml(error.message || "Runtime object provider unavailable.")}

`; + } + } + + boot(); diff --git a/capsules/library/src/dialog.js b/capsules/library/src/dialog.js new file mode 100644 index 00000000..4298ac7d --- /dev/null +++ b/capsules/library/src/dialog.js @@ -0,0 +1,921 @@ +import { + contentCid, + escapeHtml, + formatBytes, + formatTime, + hasCapability, + isDirectory, + parentUri, + publishedCid, + shortUri, + visibilityContract, + viewerOptions, +} from "./model.js"; + +export function createLibraryDialog({ + copyText, + dialog, + hideMenu, + objectByUri, + onBeforeClose, +}) { + let pendingDialogResolve = null; + + function showProperties(object) { + const identity = smartWebIdentity(object); + const availability = safeAvailabilitySummary(object.availability); + const remoteAccess = safeRemoteAccessSummary({}, object); + const archive = safeArchiveSummary(object); + const viewers = viewerOptions(object).map((viewer) => viewer.label || viewer.id); + const typeLabel = isDirectory(object) ? "Folder" : object.mime || "File"; + const location = parentUri(object.uri || ""); + const visibility = propertiesVisibilitySummary(object, identity, remoteAccess); + const placement = propertiesPlacementSummary(object); + const generalRows = [ + ["Name", object.name || "-"], + ["Path", copyableValue(object.uri || "-", "path")], + ["UID", identity.objectId], + ["Type", typeLabel], + ["Opens with", viewers.join(", ") || "-"], + ["Where", location || "-"], + ["Size", isDirectory(object) ? "-" : formatBytes(object.size)], + ["Modified", formatTime(object.modified_at)], + ["Created", formatTime(object.created_at)], + ["SmartWeb Object", identity.kind], + ["Content ID", copyableValue(identity.contentId, "content CID")], + ["Published CID", copyableValue(identity.publishedId, "published CID")], + ["Published Link", copyableValue(identity.publishedLink, "published link")], + ["Placement", badgeValue(placement.label, placement.tone)], + ["Visibility", badgeValue(visibility.label, visibility.tone)], + ["Access granted to", remoteAccess.status], + ]; + if (archive.relevant) { + generalRows.push(["Archive", archive.status]); + } + const runtimeRows = [ + ["Provider", identity.provider], + ["Object URI", copyableValue(object.uri || "-", "object URI")], + ["Content URI", copyableValue(identity.contentUri, "content URI")], + ["Head", copyableValue(identity.headId, "object head")], + ["Revision", object.revision || "-"], + ["Resolver", identity.resolver], + ["Resolver Target", copyableValue(identity.resolverTarget, "resolver target")], + ["Access Policy", identity.accessPolicy], + ["Availability", availability.status], + ["Replicas", availability.replicas], + ["Live Proof", availability.liveProof], + ["Quota", availability.quota], + ["Repair", availability.repair], + ["Storage Market", availability.storageMarket], + ["Remote Open", remoteAccess.openStatus], + ["Key Release", remoteAccess.keyRelease], + ["Protection", object.blocked_reason || "ok"], + ["Public Folder Policy", placement.policy], + ["Visibility Contract", object.metadata?.visibility?.schema || "elastos.library.visibility/v1"], + ]; + const archiveTab = archive.relevant + ? `
Archive
` + : ""; + const archivePanel = archive.relevant + ? propertiesPanel("archive", [ + ["Status", archive.status], + ["Family", archive.details?.object?.family || "-"], + ["Extractable", archive.details?.object?.extractable ? "yes" : "no"], + ["Compressible", archive.details?.object?.compressible ? "yes" : "no"], + ["Download formats", archive.details?.implemented?.download_formats?.join(", ") || "-"], + ["Extract formats", archive.details?.implemented?.extract_formats?.join(", ") || "-"], + ["Safety", archive.details?.implemented?.safety || "-"], + ["Remaining policy", archive.details?.remaining_policy || "-"], + ]) + : ""; + dialog.innerHTML = ` +
+
+ ${escapeHtml(object.name || "Object")} properties + +
+
+
+
General
+
Runtime
+ ${archiveTab} +
+ ${propertiesPanel("general", generalRows, true)} + ${propertiesPanel("runtime", runtimeRows)} + ${archivePanel} +
+
+ +
+
+ `; + dialog.classList.remove("hidden"); + } + + function propertiesPanel(tab, rows, selected = false) { + return ` +
+ + + ${rows.map(([label, value]) => propertiesRow(label, value)).join("")} + +
+
+ `; + } + + function propertiesRow(label, value) { + const rendered = propertiesValue(value); + return ` + + ${escapeHtml(label)} + ${rendered.html} + + `; + } + + function propertiesValue(value) { + if (value && typeof value === "object" && value.kind === "copyable") { + const text = displayValue(value.value); + if (text === "-") return { title: text, html: escapeHtml(text) }; + const label = value.label || "value"; + return { + title: text, + html: ` + + ${escapeHtml(text)} + + + `, + }; + } + if (value && typeof value === "object" && value.kind === "badge") { + const text = displayValue(value.value); + return { + title: text, + html: `${escapeHtml(text)}`, + }; + } + const text = displayValue(value); + return { title: text, html: escapeHtml(text) }; + } + + function copyableValue(value, label) { + return { kind: "copyable", value, label }; + } + + function badgeValue(value, tone) { + return { kind: "badge", value, tone }; + } + + function displayValue(value) { + return value === null || value === undefined || value === "" ? "-" : String(value); + } + + function copyIconSvg() { + return ''; + } + + function propertiesVisibilitySummary(object = {}, identity = {}, remoteAccess = {}) { + const visibility = visibilityContract(object); + if (visibility.effective_access === "public_content_link") { + return object.shared + ? { label: "Published and shared", tone: "public" } + : { label: "Published link", tone: "public" }; + } + if (visibility.effective_access === "recipient_scoped_link") { + return { label: "Recipient scoped", tone: "shared" }; + } + if (visibility.effective_access === "blocked") { + return { label: "Blocked", tone: "readonly" }; + } + if (visibility.placement === "public_folder") { + return { label: "Private until published", tone: "staged" }; + } + if (identity.publishedId && identity.publishedId !== "-") { + return object.shared + ? { label: "Published and shared", tone: "public" } + : { label: "Published", tone: "public" }; + } + if (remoteAccess.status === "recipient scoped") { + return { label: "Recipient scoped", tone: "shared" }; + } + if (object.shared) { + return { label: "Shared", tone: "shared" }; + } + if (object.metadata?.readonly === true) { + return { label: "Mounted read-only", tone: "readonly" }; + } + if (object.metadata?.readonly === false) { + return { label: "Mounted writable", tone: "writable" }; + } + return { label: "Private", tone: "private" }; + } + + function propertiesPlacementSummary(object = {}) { + const visibility = visibilityContract(object); + const placement = visibility.placement || "private_folder"; + if (placement === "public_folder") { + return { + label: visibility.placement_label || "Public folder", + tone: "staged", + policy: "Placement only; publish creates the public content link.", + }; + } + if (placement === "trash") { + return { + label: "Trash", + tone: "readonly", + policy: "Trash is not public and cannot publish until restored.", + }; + } + if (placement === "runtime_private") { + return { + label: "Runtime private", + tone: "readonly", + policy: "Runtime private objects are hidden from normal publishing.", + }; + } + return { + label: visibility.placement_label || "Private folder", + tone: "private", + policy: "Private placement; publish creates an explicit content-provider receipt.", + }; + } + + function smartWebIdentity(object = {}, record = null) { + const metadata = object.metadata || {}; + const localContentId = contentCid(object); + const publicContentId = record?.cid || publishedCid(object); + const contentUri = publicContentId ? `elastos://${publicContentId}` : "-"; + const resolver = metadata.resolver || metadata.provider || ""; + const resolverTarget = metadata.target_uri || metadata.resolver_target || metadata.source_uri || ""; + const accessPolicy = metadata.access_policy || ( + metadata.readonly === false + ? "owner-writable" + : metadata.readonly === true + ? "read-only" + : "-" + ); + const kind = publicContentId + ? "Published content object" + : localContentId + ? "Local content object" + : resolver + ? "Mounted Space object" + : "Local Library object"; + const provider = publicContentId + ? "content-provider" + : resolver + ? `${resolver} resolver` + : "object-provider"; + const objectId = metadata.object_id || object.uri || "-"; + return { + kind, + provider, + contentId: localContentId || "-", + publishedId: publicContentId || "-", + contentUri, + publishedLink: publicContentId ? `elastos://${publicContentId}` : "-", + objectId, + headId: metadata.head_id || object.revision || "-", + resolver: resolver || "-", + resolverTarget: resolverTarget || "-", + accessPolicy: accessPolicy || "-", + }; + } + + function safeAvailabilitySummary(availability) { + if (!availability || typeof availability === "string") { + const status = availability || "local-only"; + return { + status, + replicas: "-", + liveProof: "no", + quota: "-", + repair: status === "repair_needed" ? "needed" : "-", + storageMarket: "-", + details: { + schema: "elastos.library.availability-summary/v1", + status, + proof: "not_provided", + }, + }; + } + const peerSelection = availability.peer_selection || {}; + const quota = availability.quota || {}; + const repair = availability.repair_worker || {}; + const accounting = availability.accounting || null; + const abuseControls = availability.abuse_controls || null; + const storageMarket = availability.storage_market || {}; + const remoteReplicas = Number(peerSelection.remote_replicas ?? countRemotePeerRows(peerSelection)); + const liveProof = peerSelection.live_multi_peer_proof === true; + const quotaStatus = quota.status || (quota.enforced ? "enforced" : "not_enforced"); + const repairStatus = repair.status || (availability.status === "repair_needed" ? "needed" : "-"); + return { + status: availability.status || "unknown", + replicas: Number.isFinite(Number(availability.replicas)) ? String(availability.replicas) : "-", + liveProof: liveProof ? "yes" : "no", + quota: quotaStatus || "-", + repair: repairStatus, + storageMarket: storageMarket.status || storageMarket.settlement || "-", + details: { + schema: "elastos.library.availability-summary/v1", + status: availability.status || "unknown", + provider: availability.provider || "-", + policy: availability.policy || "-", + replicas: availability.replicas ?? null, + peer_selection: { + mode: peerSelection.mode || "-", + strategy: peerSelection.strategy || "-", + live_multi_peer_proof: liveProof, + remote_replicas: remoteReplicas, + truncated: peerSelection.replicas_truncated ?? peerSelection.recent_remote_replicas_truncated ?? false, + }, + quota: { + status: quota.status || null, + enforced: quota.enforced === true, + used_replicas: quota.used_replicas ?? null, + effective_max_replicas: quota.effective_max_replicas ?? null, + }, + repair_worker: { + status: repair.status || null, + scheduled: repair.scheduled === true, + }, + storage_market: { + mode: storageMarket.mode || null, + status: storageMarket.status || null, + settlement: storageMarket.settlement || null, + quota_enforced: storageMarket.quota_enforced === true, + }, + accounting: accounting ? { + content_bytes: accounting.content_bytes ?? null, + replica_bytes_estimate: accounting.replica_bytes_estimate ?? null, + storage_quota_status: accounting.storage_quota?.status || accounting.storage_quota_status || null, + } : null, + abuse_controls: abuseControls ? { + policy: abuseControls.policy || null, + enforced: abuseControls.enforced === true, + attempted_operations: abuseControls.attempted_operations ?? null, + failed_operations: abuseControls.failed_operations ?? null, + throttled: abuseControls.throttled === true, + } : null, + }, + }; + } + + function countRemotePeerRows(peerSelection) { + const replicas = Array.isArray(peerSelection?.replicas) ? peerSelection.replicas : []; + return replicas.filter((replica) => replica?.role === "remote").length; + } + + function safePublishReceiptSummary(receipt) { + if (!receipt) return { status: "not_available" }; + const payload = receipt.payload || receipt; + return { + schema: payload.schema || "elastos.content.availability.receipt/v1", + status: payload.status || null, + provider: payload.provider || null, + policy: payload.policy || null, + replicas: payload.replicas ?? null, + checked_at: payload.checked_at ?? null, + signer_did: receipt.signer_did || payload.signer_did || null, + verified: receipt.verified === true || payload.verified === true, + }; + } + + function safeShareReceiptSummary(payload) { + if (!payload) return { status: "not_available" }; + return { + schema: payload.schema || "elastos.library.share/v1", + policy: payload.policy || null, + uri: payload.uri || (payload.cid ? `elastos://${payload.cid}` : null), + object_uri: payload.object_uri || payload.object?.uri || null, + recipients: Array.isArray(payload.recipients) ? payload.recipients.length : 0, + grants: Array.isArray(payload.grants) ? payload.grants.length : 0, + availability: safeAvailabilitySummary(payload.availability || payload.object?.availability || null).details, + remote_access: safeRemoteAccessSummary(payload, payload.object || {}).details, + key_release_required: payload.key_release?.required === true, + shared_at: payload.shared_at || null, + }; + } + + function safeRemoteAccessSummary(payload = {}, object = {}) { + const metadata = object.metadata || payload.metadata || {}; + const providerStatus = payload.protected_content + || metadata.protected_content + || null; + const grants = Array.isArray(payload.grants) + ? payload.grants + : Array.isArray(payload.share_grants) + ? payload.share_grants + : []; + const firstGrantKeyRelease = grants.find((grant) => grant?.key_release)?.key_release || null; + const keyRelease = payload.key_release + || firstGrantKeyRelease + || metadata.key_release + || null; + const remoteEnforcement = payload.remote_enforcement + || payload.access?.open?.remote_enforcement + || payload.open?.remote_enforcement + || metadata.remote_enforcement + || null; + const policy = payload.policy + || payload.share_policy + || metadata.share_policy + || (object.shared ? "shared" : "not_shared"); + const keyReleaseRequired = keyRelease?.required === true || remoteEnforcement?.key_release_required === true; + const recipientProofRequired = remoteEnforcement?.recipient_proof_required === true || policy === "recipient_scoped"; + const keyReleaseStatus = keyRelease?.status + || remoteEnforcement?.key_release_status + || (keyReleaseRequired ? "required" : "not_required"); + const openStatus = remoteEnforcement?.status + || (keyReleaseRequired + ? "blocked_until_drm_rights_key_decrypt_providers" + : recipientProofRequired + ? "recipient_proof_enforced_by_runtime" + : policy === "public_link" + ? "public_link_ready" + : "not_shared"); + const status = policy === "recipient_scoped" + ? "recipient scoped" + : policy === "public_link" + ? "public link" + : object.shared + ? "shared" + : "not shared"; + return { + status, + openStatus, + keyRelease: keyReleaseStatus, + details: { + schema: "elastos.library.remote-access-summary/v1", + policy, + provider_gate: remoteEnforcement?.provider_gate || "object-provider shared_access", + recipient_proof_required: recipientProofRequired, + key_release_required: keyReleaseRequired, + key_release_status: keyReleaseStatus, + open_status: openStatus, + required_providers: remoteEnforcement?.required_providers || keyRelease?.required_providers || null, + provider_invocation: remoteEnforcement?.provider_invocation || null, + provider_status: providerStatus, + next: remoteEnforcement?.next || keyRelease?.next || null, + }, + }; + } + + function safeArchiveSummary(object = {}) { + const backend = object.metadata?.archive_support || null; + const name = String(object.name || "").toLowerCase(); + const mime = String(object.mime || "").toLowerCase(); + const family = backend?.family || archiveFamilyForName(name, mime); + const extractable = hasCapability(object, "extract_archive") + && (name.endsWith(".zip") + || name.endsWith(".tar") + || name.endsWith(".tar.gz") + || name.endsWith(".tgz")); + const policyGated = backend?.status === "policy_gated_unsupported_archive_family" + || (!!family && !extractable && !["zip", "tar", "tar.gz"].includes(family)); + const compressible = hasCapability(object, "compress_archive"); + const archiveLike = extractable + || policyGated + || !!backend + || mime.includes("zip") + || mime.includes("tar") + || name.endsWith(".gz"); + return { + relevant: archiveLike, + status: extractable + ? "extractable" + : policyGated + ? "policy-gated archive" + : compressible + ? "can compress/download" + : archiveLike + ? "view only" + : "-", + details: { + schema: "elastos.library.archive-support/v1", + backend, + implemented: { + download_formats: ["zip", "tar.gz"], + compress_to_library: ["zip"], + extract_formats: ["zip", "tar", "tar.gz", "tgz"], + safety: "relative UTF-8 file paths only; non-file archive entries are rejected", + }, + object: { + name: object.name || null, + mime: object.mime || null, + family, + extractable, + compressible, + policy_gated: policyGated, + }, + remaining_policy: policyGated + ? "This archive family is recognized but disabled pending dependency and release-policy review." + : "Other generic archive families need dependency and release-policy review before enabling.", + }, + }; + } + + function archiveFamilyForName(name, mime = "") { + if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) return "tar.gz"; + if (name.endsWith(".tar")) return "tar"; + if (name.endsWith(".zip") || mime.includes("zip")) return "zip"; + if (name.endsWith(".tar.xz") || name.endsWith(".txz")) return "tar.xz"; + if (name.endsWith(".tar.bz2") || name.endsWith(".tbz2")) return "tar.bz2"; + if (name.endsWith(".tar.zst") || name.endsWith(".tzst")) return "tar.zst"; + if (name.endsWith(".7z")) return "7z"; + if (name.endsWith(".rar")) return "rar"; + if (name.endsWith(".xz")) return "xz"; + if (name.endsWith(".bz2")) return "bz2"; + if (name.endsWith(".zst")) return "zst"; + if (name.endsWith(".lz4")) return "lz4"; + if (name.endsWith(".gz")) return "gzip"; + if (mime.includes("tar")) return "tar"; + return ""; + } + + function showObjectStatus(payload) { + const object = payload?.object || {}; + const record = payload?.published || null; + const availability = record?.availability || object.availability || "local-only"; + const identity = smartWebIdentity(object, record); + const availabilitySummary = safeAvailabilitySummary(availability); + const receipt = record?.receipt || null; + const shareGrants = Array.isArray(record?.share_grants) ? record.share_grants : []; + const firstGrantKeyRelease = shareGrants.find((grant) => grant?.key_release)?.key_release || null; + const contentSecurity = record?.content_security || null; + const remoteAccess = safeRemoteAccessSummary({ + policy: record?.share_policy, + grants: shareGrants, + key_release: firstGrantKeyRelease, + remote_enforcement: record?.remote_enforcement, + content_security: contentSecurity, + protected_content: payload?.protected_content || record?.protected_content, + }, object); + const protectedContent = payload?.protected_content || record?.protected_content || null; + dialog.innerHTML = ` +
+
+

Availability

+

${escapeHtml(object.name || "Object status")}

+

${escapeHtml(shortUri(object.uri || ""))}

+
+
+
SmartWeb Object
${escapeHtml(identity.kind)}
+
Content URI
${escapeHtml(identity.contentUri)}
+
Availability
${escapeHtml(availabilitySummary.status)}
+
Live Proof
${escapeHtml(availabilitySummary.liveProof)}
+
Replicas
${escapeHtml(availabilitySummary.replicas)}
+
Quota
${escapeHtml(availabilitySummary.quota)}
+
Repair
${escapeHtml(availabilitySummary.repair)}
+
Storage Market
${escapeHtml(availabilitySummary.storageMarket)}
+
Published
${object.published ? "yes" : "no"}
+
Shared
${object.shared ? "yes" : "no"}
+
Object ID
${escapeHtml(identity.objectId)}
+
Published At
${escapeHtml(formatTime(record?.published_at))}
+
Unpublished At
${escapeHtml(formatTime(record?.unpublished_at))}
+
Shared At
${escapeHtml(formatTime(record?.shared_at))}
+
Share Policy
${escapeHtml(record?.share_policy || "not shared")}
+
Share Grants
${escapeHtml(String(shareGrants.length))}
+
Remote Open
${escapeHtml(remoteAccess.openStatus)}
+
Provider Chain
${escapeHtml(protectedContent?.encrypted_recipient_sharing?.status || "-")}
+
Payload
${escapeHtml(contentSecurity?.published_payload || "-")}
+
Key Release
${escapeHtml(remoteAccess.keyRelease || contentSecurity?.status || "not required")}
+
Revision
${escapeHtml(object.revision || "-")}
+
+
+ Remote Access Policy +
${escapeHtml(JSON.stringify(remoteAccess.details, null, 2))}
+
+ ${protectedContent ? `
+ Protected Content Providers +
${escapeHtml(JSON.stringify(protectedContent, null, 2))}
+
` : ""} +
+ Availability Summary +
${escapeHtml(JSON.stringify(availabilitySummary.details, null, 2))}
+
+
+ Share Grants / Key Release +
${escapeHtml(JSON.stringify({
+            policy: record?.share_policy || null,
+            grants: shareGrants,
+            content_security: contentSecurity,
+          }, null, 2))}
+
+
+ Publish Receipt Summary +
${escapeHtml(JSON.stringify(safePublishReceiptSummary(receipt), null, 2))}
+
+
+ + +
+
+ `; + dialog.dataset.previewUri = object.uri || ""; + dialog.classList.remove("hidden"); + } + + function showShareReceipt(payload) { + const object = payload?.object || {}; + const uri = payload?.uri || (payload?.cid ? `elastos://${payload.cid}` : ""); + const availability = payload?.availability || object.availability || "unknown"; + const identity = smartWebIdentity(object, { cid: payload?.cid, availability }); + const availabilitySummary = safeAvailabilitySummary(availability); + const policy = payload?.policy || "public_link"; + const recipientCount = Array.isArray(payload?.recipients) ? payload.recipients.length : 0; + const grantCount = Array.isArray(payload?.grants) ? payload.grants.length : 0; + const keyRelease = payload?.key_release || payload?.grants?.find((grant) => grant?.key_release)?.key_release || null; + const contentSecurity = payload?.content_security || null; + const remoteAccess = safeRemoteAccessSummary(payload, object); + const protectedContent = payload?.protected_content || null; + dialog.innerHTML = ` +
+
+

Share

+

${escapeHtml(object.name || "Published object")}

+

A published content link is ready. Recipient-scoped grants are recorded by the Runtime provider when recipients are supplied.

+
+
+
Policy
${escapeHtml(policy)}
+
Recipients
${escapeHtml(String(recipientCount))}
+
Grants
${escapeHtml(String(grantCount))}
+
Content URI
${escapeHtml(uri || identity.contentUri)}
+
SmartWeb Object
${escapeHtml(identity.kind)}
+
Shared At
${escapeHtml(formatTime(payload?.shared_at))}
+
Availability
${escapeHtml(availabilitySummary.status)}
+
Live Proof
${escapeHtml(availabilitySummary.liveProof)}
+
Storage Market
${escapeHtml(availabilitySummary.storageMarket)}
+
Payload
${escapeHtml(contentSecurity?.published_payload || "-")}
+
Remote Open
${escapeHtml(remoteAccess.openStatus)}
+
Provider Chain
${escapeHtml(protectedContent?.encrypted_recipient_sharing?.status || "-")}
+
Key Release
${escapeHtml(remoteAccess.keyRelease || keyRelease?.status || "not required")}
+
Object
${escapeHtml(shortUri(payload?.object_uri || object.uri || ""))}
+
+
+ Remote Access Policy +
${escapeHtml(JSON.stringify(remoteAccess.details, null, 2))}
+
+ ${protectedContent ? `
+ Protected Content Providers +
${escapeHtml(JSON.stringify(protectedContent, null, 2))}
+
` : ""} +
+ Availability Summary +
${escapeHtml(JSON.stringify(availabilitySummary.details, null, 2))}
+
+
+ Recipient Grants / Key Release +
${escapeHtml(JSON.stringify({
+            policy,
+            grants: payload?.grants || [],
+            key_release: keyRelease,
+            content_security: contentSecurity,
+          }, null, 2))}
+
+
+ Share Receipt Summary +
${escapeHtml(JSON.stringify(safeShareReceiptSummary(payload), null, 2))}
+
+
+ + + +
+
+ `; + dialog.dataset.previewUri = object.uri || payload?.object_uri || ""; + dialog.classList.remove("hidden"); + } + + function showSharedAccessReceipt(payload) { + const object = payload?.object || {}; + const access = payload?.access || {}; + const decision = access.decision || {}; + const open = access.open || {}; + const keyRelease = access.key_release || open.key_release || null; + const remoteAccess = safeRemoteAccessSummary({ + access, + key_release: keyRelease, + policy: decision.policy || open.policy, + protected_content: payload?.protected_content, + }, object); + dialog.innerHTML = ` +
+
+

Access Check

+

${escapeHtml(object.name || "Shared object")}

+

Runtime checked this object against the signed Home principal. Recipient proof is injected by Runtime only when the launch grant matches the requested recipient.

+
+
+
Decision
${escapeHtml(decision.allowed === false ? "denied" : "allowed")}
+
Policy
${escapeHtml(decision.policy || open.policy || "-")}
+
Recipient
${escapeHtml(decision.recipient || open.recipient || "-")}
+
Reason
${escapeHtml(decision.reason || "-")}
+
Content URI
${escapeHtml(payload?.uri || (payload?.cid ? `elastos://${payload.cid}` : "-"))}
+
Open Status
${escapeHtml(open.status || remoteAccess.openStatus || "-")}
+
Key Release
${escapeHtml(remoteAccess.keyRelease || keyRelease?.status || "not required")}
+
Payload
${escapeHtml(open.published_payload || "-")}
+
+
+ Open Contract +
${escapeHtml(JSON.stringify(open, null, 2))}
+
+
+ Remote Access Policy +
${escapeHtml(JSON.stringify(remoteAccess.details, null, 2))}
+
+
+ Shared Access Receipt +
${escapeHtml(JSON.stringify(access, null, 2))}
+
+
+ + +
+
+ `; + dialog.dataset.previewUri = object.uri || ""; + dialog.classList.remove("hidden"); + } + + function showShareDialog(object) { + hideMenu(); + hideDialog(); + return new Promise((resolve) => { + pendingDialogResolve = resolve; + dialog.innerHTML = ` +
+
+
+

Share

+

${escapeHtml(object.name || "Published object")}

+

Choose a Runtime share policy. Public links are open to anyone with the published content URI. Recipient-scoped sharing records explicit grants and fails closed for other recipients.

+
+ + +

Separate recipients with commas or new lines. Recipient-scoped access requires Runtime recipient proof; encrypted key release fails closed until drm/rights/key/decrypt providers and encrypted publish mode are configured.

+ +
+ + +
+
+
+ `; + dialog.classList.remove("hidden"); + dialog.querySelector("textarea")?.focus(); + }); + } + + function confirmDestructive({ title, message, confirmLabel }) { + hideMenu(); + hideDialog(); + return new Promise((resolve) => { + pendingDialogResolve = resolve; + dialog.innerHTML = ` +
+
+

Confirm

+

${escapeHtml(title)}

+

${escapeHtml(message)}

+
+
+ + +
+
+ `; + dialog.classList.remove("hidden"); + }); + } + + function resolveDialogDecision(value) { + if (!pendingDialogResolve) return; + const resolve = pendingDialogResolve; + pendingDialogResolve = null; + resolve(value); + } + + function hideDialog() { + onBeforeClose(); + delete dialog.dataset.previewUri; + dialog.classList.add("hidden"); + dialog.innerHTML = ""; + resolveDialogDecision(false); + } + + function bindDialogEvents() { + dialog.addEventListener("submit", (event) => { + const form = event.target.closest("[data-share-form]"); + if (!form) return; + event.preventDefault(); + const formData = new FormData(form); + const policy = String(formData.get("sharePolicy") || "public_link"); + const recipients = String(formData.get("shareRecipients") || "") + .split(/[\n,]+/) + .map((recipient) => recipient.trim()) + .filter(Boolean); + const error = form.querySelector("[data-share-error]"); + if (policy === "recipient_scoped" && !recipients.length) { + if (error) { + error.textContent = "Recipient-scoped sharing requires at least one recipient."; + error.classList.remove("hidden"); + } + return; + } + resolveDialogDecision({ policy, recipients }); + hideDialog(); + }); + + dialog.addEventListener("click", (event) => { + if (event.target.closest("[data-dialog-confirm]")) { + resolveDialogDecision(true); + hideDialog(); + return; + } + const propertyCopy = event.target.closest("[data-prop-copy]"); + if (propertyCopy) { + const value = propertyCopy.getAttribute("data-prop-copy") || ""; + const label = propertyCopy.getAttribute("data-copy-label") || "value"; + if (value && copyText) { + copyText(value, label).catch(() => {}); + propertyCopy.classList.add("copied"); + propertyCopy.setAttribute("aria-label", `Copied ${label}`); + setTimeout(() => { + propertyCopy.classList.remove("copied"); + propertyCopy.removeAttribute("aria-label"); + }, 1200); + } + return; + } + const propertiesTab = event.target.closest(".item-props-tab-btn[data-tab]"); + if (propertiesTab) { + const card = propertiesTab.closest(".properties-card"); + const tab = propertiesTab.getAttribute("data-tab"); + card?.querySelectorAll(".item-props-tab-btn").forEach((button) => { + button.classList.toggle("item-props-tab-selected", button === propertiesTab); + }); + card?.querySelectorAll(".item-props-tab-content").forEach((panel) => { + panel.classList.toggle("item-props-tab-content-selected", panel.getAttribute("data-tab") === tab); + }); + return; + } + if (event.target.closest('[data-dialog-action="properties"]')) { + const object = objectByUri(dialog.dataset.previewUri); + if (object) showProperties(object); + return; + } + const copyShare = event.target.closest('[data-dialog-action="copy-share-link"]'); + if (copyShare) { + const uri = copyShare.getAttribute("data-share-uri") || ""; + if (uri && copyText) copyText(uri, "published link").catch(() => {}); + return; + } + if (event.target === dialog || event.target.closest("[data-dialog-close]")) { + hideDialog(); + } + }); + } + + return { + bindDialogEvents, + confirmDestructive, + hideDialog, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + }; +} diff --git a/capsules/library/src/editor.js b/capsules/library/src/editor.js new file mode 100644 index 00000000..519c8be5 --- /dev/null +++ b/capsules/library/src/editor.js @@ -0,0 +1,126 @@ +import { + childUri, + nextObjectName, +} from "./model.js"; + +export function createLibraryEditor({ + content, + loadCurrentFolder, + providerApi, + renderContent, + setObjects, + setStatus, + showError, + state, +}) { + function startCreateObject(kind) { + const isFolder = kind === "directory"; + const name = isFolder + ? nextObjectName(state.objects, "New Folder") + : nextObjectName(state.objects, "New File", ".txt"); + const now = Math.floor(Date.now() / 1000); + const draft = { + schema: "elastos.library.object/v1", + uri: `draft:${++state.draftCounter}:${Date.now()}`, + name, + kind: isFolder ? "directory" : "file", + mime: isFolder ? "inode/directory" : "text/plain", + size: 0, + created_at: now, + modified_at: now, + revision: "draft", + viewer: null, + viewers: [], + thumbnail_uri: null, + availability: "draft", + blocked_reason: null, + content_cid: null, + published_cid: null, + published: false, + shared: false, + capabilities: ["rename"], + }; + setObjects([...state.objects, draft]); + state.selectedUris = new Set([draft.uri]); + renderContent(); + setStatus(isFolder ? "Name the new folder." : "Name the new text document."); + window.requestAnimationFrame(() => { + startNameEdit(draft, { + async commit(finalName) { + if (isFolder) { + await providerApi("mkdir", { parent_uri: state.currentUri, name: finalName }); + } else { + await providerApi("write", { + uri: childUri(state.currentUri, finalName), + mime: "text/plain", + data: "", + }); + } + setStatus(`Created ${finalName}.`); + await loadCurrentFolder(); + }, + cancel() { + setObjects(state.objects.filter((object) => object.uri !== draft.uri)); + state.selectedUris.delete(draft.uri); + renderContent(); + setStatus("Ready."); + }, + }); + }); + } + + function startRename(object) { + startNameEdit(object, { + async commit(name) { + if (name && name !== object.name) { + await providerApi("rename", { uri: object.uri, name, if_revision: object.revision }); + setStatus(`Renamed ${object.name} to ${name}.`); + await loadCurrentFolder(); + } else { + renderContent(); + } + }, + cancel() { + renderContent(); + }, + }); + } + + function startNameEdit(object, handlers) { + const label = content.querySelector(`[data-name-uri="${CSS.escape(object.uri)}"]`); + if (!label) return; + const input = document.createElement("input"); + input.className = "rename-input"; + input.value = object.name || ""; + label.replaceWith(input); + input.focus(); + input.select(); + let committed = false; + async function commit() { + if (committed) return; + committed = true; + const name = input.value.trim(); + if (!name) { + handlers.cancel(); + return; + } + await handlers.commit(name); + } + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit().catch(showError); + } + if (event.key === "Escape") { + committed = true; + handlers.cancel(); + } + }); + input.addEventListener("blur", () => commit().catch(showError)); + } + + return { + startCreateObject, + startRename, + }; +} diff --git a/capsules/library/src/events.js b/capsules/library/src/events.js new file mode 100644 index 00000000..79850ccb --- /dev/null +++ b/capsules/library/src/events.js @@ -0,0 +1,309 @@ +import { + isDirectory, +} from "./model.js"; + +export function bindLibraryEvents({ + bindDialogEvents, + clearSelection, + copySelectedObjectsTo, + createFolder, + elements, + handleBrowserPopState, + hideDialog, + hideMenu, + loadCurrentFolder, + moveSelectedObjectsTo, + navigate, + navigateBack, + navigateForward, + navigateUp, + objectByUri, + openObject, + prepareDragSelection, + reorderPlace, + scheduleContentRender, + selectAllVisible, + selectRangeTo, + selectedObjects, + selectOnly, + setSort, + setView, + showBackgroundMenu, + showError, + showMenuForObject, + showPlaceMenu, + startRename, + state, + stopLibraryEventStream, + toggleSelected, + uploadFiles, +}) { + let draggingPlaceId = ""; + + elements.sidebar?.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + hideMenu(); + }); + elements.places.addEventListener("click", (event) => { + const button = event.target.closest(".place"); + if (button?.dataset.uri) { + navigate(button.dataset.uri).catch(showError); + } + }); + elements.places.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + const button = event.target.closest(".place"); + if (button?.dataset.uri) { + showPlaceMenu(button.dataset.uri, event.clientX, event.clientY); + return; + } + hideMenu(); + }); + elements.places.addEventListener("dragstart", (event) => { + const button = event.target.closest(".place"); + if (!button?.dataset.rootId) return; + draggingPlaceId = button.dataset.rootId; + button.classList.add("window-sidebar-item-dragging"); + elements.places.dataset.reordering = "true"; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-elastos-library-root-id", draggingPlaceId); + event.dataTransfer.setData("text/plain", button.dataset.uri || ""); + }); + elements.places.addEventListener("dragover", (event) => { + const button = event.target.closest(".place"); + if (button?.dataset.rootId && dataTransferHasType(event.dataTransfer, "application/x-elastos-library-root-id")) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + markPlaceDropTarget(elements, button, event); + return; + } + if (button?.dataset.uri && selectedObjects().length) { + event.preventDefault(); + event.dataTransfer.dropEffect = event.altKey ? "copy" : "move"; + } + }); + elements.places.addEventListener("dragleave", (event) => { + const button = event.target.closest(".place"); + if (button && !button.contains(event.relatedTarget)) { + delete button.dataset.dropPosition; + } + }); + elements.places.addEventListener("drop", (event) => { + const button = event.target.closest(".place"); + const sourceRootId = event.dataTransfer?.getData("application/x-elastos-library-root-id") || draggingPlaceId; + if (button?.dataset.rootId && sourceRootId) { + event.preventDefault(); + reorderPlace(sourceRootId, button.dataset.rootId, placeDropPosition(button, event)); + clearPlaceDropTargets(elements); + draggingPlaceId = ""; + return; + } + if (!button?.dataset.uri || !selectedObjects().length) return; + event.preventDefault(); + const action = event.altKey ? copySelectedObjectsTo : moveSelectedObjectsTo; + action(button.dataset.uri).catch(showError); + }); + elements.places.addEventListener("dragend", () => { + draggingPlaceId = ""; + clearPlaceDropTargets(elements); + }); + elements.breadcrumbs.addEventListener("click", (event) => { + const button = event.target.closest(".crumb"); + if (button?.dataset.uri) { + navigate(button.dataset.uri).catch(showError); + } + }); + elements.search.addEventListener("input", () => { + state.query = elements.search.value || ""; + scheduleContentRender(); + }); + elements.sortSelect.addEventListener("change", () => { + setSort(elements.sortSelect.value || "name"); + }); + elements.backButton.addEventListener("click", () => navigateBack().catch(showError)); + elements.forwardButton.addEventListener("click", () => navigateForward().catch(showError)); + elements.upButton.addEventListener("click", () => navigateUp().catch(showError)); + elements.refreshButton.addEventListener("click", () => loadCurrentFolder().catch(showError)); + elements.gridButton.addEventListener("click", () => setView("grid")); + elements.listButton.addEventListener("click", () => setView("list")); + elements.uploadButton.addEventListener("click", () => elements.fileInput.click()); + elements.newFolderButton.addEventListener("click", () => createFolder().catch(showError)); + elements.fileInput.addEventListener("change", () => { + uploadFiles(elements.fileInput.files).catch(showError); + elements.fileInput.value = ""; + }); + elements.content.addEventListener("click", (event) => { + if (isNameEditorTarget(event.target)) return; + const item = event.target.closest(".item"); + if (!item?.dataset.uri) { + clearSelection(); + return; + } + item.focus({ preventScroll: true }); + if (event.shiftKey) { + selectRangeTo(item.dataset.uri, event.metaKey || event.ctrlKey); + } else if (event.metaKey || event.ctrlKey) { + toggleSelected(item.dataset.uri); + } else { + selectOnly(item.dataset.uri); + } + }); + elements.content.addEventListener("dblclick", (event) => { + if (isNameEditorTarget(event.target)) return; + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + if (object) openObject(object).catch(showError); + }); + elements.content.addEventListener("contextmenu", (event) => { + if (isNameEditorTarget(event.target)) return; + event.preventDefault(); + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + if (object) { + showMenuForObject(object, event.clientX, event.clientY); + } else { + clearSelection(); + showBackgroundMenu(event.clientX, event.clientY); + } + }); + elements.content.addEventListener("dragstart", (event) => { + const item = event.target.closest(".item"); + if (!item?.dataset.uri) return; + prepareDragSelection(item.dataset.uri, item); + event.dataTransfer.effectAllowed = "copyMove"; + event.dataTransfer.setData("application/x-elastos-library-uris", JSON.stringify(Array.from(state.selectedUris))); + event.dataTransfer.setData("text/plain", item.dataset.uri); + }); + elements.content.addEventListener("dragover", (event) => { + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + const hasFiles = dataTransferHasType(event.dataTransfer, "Files"); + if (hasFiles || (object && isDirectory(object) && selectedObjects().length)) { + event.preventDefault(); + event.dataTransfer.dropEffect = hasFiles || event.altKey ? "copy" : "move"; + } + }); + elements.content.addEventListener("drop", (event) => { + event.preventDefault(); + const files = event.dataTransfer?.files; + if (files?.length) { + uploadFiles(files).catch(showError); + return; + } + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + if (object && isDirectory(object) && selectedObjects().length) { + const action = event.altKey ? copySelectedObjectsTo : moveSelectedObjectsTo; + action(object.uri).catch(showError); + } + }); + document.addEventListener("click", (event) => { + if (!event.target.closest(".context-menu")) { + hideMenu(); + } + }); + bindDialogEvents(); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hideMenu(); + hideDialog(); + clearSelection(); + return; + } + const editable = event.target.closest?.("input, textarea, select, [contenteditable='true']"); + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a" && !editable) { + event.preventDefault(); + selectAllVisible(); + } + if (event.key === "F2" && !editable) { + const objects = selectedObjects(); + if (objects.length === 1) { + event.preventDefault(); + startRename(objects[0]); + } + } + if (event.key === "Enter" && !editable && !isDialogOpen(elements) && !isMenuOpen(elements)) { + const objects = selectedObjects(); + if (objects.length) { + event.preventDefault(); + openSelectedObjects(objects, openObject, showError); + } + } + if ((event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) && !editable && !isDialogOpen(elements)) { + event.preventDefault(); + const object = selectedObjects()[0]; + if (object) { + showKeyboardObjectMenu(elements, object, showMenuForObject); + } else { + showKeyboardBackgroundMenu(elements, showBackgroundMenu); + } + } + }); + window.addEventListener("popstate", (event) => { + handleBrowserPopState(event).catch(showError); + }); + window.addEventListener("beforeunload", () => { + stopLibraryEventStream(); + }); +} + +function dataTransferHasType(dataTransfer, type) { + return Array.from(dataTransfer?.types || []).includes(type); +} + +function markPlaceDropTarget(elements, button, event) { + const position = placeDropPosition(button, event); + if (button.dataset.dropPosition === position) return; + for (const place of elements.places.querySelectorAll(".place[data-drop-position]")) { + if (place !== button) delete place.dataset.dropPosition; + } + button.dataset.dropPosition = position; +} + +function placeDropPosition(button, event) { + const rect = button.getBoundingClientRect(); + return event.clientY > rect.top + rect.height / 2 ? "after" : "before"; +} + +function clearPlaceDropTargets(elements) { + delete elements.places.dataset.reordering; + for (const place of elements.places.querySelectorAll(".place")) { + place.classList.remove("window-sidebar-item-dragging"); + delete place.dataset.dropPosition; + } +} + +function isNameEditorTarget(target) { + return !!target?.closest?.(".rename-input"); +} + +function isDialogOpen(elements) { + return elements.dialog && !elements.dialog.classList.contains("hidden"); +} + +function isMenuOpen(elements) { + return elements.contextMenu && !elements.contextMenu.classList.contains("hidden"); +} + +function showKeyboardObjectMenu(elements, object, showMenuForObject) { + const item = elements.content.querySelector(`[data-uri="${CSS.escape(object.uri)}"]`); + const rect = item?.getBoundingClientRect(); + showMenuForObject(object, rect ? rect.left + 16 : window.innerWidth / 2, rect ? rect.top + 16 : 120); +} + +function showKeyboardBackgroundMenu(elements, showBackgroundMenu) { + const rect = elements.content.getBoundingClientRect(); + showBackgroundMenu(rect.left + Math.min(48, rect.width / 2), rect.top + Math.min(48, rect.height / 2)); +} + +async function openSelectedObjects(objects, openObject, showError) { + try { + for (const object of objects) { + await openObject(object); + } + } catch (error) { + showError(error); + } +} diff --git a/capsules/library/src/menu.js b/capsules/library/src/menu.js new file mode 100644 index 00000000..f0614578 --- /dev/null +++ b/capsules/library/src/menu.js @@ -0,0 +1,158 @@ +export function createLibraryMenu({ contextMenu, perf, showError }) { + function menuAction(label, action, options = {}) { + const children = Array.isArray(options.children) + ? normalizedMenuActions(options.children) + : []; + return { + label, + action, + children, + disabled: !!options.disabled || (!children.length && typeof action !== "function"), + checked: !!options.checked, + }; + } + + function normalizedMenuActions(actions) { + const normalized = []; + for (const entry of actions) { + if (entry === "-") { + if (normalized.length && normalized[normalized.length - 1] !== "-") normalized.push(entry); + continue; + } + const item = Array.isArray(entry) + ? { label: entry[0], action: entry[1], disabled: !!entry[2] } + : { ...entry }; + if (Array.isArray(item.children)) { + item.children = normalizedMenuActions(item.children); + } + normalized.push(item); + } + while (normalized[0] === "-") normalized.shift(); + while (normalized[normalized.length - 1] === "-") normalized.pop(); + return normalized; + } + + function renderMenu(actions, x, y) { + const startedAt = performance.now(); + contextMenu.innerHTML = ""; + const menuActions = normalizedMenuActions(actions); + if (!menuActions.length) { + hideMenu(); + return; + } + renderMenuEntries(contextMenu, menuActions); + contextMenu.style.visibility = "hidden"; + contextMenu.classList.remove("hidden"); + const rect = contextMenu.getBoundingClientRect(); + const margin = 8; + const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin)); + const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)); + contextMenu.style.left = left + "px"; + contextMenu.style.top = top + "px"; + contextMenu.dataset.submenuSide = left + rect.width + 226 > window.innerWidth ? "left" : "right"; + contextMenu.style.visibility = ""; + perf.menuRenderCount += 1; + perf.lastMenuRender = { + durationMs: performance.now() - startedAt, + actionCount: countMenuActions(menuActions), + }; + } + + function renderMenuEntries(container, actions) { + for (const entry of actions) { + if (entry === "-") { + const divider = document.createElement("div"); + divider.className = "menu-divider"; + divider.setAttribute("role", "separator"); + container.appendChild(divider); + continue; + } + const { label, action, disabled, checked } = entry; + const children = Array.isArray(entry.children) ? entry.children : []; + const wrapper = document.createElement("div"); + wrapper.className = children.length ? "menu-entry menu-entry-has-children" : "menu-entry"; + const button = document.createElement("button"); + button.className = "menu-item"; + button.type = "button"; + button.title = String(label || ""); + const labelNode = document.createElement("span"); + labelNode.className = "menu-item-label"; + labelNode.textContent = label; + button.appendChild(labelNode); + if (checked) { + const markNode = document.createElement("span"); + markNode.className = "menu-item-mark"; + markNode.textContent = "\u2713"; + button.appendChild(markNode); + } + if (children.length) { + const arrowNode = document.createElement("span"); + arrowNode.className = "menu-item-mark menu-item-arrow"; + arrowNode.textContent = "\u203a"; + button.appendChild(arrowNode); + const openSubmenu = () => { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + }; + button.addEventListener("pointerenter", openSubmenu); + button.addEventListener("mouseenter", openSubmenu); + button.addEventListener("focus", openSubmenu); + } + const runnable = typeof action === "function"; + button.disabled = !!disabled || (!children.length && !runnable); + button.addEventListener("click", (event) => { + event.stopPropagation(); + if (children.length) { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + return; + } + if (button.disabled || !runnable) return; + hideMenu(); + Promise.resolve(action()).catch(showError); + }); + wrapper.appendChild(button); + if (children.length) { + const submenu = document.createElement("div"); + submenu.className = "menu-submenu"; + renderMenuEntries(submenu, children); + wrapper.appendChild(submenu); + wrapper.addEventListener("mouseenter", () => { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + }); + wrapper.addEventListener("focusin", () => { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + }); + } + container.appendChild(wrapper); + } + } + + function closeSiblingSubmenus(wrapper) { + for (const entry of wrapper.parentElement?.querySelectorAll(":scope > .menu-entry[data-open='true']") || []) { + if (entry !== wrapper) entry.dataset.open = "false"; + } + } + + function countMenuActions(actions) { + return actions.reduce((count, entry) => { + if (entry === "-") return count; + return count + 1 + countMenuActions(Array.isArray(entry.children) ? entry.children : []); + }, 0); + } + + function hideMenu() { + for (const entry of contextMenu.querySelectorAll(".menu-entry[data-open='true']")) { + entry.dataset.open = "false"; + } + contextMenu.classList.add("hidden"); + } + + return { + hideMenu, + menuAction, + renderMenu, + }; +} diff --git a/capsules/library/src/model.js b/capsules/library/src/model.js new file mode 100644 index 00000000..ca01ef03 --- /dev/null +++ b/capsules/library/src/model.js @@ -0,0 +1,209 @@ +export function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function shortUri(uri) { + if (!uri) return ""; + if (uri.length <= 44) return uri; + return uri.slice(0, 24) + "..." + uri.slice(-14); +} + +export function baseName(uri) { + const clean = String(uri || "").replace(/\/+$/, ""); + return clean.split("/").pop() || "Explorer"; +} + +export function parentUri(uri) { + const clean = String(uri || "").replace(/\/+$/, ""); + const index = clean.lastIndexOf("/"); + return index > "localhost://".length ? clean.slice(0, index) : clean; +} + +export function childUri(parent, name) { + return String(parent || "").replace(/\/+$/, "") + "/" + encodeURIComponent(name).replace(/%20/g, " "); +} + +export function nextObjectName(objects, base, extension = "") { + const existing = new Set(objects.map((object) => String(object.name || "").toLowerCase())); + const initial = `${base}${extension}`; + if (!existing.has(initial.toLowerCase())) return initial; + for (let index = 2; index < 1000; index += 1) { + const candidate = `${base} ${index}${extension}`; + if (!existing.has(candidate.toLowerCase())) return candidate; + } + return `${base} ${Date.now()}${extension}`; +} + +export function isDirectory(object) { + return object && object.kind === "directory"; +} + +export function inTrash(object) { + return object && object.uri.includes("/.Trash"); +} + +export function isBlockedObject(object) { + return !!object?.blocked_reason; +} + +export function hasCapability(object, capability) { + const capabilities = object && object.capabilities; + return !Array.isArray(capabilities) || capabilities.includes(capability); +} + +export function isWebSpaceUri(uri) { + const value = String(uri || "").replace(/\/+$/, ""); + return value === "localhost://WebSpaces" || value.startsWith("localhost://WebSpaces/"); +} + +export function viewerOptions(object) { + return Array.isArray(object && object.viewers) ? object.viewers : []; +} + +export function contentCid(object) { + return String(object?.content_cid || "").trim(); +} + +export function publishedCid(object) { + return String(object?.published_cid || "").trim(); +} + +export function visibilityContract(object) { + const visibility = object?.metadata?.visibility; + if (visibility && typeof visibility === "object") return visibility; + return { + schema: "elastos.library.visibility/v1", + placement: inTrash(object) ? "trash" : "private_folder", + placement_label: inTrash(object) ? "Trash" : "Private folder", + effective_access: object?.published ? "public_content_link" : "principal_private", + published: !!object?.published, + published_cid: publishedCid(object) || null, + published_link: publishedCid(object) ? `elastos://${publishedCid(object)}` : null, + shared: !!object?.shared, + share_policy: object?.shared ? "shared" : "not_shared", + public_folder_policy: "placement_only", + publish_required_for_public_link: !isDirectory(object) && !object?.published, + }; +} + +export function previewKind(object) { + if (!object || isDirectory(object)) return ""; + const mime = String(object.mime || ""); + const name = String(object.name || "").toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + if (mime === "application/pdf" || name.endsWith(".pdf")) return "pdf"; + if ( + mime.startsWith("text/") || + [ + ".txt", + ".md", + ".json", + ".csv", + ".log", + ".html", + ".css", + ".js", + ".ts", + ".rs", + ".toml", + ".yaml", + ".yml", + ].some((ext) => name.endsWith(ext)) + ) { + return "text"; + } + return ""; +} + +export function canPreviewObject(object) { + return !!previewKind(object); +} + +export function iconFor(object) { + if (inTrash(object)) return "icons/trash.svg"; + if (isDirectory(object)) return "icons/folder.svg"; + const mime = String(object.mime || ""); + const name = String(object.name || "").toLowerCase(); + if (mime.startsWith("image/")) return "icons/file-image.svg"; + if (mime.startsWith("video/")) return "icons/file-video.svg"; + if (mime.startsWith("audio/")) return "icons/file-audio.svg"; + if (mime.includes("pdf")) return "icons/file-pdf.svg"; + if (isArchiveName(name)) return "icons/file-zip.svg"; + if (name.endsWith(".json")) return "icons/file-json.svg"; + if (name.endsWith(".md")) return "icons/file-md.svg"; + if (mime.startsWith("text/")) return "icons/file-text.svg"; + return "icons/file.svg"; +} + +function isArchiveName(name) { + return [ + ".zip", + ".tar", + ".tar.gz", + ".tgz", + ".tar.xz", + ".txz", + ".tar.bz2", + ".tbz2", + ".tar.zst", + ".tzst", + ".7z", + ".rar", + ".xz", + ".bz2", + ".zst", + ".lz4", + ".gz", + ].some((extension) => name.endsWith(extension)); +} + +export function formatBytes(bytes) { + const value = Number(bytes || 0); + if (!value) return "-"; + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +export function formatTime(timestamp) { + const value = Number(timestamp || 0); + if (!value) return "-"; + return new Date(value * 1000).toLocaleString(); +} + +export function fileToBase64(file, onProgress) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Could not read file.")); + reader.onprogress = (event) => { + if (event.lengthComputable && typeof onProgress === "function") { + onProgress(event.loaded / Math.max(event.total, 1)); + } + }; + reader.onload = () => { + const dataUrl = String(reader.result || ""); + resolve(dataUrl.split(",", 2)[1] || ""); + }; + reader.readAsDataURL(file); + }); +} + +export function base64ToBlob(data, mime) { + const binary = atob(data || ""); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mime || "application/octet-stream" }); +} diff --git a/capsules/library/src/navigation.js b/capsules/library/src/navigation.js new file mode 100644 index 00000000..8bd21574 --- /dev/null +++ b/capsules/library/src/navigation.js @@ -0,0 +1,152 @@ +const LIBRARY_HISTORY_SCHEMA = "elastos.library.history/v1"; +const LIBRARY_HISTORY_GUARD_SCHEMA = "elastos.library.history.guard/v1"; + +export function createLibraryNavigation({ + backButton, + forwardButton, + loadCurrentFolder, + parentUri, + rootForUri, + searchInput, + setStatus, + state, + syncRouteChrome = () => {}, + upButton, +}) { + function syncNavigationButtons() { + backButton.disabled = state.backStack.length === 0; + forwardButton.disabled = state.forwardStack.length === 0; + const root = rootForUri(state.currentUri); + upButton.disabled = !root || state.currentUri === root.uri; + } + + function browserHistoryAvailable() { + return !!(window.history && typeof window.history.pushState === "function" && typeof window.history.replaceState === "function"); + } + + function createHistoryEntry(uri) { + return { key: `library:${++state.historyKeyCounter}`, uri }; + } + + function libraryHistoryState(entry) { + return { + schema: LIBRARY_HISTORY_SCHEMA, + key: entry.key, + uri: entry.uri, + }; + } + + function libraryHistoryGuardState(uri) { + return { + schema: LIBRARY_HISTORY_GUARD_SCHEMA, + uri, + }; + } + + function syncNavigationStacksFromHistory() { + if (state.historyIndex < 0) return; + state.backStack = state.historyEntries.slice(0, state.historyIndex).map((entry) => entry.uri); + state.forwardStack = state.historyEntries.slice(state.historyIndex + 1).map((entry) => entry.uri).reverse(); + syncNavigationButtons(); + } + + function installBrowserHistory() { + if (!browserHistoryAvailable() || !state.currentUri) return; + const entry = createHistoryEntry(state.currentUri); + state.historyEntries = [entry]; + state.historyIndex = 0; + window.history.replaceState(libraryHistoryGuardState(state.currentUri), "", window.location.href); + window.history.pushState(libraryHistoryState(entry), "", window.location.href); + syncNavigationStacksFromHistory(); + } + + function pushBrowserHistory(uri) { + if (!browserHistoryAvailable() || !uri || state.historyIndex < 0) return false; + state.historyEntries = state.historyEntries.slice(0, state.historyIndex + 1); + const entry = createHistoryEntry(uri); + state.historyEntries.push(entry); + state.historyIndex = state.historyEntries.length - 1; + window.history.pushState(libraryHistoryState(entry), "", window.location.href); + syncNavigationStacksFromHistory(); + return true; + } + + async function handleBrowserPopState(event) { + const data = event.state || {}; + if (data.schema === LIBRARY_HISTORY_GUARD_SCHEMA) { + const entry = state.historyEntries[state.historyIndex] || createHistoryEntry(state.currentUri); + window.history.pushState(libraryHistoryState(entry), "", window.location.href); + setStatus("Explorer kept focus."); + return; + } + if (data.schema !== LIBRARY_HISTORY_SCHEMA || !data.key || !data.uri) return; + const index = state.historyEntries.findIndex((entry) => entry.key === data.key); + if (index === -1) return; + state.historyIndex = index; + syncNavigationStacksFromHistory(); + if (state.currentUri === data.uri) return; + state.currentUri = data.uri; + state.query = ""; + searchInput.value = ""; + syncRouteChrome(); + syncNavigationButtons(); + await loadCurrentFolder({ useCache: true }); + } + + async function navigate(uri, options = {}) { + if (!uri || uri === state.currentUri) { + return; + } + const previousUri = state.currentUri; + if (options.record !== false && state.currentUri) { + if (!pushBrowserHistory(uri)) { + state.backStack.push(previousUri); + state.forwardStack = []; + } + } + state.currentUri = uri; + state.query = ""; + searchInput.value = ""; + syncRouteChrome(); + syncNavigationButtons(); + await loadCurrentFolder({ useCache: true }); + } + + async function navigateBack() { + if (browserHistoryAvailable() && state.historyIndex > 0) { + window.history.back(); + return; + } + const uri = state.backStack.pop(); + if (!uri) return; + if (state.currentUri) state.forwardStack.push(state.currentUri); + await navigate(uri, { record: false }); + } + + async function navigateForward() { + if (browserHistoryAvailable() && state.historyIndex >= 0 && state.historyIndex < state.historyEntries.length - 1) { + window.history.forward(); + return; + } + const uri = state.forwardStack.pop(); + if (!uri) return; + if (state.currentUri) state.backStack.push(state.currentUri); + await navigate(uri, { record: false }); + } + + async function navigateUp() { + const root = rootForUri(state.currentUri); + if (!root || state.currentUri === root.uri) return; + await navigate(parentUri(state.currentUri)); + } + + return { + handleBrowserPopState, + installBrowserHistory, + navigate, + navigateBack, + navigateForward, + navigateUp, + syncNavigationButtons, + }; +} diff --git a/capsules/library/src/preview.js b/capsules/library/src/preview.js new file mode 100644 index 00000000..e682600e --- /dev/null +++ b/capsules/library/src/preview.js @@ -0,0 +1,86 @@ +import { + base64ToBlob, + escapeHtml, + formatBytes, + previewKind, + shortUri, +} from "./model.js"; + +const PREVIEW_MAX_BYTES = 8 * 1024 * 1024; +const TEXT_PREVIEW_MAX_BYTES = 1 * 1024 * 1024; + +export function createLibraryPreview({ + dialog, + providerApi, + setStatus, + showProperties, + state, +}) { + function revokePreviewUrl() { + if (state.previewUrl) { + URL.revokeObjectURL(state.previewUrl); + state.previewUrl = ""; + } + } + + async function previewObject(object) { + const kind = previewKind(object); + if (!kind) { + showProperties(object); + return; + } + const maxBytes = kind === "text" ? TEXT_PREVIEW_MAX_BYTES : PREVIEW_MAX_BYTES; + if (Number(object.size || 0) > maxBytes) { + setStatus(`Preview for ${object.name} is too large. Use Open or Download.`); + showProperties(object); + return; + } + setStatus(`Loading preview for ${object.name}...`); + const data = await providerApi("read", { uri: object.uri }); + const blob = base64ToBlob(data.data, data.object?.mime || object.mime); + revokePreviewUrl(); + let previewMarkup = ""; + if (kind === "text") { + const text = await blob.text(); + previewMarkup = `
${escapeHtml(text)}
`; + } else { + state.previewUrl = URL.createObjectURL(blob); + const url = escapeHtml(state.previewUrl); + if (kind === "image") { + previewMarkup = `${escapeHtml(object.name)}`; + } else if (kind === "video") { + previewMarkup = ``; + } else if (kind === "audio") { + previewMarkup = ``; + } else if (kind === "pdf") { + previewMarkup = ``; + } + } + dialog.innerHTML = ` +
+
+

Preview

+

${escapeHtml(object.name)}

+
+
${previewMarkup}
+
+
Type
${escapeHtml(object.mime || "application/octet-stream")}
+
Size
${escapeHtml(formatBytes(object.size))}
+
Source
${escapeHtml(shortUri(object.uri))}
+
+
+ + +
+
+ `; + dialog.dataset.previewUri = object.uri; + dialog.classList.remove("hidden"); + setStatus(`Previewing ${object.name}.`); + } + + return { + previewObject, + revokePreviewUrl, + }; +} diff --git a/capsules/library/src/realtime.js b/capsules/library/src/realtime.js new file mode 100644 index 00000000..2adea651 --- /dev/null +++ b/capsules/library/src/realtime.js @@ -0,0 +1,85 @@ +export function createLibraryRealtime({ + loadCurrentFolder, + parentUri, + showError, + state, +}) { + function startLibraryEventStream() { + if (!("EventSource" in window) || state.eventSource || !state.homeToken) { + return; + } + window.clearTimeout(state.eventReconnectTimer); + const url = "/api/provider/object/events/stream?home_token=" + encodeURIComponent(state.homeToken); + const source = new EventSource(url); + state.eventSource = source; + source.addEventListener("library-events", (event) => { + try { + handleLibraryEventsPayload(JSON.parse(event.data || "{}")); + } catch (error) { + console.warn("Library event stream returned invalid payload", error); + } + }); + source.onerror = () => { + source.close(); + if (state.eventSource === source) { + state.eventSource = null; + } + state.eventReconnectTimer = window.setTimeout(startLibraryEventStream, 2_000); + }; + } + + function stopLibraryEventStream() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + window.clearTimeout(state.eventReconnectTimer); + window.clearTimeout(state.eventRefreshTimer); + } + + function handleLibraryEventsPayload(payload) { + if (payload?.schema !== "elastos.library.events/v1") { + return; + } + const events = Array.isArray(payload.events) ? payload.events : []; + if (events.some(eventTouchesCurrentFolder)) { + scheduleLibraryEventRefresh(); + } + } + + function scheduleLibraryEventRefresh() { + window.clearTimeout(state.eventRefreshTimer); + state.eventRefreshTimer = window.setTimeout(() => { + loadCurrentFolder().catch(showError); + }, 180); + } + + function eventTouchesCurrentFolder(event) { + if (!state.currentUri) return false; + return eventUris(event).some((uri) => uriTouchesFolder(uri, state.currentUri)); + } + + function eventUris(event) { + const details = event && typeof event.details === "object" ? event.details : {}; + return [ + event?.uri, + details.old_uri, + details.original_uri, + details.source_uri, + details.trash_uri, + details.target_uri, + details.object?.uri, + ].filter(Boolean).map(String); + } + + function uriTouchesFolder(uri, folderUri) { + const folder = String(folderUri || "").replace(/\/+$/, ""); + const value = String(uri || "").replace(/\/+$/, ""); + return value === folder || value.startsWith(folder + "/") || parentUri(value) === folder; + } + + return { + startLibraryEventStream, + stopLibraryEventStream, + }; +} diff --git a/capsules/library/src/render.js b/capsules/library/src/render.js new file mode 100644 index 00000000..7d2f3313 --- /dev/null +++ b/capsules/library/src/render.js @@ -0,0 +1,238 @@ +import { + escapeHtml, + formatBytes, + formatTime, + iconFor, + inTrash, + isWebSpaceUri, + isBlockedObject, + isDirectory, + shortUri, + visibilityContract, +} from "./model.js"; + +const LARGE_RENDER_THRESHOLD = 240; +const INITIAL_RENDER_LIMIT = 120; +const RENDER_CHUNK_SIZE = 180; +const OBJECT_NODE_CACHE_LIMIT = 3_000; + +export function iconPlaceholder(src, className) { + const safeSrc = escapeHtml(src); + return ``; +} + +export function createLibraryRenderer({ + elements, + isSelected, + perf, + selectedObjects, + state, + visibleObjects, +}) { + function renderContent() { + const startedAt = performance.now(); + const job = ++state.contentRenderJob; + elements.content.dataset.view = state.view === "list" ? "list" : "grid"; + elements.sortSelect.value = state.sort; + const objects = visibleObjects(); + if (!objects.length) { + elements.content.dataset.empty = "true"; + const copy = emptyStateCopy(state); + const empty = document.createElement("div"); + empty.className = "empty"; + empty.innerHTML = `

${escapeHtml(copy.title)}

${escapeHtml(copy.body)}

`; + elements.content.replaceChildren(empty); + perf.contentRenderCount += 1; + perf.lastContentRender = { + durationMs: performance.now() - startedAt, + objectCount: 0, + view: state.view, + }; + return; + } + elements.content.dataset.empty = "false"; + const chunked = objects.length > LARGE_RENDER_THRESHOLD; + const firstLimit = chunked ? Math.min(INITIAL_RENDER_LIMIT, objects.length) : objects.length; + const fragment = document.createDocumentFragment(); + if (state.view === "list") { + fragment.appendChild(renderListHeader()); + } + for (const object of objects.slice(0, firstLimit)) { + fragment.appendChild(renderObject(object)); + } + elements.content.replaceChildren(fragment); + pruneObjectNodeCache(objects); + perf.contentRenderCount += 1; + perf.lastContentRender = { + durationMs: performance.now() - startedAt, + objectCount: objects.length, + initialRenderedCount: firstLimit, + renderedCount: firstLimit, + complete: !chunked, + chunked, + view: state.view, + }; + if (chunked) { + renderContentChunks(objects, firstLimit, job, startedAt); + } + } + + function emptyStateCopy(state) { + if (state.query) { + return { title: "No matching objects", body: "Try another search." }; + } + if (isWebSpaceUri(state.currentUri)) { + return { + title: "No connected spaces", + body: "Provider-backed spaces appear here when a resolver is available. Read-only spaces hide mutable actions; writable spaces use provider-owned storage.", + }; + } + return { title: "This folder is empty", body: "Upload a file or create a folder." }; + } + + function renderContentChunks(objects, startIndex, job, startedAt) { + window.requestAnimationFrame(() => { + if (job !== state.contentRenderJob) return; + const fragment = document.createDocumentFragment(); + const nextIndex = Math.min(startIndex + RENDER_CHUNK_SIZE, objects.length); + for (const object of objects.slice(startIndex, nextIndex)) { + fragment.appendChild(renderObject(object)); + } + elements.content.appendChild(fragment); + if (nextIndex < objects.length) { + renderContentChunks(objects, nextIndex, job, startedAt); + return; + } + perf.lastContentRender = { + ...(perf.lastContentRender || {}), + durationMs: perf.lastContentRender?.durationMs || 0, + completeDurationMs: performance.now() - startedAt, + renderedCount: objects.length, + complete: true, + }; + }); + } + + function renderListHeader() { + const header = document.createElement("div"); + header.className = "explore-table-headers"; + header.innerHTML = ` + Name + Modified + Size + Type + `; + return header; + } + + function renderObject(object) { + const signature = objectRenderSignature(object); + const cached = state.objectNodeCache.get(object.uri); + if (cached && cached.signature === signature && !cached.node.querySelector(".rename-input")) { + perf.objectNodeCacheHits += 1; + cached.node.dataset.selected = isSelected(object.uri) ? "true" : "false"; + state.objectNodeCache.delete(object.uri); + state.objectNodeCache.set(object.uri, cached); + return cached.node; + } + perf.objectNodeCacheMisses += 1; + const item = document.createElement("article"); + item.className = "item"; + item.dataset.uri = object.uri; + item.dataset.kind = object.kind; + item.dataset.blocked = isBlockedObject(object) ? "true" : "false"; + item.dataset.selected = isSelected(object.uri) ? "true" : "false"; + item.draggable = !isBlockedObject(object); + item.tabIndex = 0; + const visibility = visibilityContract(object); + const inPublicFolder = visibility.placement === "public_folder"; + const badges = [ + isBlockedObject(object) ? 'Blocked' : "", + inPublicFolder && !object.published ? 'Public folder' : "", + object.published ? 'Published' : "", + inTrash(object) ? 'Trash' : "", + ].join(""); + const badgesMarkup = badges ? `${badges}` : ""; + item.innerHTML = ` + ${iconPlaceholder(iconFor(object), "file-icon")} + ${escapeHtml(object.name)} + ${escapeHtml(formatTime(object.modified_at))} + ${escapeHtml(isDirectory(object) ? "-" : formatBytes(object.size))} + ${escapeHtml(isDirectory(object) ? "Folder" : object.mime || "File")} + ${badgesMarkup} + `; + state.objectNodeCache.set(object.uri, { signature, node: item }); + return item; + } + + function objectRenderSignature(object) { + return [ + object.uri, + object.kind, + object.name, + object.mime || "", + object.size || 0, + object.modified_at || 0, + object.revision || "", + object.content_cid || "", + object.published_cid || "", + object.availability || "", + object.metadata?.visibility?.placement || "", + object.metadata?.visibility?.effective_access || "", + object.published ? 1 : 0, + object.shared ? 1 : 0, + object.blocked_reason || "", + inTrash(object) ? 1 : 0, + isBlockedObject(object) ? 1 : 0, + ].join("\u0000"); + } + + function pruneObjectNodeCache(visible) { + if (state.objectNodeCache.size <= OBJECT_NODE_CACHE_LIMIT) return; + const visibleUris = new Set(visible.map((object) => object.uri)); + for (const uri of state.objectNodeCache.keys()) { + if (state.objectNodeCache.size <= OBJECT_NODE_CACHE_LIMIT) break; + if (!visibleUris.has(uri)) state.objectNodeCache.delete(uri); + } + } + + function renderFooter() { + const visible = visibleObjects().length; + const selected = selectedObjects().length; + const prefix = selected ? `${selected} selected · ` : ""; + elements.footerLeft.textContent = `${prefix}${visible} object${visible === 1 ? "" : "s"}`; + elements.footerRight.textContent = shortUri(state.currentUri); + } + + function scheduleContentRender() { + if (state.contentRenderFrame) return; + state.contentRenderFrame = window.requestAnimationFrame(() => { + state.contentRenderFrame = 0; + renderContent(); + renderFooter(); + }); + } + + function syncViewButtons() { + elements.gridButton.classList.toggle("layout-toggle-segment-active", state.view !== "list"); + elements.listButton.classList.toggle("layout-toggle-segment-active", state.view === "list"); + elements.gridButton.setAttribute("aria-pressed", state.view !== "list" ? "true" : "false"); + elements.listButton.setAttribute("aria-pressed", state.view === "list" ? "true" : "false"); + } + + function syncContentViewMode() { + elements.content.dataset.view = state.view === "list" ? "list" : "grid"; + elements.content.querySelector(".explore-table-headers")?.remove(); + if (state.view === "list" && visibleObjects().length) { + elements.content.prepend(renderListHeader()); + } + } + + return { + renderContent, + renderFooter, + scheduleContentRender, + syncContentViewMode, + syncViewButtons, + }; +} diff --git a/capsules/library/src/selection.js b/capsules/library/src/selection.js new file mode 100644 index 00000000..f994dc7d --- /dev/null +++ b/capsules/library/src/selection.js @@ -0,0 +1,103 @@ +export function createLibrarySelection({ + content, + renderFooter, + state, + visibleObjects, +}) { + function objectByUri(uri) { + return state.objectsByUri.get(uri); + } + + function selectedObjects() { + return Array.from(state.selectedUris) + .map((uri) => state.objectsByUri.get(uri)) + .filter(Boolean); + } + + function isSelected(uri) { + return state.selectedUris.has(uri); + } + + function selectOnly(uri) { + state.selectedUris.clear(); + if (uri) state.selectedUris.add(uri); + state.selectionAnchorUri = uri || ""; + syncSelectionDom(); + } + + function toggleSelected(uri) { + if (!uri) return; + if (state.selectedUris.has(uri)) { + state.selectedUris.delete(uri); + } else { + state.selectedUris.add(uri); + } + state.selectionAnchorUri = uri; + syncSelectionDom(); + } + + function selectRangeTo(uri, extend = false) { + if (!uri) return; + const visible = visibleObjects(); + const anchorUri = state.selectionAnchorUri || Array.from(state.selectedUris)[0] || uri; + const anchorIndex = visible.findIndex((object) => object.uri === anchorUri); + const targetIndex = visible.findIndex((object) => object.uri === uri); + if (anchorIndex < 0 || targetIndex < 0) { + selectOnly(uri); + return; + } + if (!extend) state.selectedUris.clear(); + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + for (const object of visible.slice(start, end + 1)) { + state.selectedUris.add(object.uri); + } + state.selectionAnchorUri = anchorUri; + syncSelectionDom(); + } + + function selectAllVisible() { + const visible = visibleObjects(); + state.selectedUris = new Set(visible.map((object) => object.uri)); + state.selectionAnchorUri = visible[0]?.uri || ""; + syncSelectionDom(); + } + + function clearSelection(render = true) { + state.selectedUris.clear(); + state.selectionAnchorUri = ""; + if (render) { + syncSelectionDom(); + } + } + + function syncSelectionDom() { + for (const item of content.querySelectorAll(".item[data-uri]")) { + item.dataset.selected = state.selectedUris.has(item.dataset.uri) ? "true" : "false"; + } + renderFooter(); + } + + function prepareDragSelection(uri, item) { + if (!isSelected(uri)) { + state.selectedUris.clear(); + state.selectedUris.add(uri); + state.selectionAnchorUri = uri; + if (item) item.dataset.selected = "true"; + syncSelectionDom(); + } + } + + return { + clearSelection, + isSelected, + objectByUri, + prepareDragSelection, + selectAllVisible, + selectedObjects, + selectOnly, + selectRangeTo, + syncSelectionDom, + toggleSelected, + }; +} diff --git a/capsules/library/src/state.js b/capsules/library/src/state.js new file mode 100644 index 00000000..edd2274f --- /dev/null +++ b/capsules/library/src/state.js @@ -0,0 +1,182 @@ +export const MUTATING_PROVIDER_OPS = new Set([ + "write", + "mkdir", + "rename", + "move", + "copy", + "trash", + "restore", + "delete_permanently", + "extract_archive", + "compress_archive", + "publish", + "unpublish", + "repair", + "share", +]); + +export function createLibraryState({ queryParams, storage, perfTarget }) { + const state = { + homeToken: queryParams.get("home_token") || "", + mode: queryParams.get("mode") === "attach" ? "attach" : "browse", + returnTarget: queryParams.get("returnTarget") || "", + initialUri: queryParams.get("uri") || "", + initialObjectUri: queryParams.get("objectUri") || "", + initialAction: queryParams.get("action") || "", + initialActionHandled: false, + roots: [], + currentUri: "", + currentObject: null, + objects: [], + objectsByUri: new Map(), + objectsVersion: 0, + visibleCacheKey: "", + visibleCache: [], + folderCache: new Map(), + folderPrefetches: new Map(), + rootPrefetchStarted: false, + contentRenderFrame: 0, + contentRenderJob: 0, + objectNodeCache: new Map(), + selectedUris: new Set(), + selectionAnchorUri: "", + view: storage.getItem("library.view") || "grid", + sort: storage.getItem("library.sort") || "name", + sortOrder: storage.getItem("library.sortOrder") || "asc", + sidebarOrder: readStoredStringArray(storage.getItem("library.sidebarOrder")), + showHidden: storage.getItem("library.showHidden") === "true", + query: "", + loading: false, + loadSeq: 0, + eventSource: null, + eventReconnectTimer: null, + eventRefreshTimer: null, + uploads: [], + previewUrl: "", + draftCounter: 0, + backStack: [], + forwardStack: [], + historyEntries: [], + historyIndex: -1, + historyKeyCounter: 0, + clipboard: { + op: "", + uris: [], + }, + }; + const perf = perfTarget || {}; + Object.assign(perf, { + iconFetchCount: 0, + renderPlacesCount: 0, + contentRenderCount: 0, + menuRenderCount: 0, + uploadRenderCount: 0, + uploadRenderScheduledCount: 0, + lastContentRender: null, + lastMenuRender: null, + folderCacheHits: 0, + folderCacheSize: 0, + objectNodeCacheHits: 0, + objectNodeCacheMisses: 0, + }); + return { state, perf }; +} + +function readStoredStringArray(value) { + try { + const parsed = JSON.parse(value || "[]"); + return Array.isArray(parsed) + ? parsed.filter((entry) => typeof entry === "string" && entry) + : []; + } catch { + return []; + } +} + +export function setLibraryObjects(state, objects) { + state.objects = Array.isArray(objects) ? objects : []; + state.objectsByUri = new Map(state.objects.map((object) => [object.uri, object])); + state.objectsVersion += 1; + invalidateVisibleCache(state); +} + +export function cacheFolderListing(state, perf, uri, objects, object = null) { + const safeObjects = Array.isArray(objects) ? objects : []; + const cached = { + object, + objects: safeObjects, + signature: `${folderListingSignature(safeObjects)}\u0000${folderObjectSignature(object)}`, + }; + state.folderCache.set(uri, cached); + perf.folderCacheSize = state.folderCache.size; + return cached; +} + +function folderObjectSignature(object) { + if (!object) return ""; + const metadata = object.metadata || {}; + return [ + object.uri || "", + metadata.readonly === false ? "writable" : "readonly", + metadata.access_policy || "", + metadata.webspace_kind || "", + ].join("\u0001"); +} + +export function visibleObjectsForState(state) { + const cacheKey = [ + state.objectsVersion, + state.query, + state.showHidden ? "hidden" : "visible", + state.sort, + state.sortOrder, + ].join("\u0000"); + if (state.visibleCacheKey === cacheKey) return state.visibleCache; + const query = state.query.trim().toLowerCase(); + const objects = state.objects.filter((object) => { + if (!state.showHidden && String(object.name || "").startsWith(".")) return false; + if (!query) return true; + return [object.name, object.uri, object.mime, object.content_cid || "", object.published_cid || ""] + .join("\n") + .toLowerCase() + .includes(query); + }); + objects.sort((left, right) => { + if (left.kind !== right.kind) { + return left.kind === "directory" ? -1 : 1; + } + let result = 0; + if (state.sort === "modified") result = Number(left.modified_at || 0) - Number(right.modified_at || 0); + else if (state.sort === "size") result = Number(left.size || 0) - Number(right.size || 0); + else if (state.sort === "type") result = String(left.mime || "").localeCompare(String(right.mime || "")); + else result = String(left.name || "").localeCompare(String(right.name || ""), undefined, { sensitivity: "base" }); + return state.sortOrder === "desc" ? -result : result; + }); + state.visibleCacheKey = cacheKey; + state.visibleCache = objects; + return objects; +} + +function invalidateVisibleCache(state) { + state.visibleCacheKey = ""; + state.visibleCache = []; +} + +function folderListingSignature(objects) { + return JSON.stringify((Array.isArray(objects) ? objects : []).map((object) => [ + object.uri, + object.revision, + object.kind, + object.name, + object.size, + object.modified_at, + object.content_cid || "", + object.published_cid || "", + object.availability || "", + object.metadata?.visibility?.placement || "", + object.metadata?.visibility?.effective_access || "", + object.published ? 1 : 0, + object.shared ? 1 : 0, + object.blocked_reason || "", + ])); +} diff --git a/capsules/library/src/uploads.js b/capsules/library/src/uploads.js new file mode 100644 index 00000000..b449cea5 --- /dev/null +++ b/capsules/library/src/uploads.js @@ -0,0 +1,54 @@ +import { escapeHtml } from "./model.js"; + +export function createLibraryUploads({ container, perf, state }) { + let uploadRenderFrame = 0; + + function cancelUploadRender() { + if (!uploadRenderFrame) return; + window.cancelAnimationFrame(uploadRenderFrame); + uploadRenderFrame = 0; + } + + function scheduleUploadRender() { + if (uploadRenderFrame) return; + perf.uploadRenderScheduledCount += 1; + uploadRenderFrame = window.requestAnimationFrame(() => { + uploadRenderFrame = 0; + renderUploads(); + }); + } + + function renderUploads() { + cancelUploadRender(); + perf.uploadRenderCount += 1; + if (!state.uploads.length) { + container.classList.add("hidden"); + container.innerHTML = ""; + return; + } + container.classList.remove("hidden"); + container.innerHTML = state.uploads.map((upload) => ` +
+
+
${escapeHtml(upload.name)}
+
${escapeHtml(upload.status)}
+
+
+
+
+
+ `).join(""); + } + + function setUploadProgress(id, patch) { + const upload = state.uploads.find((entry) => entry.id === id); + if (!upload) return; + Object.assign(upload, patch); + scheduleUploadRender(); + } + + return { + renderUploads, + setUploadProgress, + }; +} diff --git a/capsules/object-provider/Cargo.lock b/capsules/object-provider/Cargo.lock new file mode 100644 index 00000000..30491701 --- /dev/null +++ b/capsules/object-provider/Cargo.lock @@ -0,0 +1,7385 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str", + "sync_wrapper", + "tokio", + "tracing", +] + +[[package]] +name = "actor-helper" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db99f3032635124f4ad5639cfdb8fc571c84fd9c6f351e05dab7c6558ca2b157" +dependencies = [ + "anyhow", + "flume", + "futures-executor", + "futures-util", + "tokio", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.4", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.6", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "unsigned-varint", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2be1bdbf929c2a2242cbbe15d6583c56f1cc723c6c8452d0179362de28c9d5" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0336914de11298290783a95a9a7154b894da601659eb5f8f8bc62d1bea98f8" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb972cba51a52c1b2a329fec993b911e4d1f9cfab3795811a319b6746c28e014" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642c920666bfed9aebca39d8c6e7cb76f09314cc7a4074b1db5edcccdde771b9" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1231caaeee3d2363d9b2dba9d6c1f7ff835b8ede6612fba98120af73df44bd" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb83e89be8b413e4f7a4215a02d5c5f3e6f04b1060f5db293dd1007b2871dcf5" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d14f8068a98f0a85ffa63dc5fe73cb486a955adbe7311465d13cde54c656d5f" + +[[package]] +name = "cranelift-control" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c070aee9312b9736028e99b58d45e1099683386082af38529d5e2ce8c76648f3" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d619bb3d14251e96dc9b6a846d6955d78048a168cc3876eb2b789b855c1c22" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2350fcff24d78be5e4201e1eeb4b306e474b9f21e452722b21ffc4f773e8d49a" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdc2b14d7491c53c2989b967b4c07511374733abbc01a895fb01ea31e97bfc8" + +[[package]] +name = "cranelift-native" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e98dbe1326d0001a17b3b0675e3adafcfbd0e7f25f1f845a2f1bb9ce3029f359" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7af563cd300c8a1e4e64387929b40e32867112143f0a0e1ce90f977ce4a41" + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.0-rc.10", + "fiat-crypto 0.3.0", + "rand_core 0.9.5", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c1d73e9668ea6b6a28172aa55f3ebec38507131ce179051c8033b5c6037653" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa94b64bfc6549e6e4b5a3216f22593224174083da7a90db47e951c4fb31725" +dependencies = [ + "block-buffer 0.11.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "distributed-topic-tracker" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0712d81d0381dc9d2ad9536326d0dde52595d4df73c61a9fd910a9afbf237ef" +dependencies = [ + "actor-helper", + "anyhow", + "chrono", + "ed25519-dalek 3.0.0-pre.1", + "ed25519-dalek-hpke", + "futures-lite", + "iroh", + "iroh-gossip", + "mainline", + "postcard", + "rand 0.9.4", + "serde", + "sha2 0.10.9", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +dependencies = [ + "pkcs8 0.11.0-rc.10", + "serde", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "ed25519 3.0.0-rc.4", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek-hpke" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553f25a4e38b5ca64b26c54f34e1dd5092d18a5dea03797ac235baab53eaf8f" +dependencies = [ + "ed25519-dalek 3.0.0-pre.1", + "hpke", + "rand 0.9.4", + "x25519-dalek", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elastos-auth" +version = "0.2.0" +dependencies = [ + "chrono", + "hex", + "k256", + "serde", + "sha2 0.10.9", + "sha3", +] + +[[package]] +name = "elastos-common" +version = "0.2.0" +dependencies = [ + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "elastos-compute" +version = "0.2.0" +dependencies = [ + "async-trait", + "dirs", + "elastos-common", + "libc", + "tokio", + "tracing", + "uuid", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "elastos-crosvm" +version = "0.2.0" +dependencies = [ + "async-trait", + "elastos-common", + "elastos-compute", + "libc", + "nix", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "elastos-identity" +version = "0.2.0" +dependencies = [ + "aes-gcm", + "anyhow", + "aws-lc-rs", + "base64", + "bs58", + "ciborium", + "ed25519-dalek 2.2.0", + "hex", + "hkdf", + "p256", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "elastos-namespace" +version = "0.2.0" +dependencies = [ + "async-trait", + "base64", + "cid", + "ed25519-dalek 2.2.0", + "elastos-common", + "hex", + "multihash", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "elastos-runtime" +version = "0.2.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bincode", + "ed25519-dalek 2.2.0", + "elastos-auth", + "elastos-common", + "elastos-compute", + "elastos-namespace", + "elastos-storage", + "flate2", + "hex", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", + "toml", + "tracing", + "uuid", +] + +[[package]] +name = "elastos-server" +version = "0.2.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "async-trait", + "axum", + "axum-server", + "base64", + "bs58", + "cid", + "clap", + "data-encoding", + "dirs", + "distributed-topic-tracker", + "ed25519-dalek 2.2.0", + "ed25519-dalek 3.0.0-pre.1", + "elastos-common", + "elastos-compute", + "elastos-crosvm", + "elastos-identity", + "elastos-namespace", + "elastos-runtime", + "elastos-storage", + "elastos-tls", + "flate2", + "futures-lite", + "getrandom 0.2.17", + "hex", + "hkdf", + "iroh", + "iroh-gossip", + "libc", + "qrcodegen", + "rand 0.8.6", + "reqwest 0.12.28", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", + "toml", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "url", + "zip", +] + +[[package]] +name = "elastos-storage" +version = "0.2.0" +dependencies = [ + "async-trait", + "dirs", + "elastos-common", + "futures", + "hex", + "lru 0.18.0", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "elastos-tls" +version = "0.2.0" +dependencies = [ + "anyhow", + "axum-server", + "rcgen", + "time", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.4", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +dependencies = [ + "bitflags", + "debugid", + "fxhash", + "serde", + "serde_json", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.4", + "resolv-conf", + "rustls", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hpke" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65d16b699dd1a1fa2d851c970b0c971b388eeeb40f744252b8de48860980c8f" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array", + "hkdf", + "hmac", + "p256", + "rand_core 0.9.5", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.4", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iroh" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5236da4d5681f317ec393c8fe2b7e3d360d31c6bb40383991d0b7429ca5ad117" +dependencies = [ + "backon", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0-pre.1", + "futures-util", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "igd-next", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "iroh-quinn-udp", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netwatch", + "papaya", + "pin-project", + "pkarr", + "pkcs8 0.11.0-rc.10", + "portmapper", + "rand 0.9.4", + "reqwest 0.12.28", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "smallvec", + "strum", + "swarm-discovery", + "sync_wrapper", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots", +] + +[[package]] +name = "iroh-base" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c99d836a1c99e037e98d1bf3ef209c3a4df97555a00ce9510eb78eccdf5567" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "data-encoding", + "derive_more", + "digest 0.11.0-rc.10", + "ed25519-dalek 3.0.0-pre.1", + "n0-error", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-gossip" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d04f83254c847ac61a9b2215b95a36d598d87af033ca12a546cd1c6a2e06dab" +dependencies = [ + "blake3", + "bytes", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0-pre.1", + "futures-concurrency", + "futures-lite", + "futures-util", + "hex", + "indexmap", + "iroh", + "iroh-base", + "iroh-metrics", + "irpc", + "n0-error", + "n0-future", + "postcard", + "rand 0.9.4", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-metrics" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "postcard", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-quinn" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b" +dependencies = [ + "bytes", + "cfg_aliases", + "iroh-quinn-proto", + "iroh-quinn-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-proto" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de99ad8adc878ee0e68509ad256152ce23b8bbe45f5539d04e179630aca40a9" +dependencies = [ + "bytes", + "derive_more", + "enum-assoc", + "fastbloom", + "getrandom 0.3.4", + "identity-hash", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-udp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "iroh-relay" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "lru 0.16.4", + "n0-error", + "n0-future", + "num_enum", + "pin-project", + "pkarr", + "postcard", + "rand 0.9.4", + "reqwest 0.12.28", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", + "z32", +] + +[[package]] +name = "irpc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bbc84aaeab13a6d7502bae4f40f2517b643924842e0230ea0bf807477cc208" +dependencies = [ + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58148196d2230183c9679431ac99b57e172000326d664e8456fa2cd27af6505a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "mainline" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fd96e1eb0cff23f7048801d1561336c2e3c9f2b468d0271f27ad3463f583bf" +dependencies = [ + "crc", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "flume", + "futures-lite", + "getrandom 0.4.2", + "lru 0.16.4", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint", +] + +[[package]] +name = "n0-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +dependencies = [ + "anyhow", + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38795f7932e6e9d1c6e989270ef5b3ff24ebb910e2c9d4bed2d28d8bae3007dc" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "netdev" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.59.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454b8c0759b2097581f25ed5180b4a1d14c324fde6d0734932a288e044d06232" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "iroh-quinn-udp", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntimestamp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +dependencies = [ + "base32", + "document-features", + "getrandom 0.2.17", + "httpdate", + "js-sys", + "once_cell", + "serde", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + +[[package]] +name = "object-provider" +version = "0.1.0" +dependencies = [ + "der 0.8.0-rc.10", + "ed25519 3.0.0-rc.4", + "elastos-server", + "pkcs8 0.11.0-rc.10", + "serde_json", + "spki 0.8.0-rc.4", + "tempfile", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkarr" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f950360d31be432c0c9467fba5024a94f55128e7f32bc9d32db140369f24c77" +dependencies = [ + "async-compat", + "base32", + "bytes", + "cfg_aliases", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "futures-buffered", + "futures-lite", + "getrandom 0.4.2", + "log", + "lru 0.16.4", + "ntimestamp", + "reqwest 0.13.4", + "self_cell", + "serde", + "sha1_smol", + "simple-dns", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b226d2cc389763951db8869584fd800cbbe2962bf454e2edeb5172b31ee99774" +dependencies = [ + "der 0.8.0-rc.10", + "spki 0.8.0-rc.4", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d2a8825353ace3285138da3378b1e21860d60351942f7aa3b99b13b41f80318" +dependencies = [ + "base64", + "bytes", + "derive_more", + "futures-lite", + "futures-util", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "netwatch", + "num_enum", + "rand 0.9.4", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329f575a931601f71fbcb3b31d32d16273da5ba7f532fc10be2e432e710b02de" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bccae89ed67a40989e780105fab43e6c71a077b9fc8ae4c805ff5f73d2a79c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.11.0-rc.10", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +dependencies = [ + "base64ct", + "der 0.8.0-rc.10", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swarm-discovery" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5ab62937edac8b23fa40e55a358ea1924245b17fc1eb20d14929c8f11be98d" +dependencies = [ + "acto", + "hickory-proto", + "rand 0.9.4", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.3.4", + "http", + "httparse", + "rand 0.9.4", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib 9.1.0", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib 0.1.6", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" +dependencies = [ + "leb128fmt", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser 0.251.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasmtime" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507d213104e83a7519d91af444a8b19c04281f2eef162d448ee7a894ac1c827d" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "fxprof-processed-profile", + "gimli", + "hashbrown 0.15.5", + "indexmap", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-asm-macros", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-environ" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9784b325c3b85562ac6d7f81c8348c42af1f137d98dd4fc6631860e4e68bb655" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", + "wasmprinter", + "wasmtime-internal-component-util", +] + +[[package]] +name = "wasmtime-internal-asm-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcaa9336cd5ba934ba734dfdfe35f5245c3c74b4e34f9af9e114fad892d81b3d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e297746bc001fd919f8f9bb3d28ed1a01fb1ff10a5ccd9720e649b5807bf2b68" +dependencies = [ + "anyhow", + "base64", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml", + "windows-sys 0.60.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91aba228ec4f646cb9514be55538c842822cb96f2c306f75d664eea6b6f2e9eb" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.236.1", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f4a2387b0aea544aa2317e295583be54fed852e0d1a31c0070984bfd6a507" + +[[package]] +name = "wasmtime-internal-cranelift" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d938ae501275f44e7e5532ae4bb720542b429357014d33842e128c46fb9b54" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1443b0914ff848ee7920e0f232368168e2819b739c54f3c352f0559b6164343" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-internal-asm-macros", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861d6f2a1652e95ca10b02552934b3bd460d7416b285fe10d7ca8c0a2b90dc3e" +dependencies = [ + "cc", + "object", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1caeb3140c46319fecf09d93dc38a373eb535fd478e401a9fb2ac2da30fe5f6" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-math" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c631615929951a4076aae64da7d6cad88668d292f19672606392c24ae9c5a00" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-slab" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b28104d57b5bdb5d8facb3a8418463ec6c2cb40bb4adf9833b727ebf6a254eb" + +[[package]] +name = "wasmtime-internal-unwinder" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd89f2db7377869aeaf66b71f56def8df54b9482e4f4e5533ccec2505f5c691" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cdb9c2e3965ee15629d067203cb800e9822664d04335dadc6fe1788d4fc335" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f693c8db710f20b927bcee025acd345acf599d055b63f122613d52f5553a5f" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object", + "target-lexicon", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c97d4e849494d290e05573298bd372e12be86b2074502dc5e02f4ef7628002" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-parser 0.236.1", +] + +[[package]] +name = "wasmtime-wasi" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eabc75a6afeac11870ee5402268ec0f61fc394728d6dcbe5091a57dc6eb5a57" +dependencies = [ + "anyhow", + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367b77d382241c7f9b3cde3c8cfc1c0d800f04d665622779a724b71d0a2a2028" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "futures", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "251.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.251.0", +] + +[[package]] +name = "wat" +version = "1.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4" +dependencies = [ + "wast 251.0.0", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wiggle" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aed7ef247a05956b0a25e7905fdb709ae89e506547af42897e40301b0658d07" +dependencies = [ + "anyhow", + "async-trait", + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d5550c5f49730d0a8babd089771ff7412e598cf8f7bbe3b647b8e2147a11b" +dependencies = [ + "anyhow", + "heck", + "proc-macro2", + "quote", + "syn", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602175405f0c04fd47439ad6afc5e151c4864883259254d3676562bfb00a7ce8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wiggle-generate", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winch-codegen" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4332c8656af179fb8fc3ae5114c738c29399ee97b638d431725201c17f99294e" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.236.1", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "z32" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/capsules/object-provider/Cargo.toml b/capsules/object-provider/Cargo.toml new file mode 100644 index 00000000..ac09540f --- /dev/null +++ b/capsules/object-provider/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "object-provider" +version = "0.1.0" +edition = "2021" +description = "ElastOS object provider capsule - principal-root object authority" +license = "MIT" + +[[bin]] +name = "object-provider" +path = "src/main.rs" + +[dependencies] +elastos-server = { path = "../../elastos/crates/elastos-server" } +serde_json = "1.0" +# Keep the distributed-topic-tracker / ed25519-dalek 3 pre-release chain pinned +# to the same rc versions as the main workspace. Without these direct pins, +# Cargo can resolve stable ed25519/pkcs8 versions that do not compile with +# ed25519-dalek 3.0.0-pre.1. +ed25519 = "=3.0.0-rc.4" +pkcs8 = "=0.11.0-rc.10" +der = "=0.8.0-rc.10" +spki = "=0.8.0-rc.4" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +opt-level = "s" +lto = true + +[workspace] diff --git a/capsules/object-provider/capsule.json b/capsules/object-provider/capsule.json new file mode 100644 index 00000000..2fec85c3 --- /dev/null +++ b/capsules/object-provider/capsule.json @@ -0,0 +1,28 @@ +{ + "schema": "elastos.capsule/v1", + "name": "object-provider", + "version": "0.1.0", + "description": "Principal-root object authority for Explorer/Library", + "author": "elastos", + "role": "provider", + "type": "microvm", + "entrypoint": "rootfs.ext4", + "provides": "elastos://object/*", + "authority": { + "reason": "Owns principal-root object reads, writes, moves, metadata, and events without exposing raw host filesystem access to app capsules.", + "capabilities": [ + { + "resource": "elastos://object/*", + "actions": ["read", "write", "delete"], + "operations": ["roots", "list", "stat", "read", "download", "write", "mkdir", "rename", "move", "copy", "trash", "restore", "delete_permanently", "status", "events", "publish", "unpublish", "repair", "share"] + } + ], + "audit_events": ["object.provider.requested", "object.provider.completed", "object.provider.failed"] + }, + "capabilities": ["localhost://Users/*"], + "resources": { + "memory_mb": 64, + "gpu": false + }, + "permissions": {} +} diff --git a/capsules/object-provider/src/main.rs b/capsules/object-provider/src/main.rs new file mode 100644 index 00000000..e1db6641 --- /dev/null +++ b/capsules/object-provider/src/main.rs @@ -0,0 +1,182 @@ +//! ElastOS Object Provider Capsule +//! +//! Principal-root object authority. Library remains the app capsule; Runtime +//! injects the authenticated principal and forwards typed operations to this +//! provider process. + +use serde_json::{json, Value}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +const PROVIDER_VERSION: &str = match option_env!("ELASTOS_RELEASE_VERSION") { + Some(version) => version, + None => concat!(env!("CARGO_PKG_VERSION"), "-dev"), +}; + +const PROVIDER_ID: &str = "object-provider"; + +struct ObjectProviderProcess { + data_dir: Option, +} + +impl ObjectProviderProcess { + fn new() -> Self { + Self { data_dir: None } + } + + fn handle(&mut self, request: Value) -> Value { + match request.get("op").and_then(Value::as_str) { + Some("init") => self.init(request.get("config").cloned().unwrap_or(Value::Null)), + Some("shutdown") => response_ok(json!({ + "provider": PROVIDER_ID, + })), + Some(_) => { + let Some(data_dir) = &self.data_dir else { + return response_error("not_initialized", "object-provider is not initialized"); + }; + elastos_server::library::handle_object_provider_raw_request(data_dir, &request) + } + None => response_error("invalid_request", "provider request missing op"), + } + } + + fn init(&mut self, config: Value) -> Value { + let base_path = config + .get("base_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(base_path) = base_path else { + return response_error( + "invalid_config", + "object-provider requires config.base_path", + ); + }; + let data_dir = PathBuf::from(base_path); + if !data_dir.is_absolute() { + return response_error("invalid_config", "config.base_path must be absolute"); + } + self.data_dir = Some(data_dir.clone()); + response_ok(json!({ + "provider": PROVIDER_ID, + "protocol_version": "1.0", + "version": PROVIDER_VERSION, + "base_path": data_dir, + })) + } +} + +fn response_ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn response_error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +fn main() { + eprintln!("object-provider: starting v{PROVIDER_VERSION}"); + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let mut provider = ObjectProviderProcess::new(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + eprintln!("object-provider read error: {err}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + + let request = match serde_json::from_str::(&line) { + Ok(request) => request, + Err(err) => response_error("invalid_request", &err.to_string()), + }; + let is_shutdown = request.get("op").and_then(Value::as_str) == Some("shutdown"); + let response = provider.handle(request); + writeln!(stdout, "{}", serde_json::to_string(&response).unwrap()).unwrap(); + stdout.flush().unwrap(); + if is_shutdown { + break; + } + } + + eprintln!("object-provider: exiting"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_requests_before_init() { + let mut provider = ObjectProviderProcess::new(); + + let response = provider.handle(json!({ + "op": "list", + "principal_id": "alice", + "uri": "localhost://Users/alice/Documents" + })); + + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "not_initialized"); + } + + #[test] + fn init_requires_absolute_base_path() { + let mut provider = ObjectProviderProcess::new(); + + let missing = provider.handle(json!({ + "op": "init", + "config": {} + })); + assert_eq!(missing["status"], "error"); + assert_eq!(missing["code"], "invalid_config"); + + let relative = provider.handle(json!({ + "op": "init", + "config": { + "base_path": "relative/path" + } + })); + assert_eq!(relative["status"], "error"); + assert_eq!(relative["code"], "invalid_config"); + } + + #[test] + fn initialized_raw_provider_refuses_runtime_coordinated_content_ops() { + let dir = tempfile::tempdir().unwrap(); + let mut provider = ObjectProviderProcess::new(); + + let init = provider.handle(json!({ + "op": "init", + "config": { + "base_path": dir.path().to_string_lossy().to_string() + } + })); + assert_eq!(init["status"], "ok"); + + let response = provider.handle(json!({ + "op": "publish", + "principal_id": "alice", + "uri": "localhost://Users/alice/Documents/file.txt" + })); + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "library_error"); + assert!(response["message"] + .as_str() + .unwrap() + .contains("Runtime content coordinator")); + } +} diff --git a/elastos/crates/elastos-server/src/library.rs b/elastos/crates/elastos-server/src/library.rs new file mode 100644 index 00000000..a582970e --- /dev/null +++ b/elastos/crates/elastos-server/src/library.rs @@ -0,0 +1,6551 @@ +use std::collections::BTreeSet; +use std::fs; +use std::io::{Cursor, Read as _, Write as _}; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock, Weak}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context as _}; +use base64::Engine as _; +use elastos_common::localhost::rooted_localhost_fs_path; +use elastos_common::protected_content::{ + DecryptSessionRequestV1, KeyEnvelopeAlgorithmsV1, KeyEnvelopeV1, KeyReleaseRequestV1, + ReleaseReceiptV1, RightsDecisionReceiptV1, SealedObjectV1, ViewerRequirementV1, + DECRYPT_SESSION_REQUEST_SCHEMA, DECRYPT_SESSION_SCHEMA, DEFAULT_PROTECTED_CONTENT_CIPHER, + DEFAULT_PROTECTED_CONTENT_KEMS, DEFAULT_PROTECTED_CONTENT_SHARE_SCHEME, + DEFAULT_PROTECTED_CONTENT_SIGNATURES, KEY_RELEASE_REQUEST_SCHEMA, RELEASE_RECEIPT_SCHEMA, + RIGHTS_DECISION_RECEIPT_SCHEMA, SEALED_OBJECT_SCHEMA, +}; +use elastos_runtime::provider::{ + Provider, ProviderError, ProviderInvocation, ProviderInvocationTransport, ProviderRegistry, + ProviderTransfer, ResourceRequest, ResourceResponse, +}; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; +use zip::{ZipArchive, ZipWriter}; + +const LIBRARY_OBJECT_SCHEMA: &str = "elastos.library.object/v1"; +const LIBRARY_ROOT_SCHEMA: &str = "elastos.library.root/v1"; +const LIBRARY_EVENT_SCHEMA: &str = "elastos.library.event/v1"; +const LIBRARY_ARCHIVE_ENTRIES_SCHEMA: &str = "elastos.library.archive-entries/v1"; +const LIBRARY_ARCHIVE_EXTRACT_ENTRIES_SCHEMA: &str = "elastos.library.archive-extract-entries/v1"; +const LIBRARY_ARCHIVE_PREVIEW_ENTRY_SCHEMA: &str = "elastos.library.archive-preview-entry/v1"; +const LIBRARY_VISIBILITY_SCHEMA: &str = "elastos.library.visibility/v1"; +const MAX_LIBRARY_EVENTS: usize = 256; +const MAX_ARCHIVE_LIST_ENTRIES: usize = 512; +const MAX_ARCHIVE_PREVIEW_BYTES: usize = 64 * 1024; + +static LIBRARY_EVENT_NOTIFY: OnceLock = OnceLock::new(); + +pub(crate) fn library_event_notifier() -> &'static tokio::sync::Notify { + LIBRARY_EVENT_NOTIFY.get_or_init(tokio::sync::Notify::new) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryObject { + schema: &'static str, + uri: String, + name: String, + kind: &'static str, + mime: String, + size: u64, + created_at: u64, + modified_at: u64, + revision: String, + #[serde(skip_serializing_if = "Option::is_none")] + viewer: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + viewers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + thumbnail_uri: Option, + availability: String, + #[serde(skip_serializing_if = "Option::is_none")] + blocked_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content_cid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + published_cid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + published: bool, + shared: bool, + capabilities: Vec<&'static str>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryViewerOption { + id: String, + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + default: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct LibraryRoot { + schema: &'static str, + id: &'static str, + label: &'static str, + uri: String, + kind: &'static str, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryPublishRecord { + schema: String, + object_uri: String, + cid: String, + published_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + unpublished_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shared_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + share_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + share_grants: Vec, + #[serde(default = "default_publish_content_security")] + content_security: Value, + receipt: Value, + availability: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryShareGrant { + schema: String, + grant_id: String, + recipient: String, + uri: String, + cid: String, + policy: String, + #[serde(default = "default_share_key_release")] + key_release: Value, + created_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryEvent { + schema: String, + event_id: String, + op: String, + uri: String, + at: u64, + #[serde(default, skip_serializing_if = "Value::is_null")] + details: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum ObjectProviderRequest { + Roots { + principal_id: String, + }, + List { + principal_id: String, + #[serde(default)] + uri: Option, + }, + Stat { + principal_id: String, + uri: String, + }, + Read { + principal_id: String, + uri: String, + }, + Download { + principal_id: String, + uri: String, + }, + ExtractArchive { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + ArchiveEntries { + principal_id: String, + uri: String, + }, + ArchivePreviewEntry { + principal_id: String, + uri: String, + entry: String, + #[serde(default)] + if_revision: Option, + }, + ArchiveExtractEntries { + principal_id: String, + uri: String, + destination_uri: String, + #[serde(default)] + entries: Vec, + #[serde(default)] + conflict_policy: Option, + #[serde(default)] + if_revision: Option, + #[serde(default)] + cancel: bool, + }, + CompressArchive { + principal_id: String, + #[serde(default)] + uri: Option, + #[serde(default)] + uris: Vec, + #[serde(default)] + if_revision: Option, + }, + Write { + principal_id: String, + uri: String, + data: String, + #[serde(default)] + mime: Option, + #[serde(default)] + if_revision: Option, + }, + Mkdir { + principal_id: String, + parent_uri: String, + name: String, + }, + Rename { + principal_id: String, + uri: String, + name: String, + #[serde(default)] + if_revision: Option, + }, + Move { + principal_id: String, + uri: String, + target_parent_uri: String, + #[serde(default)] + if_revision: Option, + }, + Copy { + principal_id: String, + uri: String, + target_parent_uri: String, + #[serde(default)] + if_revision: Option, + }, + Trash { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + Restore { + principal_id: String, + uri: String, + target_uri: String, + #[serde(default)] + if_revision: Option, + }, + DeletePermanently { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + Status { + principal_id: String, + uri: String, + }, + Sync { + principal_id: String, + uri: String, + }, + Events { + principal_id: String, + #[serde(default)] + uri: Option, + #[serde(default)] + since: Option, + #[serde(default)] + limit: Option, + }, + Publish { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + #[serde(default)] + protected_content_fixture: bool, + }, + Unpublish { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + Repair { + principal_id: String, + uri: String, + }, + Share { + principal_id: String, + uri: String, + #[serde(default)] + recipients: Vec, + #[serde(default)] + policy: Option, + #[serde(default)] + key_release_policy: Option, + }, + SharedAccess { + principal_id: String, + uri: String, + recipient: String, + #[serde(default)] + recipient_proof: Option, + }, +} + +pub struct ObjectProvider { + data_dir: PathBuf, + registry: Weak, +} + +impl ObjectProvider { + pub fn new(data_dir: PathBuf, registry: Weak) -> Self { + Self { data_dir, registry } + } +} + +#[async_trait::async_trait] +impl Provider for ObjectProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "object provider does not support URI resource routing; use raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["object"] + } + + fn name(&self) -> &'static str { + "object-provider" + } + + async fn send_raw(&self, request: &Value) -> Result { + let request = match serde_json::from_value::(request.clone()) { + Ok(request) => request, + Err(err) => return Ok(provider_error("invalid_request", &err.to_string())), + }; + + let data_dir = self.data_dir.clone(); + let result = match request { + ObjectProviderRequest::Publish { + principal_id, + uri, + if_revision, + protected_content_fixture, + } => { + let Some(registry) = self.registry.upgrade() else { + return Ok(provider_error( + "library_error", + "object provider registry unavailable", + )); + }; + library_publish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + protected_content_fixture, + ) + .await + } + ObjectProviderRequest::Unpublish { + principal_id, + uri, + if_revision, + } => { + let Some(registry) = self.registry.upgrade() else { + return Ok(provider_error( + "library_error", + "object provider registry unavailable", + )); + }; + library_unpublish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + ) + .await + } + ObjectProviderRequest::Repair { principal_id, uri } => { + let Some(registry) = self.registry.upgrade() else { + return Ok(provider_error( + "library_error", + "object provider registry unavailable", + )); + }; + library_repair(&data_dir, registry, &principal_id, &uri).await + } + request @ (ObjectProviderRequest::Status { .. } + | ObjectProviderRequest::Share { .. } + | ObjectProviderRequest::SharedAccess { .. }) => { + handle_library_request_with_protected_content_status( + data_dir, + request, + self.registry.upgrade(), + ) + .await + } + request => { + tokio::task::spawn_blocking(move || handle_library_request(&data_dir, request)) + .await + .map_err(|err| anyhow!("object provider task failed: {err}")) + .and_then(|result| result) + } + }; + + Ok(match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + }) + } +} + +/// Handle one raw object provider request inside an isolated provider process. +/// +/// The standalone provider owns principal-root object storage and Library event +/// state. Content publish/unpublish/repair are coordinated by Runtime for now, +/// because the current stdio provider ABI has no provider-to-provider +/// invocation channel. +pub fn handle_object_provider_raw_request(data_dir: &Path, request: &Value) -> Value { + let request = match serde_json::from_value::(request.clone()) { + Ok(request) => request, + Err(err) => return provider_error("invalid_request", &err.to_string()), + }; + + let result = match request { + ObjectProviderRequest::Publish { .. } + | ObjectProviderRequest::Unpublish { .. } + | ObjectProviderRequest::Repair { .. } => Err(anyhow!( + "library content operation requires Runtime content coordinator" + )), + request => handle_library_request(data_dir, request), + }; + + match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + } +} + +pub fn handle_library_upload_bytes( + data_dir: &Path, + principal_id: &str, + uri: &str, + mime: Option<&str>, + if_revision: Option<&str>, + bytes: &[u8], +) -> anyhow::Result { + let object = write_library_file_bytes(data_dir, principal_id, uri, mime, if_revision, bytes)?; + Ok(provider_ok(json!({ + "object": object, + "transport": "raw-body", + }))) +} + +pub(crate) async fn handle_library_upload_bytes_runtime( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + mime: Option<&str>, + if_revision: Option<&str>, + bytes: &[u8], +) -> anyhow::Result { + if is_webspace_uri(uri) { + let uri = clean_webspace_uri(uri)?; + let receipt = webspace_write_bytes(®istry, &uri, bytes).await?; + let object = webspace_stat_object(data_dir, ®istry, &uri).await?; + return Ok(provider_ok(json!({ + "object": object, + "transport": "raw-body", + "provider_receipt": receipt, + }))); + } + handle_library_upload_bytes(data_dir, principal_id, uri, mime, if_revision, bytes) +} + +pub(crate) struct LibraryDownloadBytes { + pub(crate) filename: String, + pub(crate) mime: String, + pub(crate) bytes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LibraryArchiveFormat { + TarGz, + Zip, +} + +impl LibraryArchiveFormat { + pub(crate) fn parse(value: Option<&str>) -> anyhow::Result { + match value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("tar.gz") + .to_ascii_lowercase() + .as_str() + { + "tar.gz" | "tgz" | "gzip" | "gz" => Ok(Self::TarGz), + "zip" => Ok(Self::Zip), + other => bail!("unsupported Library archive format: {other}"), + } + } + + fn mime(self) -> &'static str { + match self { + Self::TarGz => "application/gzip", + Self::Zip => "application/zip", + } + } + + fn extension(self) -> &'static str { + match self { + Self::TarGz => "tar.gz", + Self::Zip => "zip", + } + } +} + +pub(crate) fn handle_library_download_bytes_with_format( + data_dir: &Path, + principal_id: &str, + uri: &str, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + let (object, filename, bytes) = + library_download_object(data_dir, principal_id, uri, archive_format)?; + Ok(LibraryDownloadBytes { + filename, + mime: object.mime, + bytes, + }) +} + +pub(crate) fn handle_library_download_selection_bytes_with_format( + data_dir: &Path, + principal_id: &str, + uris: &[String], + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + let (filename, bytes) = + archive_library_selection(data_dir, principal_id, uris, archive_format)?; + Ok(LibraryDownloadBytes { + filename, + mime: archive_format.mime().to_string(), + bytes, + }) +} + +pub(crate) async fn handle_library_download_bytes_runtime( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + if is_webspace_uri(uri) { + return webspace_download_bytes(data_dir, ®istry, uri).await; + } + handle_library_download_bytes_with_format(data_dir, principal_id, uri, archive_format) +} + +pub(crate) async fn handle_library_download_selection_bytes_runtime( + data_dir: &Path, + principal_id: &str, + uris: &[String], + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + if uris.iter().any(|uri| is_webspace_uri(uri)) { + bail!("Spaces selections cannot be archived from Library yet"); + } + handle_library_download_selection_bytes_with_format( + data_dir, + principal_id, + uris, + archive_format, + ) +} + +/// Handle one Library request with Runtime coordination available. +/// +/// This bridge keeps publish/share/status content effects Runtime-mediated so +/// Library never gains raw content, Carrier, Kubo/IPFS, or backend authority. +pub async fn handle_object_provider_runtime_request( + data_dir: &Path, + registry: Arc, + request: &Value, +) -> Value { + let request = match serde_json::from_value::(request.clone()) { + Ok(request) => request, + Err(err) => return provider_error("invalid_request", &err.to_string()), + }; + + let data_dir = data_dir.to_path_buf(); + if library_request_touches_webspace(&request) { + let result = handle_library_webspace_request(&data_dir, ®istry, request).await; + return match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + }; + } + + let result = match request { + ObjectProviderRequest::Publish { + principal_id, + uri, + if_revision, + protected_content_fixture, + } => { + library_publish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + protected_content_fixture, + ) + .await + } + ObjectProviderRequest::Unpublish { + principal_id, + uri, + if_revision, + } => { + library_unpublish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + ) + .await + } + ObjectProviderRequest::Repair { principal_id, uri } => { + library_repair(&data_dir, registry, &principal_id, &uri).await + } + request @ (ObjectProviderRequest::Status { .. } + | ObjectProviderRequest::Share { .. } + | ObjectProviderRequest::SharedAccess { .. }) => { + handle_library_request_with_protected_content_status(data_dir, request, Some(registry)) + .await + } + request => tokio::task::spawn_blocking(move || handle_library_request(&data_dir, request)) + .await + .map_err(|err| anyhow!("object provider task failed: {err}")) + .and_then(|result| result), + }; + + match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + } +} + +async fn handle_library_request_with_protected_content_status( + data_dir: PathBuf, + request: ObjectProviderRequest, + registry: Option>, +) -> anyhow::Result { + let request_is_shared_access = matches!(request, ObjectProviderRequest::SharedAccess { .. }); + let mut data = tokio::task::spawn_blocking(move || handle_library_request(&data_dir, request)) + .await + .map_err(|err| anyhow!("object provider task failed: {err}")) + .and_then(|result| result)?; + if let Some(registry) = registry { + if request_is_shared_access { + attach_protected_content_open_chain(®istry, &mut data).await?; + } + attach_protected_content_provider_status(®istry, &mut data).await; + } + Ok(data) +} + +async fn attach_protected_content_open_chain( + registry: &ProviderRegistry, + data: &mut Value, +) -> anyhow::Result<()> { + let object_cid = data + .get("cid") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("protected shared_access missing cid"))? + .to_string(); + let Some(access) = data.get_mut("access") else { + return Ok(()); + }; + let key_release_required = access + .get("key_release") + .and_then(|value| value.get("required")) + .and_then(Value::as_bool) + .unwrap_or(false); + if !key_release_required { + return Ok(()); + } + + let content_security = access + .get("content_security") + .cloned() + .ok_or_else(|| anyhow!("protected shared_access missing content_security"))?; + let sealed_object = protected_content_sealed_object_from_security(&content_security)?; + let recipient_proof = access + .get("recipient_proof") + .cloned() + .ok_or_else(|| anyhow!("protected shared_access missing recipient_proof"))?; + let principal_id = recipient_proof + .get("recipient") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("protected shared_access recipient proof missing recipient"))? + .to_string(); + let session_id = recipient_proof + .get("session_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("protected shared_access recipient proof missing session_id"))? + .to_string(); + let now = now_ts(); + let expires_at = now.saturating_add(900); + let action = "view"; + let reason = "library protected shared_access open"; + + let drm_receipt = protected_provider_data( + registry, + "drm", + "open", + &json!({ + "op": "open", + "request": { + "object": sealed_object, + "principal_id": principal_id, + "session_id": session_id, + "action": action, + "reason": reason + } + }), + ) + .await?; + reject_forbidden_protected_content_fields(&drm_receipt)?; + + let rights_receipt_value = protected_provider_data( + registry, + "rights", + "has_access_by_content_id", + &json!({ + "op": "has_access_by_content_id", + "request": { + "principal_id": principal_id, + "session_id": session_id, + "content_id": object_cid, + "right": action, + "reason": reason, + "policy_ref": sealed_object.rights_policy_cid + } + }), + ) + .await?; + reject_forbidden_protected_content_fields(&rights_receipt_value)?; + let rights_receipt: RightsDecisionReceiptV1 = + serde_json::from_value(rights_receipt_value.clone()) + .context("rights provider returned invalid protected-content receipt")?; + if rights_receipt.schema != RIGHTS_DECISION_RECEIPT_SCHEMA || !rights_receipt.allowed { + bail!("rights provider did not allow protected shared_access"); + } + + let key_release_request = KeyReleaseRequestV1 { + schema: KEY_RELEASE_REQUEST_SCHEMA.to_string(), + request_id: protected_request_id("key-release", &object_cid, &principal_id, now), + principal_id: principal_id.clone(), + session_id: session_id.clone(), + object_cid: object_cid.clone(), + action: action.to_string(), + rights_receipt, + key_envelope: sealed_object.key_envelope.clone(), + reason: reason.to_string(), + expires_at, + }; + let release_receipt_value = protected_provider_data( + registry, + "key", + "release", + &json!({ + "op": "release", + "request": key_release_request + }), + ) + .await?; + reject_forbidden_protected_content_fields(&release_receipt_value)?; + let release_receipt: ReleaseReceiptV1 = + serde_json::from_value(release_receipt_value.clone()) + .context("key provider returned invalid release receipt")?; + if release_receipt.schema != RELEASE_RECEIPT_SCHEMA { + bail!("key provider returned unsupported release receipt schema"); + } + + let decrypt_request = DecryptSessionRequestV1 { + schema: DECRYPT_SESSION_REQUEST_SCHEMA.to_string(), + request_id: protected_request_id("decrypt-session", &object_cid, &principal_id, now), + principal_id: principal_id.clone(), + session_id: session_id.clone(), + object_cid: object_cid.clone(), + action: action.to_string(), + viewer_interface: sealed_object.viewer.required_interface.clone(), + release_receipt, + output_kind: "rendered".to_string(), + reason: reason.to_string(), + expires_at, + }; + let decrypt_session_value = protected_provider_data( + registry, + "decrypt", + "open_session", + &json!({ + "op": "open_session", + "request": decrypt_request + }), + ) + .await?; + reject_forbidden_protected_content_fields(&decrypt_session_value)?; + if decrypt_session_value.get("schema").and_then(Value::as_str) != Some(DECRYPT_SESSION_SCHEMA) { + bail!("decrypt provider returned unsupported decrypt session schema"); + } + + if let Some(open) = access.get_mut("open").and_then(Value::as_object_mut) { + open.insert( + "provider".to_string(), + Value::String("decrypt-provider".to_string()), + ); + open.insert( + "transport".to_string(), + Value::String("runtime-protected-provider-chain".to_string()), + ); + open.insert( + "status".to_string(), + Value::String("ready_for_protected_viewer_session".to_string()), + ); + open.insert( + "protected_content".to_string(), + json!({ + "schema": "elastos.library.protected-open/v1", + "action": action, + "provider_chain": ["drm-provider.open", "rights-provider.has_access_by_content_id", "key-provider.release", "decrypt-provider.open_session"], + "drm_receipt": drm_receipt, + "rights_receipt": rights_receipt_value, + "key_release_receipt": release_receipt_value, + "decrypt_session": decrypt_session_value, + "viewer": { + "required_interface": sealed_object.viewer.required_interface, + "handoff": "viewer_capsule_session" + }, + "raw_cek_exposed": false, + "raw_plaintext_exposed": false + }), + ); + } + Ok(()) +} + +async fn protected_provider_data( + registry: &ProviderRegistry, + scheme: &str, + op: &str, + request: &Value, +) -> anyhow::Result { + let response = registry + .send_raw(scheme, request) + .await + .map_err(|err| anyhow!("{scheme} provider unavailable for protected {op}: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("provider returned error"); + bail!("{scheme} provider rejected protected {op}: {message}"); + } + response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("{scheme} provider protected {op} response missing data")) +} + +fn protected_request_id(kind: &str, object_cid: &str, principal_id: &str, now: u64) -> String { + let digest = Sha256::digest(format!("{kind}:{object_cid}:{principal_id}:{now}")); + format!("{kind}:{}", hex::encode(&digest[..16])) +} + +fn reject_forbidden_protected_content_fields(value: &Value) -> anyhow::Result<()> { + const FORBIDDEN: &[&str] = &[ + "raw_cek", + "cek", + "raw_plaintext", + "plaintext", + "private_key", + "provider_credentials", + "kms_node_credentials", + "wallet_rpc", + "chain_rpc", + "kubo_api", + "elacity_sdk", + ]; + let mut stack = vec![value]; + while let Some(value) = stack.pop() { + match value { + Value::Object(map) => { + for (key, value) in map { + if FORBIDDEN.contains(&key.as_str()) { + bail!("protected provider response exposed forbidden field: {key}"); + } + stack.push(value); + } + } + Value::Array(values) => stack.extend(values), + _ => {} + } + } + Ok(()) +} + +async fn handle_library_webspace_request( + data_dir: &Path, + registry: &ProviderRegistry, + request: ObjectProviderRequest, +) -> anyhow::Result { + match request { + ObjectProviderRequest::List { uri, .. } => { + let uri = clean_webspace_uri(uri.as_deref().unwrap_or("localhost://WebSpaces"))?; + webspace_try_refresh_index_from_adapter(data_dir, registry, &uri).await?; + let data = webspace_provider_data( + registry, + json!({ + "op": "list", + "path": uri, + "token": "", + }), + "list", + ) + .await?; + let entries: Vec = serde_json::from_value(data) + .context("webspace-provider list response has invalid entries")?; + let objects = entries + .into_iter() + .map(|entry| webspace_entry_object(data_dir, &uri, entry)) + .collect::>>()?; + let object = webspace_stat_object(data_dir, registry, &uri).await?; + Ok(json!({ + "uri": uri, + "object": object, + "objects": objects, + })) + } + ObjectProviderRequest::Stat { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + })) + } + ObjectProviderRequest::Read { uri, .. } | ObjectProviderRequest::Download { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + let (object, content) = webspace_read_bytes(data_dir, registry, &uri).await?; + Ok(json!({ + "object": object, + "encoding": "base64", + "data": base64::engine::general_purpose::STANDARD.encode(content), + })) + } + ObjectProviderRequest::ArchiveEntries { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + let archive_name = object.name.clone(); + archive_entries_for_object(webspace_archive_object(object), &uri, &archive_name, bytes) + } + ObjectProviderRequest::ArchivePreviewEntry { + uri, + entry, + if_revision, + .. + } => { + let uri = clean_webspace_uri(&uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + check_object_revision(&object, if_revision.as_deref())?; + let archive_name = object.name.clone(); + archive_preview_entry_for_object( + data_dir, + webspace_archive_object(object), + &uri, + &archive_name, + bytes, + &entry, + ) + } + ObjectProviderRequest::ArchiveExtractEntries { + principal_id, + uri, + destination_uri, + entries, + conflict_policy, + if_revision, + cancel, + } => { + let (source_uri, archive_name, bytes) = if is_webspace_uri(uri.as_str()) { + let uri = clean_webspace_uri(&uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + check_object_revision(&object, if_revision.as_deref())?; + let archive_name = object.name.clone(); + (uri, archive_name, bytes) + } else { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let archive_name = library_archive_name(&target, "selected extraction")?; + let bytes = read_library_file_bytes(data_dir, &principal_id, &target)?; + (target.uri.clone(), archive_name, bytes) + }; + let request = ArchiveExtractRequest { + source_uri: &source_uri, + archive_name: &archive_name, + destination_uri: &destination_uri, + entries: &entries, + conflict_policy: conflict_policy.as_deref(), + cancel, + }; + if is_webspace_uri(destination_uri.as_str()) { + extract_archive_entries_to_webspace_destination( + data_dir, + registry, + &principal_id, + bytes, + request, + ) + .await + } else { + extract_archive_entries_to_local_destination( + data_dir, + &principal_id, + bytes, + request, + ) + } + } + ObjectProviderRequest::Sync { principal_id, uri } => { + let _ = principal_id; + let uri = clean_webspace_uri(&uri)?; + let receipt = webspace_sync_bytes(data_dir, registry, &uri).await?; + Ok(json!({ + "object": receipt.get("object").cloned().unwrap_or(Value::Null), + "receipt": receipt, + })) + } + ObjectProviderRequest::Status { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + "published": null, + })) + } + ObjectProviderRequest::Write { + uri, + data, + if_revision: _, + .. + } => { + let uri = clean_webspace_uri(&uri)?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .context("library WebSpace write data must be base64")?; + let receipt = webspace_write_bytes(registry, &uri, &bytes).await?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + "receipt": receipt, + })) + } + ObjectProviderRequest::Mkdir { + parent_uri, name, .. + } => { + let parent_uri = clean_webspace_uri(&parent_uri)?; + let uri = child_uri(&parent_uri, &name)?; + let receipt = webspace_mkdir(registry, &uri).await?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + "receipt": receipt, + })) + } + ObjectProviderRequest::DeletePermanently { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + let receipt = webspace_delete_permanently(registry, &uri).await?; + Ok(json!({ + "deleted_uri": uri, + "receipt": receipt, + })) + } + _ => Err(anyhow!( + "Spaces operation is not supported by the current resolver lifecycle model" + )), + } +} + +fn handle_library_request( + data_dir: &Path, + request: ObjectProviderRequest, +) -> anyhow::Result { + match request { + ObjectProviderRequest::Roots { principal_id } => { + Ok(json!({ "roots": library_roots(&principal_id) })) + } + ObjectProviderRequest::List { principal_id, uri } => { + let root = crate::auth::principal_localhost_root(&principal_id); + let uri = uri.unwrap_or_else(|| root.clone()); + let target = library_target(data_dir, &principal_id, &uri)?; + if !target.path.exists() { + return Ok(json!({ "uri": target.uri, "objects": [] })); + } + if !target.path.is_dir() { + bail!("library list target must be a directory"); + } + let mut objects = Vec::new(); + for entry in fs::read_dir(&target.path) + .with_context(|| format!("failed to list {:?}", target.path))? + { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_uri = format!("{}/{}", target.uri.trim_end_matches('/'), name); + objects.push(library_object(data_dir, &principal_id, &child_uri)?); + } + objects.sort_by(|a, b| { + a.kind + .cmp(b.kind) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(json!({ "uri": target.uri, "objects": objects })) + } + ObjectProviderRequest::Stat { principal_id, uri } => { + Ok(json!({ "object": library_object(data_dir, &principal_id, &uri)? })) + } + ObjectProviderRequest::Read { principal_id, uri } => { + let target = library_target(data_dir, &principal_id, &uri)?; + if !target.path.is_file() { + bail!("library read target must be a file"); + } + let bytes = read_library_file_bytes(data_dir, &principal_id, &target)?; + Ok(json!({ + "object": library_object(data_dir, &principal_id, &target.uri)?, + "encoding": "base64", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })) + } + ObjectProviderRequest::Download { principal_id, uri } => { + let (object, filename, bytes) = library_download_object( + data_dir, + &principal_id, + &uri, + LibraryArchiveFormat::TarGz, + )?; + Ok(json!({ + "object": object, + "encoding": "base64", + "filename": filename, + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })) + } + ObjectProviderRequest::ExtractArchive { + principal_id, + uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let extracted_uri = extract_library_archive(data_dir, &principal_id, &target)?; + let object = library_object(data_dir, &principal_id, &extracted_uri)?; + append_library_event( + data_dir, + &principal_id, + "extract_archive", + &extracted_uri, + json!({ + "source_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(json!({ + "object": object, + "source_uri": target.uri, + })) + } + ObjectProviderRequest::ArchiveEntries { principal_id, uri } => { + let target = library_target(data_dir, &principal_id, &uri)?; + Ok(library_archive_entries(data_dir, &principal_id, &target)?) + } + ObjectProviderRequest::ArchivePreviewEntry { + principal_id, + uri, + entry, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + archive_preview_entry(data_dir, &principal_id, &target, &entry) + } + ObjectProviderRequest::ArchiveExtractEntries { + principal_id, + uri, + destination_uri, + entries, + conflict_policy, + if_revision, + cancel, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + extract_library_archive_entries( + data_dir, + &principal_id, + &target, + &destination_uri, + &entries, + conflict_policy.as_deref(), + cancel, + ) + } + ObjectProviderRequest::CompressArchive { + principal_id, + uri, + uris, + if_revision, + } => { + let object = compress_library_archive( + data_dir, + &principal_id, + uri.as_deref(), + &uris, + if_revision.as_deref(), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Write { + principal_id, + uri, + data, + mime, + if_revision, + } => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .context("library write data must be base64")?; + let object = write_library_file_bytes( + data_dir, + &principal_id, + &uri, + mime.as_deref(), + if_revision.as_deref(), + &bytes, + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Mkdir { + principal_id, + parent_uri, + name, + } => { + let parent = library_target(data_dir, &principal_id, &parent_uri)?; + if parent.path.exists() && !parent.path.is_dir() { + bail!("library mkdir parent must be a directory"); + } + let child_uri = child_uri(&parent.uri, &name)?; + let child = library_target(data_dir, &principal_id, &child_uri)?; + fs::create_dir_all(&child.path)?; + let object = library_object(data_dir, &principal_id, &child.uri)?; + append_library_event( + data_dir, + &principal_id, + "mkdir", + &child.uri, + json!({ + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Rename { + principal_id, + uri, + name, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let parent_uri = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent) + .ok_or_else(|| anyhow!("library rename target has no parent"))?; + let new_uri = child_uri(parent_uri, &name)?; + move_library_object(data_dir, &principal_id, &target.uri, &new_uri)?; + let object = library_object(data_dir, &principal_id, &new_uri)?; + append_library_event( + data_dir, + &principal_id, + "rename", + &new_uri, + json!({ + "old_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Move { + principal_id, + uri, + target_parent_uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let parent = library_target(data_dir, &principal_id, &target_parent_uri)?; + if parent.path.exists() && !parent.path.is_dir() { + bail!("library move target parent must be a directory"); + } + if parent.uri == target.uri || parent.uri.starts_with(&(target.uri.clone() + "/")) { + bail!("library object cannot be moved inside itself"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library move target has no name"))?; + let new_uri = unique_child_uri(data_dir, &principal_id, &parent.uri, name)?; + move_library_object(data_dir, &principal_id, &target.uri, &new_uri)?; + let object = library_object(data_dir, &principal_id, &new_uri)?; + append_library_event( + data_dir, + &principal_id, + "move", + &new_uri, + json!({ + "old_uri": target.uri, + "target_uri": new_uri, + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Copy { + principal_id, + uri, + target_parent_uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let parent = library_target(data_dir, &principal_id, &target_parent_uri)?; + if parent.path.exists() && !parent.path.is_dir() { + bail!("library copy target parent must be a directory"); + } + if parent.uri == target.uri || parent.uri.starts_with(&(target.uri.clone() + "/")) { + bail!("library object cannot be copied inside itself"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library copy target has no name"))?; + let new_uri = unique_child_uri(data_dir, &principal_id, &parent.uri, name)?; + copy_library_object(data_dir, &principal_id, &target.uri, &new_uri)?; + let object = library_object(data_dir, &principal_id, &new_uri)?; + append_library_event( + data_dir, + &principal_id, + "copy", + &new_uri, + json!({ + "source_uri": target.uri, + "target_uri": new_uri, + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Trash { + principal_id, + uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + if is_trash_uri(&target.localhost_root, &target.uri) { + bail!("library object is already in Trash"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library trash target has no name"))?; + let trash_root = format!("{}/.Trash", target.localhost_root); + let trash_uri = unique_child_uri(data_dir, &principal_id, &trash_root, name)?; + move_library_object(data_dir, &principal_id, &target.uri, &trash_uri)?; + let object = library_object(data_dir, &principal_id, &trash_uri)?; + append_library_event( + data_dir, + &principal_id, + "trash", + &trash_uri, + json!({ + "original_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(json!({ + "object": object, + "original_uri": target.uri, + })) + } + ObjectProviderRequest::Restore { + principal_id, + uri, + target_uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + if !is_trash_uri(&target.localhost_root, &target.uri) { + bail!("library restore target must be in Trash"); + } + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let restore_target = library_target(data_dir, &principal_id, &target_uri)?; + move_library_object(data_dir, &principal_id, &target.uri, &restore_target.uri)?; + let object = library_object(data_dir, &principal_id, &restore_target.uri)?; + append_library_event( + data_dir, + &principal_id, + "restore", + &restore_target.uri, + json!({ + "trash_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::DeletePermanently { + principal_id, + uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + if !is_trash_uri(&target.localhost_root, &target.uri) { + bail!("library delete_permanently target must be in Trash"); + } + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + if target.path.is_dir() { + fs::remove_dir_all(&target.path)?; + } else { + fs::remove_file(&target.path)?; + } + append_library_event( + data_dir, + &principal_id, + "delete_permanently", + &target.uri, + json!({}), + )?; + Ok(json!({ "deleted_uri": target.uri })) + } + ObjectProviderRequest::Status { principal_id, uri } => { + let object = library_object(data_dir, &principal_id, &uri)?; + let record = read_publish_record(data_dir, &principal_id, &uri).ok(); + Ok(json!({ + "object": object, + "published": record, + })) + } + ObjectProviderRequest::Sync { principal_id, .. } => { + let _ = principal_id; + bail!("library sync is only supported for Spaces objects") + } + ObjectProviderRequest::Events { + principal_id, + uri, + since, + limit, + } => Ok(json!({ + "schema": "elastos.library.events/v1", + "events": library_events(data_dir, &principal_id, uri.as_deref(), since, limit)?, + })), + ObjectProviderRequest::Share { + principal_id, + uri, + recipients, + policy, + key_release_policy, + } => { + let object = library_object(data_dir, &principal_id, &uri)?; + let mut record = read_publish_record(data_dir, &principal_id, &uri) + .context("library share requires a published object")?; + if !record_is_published(&record) { + bail!("library share requires an actively published object"); + } + let shared_at = now_ts(); + let recipients = normalized_share_recipients(&recipients)?; + let share_policy = normalized_share_policy(policy.as_deref(), recipients.is_empty())?; + let key_release = normalized_key_release_policy( + key_release_policy.as_deref(), + &record.content_security, + )?; + let remote_enforcement = share_remote_enforcement_contract(&share_policy, &key_release); + record.shared_at = Some(shared_at); + record.share_policy = Some(share_policy.clone()); + record.share_grants = recipients + .iter() + .map(|recipient| { + share_grant( + recipient, + &record.cid, + &format!("elastos://{}", record.cid), + &share_policy, + key_release.clone(), + shared_at, + ) + }) + .collect(); + write_publish_record(data_dir, &principal_id, &record)?; + append_library_event( + data_dir, + &principal_id, + "share", + &uri, + json!({ + "cid": record.cid, + "policy": share_policy, + "key_release": key_release.clone(), + "remote_enforcement": remote_enforcement.clone(), + "recipients": recipients, + "shared_at": record.shared_at, + }), + )?; + Ok(json!({ + "schema": "elastos.library.share/v1", + "object": library_object(data_dir, &principal_id, &uri)?, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "policy": record.share_policy, + "content_security": record.content_security, + "key_release": key_release.clone(), + "remote_enforcement": remote_enforcement, + "recipients": recipients, + "grants": record.share_grants, + "availability": record.availability, + "shared_at": record.shared_at, + "object_uri": object.uri, + })) + } + ObjectProviderRequest::SharedAccess { + principal_id, + uri, + recipient, + recipient_proof, + } => { + let object = library_object(data_dir, &principal_id, &uri)?; + let record = read_publish_record(data_dir, &principal_id, &uri) + .context("library shared_access requires a published object")?; + if !record_is_published(&record) || record.shared_at.is_none() { + bail!("library shared_access requires an actively shared object"); + } + let access = match shared_access_receipt(&record, &recipient, recipient_proof.as_ref()) + { + Ok(access) => access, + Err(err) => { + append_library_event( + data_dir, + &principal_id, + "shared_access", + &uri, + json!({ + "cid": record.cid, + "recipient": recipient, + "policy": record.share_policy, + "allowed": false, + "reason": err.to_string(), + }), + )?; + return Err(err); + } + }; + append_library_event( + data_dir, + &principal_id, + "shared_access", + &uri, + json!({ + "cid": record.cid, + "recipient": recipient, + "policy": record.share_policy, + "allowed": true, + "decision": access.get("decision").cloned().unwrap_or(Value::Null), + "open": access.get("open").cloned().unwrap_or(Value::Null), + "key_release": access.get("key_release").cloned().unwrap_or(Value::Null), + }), + )?; + Ok(json!({ + "schema": "elastos.library.shared-access/v1", + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "access": access, + "availability": record.availability, + })) + } + ObjectProviderRequest::Publish { .. } + | ObjectProviderRequest::Unpublish { .. } + | ObjectProviderRequest::Repair { .. } => { + unreachable!("publish/unpublish/repair handled asynchronously") + } + } +} + +async fn library_publish( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + if_revision: Option<&str>, + protected_content_fixture: bool, +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + if !target.path.is_file() { + bail!("library publish currently supports files only"); + } + let bytes = read_library_file_bytes(data_dir, principal_id, &target)?; + let filename = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("object.bin"); + let publish_request = json!({ + "op": "publish", + "kind": "file", + "filename": filename, + "mime": mime_for_name(filename), + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "pin": true, + "publisher_did": principal_id, + }); + let response = registry + .send_raw("content", &publish_request) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("content publish failed"); + bail!("content publish failed: {message}"); + } + let data = response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("content publish response missing data"))?; + let payload_cid = data + .get("cid") + .and_then(Value::as_str) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| anyhow!("content publish response missing cid"))? + .to_string(); + let (cid, receipt, availability, content_security) = if protected_content_fixture { + let content_security = + protected_content_fixture_security(data_dir, principal_id, &target, &payload_cid)?; + let sealed = protected_content_sealed_object_from_security(&content_security)?; + let sealed_publish_request = protected_content_fixture_publish_request( + principal_id, + &target, + &sealed, + data.get("availability"), + )?; + let sealed_response = registry + .send_raw("content", &sealed_publish_request) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if sealed_response.get("status").and_then(Value::as_str) == Some("error") { + let message = sealed_response + .get("message") + .and_then(Value::as_str) + .unwrap_or("sealed content publish failed"); + bail!("sealed content publish failed: {message}"); + } + let sealed_data = sealed_response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("sealed content publish response missing data"))?; + let sealed_cid = sealed_data + .get("cid") + .and_then(Value::as_str) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| anyhow!("sealed content publish response missing cid"))? + .to_string(); + let mut content_security = content_security; + if let Some(object) = content_security.as_object_mut() { + object.insert("sealed_cid".to_string(), Value::String(sealed_cid.clone())); + } + ( + sealed_cid, + sealed_data + .get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})), + sealed_data + .get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})), + content_security, + ) + } else { + ( + payload_cid, + data.get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})), + data.get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})), + published_content_security(data_dir, principal_id, &target)?, + ) + }; + let record = LibraryPublishRecord { + schema: "elastos.library.publish-record/v1".to_string(), + object_uri: target.uri.clone(), + cid: cid.clone(), + published_at: now_ts(), + unpublished_at: None, + shared_at: None, + share_policy: None, + share_grants: Vec::new(), + content_security, + receipt, + availability, + }; + write_publish_record(data_dir, principal_id, &record)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "publish", + &target.uri, + json!({ + "cid": cid, + "availability": record.availability, + "object": object, + }), + )?; + Ok(json!({ + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "receipt": record.receipt, + "availability": record.availability, + "content_security": record.content_security, + "published_at": record.published_at, + })) +} + +async fn library_unpublish( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + if_revision: Option<&str>, +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + let mut record = read_publish_record(data_dir, principal_id, &target.uri) + .context("library unpublish requires a published object")?; + if !record_is_published(&record) { + bail!("library object is not actively published"); + } + let response = registry + .send_raw( + "content", + &json!({ + "op": "unpublish", + "cid": record.cid, + "object_did": target.uri, + "publisher_did": principal_id, + }), + ) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("content unpublish failed"); + bail!("content unpublish failed: {message}"); + } + let data = response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("content unpublish response missing data"))?; + record.unpublished_at = Some(now_ts()); + record.shared_at = None; + record.receipt = data + .get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})); + record.availability = data + .get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "local_unpinned"})); + write_publish_record(data_dir, principal_id, &record)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "unpublish", + &target.uri, + json!({ + "cid": record.cid, + "availability": record.availability, + "object": object, + }), + )?; + Ok(json!({ + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "receipt": record.receipt, + "availability": record.availability, + "unpublished_at": record.unpublished_at, + })) +} + +async fn library_repair( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + let mut record = read_publish_record(data_dir, principal_id, &target.uri) + .context("library repair requires a published object")?; + let response = registry + .send_raw( + "content", + &json!({ + "op": "repair", + "cid": record.cid, + "object_did": target.uri, + "publisher_did": principal_id, + }), + ) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("content repair failed"); + bail!("content repair failed: {message}"); + } + let data = response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("content repair response missing data"))?; + record.unpublished_at = None; + record.receipt = data + .get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})); + record.availability = data + .get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})); + write_publish_record(data_dir, principal_id, &record)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "repair", + &target.uri, + json!({ + "cid": record.cid, + "availability": record.availability, + "object": object, + }), + )?; + Ok(json!({ + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "receipt": record.receipt, + "availability": record.availability, + })) +} + +struct LibraryTarget { + localhost_root: String, + uri: String, + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct WebSpaceDirEntry { + name: String, + is_file: bool, + is_dir: bool, + size: u64, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default, rename = "kind")] + webspace_kind: Option, + #[serde(default)] + object_id: Option, + #[serde(default)] + head_id: Option, + #[serde(default)] + cache_state: Option, + #[serde(default)] + sync_state: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + access_policy: Option, +} + +#[derive(Debug, Deserialize)] +struct WebSpaceFileStat { + path: String, + is_file: bool, + is_dir: bool, + size: u64, + #[serde(default)] + modified: Option, + #[serde(default)] + created: Option, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default, rename = "kind")] + webspace_kind: Option, + #[serde(default)] + object_id: Option, + #[serde(default)] + head_id: Option, + #[serde(default)] + cache_state: Option, + #[serde(default)] + sync_state: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + access_policy: Option, +} + +async fn webspace_provider_data( + registry: &ProviderRegistry, + request: Value, + op: &str, +) -> anyhow::Result { + let response = registry + .send_raw("webspace", &request) + .await + .map_err(|err| anyhow!("webspace-provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let code = response + .get("code") + .and_then(Value::as_str) + .unwrap_or("webspace_error"); + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("webspace-provider request failed"); + bail!("webspace-provider {op} failed [{code}]: {message}"); + } + response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("webspace-provider {op} response missing data")) +} + +async fn webspace_stat_object( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + let data = webspace_provider_data( + registry, + json!({ + "op": "stat", + "path": uri, + "token": "", + }), + "stat", + ) + .await?; + let stat: WebSpaceFileStat = + serde_json::from_value(data).context("webspace-provider stat response is invalid")?; + webspace_stat_to_object(data_dir, uri, stat) +} + +async fn webspace_download_bytes( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + let uri = clean_webspace_uri(uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + Ok(LibraryDownloadBytes { + filename: object.name, + mime: object.mime, + bytes, + }) +} + +async fn webspace_read_bytes( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result<(LibraryObject, Vec)> { + let object = webspace_stat_object(data_dir, registry, uri).await?; + if object.kind != "file" { + bail!("library read target must be a file"); + } + if let Some((object, bytes)) = + webspace_try_cache_bytes_from_adapter(data_dir, registry, &object).await? + { + return Ok((object, bytes)); + } + let content = webspace_read_provider_bytes(registry, uri).await?; + Ok((object, content)) +} + +async fn webspace_sync_bytes( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + let object = webspace_stat_object(data_dir, registry, uri).await?; + if object.kind != "file" { + bail!("Spaces byte sync target must be a file"); + } + if webspace_metadata_str(&object, "sync_state") == Some("manual_pending") + || (webspace_metadata_bool(&object, "readonly") == Some(false) + && webspace_metadata_str(&object, "sync_state") != Some("manual_synced")) + { + return webspace_sync_mutable_file_to_resolver(data_dir, registry, object).await; + } + if webspace_metadata_str(&object, "cache_state") == Some("content_cached") + || webspace_metadata_str(&object, "webspace_kind") == Some("materialized-file") + { + let availability_hint = webspace_object_availability_hint(&object); + return Ok(json!({ + "schema": "elastos.webspace.byte-sync-receipt/v1", + "action": "already_content_cached", + "handle_uri": object.uri, + "content_synced": true, + "foreground_read": false, + "bytes_exposed": false, + "availability_hint": availability_hint, + "object": object, + "note": "Resolver bytes are already present in the provider-owned WebSpace cache." + })); + } + let Some((cached_object, bytes)) = + webspace_try_cache_bytes_from_adapter(data_dir, registry, &object).await? + else { + bail!("Spaces byte sync requires a connected resolver adapter with read_bytes"); + }; + let availability_hint = webspace_object_availability_hint(&cached_object); + Ok(json!({ + "schema": "elastos.webspace.byte-sync-receipt/v1", + "action": "bytes_cached_from_adapter", + "handle_uri": cached_object.uri, + "content_synced": true, + "foreground_read": false, + "bytes_exposed": false, + "bytes_cached": bytes.len(), + "availability_hint": availability_hint, + "object": cached_object, + "note": "Runtime invoked the resolver adapter and stored bytes in the provider-owned WebSpace cache without returning bytes to the caller." + })) +} + +async fn webspace_sync_mutable_file_to_resolver( + data_dir: &Path, + registry: &ProviderRegistry, + object: LibraryObject, +) -> anyhow::Result { + let bytes = webspace_read_provider_bytes(registry, &object.uri).await?; + let Some(adapter) = webspace_adapter_target(registry, &object).await? else { + return Ok(webspace_resolver_sync_failed_receipt( + &object, + "resolver_write_unavailable", + "No connected resolver adapter is available for this mutable WebSpace object.", + None, + None, + )); + }; + if !adapter.capabilities.contains("write_bytes") { + return Ok(webspace_resolver_sync_failed_receipt( + &object, + "resolver_write_unavailable", + "The connected resolver adapter does not advertise write_bytes.", + Some(adapter.provider), + None, + )); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "webspace-provider".to_string(), + target: adapter.provider.clone(), + op: "write_bytes".to_string(), + request: json!({ + "op": "write_bytes", + "schema": "elastos.webspace.adapter.write-bytes-request/v1", + "mount": adapter.mount.clone(), + "resolver": adapter.resolver.clone(), + "handle_uri": object.uri.clone(), + "target_uri": adapter.target_uri.clone(), + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "if_head": webspace_metadata_str(&object, "head_id"), + }), + transfer: ProviderTransfer::Bytes, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow!("WebSpace adapter write_bytes failed: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let code = response + .get("code") + .and_then(Value::as_str) + .unwrap_or("adapter_write_failed") + .to_string(); + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("resolver adapter write_bytes failed") + .to_string(); + return Ok(webspace_resolver_sync_failed_receipt( + &object, + if code == "conflict" { + "resolver_write_conflict" + } else { + "resolver_write_failed" + }, + &message, + Some(adapter.provider), + Some(response), + )); + } + let data = response_data_object(&response, "write_bytes")?; + if data.get("schema").and_then(Value::as_str) != Some("elastos.webspace.adapter.write-bytes/v1") + { + bail!("WebSpace adapter write_bytes response schema mismatch"); + } + let provider_sync_receipt = webspace_provider_data( + registry, + json!({ + "op": "sync", + "path": object.uri.clone(), + "token": "", + }), + "sync", + ) + .await?; + let synced_object = webspace_stat_object(data_dir, registry, &object.uri).await?; + let availability_hint = webspace_object_availability_hint(&synced_object); + Ok(json!({ + "schema": "elastos.webspace.resolver-sync-receipt/v1", + "action": "resolver_write_synced", + "handle_uri": synced_object.uri.clone(), + "target_uri": adapter.target_uri, + "resolver": adapter.resolver, + "provider": adapter.provider, + "resolver_synced": true, + "content_synced": true, + "fail_closed": false, + "conflict": false, + "bytes_exposed": false, + "bytes_synced": bytes.len(), + "availability_hint": availability_hint, + "object": synced_object, + "provider_sync_receipt": provider_sync_receipt, + "adapter_receipt": data.get("receipt").cloned(), + "runtime_transfer": response.get("_runtime_transfer").cloned(), + })) +} + +fn webspace_resolver_sync_failed_receipt( + object: &LibraryObject, + action: &str, + message: &str, + provider: Option, + adapter_response: Option, +) -> Value { + json!({ + "schema": "elastos.webspace.resolver-sync-receipt/v1", + "action": action, + "handle_uri": object.uri.clone(), + "target_uri": webspace_metadata_str(object, "target_uri"), + "resolver": webspace_metadata_str(object, "resolver"), + "provider": provider, + "resolver_synced": false, + "content_synced": false, + "fail_closed": true, + "conflict": action == "resolver_write_conflict", + "bytes_exposed": false, + "object": object, + "adapter_response": adapter_response, + "message": message, + }) +} + +#[derive(Debug)] +struct WebSpaceAdapterTarget { + mount: String, + resolver: String, + target_uri: String, + provider: String, + capabilities: BTreeSet, +} + +async fn webspace_try_refresh_index_from_adapter( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result<()> { + let object = match webspace_stat_object(data_dir, registry, uri).await { + Ok(object) => object, + Err(_) => return Ok(()), + }; + let Some(adapter) = webspace_adapter_target(registry, &object).await? else { + return Ok(()); + }; + if !adapter.capabilities.contains("metadata_index") { + return Ok(()); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "webspace-provider".to_string(), + target: adapter.provider.clone(), + op: "metadata_index".to_string(), + request: json!({ + "op": "metadata_index", + "schema": "elastos.webspace.adapter.metadata-index-request/v1", + "mount": adapter.mount.clone(), + "resolver": adapter.resolver.clone(), + "handle_uri": object.uri, + "target_uri": adapter.target_uri.clone(), + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow!("WebSpace adapter metadata_index failed: {err}"))?; + let data = response_data_object(&response, "metadata_index")?; + if data.get("schema").and_then(Value::as_str) + != Some("elastos.webspace.adapter.metadata-index/v1") + { + bail!("WebSpace adapter metadata_index response schema mismatch"); + } + let entries = data + .get("entries") + .cloned() + .ok_or_else(|| anyhow!("WebSpace adapter metadata_index response missing entries"))?; + webspace_provider_data( + registry, + json!({ + "op": "refresh", + "path": format!("localhost://WebSpaces/{}", adapter.mount), + "entries": entries, + "token": "", + }), + "refresh", + ) + .await?; + Ok(()) +} + +async fn webspace_try_cache_bytes_from_adapter( + data_dir: &Path, + registry: &ProviderRegistry, + object: &LibraryObject, +) -> anyhow::Result)>> { + if webspace_metadata_str(object, "cache_state") == Some("content_cached") + || webspace_metadata_str(object, "webspace_kind") == Some("materialized-file") + { + return Ok(None); + } + let Some(adapter) = webspace_adapter_target(registry, object).await? else { + return Ok(None); + }; + if !adapter.capabilities.contains("read_bytes") { + return Ok(None); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "webspace-provider".to_string(), + target: adapter.provider.clone(), + op: "read_bytes".to_string(), + request: json!({ + "op": "read_bytes", + "schema": "elastos.webspace.adapter.read-bytes-request/v1", + "mount": adapter.mount.clone(), + "resolver": adapter.resolver.clone(), + "handle_uri": object.uri, + "target_uri": adapter.target_uri.clone(), + }), + transfer: ProviderTransfer::Bytes, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow!("WebSpace adapter read_bytes failed: {err}"))?; + let data = response_data_object(&response, "read_bytes")?; + if data.get("schema").and_then(Value::as_str) != Some("elastos.webspace.adapter.read-bytes/v1") + { + bail!("WebSpace adapter read_bytes response schema mismatch"); + } + let encoded = data + .get("data") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("WebSpace adapter read_bytes response missing data"))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .context("WebSpace adapter read_bytes response has invalid base64 data")?; + let mime = data + .get("mime") + .and_then(Value::as_str) + .unwrap_or("application/octet-stream"); + let source_receipt = json!({ + "schema": "elastos.webspace.adapter-cache-source/v1", + "provider": adapter.provider.clone(), + "resolver": adapter.resolver.clone(), + "target_uri": adapter.target_uri.clone(), + "runtime_transfer": response.get("_runtime_transfer").cloned(), + "adapter_receipt": data.get("receipt").cloned(), + }); + webspace_provider_data( + registry, + json!({ + "op": "cache", + "path": object.uri, + "content": bytes, + "mime": mime, + "source_receipt": source_receipt, + "token": "", + }), + "cache", + ) + .await?; + let cached_object = webspace_stat_object(data_dir, registry, &object.uri).await?; + Ok(Some((cached_object, bytes))) +} + +async fn webspace_adapter_target( + registry: &ProviderRegistry, + object: &LibraryObject, +) -> anyhow::Result> { + let Some(mount) = webspace_metadata_string(object, "mount") else { + return Ok(None); + }; + let Some(resolver) = webspace_metadata_string(object, "resolver") else { + return Ok(None); + }; + if resolver == "builtin" { + return Ok(None); + } + let Some(target_uri) = webspace_metadata_string(object, "target_uri") else { + return Ok(None); + }; + let health = match webspace_provider_data( + registry, + json!({ + "op": "health", + "moniker": mount.clone(), + "token": "", + }), + "health", + ) + .await + { + Ok(health) => health, + Err(_) => return Ok(None), + }; + let adapter = health + .get("mounts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .find(|mount_health| { + mount_health.get("moniker").and_then(Value::as_str) + == webspace_metadata_str(object, "mount") + }) + .and_then(|mount_health| mount_health.get("adapter")) + .and_then(Value::as_object); + let Some(adapter) = adapter else { + return Ok(None); + }; + if adapter.get("live").and_then(Value::as_bool) != Some(true) + || adapter.get("state").and_then(Value::as_str) != Some("connected") + { + return Ok(None); + } + let Some(provider) = adapter.get("provider").and_then(Value::as_str) else { + return Ok(None); + }; + let capabilities = adapter + .get("capabilities") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + Ok(Some(WebSpaceAdapterTarget { + mount, + resolver, + target_uri, + provider: provider.to_string(), + capabilities, + })) +} + +fn response_data_object<'a>(response: &'a Value, op: &str) -> anyhow::Result<&'a Value> { + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("provider request failed"); + bail!("WebSpace adapter {op} failed: {message}"); + } + response + .get("data") + .ok_or_else(|| anyhow!("WebSpace adapter {op} response missing data")) +} + +fn webspace_metadata_string(object: &LibraryObject, key: &str) -> Option { + webspace_metadata_str(object, key).map(str::to_string) +} + +fn webspace_metadata_str<'a>(object: &'a LibraryObject, key: &str) -> Option<&'a str> { + object + .metadata + .as_ref() + .and_then(|metadata| metadata.get(key)) + .and_then(Value::as_str) +} + +fn webspace_metadata_bool(object: &LibraryObject, key: &str) -> Option { + object + .metadata + .as_ref() + .and_then(|metadata| metadata.get(key)) + .and_then(Value::as_bool) +} + +async fn webspace_read_provider_bytes( + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result> { + let data = webspace_provider_data( + registry, + json!({ + "op": "read", + "path": uri, + "token": "", + "offset": null, + "length": null, + }), + "read", + ) + .await?; + data.get("content") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("webspace-provider read response missing content"))? + .iter() + .map(|value| { + value + .as_u64() + .filter(|byte| *byte <= u8::MAX as u64) + .map(|byte| byte as u8) + .ok_or_else(|| anyhow!("webspace-provider read response has invalid byte")) + }) + .collect() +} + +async fn webspace_write_bytes( + registry: &ProviderRegistry, + uri: &str, + bytes: &[u8], +) -> anyhow::Result { + webspace_provider_data( + registry, + json!({ + "op": "write", + "path": uri, + "token": "", + "content": bytes, + "append": false, + }), + "write", + ) + .await +} + +async fn webspace_mkdir(registry: &ProviderRegistry, uri: &str) -> anyhow::Result { + webspace_provider_data( + registry, + json!({ + "op": "mkdir", + "path": uri, + "token": "", + "parents": false, + }), + "mkdir", + ) + .await +} + +async fn webspace_delete_permanently( + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + webspace_provider_data( + registry, + json!({ + "op": "delete", + "path": uri, + "token": "", + "recursive": true, + }), + "delete", + ) + .await +} + +fn webspace_entry_object( + data_dir: &Path, + parent_uri: &str, + entry: WebSpaceDirEntry, +) -> anyhow::Result { + let uri = child_uri(parent_uri, &entry.name)?; + webspace_stat_to_object( + data_dir, + &uri, + WebSpaceFileStat { + path: uri.clone(), + is_file: entry.is_file, + is_dir: entry.is_dir, + size: entry.size, + modified: None, + created: None, + target_uri: entry.target_uri, + resolver_state: entry.resolver_state, + resolver: entry.resolver, + cache_policy: entry.cache_policy, + sync_policy: entry.sync_policy, + webspace_kind: entry.webspace_kind, + object_id: entry.object_id, + head_id: entry.head_id, + cache_state: entry.cache_state, + sync_state: entry.sync_state, + provider: entry.provider, + readonly: entry.readonly, + access_policy: entry.access_policy, + }, + ) +} + +fn webspace_stat_to_object( + data_dir: &Path, + uri: &str, + stat: WebSpaceFileStat, +) -> anyhow::Result { + let uri = clean_webspace_uri(uri)?; + let is_dir = stat.is_dir && !stat.is_file; + let name = if uri == "localhost://WebSpaces" { + "Spaces".to_string() + } else { + uri.rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Space") + .to_string() + }; + let revision = format!( + "rev:webspace:{}", + hex::encode(Sha256::digest(format!( + "{}:{}:{}", + stat.path, + stat.size, + stat.modified.unwrap_or(0) + ))) + ); + let metadata = webspace_object_metadata(&uri, &stat); + let availability = webspace_availability_label(&metadata); + let readonly = stat.readonly.unwrap_or(true); + let viewers = if is_dir { + Vec::new() + } else { + viewer_options_for_name(data_dir, &name) + }; + let viewer = viewers.first().map(|viewer| viewer.id.clone()); + let mime = if is_dir { + "inode/directory".to_string() + } else if stat.webspace_kind.as_deref() == Some("file-endpoint") { + "application/json".to_string() + } else { + mime_for_name(&name).to_string() + }; + let capabilities = if is_dir { + if readonly { + vec!["open", "list", "properties"] + } else { + vec!["open", "list", "new_folder", "write", "properties"] + } + } else if readonly { + vec!["open", "read", "download", "properties"] + } else { + vec![ + "open", + "read", + "download", + "write", + "delete_permanently", + "properties", + ] + }; + let content_cid = if is_dir { + None + } else { + webspace_content_cid_from_metadata(&metadata) + }; + Ok(LibraryObject { + schema: LIBRARY_OBJECT_SCHEMA, + uri, + name, + kind: if is_dir { "directory" } else { "file" }, + mime, + size: stat.size, + created_at: stat.created.unwrap_or(0), + modified_at: stat.modified.unwrap_or(0), + revision, + viewer, + viewers, + thumbnail_uri: None, + availability, + blocked_reason: None, + content_cid, + published_cid: None, + metadata: Some(metadata), + published: false, + shared: false, + capabilities, + }) +} + +fn webspace_content_cid_from_metadata(metadata: &Value) -> Option { + metadata + .get("target_uri") + .and_then(Value::as_str) + .and_then(|target| target.strip_prefix("elastos://")) + .map(str::trim) + .filter(|cid| cid::Cid::try_from(*cid).is_ok()) + .map(str::to_string) +} + +fn webspace_object_metadata(uri: &str, stat: &WebSpaceFileStat) -> Value { + let mount = uri + .strip_prefix("localhost://WebSpaces/") + .and_then(|rest| rest.split('/').next()) + .filter(|segment| !segment.is_empty()); + let target_uri = stat + .target_uri + .clone() + .or_else(|| inferred_webspace_target_uri(uri)); + let mut metadata = json!({ + "schema": "elastos.library.webspace-object/v1", + "handle_uri": uri, + "mount": mount, + "resolver_state": stat.resolver_state.as_deref().unwrap_or("resolved"), + "resolver": stat.resolver.as_deref().unwrap_or("builtin"), + "cache_policy": stat.cache_policy.as_deref().unwrap_or("metadata-only"), + "sync_policy": stat.sync_policy.as_deref().unwrap_or("manual"), + "cache_state": stat.cache_state.as_deref().unwrap_or("metadata_cached"), + "sync_state": stat.sync_state.as_deref().unwrap_or("manual_idle"), + "webspace_kind": stat.webspace_kind.as_deref().unwrap_or("webspace-object"), + "readonly": stat.readonly.unwrap_or(true), + "access_policy": stat.access_policy.as_deref().unwrap_or("resolver-readonly"), + "provider": stat.provider.as_deref().unwrap_or("webspace-provider"), + }); + if let Some(object_id) = stat.object_id.as_deref() { + metadata["object_id"] = Value::String(object_id.to_string()); + } + if let Some(head_id) = stat.head_id.as_deref() { + metadata["head_id"] = Value::String(head_id.to_string()); + } + if let Some(target_uri) = target_uri { + metadata["target_uri"] = Value::String(target_uri); + } + if let Some(hint) = webspace_availability_hint(uri, stat, metadata.get("target_uri")) { + metadata["availability_hint"] = hint; + } + metadata +} + +fn webspace_availability_hint( + uri: &str, + stat: &WebSpaceFileStat, + target_uri: Option<&Value>, +) -> Option { + if !stat.is_file { + return None; + } + let resolver = stat.resolver.as_deref().unwrap_or("builtin"); + if resolver == "builtin" || resolver == "local-materialized" { + return None; + } + let target_uri = target_uri.and_then(Value::as_str)?; + let webspace_kind = stat.webspace_kind.as_deref().unwrap_or("webspace-object"); + let cache_state = stat.cache_state.as_deref().unwrap_or("metadata_cached"); + let sync_state = stat.sync_state.as_deref().unwrap_or("manual_idle"); + let readonly = stat.readonly.unwrap_or(true); + let status = if !readonly && sync_state == "manual_synced" { + "resolver_synced" + } else if readonly && webspace_kind == "materialized-file" && cache_state == "content_cached" { + "resolver_cached" + } else { + return None; + }; + Some(json!({ + "schema": "elastos.webspace.availability-hint/v1", + "scope": "resolver", + "status": status, + "handle_uri": uri, + "target_uri": target_uri, + "resolver": resolver, + "cache_state": cache_state, + "sync_state": sync_state, + "not_content_availability": true, + "note": "This is a resolver/cache hint only. It is not a SmartWeb content availability receipt and does not prove CID replication." + })) +} + +fn webspace_availability_label(metadata: &Value) -> String { + match metadata + .get("availability_hint") + .and_then(|hint| hint.get("status")) + .and_then(Value::as_str) + { + Some("resolver_synced") => "resolver-synced", + Some("resolver_cached") => "resolver-cached", + _ => "resolver-owned", + } + .to_string() +} + +fn webspace_object_availability_hint(object: &LibraryObject) -> Option { + object + .metadata + .as_ref() + .and_then(|metadata| metadata.get("availability_hint")) + .cloned() +} + +fn inferred_webspace_target_uri(uri: &str) -> Option { + let cid = uri.strip_prefix("localhost://WebSpaces/Elastos/content/")?; + let cid = cid.split('/').next()?.trim(); + if cid.is_empty() { + None + } else { + Some(format!("elastos://{cid}")) + } +} + +fn library_target(data_dir: &Path, principal_id: &str, uri: &str) -> anyhow::Result { + let localhost_root = crate::auth::principal_localhost_root(principal_id); + let uri = clean_library_uri(&localhost_root, uri)?; + let rooted = uri + .strip_prefix("localhost://") + .ok_or_else(|| anyhow!("library object URI must be localhost://"))?; + let path = rooted_localhost_fs_path(data_dir, rooted) + .ok_or_else(|| anyhow!("invalid library object path"))?; + Ok(LibraryTarget { + localhost_root, + uri, + path, + }) +} + +fn clean_library_uri(localhost_root: &str, uri: &str) -> anyhow::Result { + let uri = uri.trim().trim_end_matches('/').to_string(); + if !uri.starts_with("localhost://") { + bail!("library object URI must be localhost://"); + } + let under_root = uri == localhost_root + || uri + .strip_prefix(localhost_root) + .is_some_and(|rest| rest.starts_with('/')); + if !under_root { + bail!("library object URI is outside the active principal root"); + } + if uri.split('/').any(|part| part == ".." || part == ".") { + bail!("library object URI must not contain traversal segments"); + } + Ok(uri) +} + +fn is_webspace_uri(uri: &str) -> bool { + uri == "localhost://WebSpaces" + || uri + .strip_prefix("localhost://WebSpaces/") + .is_some_and(|rest| !rest.is_empty()) +} + +fn clean_webspace_uri(uri: &str) -> anyhow::Result { + let uri = uri.trim().trim_end_matches('/').to_string(); + if !is_webspace_uri(&uri) { + bail!("Library Spaces URI must be under localhost://WebSpaces"); + } + if uri.split('/').any(|part| part == ".." || part == ".") { + bail!("Library Spaces URI must not contain traversal segments"); + } + Ok(uri) +} + +fn library_request_touches_webspace(request: &ObjectProviderRequest) -> bool { + fn any_webspace(values: &[&str]) -> bool { + values + .iter() + .any(|value| is_webspace_uri(value.trim_end_matches('/'))) + } + + match request { + ObjectProviderRequest::List { uri: Some(uri), .. } => any_webspace(&[uri]), + ObjectProviderRequest::Stat { uri, .. } + | ObjectProviderRequest::Read { uri, .. } + | ObjectProviderRequest::Download { uri, .. } + | ObjectProviderRequest::ExtractArchive { uri, .. } + | ObjectProviderRequest::ArchiveEntries { uri, .. } + | ObjectProviderRequest::ArchivePreviewEntry { uri, .. } + | ObjectProviderRequest::Write { uri, .. } + | ObjectProviderRequest::Rename { uri, .. } + | ObjectProviderRequest::Trash { uri, .. } + | ObjectProviderRequest::DeletePermanently { uri, .. } + | ObjectProviderRequest::Status { uri, .. } + | ObjectProviderRequest::Sync { uri, .. } + | ObjectProviderRequest::Publish { uri, .. } + | ObjectProviderRequest::Unpublish { uri, .. } + | ObjectProviderRequest::Repair { uri, .. } + | ObjectProviderRequest::Share { uri, .. } + | ObjectProviderRequest::SharedAccess { uri, .. } => any_webspace(&[uri]), + ObjectProviderRequest::CompressArchive { uri, uris, .. } => { + uri.as_deref().is_some_and(|uri| any_webspace(&[uri])) + || uris + .iter() + .any(|uri| is_webspace_uri(uri.trim_end_matches('/'))) + } + ObjectProviderRequest::ArchiveExtractEntries { + uri, + destination_uri, + .. + } => any_webspace(&[uri, destination_uri]), + ObjectProviderRequest::Mkdir { parent_uri, .. } => any_webspace(&[parent_uri]), + ObjectProviderRequest::Move { + uri, + target_parent_uri, + .. + } + | ObjectProviderRequest::Copy { + uri, + target_parent_uri, + .. + } => any_webspace(&[uri, target_parent_uri]), + ObjectProviderRequest::Restore { + uri, target_uri, .. + } => any_webspace(&[uri, target_uri]), + ObjectProviderRequest::Roots { .. } + | ObjectProviderRequest::List { uri: None, .. } + | ObjectProviderRequest::Events { .. } => false, + } +} + +fn library_object(data_dir: &Path, principal_id: &str, uri: &str) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + let metadata = fs::metadata(&target.path)?; + let is_dir = metadata.is_dir(); + let name = if target.uri == target.localhost_root { + "Home".to_string() + } else { + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("object") + .to_string() + }; + let modified_at = system_time_secs(metadata.modified().ok()).unwrap_or_else(now_ts); + let created_at = system_time_secs(metadata.created().ok()).unwrap_or(modified_at); + let mut blocked_reason = None; + let (size, revision, content_cid) = if is_dir { + (0, directory_revision(&target.path, &target.uri)?, None) + } else { + match read_library_file_bytes(data_dir, principal_id, &target) { + Ok(bytes) => { + let revision = format!("rev:{}", hex::encode(Sha256::digest(&bytes))); + let content_cid = raw_sha256_cid(&bytes)?; + (bytes.len() as u64, revision, Some(content_cid)) + } + Err(err) if is_unencrypted_principal_root_object(&err) => { + blocked_reason = Some("protected_principal_root_object_not_encrypted".to_string()); + let revision_input = format!("{}:{}:{}", target.uri, metadata.len(), modified_at); + ( + metadata.len(), + format!( + "rev:blocked:{}", + hex::encode(Sha256::digest(revision_input)) + ), + None, + ) + } + Err(err) => return Err(err), + } + }; + let record = read_publish_record(data_dir, principal_id, &target.uri).ok(); + let active_record = record.as_ref().filter(|record| record_is_published(record)); + let published_cid = active_record.map(|record| record.cid.clone()); + let in_trash = is_trash_uri(&target.localhost_root, &target.uri); + let visibility = library_visibility_metadata( + &target.localhost_root, + &target.uri, + is_dir, + blocked_reason.as_deref(), + active_record, + ); + let mut capabilities = if is_dir { + let mut capabilities = vec![ + "open", + "list", + "rename", + "move", + "copy", + "trash", + "properties", + ]; + if target.uri != target.localhost_root + && !is_runtime_private_uri(&target.localhost_root, &target.uri) + { + capabilities.push("download"); + capabilities.push("compress_archive"); + } + capabilities + } else { + let mut capabilities = vec![ + "open", + "read", + "download", + "rename", + "move", + "copy", + "publish", + "trash", + "properties", + ]; + if !is_runtime_private_uri(&target.localhost_root, &target.uri) { + capabilities.push("compress_archive"); + } + if active_record.is_some() { + capabilities.push("unpublish"); + capabilities.push("repair"); + capabilities.push("share"); + } + if is_extractable_archive_name(&name) { + capabilities.push("extract_archive"); + } + capabilities + }; + if in_trash { + capabilities = vec!["restore", "delete_permanently", "properties"]; + } + if blocked_reason.is_some() { + capabilities = vec!["properties"]; + } + let local_metadata = if is_dir { + Some(json!({ + "schema": "elastos.library.object-metadata/v1", + "visibility": visibility, + })) + } else { + let mut metadata = json!({ + "schema": "elastos.library.object-metadata/v1", + "visibility": visibility, + "content_identity": { + "schema": "elastos.library.content-identity/v1", + "current_cid": content_cid.clone(), + "scope": "local-object-head", + "published_cid": published_cid.clone(), + "note": "The current CID is the immutable raw-byte CID for this mutable object head. The published CID is set only after content-provider publish." + } + }); + if let Some(archive_support) = archive_support_for_name(&name) { + metadata["archive_support"] = archive_support; + } + Some(metadata) + }; + let viewers = viewer_options_for_name(data_dir, uri); + let viewer = viewers.first().map(|viewer| viewer.id.clone()); + let availability = if blocked_reason.is_some() { + "blocked".to_string() + } else { + record_availability_label(record.as_ref()) + }; + Ok(LibraryObject { + schema: LIBRARY_OBJECT_SCHEMA, + uri: target.uri, + name, + kind: if is_dir { "directory" } else { "file" }, + mime: if is_dir { + "inode/directory".to_string() + } else { + mime_for_name(uri).to_string() + }, + size, + created_at, + modified_at, + revision, + viewer, + viewers, + thumbnail_uri: None, + availability, + blocked_reason, + content_cid, + published_cid, + metadata: local_metadata, + published: active_record.is_some(), + shared: active_record.is_some_and(|record| record.shared_at.is_some()), + capabilities, + }) +} + +fn library_visibility_metadata( + localhost_root: &str, + uri: &str, + is_dir: bool, + blocked_reason: Option<&str>, + active_record: Option<&LibraryPublishRecord>, +) -> Value { + let placement = if is_trash_uri(localhost_root, uri) { + "trash" + } else if is_runtime_private_uri(localhost_root, uri) { + "runtime_private" + } else if is_public_uri(localhost_root, uri) { + "public_folder" + } else { + "private_folder" + }; + let share_policy = active_record + .and_then(|record| record.share_policy.as_deref()) + .unwrap_or("not_shared"); + let effective_access = if blocked_reason.is_some() { + "blocked" + } else if active_record.is_some_and(|record| record.shared_at.is_some()) { + match share_policy { + "recipient_scoped" => "recipient_scoped_link", + _ => "public_content_link", + } + } else if active_record.is_some() { + "public_content_link" + } else { + "principal_private" + }; + let published_cid = active_record.map(|record| record.cid.clone()); + + json!({ + "schema": LIBRARY_VISIBILITY_SCHEMA, + "placement": placement, + "placement_label": match placement { + "public_folder" => "Public folder", + "trash" => "Trash", + "runtime_private" => "Runtime private area", + _ => "Private folder", + }, + "effective_access": effective_access, + "published": active_record.is_some(), + "published_cid": published_cid.clone(), + "published_link": published_cid.map(|cid| format!("elastos://{cid}")), + "shared": active_record.is_some_and(|record| record.shared_at.is_some()), + "share_policy": share_policy, + "public_folder_policy": "placement_only", + "publish_required_for_public_link": !is_dir && active_record.is_none(), + "note": "Public folder placement is a user-facing Library projection. Public network access requires an explicit content-provider publish receipt." + }) +} + +fn is_public_uri(localhost_root: &str, uri: &str) -> bool { + let public_root = format!("{}/Public", localhost_root.trim_end_matches('/')); + uri == public_root || uri.starts_with(&format!("{public_root}/")) +} + +fn raw_sha256_cid(bytes: &[u8]) -> anyhow::Result { + let digest = Sha256::digest(bytes); + let multihash = cid::multihash::Multihash::<64>::wrap(0x12, &digest) + .map_err(|err| anyhow!("failed to build raw content CID: {err}"))?; + Ok(cid::Cid::new_v1(0x55, multihash).to_string()) +} + +fn is_unencrypted_principal_root_object(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + cause + .to_string() + .contains(crate::auth::PROTECTED_PRINCIPAL_ROOT_OBJECT_NOT_ENCRYPTED) + }) +} + +fn read_library_file_bytes( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + match crate::auth::read_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + ) { + Ok(bytes) => Ok(bytes), + Err(err) if is_unencrypted_principal_root_object(&err) => { + protect_legacy_plaintext_library_file(data_dir, principal_id, target) + } + Err(err) => Err(err), + } +} + +fn write_library_file_bytes( + data_dir: &Path, + principal_id: &str, + uri: &str, + _mime: Option<&str>, + if_revision: Option<&str>, + bytes: &[u8], +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + if let Some(parent) = target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + bytes, + )?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "write", + &target.uri, + json!({ + "object": object.clone(), + }), + )?; + Ok(object) +} + +fn library_download_object( + data_dir: &Path, + principal_id: &str, + uri: &str, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result<(LibraryObject, String, Vec)> { + let target = library_target(data_dir, principal_id, uri)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + if target.path.is_file() { + let bytes = read_library_file_bytes(data_dir, principal_id, &target)?; + let filename = object.name.clone(); + return Ok((object, filename, bytes)); + } + if !target.path.is_dir() { + bail!("library download target must be a file or directory"); + } + let bytes = archive_library_directory(data_dir, principal_id, &target, archive_format)?; + let filename = format!( + "{}.{}", + safe_archive_name(&object.name), + archive_format.extension() + ); + let mut archive_object = object; + archive_object.mime = archive_format.mime().to_string(); + archive_object.size = bytes.len() as u64; + Ok((archive_object, filename, bytes)) +} + +fn compress_library_archive( + data_dir: &Path, + principal_id: &str, + uri: Option<&str>, + uris: &[String], + if_revision: Option<&str>, +) -> anyhow::Result { + if !uris.is_empty() { + if uri.is_some() { + bail!("library compress_archive accepts either uri or uris, not both"); + } + if if_revision.is_some() { + bail!("library selected compress_archive does not accept if_revision"); + } + let (parent_uri, targets) = + library_selection_archive_targets(data_dir, principal_id, uris)?; + let bytes = archive_library_selection_zip(data_dir, principal_id, &targets)?; + let filename = library_selection_archive_filename(&parent_uri, LibraryArchiveFormat::Zip); + let archive_uri = unique_child_uri(data_dir, principal_id, &parent_uri, &filename)?; + let object = write_library_file_bytes( + data_dir, + principal_id, + &archive_uri, + Some(LibraryArchiveFormat::Zip.mime()), + None, + &bytes, + )?; + append_library_event( + data_dir, + principal_id, + "compress_archive", + &archive_uri, + json!({ + "uris": targets.iter().map(|target| target.uri.clone()).collect::>(), + "object": object.clone(), + }), + )?; + return Ok(object); + } + + let uri = uri.ok_or_else(|| anyhow!("library compress_archive requires uri or uris"))?; + let target = library_target(data_dir, principal_id, uri)?; + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + let parent_uri = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .ok_or_else(|| anyhow!("library compress_archive target has no parent"))?; + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + let filename = format!("{}.zip", safe_archive_name(name)); + let bytes = archive_library_single_zip(data_dir, principal_id, &target)?; + let archive_uri = unique_child_uri(data_dir, principal_id, &parent_uri, &filename)?; + let object = write_library_file_bytes( + data_dir, + principal_id, + &archive_uri, + Some(LibraryArchiveFormat::Zip.mime()), + None, + &bytes, + )?; + append_library_event( + data_dir, + principal_id, + "compress_archive", + &archive_uri, + json!({ + "source_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(object) +} + +fn protect_legacy_plaintext_library_file( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + let bytes = fs::read(&target.path).with_context(|| { + format!( + "failed to read legacy plaintext library object {:?}", + target.path + ) + })?; + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + &bytes, + )?; + Ok(bytes) +} + +fn archive_library_directory( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result> { + if target.uri == target.localhost_root { + bail!("library root download is not supported; use Recovery Kit for full backups"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library folders cannot be downloaded"); + } + match archive_format { + LibraryArchiveFormat::TarGz => { + archive_library_directory_tar_gz(data_dir, principal_id, target) + } + LibraryArchiveFormat::Zip => archive_library_directory_zip(data_dir, principal_id, target), + } +} + +fn archive_library_directory_tar_gz( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = tar::Builder::new(encoder); + let archive_root = PathBuf::from(safe_archive_name( + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"), + )); + append_library_archive_entry(&mut builder, data_dir, principal_id, target, &archive_root)?; + let encoder = builder.into_inner()?; + let bytes = encoder.finish()?; + Ok(bytes) +} + +fn archive_library_directory_zip( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + let archive_root = PathBuf::from(safe_archive_name( + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"), + )); + append_library_zip_entry(&mut writer, data_dir, principal_id, target, &archive_root)?; + Ok(writer.finish()?.into_inner()) +} + +fn archive_library_selection( + data_dir: &Path, + principal_id: &str, + uris: &[String], + archive_format: LibraryArchiveFormat, +) -> anyhow::Result<(String, Vec)> { + let (parent_uri, targets) = library_selection_archive_targets(data_dir, principal_id, uris)?; + let bytes = match archive_format { + LibraryArchiveFormat::TarGz => { + archive_library_selection_tar_gz(data_dir, principal_id, &targets)? + } + LibraryArchiveFormat::Zip => { + archive_library_selection_zip(data_dir, principal_id, &targets)? + } + }; + Ok(( + library_selection_archive_filename(&parent_uri, archive_format), + bytes, + )) +} + +fn library_selection_archive_targets( + data_dir: &Path, + principal_id: &str, + uris: &[String], +) -> anyhow::Result<(String, Vec)> { + if uris.len() < 2 { + bail!("library selected archive requires at least two objects"); + } + let mut seen = BTreeSet::new(); + let mut parent_uri: Option = None; + let mut targets = Vec::new(); + for uri in uris { + let target = library_target(data_dir, principal_id, uri)?; + if !seen.insert(target.uri.clone()) { + continue; + } + if target.uri == target.localhost_root { + bail!("library root download is not supported; use Recovery Kit for full backups"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library folders cannot be downloaded"); + } + if !target.path.is_file() && !target.path.is_dir() { + bail!("library selected archive entries must be files or directories"); + } + let parent = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .ok_or_else(|| anyhow!("library selected archive entry has no parent"))?; + if parent_uri + .as_deref() + .is_some_and(|expected| expected != parent) + { + bail!("library selected archive entries must share one parent folder"); + } + parent_uri = Some(parent); + targets.push(target); + } + if targets.len() < 2 { + bail!("library selected archive requires at least two unique objects"); + } + let parent_uri = parent_uri.ok_or_else(|| anyhow!("library selected archive has no parent"))?; + Ok((parent_uri, targets)) +} + +fn library_selection_archive_filename( + parent_uri: &str, + archive_format: LibraryArchiveFormat, +) -> String { + let parent_name = parent_uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + format!( + "{} Selection.{}", + safe_archive_name(parent_name), + archive_format.extension() + ) +} + +fn archive_library_selection_tar_gz( + data_dir: &Path, + principal_id: &str, + targets: &[LibraryTarget], +) -> anyhow::Result> { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = tar::Builder::new(encoder); + for target in targets { + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + append_library_archive_entry( + &mut builder, + data_dir, + principal_id, + target, + &PathBuf::from(safe_archive_name(name)), + )?; + } + let encoder = builder.into_inner()?; + Ok(encoder.finish()?) +} + +fn archive_library_selection_zip( + data_dir: &Path, + principal_id: &str, + targets: &[LibraryTarget], +) -> anyhow::Result> { + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + for target in targets { + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + append_library_zip_entry( + &mut writer, + data_dir, + principal_id, + target, + &PathBuf::from(safe_archive_name(name)), + )?; + } + Ok(writer.finish()?.into_inner()) +} + +fn archive_library_single_zip( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + if target.uri == target.localhost_root { + bail!("library root compress is not supported; use Recovery Kit for full backups"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library folders cannot be compressed"); + } + if !target.path.is_file() && !target.path.is_dir() { + bail!("library compress_archive target must be a file or directory"); + } + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + append_library_zip_entry( + &mut writer, + data_dir, + principal_id, + target, + &PathBuf::from(safe_archive_name(name)), + )?; + Ok(writer.finish()?.into_inner()) +} + +fn library_archive_entries( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive listing target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be listed"); + } + let name = library_archive_name(target, "listing")?; + if !is_extractable_archive_name(&name) { + bail!("library archive listing only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + archive_entries_for_object( + library_object(data_dir, principal_id, &target.uri)?, + &target.uri, + &name, + bytes, + ) +} + +fn archive_preview_entry( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + entry: &str, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive preview target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be previewed"); + } + let name = library_archive_name(target, "preview")?; + if !is_extractable_archive_name(&name) { + bail!("library archive preview only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + archive_preview_entry_for_object( + data_dir, + library_object(data_dir, principal_id, &target.uri)?, + &target.uri, + &name, + bytes, + entry, + ) +} + +fn archive_entries_for_object( + object: LibraryObject, + uri: &str, + archive_name: &str, + bytes: Vec, +) -> anyhow::Result { + if !is_extractable_archive_name(archive_name) { + bail!("library archive listing only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let lower_name = archive_name.to_ascii_lowercase(); + let (entries, truncated) = if lower_name.ends_with(".zip") { + list_zip_archive_entries(bytes)? + } else { + list_tar_archive_entries(archive_name, bytes)? + }; + let returned_entries = entries.len(); + Ok(json!({ + "schema": LIBRARY_ARCHIVE_ENTRIES_SCHEMA, + "object": object, + "uri": uri, + "family": archive_family_for_name(archive_name).unwrap_or("archive"), + "entries": entries, + "limits": { + "max_entries": MAX_ARCHIVE_LIST_ENTRIES, + "returned_entries": returned_entries, + "truncated": truncated, + }, + })) +} + +fn archive_preview_entry_for_object( + data_dir: &Path, + object: LibraryObject, + uri: &str, + archive_name: &str, + bytes: Vec, + entry: &str, +) -> anyhow::Result { + if !is_extractable_archive_name(archive_name) { + bail!("library archive preview only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let normalized = normalized_archive_entry_path(Path::new(entry))?; + let preview = if archive_name.to_ascii_lowercase().ends_with(".zip") { + preview_zip_archive_entry(bytes, &normalized)? + } else { + preview_tar_archive_entry(archive_name, bytes, &normalized)? + }; + let mime = mime_for_name(&normalized); + let text = if is_archive_preview_text_mime(mime) { + Some(String::from_utf8_lossy(&preview.bytes).to_string()) + } else { + None + }; + let entry_name = normalized + .rsplit('/') + .next() + .unwrap_or(normalized.as_str()) + .to_string(); + let viewers = viewer_options_for_name(data_dir, &normalized); + Ok(json!({ + "schema": LIBRARY_ARCHIVE_PREVIEW_ENTRY_SCHEMA, + "object": object, + "uri": uri, + "family": archive_family_for_name(archive_name).unwrap_or("archive"), + "entry": { + "path": normalized, + "name": entry_name, + "kind": "file", + "size": preview.size, + "compressed_size": preview.compressed_size, + "modified_at": preview.modified_at, + "mime": mime, + "safety": { + "status": "safe", + "reason": Value::Null, + }, + "viewers": viewers, + }, + "preview": { + "encoding": "base64", + "data": base64::engine::general_purpose::STANDARD.encode(&preview.bytes), + "text": text, + "truncated": preview.truncated, + "max_bytes": MAX_ARCHIVE_PREVIEW_BYTES, + "mode": "provider_bounded_safe_entry_preview", + }, + })) +} + +struct ArchiveEntryPreview { + bytes: Vec, + size: Option, + compressed_size: Option, + modified_at: Option, + truncated: bool, +} + +fn preview_zip_archive_entry( + bytes: Vec, + selected: &str, +) -> anyhow::Result { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let raw_name = file.name().to_string(); + let Ok(normalized) = normalized_archive_entry_path(Path::new(&raw_name)) else { + continue; + }; + if normalized != selected { + continue; + } + if file.is_dir() || !file.is_file() { + bail!("library archive preview only supports safe file entries"); + } + let declared_size = file.size(); + let mut preview_bytes = Vec::new(); + file.by_ref() + .take(MAX_ARCHIVE_PREVIEW_BYTES as u64 + 1) + .read_to_end(&mut preview_bytes)?; + let truncated = preview_bytes.len() > MAX_ARCHIVE_PREVIEW_BYTES + || declared_size > MAX_ARCHIVE_PREVIEW_BYTES as u64; + preview_bytes.truncate(MAX_ARCHIVE_PREVIEW_BYTES); + return Ok(ArchiveEntryPreview { + bytes: preview_bytes, + size: Some(declared_size), + compressed_size: Some(file.compressed_size()), + modified_at: None, + truncated, + }); + } + bail!("library archive preview entry not found"); +} + +fn preview_tar_archive_entry( + name: &str, + bytes: Vec, + selected: &str, +) -> anyhow::Result { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + for entry in archive.entries()? { + let mut entry = entry?; + let path = match entry.path() { + Ok(path) => path.to_path_buf(), + Err(_) => continue, + }; + let Ok(normalized) = normalized_archive_entry_path(&path) else { + continue; + }; + if normalized != selected { + continue; + } + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() || !entry_type.is_file() { + bail!("library archive preview only supports safe file entries"); + } + let declared_size = entry.header().size().ok(); + let modified_at = entry.header().mtime().ok(); + let mut preview_bytes = Vec::new(); + entry + .by_ref() + .take(MAX_ARCHIVE_PREVIEW_BYTES as u64 + 1) + .read_to_end(&mut preview_bytes)?; + let truncated = preview_bytes.len() > MAX_ARCHIVE_PREVIEW_BYTES + || declared_size.is_some_and(|size| size > MAX_ARCHIVE_PREVIEW_BYTES as u64); + preview_bytes.truncate(MAX_ARCHIVE_PREVIEW_BYTES); + return Ok(ArchiveEntryPreview { + bytes: preview_bytes, + size: declared_size, + compressed_size: None, + modified_at, + truncated, + }); + } + bail!("library archive preview entry not found"); +} + +fn is_archive_preview_text_mime(mime: &str) -> bool { + mime == "text/plain" || mime == "application/json" || mime == "text/html" +} + +fn library_archive_name(target: &LibraryTarget, action: &str) -> anyhow::Result { + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow!("library archive {action} target has no name")) +} + +fn check_object_revision(object: &LibraryObject, if_revision: Option<&str>) -> anyhow::Result<()> { + if let Some(expected) = if_revision { + if expected != object.revision { + bail!("library object revision mismatch"); + } + } + Ok(()) +} + +fn webspace_archive_object(mut object: LibraryObject) -> LibraryObject { + if let Some(metadata) = object.metadata.as_mut() { + redact_resolver_private_fields(metadata); + if let Some(map) = metadata.as_object_mut() { + map.insert("resolver_target_redacted".to_string(), Value::Bool(true)); + } + } + object +} + +fn redact_resolver_private_fields(value: &mut Value) { + match value { + Value::Object(map) => { + map.remove("target_uri"); + map.remove("provider_credentials"); + map.remove("endpoint_credentials"); + map.remove("credentials"); + for child in map.values_mut() { + redact_resolver_private_fields(child); + } + } + Value::Array(values) => { + for child in values { + redact_resolver_private_fields(child); + } + } + _ => {} + } +} + +fn redacted_resolver_private_value(mut value: Value) -> Value { + redact_resolver_private_fields(&mut value); + value +} + +fn list_tar_archive_entries(name: &str, bytes: Vec) -> anyhow::Result<(Vec, bool)> { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + let mut entries = Vec::new(); + let mut truncated = false; + for (index, entry) in archive.entries()?.enumerate() { + if entries.len() >= MAX_ARCHIVE_LIST_ENTRIES { + truncated = true; + break; + } + let entry = entry?; + let entry_type = entry.header().entry_type(); + let size = entry.header().size().ok(); + let modified_at = entry.header().mtime().ok(); + let path = match entry.path() { + Ok(path) => path.to_path_buf(), + Err(err) => { + entries.push(blocked_archive_entry_listing( + index, + format!("entry-{index}"), + None, + None, + modified_at, + format!("invalid archive entry path: {err}"), + )); + continue; + } + }; + let display_path = archive_entry_display_path(&path, index); + let normalized = match normalized_archive_entry_path(&path) { + Ok(path) => path, + Err(err) => { + entries.push(blocked_archive_entry_listing( + index, + display_path, + size, + None, + modified_at, + err.to_string(), + )); + continue; + } + }; + let kind = if entry_type.is_dir() { + "directory" + } else if entry_type.is_file() { + "file" + } else { + entries.push(blocked_archive_entry_listing( + index, + normalized, + size, + None, + modified_at, + "library archive listing rejects non-file archive entries".to_string(), + )); + continue; + }; + entries.push(safe_archive_entry_listing( + index, + normalized, + kind, + size, + None, + modified_at, + )); + } + Ok((entries, truncated)) +} + +fn list_zip_archive_entries(bytes: Vec) -> anyhow::Result<(Vec, bool)> { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + let mut entries = Vec::new(); + let truncated = archive.len() > MAX_ARCHIVE_LIST_ENTRIES; + for index in 0..archive.len().min(MAX_ARCHIVE_LIST_ENTRIES) { + let file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let raw_name = file.name().to_string(); + let path = Path::new(&raw_name); + let normalized = match normalized_archive_entry_path(path) { + Ok(path) => path, + Err(err) => { + entries.push(blocked_archive_entry_listing( + index, + archive_entry_display_name(&raw_name, index), + Some(file.size()), + Some(file.compressed_size()), + None, + err.to_string(), + )); + continue; + } + }; + let kind = if file.is_dir() { + "directory" + } else if file.is_file() { + "file" + } else { + entries.push(blocked_archive_entry_listing( + index, + normalized, + Some(file.size()), + Some(file.compressed_size()), + None, + "library archive listing rejects non-file archive entries".to_string(), + )); + continue; + }; + entries.push(safe_archive_entry_listing( + index, + normalized, + kind, + Some(file.size()), + Some(file.compressed_size()), + None, + )); + } + Ok((entries, truncated)) +} + +fn normalized_archive_entry_path(path: &Path) -> anyhow::Result { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(name) => { + let name = name + .to_str() + .ok_or_else(|| anyhow!("library archive entry path must be UTF-8"))?; + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + || name == "." + || name == ".." + { + bail!("library archive entry path must be relative and safe"); + } + parts.push(name); + } + _ => bail!("library archive entry path must be relative and safe"), + } + } + if parts.is_empty() { + bail!("library archive entry path must not be empty"); + } + Ok(parts.join("/")) +} + +fn archive_entry_display_path(path: &Path, index: usize) -> String { + let display = path.to_string_lossy().trim().to_string(); + if display.is_empty() { + format!("entry-{index}") + } else { + display + } +} + +fn archive_entry_display_name(name: &str, index: usize) -> String { + let display = name.trim(); + if display.is_empty() { + format!("entry-{index}") + } else { + display.to_string() + } +} + +struct ArchiveEntryListing { + index: usize, + path: String, + kind: &'static str, + size: Option, + compressed_size: Option, + modified_at: Option, + safety_status: &'static str, + safety_reason: Option, +} + +fn safe_archive_entry_listing( + index: usize, + path: String, + kind: &'static str, + size: Option, + compressed_size: Option, + modified_at: Option, +) -> Value { + archive_entry_listing(ArchiveEntryListing { + index, + path, + kind, + size, + compressed_size, + modified_at, + safety_status: "safe", + safety_reason: None, + }) +} + +fn blocked_archive_entry_listing( + index: usize, + path: String, + size: Option, + compressed_size: Option, + modified_at: Option, + safety_reason: String, +) -> Value { + archive_entry_listing(ArchiveEntryListing { + index, + path, + kind: "blocked", + size, + compressed_size, + modified_at, + safety_status: "blocked", + safety_reason: Some(safety_reason), + }) +} + +fn archive_entry_listing(entry: ArchiveEntryListing) -> Value { + let name = entry + .path + .rsplit('/') + .next() + .unwrap_or(&entry.path) + .to_string(); + json!({ + "id": format!("entry:{}", entry.index), + "path": entry.path, + "name": name, + "kind": entry.kind, + "size": entry.size, + "compressed_size": entry.compressed_size, + "modified_at": entry.modified_at, + "safety": { + "status": entry.safety_status, + "reason": entry.safety_reason, + }, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ArchiveConflictPolicy { + KeepBoth, + Replace, + Skip, +} + +impl ArchiveConflictPolicy { + fn parse(value: Option<&str>) -> anyhow::Result { + match value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("keep_both") + .to_ascii_lowercase() + .replace(' ', "_") + .as_str() + { + "keep_both" => Ok(Self::KeepBoth), + "replace" => Ok(Self::Replace), + "skip" => Ok(Self::Skip), + other => bail!("unsupported archive conflict policy: {other}"), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::KeepBoth => "keep_both", + Self::Replace => "replace", + Self::Skip => "skip", + } + } +} + +#[derive(Debug)] +struct ArchiveExtractOutcome { + requested: BTreeSet, + matched: BTreeSet, + written: Vec, + skipped: Vec, + blocked: Vec, + processed_entries: usize, +} + +impl ArchiveExtractOutcome { + fn new(requested: BTreeSet) -> Self { + Self { + requested, + matched: BTreeSet::new(), + written: Vec::new(), + skipped: Vec::new(), + blocked: Vec::new(), + processed_entries: 0, + } + } + + fn mark_matched(&mut self, normalized_path: &str) { + for selected in &self.requested { + if normalized_path == selected + || normalized_path + .strip_prefix(selected) + .is_some_and(|rest| rest.starts_with('/')) + { + self.matched.insert(selected.clone()); + } + } + } + + fn is_selected(&self, normalized_path: &str) -> bool { + self.requested.iter().any(|selected| { + normalized_path == selected + || normalized_path + .strip_prefix(selected) + .is_some_and(|rest| rest.starts_with('/')) + }) + } + + fn finish_missing(&mut self) { + for entry in self.requested.difference(&self.matched) { + self.skipped.push(json!({ + "path": entry, + "reason": "entry_not_found", + })); + } + } +} + +#[derive(Debug)] +enum ArchiveExtractWrite { + Directory { path: String }, + File { path: String, bytes: Vec }, +} + +struct ArchiveExtractRequest<'a> { + source_uri: &'a str, + archive_name: &'a str, + destination_uri: &'a str, + entries: &'a [String], + conflict_policy: Option<&'a str>, + cancel: bool, +} + +struct ArchiveExtractResponseInput<'a> { + destination_object: LibraryObject, + source_uri: &'a str, + destination_uri: &'a str, + archive_name: &'a str, + policy: ArchiveConflictPolicy, + status: &'a str, + outcome: ArchiveExtractOutcome, + cancel_requested: bool, +} + +fn extract_library_archive_entries( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + destination_uri: &str, + entries: &[String], + conflict_policy: Option<&str>, + cancel: bool, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive selected extraction target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be selectively extracted"); + } + let name = library_archive_name(target, "selected extraction")?; + if !is_extractable_archive_name(&name) { + bail!( + "library archive selected extraction only supports .tar, .tar.gz, .tgz, and .zip archives" + ); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let request = ArchiveExtractRequest { + source_uri: &target.uri, + archive_name: &name, + destination_uri, + entries, + conflict_policy, + cancel, + }; + extract_archive_entries_to_local_destination(data_dir, principal_id, bytes, request) +} + +fn extract_archive_entries_to_local_destination( + data_dir: &Path, + principal_id: &str, + bytes: Vec, + request: ArchiveExtractRequest<'_>, +) -> anyhow::Result { + if !is_extractable_archive_name(request.archive_name) { + bail!( + "library archive selected extraction only supports .tar, .tar.gz, .tgz, and .zip archives" + ); + } + let destination = library_target(data_dir, principal_id, request.destination_uri)?; + if destination.path.exists() && !destination.path.is_dir() { + bail!("library archive selected extraction destination must be a directory"); + } + let policy = ArchiveConflictPolicy::parse(request.conflict_policy)?; + let selected_entries = selected_archive_entries(request.entries)?; + let mut outcome = ArchiveExtractOutcome::new(selected_entries); + if request.cancel { + let destination_object = library_object(data_dir, principal_id, &destination.uri)?; + return Ok(archive_extract_entries_response( + ArchiveExtractResponseInput { + destination_object, + source_uri: request.source_uri, + destination_uri: &destination.uri, + archive_name: request.archive_name, + policy, + status: "cancelled", + outcome, + cancel_requested: true, + }, + )); + } + + fs::create_dir_all(&destination.path)?; + let writes = collect_selected_archive_writes(request.archive_name, bytes, &mut outcome)?; + apply_selected_archive_writes_to_local( + data_dir, + principal_id, + &destination, + writes, + policy, + &mut outcome, + )?; + outcome.finish_missing(); + let status = if outcome.blocked.is_empty() { + "completed" + } else { + "completed_with_blocked_entries" + }; + let destination_object = library_object(data_dir, principal_id, &destination.uri)?; + let response = archive_extract_entries_response(ArchiveExtractResponseInput { + destination_object, + source_uri: request.source_uri, + destination_uri: &destination.uri, + archive_name: request.archive_name, + policy, + status, + outcome, + cancel_requested: false, + }); + append_library_event( + data_dir, + principal_id, + "archive_extract_entries", + &destination.uri, + json!({ + "source_uri": request.source_uri, + "destination_uri": destination.uri, + "receipt": response.get("receipt").cloned().unwrap_or(Value::Null), + }), + )?; + Ok(response) +} + +fn selected_archive_entries(entries: &[String]) -> anyhow::Result> { + let mut selected = BTreeSet::new(); + for entry in entries { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + selected.insert(normalized_archive_entry_path(Path::new(entry))?); + } + if selected.is_empty() { + bail!("library archive selected extraction requires at least one entry"); + } + Ok(selected) +} + +fn collect_selected_archive_writes( + name: &str, + bytes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + if name.to_ascii_lowercase().ends_with(".zip") { + collect_selected_zip_writes(bytes, outcome) + } else { + collect_selected_tar_writes(name, bytes, outcome) + } +} + +fn collect_selected_tar_writes( + name: &str, + bytes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + let mut writes = Vec::new(); + for entry in archive.entries()? { + let mut entry = entry?; + let path = match entry.path() { + Ok(path) => path.to_path_buf(), + Err(err) => { + outcome.blocked.push(json!({ + "path": "invalid-entry-path", + "reason": format!("invalid archive entry path: {err}"), + })); + continue; + } + }; + let normalized = match normalized_archive_entry_path(&path) { + Ok(path) => path, + Err(err) => { + outcome.blocked.push(json!({ + "path": archive_entry_display_path(&path, outcome.processed_entries), + "reason": err.to_string(), + })); + continue; + } + }; + if !outcome.is_selected(&normalized) { + continue; + } + outcome.mark_matched(&normalized); + outcome.processed_entries += 1; + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + writes.push(ArchiveExtractWrite::Directory { path: normalized }); + continue; + } + if !entry_type.is_file() { + outcome.blocked.push(json!({ + "path": normalized, + "reason": "library archive selected extraction rejects non-file archive entries", + })); + continue; + } + let mut entry_bytes = Vec::new(); + entry.read_to_end(&mut entry_bytes)?; + writes.push(ArchiveExtractWrite::File { + path: normalized, + bytes: entry_bytes, + }); + } + Ok(writes) +} + +fn collect_selected_zip_writes( + bytes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + let mut writes = Vec::new(); + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let raw_name = file.name().to_string(); + let normalized = match normalized_archive_entry_path(Path::new(&raw_name)) { + Ok(path) => path, + Err(err) => { + outcome.blocked.push(json!({ + "path": archive_entry_display_name(&raw_name, index), + "reason": err.to_string(), + })); + continue; + } + }; + if !outcome.is_selected(&normalized) { + continue; + } + outcome.mark_matched(&normalized); + outcome.processed_entries += 1; + if file.is_dir() { + writes.push(ArchiveExtractWrite::Directory { path: normalized }); + continue; + } + if !file.is_file() { + outcome.blocked.push(json!({ + "path": normalized, + "reason": "library archive selected extraction rejects non-file archive entries", + })); + continue; + } + let mut entry_bytes = Vec::new(); + file.read_to_end(&mut entry_bytes)?; + writes.push(ArchiveExtractWrite::File { + path: normalized, + bytes: entry_bytes, + }); + } + Ok(writes) +} + +fn apply_selected_archive_writes_to_local( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + writes: Vec, + policy: ArchiveConflictPolicy, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + for write in writes { + match write { + ArchiveExtractWrite::Directory { path } => { + create_selected_archive_directory(data_dir, principal_id, destination, &path)?; + outcome.written.push(json!({ + "path": path, + "uri": archive_entry_uri(&destination.uri, Path::new(&path))?, + "kind": "directory", + })); + } + ArchiveExtractWrite::File { path, bytes } => { + write_selected_archive_file( + data_dir, + principal_id, + destination, + &path, + &bytes, + policy, + outcome, + )?; + } + } + } + Ok(()) +} + +async fn extract_archive_entries_to_webspace_destination( + data_dir: &Path, + registry: &ProviderRegistry, + principal_id: &str, + bytes: Vec, + request: ArchiveExtractRequest<'_>, +) -> anyhow::Result { + if !is_extractable_archive_name(request.archive_name) { + bail!( + "library archive selected extraction only supports .tar, .tar.gz, .tgz, and .zip archives" + ); + } + let destination_uri = clean_webspace_uri(request.destination_uri)?; + let destination_object = webspace_stat_object(data_dir, registry, &destination_uri).await?; + if destination_object.kind != "directory" { + bail!("library archive selected extraction WebSpace destination must be a folder"); + } + ensure_webspace_archive_write_allowed(registry, &destination_object).await?; + let policy = ArchiveConflictPolicy::parse(request.conflict_policy)?; + if policy != ArchiveConflictPolicy::Replace { + bail!("Spaces archive write-back requires conflict_policy=replace until resolver adapters expose existence/conflict APIs"); + } + let selected_entries = selected_archive_entries(request.entries)?; + let mut outcome = ArchiveExtractOutcome::new(selected_entries); + if request.cancel { + return Ok(archive_extract_entries_response( + ArchiveExtractResponseInput { + destination_object: webspace_archive_object(destination_object), + source_uri: request.source_uri, + destination_uri: &destination_uri, + archive_name: request.archive_name, + policy, + status: "cancelled", + outcome, + cancel_requested: true, + }, + )); + } + let writes = collect_selected_archive_writes(request.archive_name, bytes, &mut outcome)?; + apply_selected_archive_writes_to_webspace( + data_dir, + registry, + &destination_uri, + writes, + &mut outcome, + ) + .await?; + outcome.finish_missing(); + let status = if outcome.blocked.is_empty() { + "completed" + } else { + "completed_with_blocked_entries" + }; + let destination_object = webspace_stat_object(data_dir, registry, &destination_uri).await?; + let response = archive_extract_entries_response(ArchiveExtractResponseInput { + destination_object: webspace_archive_object(destination_object), + source_uri: request.source_uri, + destination_uri: &destination_uri, + archive_name: request.archive_name, + policy, + status, + outcome, + cancel_requested: false, + }); + append_library_event( + data_dir, + principal_id, + "archive_extract_entries", + &destination_uri, + json!({ + "source_uri": request.source_uri, + "destination_uri": destination_uri, + "receipt": response.get("receipt").cloned().unwrap_or(Value::Null), + }), + )?; + Ok(response) +} + +async fn ensure_webspace_archive_write_allowed( + registry: &ProviderRegistry, + destination: &LibraryObject, +) -> anyhow::Result<()> { + if webspace_metadata_bool(destination, "readonly") != Some(false) { + bail!("Spaces archive write-back requires a mutable destination Space"); + } + let Some(adapter) = webspace_adapter_target(registry, destination).await? else { + bail!("Spaces archive write-back requires a connected resolver adapter"); + }; + if !adapter.capabilities.contains("write_bytes") { + bail!("Spaces archive write-back requires an adapter with write_bytes capability"); + } + Ok(()) +} + +async fn apply_selected_archive_writes_to_webspace( + data_dir: &Path, + registry: &ProviderRegistry, + destination_uri: &str, + writes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + for write in writes { + match write { + ArchiveExtractWrite::Directory { path } => { + let uri = archive_entry_uri(destination_uri, Path::new(&path))?; + let mut receipt = webspace_mkdir(registry, &uri).await?; + redact_resolver_private_fields(&mut receipt); + outcome.written.push(json!({ + "path": path, + "uri": uri, + "kind": "directory", + "webspace": { + "write_back": "materialized_directory_handle", + "provider_receipt": receipt, + }, + })); + } + ArchiveExtractWrite::File { path, bytes } => { + write_selected_archive_webspace_file( + data_dir, + registry, + destination_uri, + &path, + &bytes, + outcome, + ) + .await?; + } + } + } + Ok(()) +} + +async fn write_selected_archive_webspace_file( + data_dir: &Path, + registry: &ProviderRegistry, + destination_uri: &str, + normalized_path: &str, + bytes: &[u8], + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + ensure_webspace_archive_parent_dirs(registry, destination_uri, normalized_path).await?; + let uri = archive_entry_uri(destination_uri, Path::new(normalized_path))?; + let mut provider_receipt = webspace_write_bytes(registry, &uri, bytes).await?; + redact_resolver_private_fields(&mut provider_receipt); + let sync_receipt = webspace_sync_bytes(data_dir, registry, &uri).await?; + if sync_receipt + .get("fail_closed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + bail!("Spaces archive write-back failed to sync resolver bytes"); + } + outcome.written.push(json!({ + "path": normalized_path, + "uri": uri, + "kind": "file", + "size": bytes.len(), + "webspace": { + "write_back": "resolver_synced", + "provider_receipt": provider_receipt, + "sync_receipt": redacted_resolver_private_value(sync_receipt), + }, + })); + Ok(()) +} + +async fn ensure_webspace_archive_parent_dirs( + registry: &ProviderRegistry, + destination_uri: &str, + normalized_path: &str, +) -> anyhow::Result<()> { + let mut uri = destination_uri.trim_end_matches('/').to_string(); + let mut components = normalized_path.split('/').peekable(); + while let Some(component) = components.next() { + if components.peek().is_none() { + break; + } + uri = child_uri(&uri, component)?; + let _ = webspace_mkdir(registry, &uri).await?; + } + Ok(()) +} + +fn create_selected_archive_directory( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + normalized_path: &str, +) -> anyhow::Result<()> { + let uri = archive_entry_uri(&destination.uri, Path::new(normalized_path))?; + let target = library_target(data_dir, principal_id, &uri)?; + fs::create_dir_all(&target.path)?; + Ok(()) +} + +fn write_selected_archive_file( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + normalized_path: &str, + bytes: &[u8], + policy: ArchiveConflictPolicy, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + let candidate_uri = archive_entry_uri(&destination.uri, Path::new(normalized_path))?; + let target_uri = selected_archive_conflict_uri( + data_dir, + principal_id, + &candidate_uri, + normalized_path, + policy, + outcome, + )?; + let Some(target_uri) = target_uri else { + return Ok(()); + }; + let target = library_target(data_dir, principal_id, &target_uri)?; + if let Some(parent) = target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + bytes, + )?; + outcome.written.push(json!({ + "path": normalized_path, + "uri": target.uri, + "kind": "file", + "size": bytes.len(), + })); + Ok(()) +} + +fn selected_archive_conflict_uri( + data_dir: &Path, + principal_id: &str, + candidate_uri: &str, + normalized_path: &str, + policy: ArchiveConflictPolicy, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + let target = library_target(data_dir, principal_id, candidate_uri)?; + if !target.path.exists() { + return Ok(Some(candidate_uri.to_string())); + } + match policy { + ArchiveConflictPolicy::Skip => { + outcome.skipped.push(json!({ + "path": normalized_path, + "uri": candidate_uri, + "reason": "conflict_skipped", + })); + Ok(None) + } + ArchiveConflictPolicy::Replace => { + if target.path.is_dir() { + fs::remove_dir_all(&target.path)?; + } else { + fs::remove_file(&target.path)?; + } + Ok(Some(candidate_uri.to_string())) + } + ArchiveConflictPolicy::KeepBoth => { + let (parent_uri, name) = candidate_uri + .rsplit_once('/') + .ok_or_else(|| anyhow!("archive selected extraction target has no parent"))?; + unique_child_uri(data_dir, principal_id, parent_uri, name).map(Some) + } + } +} + +fn archive_extract_entries_response(input: ArchiveExtractResponseInput<'_>) -> Value { + let ArchiveExtractResponseInput { + destination_object, + source_uri, + destination_uri, + archive_name, + policy, + status, + outcome, + cancel_requested, + } = input; + let requested_entries = outcome.requested.len(); + let written_entries = outcome.written.len(); + let skipped_entries = outcome.skipped.len(); + let blocked_entries = outcome.blocked.len(); + json!({ + "schema": LIBRARY_ARCHIVE_EXTRACT_ENTRIES_SCHEMA, + "object": destination_object, + "source_uri": source_uri, + "destination_uri": destination_uri, + "family": archive_family_for_name(archive_name).unwrap_or("archive"), + "conflict_policy": policy.as_str(), + "written": outcome.written, + "skipped": outcome.skipped, + "blocked": outcome.blocked, + "receipt": { + "schema": "elastos.library.archive-extract-entries.receipt/v1", + "status": status, + "progress": { + "requested_entries": requested_entries, + "processed_entries": outcome.processed_entries, + "written_entries": written_entries, + "skipped_entries": skipped_entries, + "blocked_entries": blocked_entries, + }, + "cancel": { + "supported": true, + "requested": cancel_requested, + "status": if cancel_requested { "cancelled_before_write" } else { "not_requested" }, + "mode": "bounded_synchronous_provider_operation", + }, + }, + }) +} + +fn extract_library_archive( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive extraction target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be extracted"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library archive extraction target has no name"))?; + if !is_extractable_archive_name(name) { + bail!("library archive extraction only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let parent_uri = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent) + .ok_or_else(|| anyhow!("library archive extraction target has no parent"))?; + let destination_name = archive_extract_folder_name(name); + let destination_uri = unique_child_uri(data_dir, principal_id, parent_uri, &destination_name)?; + let destination = library_target(data_dir, principal_id, &destination_uri)?; + fs::create_dir_all(&destination.path)?; + + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let lower_name = name.to_ascii_lowercase(); + if lower_name.ends_with(".zip") { + extract_zip_archive(data_dir, principal_id, &destination, bytes)?; + } else { + extract_tar_archive(data_dir, principal_id, &destination, name, bytes)?; + } + Ok(destination.uri) +} + +fn extract_tar_archive( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + name: &str, + bytes: Vec, +) -> anyhow::Result<()> { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + for entry in archive.entries()? { + let mut entry = entry?; + let entry_path = entry.path()?.to_path_buf(); + let entry_uri = archive_entry_uri(&destination.uri, &entry_path)?; + if entry_uri == destination.uri { + continue; + } + let entry_target = library_target(data_dir, principal_id, &entry_uri)?; + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + fs::create_dir_all(&entry_target.path)?; + continue; + } + if !entry_type.is_file() { + bail!("library archive extraction rejects non-file archive entries"); + } + let mut entry_bytes = Vec::new(); + entry.read_to_end(&mut entry_bytes)?; + if let Some(parent) = entry_target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &entry_target.localhost_root, + &entry_target.uri, + &entry_target.path, + &entry_bytes, + )?; + } + Ok(()) +} + +fn extract_zip_archive( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + bytes: Vec, +) -> anyhow::Result<()> { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let entry_path = file + .enclosed_name() + .ok_or_else(|| anyhow!("library archive entry path must be relative and safe"))?; + let entry_uri = archive_entry_uri(&destination.uri, &entry_path)?; + if entry_uri == destination.uri { + continue; + } + let entry_target = library_target(data_dir, principal_id, &entry_uri)?; + if file.is_dir() { + fs::create_dir_all(&entry_target.path)?; + continue; + } + if !file.is_file() { + bail!("library archive extraction rejects non-file archive entries"); + } + let mut entry_bytes = Vec::new(); + file.read_to_end(&mut entry_bytes)?; + if let Some(parent) = entry_target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &entry_target.localhost_root, + &entry_target.uri, + &entry_target.path, + &entry_bytes, + )?; + } + Ok(()) +} + +fn archive_entry_uri(destination_uri: &str, path: &Path) -> anyhow::Result { + let mut uri = destination_uri.trim_end_matches('/').to_string(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(name) => { + let name = name + .to_str() + .ok_or_else(|| anyhow!("library archive entry path must be UTF-8"))?; + uri = child_uri(&uri, name)?; + } + _ => bail!("library archive entry path must be relative and safe"), + } + } + Ok(uri) +} + +fn is_extractable_archive_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.ends_with(".tar") + || name.ends_with(".tar.gz") + || name.ends_with(".tgz") + || name.ends_with(".zip") +} + +fn archive_family_for_name(name: &str) -> Option<&'static str> { + let name = name.to_ascii_lowercase(); + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Some("tar.gz") + } else if name.ends_with(".tar") { + Some("tar") + } else if name.ends_with(".zip") { + Some("zip") + } else if name.ends_with(".tar.xz") || name.ends_with(".txz") { + Some("tar.xz") + } else if name.ends_with(".tar.bz2") || name.ends_with(".tbz2") { + Some("tar.bz2") + } else if name.ends_with(".tar.zst") || name.ends_with(".tzst") { + Some("tar.zst") + } else if name.ends_with(".rar") { + Some("rar") + } else if name.ends_with(".7z") { + Some("7z") + } else if name.ends_with(".xz") { + Some("xz") + } else if name.ends_with(".bz2") { + Some("bz2") + } else if name.ends_with(".zst") { + Some("zst") + } else if name.ends_with(".lz4") { + Some("lz4") + } else if name.ends_with(".gz") { + Some("gzip") + } else { + None + } +} + +fn archive_support_for_name(name: &str) -> Option { + let family = archive_family_for_name(name)?; + let extractable = is_extractable_archive_name(name); + Some(json!({ + "schema": "elastos.library.archive-support/v1", + "family": family, + "status": if extractable { + "extractable" + } else { + "policy_gated_unsupported_archive_family" + }, + "implemented": { + "download_formats": ["zip", "tar.gz"], + "compress_to_library": ["zip"], + "extract_formats": ["zip", "tar", "tar.gz", "tgz"], + "safety": "relative UTF-8 file paths only; non-file archive entries are rejected" + }, + "policy_gate": if extractable { + Value::Null + } else { + json!({ + "required": true, + "reason": "generic archive support needs dependency and release-policy review before enabling", + "blocked_formats": ["7z", "rar", "tar.xz", "tar.bz2", "tar.zst", "xz", "bz2", "zst", "lz4", "gzip"] + }) + } + })) +} + +fn archive_extract_folder_name(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + let stem = if lower.ends_with(".tar.gz") { + &name[..name.len().saturating_sub(".tar.gz".len())] + } else if lower.ends_with(".tgz") { + &name[..name.len().saturating_sub(".tgz".len())] + } else if lower.ends_with(".tar") { + &name[..name.len().saturating_sub(".tar".len())] + } else if lower.ends_with(".zip") { + &name[..name.len().saturating_sub(".zip".len())] + } else { + name + }; + let stem = stem.trim().trim_matches('.'); + if stem.is_empty() { + "Extracted Archive".to_string() + } else { + stem.to_string() + } +} + +fn append_library_archive_entry( + builder: &mut tar::Builder>>, + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + archive_path: &Path, +) -> anyhow::Result<()> { + let metadata = fs::metadata(&target.path)?; + if metadata.is_dir() { + builder.append_dir(archive_path, &target.path)?; + for entry in fs::read_dir(&target.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_uri = format!("{}/{}", target.uri, name); + let child_target = library_target(data_dir, principal_id, &child_uri)?; + let child_archive_path = archive_path.join(safe_archive_name(&name)); + append_library_archive_entry( + builder, + data_dir, + principal_id, + &child_target, + &child_archive_path, + )?; + } + return Ok(()); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + if let Some(modified) = system_time_secs(metadata.modified().ok()) { + header.set_mtime(modified); + } + header.set_cksum(); + let mut data = bytes.as_slice(); + builder.append_data(&mut header, archive_path, &mut data)?; + Ok(()) +} + +fn append_library_zip_entry( + writer: &mut ZipWriter>>, + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + archive_path: &Path, +) -> anyhow::Result<()> { + let metadata = fs::metadata(&target.path)?; + if metadata.is_dir() { + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.add_directory(zip_archive_entry_name(archive_path, true)?, options)?; + for entry in fs::read_dir(&target.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_uri = format!("{}/{}", target.uri, name); + let child_target = library_target(data_dir, principal_id, &child_uri)?; + let child_archive_path = archive_path.join(safe_archive_name(&name)); + append_library_zip_entry( + writer, + data_dir, + principal_id, + &child_target, + &child_archive_path, + )?; + } + return Ok(()); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let options = zip_file_options_for_entry(archive_path, bytes.len()); + writer.start_file(zip_archive_entry_name(archive_path, false)?, options)?; + writer.write_all(&bytes)?; + Ok(()) +} + +fn zip_file_options_for_entry(path: &Path, size: usize) -> zip::write::SimpleFileOptions { + let method = if should_store_zip_entry(path, size) { + zip::CompressionMethod::Stored + } else { + zip::CompressionMethod::Deflated + }; + zip::write::SimpleFileOptions::default().compression_method(method) +} + +fn should_store_zip_entry(path: &Path, size: usize) -> bool { + if size < 1024 { + return true; + } + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return false; + }; + matches!( + extension.to_ascii_lowercase().as_str(), + "zip" + | "gz" + | "tgz" + | "7z" + | "rar" + | "xz" + | "bz2" + | "zst" + | "lz4" + | "mp4" + | "mov" + | "mkv" + | "webm" + | "mp3" + | "aac" + | "ogg" + | "flac" + | "jpg" + | "jpeg" + | "png" + | "gif" + | "webp" + | "avif" + | "pdf" + ) +} + +fn zip_archive_entry_name(path: &Path, directory: bool) -> anyhow::Result { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Normal(name) => { + let name = name + .to_str() + .ok_or_else(|| anyhow!("library ZIP archive entry path must be UTF-8"))?; + if !name.is_empty() { + parts.push(name); + } + } + Component::CurDir => {} + _ => bail!("library ZIP archive entry path must be relative and safe"), + } + } + let mut name = parts.join("/"); + if name.is_empty() { + bail!("library ZIP archive entry path must not be empty"); + } + if directory && !name.ends_with('/') { + name.push('/'); + } + Ok(name) +} + +fn safe_archive_name(name: &str) -> String { + let sanitized: String = name + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '\0' => '_', + _ => ch, + }) + .collect(); + let sanitized = sanitized.trim().trim_matches('.').to_string(); + if sanitized.is_empty() { + "Library".to_string() + } else { + sanitized + } +} + +fn is_runtime_private_uri(localhost_root: &str, uri: &str) -> bool { + uri == format!("{localhost_root}/.AppData") + || uri + .strip_prefix(&format!("{localhost_root}/.AppData/")) + .is_some() + || is_trash_uri(localhost_root, uri) +} + +fn record_is_published(record: &LibraryPublishRecord) -> bool { + record.unpublished_at.is_none() + && record + .availability + .get("status") + .and_then(Value::as_str) + .map(|status| status != "local_unpinned") + .unwrap_or(true) +} + +fn default_publish_content_security() -> Value { + json!({ + "schema": "elastos.library.published-content-security/v1", + "source_storage": "unknown", + "published_payload": "plain_content", + "key_release_required": false, + "status": "not_required_for_plain_published_content", + "required_providers": protected_content_provider_requirements(false), + }) +} + +fn default_share_key_release() -> Value { + json!({ + "schema": "elastos.library.key-release/v1", + "required": false, + "status": "not_required_for_plain_published_content", + "required_providers": protected_content_provider_requirements(false), + "next": "Protected encrypted-content sharing requires drm/rights/key/decrypt providers before content is opened." + }) +} + +fn protected_content_provider_requirements(required: bool) -> Value { + let status = if required { + "required_for_encrypted_recipient_payload" + } else { + "not_required_for_plain_published_content" + }; + json!({ + "schema": "elastos.library.protected-content-provider-requirements/v1", + "required": required, + "status": status, + "providers": [ + { + "id": "drm-provider", + "scheme": "drm", + "role": "protected-content open orchestration", + "operation": "open", + "required": required + }, + { + "id": "rights-provider", + "scheme": "rights", + "role": "recipient rights/ACL decision", + "operation": "has_access_by_content_id", + "required": required + }, + { + "id": "key-provider", + "scheme": "key", + "role": "recipient-scoped key release", + "operation": "release", + "required": required + }, + { + "id": "decrypt-provider", + "scheme": "decrypt", + "role": "viewer-scoped decrypt/render session", + "operation": "open_session", + "required": required + } + ], + "authority_boundary": "Library records grants; drm/rights/key/decrypt providers enforce protected-content access without exposing raw CEKs or broad plaintext authority." + }) +} + +async fn attach_protected_content_provider_status(registry: &ProviderRegistry, data: &mut Value) { + let status = protected_content_provider_status(registry).await; + if let Some(object) = data.as_object_mut() { + object.insert("protected_content".to_string(), status.clone()); + if let Some(published) = object.get_mut("published").and_then(Value::as_object_mut) { + published.insert("protected_content".to_string(), status); + } + } +} + +async fn protected_content_provider_status(registry: &ProviderRegistry) -> Value { + let providers = [ + protected_content_provider_runtime_status(registry, "drm-provider", "drm", "open").await, + protected_content_provider_runtime_status( + registry, + "rights-provider", + "rights", + "has_access_by_content_id", + ) + .await, + protected_content_provider_runtime_status(registry, "key-provider", "key", "release").await, + protected_content_provider_runtime_status( + registry, + "decrypt-provider", + "decrypt", + "open_session", + ) + .await, + ]; + let available_count = providers + .iter() + .filter(|provider| { + provider + .get("available") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + let configured_count = providers + .iter() + .filter(|provider| { + provider + .get("configured") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + let provider_chain_ready = configured_count == providers.len(); + json!({ + "schema": "elastos.library.protected-content-provider-status/v1", + "authority_boundary": "Apps receive provider readiness and receipts only; drm/rights/key/decrypt providers retain protected-content open, dDRM, key-release, and decrypt authority.", + "available_provider_count": available_count, + "configured_provider_count": configured_count, + "required_provider_count": providers.len(), + "providers": providers, + "encrypted_recipient_sharing": { + "schema": "elastos.library.encrypted-recipient-sharing-readiness/v1", + "providers_ready": provider_chain_ready, + "protected_content_fixture_ready": true, + "production_encrypted_publish_mode_ready": false, + "status": if provider_chain_ready { + "provider_chain_ready" + } else { + "blocked_until_drm_rights_key_decrypt_providers_configured" + }, + "required_published_payload": "encrypted_recipient_content", + "next": if provider_chain_ready { + "Use protected_content_fixture for non-production receipt-chain tests. Production encrypted_recipient_content still requires real dDRM/dKMS payload encryption." + } else { + "Configure drm/rights/key/decrypt providers before encrypted recipient sharing can release keys." + } + } + }) +} + +async fn protected_content_provider_runtime_status( + registry: &ProviderRegistry, + id: &str, + scheme: &str, + primary_operation: &str, +) -> Value { + if registry.get(scheme).await.is_none() { + return json!({ + "id": id, + "scheme": scheme, + "primary_operation": primary_operation, + "available": false, + "configured": false, + "status": "provider_not_registered", + "next": format!("{id} must be installed and registered on the Runtime provider plane.") + }); + } + match registry.send_raw(scheme, &json!({ "op": "status" })).await { + Ok(response) => { + let data = response + .get("data") + .filter(|_| response.get("status").and_then(Value::as_str) == Some("ok")) + .unwrap_or(&response); + let configured = data + .get("configured") + .and_then(Value::as_bool) + .unwrap_or(false); + json!({ + "id": id, + "scheme": scheme, + "primary_operation": primary_operation, + "available": true, + "configured": configured, + "provider": data.get("provider").and_then(Value::as_str).unwrap_or(scheme), + "version": data.get("version").cloned().unwrap_or(Value::Null), + "contract_schema": data + .get("contract") + .and_then(|contract| contract.get("schema")) + .cloned() + .unwrap_or(Value::Null), + "supported_operations": data + .get("supported_operations") + .cloned() + .unwrap_or_else(|| json!([])), + "blocked_authority": data + .get("blocked_authority") + .cloned() + .unwrap_or_else(|| json!([])), + "status": if configured { + "configured" + } else { + "provider_registered_not_configured" + }, + "next": if configured { + "Provider is configured; encrypted publish mode still controls whether Library requests key release." + } else { + "Provider is installed but still fail-closed until its backend policy/key/decrypt configuration is complete." + } + }) + } + Err(err) => json!({ + "id": id, + "scheme": scheme, + "primary_operation": primary_operation, + "available": false, + "configured": false, + "status": "provider_status_unavailable", + "error": err.to_string(), + "next": format!("Inspect {id} registration and provider health.") + }), + } +} + +fn published_content_security( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result { + let source_storage = if crate::auth::load_principal_root_protection( + data_dir, + principal_id, + &target.localhost_root, + )? + .is_some() + { + "protected_principal_root" + } else { + "plain_localhost_root" + }; + Ok(json!({ + "schema": "elastos.library.published-content-security/v1", + "object_uri": target.uri, + "source_storage": source_storage, + "published_payload": "plain_content", + "key_release_required": false, + "status": "not_required_for_plain_published_content", + "required_providers": protected_content_provider_requirements(false), + "next": "Publishing currently materializes a plain content payload through content-provider. Encrypted recipient payloads require drm/rights/key/decrypt providers and encrypted-content publish mode." + })) +} + +fn protected_content_fixture_security( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + payload_cid: &str, +) -> anyhow::Result { + let source_storage = if crate::auth::load_principal_root_protection( + data_dir, + principal_id, + &target.localhost_root, + )? + .is_some() + { + "protected_principal_root" + } else { + "plain_localhost_root" + }; + let policy_hash = format!( + "sha256:{}", + hex::encode(Sha256::digest(format!("{}:{payload_cid}:view", target.uri))) + ); + let sealed_object = SealedObjectV1 { + schema: SEALED_OBJECT_SCHEMA.to_string(), + payload_cid: payload_cid.to_string(), + rights_policy_cid: payload_cid.to_string(), + availability_receipt_cid: payload_cid.to_string(), + key_envelope: KeyEnvelopeV1 { + scheme: "elastos-pq-hybrid-threshold-v0".to_string(), + kid: format!("kid:library:{}", &policy_hash["sha256:".len()..24]), + wrapped_cek: format!( + "fixture-wrapped:{}", + hex::encode(Sha256::digest(payload_cid.as_bytes())) + ), + policy_hash, + algorithms: KeyEnvelopeAlgorithmsV1 { + cipher: DEFAULT_PROTECTED_CONTENT_CIPHER.to_string(), + signature: DEFAULT_PROTECTED_CONTENT_SIGNATURES + .iter() + .map(|algorithm| algorithm.to_string()) + .collect(), + kem: DEFAULT_PROTECTED_CONTENT_KEMS + .iter() + .map(|algorithm| algorithm.to_string()) + .collect(), + share_scheme: DEFAULT_PROTECTED_CONTENT_SHARE_SCHEME.to_string(), + }, + }, + viewer: ViewerRequirementV1 { + required_interface: "elastos.viewer/document@1".to_string(), + }, + }; + Ok(json!({ + "schema": "elastos.library.published-content-security/v1", + "object_uri": target.uri, + "source_storage": source_storage, + "published_payload": "protected_content_fixture", + "payload_cid": payload_cid, + "sealed_cid": null, + "sealed_object": sealed_object, + "key_release_required": true, + "status": "protected_content_fixture_requires_provider_receipt_chain", + "production_encryption": false, + "required_providers": protected_content_provider_requirements(true), + "authority_boundary": "This fixture proves the Runtime protected-content receipt chain. It does not claim production dDRM/dKMS encryption.", + "next": "Replace protected_content_fixture with production encrypted_recipient_content once real rights, dKMS, decrypt, and storage policies are configured." + })) +} + +fn protected_content_fixture_publish_request( + principal_id: &str, + target: &LibraryTarget, + sealed_object: &SealedObjectV1, + availability: Option<&Value>, +) -> anyhow::Result { + let sealed_data = + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(sealed_object)?); + Ok(json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "files": [ + { + "path": "sealed.json", + "data": sealed_data + } + ], + "links": [ + { + "rel": "availability.receipt", + "cid": sealed_object.availability_receipt_cid + }, + { + "rel": "payload", + "cid": sealed_object.payload_cid + }, + { + "rel": "provenance", + "cid": sealed_object.payload_cid + }, + { + "rel": "rights.policy", + "cid": sealed_object.rights_policy_cid + } + ], + "pin": true, + "publisher_did": principal_id, + "metadata": { + "source_uri": target.uri, + "availability": availability.cloned().unwrap_or_else(|| json!({"status": "unknown"})), + "fixture": "protected-content-receipt-chain" + } + })) +} + +fn protected_content_sealed_object_from_security( + content_security: &Value, +) -> anyhow::Result { + let sealed = content_security + .get("sealed_object") + .cloned() + .ok_or_else(|| anyhow!("protected content security missing sealed_object"))?; + serde_json::from_value(sealed).context("protected content sealed_object is invalid") +} + +fn normalized_key_release_policy( + policy: Option<&str>, + content_security: &Value, +) -> anyhow::Result { + let policy = policy + .map(str::trim) + .filter(|policy| !policy.is_empty()) + .unwrap_or("auto"); + let content_requires_key_release = content_security + .get("key_release_required") + .and_then(Value::as_bool) + .unwrap_or(false); + match policy { + "auto" if content_requires_key_release => { + protected_content_key_release_policy(content_security) + } + "auto" | "none" | "plain_published_content" => Ok(json!({ + "schema": "elastos.library.key-release/v1", + "required": false, + "status": content_security + .get("status") + .and_then(Value::as_str) + .unwrap_or("not_required_for_plain_published_content"), + "published_payload": content_security + .get("published_payload") + .and_then(Value::as_str) + .unwrap_or("plain_content"), + "source_storage": content_security + .get("source_storage") + .and_then(Value::as_str) + .unwrap_or("unknown"), + "required_providers": protected_content_provider_requirements(false), + "next": "No recipient key release is needed for the current plain published payload. Encrypted recipient payloads require drm/rights/key/decrypt providers." + })), + "recipient_key_release" | "protected_content" | "encrypted_recipient" + if content_requires_key_release => + { + protected_content_key_release_policy(content_security) + } + "recipient_key_release" | "protected_content" | "encrypted_recipient" => bail!( + "recipient key release requires drm/rights/key/decrypt providers and encrypted-content publish mode" + ), + _ => bail!("unsupported library key_release_policy"), + } +} + +fn protected_content_key_release_policy(content_security: &Value) -> anyhow::Result { + let sealed_object = protected_content_sealed_object_from_security(content_security)?; + Ok(json!({ + "schema": "elastos.library.key-release/v1", + "required": true, + "status": "provider_receipt_chain_required", + "published_payload": content_security + .get("published_payload") + .and_then(Value::as_str) + .unwrap_or("protected_content"), + "payload_cid": sealed_object.payload_cid, + "sealed_cid": content_security + .get("sealed_cid") + .cloned() + .unwrap_or(Value::Null), + "viewer_interface": sealed_object.viewer.required_interface, + "key_envelope": sealed_object.key_envelope, + "required_providers": protected_content_provider_requirements(true), + "authority_boundary": "Library records the recipient grant; drm/rights/key/decrypt providers must issue receipts before a viewer opens protected content.", + "next": "Runtime shared_access must invoke drm, rights, key, and decrypt providers before returning a protected viewer session." + })) +} + +fn record_availability_label(record: Option<&LibraryPublishRecord>) -> String { + let Some(record) = record else { + return "local-only".to_string(); + }; + record + .availability + .get("status") + .and_then(Value::as_str) + .unwrap_or(if record_is_published(record) { + "published" + } else { + "local_unpinned" + }) + .to_string() +} + +fn normalized_share_recipients(recipients: &[String]) -> anyhow::Result> { + let mut normalized = BTreeSet::new(); + for recipient in recipients { + let recipient = recipient.trim(); + if recipient.is_empty() { + continue; + } + if recipient.len() > 256 || recipient.chars().any(char::is_control) { + bail!("library share recipient is invalid"); + } + if !(recipient.starts_with("did:") + || recipient.starts_with("person:") + || recipient.starts_with("principal:") + || recipient.contains('@')) + { + bail!("library share recipient must be a DID, principal/person id, or address"); + } + normalized.insert(recipient.to_string()); + } + Ok(normalized.into_iter().collect()) +} + +fn normalized_share_policy(policy: Option<&str>, public_link: bool) -> anyhow::Result { + let default_policy = if public_link { + "public_link" + } else { + "recipient_scoped" + }; + let policy = policy + .map(str::trim) + .filter(|policy| !policy.is_empty()) + .unwrap_or(default_policy); + match policy { + "public_link" if public_link => Ok(policy.to_string()), + "recipient_scoped" if !public_link => Ok(policy.to_string()), + "public_link" => bail!("public_link share policy must not include recipients"), + "recipient_scoped" => bail!("recipient_scoped share policy requires recipients"), + _ => bail!("unsupported library share policy"), + } +} + +fn share_remote_enforcement_contract(policy: &str, key_release: &Value) -> Value { + let key_release_required = key_release + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + let recipient_proof_required = policy == "recipient_scoped"; + json!({ + "schema": "elastos.library.remote-access-policy/v1", + "policy": policy, + "provider_gate": "object-provider shared_access", + "recipient_proof_required": recipient_proof_required, + "key_release_required": key_release_required, + "key_release_status": key_release + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"), + "required_providers": protected_content_provider_requirements(key_release_required), + "provider_invocation": { + "drm": "drm-provider.open", + "rights": "rights-provider.has_access_by_content_id", + "key": "key-provider.release", + "decrypt": "decrypt-provider.open_session", + "transport": "Carrier provider invocation when encrypted payloads are enabled" + }, + "plain_content_fetch": !key_release_required, + "status": if key_release_required { + "blocked_until_drm_rights_key_decrypt_providers" + } else if recipient_proof_required { + "recipient_proof_enforced_by_runtime" + } else { + "public_link_ready" + }, + "next": if key_release_required { + "Attach drm/rights/key/decrypt providers before releasing encrypted payload keys." + } else if recipient_proof_required { + "Remote recipients must present a Runtime recipient proof before object-provider returns the shared open contract." + } else { + "Published plain content is available to holders of the content URI." + } + }) +} + +fn share_grant( + recipient: &str, + cid: &str, + uri: &str, + policy: &str, + key_release: Value, + created_at: u64, +) -> LibraryShareGrant { + let digest = Sha256::digest(format!("{recipient}:{cid}:{policy}:{created_at}")); + LibraryShareGrant { + schema: "elastos.library.share-grant/v1".to_string(), + grant_id: format!("share:{}", hex::encode(&digest[..16])), + recipient: recipient.to_string(), + uri: uri.to_string(), + cid: cid.to_string(), + policy: policy.to_string(), + key_release, + created_at, + } +} + +fn shared_access_receipt( + record: &LibraryPublishRecord, + recipient: &str, + recipient_proof: Option<&Value>, +) -> anyhow::Result { + let policy = record.share_policy.as_deref().unwrap_or("public_link"); + match policy { + "public_link" => Ok(json!({ + "schema": "elastos.library.shared-access.receipt/v1", + "policy": "public_link", + "recipient": recipient.trim(), + "grant_id": null, + "content_security": record.content_security.clone(), + "key_release": default_share_key_release(), + "recipient_proof": shared_access_recipient_proof_state(recipient.trim(), false, "not_required_for_public_link", None, None), + "decision": shared_access_decision("public_link", recipient.trim(), None, true, "public_link"), + "open": shared_access_open_contract(record, "public_link", recipient.trim(), None, &default_share_key_release(), false) + })), + "recipient_scoped" => { + let normalized = normalized_share_recipients(&[recipient.to_string()])?; + let recipient = normalized + .first() + .ok_or_else(|| anyhow!("library shared_access recipient is required"))?; + let grant = record + .share_grants + .iter() + .find(|grant| grant.recipient == *recipient && grant.policy == "recipient_scoped") + .ok_or_else(|| anyhow!("library shared_access recipient is not authorized"))?; + let proof_state = validate_shared_access_recipient_proof(recipient, recipient_proof)?; + Ok(json!({ + "schema": "elastos.library.shared-access.receipt/v1", + "policy": "recipient_scoped", + "recipient": recipient, + "grant_id": grant.grant_id, + "content_security": record.content_security.clone(), + "key_release": grant.key_release.clone(), + "recipient_proof": proof_state, + "decision": shared_access_decision("recipient_scoped", recipient, Some(&grant.grant_id), true, "recipient grant and Runtime recipient proof matched"), + "open": shared_access_open_contract(record, "recipient_scoped", recipient, Some(&grant.grant_id), &grant.key_release, true) + })) + } + other => bail!("unsupported library share policy: {other}"), + } +} + +fn validate_shared_access_recipient_proof( + recipient: &str, + proof: Option<&Value>, +) -> anyhow::Result { + let proof = proof.ok_or_else(|| { + anyhow!( + "library shared_access requires Runtime recipient_proof for recipient_scoped policy" + ) + })?; + let schema = proof + .get("schema") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("library shared_access recipient_proof requires schema"))?; + if schema != "elastos.library.recipient-proof/v1" { + bail!("library shared_access recipient_proof schema is unsupported"); + } + let source = proof + .get("source") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if source != "runtime-launch-grant" { + bail!("library shared_access recipient_proof source is unsupported"); + } + let proof_binding_id = proof + .get("proof_binding_id") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow!("library shared_access recipient_proof requires proof_binding_id") + })?; + if !proof_binding_id.starts_with("proof:passkey:") { + bail!("library shared_access recipient_proof requires passkey proof binding"); + } + let claimed = proof + .get("recipient") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("library shared_access recipient_proof requires recipient"))?; + let normalized_requested = normalized_share_recipients(&[recipient.to_string()])?; + let normalized_claimed = normalized_share_recipients(&[claimed.to_string()])?; + if normalized_requested.first() != normalized_claimed.first() { + bail!("library shared_access recipient_proof recipient mismatch"); + } + let session_id = proof + .get("session_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + Ok(shared_access_recipient_proof_state( + normalized_requested + .first() + .map(String::as_str) + .unwrap_or(recipient), + true, + source, + Some(proof_binding_id), + session_id, + )) +} + +fn shared_access_recipient_proof_state( + recipient: &str, + verified: bool, + source: &str, + proof_binding_id: Option<&str>, + session_id: Option<&str>, +) -> Value { + json!({ + "schema": "elastos.library.recipient-proof-state/v1", + "recipient": recipient, + "verified": verified, + "source": source, + "proof_binding_id": proof_binding_id, + "session_id": session_id, + }) +} + +fn shared_access_decision( + policy: &str, + recipient: &str, + grant_id: Option<&str>, + allowed: bool, + reason: &str, +) -> Value { + json!({ + "schema": "elastos.library.access-decision/v1", + "policy": policy, + "recipient": recipient, + "grant_id": grant_id, + "allowed": allowed, + "reason": reason, + }) +} + +fn shared_access_open_contract( + record: &LibraryPublishRecord, + policy: &str, + recipient: &str, + grant_id: Option<&str>, + key_release: &Value, + recipient_proof_verified: bool, +) -> Value { + let key_release_required = key_release + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + json!({ + "schema": "elastos.library.shared-open/v1", + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "policy": policy, + "recipient": recipient, + "grant_id": grant_id, + "provider": "content-provider", + "transport": "runtime-provider-fetch", + "published_payload": record + .content_security + .get("published_payload") + .and_then(Value::as_str) + .unwrap_or("plain_content"), + "recipient_proof_verified": recipient_proof_verified, + "key_release_required": key_release_required, + "drm_provider_required": key_release_required, + "rights_provider_required": key_release_required, + "key_provider_required": key_release_required, + "decrypt_provider_required": key_release_required, + "required_providers": protected_content_provider_requirements(key_release_required), + "status": if key_release_required { + "blocked_until_drm_rights_key_decrypt_providers" + } else { + "ready_for_plain_content_fetch" + }, + "remote_enforcement": share_remote_enforcement_contract(policy, key_release), + }) +} + +fn library_roots(principal_id: &str) -> Vec { + let root = crate::auth::principal_localhost_root(principal_id); + [ + ("home", "Home", root.clone(), "principal-root"), + ("desktop", "Desktop", format!("{root}/Desktop"), "directory"), + ( + "documents", + "Documents", + format!("{root}/Documents"), + "directory", + ), + ( + "pictures", + "Pictures", + format!("{root}/Pictures"), + "directory", + ), + ("videos", "Videos", format!("{root}/Videos"), "directory"), + ( + "downloads", + "Downloads", + format!("{root}/Downloads"), + "directory", + ), + ("public", "Public", format!("{root}/Public"), "directory"), + ( + "webspaces", + "Spaces", + "localhost://WebSpaces".to_string(), + "webspace-root", + ), + ] + .into_iter() + .map(|(id, label, uri, kind)| LibraryRoot { + schema: LIBRARY_ROOT_SCHEMA, + id, + label, + uri, + kind, + }) + .collect() +} + +fn move_library_object( + data_dir: &Path, + principal_id: &str, + from_uri: &str, + to_uri: &str, +) -> anyhow::Result<()> { + let from = library_target(data_dir, principal_id, from_uri)?; + let to = library_target(data_dir, principal_id, to_uri)?; + if !from.path.exists() { + bail!("library source object not found"); + } + if to.path.exists() { + bail!("library destination already exists"); + } + if from.path.is_dir() { + move_library_directory(data_dir, principal_id, &from, &to)?; + } else { + move_library_file(data_dir, principal_id, &from, &to)?; + } + Ok(()) +} + +fn copy_library_object( + data_dir: &Path, + principal_id: &str, + from_uri: &str, + to_uri: &str, +) -> anyhow::Result<()> { + let from = library_target(data_dir, principal_id, from_uri)?; + let to = library_target(data_dir, principal_id, to_uri)?; + if !from.path.exists() { + bail!("library source object not found"); + } + if to.path.exists() { + bail!("library destination already exists"); + } + if from.path.is_dir() { + copy_library_directory(data_dir, principal_id, &from, &to)?; + } else { + copy_library_file(data_dir, principal_id, &from, &to)?; + } + Ok(()) +} + +fn copy_library_directory( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + fs::create_dir_all(&to.path)?; + for entry in fs::read_dir(&from.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_from_uri = format!("{}/{}", from.uri, name); + let child_to_uri = format!("{}/{}", to.uri, name); + let child_from = library_target(data_dir, principal_id, &child_from_uri)?; + let child_to = library_target(data_dir, principal_id, &child_to_uri)?; + if child_from.path.is_dir() { + copy_library_directory(data_dir, principal_id, &child_from, &child_to)?; + } else { + copy_library_file(data_dir, principal_id, &child_from, &child_to)?; + } + } + Ok(()) +} + +fn copy_library_file( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + let bytes = read_library_file_bytes(data_dir, principal_id, from)?; + if let Some(parent) = to.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &to.localhost_root, + &to.uri, + &to.path, + &bytes, + )?; + Ok(()) +} + +fn move_library_directory( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + fs::create_dir_all(&to.path)?; + for entry in fs::read_dir(&from.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_from_uri = format!("{}/{}", from.uri, name); + let child_to_uri = format!("{}/{}", to.uri, name); + let child_from = library_target(data_dir, principal_id, &child_from_uri)?; + let child_to = library_target(data_dir, principal_id, &child_to_uri)?; + if child_from.path.is_dir() { + move_library_directory(data_dir, principal_id, &child_from, &child_to)?; + } else { + move_library_file(data_dir, principal_id, &child_from, &child_to)?; + } + } + fs::remove_dir(&from.path)?; + Ok(()) +} + +fn move_library_file( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + let bytes = read_library_file_bytes(data_dir, principal_id, from)?; + if let Some(parent) = to.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &to.localhost_root, + &to.uri, + &to.path, + &bytes, + )?; + fs::remove_file(&from.path)?; + Ok(()) +} + +fn check_revision( + data_dir: &Path, + principal_id: &str, + uri: &str, + expected: Option<&str>, +) -> anyhow::Result<()> { + let Some(expected) = expected.filter(|value| !value.trim().is_empty()) else { + return Ok(()); + }; + let object = library_object(data_dir, principal_id, uri)?; + if object.revision != expected { + bail!("library object revision precondition failed"); + } + Ok(()) +} + +fn child_uri(parent_uri: &str, name: &str) -> anyhow::Result { + let name = clean_object_name(name)?; + Ok(format!("{}/{}", parent_uri.trim_end_matches('/'), name)) +} + +fn unique_child_uri( + data_dir: &Path, + principal_id: &str, + parent_uri: &str, + name: &str, +) -> anyhow::Result { + let mut candidate = child_uri(parent_uri, name)?; + if !library_target(data_dir, principal_id, &candidate)? + .path + .exists() + { + return Ok(candidate); + } + let timestamp = now_ts(); + let (stem, ext) = name.rsplit_once('.').unwrap_or((name, "")); + for index in 0..1_000 { + let suffix = if index == 0 { + timestamp.to_string() + } else { + format!("{timestamp}-{index}") + }; + let fallback = if ext.is_empty() { + format!("{stem} ({suffix})") + } else { + format!("{stem} ({suffix}).{ext}") + }; + candidate = child_uri(parent_uri, &fallback)?; + if !library_target(data_dir, principal_id, &candidate)? + .path + .exists() + { + return Ok(candidate); + } + } + bail!("failed to allocate unique Library object name") +} + +fn clean_object_name(name: &str) -> anyhow::Result<&str> { + let name = name.trim(); + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + || name == "." + || name == ".." + { + bail!("invalid library object name"); + } + Ok(name) +} + +fn is_trash_uri(localhost_root: &str, uri: &str) -> bool { + uri == format!("{localhost_root}/.Trash") + || uri + .strip_prefix(&format!("{localhost_root}/.Trash/")) + .is_some() +} + +fn publish_record_uri(localhost_root: &str, object_uri: &str) -> String { + let digest = hex::encode(Sha256::digest(object_uri.as_bytes())); + format!("{localhost_root}/.AppData/LocalHost/.Runtime/Library/Published/{digest}.json") +} + +fn read_publish_record( + data_dir: &Path, + principal_id: &str, + object_uri: &str, +) -> anyhow::Result { + let root = crate::auth::principal_localhost_root(principal_id); + let record_uri = publish_record_uri(&root, object_uri); + let record_path = rooted_localhost_fs_path(data_dir, &record_uri) + .ok_or_else(|| anyhow!("invalid library publish record path"))?; + let bytes = crate::auth::read_principal_root_object( + data_dir, + principal_id, + &root, + &record_uri, + &record_path, + )?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn write_publish_record( + data_dir: &Path, + principal_id: &str, + record: &LibraryPublishRecord, +) -> anyhow::Result<()> { + let root = crate::auth::principal_localhost_root(principal_id); + let record_uri = publish_record_uri(&root, &record.object_uri); + let record_path = rooted_localhost_fs_path(data_dir, &record_uri) + .ok_or_else(|| anyhow!("invalid library publish record path"))?; + if let Some(parent) = record_path.parent() { + fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec_pretty(record)?; + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &root, + &record_uri, + &record_path, + &bytes, + ) +} + +fn library_events( + data_dir: &Path, + principal_id: &str, + uri_filter: Option<&str>, + since: Option, + limit: Option, +) -> anyhow::Result> { + let root = crate::auth::principal_localhost_root(principal_id); + let uri_filter = uri_filter + .map(|uri| clean_library_uri(&root, uri)) + .transpose()?; + let limit = limit.unwrap_or(64).clamp(1, MAX_LIBRARY_EVENTS); + let mut events = read_library_events(data_dir, principal_id)?; + events.retain(|event| { + since.map(|since| event.at > since).unwrap_or(true) + && uri_filter + .as_deref() + .map(|uri| library_event_matches_uri(event, uri)) + .unwrap_or(true) + }); + if events.len() > limit { + let keep_from = events.len() - limit; + events.drain(0..keep_from); + } + Ok(events) +} + +fn append_library_event( + data_dir: &Path, + principal_id: &str, + op: &str, + uri: &str, + details: Value, +) -> anyhow::Result { + let mut events = read_library_events(data_dir, principal_id)?; + let event = LibraryEvent { + schema: LIBRARY_EVENT_SCHEMA.to_string(), + event_id: library_event_id(op, uri, &details), + op: op.to_string(), + uri: uri.to_string(), + at: now_ts(), + details, + }; + events.push(event.clone()); + if events.len() > MAX_LIBRARY_EVENTS { + let keep_from = events.len() - MAX_LIBRARY_EVENTS; + events.drain(0..keep_from); + } + write_library_events(data_dir, principal_id, &events)?; + library_event_notifier().notify_waiters(); + Ok(event) +} + +fn read_library_events(data_dir: &Path, principal_id: &str) -> anyhow::Result> { + let root = crate::auth::principal_localhost_root(principal_id); + let event_uri = library_event_log_uri(&root); + let event_path = rooted_localhost_fs_path(data_dir, &event_uri) + .ok_or_else(|| anyhow!("invalid library event log path"))?; + if !event_path.exists() { + return Ok(Vec::new()); + } + let bytes = crate::auth::read_principal_root_object( + data_dir, + principal_id, + &root, + &event_uri, + &event_path, + )?; + let text = String::from_utf8(bytes).context("library event log must be utf-8 jsonl")?; + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("invalid library event entry")) + .collect() +} + +fn write_library_events( + data_dir: &Path, + principal_id: &str, + events: &[LibraryEvent], +) -> anyhow::Result<()> { + let root = crate::auth::principal_localhost_root(principal_id); + let event_uri = library_event_log_uri(&root); + let event_path = rooted_localhost_fs_path(data_dir, &event_uri) + .ok_or_else(|| anyhow!("invalid library event log path"))?; + if let Some(parent) = event_path.parent() { + fs::create_dir_all(parent)?; + } + let mut bytes = Vec::new(); + for event in events { + serde_json::to_writer(&mut bytes, event)?; + bytes.push(b'\n'); + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &root, + &event_uri, + &event_path, + &bytes, + ) +} + +fn library_event_log_uri(localhost_root: &str) -> String { + format!("{localhost_root}/.AppData/LocalHost/.Runtime/Library/events.jsonl") +} + +fn library_event_id(op: &str, uri: &str, details: &Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(now_nanos().to_be_bytes()); + hasher.update(op.as_bytes()); + hasher.update(uri.as_bytes()); + hasher.update(details.to_string().as_bytes()); + let digest = hasher.finalize(); + format!("library:event:{}", hex::encode(&digest[..16])) +} + +fn library_event_matches_uri(event: &LibraryEvent, uri: &str) -> bool { + uri_matches_filter(&event.uri, uri) + || [ + "old_uri", + "original_uri", + "source_uri", + "trash_uri", + "target_uri", + ] + .into_iter() + .any(|field| { + event + .details + .get(field) + .and_then(Value::as_str) + .map(|value| uri_matches_filter(value, uri)) + .unwrap_or(false) + }) + || event + .details + .get("object") + .and_then(|object| object.get("uri")) + .and_then(Value::as_str) + .map(|value| uri_matches_filter(value, uri)) + .unwrap_or(false) +} + +fn uri_matches_filter(uri: &str, filter: &str) -> bool { + uri == filter + || uri + .strip_prefix(filter.trim_end_matches('/')) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn directory_revision(path: &Path, uri: &str) -> anyhow::Result { + let mut hasher = Sha256::new(); + hasher.update(uri.as_bytes()); + if path.exists() { + for entry in fs::read_dir(path)? { + let entry = entry?; + hasher.update(entry.file_name().to_string_lossy().as_bytes()); + let metadata = entry.metadata()?; + hasher.update(metadata.len().to_be_bytes()); + if let Some(modified) = system_time_secs(metadata.modified().ok()) { + hasher.update(modified.to_be_bytes()); + } + } + } + Ok(format!("rev:{}", hex::encode(hasher.finalize()))) +} + +fn system_time_secs(time: Option) -> Option { + time.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) +} + +fn now_ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + +fn mime_for_name(name: &str) -> &'static str { + let lower = name.to_lowercase(); + if lower.ends_with(".md") || lower.ends_with(".txt") { + "text/plain" + } else if lower.ends_with(".html") { + "text/html" + } else if lower.ends_with(".json") { + "application/json" + } else if lower.ends_with(".png") { + "image/png" + } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + "image/jpeg" + } else if lower.ends_with(".gif") { + "image/gif" + } else if lower.ends_with(".pdf") { + "application/pdf" + } else if lower.ends_with(".tar") { + "application/x-tar" + } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { + "application/gzip" + } else if lower.ends_with(".zip") { + "application/zip" + } else if lower.ends_with(".mp4") { + "video/mp4" + } else if lower.ends_with(".mp3") { + "audio/mpeg" + } else { + "application/octet-stream" + } +} + +fn viewer_options_for_name(data_dir: &Path, name: &str) -> Vec { + viewer_ids_for_name(name) + .into_iter() + .filter_map(|id| installed_viewer_option(data_dir, id)) + .collect() +} + +fn viewer_ids_for_name(name: &str) -> Vec<&'static str> { + let lower = name.to_lowercase(); + if archive_family_for_name(&lower).is_some() { + vec!["archive-manager"] + } else if lower.ends_with(".md") || lower.ends_with(".txt") { + vec!["documents"] + } else if lower.ends_with(".png") + || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".gif") + { + vec!["image-viewer"] + } else if lower.ends_with(".mp4") { + vec!["video-viewer"] + } else if lower.ends_with(".pdf") { + vec!["documents"] + } else if lower.ends_with(".gba") || lower.ends_with(".gb") || lower.ends_with(".gbc") { + vec!["gba-emulator"] + } else { + Vec::new() + } +} + +fn installed_viewer_option(data_dir: &Path, id: &str) -> Option { + crate::api::browser_capsules::list_launchable_browser_capsules(data_dir) + .into_iter() + .find(|capsule| capsule.name == id && capsule.role == elastos_common::CapsuleRole::Viewer) + .map(|capsule| LibraryViewerOption { + id: capsule.name.clone(), + label: viewer_label(&capsule.name).to_string(), + description: capsule.description, + default: true, + }) +} + +fn viewer_label(id: &str) -> &str { + match id { + "documents" => "Documents", + "image-viewer" => "Image Viewer", + "video-viewer" => "Video Viewer", + "gba-emulator" => "GBA Emulator", + "archive-manager" => "Archive Manager", + _ => id, + } +} + +fn provider_ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn provider_error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_provider_exposes_object_scheme_only() { + let registry = Arc::new(ProviderRegistry::new()); + let provider = ObjectProvider::new(PathBuf::new(), Arc::downgrade(®istry)); + let schemes = provider.schemes(); + + assert!(schemes.contains(&"object")); + assert!(!schemes.contains(&"library")); + assert_eq!(provider.name(), "object-provider"); + } +} From a4e200229e47763b8cb134baac122a63ec049674 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:44:52 +0000 Subject: [PATCH 03/18] feat(home): project Library objects onto Desktop Make Home/Desktop use the same Library object model for file and folder projections, including signed session context, desktop item rendering, self-open Library windows, and Home system API support. This keeps desktop files/shortcuts consistent with Library instead of creating a second file authority surface. --- capsules/documents/index.html | 136 ++++++++- capsules/home/browser/index.html | 6 +- capsules/home/browser/service-worker.js | 33 +-- capsules/home/browser/shell-auth.js | 2 +- capsules/home/browser/shell-chrome.js | 2 +- capsules/home/browser/shell-core.js | 75 ++++- capsules/home/browser/shell-surface.js | 274 ++++++++++++++++-- .../home/browser/shell-window-geometry.js | 2 +- capsules/home/browser/shell-windows.js | 4 +- capsules/home/browser/shell.js | 62 ++-- .../src/api/gateway_home_system.rs | 154 +++++++++- .../elastos-server/src/api/gateway_models.rs | 12 + elastos/crates/elastos-server/src/home_cmd.rs | 2 + 13 files changed, 661 insertions(+), 103 deletions(-) diff --git a/capsules/documents/index.html b/capsules/documents/index.html index cfc8627d..f117f057 100644 --- a/capsules/documents/index.html +++ b/capsules/documents/index.html @@ -1139,6 +1139,10 @@

Continue?

homeToken: new URLSearchParams(window.location.search).get("home_token") || "", initialDocDid: new URLSearchParams(window.location.search).get("doc") || "", initialCid: new URLSearchParams(window.location.search).get("cid") || "", + initialLibraryObjectUri: + new URLSearchParams(window.location.search).get("objectUri") || + new URLSearchParams(window.location.search).get("object_uri") || + "", initialView: new URLSearchParams(window.location.search).get("view") || "", initialIntent: new URLSearchParams(window.location.search).get("intent") || "", mode: "share", @@ -1347,8 +1351,22 @@

Continue?

return docDid ? "localhost://ElastOS/Documents/" + docDid : ""; } +function baseName(uri) { + const clean = String(uri || "").replace(/\/+$/, ""); + const name = clean.split("/").pop() || "Document"; + try { + return decodeURIComponent(name); + } catch (_error) { + return name; + } +} + +function isLibraryFileDocument(document) { + return !!document && !!document.library_object_uri; +} + function isDraftDocument(document) { - return !!document && !document.doc_did; + return !!document && !document.doc_did && !isLibraryFileDocument(document); } function createDraftDocument() { @@ -1424,11 +1442,12 @@

Continue?

state.dirty = !!dirty; const hasCurrent = !!state.current; const hasPersistedCurrent = hasCurrent && !isDraftDocument(state.current); + const isLibraryFile = isLibraryFileDocument(state.current); elements.saveButton.disabled = !hasCurrent || !state.dirty; - elements.saveAsButton.disabled = !hasPersistedCurrent; - elements.publishButton.disabled = !hasCurrent; - elements.unpublishButton.disabled = !hasPersistedCurrent || !state.current.latest_published_cid; - elements.deleteButton.disabled = !hasPersistedCurrent; + elements.saveAsButton.disabled = !hasPersistedCurrent || isLibraryFile; + elements.publishButton.disabled = !hasCurrent || isLibraryFile; + elements.unpublishButton.disabled = !hasPersistedCurrent || isLibraryFile || !state.current.latest_published_cid; + elements.deleteButton.disabled = !hasPersistedCurrent || isLibraryFile; if (hasCurrent && state.dirty) { setStatus("Unsaved local changes.", "warning"); } else if (hasCurrent) { @@ -1541,7 +1560,7 @@

Continue?

return; } - elements.titleInput.disabled = false; + elements.titleInput.disabled = isLibraryFileDocument(state.current); elements.editor.disabled = false; elements.titleInput.value = state.current.title; elements.editor.value = state.current.body; @@ -1634,6 +1653,43 @@

Continue?

return response && typeof response === "object" && "data" in response ? response.data : response; } +async function libraryObjectApi(op, payload) { + const uri = payload && payload.uri ? String(payload.uri) : ""; + const endpoint = "/api/viewers/documents/library-object?uri=" + encodeURIComponent(uri); + const response = op === "write" + ? await documentsApi(endpoint, { + method: "PUT", + body: JSON.stringify({ + data: payload.data || "", + mime: payload.mime || null, + if_revision: payload.if_revision || null, + }), + }) + : await documentsApi(endpoint, { method: "GET" }); + if (response && response.status === "error") { + throw new Error(response.message || "Library object request failed"); + } + return response && typeof response === "object" && "data" in response ? response.data : response; +} + +function base64ToUtf8(data) { + const binary = atob(data || ""); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); +} + +function utf8ToBase64(text) { + const bytes = new TextEncoder().encode(String(text || "")); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +} + function upsertDocumentListItem(document) { const listItem = { doc_did: document.doc_did, @@ -1674,6 +1730,13 @@

Continue?

await createDocument(""); return; } + if (state.initialLibraryObjectUri) { + const requestedUri = state.initialLibraryObjectUri.trim(); + state.initialLibraryObjectUri = ""; + await loadLibraryObject(requestedUri); + setWorkspaceView(state.initialView === "write" ? "write" : "read"); + return; + } if (state.initialCid) { const requestedCid = state.initialCid.trim(); state.initialCid = ""; @@ -1705,6 +1768,30 @@

Continue?

startDraftDocument({ focusTitle: true }); } +async function loadLibraryObject(uri) { + const response = await libraryObjectApi("read", { uri }); + const object = response.object || {}; + const name = object.name || baseName(object.uri || uri); + state.current = { + doc_did: "", + document_uri: object.uri || uri, + library_object_uri: object.uri || uri, + revision: object.revision || "", + title: name, + file_name: name, + working_copy_uri: object.uri || uri, + body: base64ToUtf8(response.data || ""), + mime: object.mime || "text/markdown", + created_at: object.created_at || 0, + updated_at: object.modified_at || 0, + latest_published_cid: null, + publish_history: [], + }; + renderDocumentsList(); + renderCurrentDocument(); + setStatus("Opened from Library."); +} + async function selectDocument(docDid, options) { const force = !!(options && options.force); if (state.current && state.current.doc_did !== docDid && state.dirty) { @@ -1751,6 +1838,30 @@

Continue?

return; } setStatus("Saving…"); + if (isLibraryFileDocument(state.current)) { + const response = await libraryObjectApi("write", { + uri: state.current.library_object_uri, + data: utf8ToBase64(elements.editor.value), + mime: state.current.mime || "text/markdown", + if_revision: state.current.revision || null, + }); + const object = response.object || {}; + state.current = { + ...state.current, + document_uri: object.uri || state.current.document_uri, + library_object_uri: object.uri || state.current.library_object_uri, + revision: object.revision || state.current.revision, + title: object.name || state.current.title, + file_name: object.name || state.current.file_name, + body: elements.editor.value, + mime: object.mime || state.current.mime, + updated_at: object.modified_at || state.current.updated_at, + }; + renderCurrentDocument(); + renderDocumentsList(); + setStatus("Saved to Library."); + return; + } if (isDraftDocument(state.current)) { const created = await documentsProviderApi("create", { title: elements.titleInput.value || null, @@ -1852,6 +1963,10 @@

Continue?

if (!state.current) { return; } + if (isLibraryFileDocument(state.current)) { + setStatus("Publish Library files from Library.", "warning"); + return; + } try { if (state.dirty || isDraftDocument(state.current)) { await saveCurrent(); @@ -1873,7 +1988,12 @@

Continue?

} async function unpublishCurrent() { - if (!state.current || isDraftDocument(state.current) || !state.current.latest_published_cid) { + if ( + !state.current || + isDraftDocument(state.current) || + isLibraryFileDocument(state.current) || + !state.current.latest_published_cid + ) { return; } try { @@ -1890,7 +2010,7 @@

Continue?

} async function requestDeleteCurrent() { - if (!state.current || isDraftDocument(state.current)) { + if (!state.current || isDraftDocument(state.current) || isLibraryFileDocument(state.current)) { return; } const deletedDocDid = state.current.doc_did; diff --git a/capsules/home/browser/index.html b/capsules/home/browser/index.html index 02c08ece..9a20fcf7 100644 --- a/capsules/home/browser/index.html +++ b/capsules/home/browser/index.html @@ -10,10 +10,10 @@ Home · ElastOS - + - +
@@ -206,6 +206,6 @@

Runtime data not attached on this host

- + diff --git a/capsules/home/browser/service-worker.js b/capsules/home/browser/service-worker.js index 6aa8895a..0fd88dac 100644 --- a/capsules/home/browser/service-worker.js +++ b/capsules/home/browser/service-worker.js @@ -1,4 +1,3 @@ -const CACHE_NAME = "elastos-home-20260526d"; const CACHE_PREFIX = "elastos-home-"; self.addEventListener("install", (event) => { @@ -10,36 +9,12 @@ self.addEventListener("activate", (event) => { caches.keys() .then((keys) => Promise.all( keys - .filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME) + .filter((key) => key.startsWith(CACHE_PREFIX)) .map((key) => caches.delete(key)), )) - .then(() => self.clients.claim()), + .then(() => self.clients.claim()) + .then(() => self.registration.unregister()), ); }); -self.addEventListener("fetch", (event) => { - const request = event.request; - if (request.method !== "GET") { - return; - } - - const url = new URL(request.url); - if (url.origin !== self.location.origin || !url.pathname.includes("/apps/home/")) { - return; - } - - event.respondWith( - fetch(request) - .then((response) => { - if (response.ok) { - const copy = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); - } - return response; - }) - .catch(() => caches.match(request).then((cached) => cached || new Response("", { - status: 504, - statusText: "Offline", - }))), - ); -}); +self.addEventListener("fetch", () => {}); diff --git a/capsules/home/browser/shell-auth.js b/capsules/home/browser/shell-auth.js index 2236432c..1d3244ce 100644 --- a/capsules/home/browser/shell-auth.js +++ b/capsules/home/browser/shell-auth.js @@ -1,4 +1,4 @@ -import { fetchJson } from "./shell-core.js?v=home-20260526d"; +import { fetchJson } from "./shell-core.js?v=home-20260603c"; const unlockPanel = document.querySelector("#home-unlock"); const unlockCard = document.querySelector(".home-unlock-card"); diff --git a/capsules/home/browser/shell-chrome.js b/capsules/home/browser/shell-chrome.js index 63ab10fa..f46133ee 100644 --- a/capsules/home/browser/shell-chrome.js +++ b/capsules/home/browser/shell-chrome.js @@ -1,4 +1,4 @@ -import { clockNode } from "./shell-core.js?v=home-20260526d"; +import { clockNode } from "./shell-core.js?v=home-20260603c"; export function syncIdentity(_summary) {} diff --git a/capsules/home/browser/shell-core.js b/capsules/home/browser/shell-core.js index 7f943884..352d96a1 100644 --- a/capsules/home/browser/shell-core.js +++ b/capsules/home/browser/shell-core.js @@ -129,6 +129,57 @@ export function allVisibleTargets(summary) { return (summary && Array.isArray(summary.targets)) ? summary.targets : []; } +export function desktopObjects(summary) { + const objects = summary && + summary.desktop_objects && + Array.isArray(summary.desktop_objects.objects) + ? summary.desktop_objects.objects + : []; + return objects.filter((object) => ( + object && + typeof object.uri === "string" && + object.uri.trim() !== "" && + typeof object.name === "string" && + object.name.trim() !== "" + )); +} + +export function desktopObjectEntryId(object) { + return `object:${object.uri}`; +} + +export function desktopObjectByEntryId(summary, entryId) { + if (typeof entryId !== "string" || !entryId.startsWith("object:")) { + return null; + } + const uri = entryId.slice("object:".length); + return desktopObjects(summary).find((object) => object.uri === uri) || null; +} + +export function desktopEntryExists(summary, entryId) { + if (!summary || typeof entryId !== "string" || entryId.trim() === "") { + return false; + } + if (entryId.startsWith("object:")) { + return Boolean(desktopObjectByEntryId(summary, entryId)); + } + return Boolean(targetById(summary, entryId) && isTargetOnDesktop(entryId)); +} + +export function desktopLayoutEntries(summary) { + const appEntries = allVisibleTargets(summary).map((target) => ({ + id: target.target, + kind: "target", + target, + })); + const objectEntries = desktopObjects(summary).map((object) => ({ + id: desktopObjectEntryId(object), + kind: "object", + object, + })); + return [...appEntries, ...objectEntries]; +} + function syncHomeBrowserState(summary) { const state = summary && summary.browser_state && typeof summary.browser_state === "object" ? summary.browser_state @@ -213,11 +264,11 @@ export function initializeShellLayout(summary) { normalizedDesktopHidden, ) || typeof stored.desktopIconsVisible !== "boolean"; - for (const [index, app] of allVisibleTargets(summary).entries()) { + for (const [index, entry] of desktopLayoutEntries(summary).entries()) { const defaultPosition = defaultDesktopPosition(index); - const storedPosition = stored && stored.desktop ? stored.desktop[app.target] : null; + const storedPosition = stored && stored.desktop ? stored.desktop[entry.id] : null; const position = clampDesktopPosition(normalizeDesktopPosition(storedPosition, defaultPosition)); - shellState.shellLayoutState.desktop[app.target] = position; + shellState.shellLayoutState.desktop[entry.id] = position; if (!storedPosition || !positionsEqual(storedPosition, position)) { changed = true; } @@ -502,9 +553,13 @@ export function autoArrangeDesktopIcons(summary = shellState.currentSummary) { } let changed = false; const desktopTargets = sortedDesktopTargets(summary) - .filter((target) => isTargetOnDesktop(target.target)); - for (const [index, target] of desktopTargets.entries()) { - changed = setDesktopPosition(target.target, defaultDesktopPosition(index)) || changed; + .filter((target) => isTargetOnDesktop(target.target)) + .map((target) => ({ id: target.target })); + const desktopObjectEntries = desktopObjects(summary).map((object) => ({ + id: desktopObjectEntryId(object), + })); + for (const [index, entry] of [...desktopTargets, ...desktopObjectEntries].entries()) { + changed = setDesktopPosition(entry.id, defaultDesktopPosition(index)) || changed; } if (!changed) { return false; @@ -546,10 +601,10 @@ export function clampDesktopLayoutToViewport() { return false; } let changed = false; - for (const [index, app] of allVisibleTargets(shellState.currentSummary).entries()) { - const next = clampDesktopPosition(desktopPositionForTarget(app.target, index)); - if (!positionsEqual(shellState.shellLayoutState.desktop[app.target], next)) { - shellState.shellLayoutState.desktop[app.target] = next; + for (const [index, entry] of desktopLayoutEntries(shellState.currentSummary).entries()) { + const next = clampDesktopPosition(desktopPositionForTarget(entry.id, index)); + if (!positionsEqual(shellState.shellLayoutState.desktop[entry.id], next)) { + shellState.shellLayoutState.desktop[entry.id] = next; changed = true; } } diff --git a/capsules/home/browser/shell-surface.js b/capsules/home/browser/shell-surface.js index 80c4fe40..109508c0 100644 --- a/capsules/home/browser/shell-surface.js +++ b/capsules/home/browser/shell-surface.js @@ -36,7 +36,11 @@ import { clamp, pointInRect, CONTEXT_MENU_IGNORE_OUTSIDE_MS, -} from "./shell-core.js?v=home-20260526d"; + desktopObjects, + desktopObjectEntryId, + desktopObjectByEntryId, + desktopEntryExists, +} from "./shell-core.js?v=home-20260603c"; import { browserWindowEntries, sortWindowEntriesByZOrder, @@ -50,7 +54,7 @@ import { hideAllTargetWindows, closeAllTargetWindows, focusWindow, -} from "./shell-windows.js?v=home-20260526d"; +} from "./shell-windows.js?v=home-20260603c"; const DESKTOP_LONG_PRESS_MS = 520; const DESKTOP_RENAME_BLUR_GUARD_MS = 350; @@ -65,6 +69,7 @@ export function renderDesktop(summary) { const position = desktopPositionForTarget(app.target, index); const label = desktopLabelForTarget(summary, app.target); button.dataset.target = app.target; + button.dataset.desktopEntryId = app.target; button.id = `desktop-shortcut-${app.target}`; button.style.left = `${position.x}px`; button.style.top = `${position.y}px`; @@ -75,22 +80,47 @@ export function renderDesktop(summary) { attachTargetIconInteractions(button, app.target, "desktop"); desktopShortcuts.appendChild(button); } + const desktopObjectOffset = allVisibleTargets(summary).length; + for (const [index, object] of desktopObjects(summary).entries()) { + const entryId = desktopObjectEntryId(object); + const button = shortcutTemplate.content.firstElementChild.cloneNode(true); + const position = desktopPositionForTarget(entryId, desktopObjectOffset + index); + const label = object.name; + button.dataset.desktopEntryId = entryId; + button.dataset.objectUri = object.uri; + button.id = desktopShortcutIdForEntry(entryId); + button.style.left = `${position.x}px`; + button.style.top = `${position.y}px`; + button.setAttribute("aria-label", desktopShortcutAriaLabel(label)); + button.title = `${label}\nDouble-click or press Enter to open`; + mountGlyph( + button.querySelector(".desktop-shortcut-icon"), + object.kind === "directory" ? "file-folder" : "documents", + ); + button.querySelector(".desktop-shortcut-title").textContent = label; + attachDesktopObjectInteractions(button, entryId); + desktopShortcuts.appendChild(button); + } syncDesktopIconsVisibility(); updateDesktopSelectionState(); } +function desktopShortcutIdForEntry(entryId) { + return `desktop-shortcut-${encodeURIComponent(entryId).replaceAll("%", "_")}`; +} + function syncDesktopIconsVisibility() { const visible = shellState.shellLayoutState.desktopIconsVisible !== false; desktopShortcuts.hidden = !visible; desktopShortcuts.setAttribute("aria-hidden", visible ? "false" : "true"); } -function selectDesktopTarget(targetId) { - if (shellState.selectedDesktopTargetId === targetId) { +function selectDesktopTarget(entryId) { + if (shellState.selectedDesktopTargetId === entryId) { focusDesktopSelectionSurface(); return; } - shellState.selectedDesktopTargetId = targetId; + shellState.selectedDesktopTargetId = entryId; updateDesktopSelectionState(); focusDesktopSelectionSurface(); } @@ -115,15 +145,13 @@ function updateDesktopSelectionState() { if ( shellState.selectedDesktopTargetId && shellState.currentSummary && - ( - !targetById(shellState.currentSummary, shellState.selectedDesktopTargetId) || - !isTargetOnDesktop(shellState.selectedDesktopTargetId) - ) + !desktopEntryExists(shellState.currentSummary, shellState.selectedDesktopTargetId) ) { shellState.selectedDesktopTargetId = null; } - for (const shortcut of desktopShortcuts.querySelectorAll(".desktop-shortcut[data-target]")) { - const selected = shortcut.dataset.target === shellState.selectedDesktopTargetId; + for (const shortcut of desktopShortcuts.querySelectorAll(".desktop-shortcut")) { + const entryId = shortcut.dataset.desktopEntryId || shortcut.dataset.target || ""; + const selected = entryId === shellState.selectedDesktopTargetId; shortcut.classList.toggle("selected", selected); shortcut.setAttribute("aria-selected", selected ? "true" : "false"); if (selected) { @@ -523,6 +551,125 @@ function attachTargetIconInteractions(node, targetId, source) { }); } +function attachDesktopObjectInteractions(node, entryId) { + node.addEventListener("click", (event) => { + if (shouldOpenDesktopShortcutFromClick(node, event)) { + openDesktopObject(entryId); + return; + } + selectDesktopTarget(entryId); + }); + node.addEventListener("dblclick", () => { + selectDesktopTarget(entryId); + openDesktopObject(entryId); + }); + node.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + event.stopPropagation(); + selectDesktopTarget(entryId); + openDesktopObject(entryId); + }); + node.addEventListener("focus", () => { + selectDesktopTarget(entryId); + }); + node.addEventListener("pointerdown", (event) => { + if (event.button !== 0) { + return; + } + node.dataset.lastPointerType = event.pointerType || ""; + maybeStartLongPressGesture(event, entryId, "desktop-object", node); + beginTargetDrag(event, entryId, "desktop-object", node); + if (!isTouchLikePointer(event)) { + selectDesktopTarget(entryId); + } + }); + node.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + selectDesktopTarget(entryId); + openDesktopContextMenu(event.clientX, event.clientY, { + kind: "desktop-object", + entryId, + source: "desktop", + }); + }); +} + +function openDesktopObject(entryId) { + const object = desktopObjectByEntryId(shellState.currentSummary, entryId); + if (!object) { + return; + } + if (object.kind === "directory") { + openTarget("library", { query: { uri: object.uri } }); + return; + } + const viewer = desktopObjectViewer(object); + openTarget(viewer, { + query: { + objectUri: object.uri, + uri: object.uri, + name: object.name || "", + mime: object.mime || "application/octet-stream", + }, + }); +} + +export function openSelectedDesktopEntry() { + const entryId = shellState.selectedDesktopTargetId; + if (!entryId) { + return false; + } + if (entryId.startsWith("object:")) { + openDesktopObject(entryId); + return true; + } + openTarget(entryId); + return true; +} + +function desktopObjectViewer(object) { + const viewers = Array.isArray(object.viewers) ? object.viewers : []; + const preferred = viewers.find((viewer) => viewer && viewer.default) || viewers[0]; + return preferred && typeof preferred.id === "string" && preferred.id.trim() !== "" + ? preferred.id + : "documents"; +} + +function parentUri(uri) { + const clean = String(uri || "").replace(/\/+$/, ""); + const index = clean.lastIndexOf("/"); + return index > "localhost://".length ? clean.slice(0, index) : clean; +} + +function hasObjectCapability(object, capability) { + const capabilities = object && object.capabilities; + return !Array.isArray(capabilities) || capabilities.includes(capability); +} + +function revealDesktopObject(entryId) { + const object = desktopObjectByEntryId(shellState.currentSummary, entryId); + if (!object) { + return; + } + const uri = object.kind === "directory" ? object.uri : parentUri(object.uri); + openTarget("library", { query: { uri } }); +} + +function libraryActionForObject(object, action) { + const uri = object.kind === "directory" ? object.uri : parentUri(object.uri); + openTarget("library", { + query: { + uri, + objectUri: object.uri, + action, + }, + }); +} + function shouldOpenDesktopShortcutFromClick(node, event) { const pointerType = node.dataset.lastPointerType || ""; delete node.dataset.lastPointerType; @@ -551,7 +698,7 @@ function beginTargetDrag(event, targetId, source, sourceElement) { clearDragSelection(); } hideDesktopContextMenu(); - if (source === "desktop" && !isTouchLikePointer(event)) { + if ((source === "desktop" || source === "desktop-object") && !isTouchLikePointer(event)) { selectDesktopTarget(targetId); } const rect = sourceElement.getBoundingClientRect(); @@ -574,7 +721,7 @@ function beginTargetDrag(event, targetId, source, sourceElement) { } function maybeStartLongPressGesture(event, targetId, source, sourceElement) { - if (source !== "desktop" || !isTouchLikePointer(event)) { + if ((source !== "desktop" && source !== "desktop-object") || !isTouchLikePointer(event)) { clearLongPressGesture(); return; } @@ -599,8 +746,9 @@ function maybeStartLongPressGesture(event, targetId, source, sourceElement) { shellState.longPressState = null; selectDesktopTarget(targetId); openDesktopContextMenu(gesture.clientX, gesture.clientY, { - kind: "target", - targetId, + kind: source === "desktop-object" ? "desktop-object" : "target", + targetId: source === "desktop-object" ? undefined : targetId, + entryId: source === "desktop-object" ? targetId : undefined, source, }); }, DESKTOP_LONG_PRESS_MS); @@ -636,7 +784,8 @@ function updateLongPressGesture(event) { ) { if ( shellState.dragState && - shellState.dragState.source === "desktop" && + (shellState.dragState.source === "desktop" || + shellState.dragState.source === "desktop-object") && isTouchLikeDragState(shellState.dragState) && !shellState.dragState.longPressReady && !shellState.dragState.started @@ -657,7 +806,8 @@ export function continueTargetDrag(event) { return; } if ( - shellState.dragState.source === "desktop" && + (shellState.dragState.source === "desktop" || + shellState.dragState.source === "desktop-object") && isTouchLikeDragState(shellState.dragState) && !shellState.dragState.longPressReady ) { @@ -704,20 +854,35 @@ function startTargetDrag() { } catch (_error) { // Pointer capture can fail on browsers that do not support it here. } - const target = targetById(shellState.currentSummary, shellState.dragState.targetId); - if (!target) { + const dragEntry = dragEntryDescriptor(shellState.dragState.targetId); + if (!dragEntry) { return; } document.body.classList.add("dragging-target"); clearDragSelection(); const ghost = shortcutTemplate.content.firstElementChild.cloneNode(true); ghost.classList.add("desktop-shortcut-ghost"); - mountGlyph(ghost.querySelector(".desktop-shortcut-icon"), target.target); - ghost.querySelector(".desktop-shortcut-title").textContent = target.title; + mountGlyph(ghost.querySelector(".desktop-shortcut-icon"), dragEntry.glyphId); + ghost.querySelector(".desktop-shortcut-title").textContent = dragEntry.title; document.body.appendChild(ghost); shellState.dragState.ghost = ghost; } +function dragEntryDescriptor(entryId) { + const target = targetById(shellState.currentSummary, entryId); + if (target) { + return { glyphId: target.target, title: target.title }; + } + const object = desktopObjectByEntryId(shellState.currentSummary, entryId); + if (object) { + return { + glyphId: object.kind === "directory" ? "file-folder" : "documents", + title: object.name, + }; + } + return null; +} + function updateDragGhost(clientX, clientY) { if (!shellState.dragState || !shellState.dragState.ghost) { return; @@ -731,11 +896,13 @@ function updateDragTarget(clientX, clientY) { return; } taskbarTargets.classList.remove("drop-active"); - const taskbarTarget = taskbarDropTarget(clientX, clientY); - if (taskbarTarget) { - taskbarTargets.classList.add("drop-active"); - shellState.dragState.dropTarget = taskbarTarget; - return; + if (shellState.dragState.source !== "desktop-object") { + const taskbarTarget = taskbarDropTarget(clientX, clientY); + if (taskbarTarget) { + taskbarTargets.classList.add("drop-active"); + shellState.dragState.dropTarget = taskbarTarget; + return; + } } shellState.dragState.dropTarget = desktopDropTarget(clientX, clientY); } @@ -814,7 +981,11 @@ export function finishTargetDrag(event) { state.sourceElement.classList.remove("drag-source"); let changed = false; - if (state.dropTarget && state.dropTarget.kind === "taskbar") { + if ( + state.dropTarget && + state.dropTarget.kind === "taskbar" && + state.source !== "desktop-object" + ) { changed = pinTargetToTaskbar(state.targetId, state.dropTarget.index) || changed; } else if (state.dropTarget && state.dropTarget.kind === "desktop") { changed = setDesktopPosition(state.targetId, state.dropTarget.position) || changed; @@ -942,6 +1113,9 @@ function contextMenuItems(target) { if (target.kind === "target") { return targetContextMenuItems(target); } + if (target.kind === "desktop-object") { + return desktopObjectContextMenuItems(target); + } const iconsVisible = shellState.shellLayoutState.desktopIconsVisible !== false; const items = [ { @@ -955,6 +1129,29 @@ function contextMenuItems(target) { return items; } +function desktopObjectContextMenuItems(target) { + const object = desktopObjectByEntryId(shellState.currentSummary, target.entryId); + if (!object) { + return []; + } + const items = [ + { + action: "open-desktop-object", + label: object.kind === "directory" ? `Open ${object.name}` : "Open", + }, + ]; + if (object.kind === "directory") { + items.push({ action: "open-desktop-object-new-window", label: "Open in New Window" }); + } + items.push({ action: "reveal-desktop-object", label: "Show in Library" }); + items.push({ kind: "divider" }); + if (hasObjectCapability(object, "download")) { + items.push({ action: "download-desktop-object", label: "Download" }); + } + items.push({ action: "properties-desktop-object", label: "Properties" }); + return items; +} + function targetContextMenuItems(target) { const openWindows = sortWindowEntriesByZOrder(browserWindowEntriesForTarget(target.targetId)); const items = []; @@ -988,6 +1185,29 @@ function desktopPinMenuItem(targetId) { } export function handleContextAction(action) { + if (shellState.contextMenuTarget.kind === "desktop-object") { + if (action === "open-desktop-object" || action === "open-desktop-object-new-window") { + openDesktopObject(shellState.contextMenuTarget.entryId); + return; + } + if (action === "reveal-desktop-object") { + revealDesktopObject(shellState.contextMenuTarget.entryId); + return; + } + if (action === "download-desktop-object" || action === "properties-desktop-object") { + const object = desktopObjectByEntryId( + shellState.currentSummary, + shellState.contextMenuTarget.entryId, + ); + if (object) { + libraryActionForObject( + object, + action === "download-desktop-object" ? "download" : "properties", + ); + } + return; + } + } if (action.startsWith("focus-window:")) { focusWindow(action.slice("focus-window:".length)); return; diff --git a/capsules/home/browser/shell-window-geometry.js b/capsules/home/browser/shell-window-geometry.js index 25c8db16..f0ae6315 100644 --- a/capsules/home/browser/shell-window-geometry.js +++ b/capsules/home/browser/shell-window-geometry.js @@ -10,7 +10,7 @@ import { beginShellInteraction, clamp, endShellInteraction, -} from "./shell-core.js?v=home-20260526d"; +} from "./shell-core.js?v=home-20260603c"; const WINDOW_MIN_VISIBLE_DRAG_WIDTH = 96; const WINDOW_MIN_VISIBLE_DRAG_HEIGHT = 32; diff --git a/capsules/home/browser/shell-windows.js b/capsules/home/browser/shell-windows.js index e127c49f..0be67848 100644 --- a/capsules/home/browser/shell-windows.js +++ b/capsules/home/browser/shell-windows.js @@ -18,7 +18,7 @@ import { clearShellSessionState, ignoreRepeatedAction, targetById, -} from "./shell-core.js?v=home-20260526d"; +} from "./shell-core.js?v=home-20260603c"; import { fitWindowBounds, fitWindowToBrowserAspect, @@ -28,7 +28,7 @@ import { hideWindowSnapPreview, attachWindowDrag, attachWindowResize, -} from "./shell-window-geometry.js?v=home-20260526d"; +} from "./shell-window-geometry.js?v=home-20260603c"; let windowHooks = null; const REQUIRED_WINDOW_HOOKS = [ diff --git a/capsules/home/browser/shell.js b/capsules/home/browser/shell.js index 71c70284..17cdd249 100644 --- a/capsules/home/browser/shell.js +++ b/capsules/home/browser/shell.js @@ -20,12 +20,12 @@ import { shouldIgnoreDesktopKeydown, shellInteractionActive, targetById, -} from "./shell-core.js?v=home-20260526d"; +} from "./shell-core.js?v=home-20260603c"; import { syncIdentity, clearIdentitySurface, updateClock, -} from "./shell-chrome.js?v=home-20260526d"; +} from "./shell-chrome.js?v=home-20260603c"; import { renderDesktop, renderTaskbar, @@ -38,13 +38,14 @@ import { filterLauncherItems, moveLauncherSelection, openSelectedLauncherTarget, + openSelectedDesktopEntry, clearDesktopSelection, continueTargetDrag, finishTargetDrag, openDesktopContextMenu, hideDesktopContextMenu, handleContextAction, -} from "./shell-surface.js?v=home-20260526d"; +} from "./shell-surface.js?v=home-20260603c"; import { configureWindowHooks, renderBootError, @@ -55,7 +56,7 @@ import { restoreShellSession, cleanupBeforeUnload, handleShellResize, -} from "./shell-windows.js?v=home-20260526d"; +} from "./shell-windows.js?v=home-20260603c"; import { bindHomeUnlock, hideHomeUnlock, @@ -63,7 +64,7 @@ import { refreshHomeSession, showHomeUnlock, signOutHome, -} from "./shell-auth.js?v=home-20260526d"; +} from "./shell-auth.js?v=home-20260603c"; configureWindowHooks({ clearIdentitySurface, @@ -85,7 +86,7 @@ const SESSION_REFRESH_MS = 10 * 60 * 1000; const SHELL_MESSAGE_OPEN_TARGET_SOURCES = Object.freeze({ "chat-room": new Set(["library"]), inbox: "visible-target", - library: new Set(["documents"]), + library: new Set(["documents", "library"]), system: "visible-target", "wallet": new Set(["wallet-metamask", "wallet-unisat"]), }); @@ -130,14 +131,9 @@ function toggleShellFullscreen() { } function registerHomeServiceWorker() { - if (!("serviceWorker" in navigator)) { - return; - } - window.addEventListener("load", () => { - navigator.serviceWorker.register("./service-worker.js", { scope: "./" }).catch((error) => { - console.warn("ElastOS Home service worker registration failed", error); - }); - }, { once: true }); + // Home is network-first during active Runtime development. A stale service + // worker can strand the shell on an old module graph while provider APIs are + // live, so service-worker registration is intentionally disabled for now. } function trackPointerDown(event) { @@ -247,7 +243,7 @@ desktopShortcuts.addEventListener("keydown", (event) => { if ((event.key === "Enter" || event.key === " ") && shellState.selectedDesktopTargetId) { event.preventDefault(); event.stopPropagation(); - openTarget(shellState.selectedDesktopTargetId); + openSelectedDesktopEntry(); } }); @@ -311,7 +307,7 @@ document.addEventListener("keydown", (event) => { } if ((event.key === "Enter" || event.key === " ") && shellState.selectedDesktopTargetId) { event.preventDefault(); - openTarget(shellState.selectedDesktopTargetId); + openSelectedDesktopEntry(); } }); @@ -566,9 +562,11 @@ async function boot() { console.error("home runtime ensure failed", error); return null; }); - await restoreShellSession(); document.body.dataset.homeStatus = "ready"; hideHomeUnlock(); + restoreShellSession().catch((error) => { + console.error("home session restore failed", error); + }); runtimeReady.then(() => refreshShellSummary()).catch((error) => { console.error("home summary refresh failed after runtime ensure", error); }); @@ -673,7 +671,12 @@ async function refreshShellSummary({ initialize = false } = {}) { stopHomeEventChannel(); } - if (initialize || principalChanged || targetsChanged(previous, summary)) { + if ( + initialize || + principalChanged || + targetsChanged(previous, summary) || + desktopObjectsChanged(previous, summary) + ) { renderDesktop(summary); renderTaskbar(summary); renderLauncher(summary); @@ -805,7 +808,8 @@ function homeEventsRequireShellSummary(events) { scope === "wallet" || kind === "home.summary.changed" || kind === "inbox.changed" || - kind === "wallet.requests.changed" + kind === "wallet.requests.changed" || + kind === "home.desktop.changed" ); }); } @@ -869,3 +873,23 @@ function targetsChanged(previous, next) { : ""; return previousTargets !== nextTargets; } + +function desktopObjectsChanged(previous, next) { + return desktopObjectsSignature(previous) !== desktopObjectsSignature(next); +} + +function desktopObjectsSignature(summary) { + const objects = summary && + summary.desktop_objects && + Array.isArray(summary.desktop_objects.objects) + ? summary.desktop_objects.objects + : []; + return objects + .map((object) => [ + object && object.uri, + object && object.revision, + object && object.kind, + object && object.name, + ].join(":")) + .join("|"); +} diff --git a/elastos/crates/elastos-server/src/api/gateway_home_system.rs b/elastos/crates/elastos-server/src/api/gateway_home_system.rs index 5555fcf1..55b62fd4 100644 --- a/elastos/crates/elastos-server/src/api/gateway_home_system.rs +++ b/elastos/crates/elastos-server/src/api/gateway_home_system.rs @@ -10,6 +10,7 @@ const HOME_EVENTS_MAX_WAIT_MS: u64 = 30_000; const HOME_EVENTS_POLL_MS: u64 = 1_000; const HOME_EVENTS_RETRY_MS: u64 = 250; const HOME_EVENTS_STREAM_KEEPALIVE_SECS: u64 = 15; +const HOME_DESKTOP_OBJECTS_SCHEMA: &str = "elastos.home.desktop-objects/v1"; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -42,6 +43,7 @@ struct HomeRealtimeSnapshot { notification_signature: Vec, wallet_request_signature: Vec, capability_request_count: usize, + desktop_signature: Vec, room_signature: String, browser_sessions: serde_json::Value, } @@ -89,6 +91,11 @@ pub(super) async fn home_summary( }; let mut notifications = home_state.notifications; + let desktop_objects = if let Some(context) = context.as_ref() { + home_desktop_objects_summary(&state, context).await + } else { + standard_home_desktop_objects_summary() + }; if let Some(context) = context.as_ref() { let wallet_approvals = system_wallet_approvals_summary(&state, &context.principal_id, false).await; @@ -119,6 +126,7 @@ pub(super) async fn home_summary( site: home_state.site, room: home_state.room, notifications, + desktop_objects, targets: home_targets(&state.data_dir), }) .into_response() @@ -285,11 +293,13 @@ async fn home_realtime_snapshot( &context.principal_id, ) .await; + let desktop_signature = home_desktop_events_signature(state, context).await; HomeRealtimeSnapshot { principal_id: context.principal_id.clone(), notification_signature, wallet_request_signature, capability_request_count, + desktop_signature, room_signature, browser_sessions, } @@ -298,8 +308,8 @@ async fn home_realtime_snapshot( fn home_realtime_cursor(snapshot: &HomeRealtimeSnapshot) -> String { let parts = home_realtime_cursor_parts(snapshot); format!( - "v1:home={};inbox={};wallet={};browser={};chat-room={}", - parts.home, parts.inbox, parts.wallet, parts.browser, parts.chat_room + "v1:home={};inbox={};wallet={};browser={};desktop={};chat-room={}", + parts.home, parts.inbox, parts.wallet, parts.browser, parts.desktop, parts.chat_room ) } @@ -308,6 +318,7 @@ struct HomeRealtimeCursorParts { inbox: String, wallet: String, browser: String, + desktop: String, chat_room: String, } @@ -321,6 +332,7 @@ fn home_realtime_cursor_parts(snapshot: &HomeRealtimeSnapshot) -> HomeRealtimeCu )), wallet: stable_cursor_hash(&snapshot.wallet_request_signature), browser: stable_cursor_hash(&snapshot.browser_sessions), + desktop: stable_cursor_hash(&snapshot.desktop_signature), chat_room: stable_cursor_hash(&snapshot.room_signature), } } @@ -383,6 +395,7 @@ fn home_realtime_events( ("inbox", "inbox.changed", current.inbox), ("wallet", "wallet.requests.changed", current.wallet), ("browser", "browser.sessions.changed", current.browser), + ("desktop", "home.desktop.changed", current.desktop), ("chat-room", "chat-room.changed", current.chat_room), ] .into_iter() @@ -510,6 +523,142 @@ fn home_browser_localhost_root(context: &HomeLaunchTokenContext) -> String { crate::auth::principal_localhost_root(&context.principal_id) } +fn home_desktop_uri(context: &HomeLaunchTokenContext) -> String { + format!("{}/Desktop", home_browser_localhost_root(context)) +} + +fn standard_home_desktop_objects_summary() -> HomeDesktopObjectsSummary { + HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri: String::new(), + objects: Vec::new(), + stale: false, + error: None, + } +} + +async fn home_desktop_objects_summary( + state: &GatewayState, + context: &HomeLaunchTokenContext, +) -> HomeDesktopObjectsSummary { + let uri = home_desktop_uri(context); + let Some(registry) = state.provider_registry.as_ref() else { + return HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri, + objects: Vec::new(), + stale: true, + error: Some("object provider registry unavailable".to_string()), + }; + }; + let request = serde_json::json!({ + "op": "list", + "principal_id": &context.principal_id, + "uri": uri, + }); + let response = match registry.send_raw("object", &request).await { + Ok(response) => response, + Err(err) => { + return HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri, + objects: Vec::new(), + stale: true, + error: Some(format!("object provider failed to list Desktop: {err}")), + } + } + }; + if response.get("status").and_then(serde_json::Value::as_str) != Some("ok") { + return HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri: request + .get("uri") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + objects: Vec::new(), + stale: true, + error: Some( + response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("object provider failed to list Desktop") + .to_string(), + ), + }; + } + let data = response.get("data").and_then(serde_json::Value::as_object); + let objects = data + .and_then(|data| data.get("objects")) + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let uri = data + .and_then(|data| data.get("uri")) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| { + request + .get("uri") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + }) + .to_string(); + HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri, + objects, + stale: false, + error: None, + } +} + +async fn home_desktop_events_signature( + state: &GatewayState, + context: &HomeLaunchTokenContext, +) -> Vec { + let Some(registry) = state.provider_registry.as_ref() else { + return Vec::new(); + }; + let request = serde_json::json!({ + "op": "events", + "principal_id": &context.principal_id, + "uri": home_desktop_uri(context), + "limit": 32, + }); + let Ok(response) = registry.send_raw("object", &request).await else { + return Vec::new(); + }; + let mut signature = response + .get("data") + .and_then(|data| data.get("events")) + .and_then(serde_json::Value::as_array) + .map(|events| { + events + .iter() + .map(|event| { + format!( + "{}:{}:{}", + event + .get("event_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + event + .get("op") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + event + .get("at") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() + ) + }) + .collect::>() + }) + .unwrap_or_default(); + signature.sort(); + signature +} + fn default_home_browser_state(context: &HomeLaunchTokenContext) -> HomeBrowserStateSummary { HomeBrowserStateSummary { schema: HOME_BROWSER_STATE_SCHEMA.to_string(), @@ -1395,6 +1544,7 @@ mod home_realtime_tests { notification_signature: Vec::new(), wallet_request_signature: Vec::new(), capability_request_count: 0, + desktop_signature: Vec::new(), room_signature: String::new(), browser_sessions: serde_json::json!({ "schema": "elastos.browser.session-capacity/v1", diff --git a/elastos/crates/elastos-server/src/api/gateway_models.rs b/elastos/crates/elastos-server/src/api/gateway_models.rs index 5cca6c4f..a6051902 100644 --- a/elastos/crates/elastos-server/src/api/gateway_models.rs +++ b/elastos/crates/elastos-server/src/api/gateway_models.rs @@ -10,6 +10,7 @@ struct HomeSummaryResponse { site: HomeSiteSummary, room: HomeRoomSummary, notifications: HomeNotificationsSummary, + desktop_objects: HomeDesktopObjectsSummary, targets: Vec, } @@ -59,6 +60,17 @@ struct HomeBrowserStateUpdate { recent_targets: Option>, } +#[derive(Debug, Clone, Serialize)] +struct HomeDesktopObjectsSummary { + schema: String, + uri: String, + #[serde(default)] + objects: Vec, + stale: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct SystemHandleUpdateRequest { diff --git a/elastos/crates/elastos-server/src/home_cmd.rs b/elastos/crates/elastos-server/src/home_cmd.rs index 9b6ada73..dfc29964 100644 --- a/elastos/crates/elastos-server/src/home_cmd.rs +++ b/elastos/crates/elastos-server/src/home_cmd.rs @@ -200,6 +200,7 @@ const PROVIDER_CAPSULE_NAMES: &[&str] = &[ "did-provider", "chain-provider", "wallet-provider", + "object-provider", "drm-provider", "rights-provider", "key-provider", @@ -3276,6 +3277,7 @@ mod tests { assert!(PROVIDER_CAPSULE_NAMES.contains(&"did-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"chain-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"wallet-provider")); + assert!(PROVIDER_CAPSULE_NAMES.contains(&"object-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"drm-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"rights-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"key-provider")); From cab6b21ae58b66718520e3b3c8c04b29380aa103 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:45:01 +0000 Subject: [PATCH 04/18] feat(content): add availability and protected sharing providers Extend content-provider, Carrier orchestration, availability-provider, content-block-graph-provider, and protected-content provider contracts for CID-backed publication, replication proof/status, protected payload metadata, recipient proof handling, and fail-closed provider behavior. This keeps mutable object authority in object-provider while published content identity, delivery, and availability stay with content-provider and Carrier-backed providers. --- capsules/availability-provider/src/main.rs | 653 +- .../content-block-graph-provider/Cargo.lock | 679 + .../content-block-graph-provider/Cargo.toml | 22 + .../content-block-graph-provider/capsule.json | 31 + .../content-block-graph-provider/src/main.rs | 638 + capsules/decrypt-provider/src/main.rs | 129 +- capsules/ipfs-provider/src/main.rs | 51 +- capsules/key-provider/src/main.rs | 118 +- capsules/rights-provider/src/main.rs | 42 + .../elastos-common/src/protected_content.rs | 59 +- elastos/crates/elastos-server/src/carrier.rs | 7675 +++++++- elastos/crates/elastos-server/src/content.rs | 14445 ++++++++++++++-- .../crates/elastos-server/src/content_cmd.rs | 158 + elastos/crates/elastos-server/src/publish.rs | 2 + 14 files changed, 22207 insertions(+), 2495 deletions(-) create mode 100644 capsules/content-block-graph-provider/Cargo.lock create mode 100644 capsules/content-block-graph-provider/Cargo.toml create mode 100644 capsules/content-block-graph-provider/capsule.json create mode 100644 capsules/content-block-graph-provider/src/main.rs diff --git a/capsules/availability-provider/src/main.rs b/capsules/availability-provider/src/main.rs index 14e61cc0..1e51cd04 100644 --- a/capsules/availability-provider/src/main.rs +++ b/capsules/availability-provider/src/main.rs @@ -36,6 +36,8 @@ struct EnsureRequest { #[serde(default)] local: Value, #[serde(default)] + requirements: Value, + #[serde(default)] object_did: Option, #[serde(default)] publisher_did: Option, @@ -58,6 +60,13 @@ struct AvailabilityTarget { headers: BTreeMap, } +#[derive(Debug, Clone, Copy)] +struct TargetAvailabilityRequirements { + min_replicas: u64, + max_replicas: Option, + require_live_multi_peer_proof: bool, +} + #[derive(Debug, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] enum Response { @@ -138,21 +147,52 @@ impl AvailabilityProvider { })); } - let mut last_error = None; + let requirements = TargetAvailabilityRequirements::from_value(&request.requirements); + let mut network_available = Vec::new(); + let mut repair_needed_reports = Vec::new(); + let mut errors = Vec::new(); for target in &self.targets { match self.ensure_target(target, &request) { Ok(availability) => { - return Response::ok(json!({ "availability": availability })); + if availability_status(&availability) == Some("network_available") { + network_available.push(availability); + if let Some(availability) = availability_for_requirements( + &request, + requirements, + &network_available, + ) { + return Response::ok(json!({ "availability": availability })); + } + } else { + repair_needed_reports.push(availability); + } } - Err(err) => last_error = Some(format!("{}: {}", target.id, err)), + Err(err) => errors.push(format!("{}: {}", target.id, err)), } } + if !network_available.is_empty() { + return Response::ok(json!({ + "availability": repair_needed( + "availability-provider", + &request, + insufficient_target_reason(requirements, &network_available, &errors), + ) + })); + } + + if let Some(availability) = repair_needed_reports.into_iter().next() { + return Response::ok(json!({ "availability": availability })); + } + Response::ok(json!({ "availability": repair_needed( &self.targets[0].id, &request, - last_error.unwrap_or_else(|| "availability target failed".to_string()), + errors + .last() + .cloned() + .unwrap_or_else(|| "availability target failed".to_string()), ) })) } @@ -269,6 +309,26 @@ fn validate_header_value(target_id: &str, name: &str, value: &str) -> Result<(), Ok(()) } +impl TargetAvailabilityRequirements { + fn from_value(value: &Value) -> Self { + Self { + min_replicas: value + .get("min_replicas") + .and_then(Value::as_u64) + .unwrap_or(1) + .max(1), + max_replicas: value + .get("max_replicas") + .and_then(Value::as_u64) + .filter(|value| *value > 0), + require_live_multi_peer_proof: value + .get("require_live_multi_peer_proof") + .and_then(Value::as_bool) + .unwrap_or(false), + } + } +} + fn normalize_upstream_availability( target: &AvailabilityTarget, request: &EnsureRequest, @@ -302,6 +362,12 @@ fn normalize_upstream_availability( .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&request.policy); + let peer_selection = upstream_peer_selection(target, availability, replicas)?; + let quota = upstream_quota(target, availability, request); + let repair_worker = upstream_repair_worker(availability); + let storage_market = upstream_storage_market(target, availability); + let repair_graph = upstream_repair_graph(target, availability); + let abuse_controls = upstream_abuse_controls(target, availability); match status { "network_available" if replicas > 0 => Ok(json!({ @@ -309,6 +375,12 @@ fn normalize_upstream_availability( "provider": provider, "policy": policy, "replicas": replicas, + "peer_selection": peer_selection, + "quota": quota, + "repair_worker": repair_worker, + "storage_market": storage_market, + "repair_graph": repair_graph, + "abuse_controls": abuse_controls, })), "network_available" => Err("network_available requires replicas > 0".to_string()), "repair_needed" => Ok(json!({ @@ -317,11 +389,311 @@ fn normalize_upstream_availability( "policy": policy, "replicas": replicas, "reason": availability.get("reason").and_then(Value::as_str).unwrap_or("availability target requested repair"), + "peer_selection": peer_selection, + "quota": quota, + "repair_worker": repair_worker, + "storage_market": storage_market, + "repair_graph": repair_graph, + "abuse_controls": abuse_controls, })), other => Err(format!("unsupported availability status: {other}")), } } +fn availability_for_requirements( + request: &EnsureRequest, + requirements: TargetAvailabilityRequirements, + reports: &[Value], +) -> Option { + let first = reports.first()?; + if reports.len() == 1 && target_report_satisfies_requirements(first, requirements) { + return Some(first.clone()); + } + let aggregate = aggregate_target_availability(request, reports); + if target_report_satisfies_requirements(&aggregate, requirements) { + Some(aggregate) + } else { + None + } +} + +fn target_report_satisfies_requirements( + availability: &Value, + requirements: TargetAvailabilityRequirements, +) -> bool { + if availability_status(availability) != Some("network_available") { + return false; + } + let replicas = availability_replicas(availability); + if replicas < requirements.min_replicas { + return false; + } + if let Some(max_replicas) = requirements.max_replicas { + if replicas > max_replicas { + return false; + } + } + if replicas > 1 && !availability_live_multi_peer_proof(availability) { + return false; + } + if requirements.require_live_multi_peer_proof + && !availability_live_multi_peer_proof(availability) + { + return false; + } + true +} + +fn aggregate_target_availability(request: &EnsureRequest, reports: &[Value]) -> Value { + let replicas = reports.iter().map(availability_replicas).sum::(); + let live_multi_peer_proof = + reports.len() > 1 || reports.iter().any(availability_live_multi_peer_proof); + let target_reports = reports + .iter() + .map(target_report_summary) + .collect::>(); + json!({ + "status": "network_available", + "provider": "availability-provider", + "policy": request.policy, + "replicas": replicas, + "peer_selection": { + "mode": "configured_availability_target_fanout", + "strategy": "target_fanout", + "target_count": reports.len(), + "live_multi_peer_proof": live_multi_peer_proof, + "targets": target_reports, + }, + "quota": { + "policy": "configured_availability_target_fanout", + "target_count": reports.len(), + "requirements": request.requirements.clone(), + }, + "repair_worker": { + "scheduled": false, + "status": "healthy", + "worker": "availability-provider", + }, + "storage_market": { + "schema": "elastos.content.storage-market/v1", + "mode": "configured_availability_target_fanout", + "status": "target_reports_no_market_settlement", + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + "target_count": reports.len(), + }, + "repair_graph": { + "schema": "elastos.content.repair-graph/v1", + "policy": "configured_availability_target_fanout", + "status": "target_reports_only", + "supported": ["target_report"], + "target_count": reports.len(), + }, + "abuse_controls": { + "schema": "elastos.content.abuse-controls/v1", + "policy": "configured_availability_target_fanout", + "enforced": reports.iter().any(|report| { + report.get("abuse_controls") + .and_then(|value| value.get("enforced")) + .and_then(Value::as_bool) + .unwrap_or(false) + }), + "throttled": reports.iter().any(|report| { + report.get("abuse_controls") + .and_then(|value| value.get("throttled")) + .and_then(Value::as_bool) + .unwrap_or(false) + }), + "target_count": reports.len(), + }, + }) +} + +fn target_report_summary(report: &Value) -> Value { + json!({ + "provider": report.get("provider").cloned().unwrap_or(Value::Null), + "policy": report.get("policy").cloned().unwrap_or(Value::Null), + "status": report.get("status").cloned().unwrap_or(Value::Null), + "replicas": availability_replicas(report), + "peer_selection": report.get("peer_selection").cloned().unwrap_or(Value::Null), + "quota": report.get("quota").cloned().unwrap_or(Value::Null), + "repair_worker": report.get("repair_worker").cloned().unwrap_or(Value::Null), + "storage_market": report.get("storage_market").cloned().unwrap_or(Value::Null), + "repair_graph": report.get("repair_graph").cloned().unwrap_or(Value::Null), + "abuse_controls": report.get("abuse_controls").cloned().unwrap_or(Value::Null), + }) +} + +fn insufficient_target_reason( + requirements: TargetAvailabilityRequirements, + reports: &[Value], + errors: &[String], +) -> String { + let replicas = reports.iter().map(availability_replicas).sum::(); + let live_multi_peer_proof = + reports.len() > 1 || reports.iter().any(availability_live_multi_peer_proof); + let mut reason = + format!("configured availability targets reported {replicas} replicas below requirements"); + if replicas < requirements.min_replicas { + reason = format!( + "configured availability targets reported {replicas} replicas below required {}", + requirements.min_replicas + ); + } else if let Some(max_replicas) = requirements.max_replicas { + if replicas > max_replicas { + reason = format!( + "configured availability targets reported {replicas} replicas above quota {max_replicas}" + ); + } + } + if requirements.require_live_multi_peer_proof && !live_multi_peer_proof { + reason.push_str(" and no live multi-peer proof"); + } + if let Some(last_error) = errors.last() { + reason.push_str("; last target error: "); + reason.push_str(last_error); + } + reason +} + +fn availability_status(availability: &Value) -> Option<&str> { + availability.get("status").and_then(Value::as_str) +} + +fn availability_replicas(availability: &Value) -> u64 { + availability + .get("replicas") + .and_then(Value::as_u64) + .unwrap_or(0) +} + +fn availability_live_multi_peer_proof(availability: &Value) -> bool { + availability + .get("peer_selection") + .and_then(|value| value.get("live_multi_peer_proof")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn upstream_peer_selection( + target: &AvailabilityTarget, + availability: &Value, + replicas: u64, +) -> Result { + if let Some(peer_selection) = availability + .get("peer_selection") + .filter(|value| value.is_object()) + .cloned() + { + let live_multi_peer_proof = peer_selection + .get("live_multi_peer_proof") + .and_then(Value::as_bool) + .unwrap_or(false); + if replicas > 1 && !live_multi_peer_proof { + return Err( + "multi-replica availability target response requires live_multi_peer_proof=true" + .to_string(), + ); + } + return Ok(peer_selection); + } + if replicas > 1 { + return Err( + "multi-replica availability target response requires peer_selection metadata" + .to_string(), + ); + } + Ok(json!({ + "mode": "configured_availability_target", + "strategy": "target_report", + "target_id": target.id, + "live_multi_peer_proof": false, + })) +} + +fn upstream_quota( + target: &AvailabilityTarget, + availability: &Value, + request: &EnsureRequest, +) -> Value { + availability + .get("quota") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "policy": "configured_availability_target", + "target_id": target.id, + "requirements": request.requirements.clone(), + }) + }) +} + +fn upstream_repair_worker(availability: &Value) -> Value { + availability + .get("repair_worker") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "scheduled": false, + "status": "healthy", + "worker": "availability-provider", + }) + }) +} + +fn upstream_storage_market(target: &AvailabilityTarget, availability: &Value) -> Value { + availability + .get("storage_market") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "configured_availability_target", + "status": "target_report_no_market_settlement", + "target_id": target.id, + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + }) + }) +} + +fn upstream_repair_graph(target: &AvailabilityTarget, availability: &Value) -> Value { + availability + .get("repair_graph") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": "elastos.content.repair-graph/v1", + "policy": "configured_availability_target", + "status": "target_report_only", + "target_id": target.id, + "supported": ["target_report"], + }) + }) +} + +fn upstream_abuse_controls(target: &AvailabilityTarget, availability: &Value) -> Value { + availability + .get("abuse_controls") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": "elastos.content.abuse-controls/v1", + "policy": "configured_availability_target", + "target_id": target.id, + "enforced": false, + "throttled": false, + }) + }) +} + fn repair_needed(provider: &str, request: &EnsureRequest, reason: impl Into) -> Value { json!({ "status": "repair_needed", @@ -329,6 +701,40 @@ fn repair_needed(provider: &str, request: &EnsureRequest, reason: impl Into version, + None => concat!(env!("CARGO_PKG_VERSION"), "-dev"), +}; + +#[derive(Debug, Deserialize)] +struct CoordFile { + kubo_pid: u32, + api_port: u16, + gateway_port: u16, + started_at: u64, + last_used: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Init { + #[serde(default)] + config: Value, + }, + ExportGraph(GraphRequest), + ImportGraph(ImportGraphRequest), + Status { + #[serde(default)] + _runtime_invocation: Option, + #[serde(default)] + _runtime_transfer: Option, + }, + Shutdown, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct GraphRequest { + cid: String, + #[serde(default)] + schema: Option, + #[serde(default)] + repair_graph_kind: Option, + #[serde(default)] + availability_requirements: Value, + #[serde(default)] + policy: Option, + #[serde(default)] + object_did: Option, + #[serde(default)] + publisher_did: Option, + #[serde(default)] + _runtime_invocation: Option, + #[serde(default)] + _runtime_transfer: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct ImportGraphRequest { + cid: String, + graph: Value, + #[serde(default)] + availability_policy: Option, + #[serde(default)] + availability_requirements: Value, + #[serde(default)] + ensure_failure: Option, + #[serde(default)] + object_did: Option, + #[serde(default)] + publisher_did: Option, + #[serde(default)] + _runtime_invocation: Option, + #[serde(default)] + _runtime_transfer: Option, +} + +struct ContentBlockGraphProvider { + data_dir: PathBuf, + max_graph_bytes: usize, +} + +impl Default for ContentBlockGraphProvider { + fn default() -> Self { + Self { + data_dir: default_data_dir(), + max_graph_bytes: DEFAULT_MAX_GRAPH_BYTES, + } + } +} + +impl ContentBlockGraphProvider { + fn handle(&mut self, request: Request) -> Value { + match request { + Request::Init { config } => self.init(config), + Request::ExportGraph(request) => self.export_graph(request), + Request::ImportGraph(request) => self.import_graph(request), + Request::Status { .. } => ok(json!({ + "provider": PROVIDER_ID, + "version": PROVIDER_VERSION, + "schema": GRAPH_SCHEMA, + "backend": self.backend_status(), + "operations": ["export_graph", "import_graph", "status"], + "status": if self.kubo_api_url().is_ok() { + "ready" + } else { + "backend_not_configured" + } + })), + Request::Shutdown => ok(json!({ "provider": PROVIDER_ID })), + } + } + + fn init(&mut self, config: Value) -> Value { + let extra = config + .get("extra") + .filter(|value| !value.is_null()) + .unwrap_or(&config); + if let Some(base_path) = config + .get("base_path") + .and_then(Value::as_str) + .or_else(|| extra.get("data_dir").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let path = PathBuf::from(base_path); + if !path.is_absolute() { + return error( + "invalid_config", + "content block graph data_dir must be absolute", + ); + } + self.data_dir = path; + } + if let Some(max_graph_bytes) = extra.get("max_graph_bytes").and_then(Value::as_u64) { + let max_graph_bytes = usize::try_from(max_graph_bytes).unwrap_or(usize::MAX); + if max_graph_bytes == 0 || max_graph_bytes > DEFAULT_MAX_GRAPH_BYTES { + return error( + "invalid_config", + "max_graph_bytes must be between 1 and 67108864", + ); + } + self.max_graph_bytes = max_graph_bytes; + } + + ok(json!({ + "provider": PROVIDER_ID, + "protocol_version": "1.0", + "version": PROVIDER_VERSION, + "schema": GRAPH_SCHEMA, + "backend": self.backend_status(), + "status": if self.kubo_api_url().is_ok() { + "ready" + } else { + "backend_not_configured" + } + })) + } + + fn export_graph(&self, request: GraphRequest) -> Value { + if let Err(message) = validate_cid(&request.cid) { + return error("invalid_request", &message); + } + if let Some(schema) = request.schema.as_deref() { + if schema != GRAPH_SCHEMA { + return error( + "unsupported_schema", + "export_graph requires block graph schema v1", + ); + } + } + if request + .repair_graph_kind + .as_deref() + .is_some_and(|kind| !matches!(kind, "ipld_dag" | "block_dag" | "dag" | "arbitrary_dag")) + { + return error( + "unsupported_repair_graph", + "export_graph only handles arbitrary block/IPLD DAG repair", + ); + } + let car = match self.kubo_dag_export(&request.cid) { + Ok(car) => car, + Err(message) => return error("export_failed", &message), + }; + if car.len() > self.max_graph_bytes { + return error( + "graph_too_large", + "exported graph exceeds content-block-graph-provider max_graph_bytes", + ); + } + ok(json!({ + "graph": { + "schema": GRAPH_SCHEMA, + "root_cid": request.cid, + "kind": "ipld_dag", + "encoding": GRAPH_ENCODING, + "car": BASE64.encode(&car), + "bytes": car.len(), + "backend": "kubo_dag_car", + "exported_at": now_unix_secs(), + "max_graph_bytes": self.max_graph_bytes, + } + })) + } + + fn import_graph(&self, request: ImportGraphRequest) -> Value { + if let Err(message) = validate_cid(&request.cid) { + return error("invalid_request", &message); + } + if request.graph.get("schema").and_then(Value::as_str) != Some(GRAPH_SCHEMA) { + return error( + "unsupported_schema", + "import_graph requires block graph schema v1", + ); + } + if request.graph.get("root_cid").and_then(Value::as_str) != Some(request.cid.as_str()) { + return error("cid_mismatch", "graph root_cid must match import cid"); + } + if request.graph.get("encoding").and_then(Value::as_str) != Some(GRAPH_ENCODING) { + return error( + "unsupported_encoding", + "import_graph requires base64-car encoding", + ); + } + let car = match request.graph.get("car").and_then(Value::as_str) { + Some(car) => match BASE64.decode(car) { + Ok(bytes) => bytes, + Err(err) => { + return error("invalid_car", &format!("invalid graph CAR base64: {err}")) + } + }, + None => return error("invalid_graph", "import_graph requires graph.car"), + }; + if car.is_empty() { + return error("invalid_graph", "import_graph requires non-empty CAR bytes"); + } + if car.len() > self.max_graph_bytes { + return error( + "graph_too_large", + "import graph exceeds content-block-graph-provider max_graph_bytes", + ); + } + if let Err(message) = self.kubo_dag_import_and_pin(&request.cid, &car) { + return error("import_failed", &message); + } + ok(json!({ + "cid": request.cid, + "availability": { + "status": "local_pinned", + "provider": PROVIDER_ID, + "policy": request + .availability_policy + .unwrap_or_else(|| "carrier_block_graph_import".to_string()), + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "provider_local", + "enforced": false + }, + "repair_worker": { + "scheduled": false, + "status": "healthy", + "worker": PROVIDER_ID + }, + "repair_graph": { + "schema": "elastos.content.repair-graph/v1", + "policy": "carrier_provider_block_graph_repair", + "requested_kind": "ipld_dag", + "status": "block_graph_provider_imported", + "backend": "kubo_dag_car" + } + }, + "import": { + "schema": GRAPH_SCHEMA, + "verified_cid": true, + "bytes": car.len(), + "backend": "kubo_dag_import" + } + })) + } + + fn backend_status(&self) -> Value { + match read_coord_file(&self.data_dir) { + Some(coord) => json!({ + "kind": "kubo_coord", + "configured": true, + "coord_file": coord_file_path(&self.data_dir), + "kubo_pid": coord.kubo_pid, + "api_port": coord.api_port, + "gateway_port": coord.gateway_port, + "started_at": coord.started_at, + "last_used": coord.last_used, + "max_graph_bytes": self.max_graph_bytes, + }), + None => json!({ + "kind": "kubo_coord", + "configured": false, + "coord_file": coord_file_path(&self.data_dir), + "max_graph_bytes": self.max_graph_bytes, + }), + } + } + + fn kubo_api_url(&self) -> Result { + let coord = read_coord_file(&self.data_dir).ok_or_else(|| { + format!( + "Kubo coord file missing at {}; start ipfs-provider before block graph repair", + coord_file_path(&self.data_dir).display() + ) + })?; + if coord.api_port == 0 { + return Err("Kubo coord file has no API port".to_string()); + } + Ok(format!("http://127.0.0.1:{}", coord.api_port)) + } + + fn kubo_dag_export(&self, cid: &str) -> Result, String> { + let url = format!("{}/api/v0/dag/export?arg={}", self.kubo_api_url()?, cid); + let response = ureq::post(&url) + .timeout(LARGE_HTTP_TIMEOUT) + .call() + .map_err(|err| format!("kubo dag export failed: {err}"))?; + if response.status() != 200 { + return Err(format!( + "kubo dag export returned HTTP {}", + response.status() + )); + } + let mut bytes = Vec::new(); + response + .into_reader() + .take((self.max_graph_bytes as u64).saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|err| format!("kubo dag export read failed: {err}"))?; + update_coord_last_used(&self.data_dir); + Ok(bytes) + } + + fn kubo_dag_import_and_pin(&self, cid: &str, car: &[u8]) -> Result<(), String> { + let api_url = self.kubo_api_url()?; + let boundary = format!("----elastos-block-graph-{}", now_unix_secs()); + let mut body = Vec::new(); + write!(body, "--{boundary}\r\n").unwrap(); + write!( + body, + "Content-Disposition: form-data; name=\"file\"; filename=\"graph.car\"\r\n" + ) + .unwrap(); + write!(body, "Content-Type: application/vnd.ipld.car\r\n\r\n").unwrap(); + body.extend_from_slice(car); + write!(body, "\r\n--{boundary}--\r\n").unwrap(); + + let import_url = format!("{api_url}/api/v0/dag/import?pin-roots=true"); + let response = ureq::post(&import_url) + .set( + "Content-Type", + &format!("multipart/form-data; boundary={boundary}"), + ) + .timeout(LARGE_HTTP_TIMEOUT) + .send_bytes(&body) + .map_err(|err| format!("kubo dag import failed: {err}"))?; + if response.status() != 200 { + return Err(format!( + "kubo dag import returned HTTP {}", + response.status() + )); + } + + let pin_url = format!("{api_url}/api/v0/pin/add?arg={cid}"); + let pin_response = ureq::post(&pin_url) + .timeout(HTTP_TIMEOUT) + .call() + .map_err(|err| format!("kubo pin after dag import failed: {err}"))?; + if pin_response.status() != 200 { + return Err(format!( + "kubo pin after dag import returned HTTP {}", + pin_response.status() + )); + } + update_coord_last_used(&self.data_dir); + Ok(()) + } +} + +fn validate_cid(cid: &str) -> Result<(), String> { + let value = cid.trim(); + if value.is_empty() { + return Err("cid must not be empty".to_string()); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("cid contains unsupported characters".to_string()); + } + Ok(()) +} + +fn ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +fn default_data_dir() -> PathBuf { + if let Ok(dir) = std::env::var("ELASTOS_DATA_DIR") { + PathBuf::from(dir) + } else if let Ok(dir) = std::env::var("XDG_DATA_HOME") { + PathBuf::from(dir).join("elastos") + } else if let Some(home) = std::env::var_os("HOME") { + PathBuf::from(home).join(".local/share/elastos") + } else { + PathBuf::from("/tmp/elastos") + } +} + +fn coord_file_path(data_dir: &Path) -> PathBuf { + data_dir.join("ipfs-coords.json") +} + +fn read_coord_file(data_dir: &Path) -> Option { + let path = coord_file_path(data_dir); + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +fn update_coord_last_used(data_dir: &Path) { + let Some(mut coord) = read_coord_file(data_dir) else { + return; + }; + coord.last_used = now_unix_secs(); + let path = coord_file_path(data_dir); + let Ok(json) = serde_json::to_string_pretty(&json!({ + "kubo_pid": coord.kubo_pid, + "api_port": coord.api_port, + "gateway_port": coord.gateway_port, + "started_at": coord.started_at, + "last_used": coord.last_used, + })) else { + return; + }; + let tmp = path.with_extension("tmp"); + if fs::write(&tmp, json).is_ok() { + let _ = fs::rename(tmp, path); + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "elastos-block-graph-provider-test-{name}-{}", + now_unix_secs() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn cid_validation_rejects_empty_and_path_like_values() { + assert!( + validate_cid("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi").is_ok() + ); + assert!(validate_cid("").is_err()); + assert!(validate_cid("../secret").is_err()); + assert!(validate_cid("bafy cid").is_err()); + } + + #[test] + fn status_reports_missing_kubo_coord_as_not_configured() { + let mut provider = ContentBlockGraphProvider::default(); + let data_dir = temp_dir("missing-coord"); + let response = provider.handle(Request::Init { + config: json!({ + "base_path": data_dir, + }), + }); + assert_eq!(response["status"], "ok"); + + let response = provider.handle(Request::Status { + _runtime_invocation: None, + _runtime_transfer: None, + }); + assert_eq!(response["data"]["status"], "backend_not_configured"); + assert_eq!(response["data"]["backend"]["configured"], false); + } + + #[test] + fn export_graph_fails_closed_without_kubo_coord() { + let provider = ContentBlockGraphProvider { + data_dir: temp_dir("export-no-coord"), + ..Default::default() + }; + let response = provider.export_graph(GraphRequest { + cid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi".to_string(), + schema: Some(GRAPH_SCHEMA.to_string()), + repair_graph_kind: Some("ipld_dag".to_string()), + availability_requirements: Value::Null, + policy: None, + object_did: None, + publisher_did: None, + _runtime_invocation: None, + _runtime_transfer: None, + }); + + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "export_failed"); + assert!(response["message"] + .as_str() + .unwrap() + .contains("Kubo coord file missing")); + } + + #[test] + fn import_graph_rejects_wrong_schema_and_root() { + let provider = ContentBlockGraphProvider { + data_dir: temp_dir("import-validate"), + ..Default::default() + }; + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi".to_string(); + let response = provider.import_graph(ImportGraphRequest { + cid: cid.clone(), + graph: json!({ + "schema": "wrong", + "root_cid": cid, + }), + availability_policy: None, + availability_requirements: Value::Null, + ensure_failure: None, + object_did: None, + publisher_did: None, + _runtime_invocation: None, + _runtime_transfer: None, + }); + assert_eq!(response["code"], "unsupported_schema"); + + let response = provider.import_graph(ImportGraphRequest { + cid, + graph: json!({ + "schema": GRAPH_SCHEMA, + "root_cid": "bafywrong", + "encoding": GRAPH_ENCODING, + "car": BASE64.encode(b"car"), + }), + availability_policy: None, + availability_requirements: Value::Null, + ensure_failure: None, + object_did: None, + publisher_did: None, + _runtime_invocation: None, + _runtime_transfer: None, + }); + assert_eq!(response["code"], "cid_mismatch"); + } +} + +fn main() { + eprintln!("{PROVIDER_ID}: starting v{PROVIDER_VERSION}"); + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let mut provider = ContentBlockGraphProvider::default(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + eprintln!("{PROVIDER_ID} read error: {err}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + + let request = match serde_json::from_str::(&line) { + Ok(request) => request, + Err(err) => { + let response = error("invalid_request", &err.to_string()); + writeln!(stdout, "{}", serde_json::to_string(&response).unwrap()).unwrap(); + stdout.flush().unwrap(); + continue; + } + }; + let is_shutdown = matches!(request, Request::Shutdown); + let response = provider.handle(request); + writeln!(stdout, "{}", serde_json::to_string(&response).unwrap()).unwrap(); + stdout.flush().unwrap(); + if is_shutdown { + break; + } + } + + eprintln!("{PROVIDER_ID}: exiting"); +} diff --git a/capsules/decrypt-provider/src/main.rs b/capsules/decrypt-provider/src/main.rs index 7412b26b..f0e5c271 100644 --- a/capsules/decrypt-provider/src/main.rs +++ b/capsules/decrypt-provider/src/main.rs @@ -7,7 +7,7 @@ use elastos_common::protected_content::{ DecryptSessionRequestV1, DECRYPT_SESSION_REQUEST_SCHEMA, PROTECTED_CONTENT_ACTIONS, - PROTECTED_CONTENT_OUTPUTS, + PROTECTED_CONTENT_OUTPUTS, RELEASE_RECEIPT_SCHEMA, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -108,6 +108,44 @@ impl DecryptProvider { "next_required_providers": [ "key-provider" ], + "contract": { + "schema": "elastos.protected-content.decrypt-provider/v1", + "authority_boundary": "viewer-scoped decrypt/render sessions only", + "denied_to_apps": [ + "raw_cek", + "raw_plaintext", + "filesystem", + "key_backend_sdk", + "kms_node_credentials", + "chain_rpc", + "wallet_rpc", + "provider_credentials" + ], + "operations": { + "open_session": { + "input_schema": DECRYPT_SESSION_REQUEST_SCHEMA, + "input": [ + "request_id", + "principal_id", + "session_id", + "object_cid", + "action", + "viewer_interface", + "release_receipt", + "output_kind", + "reason", + "expires_at" + ], + "output": "viewer-scoped decrypt session receipt" + }, + "render": { + "input_schema": DECRYPT_SESSION_REQUEST_SCHEMA, + "output": "bounded rendered output for an authorized viewer capsule" + } + }, + "supported_outputs": PROTECTED_CONTENT_OUTPUTS, + "status": "fail_closed_until_key_release_and_decrypt_backend_configured" + }, })) } @@ -142,7 +180,7 @@ fn validate_decrypt_session_request(request: &DecryptSessionRequestV1) -> Result require_identifier(&request.object_cid, "object_cid")?; validate_action(&request.action)?; require_non_empty(&request.viewer_interface, "viewer_interface")?; - require_non_empty(&request.release_receipt_id, "release_receipt_id")?; + validate_release_receipt_binding(request)?; validate_output_kind(&request.output_kind)?; require_non_empty(&request.reason, "reason")?; if request.expires_at == 0 { @@ -151,6 +189,47 @@ fn validate_decrypt_session_request(request: &DecryptSessionRequestV1) -> Result Ok(()) } +fn validate_release_receipt_binding(request: &DecryptSessionRequestV1) -> Result<(), String> { + let receipt = &request.release_receipt; + if receipt.schema != RELEASE_RECEIPT_SCHEMA { + return Err("release receipt schema is unsupported".to_string()); + } + require_identifier(&receipt.request_id, "release_receipt.request_id")?; + require_identifier(&receipt.object_cid, "release_receipt.object_cid")?; + require_non_empty(&receipt.principal_id, "release_receipt.principal_id")?; + require_non_empty(&receipt.session_id, "release_receipt.session_id")?; + validate_action(&receipt.action)?; + require_non_empty(&receipt.provider, "release_receipt.provider")?; + if receipt.provider != "key-provider" { + return Err("release receipt provider must be key-provider".to_string()); + } + if !matches!(receipt.status.as_str(), "released" | "issued") { + return Err("release receipt must be issued or released".to_string()); + } + if receipt.object_cid != request.object_cid { + return Err("release receipt object_cid must match decrypt object_cid".to_string()); + } + if receipt.principal_id != request.principal_id { + return Err("release receipt principal_id must match decrypt principal_id".to_string()); + } + if receipt.session_id != request.session_id { + return Err("release receipt session_id must match decrypt session_id".to_string()); + } + if receipt.action != request.action { + return Err("release receipt action must match decrypt action".to_string()); + } + if receipt.issued_at == 0 { + return Err("release receipt issued_at is required".to_string()); + } + if receipt.expires_at == 0 { + return Err("release receipt expires_at is required".to_string()); + } + if receipt.expires_at < request.expires_at { + return Err("release receipt must cover the decrypt session expiry".to_string()); + } + Ok(()) +} + fn validate_action(action: &str) -> Result<(), String> { if PROTECTED_CONTENT_ACTIONS.contains(&action) { Ok(()) @@ -238,6 +317,7 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use elastos_common::protected_content::ReleaseReceiptV1; fn decrypt_request() -> DecryptSessionRequestV1 { DecryptSessionRequestV1 { @@ -248,7 +328,18 @@ mod tests { object_cid: "bafybeigprotectedcontent".to_string(), action: "view".to_string(), viewer_interface: "elastos.viewer/document@1".to_string(), - release_receipt_id: "key-release:test".to_string(), + release_receipt: ReleaseReceiptV1 { + schema: RELEASE_RECEIPT_SCHEMA.to_string(), + request_id: "key-release:test".to_string(), + object_cid: "bafybeigprotectedcontent".to_string(), + principal_id: "person:local:test".to_string(), + session_id: "session:test".to_string(), + action: "view".to_string(), + provider: "key-provider".to_string(), + status: "released".to_string(), + issued_at: 1_800_000_000, + expires_at: 1_900_000_000, + }, output_kind: "rendered".to_string(), reason: "open protected document".to_string(), expires_at: 1_900_000_000, @@ -284,6 +375,14 @@ mod tests { .as_array() .unwrap() .contains(&json!("raw_plaintext"))); + assert_eq!( + data["contract"]["schema"], + "elastos.protected-content.decrypt-provider/v1" + ); + assert_eq!( + data["contract"]["status"], + "fail_closed_until_key_release_and_decrypt_backend_configured" + ); } #[test] @@ -339,4 +438,28 @@ mod tests { "invalid_request" ); } + + #[test] + fn open_session_rejects_denied_release_receipt() { + let provider = DecryptProvider; + let mut request = decrypt_request(); + request.release_receipt.status = "denied".to_string(); + + assert_eq!( + error_code(provider.open_session(request)), + "invalid_request" + ); + } + + #[test] + fn open_session_rejects_mismatched_release_receipt() { + let provider = DecryptProvider; + let mut request = decrypt_request(); + request.release_receipt.object_cid = "bafybeigother".to_string(); + + assert_eq!( + error_code(provider.open_session(request)), + "invalid_request" + ); + } } diff --git a/capsules/ipfs-provider/src/main.rs b/capsules/ipfs-provider/src/main.rs index d87fec32..29126f91 100644 --- a/capsules/ipfs-provider/src/main.rs +++ b/capsules/ipfs-provider/src/main.rs @@ -41,6 +41,8 @@ enum Request { filename: String, #[serde(default = "default_true")] pin: bool, + #[serde(default, rename = "_runtime_invocation")] + _runtime_invocation: Option, }, AddPath { path: String, // absolute filesystem path @@ -51,11 +53,15 @@ enum Request { files: Vec, #[serde(default = "default_true")] pin: bool, + #[serde(default, rename = "_runtime_invocation")] + _runtime_invocation: Option, }, Cat { cid: String, #[serde(default)] path: Option, + #[serde(default, rename = "_runtime_invocation")] + _runtime_invocation: Option, }, CatToPath { cid: String, @@ -77,9 +83,13 @@ enum Request { }, Pin { cid: String, + #[serde(default, rename = "_runtime_invocation")] + _runtime_invocation: Option, }, Unpin { cid: String, + #[serde(default, rename = "_runtime_invocation")] + _runtime_invocation: Option, }, Health, Status, @@ -250,18 +260,19 @@ impl IpfsProvider { data, filename, pin, + .. } => self.add_bytes(&data, &filename, pin), Request::AddPath { path, pin } => self.add_path(&path, pin), - Request::AddDirectory { files, pin } => self.add_directory(files, pin), - Request::Cat { cid, path } => self.cat(&cid, path.as_deref()), + Request::AddDirectory { files, pin, .. } => self.add_directory(files, pin), + Request::Cat { cid, path, .. } => self.cat(&cid, path.as_deref()), Request::CatToPath { cid, path, dest } => { self.cat_to_path(&cid, path.as_deref(), &dest) } Request::GetBytes { cid, path } => self.cat(&cid, path.as_deref()), Request::Ls { cid } => self.ls(&cid), Request::DownloadDirectory { cid, dest } => self.download_directory(&cid, &dest), - Request::Pin { cid } => self.pin(&cid), - Request::Unpin { cid } => self.unpin(&cid), + Request::Pin { cid, .. } => self.pin(&cid), + Request::Unpin { cid, .. } => self.unpin(&cid), Request::Health => self.health(), Request::Status => self.status(), Request::Shutdown => self.shutdown(), @@ -1517,6 +1528,7 @@ mod tests { data, filename, pin, + .. } => { assert_eq!(data, "aGVsbG8="); assert_eq!(filename, "test.txt"); @@ -1526,12 +1538,41 @@ mod tests { } } + #[test] + fn test_request_accepts_runtime_invocation_metadata() { + let runtime = r#"{ + "schema":"elastos.provider.invocation/v1", + "source":"content-provider", + "target":"ipfs", + "op":"add_bytes", + "transport":"runtime-local-provider-plane", + "transfer":"bytes" + }"#; + let json = format!( + r#"{{"op":"add_bytes","data":"aGVsbG8=","filename":"test.txt","pin":true,"_runtime_invocation":{runtime}}}"# + ); + let req: Request = serde_json::from_str(&json).expect("Should parse runtime envelope"); + assert!(matches!(req, Request::AddBytes { .. })); + + let json = r#"{"op":"cat","cid":"QmTest","_runtime_invocation":{"schema":"elastos.provider.invocation/v1"}}"#; + let req: Request = serde_json::from_str(json).expect("Should parse runtime cat envelope"); + assert!(matches!(req, Request::Cat { .. })); + } + + #[test] + fn test_request_still_rejects_unknown_fields() { + let json = + r#"{"op":"add_bytes","data":"aGVsbG8=","filename":"test.txt","pin":true,"admin":true}"#; + let err = serde_json::from_str::(json).expect_err("Should reject unknown fields"); + assert!(err.to_string().contains("unknown field `admin`")); + } + #[test] fn test_cat_request_deserialization() { let json = r#"{"op":"cat","cid":"QmTest","path":"file.txt"}"#; let req: Request = serde_json::from_str(json).expect("Should parse cat"); match req { - Request::Cat { cid, path } => { + Request::Cat { cid, path, .. } => { assert_eq!(cid, "QmTest"); assert_eq!(path.as_deref(), Some("file.txt")); } diff --git a/capsules/key-provider/src/main.rs b/capsules/key-provider/src/main.rs index e08d51a3..478af96e 100644 --- a/capsules/key-provider/src/main.rs +++ b/capsules/key-provider/src/main.rs @@ -6,7 +6,7 @@ use elastos_common::protected_content::{ validate_protected_content_key_envelope_algorithms, KeyReleaseRequestV1, - KEY_RELEASE_REQUEST_SCHEMA, PROTECTED_CONTENT_ACTIONS, + KEY_RELEASE_REQUEST_SCHEMA, PROTECTED_CONTENT_ACTIONS, RIGHTS_DECISION_RECEIPT_SCHEMA, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -102,6 +102,36 @@ impl KeyProvider { "rights-provider", "decrypt-provider" ], + "contract": { + "schema": "elastos.protected-content.key-provider/v1", + "authority_boundary": "typed key-release receipts only", + "denied_to_apps": [ + "raw_cek", + "kms_node_credentials", + "chain_rpc", + "wallet_rpc", + "provider_credentials" + ], + "operations": { + "release": { + "input_schema": KEY_RELEASE_REQUEST_SCHEMA, + "input": [ + "request_id", + "principal_id", + "session_id", + "object_cid", + "action", + "rights_receipt", + "key_envelope", + "reason", + "expires_at" + ], + "output": "key-release receipt or provider-scoped decrypt capability after an allowed rights receipt, never a raw CEK" + } + }, + "supported_schemes": SUPPORTED_SCHEMES, + "status": "fail_closed_until_dkms_backend_configured" + }, })) } @@ -125,6 +155,7 @@ fn validate_key_release_request(request: &KeyReleaseRequestV1) -> Result<(), Str require_non_empty(&request.session_id, "session_id")?; require_identifier(&request.object_cid, "object_cid")?; validate_action(&request.action)?; + validate_rights_receipt_binding(request)?; require_non_empty(&request.reason, "reason")?; require_non_empty(&request.key_envelope.scheme, "key_envelope.scheme")?; require_supported_scheme(&request.key_envelope.scheme)?; @@ -144,6 +175,47 @@ fn validate_key_release_request(request: &KeyReleaseRequestV1) -> Result<(), Str Ok(()) } +fn validate_rights_receipt_binding(request: &KeyReleaseRequestV1) -> Result<(), String> { + let receipt = &request.rights_receipt; + if receipt.schema != RIGHTS_DECISION_RECEIPT_SCHEMA { + return Err("rights receipt schema is unsupported".to_string()); + } + require_identifier(&receipt.request_id, "rights_receipt.request_id")?; + require_identifier(&receipt.content_id, "rights_receipt.content_id")?; + require_non_empty(&receipt.principal_id, "rights_receipt.principal_id")?; + require_non_empty(&receipt.session_id, "rights_receipt.session_id")?; + validate_action(&receipt.right)?; + require_non_empty(&receipt.provider, "rights_receipt.provider")?; + if receipt.provider != "rights-provider" { + return Err("rights receipt provider must be rights-provider".to_string()); + } + if !receipt.allowed { + return Err("rights receipt must allow the requested action".to_string()); + } + if receipt.content_id != request.object_cid { + return Err("rights receipt content_id must match object_cid".to_string()); + } + if receipt.principal_id != request.principal_id { + return Err("rights receipt principal_id must match key release principal_id".to_string()); + } + if receipt.session_id != request.session_id { + return Err("rights receipt session_id must match key release session_id".to_string()); + } + if receipt.right != request.action { + return Err("rights receipt right must match key release action".to_string()); + } + if receipt.issued_at == 0 { + return Err("rights receipt issued_at is required".to_string()); + } + if receipt.expires_at == 0 { + return Err("rights receipt expires_at is required".to_string()); + } + if receipt.expires_at < request.expires_at { + return Err("rights receipt must cover the key release expiry".to_string()); + } + Ok(()) +} + fn validate_action(action: &str) -> Result<(), String> { if PROTECTED_CONTENT_ACTIONS.contains(&action) { Ok(()) @@ -228,9 +300,9 @@ fn main() { mod tests { use super::*; use elastos_common::protected_content::{ - KeyEnvelopeAlgorithmsV1, KeyEnvelopeV1, DEFAULT_PROTECTED_CONTENT_CIPHER, - DEFAULT_PROTECTED_CONTENT_KEMS, DEFAULT_PROTECTED_CONTENT_SHARE_SCHEME, - DEFAULT_PROTECTED_CONTENT_SIGNATURES, + KeyEnvelopeAlgorithmsV1, KeyEnvelopeV1, RightsDecisionReceiptV1, + DEFAULT_PROTECTED_CONTENT_CIPHER, DEFAULT_PROTECTED_CONTENT_KEMS, + DEFAULT_PROTECTED_CONTENT_SHARE_SCHEME, DEFAULT_PROTECTED_CONTENT_SIGNATURES, }; fn key_release_request() -> KeyReleaseRequestV1 { @@ -241,6 +313,18 @@ mod tests { session_id: "session:test".to_string(), object_cid: "bafybeigprotectedcontent".to_string(), action: "view".to_string(), + rights_receipt: RightsDecisionReceiptV1 { + schema: RIGHTS_DECISION_RECEIPT_SCHEMA.to_string(), + request_id: "rights:test".to_string(), + content_id: "bafybeigprotectedcontent".to_string(), + principal_id: "person:local:test".to_string(), + session_id: "session:test".to_string(), + right: "view".to_string(), + provider: "rights-provider".to_string(), + allowed: true, + issued_at: 1_800_000_000, + expires_at: 1_900_000_000, + }, key_envelope: KeyEnvelopeV1 { scheme: "elastos-pq-hybrid-threshold-v0".to_string(), kid: "kid:test".to_string(), @@ -289,6 +373,14 @@ mod tests { .as_array() .unwrap() .contains(&json!("raw_cek"))); + assert_eq!( + data["contract"]["schema"], + "elastos.protected-content.key-provider/v1" + ); + assert_eq!( + data["contract"]["status"], + "fail_closed_until_dkms_backend_configured" + ); } #[test] @@ -326,4 +418,22 @@ mod tests { assert_eq!(error_code(provider.release(request)), "invalid_request"); } + + #[test] + fn release_rejects_denied_rights_receipt() { + let provider = KeyProvider; + let mut request = key_release_request(); + request.rights_receipt.allowed = false; + + assert_eq!(error_code(provider.release(request)), "invalid_request"); + } + + #[test] + fn release_rejects_mismatched_rights_receipt() { + let provider = KeyProvider; + let mut request = key_release_request(); + request.rights_receipt.principal_id = "person:local:other".to_string(); + + assert_eq!(error_code(provider.release(request)), "invalid_request"); + } } diff --git a/capsules/rights-provider/src/main.rs b/capsules/rights-provider/src/main.rs index 16b46cbf..2b02f9a5 100644 --- a/capsules/rights-provider/src/main.rs +++ b/capsules/rights-provider/src/main.rs @@ -157,6 +157,40 @@ impl RightsProvider { "key-provider", "decrypt-provider" ], + "contract": { + "schema": "elastos.protected-content.rights-provider/v1", + "authority_boundary": "typed rights decisions only", + "denied_to_apps": [ + "contract_sdk", + "chain_rpc", + "wallet_rpc", + "key_backend_sdk", + "raw_cek", + "provider_credentials" + ], + "operations": { + "has_access_by_content_id": { + "input": [ + "principal_id", + "session_id", + "content_id", + "right", + "reason", + "policy_ref?" + ], + "output": "allow/deny receipt when a dDRM policy backend is configured" + }, + "can_stream": { + "input": ["principal_id", "session_id", "content_id", "reason", "policy_ref?"], + "output": "allow/deny stream receipt" + }, + "can_download": { + "input": ["principal_id", "session_id", "content_id", "reason", "policy_ref?"], + "output": "allow/deny download receipt" + } + }, + "status": "fail_closed_until_policy_backend_configured" + }, })) } @@ -368,6 +402,14 @@ mod tests { .as_array() .unwrap() .contains(&json!("contract_sdk"))); + assert_eq!( + data["contract"]["schema"], + "elastos.protected-content.rights-provider/v1" + ); + assert_eq!( + data["contract"]["status"], + "fail_closed_until_policy_backend_configured" + ); } #[test] diff --git a/elastos/crates/elastos-common/src/protected_content.rs b/elastos/crates/elastos-common/src/protected_content.rs index 1d359f06..5b7b927f 100644 --- a/elastos/crates/elastos-common/src/protected_content.rs +++ b/elastos/crates/elastos-common/src/protected_content.rs @@ -12,6 +12,7 @@ pub const KEY_RELEASE_REQUEST_SCHEMA: &str = "elastos.key_release.request/v1"; pub const DECRYPT_SESSION_REQUEST_SCHEMA: &str = "elastos.decrypt.session.request/v1"; pub const DECRYPT_SESSION_SCHEMA: &str = "elastos.decrypt.session/v1"; pub const RELEASE_RECEIPT_SCHEMA: &str = "elastos.release.receipt/v1"; +pub const RIGHTS_DECISION_RECEIPT_SCHEMA: &str = "elastos.rights.decision.receipt/v1"; pub const PROTECTED_CONTENT_ACTIONS: &[&str] = &["view", "stream", "download", "execute"]; pub const PROTECTED_CONTENT_OUTPUTS: &[&str] = &["rendered", "stream", "working_copy"]; pub const DEFAULT_PROTECTED_CONTENT_CIPHER: &str = "aes-256-gcm"; @@ -174,6 +175,7 @@ pub struct KeyReleaseRequestV1 { pub session_id: String, pub object_cid: String, pub action: String, + pub rights_receipt: RightsDecisionReceiptV1, pub key_envelope: KeyEnvelopeV1, pub reason: String, pub expires_at: u64, @@ -189,7 +191,7 @@ pub struct DecryptSessionRequestV1 { pub object_cid: String, pub action: String, pub viewer_interface: String, - pub release_receipt_id: String, + pub release_receipt: ReleaseReceiptV1, pub output_kind: String, pub reason: String, pub expires_at: u64, @@ -213,12 +215,29 @@ pub struct ReleaseReceiptV1 { pub request_id: String, pub object_cid: String, pub principal_id: String, + pub session_id: String, + pub action: String, pub provider: String, pub status: String, pub issued_at: u64, pub expires_at: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RightsDecisionReceiptV1 { + pub schema: String, + pub request_id: String, + pub content_id: String, + pub principal_id: String, + pub session_id: String, + pub right: String, + pub provider: String, + pub allowed: bool, + pub issued_at: u64, + pub expires_at: u64, +} + #[cfg(test)] mod tests { use super::*; @@ -271,7 +290,18 @@ mod tests { object_cid: "bafybeigprotectedcontent".to_string(), action: PROTECTED_CONTENT_ACTIONS[0].to_string(), viewer_interface: "elastos.viewer/document@1".to_string(), - release_receipt_id: "release:test".to_string(), + release_receipt: ReleaseReceiptV1 { + schema: RELEASE_RECEIPT_SCHEMA.to_string(), + request_id: "key-release:test".to_string(), + object_cid: "bafybeigprotectedcontent".to_string(), + principal_id: "person:local:test".to_string(), + session_id: "session:test".to_string(), + action: PROTECTED_CONTENT_ACTIONS[0].to_string(), + provider: "key-provider".to_string(), + status: "released".to_string(), + issued_at: 1_800_000_000, + expires_at: 1_900_000_000, + }, output_kind: PROTECTED_CONTENT_OUTPUTS[0].to_string(), reason: "open protected document".to_string(), expires_at: 1_900_000_000, @@ -399,6 +429,18 @@ mod tests { "session_id": "session:test", "object_cid": "bafybeigprotectedcontent", "action": "view", + "rights_receipt": { + "schema": RIGHTS_DECISION_RECEIPT_SCHEMA, + "request_id": "rights:test", + "content_id": "bafybeigprotectedcontent", + "principal_id": "person:local:test", + "session_id": "session:test", + "right": "view", + "provider": "rights-provider", + "allowed": true, + "issued_at": 1_800_000_000u64, + "expires_at": 1_900_000_000u64 + }, "key_envelope": { "scheme": "elastos-pq-hybrid-threshold-v0", "kid": "kid:test", @@ -432,7 +474,18 @@ mod tests { "object_cid": "bafybeigprotectedcontent", "action": "view", "viewer_interface": "elastos.viewer/document@1", - "release_receipt_id": "release:test", + "release_receipt": { + "schema": RELEASE_RECEIPT_SCHEMA, + "request_id": "key-release:test", + "object_cid": "bafybeigprotectedcontent", + "principal_id": "person:local:test", + "session_id": "session:test", + "action": "view", + "provider": "key-provider", + "status": "released", + "issued_at": 1_800_000_000u64, + "expires_at": 1_900_000_000u64 + }, "output_kind": "rendered", "reason": "open protected document", "expires_at": 1_900_000_000u64, diff --git a/elastos/crates/elastos-server/src/carrier.rs b/elastos/crates/elastos-server/src/carrier.rs index f1deee5f..75ef24ef 100644 --- a/elastos/crates/elastos-server/src/carrier.rs +++ b/elastos/crates/elastos-server/src/carrier.rs @@ -18,12 +18,13 @@ //! //! See `docs/CARRIER_TRUST_DECISION.md` for the rationale. -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use std::time::Duration; use anyhow::{Context, Result}; +use base64::Engine as _; use iroh::address_lookup::memory::MemoryLookup; use iroh::protocol::{AcceptError, ProtocolHandler, Router}; use iroh::{Endpoint, SecretKey, Watcher}; @@ -44,13 +45,48 @@ use elastos_common::localhost::{ publisher_artifacts_path, publisher_install_script_path, publisher_publish_state_path, publisher_release_head_path, publisher_release_manifest_path, }; -use elastos_runtime::provider::{Provider, ProviderError, ResourceRequest, ResourceResponse}; +use elastos_runtime::provider::{ + Provider, ProviderCarrierInvoker, ProviderCarrierRoute, ProviderError, ProviderInvocation, + ProviderInvocationTransport, ProviderRegistry, ProviderTransfer, ResourceRequest, + ResourceResponse, +}; +use crate::content::{ContentObjectManifest, CONTENT_OBJECT_MANIFEST_PATH}; use crate::operator_control::{OperatorHandler, OperatorRuntimeContext, OPERATOR_ALPN}; use crate::sources::TrustedSource; const CARRIER_ALPN: &[u8] = b"elastos/carrier/1"; const CHAT_DISCOVERY_TOPIC_GENERAL: &str = "__elastos_internal/chat-presence-v1/#general"; +const CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA: &str = + "elastos.content.availability.announcement/v1"; +const CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN: &str = + "elastos.content.availability.announcement.v1"; +const CONTENT_ADMISSION_DOMAIN: &str = "elastos.content.admission.v1"; +const CONTENT_REPAIR_GRAPH_SCHEMA: &str = "elastos.content.repair-graph/v1"; +const CONTENT_BLOCK_GRAPH_SCHEMA: &str = "elastos.content.block-graph/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA: &str = + "elastos.content.federated-quota-ledger-policy/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA: &str = + "elastos.content.storage-market-admission-policy/v1"; +const CONTENT_BLOCK_GRAPH_PROVIDER: &str = "content-block-graph-provider"; +const CONTENT_BLOCK_GRAPH_TARGET: &str = "block-graph"; +const CARRIER_PEER_REPUTATION_SCHEMA: &str = "elastos.carrier.peer-reputation/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA: &str = + "elastos.carrier.peer-attestation-exchange-policy/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.carrier.peer-attestation.exchange-request/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_DOMAIN: &str = + "elastos.carrier.peer-attestation.exchange-request.v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.carrier.peer-attestation.exchange-receipt/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_DOMAIN: &str = + "elastos.carrier.peer-attestation.exchange-receipt.v1"; +const MAX_CARRIER_REPLICATION_CANDIDATES: usize = 8; +const MAX_CARRIER_AVAILABILITY_TICKET_LEN: usize = 8192; +const MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN: usize = 256; +const MAX_CARRIER_OBJECT_IMPORT_FILES: usize = 512; +const MAX_CARRIER_OBJECT_IMPORT_BYTES: usize = 64 * 1024 * 1024; +const MAX_REMOTE_RECEIPT_REPLICA_SUMMARY_ROWS: usize = 5; /// Well-known secret for topic discovery. Any Carrier node with this secret /// can discover peers on the same topic via DHT. @@ -481,6 +517,15 @@ pub async fn start_carrier_node( signing_key: &ed25519_dalek::SigningKey, did: &str, data_dir: PathBuf, +) -> Result { + start_carrier_node_with_registry(signing_key, did, data_dir, None).await +} + +pub async fn start_carrier_node_with_registry( + signing_key: &ed25519_dalek::SigningKey, + did: &str, + data_dir: PathBuf, + provider_registry: Option>, ) -> Result { let secret_key = SecretKey::from_bytes(&signing_key.to_bytes()); @@ -538,6 +583,7 @@ pub async fn start_carrier_node( let file_handler = FileHandler { data_dir: data_dir.clone(), + provider_registry, }; let operator_handler = OperatorHandler::new(OperatorRuntimeContext { data_dir: data_dir.clone(), @@ -616,6 +662,7 @@ pub async fn start_carrier_node( #[derive(Debug, Clone)] struct FileHandler { data_dir: PathBuf, + provider_registry: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -634,8 +681,9 @@ impl ProtocolHandler for FileHandler { conn: iroh::endpoint::Connection, ) -> futures_lite::future::Boxed> { let data_dir = self.data_dir.clone(); + let provider_registry = self.provider_registry.clone(); Box::pin(async move { - handle_file_connection(conn, &data_dir) + handle_file_connection(conn, &data_dir, provider_registry) .await .map_err(|e| AcceptError::from(std::io::Error::other(e.to_string()))) }) @@ -645,6 +693,7 @@ impl ProtocolHandler for FileHandler { async fn handle_file_connection( conn: iroh::endpoint::Connection, data_dir: &std::path::Path, + provider_registry: Option>, ) -> Result<()> { loop { let (mut send, recv) = match conn.accept_bi().await { @@ -652,8 +701,10 @@ async fn handle_file_connection( Err(_) => break, }; let data_dir = data_dir.to_path_buf(); + let provider_registry = provider_registry.clone(); tokio::spawn(async move { - if let Err(e) = handle_file_stream(&mut send, recv, &data_dir).await { + if let Err(e) = handle_file_stream(&mut send, recv, &data_dir, provider_registry).await + { debug!("carrier file stream error: {:#}", e); } }); @@ -665,6 +716,7 @@ async fn handle_file_stream( send: &mut iroh::endpoint::SendStream, recv: iroh::endpoint::RecvStream, data_dir: &std::path::Path, + provider_registry: Option>, ) -> Result<()> { let mut reader = BufReader::new(recv); let mut line = String::new(); @@ -752,6 +804,71 @@ async fn handle_file_stream( send.stopped().await.ok(); info!("carrier: served file {} ({} bytes)", path, len); } + "content_fetch" => { + let Some(registry) = provider_registry.and_then(|registry| registry.upgrade()) else { + send_json( + send, + &serde_json::json!({ + "ok": false, + "error": "content provider registry unavailable" + }), + ) + .await?; + return Ok(()); + }; + let cid = msg + .data + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let path = msg + .data + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or(""); + match carrier_content_fetch_bytes(®istry, cid, path).await { + Ok(content) => { + let len = content.len() as u64; + send.write_all(&len.to_be_bytes()).await?; + send.write_all(&content).await?; + send.finish()?; + send.stopped().await.ok(); + info!( + "carrier: served content {}{}{} ({} bytes)", + cid, + if path.is_empty() { "" } else { "/" }, + path, + len + ); + } + Err(err) => { + send_json( + send, + &serde_json::json!({ + "ok": false, + "error": err.to_string(), + }), + ) + .await?; + } + } + } + "provider_invoke" => { + let Some(registry) = provider_registry.and_then(|registry| registry.upgrade()) else { + send_json( + send, + &serde_json::json!({ + "ok": false, + "code": "provider_registry_unavailable", + "error": "provider registry unavailable" + }), + ) + .await?; + return Ok(()); + }; + let response = carrier_provider_invoke_registry(®istry, &msg.data).await?; + send_json(send, &response).await?; + } _ => { send_json( send, @@ -763,6 +880,228 @@ async fn handle_file_stream( Ok(()) } +async fn carrier_content_fetch_bytes( + registry: &ProviderRegistry, + cid: &str, + path: &str, +) -> Result> { + validate_content_cid(cid).map_err(anyhow::Error::msg)?; + validate_carrier_content_path(path).map_err(anyhow::Error::msg)?; + let mut request = serde_json::json!({ + "op": "cat", + "cid": cid, + }); + if !path.is_empty() { + request["path"] = serde_json::Value::String(path.to_string()); + } + let response = registry + .send_raw("ipfs", &request) + .await + .map_err(|err| anyhow::anyhow!("ipfs-provider unavailable: {err}"))?; + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("ipfs-provider fetch failed: {message}"); + } + let data = response + .get("data") + .and_then(|data| data.get("data")) + .and_then(|data| data.as_str()) + .ok_or_else(|| anyhow::anyhow!("ipfs-provider response missing data"))?; + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| anyhow::anyhow!("ipfs-provider returned invalid base64: {err}")) +} + +async fn carrier_provider_invoke_registry( + registry: &ProviderRegistry, + data: &serde_json::Value, +) -> Result { + let source = data + .get("source") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing source"))?; + let target = data + .get("target") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing target"))?; + let operation = data + .get("operation") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing operation"))?; + let transfer = data + .get("transfer") + .and_then(|value| value.as_str()) + .unwrap_or("json"); + let request = data + .get("request") + .cloned() + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing request"))?; + + if !carrier_provider_target_allowed(target) { + return Ok(serde_json::json!({ + "ok": false, + "code": "unauthorized_provider_target", + "error": "Carrier provider invocation must target an ElastOS service provider, not a raw backend", + })); + } + if let Err(message) = + validate_carrier_provider_invocation(source, target, operation, transfer, &request) + { + return Ok(serde_json::json!({ + "ok": false, + "code": "invalid_provider_invocation", + "error": message, + })); + } + + match registry.send_raw(target, &request).await { + Ok(result) => Ok(serde_json::json!({ + "ok": true, + "result": result, + })), + Err(err) => Ok(serde_json::json!({ + "ok": false, + "code": "provider_error", + "error": err.to_string(), + })), + } +} + +fn carrier_provider_target_allowed(target: &str) -> bool { + matches!( + target, + "content" | "availability" | "rights" | "key" | "decrypt" | "drm" + ) +} + +fn validate_carrier_provider_invocation( + source: &str, + target: &str, + operation: &str, + transfer: &str, + request: &serde_json::Value, +) -> std::result::Result<(), String> { + if !matches!(transfer, "json" | "bytes" | "stream") { + return Err(format!( + "provider_invoke transfer must be json, bytes, or stream, got {transfer}" + )); + } + if request.get("_runtime_transfer").is_some() { + return Err("provider_invoke request must not predeclare _runtime_transfer".to_string()); + } + let request_op = request + .get("op") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if request_op != operation { + return Err(format!( + "provider_invoke op mismatch: envelope={operation}, request={request_op}" + )); + } + let runtime = request + .get("_runtime_invocation") + .and_then(|value| value.as_object()) + .ok_or_else(|| "provider_invoke requires _runtime_invocation".to_string())?; + let expected_capability = format!("provider:{source}->{target}:{operation}"); + for (field, expected) in [ + ("schema", "elastos.provider.invocation/v1"), + ("source", source), + ("target", target), + ("op", operation), + ("capability", expected_capability.as_str()), + ("transport", "carrier-provider-plane"), + ("transfer", transfer), + ] { + let actual = runtime + .get(field) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if actual != expected { + return Err(format!( + "provider_invoke runtime field {field} mismatch: expected {expected}, got {actual}" + )); + } + } + let carrier = runtime + .get("carrier") + .and_then(|value| value.as_object()) + .ok_or_else(|| "provider_invoke requires carrier route metadata".to_string())?; + if carrier + .get("route") + .and_then(|value| value.as_str()) + .unwrap_or_default() + != "connect_ticket" + { + return Err("provider_invoke carrier route must be connect_ticket".to_string()); + } + if carrier.contains_key("connect_ticket") { + return Err("provider_invoke carrier metadata must not expose connect_ticket".to_string()); + } + if transfer == "stream" { + validate_carrier_provider_stream_contract(runtime)?; + } + Ok(()) +} + +fn validate_carrier_provider_stream_contract( + runtime: &serde_json::Map, +) -> std::result::Result<(), String> { + let stream = runtime + .get("stream") + .and_then(|value| value.as_object()) + .ok_or_else(|| "provider_invoke stream transfer requires stream metadata".to_string())?; + let schema = stream + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != "elastos.provider.stream/v1" { + return Err(format!( + "provider_invoke stream schema mismatch: expected elastos.provider.stream/v1, got {schema}" + )); + } + let encoding = stream + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != "base64-chunks" { + return Err(format!( + "provider_invoke stream encoding mismatch: expected base64-chunks, got {encoding}" + )); + } + let chunk_size = stream + .get("chunk_size") + .and_then(|value| value.as_u64()) + .unwrap_or_default(); + if chunk_size == 0 { + return Err("provider_invoke stream chunk_size must be greater than zero".to_string()); + } + Ok(()) +} + +fn validate_carrier_content_path(path: &str) -> Result<(), String> { + if path.is_empty() { + return Ok(()); + } + if path.starts_with('/') || path.starts_with('\\') { + return Err("content fetch path must be relative".to_string()); + } + if path.contains('\\') || path.contains('\0') { + return Err("content fetch path contains invalid characters".to_string()); + } + for segment in path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err("content fetch path contains an invalid segment".to_string()); + } + } + Ok(()) +} + async fn send_json(send: &mut iroh::endpoint::SendStream, value: &serde_json::Value) -> Result<()> { let mut bytes = serde_json::to_vec(value)?; bytes.push(b'\n'); @@ -772,723 +1111,6833 @@ async fn send_json(send: &mut iroh::endpoint::SendStream, value: &serde_json::Va Ok(()) } -// ── Gossip Provider (implements Provider trait) ────────────────── - -/// In-process gossip provider for `elastos://peer/*`. -/// Replaces the separate peer-provider subprocess. -pub struct CarrierGossipProvider { +/// Runtime content availability provider backed by Carrier gossip. +/// +/// `content-provider` still owns CID policy, receipts, and local Kubo/IPFS +/// backend use. This provider only announces content availability through the +/// Carrier plane so apps keep using `elastos://content/*` instead of raw +/// peer/topic/IPFS authority. +pub struct CarrierAvailabilityProvider { state: Arc>, + provider_registry: Option>, + peer_reputation: Arc>>, + data_dir: Option, + peer_attestation_exchange: Option, } -impl CarrierGossipProvider { +#[derive(Debug, Clone)] +pub struct CarrierPeerAttestationExchangeClient { + endpoints: Vec, + quorum: usize, +} + +#[derive(Debug, Clone)] +struct CarrierPeerAttestationExchangeEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} + +impl CarrierAvailabilityProvider { pub fn new(state: Arc>) -> Self { - Self { state } + Self { + state, + provider_registry: None, + peer_reputation: Arc::new(Mutex::new(HashMap::new())), + data_dir: None, + peer_attestation_exchange: None, + } } -} -impl std::fmt::Debug for CarrierGossipProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CarrierGossipProvider").finish() + pub fn with_provider_registry( + state: Arc>, + provider_registry: Weak, + ) -> Self { + Self { + state, + provider_registry: Some(provider_registry), + peer_reputation: Arc::new(Mutex::new(HashMap::new())), + data_dir: None, + peer_attestation_exchange: None, + } } -} -#[async_trait::async_trait] -impl Provider for CarrierGossipProvider { - async fn handle(&self, _request: ResourceRequest) -> Result { - Err(ProviderError::Provider( - "use send_raw for peer operations".into(), - )) + pub fn with_provider_registry_and_data_dir( + state: Arc>, + provider_registry: Weak, + data_dir: PathBuf, + ) -> Self { + Self::with_provider_registry_data_dir_and_peer_attestation_exchange_config( + state, + provider_registry, + data_dir, + None, + ) } - fn schemes(&self) -> Vec<&'static str> { - vec!["peer"] + pub fn with_provider_registry_data_dir_and_peer_attestation_exchange_config( + state: Arc>, + provider_registry: Weak, + data_dir: PathBuf, + peer_attestation_exchange_config: Option, + ) -> Self { + let peer_reputation = load_carrier_peer_reputation(&data_dir); + let peer_attestation_exchange = peer_attestation_exchange_config.and_then(|config| { + match CarrierPeerAttestationExchangeClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("carrier peer-attestation exchange disabled: {}", err); + None + } + } + }); + Self { + state, + provider_registry: Some(provider_registry), + peer_reputation: Arc::new(Mutex::new(peer_reputation)), + data_dir: Some(data_dir), + peer_attestation_exchange, + } } - fn name(&self) -> &'static str { - "carrier-gossip" + + async fn record_peer_reputation(&self, node_did: &str, success: bool) { + let snapshot = { + let mut reputation = self.peer_reputation.lock().await; + let entry = reputation.entry(node_did.to_string()).or_default(); + if success { + entry.successes = entry.successes.saturating_add(1); + } else { + entry.failures = entry.failures.saturating_add(1); + } + reputation.clone() + }; + if let Some(data_dir) = &self.data_dir { + if let Err(err) = save_carrier_peer_reputation(data_dir, &snapshot) { + tracing::debug!("carrier peer reputation save failed: {}", err); + } + } } - async fn send_raw( + async fn exchange_peer_attestations( &self, - request: &serde_json::Value, - ) -> Result { - let op = request.get("op").and_then(|v| v.as_str()).unwrap_or(""); - let mut state = self.state.lock().await; - - match op { - "init" => { - let id = state - .did - .clone() - .unwrap_or_else(|| state.endpoint.id().to_string()); - Ok(serde_json::json!({"status": "ok", "data": {"node_id": id}})) + exchange_request: CarrierPeerAttestationExchangeRequest<'_>, + ) -> Option { + let Some(exchange) = &self.peer_attestation_exchange else { + return None; + }; + if exchange_request.remote_proofs.is_empty() { + return None; + } + let request = match carrier_peer_attestation_exchange_request( + exchange_request.signing_key, + exchange_request.cid, + exchange_request.topic_uri, + exchange_request.local_node_did, + exchange_request.remote_proofs, + exchange_request.live_multi_peer_proof, + exchange_request.requested_at, + ) { + Ok(request) => request, + Err(err) => { + return Some(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "failed", + "reason": format!("peer-attestation exchange request build failed: {err}"), + "exchange": exchange.redacted_status_json(), + "credential_exposed": false, + })) } + }; + match exchange.exchange(&request).await { + Ok(receipt) => Some(receipt), + Err(err) => Some(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "failed", + "reason": err, + "exchange": exchange.redacted_status_json(), + "credential_exposed": false, + })), + } + } +} - "gossip_join" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - let force_direct = request - .get("mode") - .and_then(|value| value.as_str()) - .map(|mode| mode.eq_ignore_ascii_case("direct")) - .unwrap_or(false); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); - } - if state.joined_topics.contains(topic_name) { - return Ok( - serde_json::json!({"status":"error","code":"already_joined","message":"already joined"}), - ); - } - if state.joined_topics.len() >= MAX_TOPICS { - return Ok( - serde_json::json!({"status":"error","code":"too_many_topics","message":"topic limit reached"}), - ); - } +impl std::fmt::Debug for CarrierAvailabilityProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CarrierAvailabilityProvider").finish() + } +} - match join_gossip_topic(&mut state, topic_name, force_direct).await { - Ok(()) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), - Err(err) => Ok( - serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), - ), +impl CarrierPeerAttestationExchangeClient { + pub fn from_config(config: serde_json::Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_carrier_authorization_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + CarrierPeerAttestationExchangeEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![CarrierPeerAttestationExchangeEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "carrier peer-attestation exchange supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "carrier peer-attestation exchange quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn redacted_status_json(&self) -> serde_json::Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + serde_json::json!({ + "configured": true, + "delivery": "carrier_peer_attestation_exchange", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(CarrierPeerAttestationExchangeEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn exchange( + &self, + request_payload: &serde_json::Value, + ) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut verified_receipts = 0_usize; + let mut first_verified_signed_receipt = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .exchange(request_payload) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + carrier_peer_attestation_endpoint_unavailable(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + } else if receipt + .get("status") + .and_then(|value| value.as_str()) + .is_some_and(|status| status == "rejected") + { + rejected_receipts = rejected_receipts.saturating_add(1); + } + if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_receipts = verified_receipts.saturating_add(1); + if first_verified_signed_receipt.is_none() { + first_verified_signed_receipt = receipt.get("signed_receipt").cloned(); } } + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); + } + endpoint_receipts.push(receipt); + } - "gossip_leave" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); - } + let accepted = accepted_receipts >= self.quorum; + let reason = if accepted { + format!( + "carrier peer-attestation quorum accepted: {accepted_receipts}/{} verified endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "carrier peer-attestation quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "carrier peer-attestation quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) + }; + let mut signed_receipt = first_verified_signed_receipt.unwrap_or_else(|| { + serde_json::json!({ + "verified": false, + }) + }); + signed_receipt["verified"] = serde_json::Value::Bool(verified_receipts > 0); + signed_receipt["verified_receipts"] = serde_json::Value::from(verified_receipts); + + Ok(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "exchange": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + "verified": verified_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "signed_receipt": signed_receipt, + "reason": reason, + "credential_exposed": false, + })) + } +} - let removed_sender = state.senders.remove(topic_name); - let removed_task = state.receiver_tasks.remove(topic_name); - let was_joined = state.joined_topics.remove(topic_name); - if removed_sender.is_none() && removed_task.is_none() && !was_joined { - return Ok( - serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), - ); - } - if let Some(task) = removed_task { - task.abort(); - } +impl CarrierPeerAttestationExchangeEndpoint { + fn from_config( + payload: &serde_json::Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "carrier peer-attestation exchange endpoint requires url".to_string())?; + validate_carrier_external_endpoint_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) + .map(str::to_string); + if let Some(value) = &authorization { + validate_carrier_authorization_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("peer-attestation-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } - state - .cursors - .lock() - .await - .retain(|(topic, _), _| topic != topic_name); - state.buffers.lock().await.remove(topic_name); - state.topic_peers.lock().await.remove(topic_name); + fn redacted_status_json(&self) -> serde_json::Value { + let parsed = url::Url::parse(&self.url).ok(); + serde_json::json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } - Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})) - } + async fn exchange( + &self, + request_payload: &serde_json::Value, + ) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("peer-attestation exchange client build failed: {err}"))?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("peer-attestation exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "peer-attestation exchange returned HTTP {}", + status.as_u16() + )); + } + let response_json = response + .json::() + .await + .map_err(|err| format!("peer-attestation exchange response decode failed: {err}"))?; + carrier_peer_attestation_exchange_receipt_from_response( + &response_json, + self.redacted_status_json(), + status.as_u16(), + ) + } +} - "gossip_send" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - let message = request["message"].as_str().unwrap_or_default(); - let sender_nick = request["sender"].as_str().unwrap_or("unknown"); +fn content_availability_topic_name(cid: &str) -> String { + let digest = Sha256::digest(cid.as_bytes()); + format!("__elastos_content/v1/{}", hex::encode(digest)) +} - let sender = match state.senders.get(topic_name) { - Some(s) => s, - None => { - return Ok( - serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), - ) - } - }; +fn content_availability_topic_uri(cid: &str) -> String { + let digest = Sha256::digest(cid.as_bytes()); + format!( + "elastos://carrier/content/{}/availability", + hex::encode(digest) + ) +} - let default_id = state - .did - .clone() +fn carrier_availability_error(code: &str, message: impl Into) -> serde_json::Value { + serde_json::json!({ + "status": "error", + "code": code, + "message": message.into(), + }) +} + +fn validate_content_cid(cid: &str) -> Result<(), String> { + let cid = cid.trim(); + if cid.len() < 8 || cid.len() > 128 { + return Err("content availability requires a valid CID".to_string()); + } + if !cid + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err("content availability CID contains unsupported characters".to_string()); + } + Ok(()) +} + +fn validate_carrier_external_endpoint_url(raw: &str) -> Result<(), String> { + let url = url::Url::parse(raw) + .map_err(|err| format!("invalid carrier external endpoint URL: {err}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err( + "carrier external endpoint URL must not contain inline credentials".to_string(), + ); + } + match url.scheme() { + "https" => Ok(()), + "http" if matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) => Ok(()), + _ => Err("carrier external endpoint URL must use https or local loopback http".to_string()), + } +} + +fn validate_carrier_authorization_header_value(value: &str) -> Result<(), String> { + if value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) { + return Err("carrier authorization header contains invalid newline".to_string()); + } + Ok(()) +} + +fn local_replica_count(request: &serde_json::Value) -> u32 { + request + .get("local") + .and_then(|value| value.get("replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(0) +} + +fn carrier_peer_attestation_remote_proofs_json( + remote_proofs: &[CarrierReplicationProof], +) -> Vec { + remote_proofs + .iter() + .map(|proof| { + serde_json::json!({ + "node_did": proof.node_did.clone(), + "endpoint_id": proof.endpoint_id.clone(), + "announced_at": proof.announced_at, + "score": proof.score, + "selection_reason": proof.selection_reason.clone(), + "local_reputation": { + "scope": "local_runtime", + "score_delta": proof.reputation_score, + "reason": proof.reputation_reason.clone(), + }, + "admission": proof.admission.clone(), + "ensure_status": proof.ensure_status.clone(), + "status": proof + .status_availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + "remote_receipt": proof.remote_receipt.as_ref().map(|receipt| { + serde_json::json!({ + "schema": receipt.get("schema").cloned().unwrap_or(serde_json::Value::Null), + "cid": receipt.get("cid").cloned().unwrap_or(serde_json::Value::Null), + "status": receipt.get("status").cloned().unwrap_or(serde_json::Value::Null), + "signer_did": receipt.get("signer_did").cloned().unwrap_or(serde_json::Value::Null), + "verified": receipt.get("verified").cloned().unwrap_or(serde_json::Value::Bool(false)), + }) + }), + "checked_at": proof.checked_at, + }) + }) + .collect() +} + +fn carrier_peer_attestation_exchange_request( + signing_key: &ed25519_dalek::SigningKey, + cid: &str, + topic_uri: &str, + local_node_did: &str, + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, + requested_at: u64, +) -> Result { + let payload = serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "cid": cid, + "topic": topic_uri, + "local_node_did": local_node_did, + "live_multi_peer_proof": live_multi_peer_proof, + "remote_provider_proofs": remote_proofs.len(), + "remote_proofs": carrier_peer_attestation_remote_proofs_json(remote_proofs), + "requested_at": requested_at, + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "raw_carrier_ticket_exposed": false, + "raw_backend_access": false, + }, + }); + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "Carrier peer-attestation request serialization failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + signing_key, + CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_DOMAIN, + canonical.as_bytes(), + ); + Ok(serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + })) +} + +fn carrier_peer_attestation_exchange_receipt_from_response( + response: &serde_json::Value, + exchange: serde_json::Value, + http_status: u16, +) -> Result { + let accepted = response + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "peer-attestation exchange response requires accepted boolean".to_string() + })?; + let signed_receipt = response.get("receipt").cloned(); + let verified_receipt = match signed_receipt.as_ref() { + Some(receipt) => { + let signer_did = receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + "peer-attestation exchange signed receipt requires signer_did".to_string() + })?; + let receipt_bytes = serde_json::to_vec(receipt) + .map_err(|err| format!("peer-attestation exchange receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_DOMAIN, + &expected_signers, + ) + .map_err(|err| { + format!("peer-attestation exchange receipt verification failed: {err}") + })?; + let payload = receipt + .get("payload") + .cloned() + .unwrap_or(serde_json::Value::Null); + Some(serde_json::json!({ + "verified": true, + "signer_did": signer_did, + "payload_schema": payload + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "exchange_id": payload + .get("exchange_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt_id": payload + .get("receipt_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + })) + } + None if accepted => { + return Err( + "peer-attestation exchange accepted response requires signed receipt".to_string(), + ) + } + None => None, + }; + Ok(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": exchange, + "remote_schema": response.get("schema").cloned().unwrap_or(serde_json::Value::Null), + "remote_exchange_id": response.get("exchange_id").cloned().unwrap_or(serde_json::Value::Null), + "remote_receipt_id": response.get("receipt_id").cloned().unwrap_or(serde_json::Value::Null), + "signed_receipt": verified_receipt.unwrap_or_else(|| { + serde_json::json!({ + "verified": false, + "reason": "no signed receipt returned", + }) + }), + "reason": response.get("reason").cloned().unwrap_or(serde_json::Value::Null), + "credential_exposed": false, + })) +} + +fn carrier_peer_attestation_endpoint_unavailable( + reason: String, + endpoint: &CarrierPeerAttestationExchangeEndpoint, +) -> serde_json::Value { + serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "failed", + "exchange": endpoint.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + }) +} + +#[derive(Debug, Clone, Copy)] +struct CarrierAvailabilityRequirements { + min_replicas: u32, + max_replicas: Option, + require_live_multi_peer_proof: bool, + repair_graph_kind: CarrierRepairGraphKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CarrierRepairGraphKind { + Auto, + ObjectManifest, + ExactBytes, + IpldDag, +} + +impl CarrierAvailabilityRequirements { + fn from_request(request: &serde_json::Value) -> Self { + let requirements = request + .get("requirements") + .or_else(|| request.get("availability_requirements")); + let min_replicas = requirements + .and_then(|value| value.get("min_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1); + let max_replicas = requirements + .and_then(|value| value.get("max_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0); + let require_live_multi_peer_proof = requirements + .and_then(|value| value.get("require_live_multi_peer_proof")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let repair_graph_kind = CarrierRepairGraphKind::from_requirements(requirements); + Self { + min_replicas, + max_replicas, + require_live_multi_peer_proof, + repair_graph_kind, + } + } + + fn effective_max(self) -> u32 { + self.max_replicas + .unwrap_or(MAX_CARRIER_REPLICATION_CANDIDATES as u32 + 1) + .min(MAX_CARRIER_REPLICATION_CANDIDATES as u32 + 1) + .max(1) + } + + fn to_json(self) -> serde_json::Value { + serde_json::json!({ + "min_replicas": self.min_replicas, + "max_replicas": self.max_replicas, + "require_live_multi_peer_proof": self.require_live_multi_peer_proof, + "repair_graph_kind": self.repair_graph_kind.as_str(), + }) + } +} + +impl CarrierRepairGraphKind { + fn from_requirements(requirements: Option<&serde_json::Value>) -> Self { + let raw = requirements + .and_then(|value| { + value + .get("repair_graph_kind") + .or_else(|| value.get("content_graph_kind")) + .or_else(|| value.get("graph_kind")) + .or_else(|| { + value + .get("repair_graph") + .and_then(|graph| graph.get("kind")) + }) + .or_else(|| { + value + .get("content_graph") + .and_then(|graph| graph.get("kind")) + }) + }) + .and_then(|value| value.as_str()) + .unwrap_or("auto"); + match raw { + "object_manifest" | "manifest" => Self::ObjectManifest, + "exact_bytes" | "exact" | "single_block" | "file" => Self::ExactBytes, + "ipld_dag" | "block_dag" | "dag" | "arbitrary_dag" => Self::IpldDag, + _ => Self::Auto, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::ObjectManifest => "object_manifest", + Self::ExactBytes => "exact_bytes", + Self::IpldDag => "ipld_dag", + } + } + + fn supports_current_import_fallback(self) -> bool { + !matches!(self, Self::IpldDag) + } +} + +#[derive(Debug, Clone)] +struct CarrierAvailabilityReplica { + node_did: String, + endpoint_id: Option, + connect_ticket: String, + announced_at: u64, + score: u32, + selection_reason: String, + reputation_score: i32, + reputation_reason: String, +} + +#[derive(Debug, Clone)] +struct CarrierReplicationProof { + node_did: String, + endpoint_id: Option, + announced_at: u64, + score: u32, + selection_reason: String, + reputation_score: i32, + reputation_reason: String, + ensure_status: String, + admission: Option, + status_availability: serde_json::Value, + remote_receipt: Option, + transfer: Option, + checked_at: u64, +} + +#[derive(Clone, Copy)] +struct CarrierPeerAttestationExchangeView<'a> { + configured: bool, + receipt: Option<&'a serde_json::Value>, +} + +struct CarrierPeerAttestationExchangeRequest<'a> { + signing_key: &'a ed25519_dalek::SigningKey, + cid: &'a str, + topic_uri: &'a str, + local_node_did: &'a str, + remote_proofs: &'a [CarrierReplicationProof], + live_multi_peer_proof: bool, + requested_at: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct CarrierPeerReputation { + successes: u32, + failures: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CarrierPeerReputationStore { + schema: String, + peers: BTreeMap, +} + +#[async_trait::async_trait] +impl Provider for CarrierAvailabilityProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "use send_raw for typed content availability operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["availability"] + } + + fn name(&self) -> &'static str { + "carrier-availability" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("ensure") | Some("repair") => self.announce_availability(request).await, + Some("fetch") => self.fetch_from_announced_carrier_peers(request).await, + Some("status") => { + let state = self.state.lock().await; + let node_did = state + .did + .clone() .unwrap_or_else(|| state.endpoint.id().to_string()); - let msg = GossipMessage { - sender_id: request - .get("sender_id") - .and_then(|v| v.as_str()) - .unwrap_or(&default_id) - .to_string(), - sender_nick: sender_nick.to_string(), - content: message.to_string(), - ts: requested_gossip_ts(request), - nonce: requested_gossip_nonce(request), - signature: request - .get("signature") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - sender_session_id: request - .get("sender_session_id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - }; + Ok(serde_json::json!({ + "status": "ok", + "data": { + "provider": "carrier-availability", + "node_did": node_did, + "transport": "carrier-gossip", + "joined_topic_count": state.joined_topics.len(), + } + })) + } + _ => Ok(carrier_availability_error( + "unsupported_op", + "unsupported Carrier availability operation", + )), + } + } +} + +impl CarrierAvailabilityProvider { + async fn announce_availability( + &self, + request: &serde_json::Value, + ) -> Result { + let cid = match request.get("cid").and_then(|value| value.as_str()) { + Some(cid) => cid.trim(), + None => { + return Ok(carrier_availability_error( + "invalid_request", + "Carrier availability requires cid", + )) + } + }; + if let Err(err) = validate_content_cid(cid) { + return Ok(carrier_availability_error("invalid_cid", err)); + } + let uri = request + .get("uri") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("elastos://{cid}")); + let policy = request + .get("policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("carrier_default"); + let local = request + .get("local") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let replicas = local_replica_count(request); + let requirements = CarrierAvailabilityRequirements::from_request(request); + let desired_replicas = requirements + .min_replicas + .max(if replicas > 0 { 2 } else { 1 }) + .min(requirements.effective_max()); + let topic_name = content_availability_topic_name(cid); + let topic_uri = content_availability_topic_uri(cid); + let announced_at = now_secs(); + let mut state = self.state.lock().await; + + if !state.joined_topics.contains(&topic_name) { + if state.joined_topics.len() >= MAX_TOPICS { + return Ok(carrier_availability_error( + "too_many_topics", + "Carrier availability topic limit reached", + )); + } + if let Err(err) = join_gossip_topic(&mut state, &topic_name, false).await { + return Ok(carrier_availability_error( + "join_failed", + format!("Carrier availability topic join failed: {err}"), + )); + } + } + + let node_did = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + let existing_messages = { + let buffers = state.buffers.lock().await; + buffers + .get(&topic_name) + .map(|buffer| buffer.messages.iter().rev().cloned().collect::>()) + .unwrap_or_default() + }; + let reputation_snapshot = self.peer_reputation.lock().await.clone(); + let remote_candidate_limit = + carrier_remote_candidate_limit(requirements, replicas, desired_replicas); + let remote_candidate_pool = content_availability_replicas_with_reputation( + &existing_messages, + cid, + &reputation_snapshot, + ) + .into_iter() + .filter(|replica| replica.node_did != node_did) + .collect::>(); + let remote_candidate_count = remote_candidate_pool.len(); + let remote_candidate_limit_applied = remote_candidate_count > remote_candidate_limit; + let remote_candidates = remote_candidate_pool + .into_iter() + .take(remote_candidate_limit) + .collect::>(); + let fetch_descriptor = if replicas > 0 { + Some(serde_json::json!({ + "transport": "carrier-file", + "endpoint_id": state.endpoint.id().to_string(), + "connect_ticket": carrier_connect_ticket(&state.endpoint), + })) + } else { + None + }; + let signing_key = match state.signing_key.as_ref() { + Some(signing_key) => signing_key, + None => { + return Ok(carrier_availability_error( + "signer_unavailable", + "Carrier availability signer unavailable", + )) + } + }; + let mut payload = serde_json::json!({ + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "uri": uri, + "policy": policy, + "provider": "carrier-availability", + "node_did": node_did, + "topic": topic_uri, + "local": local, + "announced_at": announced_at, + }); + if let Some(fetch_descriptor) = fetch_descriptor { + payload["fetch"] = fetch_descriptor; + } + if let Some(object_did) = request.get("object_did").and_then(|value| value.as_str()) { + payload["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + payload["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "Carrier availability announcement serialization failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + signing_key, + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + canonical.as_bytes(), + ); + let peer_attestation_signing_key = signing_key.clone(); + let announcement = serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }); + let content = serde_json::to_string(&announcement).map_err(|err| { + ProviderError::Provider(format!( + "Carrier availability announcement envelope failed: {err}" + )) + })?; + let msg = GossipMessage { + sender_id: signer_did.clone(), + sender_nick: "content-provider".to_string(), + content, + ts: announced_at, + nonce: random_gossip_nonce(), + signature: Some(signature.clone()), + sender_session_id: request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string), + }; + + { + let mut buffers = state.buffers.lock().await; + if let Some(buffer) = buffers.get_mut(&topic_name) { + if buffer.messages.len() >= MAX_BUFFER { + buffer.messages.pop_front(); + buffer.base_index += 1; + } + buffer.messages.push_back(msg.clone()); + } + } + + let delivery = match state.senders.get(&topic_name) { + Some(sender) => { + let bytes = serde_json::to_vec(&msg).unwrap_or_default(); + match sender.broadcast(bytes).await { + Ok(_) => "carrier", + Err(err) => { + tracing::debug!("Carrier availability broadcast failed: {}", err); + "local_only" + } + } + } + None => "local_only", + }; + drop(state); + + let mut remote_proofs = Vec::new(); + let mut replication_errors = Vec::new(); + let mut attempted_remote_invocations = 0_u32; + if !remote_candidates.is_empty() { + match self + .provider_registry + .as_ref() + .and_then(|registry| registry.upgrade()) + { + Some(registry) => { + for candidate in remote_candidates { + attempted_remote_invocations = + attempted_remote_invocations.saturating_add(1); + match ensure_content_via_carrier_provider_invocation( + ®istry, &candidate, cid, request, + ) + .await + { + Ok(proof) => { + self.record_peer_reputation(&candidate.node_did, true).await; + remote_proofs.push(proof) + } + Err(err) => { + self.record_peer_reputation(&candidate.node_did, false) + .await; + replication_errors.push(format!("{}: {err}", candidate.node_did)) + } + } + } + } + None => replication_errors + .push("Carrier replication requires Runtime provider registry".to_string()), + } + } + + let proven_remote_replicas = remote_proofs.len() as u32; + let total_replicas = replicas.saturating_add(proven_remote_replicas); + let live_multi_peer_proof = proven_remote_replicas > 0; + let peer_attestation_exchange_receipt = self + .exchange_peer_attestations(CarrierPeerAttestationExchangeRequest { + signing_key: &peer_attestation_signing_key, + cid, + topic_uri: &topic_uri, + local_node_did: &node_did, + remote_proofs: &remote_proofs, + live_multi_peer_proof, + requested_at: announced_at, + }) + .await; + let meets_replica_requirement = total_replicas >= requirements.min_replicas; + let meets_live_requirement = + !requirements.require_live_multi_peer_proof || live_multi_peer_proof; + let status = if meets_replica_requirement && meets_live_requirement && live_multi_peer_proof + { + "network_available" + } else if meets_replica_requirement && meets_live_requirement { + "carrier_announced" + } else { + "repair_needed" + }; + let repair_scheduled = status == "repair_needed" + || total_replicas < desired_replicas + || (requirements.require_live_multi_peer_proof && !live_multi_peer_proof); + let mut availability = serde_json::json!({ + "status": status, + "provider": "carrier-availability", + "policy": policy, + "replicas": total_replicas, + "transport": "carrier-gossip", + "delivery": delivery, + "topic": topic_uri, + "peer_selection": carrier_peer_selection_json( + &topic_uri, + &node_did, + replicas, + &remote_proofs, + live_multi_peer_proof, + CarrierPeerAttestationExchangeView { + configured: self.peer_attestation_exchange.is_some(), + receipt: peer_attestation_exchange_receipt.as_ref(), + }, + ), + "quota": carrier_quota_json(requirements, total_replicas, desired_replicas), + "repair_worker": carrier_repair_worker_json(repair_scheduled, status), + "repair_graph": carrier_repair_graph_policy_json(requirements), + "storage_market": carrier_storage_market_policy_json( + total_replicas, + live_multi_peer_proof, + ), + "abuse_controls": carrier_abuse_controls_json( + remote_candidate_count, + remote_candidate_limit, + attempted_remote_invocations, + replication_errors.len() as u32, + remote_candidate_limit_applied, + ), + "checked_at": announced_at, + }); + if status == "repair_needed" { + availability["reason"] = serde_json::Value::String(carrier_repair_reason( + requirements, + total_replicas, + live_multi_peer_proof, + &replication_errors, + )); + } else if delivery == "local_only" && remote_proofs.is_empty() { + availability["reason"] = serde_json::Value::String( + "Carrier announcement was recorded locally; no remote peer delivery was observed" + .to_string(), + ); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "availability": availability, + } + })) + } + + async fn fetch_from_announced_carrier_peers( + &self, + request: &serde_json::Value, + ) -> Result { + let cid = match request.get("cid").and_then(|value| value.as_str()) { + Some(cid) => cid.trim(), + None => { + return Ok(carrier_availability_error( + "invalid_request", + "Carrier availability fetch requires cid", + )) + } + }; + if let Err(err) = validate_content_cid(cid) { + return Ok(carrier_availability_error("invalid_cid", err)); + } + let path = request + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if let Err(err) = validate_carrier_content_path(path) { + return Ok(carrier_availability_error("invalid_path", err)); + } + let topic_name = content_availability_topic_name(cid); + let messages = { + let mut state = self.state.lock().await; + if !state.joined_topics.contains(&topic_name) { + if state.joined_topics.len() >= MAX_TOPICS { + return Ok(carrier_availability_error( + "too_many_topics", + "Carrier availability topic limit reached", + )); + } + if let Err(err) = join_gossip_topic(&mut state, &topic_name, false).await { + return Ok(carrier_availability_error( + "join_failed", + format!("Carrier availability topic join failed: {err}"), + )); + } + } + let buffers = state.buffers.clone(); + drop(state); + let buffers = buffers.lock().await; + buffers + .get(&topic_name) + .map(|buffer| buffer.messages.iter().rev().cloned().collect::>()) + .unwrap_or_default() + }; + let tickets = content_availability_fetch_tickets(&messages, cid); + if tickets.is_empty() { + return Ok(carrier_availability_error( + "carrier_fetch_unavailable", + "no Carrier availability announcement with a fetch ticket is available for this CID", + )); + } + + let Some(registry) = self + .provider_registry + .as_ref() + .and_then(|registry| registry.upgrade()) + else { + return Ok(carrier_availability_error( + "carrier_provider_invocation_unavailable", + "Carrier availability fetch requires Runtime provider registry", + )); + }; + + let mut errors = Vec::new(); + for ticket in tickets { + match fetch_content_via_carrier_provider_invocation(®istry, &ticket, cid, path).await + { + Ok((bytes, remote_transfer)) => { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "availability": { + "status": "network_available", + "provider": "carrier-availability", + "policy": "carrier_provider_invoke", + "replicas": 1, + "transport": "carrier-provider-plane", + "remote_transfer": remote_transfer, + "checked_at": now_secs(), + } + } + })) + } + Err(err) => errors.push(err.to_string()), + } + } + + Ok(carrier_availability_error( + "carrier_fetch_failed", + format!("Carrier content fetch failed: {}", errors.join(" | ")), + )) + } +} + +async fn fetch_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + ticket: &str, + cid: &str, + path: &str, +) -> Result<(Vec, Option)> { + validate_content_cid(cid).map_err(anyhow::Error::msg)?; + validate_carrier_content_path(path).map_err(anyhow::Error::msg)?; + + let mut request = serde_json::json!({ + "op": "fetch", + "cid": cid, + "local_only": true, + "transfer": "stream", + }); + if !path.is_empty() { + request["path"] = serde_json::Value::String(path.to_string()); + } + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request, + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: ticket.to_string(), + peer_did: None, + timeout_ms: Some(5_000), + }), + }) + .await + .map_err(|err| anyhow::anyhow!("Carrier provider invocation failed: {err}"))?; + + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content provider fetch failed: {message}"); + } + let remote_transfer = response.get("_runtime_transfer").cloned(); + let bytes = remote_content_provider_response_bytes(&response)?; + Ok((bytes, remote_transfer)) +} + +async fn ensure_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, +) -> Result { + validate_content_cid(cid).map_err(anyhow::Error::msg)?; + let route = ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }; + let admission = + content_admission_via_carrier_provider_invocation(registry, &route, cid, source_request) + .await?; + let mut ensure_request = serde_json::json!({ + "op": "ensure", + "cid": cid, + "availability_policy": "carrier_replica", + "availability_requirements": { + "min_replicas": 1, + "max_replicas": 1, + "require_live_multi_peer_proof": false, + }, + }); + if let Some(object_did) = source_request + .get("object_did") + .and_then(|value| value.as_str()) + { + ensure_request["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + ensure_request["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + + let mut ensure_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "ensure".to_string(), + request: ensure_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route.clone()), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content ensure failed: {err}"))?; + if ensure_response + .get("status") + .and_then(|value| value.as_str()) + == Some("error") + { + let message = ensure_response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + ensure_response = import_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + Some(message), + ) + .await?; + } + let ensure_status = ensure_response + .get("data") + .and_then(|data| data.get("availability")) + .and_then(|availability| availability.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + if matches!( + ensure_status.as_str(), + "repair_needed" | "local_unpinned" | "unknown" + ) { + ensure_response = import_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + Some(&ensure_status), + ) + .await?; + } + let ensure_status = ensure_response + .get("data") + .and_then(|data| data.get("availability")) + .and_then(|availability| availability.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + if matches!( + ensure_status.as_str(), + "repair_needed" | "local_unpinned" | "unknown" + ) { + anyhow::bail!( + "remote content import/ensure did not prove a pinned replica: {ensure_status}" + ); + } + let remote_receipt = remote_content_receipt_summary(&ensure_response, cid)?; + + let status_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "status".to_string(), + request: serde_json::json!({ + "op": "status", + "cid": cid, + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content status failed: {err}"))?; + if status_response + .get("status") + .and_then(|value| value.as_str()) + == Some("error") + { + let message = status_response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content status returned error: {message}"); + } + let status_cid = status_response + .get("data") + .and_then(|data| data.get("cid")) + .and_then(|value| value.as_str()) + .unwrap_or(""); + if status_cid != cid { + anyhow::bail!("remote content status CID mismatch"); + } + let status_availability = status_response + .get("data") + .and_then(|data| data.get("availability")) + .cloned() + .ok_or_else(|| anyhow::anyhow!("remote content status missing availability"))?; + let status = status_availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + if matches!(status, "repair_needed" | "local_unpinned" | "unknown") { + anyhow::bail!("remote content status did not prove a live replica: {status}"); + } + + Ok(CarrierReplicationProof { + node_did: replica.node_did.clone(), + endpoint_id: replica.endpoint_id.clone(), + announced_at: replica.announced_at, + score: replica.score, + selection_reason: replica.selection_reason.clone(), + reputation_score: replica.reputation_score, + reputation_reason: replica.reputation_reason.clone(), + ensure_status, + admission: Some(admission), + status_availability, + remote_receipt, + transfer: status_response.get("_runtime_transfer").cloned(), + checked_at: now_secs(), + }) +} + +async fn content_admission_via_carrier_provider_invocation( + registry: &ProviderRegistry, + route: &ProviderCarrierRoute, + cid: &str, + source_request: &serde_json::Value, +) -> Result { + let mut admission_request = serde_json::json!({ + "op": "admission", + "cid": cid, + "availability_policy": "carrier_replica", + "availability_requirements": carrier_source_requirements_json(source_request), + }); + if let Some(estimated_content_bytes) = carrier_admission_estimated_content_bytes(source_request) + { + admission_request["estimated_content_bytes"] = + serde_json::Value::from(estimated_content_bytes); + } + if let Some(accounting) = source_request + .get("accounting") + .filter(|value| value.is_object()) + { + admission_request["accounting"] = accounting.clone(); + } + if let Some(object_did) = source_request + .get("object_did") + .and_then(|value| value.as_str()) + { + admission_request["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + admission_request["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "admission".to_string(), + request: admission_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route.clone()), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content admission failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content admission returned error: {message}"); + } + let mut admission = response + .get("data") + .and_then(|data| data.get("admission")) + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| anyhow::anyhow!("remote content admission missing admission receipt"))?; + let receipt_summary = remote_content_admission_receipt_summary(&response, &admission, cid)?; + if let Some(admission) = admission.as_object_mut() { + admission.insert("receipt".to_string(), receipt_summary); + } + if admission.get("accepted").and_then(|value| value.as_bool()) != Some(true) { + let status = admission + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("rejected"); + let reason = admission + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("remote content provider rejected admission"); + anyhow::bail!("remote content admission rejected: {status}: {reason}"); + } + Ok(admission) +} + +fn remote_content_admission_receipt_summary( + response: &serde_json::Value, + admission: &serde_json::Value, + cid: &str, +) -> Result { + let receipt = response + .get("data") + .and_then(|data| data.get("receipt")) + .filter(|value| value.is_object()) + .ok_or_else(|| anyhow::anyhow!("remote content admission missing signed receipt"))?; + let payload = receipt + .get("payload") + .ok_or_else(|| anyhow::anyhow!("remote content admission receipt missing payload"))?; + if payload != admission { + anyhow::bail!("remote content admission receipt payload mismatch"); + } + let payload_cid = payload + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if payload_cid != cid { + anyhow::bail!("remote content admission receipt CID mismatch"); + } + let signer_did = receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("remote content admission receipt missing signer_did"))?; + let receipt_bytes = serde_json::to_vec(receipt) + .map_err(|err| anyhow::anyhow!("remote content admission receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CONTENT_ADMISSION_DOMAIN, + &expected_signers, + ) + .map_err(|err| { + anyhow::anyhow!("remote content admission receipt verification failed: {err}") + })?; + Ok(serde_json::json!({ + "schema": payload + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "signer_did": signer_did, + "verified": true, + })) +} + +fn carrier_source_requirements_json(source_request: &serde_json::Value) -> serde_json::Value { + source_request + .get("requirements") + .or_else(|| source_request.get("availability_requirements")) + .or_else(|| source_request.get("replication_requirements")) + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + serde_json::json!({ + "min_replicas": 1, + "max_replicas": 1, + "require_live_multi_peer_proof": false, + }) + }) +} + +fn carrier_admission_estimated_content_bytes(source_request: &serde_json::Value) -> Option { + ["estimated_content_bytes", "incoming_content_bytes"] + .into_iter() + .find_map(|field| source_request.get(field).and_then(|value| value.as_u64())) + .or_else(|| { + source_request + .get("accounting") + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) + .or_else(|| { + source_request + .get("local") + .and_then(|local| local.get("accounting")) + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) +} + +fn remote_content_receipt_summary( + response: &serde_json::Value, + cid: &str, +) -> Result> { + let Some(receipt) = response.get("data").and_then(|data| data.get("receipt")) else { + return Ok(None); + }; + let payload = receipt + .get("payload") + .ok_or_else(|| anyhow::anyhow!("remote content receipt missing payload"))?; + let receipt_cid = payload + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if receipt_cid != cid { + anyhow::bail!("remote content receipt CID mismatch"); + } + let signer_did = receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("remote content receipt missing signer_did"))?; + let receipt_bytes = serde_json::to_vec(receipt) + .map_err(|err| anyhow::anyhow!("remote content receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + "elastos.content.availability.receipt.v1", + &expected_signers, + ) + .map_err(|err| anyhow::anyhow!("remote content receipt verification failed: {err}"))?; + Ok(Some(serde_json::json!({ + "schema": payload.get("schema").cloned().unwrap_or(serde_json::Value::Null), + "cid": receipt_cid, + "status": payload.get("status").cloned().unwrap_or(serde_json::Value::Null), + "provider": payload.get("provider").cloned().unwrap_or(serde_json::Value::Null), + "policy": payload.get("policy").cloned().unwrap_or(serde_json::Value::Null), + "replicas": payload.get("replicas").cloned().unwrap_or(serde_json::Value::Null), + "peer_selection": remote_content_receipt_peer_selection_summary(payload.get("peer_selection")), + "quota": remote_content_receipt_quota_summary(payload.get("quota")), + "repair_worker": remote_content_receipt_repair_worker_summary(payload.get("repair_worker")), + "repair_graph": remote_content_receipt_repair_graph_summary(payload.get("repair_graph")), + "storage_market": remote_content_receipt_storage_market_summary(payload.get("storage_market")), + "accounting": remote_content_receipt_accounting_summary(payload.get("accounting")), + "abuse_controls": remote_content_receipt_abuse_controls_summary(payload.get("abuse_controls")), + "checked_at": payload.get("checked_at").cloned().unwrap_or(serde_json::Value::Null), + "signer_did": signer_did, + "verified": true, + }))) +} + +fn remote_content_receipt_peer_selection_summary( + peer_selection: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(peer_selection) = peer_selection else { + return serde_json::json!({"mode": "unknown"}); + }; + let replicas = remote_content_receipt_peer_selection_replicas_summary(peer_selection); + let replica_count = peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .map(|replicas| replicas.len()) + .unwrap_or(0); + let remote_replicas = peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .map(|replicas| { + replicas + .iter() + .filter(|replica| { + replica.get("role").and_then(|value| value.as_str()) == Some("remote") + }) + .count() + }) + .unwrap_or(0); + serde_json::json!({ + "mode": peer_selection + .get("mode") + .cloned() + .unwrap_or(serde_json::Value::Null), + "strategy": peer_selection + .get("strategy") + .cloned() + .unwrap_or(serde_json::Value::Null), + "live_multi_peer_proof": peer_selection + .get("live_multi_peer_proof") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "peer_reputation_policy": peer_selection + .get("peer_reputation_policy") + .cloned() + .unwrap_or_else(default_carrier_peer_reputation_policy_json), + "peer_attestation_exchange_policy": peer_selection + .get("peer_attestation_exchange_policy") + .cloned() + .unwrap_or_else(default_carrier_peer_attestation_exchange_policy_json), + "replica_count": replica_count, + "remote_replicas": remote_replicas, + "replica_summary_limit": MAX_REMOTE_RECEIPT_REPLICA_SUMMARY_ROWS, + "replicas_truncated": replica_count > replicas.len(), + "replicas": replicas, + }) +} + +fn default_carrier_peer_reputation_policy_json() -> serde_json::Value { + serde_json::json!({ + "schema": CARRIER_PEER_REPUTATION_SCHEMA, + "policy": "not_reported", + "scope": "content-availability", + "status": "not_reported", + "federation": { + "configured": false, + "cross_runtime_reputation": false, + }, + }) +} + +fn default_carrier_peer_attestation_exchange_policy_json() -> serde_json::Value { + serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": "not_reported", + "scope": "content-availability", + "status": "not_reported", + "attestation_exchange": { + "configured": false, + "signed_reputation_receipts": false, + "third_party_attestations": false, + "cross_runtime_trust_policy": false, + }, + }) +} + +fn remote_content_receipt_peer_selection_replicas_summary( + peer_selection: &serde_json::Value, +) -> Vec { + peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .take(MAX_REMOTE_RECEIPT_REPLICA_SUMMARY_ROWS) + .map(|replica| { + serde_json::json!({ + "role": replica + .get("role") + .cloned() + .unwrap_or(serde_json::Value::Null), + "node_did": replica + .get("node_did") + .cloned() + .unwrap_or(serde_json::Value::Null), + "endpoint_id": replica + .get("endpoint_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + "score": replica + .get("score") + .cloned() + .unwrap_or(serde_json::Value::Null), + "selection_reason": replica + .get("selection_reason") + .cloned() + .unwrap_or(serde_json::Value::Null), + "local_reputation": replica + .get("local_reputation") + .cloned() + .unwrap_or(serde_json::Value::Null), + "status": replica + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + }) + }) + .collect() +} + +fn remote_content_receipt_quota_summary(quota: Option<&serde_json::Value>) -> serde_json::Value { + let Some(quota) = quota else { + return serde_json::json!({"policy": "unknown"}); + }; + serde_json::json!({ + "policy": quota.get("policy").cloned().unwrap_or(serde_json::Value::Null), + "status": quota.get("status").cloned().unwrap_or(serde_json::Value::Null), + "enforced": quota + .get("enforced") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "used_replicas": quota + .get("used_replicas") + .cloned() + .unwrap_or(serde_json::Value::Null), + "effective_max_replicas": quota + .get("effective_max_replicas") + .cloned() + .unwrap_or(serde_json::Value::Null), + "federated_quota_ledger_policy": quota + .get("federated_quota_ledger_policy") + .cloned() + .unwrap_or_else(default_carrier_federated_quota_ledger_policy_json), + }) +} + +fn remote_content_receipt_repair_worker_summary( + repair_worker: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(repair_worker) = repair_worker else { + return serde_json::json!({"status": "unknown"}); + }; + serde_json::json!({ + "scheduled": repair_worker + .get("scheduled") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "status": repair_worker + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + "worker": repair_worker + .get("worker") + .cloned() + .unwrap_or(serde_json::Value::Null), + }) +} + +fn remote_content_receipt_storage_market_summary( + storage_market: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(storage_market) = storage_market else { + return serde_json::json!({ + "schema": "elastos.content.storage-market/v1", + "status": "not_reported", + "settlement": "not_configured", + "admission_policy": default_carrier_storage_market_admission_policy_json(), + }); + }; + serde_json::json!({ + "schema": storage_market + .get("schema") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("elastos.content.storage-market/v1".to_string())), + "mode": storage_market + .get("mode") + .cloned() + .unwrap_or(serde_json::Value::Null), + "status": storage_market + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + "settlement": storage_market + .get("settlement") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("not_configured".to_string())), + "escrow": storage_market + .get("escrow") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("not_configured".to_string())), + "quota_enforced": storage_market + .get("quota_enforced") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "admission_policy": storage_market + .get("admission_policy") + .cloned() + .unwrap_or_else(default_carrier_storage_market_admission_policy_json), + "settlement_policy": storage_market + .get("settlement_policy") + .cloned() + .unwrap_or_else(default_carrier_storage_settlement_policy_json), + }) +} + +fn carrier_storage_market_admission_policy_json( + mode: &str, + market_status: &str, + quota_enforced: bool, + live_multi_peer_proof: bool, + remote_admission_preflight: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA, + "policy": "proof_path_admission_no_production_market", + "scope": "content-availability", + "status": if remote_admission_preflight { + "remote_admission_preflight_no_market_admission" + } else if quota_enforced { + "local_quota_admission_no_market_admission" + } else { + "production_storage_market_admission_not_configured" + }, + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + "live_multi_peer_proof": live_multi_peer_proof, + }, + "current_admission": { + "local_principal_quota_ledger": quota_enforced, + "remote_content_admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "content_admission_schema": "elastos.content.admission/v1", + "content_admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + "provider_invocation_required": true, + "signed_availability_receipts": true, + }, + "production_market": { + "configured": false, + "provider_admission_network": false, + "provider_offer_receipts": false, + "price_discovery": false, + "sla_admission": false, + "abuse_economic_controls": false, + "reason": "Carrier verifies signed remote content/admission receipts in this branch; production storage-market admission needs provider offers, pricing, SLA, and trust policy receipts", + }, + }) +} + +fn default_carrier_storage_market_admission_policy_json() -> serde_json::Value { + carrier_storage_market_admission_policy_json( + "not_reported", + "not_reported", + false, + false, + false, + ) +} + +fn carrier_storage_settlement_policy_json( + mode: &str, + market_status: &str, + quota_enforced: bool, + live_multi_peer_proof: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.content.storage-settlement-policy/v1", + "policy": "no_settlement_receipt_policy", + "scope": "content-availability", + "status": "settlement_not_configured", + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + "live_multi_peer_proof": live_multi_peer_proof, + }, + "settlement": { + "pricing": "not_configured", + "escrow": "not_configured", + "payment_settlement": "not_configured", + "sla_enforcement": "not_configured", + }, + "production_federation": { + "configured": false, + "storage_market_admission": false, + "cross_provider_escrow": false, + "settlement_receipts": false, + "reason": "Carrier can prove provider replicas in this branch; pricing, escrow, settlement, and SLA policy require production storage-market providers", + }, + }) +} + +fn default_carrier_storage_settlement_policy_json() -> serde_json::Value { + carrier_storage_settlement_policy_json("not_reported", "not_reported", false, false) +} + +fn remote_content_receipt_repair_graph_summary( + repair_graph: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(repair_graph) = repair_graph else { + return serde_json::json!({ + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "status": "not_reported", + }); + }; + serde_json::json!({ + "schema": repair_graph + .get("schema") + .cloned() + .unwrap_or_else(|| serde_json::Value::String(CONTENT_REPAIR_GRAPH_SCHEMA.to_string())), + "policy": repair_graph + .get("policy") + .cloned() + .unwrap_or(serde_json::Value::Null), + "requested_kind": repair_graph + .get("requested_kind") + .cloned() + .unwrap_or(serde_json::Value::Null), + "status": repair_graph + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + "refuses_exact_fallback_for_arbitrary_dag": repair_graph + .get("refuses_exact_fallback_for_arbitrary_dag") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + }) +} + +fn remote_content_receipt_accounting_summary( + accounting: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(accounting) = accounting else { + return serde_json::json!({"observed": false}); + }; + serde_json::json!({ + "schema": accounting + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "observed": accounting + .get("observed") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "files": accounting + .get("files") + .cloned() + .unwrap_or(serde_json::Value::Null), + "content_bytes": accounting + .get("content_bytes") + .cloned() + .unwrap_or(serde_json::Value::Null), + "replica_bytes_estimate": accounting + .get("replica_bytes_estimate") + .cloned() + .unwrap_or(serde_json::Value::Null), + "storage_quota_status": accounting + .get("storage_quota") + .and_then(|quota| quota.get("status")) + .cloned() + .unwrap_or(serde_json::Value::Null), + }) +} + +fn remote_content_receipt_abuse_controls_summary( + abuse_controls: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(abuse_controls) = abuse_controls else { + return serde_json::json!({"policy": "unknown", "enforced": false}); + }; + serde_json::json!({ + "schema": abuse_controls + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "policy": abuse_controls + .get("policy") + .cloned() + .unwrap_or(serde_json::Value::Null), + "enforced": abuse_controls + .get("enforced") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "candidate_count": abuse_controls + .get("candidate_count") + .cloned() + .unwrap_or(serde_json::Value::Null), + "attempt_limit": abuse_controls + .get("attempt_limit") + .cloned() + .unwrap_or(serde_json::Value::Null), + "attempted_operations": abuse_controls + .get("attempted_operations") + .cloned() + .unwrap_or(serde_json::Value::Null), + "failed_operations": abuse_controls + .get("failed_operations") + .cloned() + .unwrap_or(serde_json::Value::Null), + "throttled": abuse_controls + .get("throttled") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + }) +} + +async fn import_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, +) -> Result { + let requirements = CarrierAvailabilityRequirements::from_request(source_request); + if !requirements + .repair_graph_kind + .supports_current_import_fallback() + { + return import_ipld_dag_content_via_carrier_block_graph_provider( + registry, + replica, + cid, + source_request, + ensure_failure, + requirements, + ) + .await; + } + match import_object_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + ensure_failure, + ) + .await + { + Ok(response) => Ok(response), + Err(object_err) => import_exact_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + ensure_failure, + ) + .await + .map_err(|exact_err| { + anyhow::anyhow!( + "remote content object import failed: {object_err}; exact import fallback failed: {exact_err}" + ) + }), + } +} + +async fn import_ipld_dag_content_via_carrier_block_graph_provider( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, + requirements: CarrierAvailabilityRequirements, +) -> Result { + let mut export_request = serde_json::json!({ + "op": "export_graph", + "cid": cid, + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "repair_graph_kind": requirements.repair_graph_kind.as_str(), + "availability_requirements": requirements.to_json(), + "policy": "carrier_block_graph_repair", + }); + copy_optional_content_identity(source_request, &mut export_request); + + let export_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: CONTENT_BLOCK_GRAPH_TARGET.to_string(), + op: "export_graph".to_string(), + request: export_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| { + anyhow::anyhow!( + "local block-graph export failed for arbitrary DAG repair: {err}; Carrier refused object/exact fallback" + ) + })?; + ensure_provider_ok(&export_response, "local block-graph export")?; + let graph = exported_block_graph(&export_response, cid)?; + + let route = ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }; + let mut import_request = serde_json::json!({ + "op": "import_graph", + "cid": cid, + "graph": graph, + "availability_policy": "carrier_block_graph_import", + "availability_requirements": requirements.to_json(), + "ensure_failure": ensure_failure, + }); + copy_optional_content_identity(source_request, &mut import_request); + + let import_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: CONTENT_BLOCK_GRAPH_TARGET.to_string(), + op: "import_graph".to_string(), + request: import_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route), + }) + .await + .map_err(|err| anyhow::anyhow!("remote block-graph import failed: {err}"))?; + ensure_provider_ok(&import_response, "remote block-graph import")?; + + let mut ensure_request = serde_json::json!({ + "op": "ensure", + "cid": cid, + "availability_policy": "carrier_block_graph_import", + "availability_requirements": requirements.to_json(), + }); + copy_optional_content_identity(source_request, &mut ensure_request); + let route = ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }; + let ensure_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "ensure".to_string(), + request: ensure_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route), + }) + .await + .map_err(|err| { + anyhow::anyhow!("remote content ensure after block-graph import failed: {err}") + })?; + ensure_provider_ok( + &ensure_response, + "remote content ensure after block-graph import", + )?; + Ok(ensure_response) +} + +fn ensure_provider_ok(response: &serde_json::Value, label: &str) -> Result<()> { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("{label} returned error: {message}"); + } + Ok(()) +} + +fn exported_block_graph(response: &serde_json::Value, cid: &str) -> Result { + let graph = response + .get("data") + .and_then(|data| data.get("graph")) + .cloned() + .ok_or_else(|| anyhow::anyhow!("local block-graph export missing data.graph"))?; + if graph.get("schema").and_then(|value| value.as_str()) != Some(CONTENT_BLOCK_GRAPH_SCHEMA) { + anyhow::bail!("local block-graph export returned unsupported graph schema"); + } + let root_cid = graph + .get("root_cid") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if root_cid != cid { + anyhow::bail!("local block-graph export root CID mismatch"); + } + Ok(graph) +} + +fn copy_optional_content_identity(source: &serde_json::Value, target: &mut serde_json::Value) { + for key in ["object_did", "publisher_did"] { + if let Some(value) = source.get(key).cloned() { + target[key] = value; + } + } +} + +async fn local_content_fetch_bytes_for_import( + registry: &ProviderRegistry, + cid: &str, + path: Option<&str>, +) -> Result> { + let mut request = serde_json::json!({ + "op": "fetch", + "cid": cid, + "local_only": true, + "transfer": "stream", + }); + if let Some(path) = path.filter(|path| !path.is_empty()) { + request["path"] = serde_json::Value::String(path.to_string()); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request, + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("local content fetch for object import failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("local content fetch for object import returned error: {message}"); + } + remote_content_provider_response_bytes(&response) +} + +async fn import_object_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, +) -> Result { + let manifest_bytes = + local_content_fetch_bytes_for_import(registry, cid, Some(CONTENT_OBJECT_MANIFEST_PATH)) + .await?; + let manifest: ContentObjectManifest = + serde_json::from_slice(&manifest_bytes).map_err(|err| { + anyhow::anyhow!("local content object manifest decode failed for {cid}: {err}") + })?; + if manifest.files.is_empty() { + anyhow::bail!("local content object manifest has no files"); + } + if manifest.files.len() > MAX_CARRIER_OBJECT_IMPORT_FILES { + anyhow::bail!( + "local content object manifest exceeds {} files", + MAX_CARRIER_OBJECT_IMPORT_FILES + ); + } + let mut files = Vec::with_capacity(manifest.files.len()); + let mut total_bytes = 0_usize; + for file in &manifest.files { + let bytes = local_content_fetch_bytes_for_import(registry, cid, Some(&file.path)).await?; + if bytes.len() as u64 != file.size { + anyhow::bail!( + "local content object file {} size mismatch: manifest {}, fetched {}", + file.path, + file.size, + bytes.len() + ); + } + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + if sha256 != file.sha256 { + anyhow::bail!( + "local content object file {} digest mismatch: manifest {}, fetched {}", + file.path, + file.sha256, + sha256 + ); + } + total_bytes = total_bytes.saturating_add(bytes.len()); + if total_bytes > MAX_CARRIER_OBJECT_IMPORT_BYTES { + anyhow::bail!( + "local content object import exceeds {} bytes", + MAX_CARRIER_OBJECT_IMPORT_BYTES + ); + } + files.push(serde_json::json!({ + "path": file.path.clone(), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })); + } + let file_count = files.len(); + let mut import_request = serde_json::json!({ + "op": "import_object", + "cid": cid, + "object_kind": manifest.kind.clone(), + "files": files, + }); + if let Some(reason) = ensure_failure { + import_request["ensure_failure"] = serde_json::Value::String(reason.to_string()); + } + if !manifest.links.is_empty() { + import_request["links"] = serde_json::to_value(&manifest.links) + .map_err(|err| anyhow::anyhow!("content object links encode failed: {err}"))?; + } + if let Some(object_did) = manifest.object_did.or_else(|| { + source_request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) { + import_request["object_did"] = serde_json::Value::String(object_did); + } + if let Some(publisher_did) = manifest.publisher_did.or_else(|| { + source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) { + import_request["publisher_did"] = serde_json::Value::String(publisher_did); + } + import_request["import_summary"] = serde_json::json!({ + "schema": "elastos.content.import-object.request-summary/v1", + "files": file_count, + "bytes": total_bytes, + "source": "local-object-manifest", + }); + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "import_object".to_string(), + request: import_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content object import failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content object import returned error: {message}"); + } + Ok(response) +} + +async fn import_exact_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, +) -> Result { + let local_fetch = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ + "op": "fetch", + "cid": cid, + "local_only": true, + "transfer": "stream", + }), + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("local content fetch for exact import failed: {err}"))?; + if local_fetch.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = local_fetch + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("local content fetch for exact import returned error: {message}"); + } + let stream = local_fetch + .get("data") + .and_then(|data| data.get("stream")) + .cloned() + .ok_or_else(|| anyhow::anyhow!("local content fetch missing stream payload"))?; + let mut import_request = serde_json::json!({ + "op": "import_exact", + "cid": cid, + "stream": stream, + "filename": "content.bin", + }); + if let Some(reason) = ensure_failure { + import_request["ensure_failure"] = serde_json::Value::String(reason.to_string()); + } + if let Some(object_did) = source_request + .get("object_did") + .and_then(|value| value.as_str()) + { + import_request["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + import_request["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "import_exact".to_string(), + request: import_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content exact import failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content exact import returned error: {message}"); + } + Ok(response) +} + +fn remote_content_provider_response_bytes(response: &serde_json::Value) -> Result> { + let data = response + .get("data") + .and_then(|data| data.as_object()) + .ok_or_else(|| anyhow::anyhow!("remote content provider response missing data"))?; + if let Some(stream) = data.get("stream") { + return decode_carrier_provider_stream_payload(stream); + } + let data_value = data + .get("data") + .ok_or_else(|| anyhow::anyhow!("remote content provider response missing data"))?; + let encoded = data_value + .as_str() + .or_else(|| data_value.get("data").and_then(|value| value.as_str())) + .ok_or_else(|| anyhow::anyhow!("remote content provider response missing base64 data"))?; + base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| anyhow::anyhow!("remote content provider returned invalid base64: {err}")) +} + +fn decode_carrier_provider_stream_payload(stream: &serde_json::Value) -> Result> { + let object = stream + .as_object() + .ok_or_else(|| anyhow::anyhow!("remote content provider stream must be an object"))?; + let schema = object + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != "elastos.provider.stream/v1" { + anyhow::bail!( + "remote content provider stream schema mismatch: expected elastos.provider.stream/v1, got {schema}" + ); + } + let encoding = object + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != "base64-chunks" { + anyhow::bail!( + "remote content provider stream encoding mismatch: expected base64-chunks, got {encoding}" + ); + } + let chunks = object + .get("chunks") + .and_then(|value| value.as_array()) + .ok_or_else(|| anyhow::anyhow!("remote content provider stream missing chunks"))?; + let mut bytes = Vec::new(); + for (expected_index, chunk) in chunks.iter().enumerate() { + let chunk = chunk.as_object().ok_or_else(|| { + anyhow::anyhow!("remote content provider stream chunk must be an object") + })?; + let index = chunk + .get("index") + .and_then(|value| value.as_u64()) + .ok_or_else(|| anyhow::anyhow!("remote content provider stream chunk missing index"))?; + if index != expected_index as u64 { + anyhow::bail!( + "remote content provider stream chunk index mismatch: expected {expected_index}, got {index}" + ); + } + let offset = chunk + .get("offset") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + anyhow::anyhow!("remote content provider stream chunk missing offset") + })?; + if offset != bytes.len() as u64 { + anyhow::bail!( + "remote content provider stream chunk {index} offset mismatch: expected {}, got {offset}", + bytes.len() + ); + } + let encoded = chunk + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow::anyhow!("remote content provider stream chunk missing data"))?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + anyhow::anyhow!("remote content provider stream chunk has invalid base64: {err}") + })?; + if let Some(length) = chunk.get("length").and_then(|value| value.as_u64()) { + if length != decoded.len() as u64 { + anyhow::bail!( + "remote content provider stream chunk {index} length {length} does not match decoded length {}", + decoded.len() + ); + } + } + bytes.extend_from_slice(&decoded); + } + if let Some(total_bytes) = object.get("total_bytes").and_then(|value| value.as_u64()) { + if total_bytes != bytes.len() as u64 { + anyhow::bail!( + "remote content provider stream total_bytes {total_bytes} does not match decoded length {}", + bytes.len() + ); + } + } + Ok(bytes) +} + +fn carrier_connect_ticket(endpoint: &Endpoint) -> String { + let mut watcher = endpoint.watch_addr(); + let addr = watcher.get(); + let ticket_json = serde_json::json!({ + "topic": null, + "endpoints": [addr], + }); + let ticket_bytes = serde_json::to_vec(&ticket_json).unwrap_or_default(); + let mut ticket_str = data_encoding::BASE32_NOPAD.encode(&ticket_bytes); + ticket_str.make_ascii_lowercase(); + ticket_str +} + +fn carrier_peer_selection_json( + topic_uri: &str, + local_node_did: &str, + local_replicas: u32, + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, + peer_attestation_exchange: CarrierPeerAttestationExchangeView<'_>, +) -> serde_json::Value { + let mut replicas = Vec::new(); + if local_replicas > 0 { + replicas.push(serde_json::json!({ + "role": "local", + "node_did": local_node_did, + "status": "local_pinned", + })); + } + replicas.extend(remote_proofs.iter().map(|proof| { + serde_json::json!({ + "role": "remote", + "node_did": proof.node_did.clone(), + "endpoint_id": proof.endpoint_id.clone(), + "announced_at": proof.announced_at, + "score": proof.score, + "selection_reason": proof.selection_reason.clone(), + "local_reputation": { + "scope": "local_runtime", + "score_delta": proof.reputation_score, + "reason": proof.reputation_reason.clone(), + }, + "admission": proof.admission.clone(), + "ensure_status": proof.ensure_status.clone(), + "status": proof + .status_availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + "remote_receipt": proof.remote_receipt.clone(), + "transfer": proof.transfer.clone(), + "checked_at": proof.checked_at, + }) + })); + serde_json::json!({ + "mode": if live_multi_peer_proof { + "carrier_provider_replication" + } else { + "carrier_topic" + }, + "strategy": "signed_announcement_then_provider_invoke", + "topic": topic_uri, + "live_multi_peer_proof": live_multi_peer_proof, + "peer_reputation_policy": carrier_peer_reputation_policy_json( + remote_proofs, + live_multi_peer_proof, + ), + "peer_attestation_exchange_policy": carrier_peer_attestation_exchange_policy_json( + remote_proofs, + live_multi_peer_proof, + peer_attestation_exchange, + ), + "replicas": replicas, + }) +} + +fn carrier_peer_reputation_policy_json( + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, +) -> serde_json::Value { + let scored_remote_peers = remote_proofs + .iter() + .filter(|proof| proof.reputation_reason != "no_local_history") + .count(); + serde_json::json!({ + "schema": CARRIER_PEER_REPUTATION_SCHEMA, + "policy": "local_runtime_reputation", + "scope": "content-availability", + "status": if scored_remote_peers > 0 { + "local_history_applied" + } else if live_multi_peer_proof { + "live_peer_proof_without_local_history" + } else { + "no_remote_peer_proof" + }, + "local_runtime": { + "used_for_candidate_score": true, + "history_store": "carrier-peer-reputation.json", + "scored_remote_peers": scored_remote_peers, + "max_positive_score_delta": 20, + "max_negative_score_delta": -30, + }, + "federation": { + "configured": false, + "cross_runtime_reputation": false, + "signed_reputation_receipts": false, + "third_party_attestations": false, + "reason": "this branch uses local Runtime success/failure history only; federated peer reputation needs signed cross-provider reputation receipts and trust policy", + }, + }) +} + +fn carrier_peer_attestation_exchange_policy_json( + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, + exchange: CarrierPeerAttestationExchangeView<'_>, +) -> serde_json::Value { + let remote_provider_proofs = remote_proofs.len(); + let verified_remote_content_receipts = remote_proofs + .iter() + .filter(|proof| { + proof + .remote_receipt + .as_ref() + .and_then(|receipt| receipt.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }) + .count(); + let exchange_status = exchange + .receipt + .and_then(|receipt| receipt.get("status")) + .and_then(|value| value.as_str()); + let exchange_accepted = exchange + .receipt + .and_then(|receipt| receipt.get("accepted")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": if exchange.configured { + "configured_peer_attestation_exchange" + } else { + "no_cross_runtime_attestation_exchange" + }, + "scope": "content-availability", + "status": if exchange_accepted { + "attestation_exchange_accepted" + } else if exchange.configured && exchange.receipt.is_some() { + exchange_status.unwrap_or("attestation_exchange_failed") + } else if exchange.configured && live_multi_peer_proof { + "attestation_exchange_configured_without_receipt" + } else if exchange.configured { + "attestation_exchange_configured_no_remote_peer_proof" + } else if live_multi_peer_proof { + "live_peer_proof_without_attestation_exchange" + } else { + "no_remote_peer_proof" + }, + "local_proof": { + "signed_availability_announcements": true, + "verified_remote_content_receipts": verified_remote_content_receipts, + "remote_provider_proofs": remote_provider_proofs, + "local_runtime_reputation": true, + "peer_reputation_schema": CARRIER_PEER_REPUTATION_SCHEMA, + }, + "attestation_exchange": { + "configured": exchange.configured, + "signed_reputation_receipts": exchange_accepted, + "third_party_attestations": false, + "cross_runtime_trust_policy": if exchange.configured { + "configured_endpoint" + } else { + "not_configured" + }, + "revocation": "not_configured", + "receipt": exchange.receipt.cloned().unwrap_or(serde_json::Value::Null), + "reason": if exchange_accepted { + "configured peer-attestation exchange accepted a signed Carrier proof receipt" + } else if exchange.configured { + "a peer-attestation exchange endpoint is configured, but this proof has no accepted signed exchange receipt" + } else { + "this branch verifies signed availability announcements and remote content receipts only; signed cross-runtime reputation attestations need a federated trust policy and receipt exchange" + }, + }, + }) +} + +fn carrier_quota_json( + requirements: CarrierAvailabilityRequirements, + replicas: u32, + desired_replicas: u32, +) -> serde_json::Value { + let effective_max_replicas = requirements.effective_max(); + let requirements_exceed_quota = requirements.min_replicas > effective_max_replicas; + let quota_status = if requirements_exceed_quota { + "requirements_exceed_quota" + } else if replicas >= effective_max_replicas { + "at_quota" + } else { + "within_quota" + }; + serde_json::json!({ + "policy": "carrier_provider_quota", + "scope": "content-availability", + "enforced": true, + "status": quota_status, + "min_replicas": requirements.min_replicas, + "desired_replicas": desired_replicas, + "max_replicas": requirements.max_replicas.unwrap_or(MAX_CARRIER_REPLICATION_CANDIDATES as u32 + 1), + "effective_max_replicas": effective_max_replicas, + "used_replicas": replicas, + "candidate_limit": MAX_CARRIER_REPLICATION_CANDIDATES, + "requirements_exceed_quota": requirements_exceed_quota, + "requirements": requirements.to_json(), + "federated_quota_ledger_policy": carrier_federated_quota_ledger_policy_json( + "carrier_provider_quota", + quota_status, + true, + true, + ), + }) +} + +fn carrier_federated_quota_ledger_policy_json( + mode: &str, + quota_status: &str, + local_principal_ledger: bool, + remote_admission_preflight: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "local_principal_ledger_plus_remote_admission_preflight", + "scope": "content-availability", + "status": "federated_quota_ledger_not_configured", + "quota": { + "mode": mode, + "status": quota_status, + "enforced": true, + }, + "local": { + "principal_storage_ledger": local_principal_ledger, + "ledger_schema": "elastos.content.storage-accounting.ledger/v1", + }, + "remote": { + "admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "admission_schema": "elastos.content.admission/v1", + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": false, + "cross_provider_quota_ledger": false, + "storage_admission_network": false, + "signed_admission_receipt_exchange": remote_admission_preflight, + "quota_receipt_exchange": false, + "production_quota_receipt_exchange": false, + "reason": if remote_admission_preflight { + "Carrier verifies signed remote content/admission receipts for this proof path; federated quota ledgers and production storage-admission networks remain unconfigured" + } else { + "Carrier local quota exists, but remote signed admission and federated quota ledgers are not configured for this path" + }, + }, + }) +} + +fn default_carrier_federated_quota_ledger_policy_json() -> serde_json::Value { + carrier_federated_quota_ledger_policy_json("not_reported", "not_reported", false, false) +} + +fn carrier_repair_worker_json(scheduled: bool, availability_status: &str) -> serde_json::Value { + serde_json::json!({ + "scheduled": scheduled, + "status": if scheduled { "queued" } else { "healthy" }, + "worker": "carrier-availability", + "reason": if scheduled { + format!("availability status is {availability_status}") + } else { + "replica requirements satisfied".to_string() + }, + }) +} + +fn carrier_repair_graph_policy_json( + requirements: CarrierAvailabilityRequirements, +) -> serde_json::Value { + let current_modes = ["object_manifest", "exact_bytes"]; + let requested_kind = requirements.repair_graph_kind.as_str(); + let supported = requirements + .repair_graph_kind + .supports_current_import_fallback(); + serde_json::json!({ + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "policy": "carrier_provider_bounded_graph_repair", + "requested_kind": requested_kind, + "status": if supported { + "bounded_import_supported" + } else { + "unsupported_without_block_graph_provider" + }, + "supported_import_fallbacks": current_modes, + "refuses_exact_fallback_for_arbitrary_dag": true, + "block_graph_contract": { + "provider": CONTENT_BLOCK_GRAPH_PROVIDER, + "target": CONTENT_BLOCK_GRAPH_TARGET, + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "operations": ["export_graph", "import_graph", "status"] + }, + "requires_provider": if supported { + serde_json::Value::Null + } else { + serde_json::Value::String(CONTENT_BLOCK_GRAPH_PROVIDER.to_string()) + }, + }) +} + +fn carrier_storage_market_policy_json( + replicas: u32, + live_multi_peer_proof: bool, +) -> serde_json::Value { + let status = if live_multi_peer_proof { + "receipt_proven_no_market_settlement" + } else { + "local_or_announced_no_market_settlement" + }; + serde_json::json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "carrier_provider_receipts", + "status": status, + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": true, + "replicas": replicas, + "live_multi_peer_proof": live_multi_peer_proof, + "remote_admission_preflight": live_multi_peer_proof, + "admission_policy": carrier_storage_market_admission_policy_json( + "carrier_provider_receipts", + status, + true, + live_multi_peer_proof, + live_multi_peer_proof, + ), + "settlement_policy": carrier_storage_settlement_policy_json( + "carrier_provider_receipts", + status, + true, + live_multi_peer_proof, + ), + "next": "Production storage markets need pricing, escrow/settlement, storage-market admission, and cross-peer SLA policy before enabling." + }) +} + +fn carrier_abuse_controls_json( + candidate_count: usize, + attempt_limit: usize, + attempted_operations: u32, + failed_operations: u32, + candidate_limit_applied: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.content.abuse-controls/v1", + "policy": "carrier_provider_invocation_guardrail", + "scope": "content-availability", + "enforced": true, + "candidate_limit": MAX_CARRIER_REPLICATION_CANDIDATES, + "candidate_count": candidate_count, + "attempt_limit": attempt_limit, + "attempted_operations": attempted_operations, + "failed_operations": failed_operations, + "throttled": candidate_limit_applied, + "reason": if candidate_limit_applied { + "candidate attempt limit applied" + } else { + "candidate attempts within bounded provider-invocation budget" + }, + }) +} + +fn carrier_remote_candidate_limit( + requirements: CarrierAvailabilityRequirements, + local_replicas: u32, + desired_replicas: u32, +) -> usize { + let replica_shortfall = desired_replicas.saturating_sub(local_replicas); + let remaining_quota = requirements.effective_max().saturating_sub(local_replicas); + let live_remote_required = + u32::from(requirements.require_live_multi_peer_proof && remaining_quota > 0); + replica_shortfall + .max(live_remote_required) + .min(remaining_quota) + .min(MAX_CARRIER_REPLICATION_CANDIDATES as u32) as usize +} + +fn carrier_repair_reason( + requirements: CarrierAvailabilityRequirements, + replicas: u32, + live_multi_peer_proof: bool, + errors: &[String], +) -> String { + let mut reasons = Vec::new(); + if replicas < requirements.min_replicas { + reasons.push(format!( + "only {replicas} replica(s) proven; {} required", + requirements.min_replicas + )); + } + if requirements.require_live_multi_peer_proof && !live_multi_peer_proof { + reasons.push( + "live multi-peer proof is required but no independent remote replica was proven" + .to_string(), + ); + } + if !errors.is_empty() { + reasons.push(format!("replication errors: {}", errors.join(" | "))); + } + if reasons.is_empty() { + "Carrier availability repair is required".to_string() + } else { + reasons.join("; ") + } +} + +fn content_availability_replicas( + messages: &[GossipMessage], + cid: &str, +) -> Vec { + content_availability_replicas_with_reputation(messages, cid, &HashMap::new()) +} + +fn content_availability_replicas_with_reputation( + messages: &[GossipMessage], + cid: &str, + reputation: &HashMap, +) -> Vec { + let mut replicas = Vec::new(); + for message in messages { + let Ok((envelope, signer_did)) = crate::crypto::verify_signed_json_envelope_against_dids( + message.content.as_bytes(), + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + &[], + ) else { + continue; + }; + let Some(payload) = envelope.get("payload") else { + continue; + }; + if payload.get("schema").and_then(|value| value.as_str()) + != Some(CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA) + { + continue; + } + if payload.get("cid").and_then(|value| value.as_str()) != Some(cid) { + continue; + } + if payload.get("node_did").and_then(|value| value.as_str()) != Some(signer_did.as_str()) { + continue; + } + let Some(ticket) = payload + .get("fetch") + .and_then(|value| value.get("connect_ticket")) + .and_then(|value| value.as_str()) + .filter(|value| { + let value = value.trim(); + !value.is_empty() && value.len() <= MAX_CARRIER_AVAILABILITY_TICKET_LEN + }) + else { + continue; + }; + let raw_endpoint_id = payload + .get("fetch") + .and_then(|value| value.get("endpoint_id")) + .and_then(|value| value.as_str()); + if raw_endpoint_id + .map(|value| value.len() > MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN) + .unwrap_or(false) + { + continue; + } + let endpoint_id = raw_endpoint_id + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + let node_did = signer_did.to_string(); + if replicas + .iter() + .any(|replica: &CarrierAvailabilityReplica| replica.node_did == node_did) + { + continue; + } + let announced_at = payload + .get("announced_at") + .and_then(|value| value.as_u64()) + .unwrap_or(message.ts); + let (score, selection_reason, reputation_score, reputation_reason) = + carrier_replica_candidate_score( + endpoint_id.as_deref(), + announced_at, + message.ts, + reputation.get(&node_did), + ); + replicas.push(CarrierAvailabilityReplica { + node_did, + endpoint_id, + connect_ticket: ticket.to_string(), + announced_at, + score, + selection_reason, + reputation_score, + reputation_reason, + }); + } + replicas.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| b.announced_at.cmp(&a.announced_at)) + .then_with(|| a.node_did.cmp(&b.node_did)) + }); + replicas +} + +fn carrier_replica_candidate_score( + endpoint_id: Option<&str>, + announced_at: u64, + message_ts: u64, + reputation: Option<&CarrierPeerReputation>, +) -> (u32, String, i32, String) { + let mut score = 50_u32; + let mut reasons = vec!["signed_announcement"]; + if endpoint_id + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + score = score.saturating_add(20); + reasons.push("endpoint_advertised"); + } + if announced_at >= message_ts.saturating_sub(60 * 60) { + score = score.saturating_add(20); + reasons.push("fresh"); + } else { + reasons.push("stale"); + } + let (reputation_score, reputation_reason) = carrier_reputation_score(reputation); + if reputation_score > 0 { + score = score.saturating_add(reputation_score as u32); + reasons.push("local_reputation_positive"); + } else if reputation_score < 0 { + score = score.saturating_sub(reputation_score.unsigned_abs()); + reasons.push("local_reputation_negative"); + } else { + reasons.push("local_reputation_neutral"); + } + ( + score.min(100), + reasons.join("+"), + reputation_score, + reputation_reason, + ) +} + +fn carrier_reputation_score(reputation: Option<&CarrierPeerReputation>) -> (i32, String) { + let Some(reputation) = reputation else { + return (0, "no_local_history".to_string()); + }; + let successes = reputation.successes.min(5) as i32; + let failures = reputation.failures.min(5) as i32; + let score = (successes * 4 - failures * 8).clamp(-30, 20); + ( + score, + format!( + "local_runtime_successes:{};failures:{}", + reputation.successes, reputation.failures + ), + ) +} + +fn carrier_peer_reputation_path(data_dir: &std::path::Path) -> PathBuf { + data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("carrier-peer-reputation.json") +} + +fn load_carrier_peer_reputation( + data_dir: &std::path::Path, +) -> HashMap { + let path = carrier_peer_reputation_path(data_dir); + let Ok(bytes) = std::fs::read(&path) else { + return HashMap::new(); + }; + let Ok(store) = serde_json::from_slice::(&bytes) else { + tracing::debug!("carrier peer reputation decode failed: {}", path.display()); + return HashMap::new(); + }; + if store.schema != CARRIER_PEER_REPUTATION_SCHEMA { + tracing::debug!( + "carrier peer reputation schema mismatch at {}: {}", + path.display(), + store.schema + ); + return HashMap::new(); + } + store.peers.into_iter().collect() +} + +fn save_carrier_peer_reputation( + data_dir: &std::path::Path, + reputation: &HashMap, +) -> Result<()> { + let path = carrier_peer_reputation_path(data_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let store = CarrierPeerReputationStore { + schema: CARRIER_PEER_REPUTATION_SCHEMA.to_string(), + peers: reputation + .iter() + .map(|(node_did, reputation)| (node_did.clone(), reputation.clone())) + .collect(), + }; + let bytes = serde_json::to_vec_pretty(&store)?; + std::fs::write(path, bytes)?; + Ok(()) +} + +fn content_availability_fetch_tickets(messages: &[GossipMessage], cid: &str) -> Vec { + content_availability_replicas(messages, cid) + .into_iter() + .map(|replica| replica.connect_ticket) + .collect() +} + +// ── Gossip Provider (implements Provider trait) ────────────────── + +/// In-process gossip provider for `elastos://peer/*`. +/// Replaces the separate peer-provider subprocess. +pub struct CarrierGossipProvider { + state: Arc>, +} + +impl CarrierGossipProvider { + pub fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl std::fmt::Debug for CarrierGossipProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CarrierGossipProvider").finish() + } +} + +#[async_trait::async_trait] +impl Provider for CarrierGossipProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "use send_raw for peer operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["peer"] + } + fn name(&self) -> &'static str { + "carrier-gossip" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + let op = request.get("op").and_then(|v| v.as_str()).unwrap_or(""); + let mut state = self.state.lock().await; + + match op { + "init" => { + let id = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + Ok(serde_json::json!({"status": "ok", "data": {"node_id": id}})) + } + + "gossip_join" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + let force_direct = request + .get("mode") + .and_then(|value| value.as_str()) + .map(|mode| mode.eq_ignore_ascii_case("direct")) + .unwrap_or(false); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + if state.joined_topics.contains(topic_name) { + return Ok( + serde_json::json!({"status":"error","code":"already_joined","message":"already joined"}), + ); + } + if state.joined_topics.len() >= MAX_TOPICS { + return Ok( + serde_json::json!({"status":"error","code":"too_many_topics","message":"topic limit reached"}), + ); + } + + match join_gossip_topic(&mut state, topic_name, force_direct).await { + Ok(()) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), + Err(err) => Ok( + serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), + ), + } + } + + "gossip_leave" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + + let removed_sender = state.senders.remove(topic_name); + let removed_task = state.receiver_tasks.remove(topic_name); + let was_joined = state.joined_topics.remove(topic_name); + if removed_sender.is_none() && removed_task.is_none() && !was_joined { + return Ok( + serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), + ); + } + if let Some(task) = removed_task { + task.abort(); + } + + state + .cursors + .lock() + .await + .retain(|(topic, _), _| topic != topic_name); + state.buffers.lock().await.remove(topic_name); + state.topic_peers.lock().await.remove(topic_name); + + Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})) + } + + "gossip_send" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + let message = request["message"].as_str().unwrap_or_default(); + let sender_nick = request["sender"].as_str().unwrap_or("unknown"); + + let sender = match state.senders.get(topic_name) { + Some(s) => s, + None => { + return Ok( + serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), + ) + } + }; + + let default_id = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + let msg = GossipMessage { + sender_id: request + .get("sender_id") + .and_then(|v| v.as_str()) + .unwrap_or(&default_id) + .to_string(), + sender_nick: sender_nick.to_string(), + content: message.to_string(), + ts: requested_gossip_ts(request), + nonce: requested_gossip_nonce(request), + signature: request + .get("signature") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + sender_session_id: request + .get("sender_session_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }; + + // Insert into local buffer so other local clients (native chat, + // WASM bridge, microVM bridge) on the same runtime see the message. + { + let mut bufs = state.buffers.lock().await; + if let Some(buf) = bufs.get_mut(topic_name) { + if buf.messages.len() >= MAX_BUFFER { + buf.messages.pop_front(); + buf.base_index += 1; + } + buf.messages.push_back(msg.clone()); + } + } + + let bytes = serde_json::to_vec(&msg).unwrap_or_default(); + match sender.broadcast(bytes).await { + Ok(_) => Ok(serde_json::json!({"status":"ok"})), + Err(e) => { + // Broadcast may fail with 0 peers — message is still in + // the local buffer for same-runtime clients, but remote + // peers did NOT receive it. Report honestly. + tracing::debug!("gossip broadcast to external peers failed: {}", e); + Ok(serde_json::json!({"status":"ok","broadcast":"local_only"})) + } + } + } + + "gossip_recv" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + let limit = request["limit"].as_u64().unwrap_or(50) as usize; + let consumer_id = request + .get("consumer_id") + .and_then(|v| v.as_str()) + .unwrap_or("default") + .to_string(); + // Skip messages from this sender (prevents local loopback echo) + let skip_sender_id = request + .get("skip_sender_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let buffers = state.buffers.lock().await; + let buf = match buffers.get(topic_name) { + Some(b) => b, + None => return Ok(serde_json::json!({"status":"ok","data":{"messages":[]}})), + }; + + let mut cursors = state.cursors.lock().await; + // Evict cursors under memory pressure. HashMap iteration order + // is arbitrary, so this is not LRU — it just prevents unbounded + // growth. Active consumers will recreate their cursor on the + // next gossip_recv call. + if cursors.len() >= MAX_CURSORS { + let to_remove: Vec<_> = + cursors.keys().take(MAX_CURSORS / 10).cloned().collect(); + for k in to_remove { + cursors.remove(&k); + } + } + let cursor_key = (topic_name.to_string(), consumer_id); + let cursor = cursors.entry(cursor_key).or_insert(buf.base_index); + + let start = if *cursor >= buf.base_index { + (*cursor - buf.base_index) as usize + } else { + 0 + }; + + let all: Vec<&GossipMessage> = + buf.messages.iter().skip(start).take(limit).collect(); + let count = all.len(); + let messages: Vec<&GossipMessage> = if skip_sender_id.is_empty() { + all + } else { + all.into_iter() + .filter(|m| m.sender_id != skip_sender_id) + .collect() + }; + + *cursor = buf.base_index + start as u64 + count as u64; + + Ok(serde_json::json!({"status":"ok","data":{"messages": messages}})) + } + + "get_ticket" => { + // Use watch_addr() to include relay URLs (NAT traversal) + let mut watcher = state.endpoint.watch_addr(); + let addr = watcher.get(); + let ticket_json = serde_json::json!({ + "topic": null, + "endpoints": [addr], + }); + let ticket_bytes = serde_json::to_vec(&ticket_json).unwrap_or_default(); + let mut ticket_str = data_encoding::BASE32_NOPAD.encode(&ticket_bytes); + ticket_str.make_ascii_lowercase(); + + Ok(serde_json::json!({"status":"ok","data":{ + "ticket": ticket_str, + "node_id": state.endpoint.id().to_string(), + }})) + } + + "connect" => { + let memory_lookup = state.memory_lookup.clone(); + let endpoints = match parse_ticket_endpoints_or_error( + request["ticket"].as_str().unwrap_or_default(), + ) { + Ok(endpoints) => endpoints, + Err(err) => return Ok(err), + }; + let added = add_ticket_endpoints( + &memory_lookup, + &mut state.bootstrap_peers, + &endpoints, + true, + ); + let connected = connect_ticket_endpoints( + &state.endpoint, + &state.gossip, + state.peers.clone(), + &endpoints, + ) + .await; + Ok( + serde_json::json!({"status":"ok","data":{"added": added, "connected": connected}}), + ) + } + + "remember_peer" => { + let memory_lookup = state.memory_lookup.clone(); + let endpoints = match parse_ticket_endpoints_or_error( + request["ticket"].as_str().unwrap_or_default(), + ) { + Ok(endpoints) => endpoints, + Err(err) => return Ok(err), + }; + let added = add_ticket_endpoints( + &memory_lookup, + &mut state.bootstrap_peers, + &endpoints, + false, + ); + Ok(serde_json::json!({"status":"ok","data":{"added": added}})) + } + + "get_node_id" => { + let id = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + Ok(serde_json::json!({"status":"ok","data":{"node_id": id}})) + } + + "list_peers" => { + let peers = state.peers.lock().await.clone(); + Ok(serde_json::json!({"status":"ok","data":{"peers": peers}})) + } + + "list_topics" => { + let topics: Vec<&String> = state + .joined_topics + .iter() + .filter(|topic| !topic.starts_with("__elastos_internal/")) + .collect(); + Ok(serde_json::json!({"status":"ok","data":{"topics": topics}})) + } + + "list_topic_peers" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + let mut peers: Vec = state + .topic_peers + .lock() + .await + .get(topic_name) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + peers.sort(); + Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name, "peers": peers}})) + } + + "gossip_join_peers" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + let sender = match state.senders.get(topic_name) { + Some(s) => s, + None => { + return Ok( + serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), + ) + } + }; + let peer_ids: Vec = request + .get("peers") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .filter_map(|peer| peer.parse::().ok()) + .collect(); + if peer_ids.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_peers","message":"peers required"}), + ); + } + match sender.join_peers(peer_ids, None).await { + Ok(_) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), + Err(err) => Ok( + serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), + ), + } + } + + _ => Ok( + serde_json::json!({"status":"error","code":"unknown_op","message": format!("unknown: {}", op)}), + ), + } + } +} + +/// Background task: receive gossip messages and buffer them. +async fn handle_gossip_event( + event: iroh_gossip::api::Event, + buffers: &Arc>>, + peers: &Arc>>, + topic_peers: &Arc>>>, + topic: &str, +) { + match event { + iroh_gossip::api::Event::Received(msg) => { + if let Ok(gossip_msg) = serde_json::from_slice::(&msg.content) { + let mut bufs = buffers.lock().await; + if let Some(buf) = bufs.get_mut(topic) { + if buf.messages.len() >= MAX_BUFFER { + buf.messages.pop_front(); + buf.base_index += 1; + } + buf.messages.push_back(gossip_msg); + } + } + } + iroh_gossip::api::Event::NeighborUp(peer) => { + let mut p = peers.lock().await; + let peer_str = peer.to_string(); + if !p.contains(&peer_str) { + p.push(peer_str.clone()); + } + drop(p); + topic_peers + .lock() + .await + .entry(topic.to_string()) + .or_default() + .insert(peer_str); + } + iroh_gossip::api::Event::NeighborDown(peer) => { + let mut p = peers.lock().await; + p.retain(|x| x != &peer.to_string()); + drop(p); + if let Some(topic_set) = topic_peers.lock().await.get_mut(topic) { + topic_set.remove(&peer.to_string()); + } + } + _ => {} + } +} + +async fn recv_loop( + receiver: distributed_topic_tracker::GossipReceiver, + buffers: Arc>>, + peers: Arc>>, + topic_peers: Arc>>>, + topic: String, +) { + loop { + match receiver.next().await { + Some(Ok(event)) => { + handle_gossip_event(event, &buffers, &peers, &topic_peers, &topic).await; + } + Some(Err(e)) => { + tracing::warn!("carrier recv_loop error on '{}': {}", topic, e); + // Continue — transient errors should not kill the receiver + } + None => { + tracing::info!("carrier recv_loop ended for '{}' (stream closed)", topic); + break; + } + } + } +} + +// ── Client and provider-plane invocation ───────────────────────── + +pub struct CarrierProviderInvoker; + +impl CarrierProviderInvoker { + pub fn new() -> Self { + Self + } +} + +impl Default for CarrierProviderInvoker { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ProviderCarrierInvoker for CarrierProviderInvoker { + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> std::result::Result { + let timeout_secs = carrier_route_timeout_secs(route); + let mut endpoints = decode_ticket_endpoints(&route.connect_ticket); + if let Some(peer_did) = route.peer_did.as_deref() { + endpoints.retain(|endpoint| carrier_endpoint_matches_peer(endpoint, peer_did)); + if endpoints.is_empty() { + return Err(ProviderError::Provider( + "Carrier provider invocation peer_did does not match connect_ticket" + .to_string(), + )); + } + } + if endpoints.is_empty() { + return Err(ProviderError::Provider( + "Carrier provider invocation connect_ticket has no endpoints".to_string(), + )); + } + + let mut errors = Vec::new(); + for (index, endpoint) in endpoints.into_iter().enumerate() { + match CarrierClient::connect_endpoint_addr(endpoint, timeout_secs).await { + Ok(client) => match client.invoke_provider(invocation, request.clone()).await { + Ok(response) => return Ok(response), + Err(err) => errors.push(format!("ticket[{index}] invoke failed: {err}")), + }, + Err(err) => errors.push(format!("ticket[{index}] connect failed: {err}")), + } + } + + Err(ProviderError::Provider(format!( + "Carrier provider invocation failed: {}", + errors.join(" | ") + ))) + } +} + +fn carrier_route_timeout_secs(route: &ProviderCarrierRoute) -> u64 { + let timeout_ms = route.timeout_ms.unwrap_or(5_000).clamp(1, 60_000); + timeout_ms.div_ceil(1_000) +} + +fn carrier_endpoint_matches_peer(endpoint: &iroh::EndpointAddr, peer_did: &str) -> bool { + if let Some(public_key) = did_to_public_key(peer_did) { + return endpoint.id == public_key; + } + endpoint.id.to_string() == peer_did +} + +pub struct CarrierClient { + conn: iroh::endpoint::Connection, + _endpoint: Endpoint, +} + +impl CarrierClient { + async fn connect_endpoint_addr(addr: iroh::EndpointAddr, timeout_secs: u64) -> Result { + let mut rng_bytes = [0u8; 32]; + getrandom::getrandom(&mut rng_bytes).map_err(|e| anyhow::anyhow!("rng: {}", e))?; + let secret_key = SecretKey::from_bytes(&rng_bytes); + let endpoint = Endpoint::builder() + .secret_key(secret_key) + .bind() + .await + .context("Failed to bind")?; + + let conn = tokio::time::timeout( + Duration::from_secs(timeout_secs), + endpoint.connect(addr, CARRIER_ALPN), + ) + .await + .map_err(|_| anyhow::anyhow!("connect timed out"))? + .context("connect failed")?; + + Ok(Self { + conn, + _endpoint: endpoint, + }) + } + + pub async fn connect( + publisher_node_id: &str, + publisher_addrs: &[String], + timeout_secs: u64, + ) -> Result { + let public_key: iroh::PublicKey = publisher_node_id.parse().context("Invalid node ID")?; + let mut addr = iroh::EndpointAddr::from(public_key); + for addr_str in publisher_addrs { + if let Ok(sa) = addr_str.parse::() { + addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); + break; + } + if let Some((host, port_str)) = addr_str.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + if let Ok(mut resolved) = + tokio::net::lookup_host(format!("{}:{}", host, port)).await + { + if let Some(sa) = resolved.next() { + addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); + break; + } + } + } + } + } + + Self::connect_endpoint_addr(addr, timeout_secs).await + } + + pub async fn connect_trusted_source(source: &TrustedSource, timeout_secs: u64) -> Result { + let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); + let mut ticket_errors = Vec::new(); + for endpoint in ticket_endpoints { + match Self::connect_endpoint_addr(endpoint.clone(), timeout_secs).await { + Ok(client) => return Ok(client), + Err(err) => ticket_errors.push(err.to_string()), + } + } + + let node_id = source_node_id(source) + .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; + let addrs = source_carrier_addrs(source); + match Self::connect(&node_id, &addrs, timeout_secs).await { + Ok(client) => Ok(client), + Err(err) if !ticket_errors.is_empty() => Err(anyhow::anyhow!( + "trusted source Carrier connection failed (ticket errors: {}; fallback error: {})", + ticket_errors.join(" | "), + err + )), + Err(err) => Err(err), + } + } + + pub async fn release_head(&self) -> Result> { + let (mut send, recv) = self.conn.open_bi().await?; + let msg = serde_json::json!({"op":"release_head","path":""}); + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + let mut reader = BufReader::new(recv); + let mut line = String::new(); + reader.read_line(&mut line).await?; + let resp: serde_json::Value = serde_json::from_str(line.trim())?; + if resp["ok"].as_bool() == Some(true) { + Ok(Some(resp["release"].clone())) + } else { + Ok(None) + } + } + + pub async fn fetch_file(&self, path: &str) -> Result> { + let (mut send, mut recv) = self.conn.open_bi().await?; + let msg = serde_json::json!({"op":"file","path":path}); + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + read_carrier_len_prefixed_bytes(&mut recv, &format!("trusted source file fetch for {path}")) + .await + } + + pub async fn fetch_content(&self, cid: &str, path: Option<&str>) -> Result> { + let (mut send, mut recv) = self.conn.open_bi().await?; + let mut msg = serde_json::json!({ + "op": "content_fetch", + "cid": cid, + }); + if let Some(path) = path.filter(|path| !path.is_empty()) { + msg["path"] = serde_json::Value::String(path.to_string()); + } + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + read_carrier_len_prefixed_bytes(&mut recv, "content fetch").await + } + + pub async fn invoke_provider( + &self, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> Result { + let (mut send, recv) = self.conn.open_bi().await?; + let msg = serde_json::json!({ + "op": "provider_invoke", + "source": invocation.source.as_str(), + "target": invocation.target.as_str(), + "operation": invocation.op.as_str(), + "transfer": invocation.transfer.as_str(), + "range": invocation.range.map(|range| serde_json::json!({ + "start": range.start, + "end": range.end, + })), + "progress": invocation.progress.as_ref().map(|progress| serde_json::json!({ + "request_id": progress.request_id.as_str(), + "expected_bytes": progress.expected_bytes, + })), + "request": request, + }); + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + + let mut reader = BufReader::new(recv); + let mut line = String::new(); + reader.read_line(&mut line).await?; + let response: serde_json::Value = serde_json::from_str(line.trim())?; + if response.get("ok").and_then(|value| value.as_bool()) == Some(true) { + return Ok(response + .get("result") + .cloned() + .unwrap_or(serde_json::Value::Null)); + } + let message = response + .get("error") + .and_then(|value| value.as_str()) + .unwrap_or("Carrier provider invocation failed"); + anyhow::bail!("{message}"); + } +} + +async fn read_carrier_len_prefixed_bytes( + recv: &mut iroh::endpoint::RecvStream, + operation: &str, +) -> Result> { + let mut len_buf = [0u8; 8]; + recv.read_exact(&mut len_buf).await?; + let len = u64::from_be_bytes(len_buf) as usize; + if len > 200 * 1024 * 1024 { + let mut error_bytes = len_buf.to_vec(); + let tail = recv.read_to_end(16 * 1024).await?; + error_bytes.extend_from_slice(&tail); + if let Ok(text) = String::from_utf8(error_bytes) { + if let Ok(json) = serde_json::from_str::(text.trim()) { + if json["ok"].as_bool() == Some(false) { + let msg = json["error"] + .as_str() + .unwrap_or("Carrier returned an unknown error"); + anyhow::bail!("{operation} failed: {msg}"); + } + } + } + anyhow::bail!("{operation} returned invalid byte reply ({len} bytes declared)"); + } + let mut content = vec![0u8; len]; + recv.read_exact(&mut content).await?; + Ok(content) +} + +async fn fetch_file_with_timeout( + client: &CarrierClient, + path: &str, + timeout_secs: u64, +) -> Result> { + tokio::time::timeout(Duration::from_secs(timeout_secs), client.fetch_file(path)) + .await + .map_err(|_| anyhow::anyhow!("file fetch timed out after {}s", timeout_secs))? +} + +pub async fn fetch_file_from_trusted_source( + source: &TrustedSource, + path: &str, + connect_timeout_secs: u64, + fetch_timeout_secs: u64, +) -> Result> { + let mut errors = Vec::new(); + let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); + for (index, endpoint) in ticket_endpoints.into_iter().enumerate() { + match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { + Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { + Ok(bytes) => return Ok(bytes), + Err(err) => errors.push(format!("ticket[{index}] fetch failed: {err}")), + }, + Err(err) => errors.push(format!("ticket[{index}] connect failed: {err}")), + } + } + + let relay_endpoints = relay_only_ticket_endpoints(source); + for (index, endpoint) in relay_endpoints.into_iter().enumerate() { + match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { + Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { + Ok(bytes) => return Ok(bytes), + Err(err) => errors.push(format!("relay[{index}] fetch failed: {err}")), + }, + Err(err) => errors.push(format!("relay[{index}] connect failed: {err}")), + } + } + + let node_id = source_node_id(source) + .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; + let addrs = source_carrier_addrs(source); + match CarrierClient::connect(&node_id, &addrs, connect_timeout_secs).await { + Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { + Ok(bytes) => Ok(bytes), + Err(err) => { + errors.push(format!("direct fetch failed: {err}")); + Err(anyhow::anyhow!( + "trusted source Carrier fetch failed: {}", + errors.join(" | ") + )) + } + }, + Err(err) => { + errors.push(format!("direct connect failed: {err}")); + Err(anyhow::anyhow!( + "trusted source Carrier fetch failed: {}", + errors.join(" | ") + )) + } + } +} + +pub async fn try_p2p_discovery( + publisher_node_id: &str, + publisher_addrs: &[String], + timeout_secs: u64, +) -> Option { + let client = CarrierClient::connect(publisher_node_id, publisher_addrs, timeout_secs) + .await + .ok()?; + let release = client.release_head().await.ok()??; + release["head_cid"].as_str().map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockCarrierIpfsProvider; + + #[async_trait::async_trait] + impl Provider for MockCarrierIpfsProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock ipfs provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-carrier-ipfs-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("op").and_then(|value| value.as_str()) != Some("cat") { + return Ok(serde_json::json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock ipfs operation" + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode(b"carrier content") + } + })) + } + } + + struct MockCarrierContentProvider; + + #[async_trait::async_trait] + impl Provider for MockCarrierContentProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock content provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-carrier-content-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("op").and_then(|op| op.as_str()) == Some("fetch") + && request + .get("local_only") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": 22, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 22, + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ), + } + ], + } + } + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "op": request.get("op").cloned().unwrap_or(serde_json::Value::Null), + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + } + })) + } + } + + struct MockCarrierObjectContentProvider; + struct MockCarrierBlockGraphProvider; + + #[async_trait::async_trait] + impl Provider for MockCarrierObjectContentProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock content provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-carrier-object-content-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> std::result::Result { + if request.get("op").and_then(|op| op.as_str()) != Some("fetch") + || !request + .get("local_only") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Ok(serde_json::json!({ + "status": "error", + "code": "unsupported", + "message": "mock object content provider only supports local fetch" + })); + } + let path = request + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let bytes = match path { + CONTENT_OBJECT_MANIFEST_PATH => carrier_test_object_manifest_bytes(), + "index.md" => carrier_test_object_file_bytes(), + _ => { + return Ok(serde_json::json!({ + "status": "error", + "code": "not_found", + "message": "mock object path not found" + })) + } + }; + Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "stream": carrier_test_stream_payload(&bytes) + } + })) + } + } + + #[async_trait::async_trait] + impl Provider for MockCarrierBlockGraphProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock block-graph provider only supports raw operations".into(), + )) + } - // Insert into local buffer so other local clients (native chat, - // WASM bridge, microVM bridge) on the same runtime see the message. - { - let mut bufs = state.buffers.lock().await; - if let Some(buf) = bufs.get_mut(topic_name) { - if buf.messages.len() >= MAX_BUFFER { - buf.messages.pop_front(); - buf.base_index += 1; - } - buf.messages.push_back(msg.clone()); + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-block-graph-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> std::result::Result { + if request.get("op").and_then(|op| op.as_str()) != Some("export_graph") { + return Ok(serde_json::json!({ + "status": "error", + "code": "unsupported", + "message": "mock block-graph provider only supports export_graph" + })); + } + let cid = request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"); + Ok(serde_json::json!({ + "status": "ok", + "data": { + "graph": { + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "root_cid": cid, + "kind": "ipld_dag", + "blocks": [ + { + "cid": cid, + "codec": "dag-pb", + "size": 22, + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ) + } + ], + "links": [], + "bytes": 22 } } + })) + } + } - let bytes = serde_json::to_vec(&msg).unwrap_or_default(); - match sender.broadcast(bytes).await { - Ok(_) => Ok(serde_json::json!({"status":"ok"})), - Err(e) => { - // Broadcast may fail with 0 peers — message is still in - // the local buffer for same-runtime clients, but remote - // peers did NOT receive it. Report honestly. - tracing::debug!("gossip broadcast to external peers failed: {}", e); - Ok(serde_json::json!({"status":"ok","broadcast":"local_only"})) + #[derive(Default)] + struct MockCarrierProviderPlaneInvoker { + requests: Mutex>, + fail_ensure: bool, + reject_admission: bool, + omit_admission_receipt: bool, + } + + #[async_trait::async_trait] + impl ProviderCarrierInvoker for MockCarrierProviderPlaneInvoker { + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> std::result::Result { + self.requests.lock().await.push(serde_json::json!({ + "ticket": route.connect_ticket.as_str(), + "source": invocation.source.as_str(), + "target": invocation.target.as_str(), + "op": invocation.op.as_str(), + "request": request, + })); + if invocation.transfer == ProviderTransfer::Stream { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": 22, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 22, + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ), + } + ], + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "admission" { + let admission = serde_json::json!({ + "schema": "elastos.content.admission/v1", + "policy": "content_provider_principal_quota_preflight", + "scope": "content-availability", + "accepted": !self.reject_admission, + "status": if self.reject_admission { "rejected" } else { "accepted" }, + "reason": if self.reject_admission { + Some("mock remote quota exceeded") + } else { + None + }, + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "publisher_did": request + .get("publisher_did") + .cloned() + .unwrap_or(serde_json::Value::Null), + "estimated_content_bytes": request + .get("estimated_content_bytes") + .cloned() + .unwrap_or(serde_json::Value::Null), + "quota": { + "policy": "principal_storage_quota", + "status": if self.reject_admission { + "quota_exceeded" + } else { + "within_quota" + }, + "enforced": true + }, + "checked_at": 1_700_000_000, + "app_visible": false + }); + let receipt = if self.omit_admission_receipt { + serde_json::Value::Null + } else { + signed_remote_admission_receipt(&admission) + }; + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "admission": admission, + "receipt": receipt } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "ensure" { + let ensure_attempts = self + .requests + .lock() + .await + .iter() + .filter(|request| request["op"] == "ensure") + .count(); + if self.fail_ensure && ensure_attempts == 1 { + return Ok(serde_json::json!({ + "status": "error", + "code": "pin_failed", + "message": "mock remote pin failed" + })); } + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "carrier_replica", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "import_exact" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "carrier_exact_import", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + }, + "import": { + "schema": "elastos.content.import-exact/v1", + "verified_cid": true, + "bytes": 22 + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "import_object" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "carrier_object_import", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + }, + "import": { + "schema": "elastos.content.import-object/v1", + "verified_cid": true, + "files": request + .get("files") + .and_then(|value| value.as_array()) + .map(|files| files.len()) + .unwrap_or(0) + } + } + })); } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "import_graph" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": CONTENT_BLOCK_GRAPH_PROVIDER, + "policy": "carrier_block_graph_import", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + }, + "repair_graph": { + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "policy": "carrier_provider_bounded_graph_repair", + "requested_kind": "ipld_dag", + "status": "block_graph_provider_imported" + } + }, + "import": { + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "verified_cid": true, + "blocks": request + .get("graph") + .and_then(|graph| graph.get("blocks")) + .and_then(|value| value.as_array()) + .map(|blocks| blocks.len()) + .unwrap_or(0) + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "status" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "local_repair_pin", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + } + } + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": { + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ) + } + } + })) + } + } - "gossip_recv" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - let limit = request["limit"].as_u64().unwrap_or(50) as usize; - let consumer_id = request - .get("consumer_id") - .and_then(|v| v.as_str()) - .unwrap_or("default") - .to_string(); - // Skip messages from this sender (prevents local loopback echo) - let skip_sender_id = request - .get("skip_sender_id") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); + #[test] + fn test_topic_hash_deterministic() { + let h1 = topic_hash("#general"); + let h2 = topic_hash("#general"); + assert_eq!(h1, h2, "same topic name must produce same hash"); - let buffers = state.buffers.lock().await; - let buf = match buffers.get(topic_name) { - Some(b) => b, - None => return Ok(serde_json::json!({"status":"ok","data":{"messages":[]}})), - }; + let h3 = topic_hash("#other"); + assert_ne!(h1, h3, "different topics must produce different hashes"); + } - let mut cursors = state.cursors.lock().await; - // Evict cursors under memory pressure. HashMap iteration order - // is arbitrary, so this is not LRU — it just prevents unbounded - // growth. Active consumers will recreate their cursor on the - // next gossip_recv call. - if cursors.len() >= MAX_CURSORS { - let to_remove: Vec<_> = - cursors.keys().take(MAX_CURSORS / 10).cloned().collect(); - for k in to_remove { - cursors.remove(&k); - } + #[test] + fn test_gossip_message_serialization() { + let msg = GossipMessage { + sender_id: "did:key:z6MkTest".to_string(), + sender_nick: "alice".to_string(), + content: "hello world".to_string(), + ts: 1700000000, + nonce: 42, + signature: None, + sender_session_id: None, + }; + let bytes = serde_json::to_vec(&msg).unwrap(); + let decoded: GossipMessage = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(decoded.sender_id, "did:key:z6MkTest"); + assert_eq!(decoded.sender_nick, "alice"); + assert_eq!(decoded.content, "hello world"); + assert_eq!(decoded.ts, 1700000000); + assert_eq!(decoded.nonce, 42); + assert!(decoded.signature.is_none()); + } + + #[test] + fn test_gossip_message_with_signature() { + let msg = GossipMessage { + sender_id: "did:key:z6MkTest".to_string(), + sender_nick: "bob".to_string(), + content: "signed msg".to_string(), + ts: 1700000000, + nonce: 1, + signature: Some("deadbeef".to_string()), + sender_session_id: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"signature\":\"deadbeef\"")); + + let decoded: GossipMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.signature, Some("deadbeef".to_string())); + } + + #[test] + fn test_content_availability_topic_is_deterministic_and_does_not_embed_raw_cid() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let topic = content_availability_topic_name(cid); + let topic_again = content_availability_topic_name(cid); + let uri = content_availability_topic_uri(cid); + + assert_eq!(topic, topic_again); + assert!(topic.starts_with("__elastos_content/v1/")); + assert!(uri.starts_with("elastos://carrier/content/")); + assert!(uri.ends_with("/availability")); + assert!(!topic.contains(cid)); + assert!(!uri.contains(cid)); + } + + #[test] + fn test_content_availability_cid_validation_is_fail_closed() { + assert!(validate_content_cid( + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" + ) + .is_ok()); + assert!(validate_content_cid("QmRSEtAyq7Xgr5YCFVWuYsBdqbR5X9fJDsdpNQuvm9yaic").is_ok()); + assert!(validate_content_cid("short").is_err()); + assert!(validate_content_cid("cid/with/slashes").is_err()); + assert!(validate_content_cid("cid with spaces").is_err()); + } + + #[test] + fn test_content_availability_fetch_tickets_require_signed_matching_announcement() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (signed_message, _) = signed_content_availability_message( + cid, + [19u8; 32], + "ticket:test", + "endpoint:test", + 1_700_000_000, + ); + let unsigned_message = GossipMessage { + content: serde_json::json!({ + "payload": { + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "node_did": "did:key:z6Mkuntrusted", + "fetch": {"connect_ticket": "ticket:unsigned"} + }, + "signature": "00", + "signer_did": "did:key:z6Mkuntrusted" + }) + .to_string(), + ..signed_message.clone() + }; + + let tickets = content_availability_fetch_tickets(&[unsigned_message, signed_message], cid); + + assert_eq!(tickets, vec!["ticket:test".to_string()]); + } + + #[test] + fn test_content_availability_replicas_ignore_signed_repair_only_announcements() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (sk, did) = elastos_identity::derive_did(&[23u8; 32]); + let payload = serde_json::json!({ + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "provider": "carrier-availability", + "node_did": did, + "topic": content_availability_topic_uri(cid), + "local": { + "status": "local_unpinned", + "provider": "ipfs-provider", + "replicas": 0 + }, + "announced_at": 1_700_000_000u64 + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + canonical.as_bytes(), + ); + let message = GossipMessage { + sender_id: signer_did.clone(), + sender_nick: "content-provider".to_string(), + content: serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + .to_string(), + ts: 1_700_000_000, + nonce: 1, + signature: None, + sender_session_id: None, + }; + + assert!( + content_availability_replicas(&[message], cid).is_empty(), + "repair-only announcements must not become fetch/replication candidates" + ); + } + + #[test] + fn test_content_availability_replicas_ignore_oversized_candidate_metadata() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (oversized_ticket, _) = signed_content_availability_message( + cid, + [24u8; 32], + &"x".repeat(MAX_CARRIER_AVAILABILITY_TICKET_LEN + 1), + "remote-endpoint", + 1_700_000_000, + ); + let (oversized_endpoint, _) = signed_content_availability_message( + cid, + [25u8; 32], + "ticket:test", + &"e".repeat(MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN + 1), + 1_700_000_000, + ); + + let replicas = content_availability_replicas(&[oversized_ticket, oversized_endpoint], cid); + + assert!( + replicas.is_empty(), + "oversized candidate metadata must not be used for Carrier provider invocation" + ); + } + + #[test] + fn test_content_availability_replicas_are_scored_and_sorted() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (mut stale, stale_did) = + signed_content_availability_message(cid, [26u8; 32], "ticket:stale", "", 10); + stale.ts = 1_700_000_000; + let (fresh, fresh_did) = signed_content_availability_message( + cid, + [27u8; 32], + "ticket:fresh", + "remote-endpoint", + 1_700_000_000, + ); + + let replicas = content_availability_replicas(&[stale, fresh], cid); + + assert_eq!(replicas.len(), 2); + assert_eq!(replicas[0].node_did, fresh_did); + assert_eq!(replicas[0].connect_ticket, "ticket:fresh"); + assert_eq!(replicas[0].score, 90); + assert_eq!( + replicas[0].selection_reason, + "signed_announcement+endpoint_advertised+fresh+local_reputation_neutral" + ); + assert_eq!(replicas[0].reputation_score, 0); + assert_eq!(replicas[0].reputation_reason, "no_local_history"); + assert_eq!(replicas[1].node_did, stale_did); + assert_eq!(replicas[1].score, 50); + assert_eq!( + replicas[1].selection_reason, + "signed_announcement+stale+local_reputation_neutral" + ); + } + + #[test] + fn test_content_availability_replicas_apply_local_runtime_reputation() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (preferred, preferred_did) = signed_content_availability_message( + cid, + [28u8; 32], + "ticket:preferred", + "remote-endpoint", + 1_700_000_000, + ); + let (penalized, penalized_did) = signed_content_availability_message( + cid, + [29u8; 32], + "ticket:penalized", + "remote-endpoint", + 1_700_000_000, + ); + let mut reputation = HashMap::new(); + reputation.insert( + preferred_did.clone(), + CarrierPeerReputation { + successes: 2, + failures: 0, + }, + ); + reputation.insert( + penalized_did.clone(), + CarrierPeerReputation { + successes: 0, + failures: 2, + }, + ); + + let replicas = content_availability_replicas_with_reputation( + &[penalized, preferred], + cid, + &reputation, + ); + + assert_eq!(replicas.len(), 2); + assert_eq!(replicas[0].node_did, preferred_did); + assert_eq!(replicas[0].score, 98); + assert_eq!(replicas[0].reputation_score, 8); + assert_eq!( + replicas[0].reputation_reason, + "local_runtime_successes:2;failures:0" + ); + assert_eq!(replicas[1].node_did, penalized_did); + assert_eq!(replicas[1].score, 74); + assert_eq!(replicas[1].reputation_score, -16); + } + + #[test] + fn test_carrier_peer_reputation_persists_local_history() { + let data_dir = tempfile::tempdir().unwrap(); + let mut reputation = HashMap::new(); + reputation.insert( + "did:key:zDurablePeer".to_string(), + CarrierPeerReputation { + successes: 3, + failures: 1, + }, + ); + + save_carrier_peer_reputation(data_dir.path(), &reputation).unwrap(); + let loaded = load_carrier_peer_reputation(data_dir.path()); + + let peer = loaded.get("did:key:zDurablePeer").unwrap(); + assert_eq!(peer.successes, 3); + assert_eq!(peer.failures, 1); + assert!(carrier_peer_reputation_path(data_dir.path()).is_file()); + } + + fn signed_content_availability_message( + cid: &str, + key_seed: [u8; 32], + connect_ticket: &str, + endpoint_id: &str, + announced_at: u64, + ) -> (GossipMessage, String) { + let (sk, did) = elastos_identity::derive_did(&key_seed); + let payload = serde_json::json!({ + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "provider": "carrier-availability", + "node_did": did, + "topic": content_availability_topic_uri(cid), + "fetch": { + "transport": "carrier-file", + "endpoint_id": endpoint_id, + "connect_ticket": connect_ticket + }, + "local": { + "status": "local_pinned", + "provider": "ipfs-provider", + "replicas": 1 + }, + "announced_at": announced_at + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + canonical.as_bytes(), + ); + ( + GossipMessage { + sender_id: signer_did.clone(), + sender_nick: "content-provider".to_string(), + content: serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + .to_string(), + ts: announced_at, + nonce: 1, + signature: None, + sender_session_id: None, + }, + did, + ) + } + + fn signed_remote_content_receipt(cid: &str) -> serde_json::Value { + let (sk, _) = elastos_identity::derive_did(&[44u8; 32]); + let checked_at = 1_700_000_123u64; + let payload = serde_json::json!({ + "schema": "elastos.content.availability.receipt/v1", + "cid": cid, + "uri": format!("elastos://{cid}"), + "provider": "content-provider", + "policy": "carrier_exact_import", + "status": "local_pinned", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "carrier_provider_quota", + "status": "within_quota", + "enforced": true, + "used_replicas": 1, + "effective_max_replicas": 3 + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled", + "worker": "content-provider" + }, + "repair_graph": { + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "policy": "carrier_provider_bounded_graph_repair", + "requested_kind": "auto", + "status": "bounded_import_supported", + "refuses_exact_fallback_for_arbitrary_dag": true + }, + "storage_market": { + "schema": "elastos.content.storage-market/v1", + "mode": "carrier_provider_receipts", + "status": "receipt_proven_no_market_settlement", + "settlement": "not_configured", + "quota_enforced": true + }, + "accounting": { + "schema": "elastos.content.accounting/v1", + "observed": true, + "files": 1, + "content_bytes": 22, + "replica_bytes_estimate": 22, + "storage_quota": { + "status": "observed_not_enforced" } - let cursor_key = (topic_name.to_string(), consumer_id); - let cursor = cursors.entry(cursor_key).or_insert(buf.base_index); + }, + "abuse_controls": { + "schema": "elastos.content.abuse-controls/v1", + "policy": "carrier_provider_invocation_guardrail", + "enforced": true, + "candidate_count": 1, + "attempt_limit": 1, + "attempted_operations": 1, + "failed_operations": 0, + "throttled": false + }, + "checked_at": checked_at, + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + "elastos.content.availability.receipt.v1", + canonical.as_bytes(), + ); + serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } - let start = if *cursor >= buf.base_index { - (*cursor - buf.base_index) as usize - } else { - 0 - }; + fn signed_remote_admission_receipt(payload: &serde_json::Value) -> serde_json::Value { + let (sk, _) = elastos_identity::derive_did(&[45u8; 32]); + let canonical = serde_json::to_string(payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CONTENT_ADMISSION_DOMAIN, + canonical.as_bytes(), + ); + serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } - let all: Vec<&GossipMessage> = - buf.messages.iter().skip(start).take(limit).collect(); - let count = all.len(); - let messages: Vec<&GossipMessage> = if skip_sender_id.is_empty() { - all - } else { - all.into_iter() - .filter(|m| m.sender_id != skip_sender_id) - .collect() - }; + fn signed_peer_attestation_exchange_receipt(payload: serde_json::Value) -> serde_json::Value { + let (sk, _) = elastos_identity::derive_did(&[46u8; 32]); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_DOMAIN, + canonical.as_bytes(), + ); + serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } - *cursor = buf.base_index + start as u64 + count as u64; + fn carrier_peer_attestation_test_proof() -> CarrierReplicationProof { + CarrierReplicationProof { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 4, + reputation_reason: "local_runtime_successes:1;failures:0".to_string(), + ensure_status: "ok".to_string(), + admission: Some(serde_json::json!({ + "accepted": true, + "quota": {"status": "within_quota"} + })), + status_availability: serde_json::json!({ + "status": "local_pinned", + "replicas": 1, + }), + remote_receipt: Some(serde_json::json!({ + "schema": "elastos.content.availability.receipt/v1", + "cid": "bafyattest", + "status": "network_available", + "signer_did": "did:key:zRemoteContentProvider", + "verified": true, + })), + transfer: Some(serde_json::json!({ + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "connect_ticket": "ticket:internal-secret", + } + })), + checked_at: 1_700_000_001, + } + } - Ok(serde_json::json!({"status":"ok","data":{"messages": messages}})) + fn spawn_peer_attestation_exchange_endpoint( + response: serde_json::Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/peer-attestation/exchange"), handle) + } - "get_ticket" => { - // Use watch_addr() to include relay URLs (NAT traversal) - let mut watcher = state.endpoint.watch_addr(); - let addr = watcher.get(); - let ticket_json = serde_json::json!({ - "topic": null, - "endpoints": [addr], - }); - let ticket_bytes = serde_json::to_vec(&ticket_json).unwrap_or_default(); - let mut ticket_str = data_encoding::BASE32_NOPAD.encode(&ticket_bytes); - ticket_str.make_ascii_lowercase(); + fn http_request_complete(request: &[u8]) -> bool { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + request.len() >= header_end + 4 + content_length + } - Ok(serde_json::json!({"status":"ok","data":{ - "ticket": ticket_str, - "node_id": state.endpoint.id().to_string(), - }})) - } + fn carrier_test_stream_payload(bytes: &[u8]) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": bytes.len(), + "completed": true, + "chunks": [{ + "index": 0, + "offset": 0, + "length": bytes.len(), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }], + }) + } - "connect" => { - let memory_lookup = state.memory_lookup.clone(); - let endpoints = match parse_ticket_endpoints_or_error( - request["ticket"].as_str().unwrap_or_default(), - ) { - Ok(endpoints) => endpoints, - Err(err) => return Ok(err), - }; - let added = add_ticket_endpoints( - &memory_lookup, - &mut state.bootstrap_peers, - &endpoints, - true, - ); - let connected = connect_ticket_endpoints( - &state.endpoint, - &state.gossip, - state.peers.clone(), - &endpoints, - ) - .await; - Ok( - serde_json::json!({"status":"ok","data":{"added": added, "connected": connected}}), - ) - } + fn carrier_test_object_file_bytes() -> Vec { + b"# Carrier Object\n".to_vec() + } - "remember_peer" => { - let memory_lookup = state.memory_lookup.clone(); - let endpoints = match parse_ticket_endpoints_or_error( - request["ticket"].as_str().unwrap_or_default(), - ) { - Ok(endpoints) => endpoints, - Err(err) => return Ok(err), - }; - let added = add_ticket_endpoints( - &memory_lookup, - &mut state.bootstrap_peers, - &endpoints, - false, - ); - Ok(serde_json::json!({"status":"ok","data":{"added": added}})) - } + fn carrier_test_object_manifest_bytes() -> Vec { + let bytes = carrier_test_object_file_bytes(); + let file_sha = format!("{:x}", Sha256::digest(&bytes)); + let mut hasher = Sha256::new(); + hasher.update(b"index.md"); + hasher.update(b"\0"); + hasher.update(file_sha.as_bytes()); + hasher.update(b"\0"); + hasher.update(bytes.len().to_string().as_bytes()); + hasher.update(b"\0"); + let manifest = serde_json::json!({ + "schema": "elastos.content.object.manifest/v1", + "kind": "document", + "content_digest": format!("sha256:{:x}", hasher.finalize()), + "files": [{ + "path": "index.md", + "sha256": file_sha, + "size": bytes.len() + }], + "links": [], + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher" + }); + serde_json::to_vec(&manifest).unwrap() + } - "get_node_id" => { - let id = state - .did - .clone() - .unwrap_or_else(|| state.endpoint.id().to_string()); - Ok(serde_json::json!({"status":"ok","data":{"node_id": id}})) + #[tokio::test] + async fn test_carrier_provider_invoke_dispatches_runtime_enveloped_request() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "carrier-availability", + "target": "content", + "operation": "fetch", + "transfer": "bytes", + "request": { + "op": "fetch", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "fetch", + "capability": "provider:carrier-availability->content:fetch", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "bytes", + "range": null, + "progress": null + } } + }); - "list_peers" => { - let peers = state.peers.lock().await.clone(); - Ok(serde_json::json!({"status":"ok","data":{"peers": peers}})) - } + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); - "list_topics" => { - let topics: Vec<&String> = state - .joined_topics - .iter() - .filter(|topic| !topic.starts_with("__elastos_internal/")) - .collect(); - Ok(serde_json::json!({"status":"ok","data":{"topics": topics}})) - } + assert_eq!(response["ok"], true); + assert_eq!(response["result"]["status"], "ok"); + assert_eq!(response["result"]["data"]["op"], "fetch"); + assert_eq!( + response["result"]["data"]["runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + assert!(!response.to_string().contains("\"connect_ticket\":")); + } - "list_topic_peers" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); + #[tokio::test] + async fn test_carrier_provider_invoke_accepts_stream_contract_metadata() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "carrier-availability", + "target": "content", + "operation": "fetch", + "transfer": "stream", + "request": { + "op": "fetch", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "fetch", + "capability": "provider:carrier-availability->content:fetch", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "stream", + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "chunk_size": 65536 + }, + "range": null, + "progress": null } - let mut peers: Vec = state - .topic_peers - .lock() - .await - .get(topic_name) - .cloned() - .unwrap_or_default() - .into_iter() - .collect(); - peers.sort(); - Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name, "peers": peers}})) } + }); - "gossip_join_peers" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); - } - let sender = match state.senders.get(topic_name) { - Some(s) => s, - None => { - return Ok( - serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), - ) - } - }; - let peer_ids: Vec = request - .get("peers") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .filter_map(|v| v.as_str()) - .filter_map(|peer| peer.parse::().ok()) - .collect(); - if peer_ids.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_peers","message":"peers required"}), - ); - } - match sender.join_peers(peer_ids, None).await { - Ok(_) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), - Err(err) => Ok( - serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), - ), + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); + + assert_eq!(response["ok"], true); + assert_eq!( + response["result"]["data"]["runtime_invocation"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!( + response["result"]["data"]["runtime_invocation"]["stream"]["encoding"], + "base64-chunks" + ); + assert!(!response.to_string().contains("\"connect_ticket\":")); + } + + #[tokio::test] + async fn test_carrier_provider_invoke_rejects_stream_without_contract_metadata() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "carrier-availability", + "target": "content", + "operation": "fetch", + "transfer": "stream", + "request": { + "op": "fetch", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "fetch", + "capability": "provider:carrier-availability->content:fetch", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket" + }, + "transfer": "stream" } } + }); - _ => Ok( - serde_json::json!({"status":"error","code":"unknown_op","message": format!("unknown: {}", op)}), - ), - } + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); + + assert_eq!(response["ok"], false); + assert_eq!(response["code"], "invalid_provider_invocation"); + assert!(response["error"] + .as_str() + .unwrap() + .contains("stream transfer requires stream metadata")); } -} -/// Background task: receive gossip messages and buffer them. -async fn handle_gossip_event( - event: iroh_gossip::api::Event, - buffers: &Arc>>, - peers: &Arc>>, - topic_peers: &Arc>>>, - topic: &str, -) { - match event { - iroh_gossip::api::Event::Received(msg) => { - if let Ok(gossip_msg) = serde_json::from_slice::(&msg.content) { - let mut bufs = buffers.lock().await; - if let Some(buf) = bufs.get_mut(topic) { - if buf.messages.len() >= MAX_BUFFER { - buf.messages.pop_front(); - buf.base_index += 1; - } - buf.messages.push_back(gossip_msg); + #[tokio::test] + async fn test_carrier_provider_invoke_rejects_raw_backend_target() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("ipfs", Arc::new(MockCarrierIpfsProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "content-provider", + "target": "ipfs", + "operation": "cat", + "transfer": "bytes", + "request": { + "op": "cat", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "content-provider", + "target": "ipfs", + "op": "cat", + "capability": "provider:content-provider->ipfs:cat", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket" + }, + "transfer": "bytes" } } - } - iroh_gossip::api::Event::NeighborUp(peer) => { - let mut p = peers.lock().await; - let peer_str = peer.to_string(); - if !p.contains(&peer_str) { - p.push(peer_str.clone()); - } - drop(p); - topic_peers - .lock() - .await - .entry(topic.to_string()) - .or_default() - .insert(peer_str); - } - iroh_gossip::api::Event::NeighborDown(peer) => { - let mut p = peers.lock().await; - p.retain(|x| x != &peer.to_string()); - drop(p); - if let Some(topic_set) = topic_peers.lock().await.get_mut(topic) { - topic_set.remove(&peer.to_string()); - } - } - _ => {} + }); + + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); + + assert_eq!(response["ok"], false); + assert_eq!(response["code"], "unauthorized_provider_target"); } -} -async fn recv_loop( - receiver: distributed_topic_tracker::GossipReceiver, - buffers: Arc>>, - peers: Arc>>, - topic_peers: Arc>>>, - topic: String, -) { - loop { - match receiver.next().await { - Some(Ok(event)) => { - handle_gossip_event(event, &buffers, &peers, &topic_peers, &topic).await; - } - Some(Err(e)) => { - tracing::warn!("carrier recv_loop error on '{}': {}", topic, e); - // Continue — transient errors should not kill the receiver - } - None => { - tracing::info!("carrier recv_loop ended for '{}' (stream closed)", topic); - break; - } - } + #[tokio::test] + async fn test_carrier_availability_fetch_uses_provider_invocation_transport() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + + let (bytes, remote_transfer) = fetch_content_via_carrier_provider_invocation( + ®istry, + "ticket:internal-secret", + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "docs/readme.md", + ) + .await + .unwrap(); + + assert_eq!(bytes, b"carrier provider bytes"); + let remote_transfer = remote_transfer.expect("Carrier invocation must emit transfer"); + assert_eq!(remote_transfer["transport"], "carrier-provider-plane"); + assert_eq!(remote_transfer["source"], "carrier-availability"); + assert_eq!(remote_transfer["target"], "content"); + assert_eq!(remote_transfer["op"], "fetch"); + assert_eq!(remote_transfer["transfer"], "stream"); + assert_eq!( + remote_transfer["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert!(!remote_transfer + .to_string() + .contains("ticket:internal-secret")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["ticket"], "ticket:internal-secret"); + assert_eq!(requests[0]["target"], "content"); + assert_eq!(requests[0]["request"]["local_only"], true); + assert_eq!(requests[0]["request"]["transfer"], "stream"); + assert_eq!(requests[0]["request"]["path"], "docs/readme.md"); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["transfer"], + "stream" + ); } -} -// ── Client (for updates) ───────────────────────────────────────── + #[tokio::test] + async fn test_carrier_replication_proof_uses_remote_content_provider_invocation() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; -pub struct CarrierClient { - conn: iroh::endpoint::Connection, - _endpoint: Endpoint, -} + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher", + "accounting": { + "schema": "elastos.content.accounting/v1", + "content_bytes": 22 + }, + "requirements": { + "max_storage_bytes_per_principal": 1024 + } + }), + ) + .await + .unwrap(); -impl CarrierClient { - async fn connect_endpoint_addr(addr: iroh::EndpointAddr, timeout_secs: u64) -> Result { - let mut rng_bytes = [0u8; 32]; - getrandom::getrandom(&mut rng_bytes).map_err(|e| anyhow::anyhow!("rng: {}", e))?; - let secret_key = SecretKey::from_bytes(&rng_bytes); - let endpoint = Endpoint::builder() - .secret_key(secret_key) - .bind() + assert_eq!(proof.node_did, "did:key:zRemote"); + assert_eq!(proof.endpoint_id.as_deref(), Some("remote-endpoint")); + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!(proof.status_availability["status"], "local_pinned"); + assert_eq!(proof.announced_at, 1_700_000_000); + assert_eq!(proof.admission.as_ref().unwrap()["accepted"], true); + assert_eq!( + proof.admission.as_ref().unwrap()["estimated_content_bytes"], + 22 + ); + assert_eq!(proof.remote_receipt.as_ref().unwrap()["verified"], true); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["status"], + "local_pinned" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["policy"], + "carrier_exact_import" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["quota"]["status"], + "within_quota" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["quota"]["enforced"], + true + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["repair_worker"]["worker"], + "content-provider" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["accounting"]["content_bytes"], + 22 + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["accounting"]["storage_quota_status"], + "observed_not_enforced" + ); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["target"], "content"); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!( + requests[0]["request"]["availability_requirements"]["max_storage_bytes_per_principal"], + 1024 + ); + assert_eq!(requests[0]["request"]["estimated_content_bytes"], 22); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!( + requests[1]["request"]["availability_policy"], + "carrier_replica" + ); + assert_eq!(requests[1]["request"]["object_did"], "did:key:zObject"); + assert_eq!(requests[2]["op"], "status"); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + } + + #[tokio::test] + async fn test_carrier_replication_falls_back_to_exact_import_when_remote_pin_fails() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) .await - .context("Failed to bind")?; + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; - let conn = tokio::time::timeout( - Duration::from_secs(timeout_secs), - endpoint.connect(addr, CARRIER_ALPN), + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher" + }), ) .await - .map_err(|_| anyhow::anyhow!("connect timed out"))? - .context("connect failed")?; + .unwrap(); - Ok(Self { - conn, - _endpoint: endpoint, - }) + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!(proof.status_availability["status"], "local_pinned"); + assert_eq!(proof.remote_receipt.as_ref().unwrap()["verified"], true); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 4); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["op"], "import_exact"); + assert_eq!(requests[2]["request"]["object_did"], "did:key:zObject"); + assert_eq!( + requests[2]["request"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!(requests[3]["op"], "status"); + assert!(!requests[2]["request"] + .to_string() + .contains("ticket:internal-secret")); } - pub async fn connect( - publisher_node_id: &str, - publisher_addrs: &[String], - timeout_secs: u64, - ) -> Result { - let public_key: iroh::PublicKey = publisher_node_id.parse().context("Invalid node ID")?; - let mut addr = iroh::EndpointAddr::from(public_key); - for addr_str in publisher_addrs { - if let Ok(sa) = addr_str.parse::() { - addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); - break; - } - if let Some((host, port_str)) = addr_str.rsplit_once(':') { - if let Ok(port) = port_str.parse::() { - if let Ok(mut resolved) = - tokio::net::lookup_host(format!("{}:{}", host, port)).await - { - if let Some(sa) = resolved.next() { - addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); - break; - } - } - } - } - } + #[tokio::test] + async fn test_carrier_replication_refuses_object_exact_fallback_without_block_graph_provider() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; - Self::connect_endpoint_addr(addr, timeout_secs).await + let err = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "availability_requirements": { + "repair_graph_kind": "ipld_dag" + } + }), + ) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("local block-graph export failed for arbitrary DAG repair")); + assert!(err.to_string().contains("refused object/exact fallback")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 2); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); } - pub async fn connect_trusted_source(source: &TrustedSource, timeout_secs: u64) -> Result { - let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); - let mut ticket_errors = Vec::new(); - for endpoint in ticket_endpoints { - match Self::connect_endpoint_addr(endpoint.clone(), timeout_secs).await { - Ok(client) => return Ok(client), - Err(err) => ticket_errors.push(err.to_string()), - } - } + #[tokio::test] + async fn test_carrier_replication_uses_block_graph_provider_for_arbitrary_dag() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + registry + .register_sub_provider("block-graph", Arc::new(MockCarrierBlockGraphProvider)) + .await + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; - let node_id = source_node_id(source) - .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; - let addrs = source_carrier_addrs(source); - match Self::connect(&node_id, &addrs, timeout_secs).await { - Ok(client) => Ok(client), - Err(err) if !ticket_errors.is_empty() => Err(anyhow::anyhow!( - "trusted source Carrier connection failed (ticket errors: {}; fallback error: {})", - ticket_errors.join(" | "), - err - )), - Err(err) => Err(err), - } + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher", + "availability_requirements": { + "repair_graph_kind": "ipld_dag" + } + }), + ) + .await + .unwrap(); + + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["verified"], + serde_json::Value::Bool(true) + ); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 5); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["target"], CONTENT_BLOCK_GRAPH_TARGET); + assert_eq!(requests[2]["op"], "import_graph"); + assert_eq!( + requests[2]["request"]["graph"]["schema"], + CONTENT_BLOCK_GRAPH_SCHEMA + ); + assert_eq!(requests[2]["request"]["object_did"], "did:key:zObject"); + assert_eq!( + requests[2]["request"]["publisher_did"], + "did:key:zPublisher" + ); + assert_eq!(requests[3]["target"], "content"); + assert_eq!(requests[3]["op"], "ensure"); + assert_eq!( + requests[3]["request"]["availability_policy"], + "carrier_block_graph_import" + ); + assert_eq!(requests[4]["op"], "status"); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); } - pub async fn release_head(&self) -> Result> { - let (mut send, recv) = self.conn.open_bi().await?; - let msg = serde_json::json!({"op":"release_head","path":""}); - let mut bytes = serde_json::to_vec(&msg)?; - bytes.push(b'\n'); - send.write_all(&bytes).await?; - send.finish()?; - let mut reader = BufReader::new(recv); - let mut line = String::new(); - reader.read_line(&mut line).await?; - let resp: serde_json::Value = serde_json::from_str(line.trim())?; - if resp["ok"].as_bool() == Some(true) { - Ok(Some(resp["release"].clone())) - } else { - Ok(None) - } + #[tokio::test] + async fn test_carrier_replication_prefers_object_import_when_manifest_exists() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierObjectContentProvider)) + .await + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; + + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zIgnoredSourceObject", + "publisher_did": "did:key:zIgnoredSourcePublisher" + }), + ) + .await + .unwrap(); + + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!(proof.status_availability["status"], "local_pinned"); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 4); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["op"], "import_object"); + assert_eq!(requests[2]["request"]["object_kind"], "document"); + assert_eq!(requests[2]["request"]["object_did"], "did:key:zObject"); + assert_eq!( + requests[2]["request"]["publisher_did"], + "did:key:zPublisher" + ); + assert_eq!(requests[2]["request"]["files"].as_array().unwrap().len(), 1); + assert_eq!( + requests[2]["request"]["files"][0]["path"], + serde_json::Value::String("index.md".to_string()) + ); + assert!(requests[2]["request"].get("stream").is_none()); + assert_eq!(requests[3]["op"], "status"); + assert!(!requests[2]["request"] + .to_string() + .contains("ticket:internal-secret")); } - pub async fn fetch_file(&self, path: &str) -> Result> { - let (mut send, mut recv) = self.conn.open_bi().await?; - let msg = serde_json::json!({"op":"file","path":path}); - let mut bytes = serde_json::to_vec(&msg)?; - bytes.push(b'\n'); - send.write_all(&bytes).await?; - send.finish()?; - let mut len_buf = [0u8; 8]; - recv.read_exact(&mut len_buf).await?; - let len = u64::from_be_bytes(len_buf) as usize; - if len > 200 * 1024 * 1024 { - let mut error_bytes = len_buf.to_vec(); - let tail = recv.read_to_end(16 * 1024).await?; - error_bytes.extend_from_slice(&tail); - if let Ok(text) = String::from_utf8(error_bytes) { - if let Ok(json) = serde_json::from_str::(text.trim()) { - if json["ok"].as_bool() == Some(false) { - let msg = json["error"] - .as_str() - .unwrap_or("trusted source returned an unknown file error"); - anyhow::bail!("trusted source file fetch failed for {}: {}", path, msg); - } + #[tokio::test] + async fn test_carrier_replication_stops_when_remote_admission_rejects() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: false, + reject_admission: true, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; + + let err = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "publisher_did": "did:key:zPublisher", + "estimated_content_bytes": 22, + "requirements": { + "max_storage_bytes_per_principal": 1 } - } - anyhow::bail!( - "invalid trusted source file reply for {} (declared {} bytes)", - path, - len - ); - } - let mut content = vec![0u8; len]; - recv.read_exact(&mut content).await?; - Ok(content) + }), + ) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("remote content admission rejected")); + assert!(err.to_string().contains("mock remote quota exceeded")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[0]["request"]["estimated_content_bytes"], 22); + assert!(!requests.iter().any(|request| request["op"] == "ensure")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_graph")); } -} -async fn fetch_file_with_timeout( - client: &CarrierClient, - path: &str, - timeout_secs: u64, -) -> Result> { - tokio::time::timeout(Duration::from_secs(timeout_secs), client.fetch_file(path)) + #[tokio::test] + async fn test_carrier_replication_rejects_unsigned_remote_admission() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: false, + reject_admission: false, + omit_admission_receipt: true, + }); + registry.set_carrier_invoker(invoker.clone()).await; + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; + + let err = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "publisher_did": "did:key:zPublisher", + "estimated_content_bytes": 22, + "requirements": { + "max_storage_bytes_per_principal": 1024 + } + }), + ) .await - .map_err(|_| anyhow::anyhow!("file fetch timed out after {}s", timeout_secs))? -} + .unwrap_err(); + + assert!(err + .to_string() + .contains("remote content admission missing signed receipt")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "admission"); + assert!(!requests.iter().any(|request| request["op"] == "ensure")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_graph")); + } -pub async fn fetch_file_from_trusted_source( - source: &TrustedSource, - path: &str, - connect_timeout_secs: u64, - fetch_timeout_secs: u64, -) -> Result> { - let mut errors = Vec::new(); - let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); - for (index, endpoint) in ticket_endpoints.into_iter().enumerate() { - match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { - Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { - Ok(bytes) => return Ok(bytes), - Err(err) => errors.push(format!("ticket[{index}] fetch failed: {err}")), + #[test] + fn test_carrier_peer_selection_proof_redacts_connect_tickets() { + let proof = CarrierReplicationProof { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 4, + reputation_reason: "local_runtime_successes:1;failures:0".to_string(), + ensure_status: "local_pinned".to_string(), + admission: Some(serde_json::json!({ + "schema": "elastos.content.admission/v1", + "accepted": true, + "status": "accepted", + "quota": { + "status": "within_quota", + "enforced": true + } + })), + status_availability: serde_json::json!({"status": "local_pinned"}), + remote_receipt: Some(serde_json::json!({ + "schema": "elastos.content.availability.receipt/v1", + "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "status": "local_pinned", + "replicas": 1, + "quota": { + "status": "within_quota", + "enforced": true + }, + "accounting": { + "observed": true, + "content_bytes": 22, + "storage_quota_status": "observed_not_enforced" + }, + "signer_did": "did:key:zRemoteContentProvider", + "verified": true + })), + transfer: Some(serde_json::json!({ + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote" + } + })), + checked_at: 1_700_000_001, + }; + + let peer_selection = carrier_peer_selection_json( + "elastos://carrier/content/test/availability", + "did:key:zLocal", + 1, + &[proof], + true, + CarrierPeerAttestationExchangeView { + configured: false, + receipt: None, }, - Err(err) => errors.push(format!("ticket[{index}] connect failed: {err}")), - } + ); + + assert_eq!(peer_selection["mode"], "carrier_provider_replication"); + assert_eq!(peer_selection["live_multi_peer_proof"], true); + assert_eq!( + peer_selection["peer_reputation_policy"]["schema"], + CARRIER_PEER_REPUTATION_SCHEMA + ); + assert_eq!( + peer_selection["peer_reputation_policy"]["status"], + "local_history_applied" + ); + assert_eq!( + peer_selection["peer_reputation_policy"]["federation"]["configured"], + false + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["status"], + "live_peer_proof_without_attestation_exchange" + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["local_proof"] + ["verified_remote_content_receipts"], + 1 + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["attestation_exchange"] + ["configured"], + false + ); + assert_eq!(peer_selection["replicas"].as_array().unwrap().len(), 2); + assert_eq!(peer_selection["replicas"][1]["score"], 90); + assert_eq!( + peer_selection["replicas"][1]["selection_reason"], + "signed_announcement+endpoint_advertised+fresh" + ); + assert_eq!( + peer_selection["replicas"][1]["local_reputation"]["scope"], + "local_runtime" + ); + assert_eq!( + peer_selection["replicas"][1]["local_reputation"]["score_delta"], + 4 + ); + assert_eq!( + peer_selection["replicas"][1]["local_reputation"]["reason"], + "local_runtime_successes:1;failures:0" + ); + assert_eq!( + peer_selection["replicas"][1]["remote_receipt"]["quota"]["status"], + "within_quota" + ); + assert_eq!( + peer_selection["replicas"][1]["remote_receipt"]["accounting"]["content_bytes"], + 22 + ); + assert_eq!( + peer_selection["replicas"][1]["remote_receipt"]["accounting"]["storage_quota_status"], + "observed_not_enforced" + ); + assert_eq!(peer_selection["replicas"][1]["admission"]["accepted"], true); + assert_eq!( + peer_selection["replicas"][1]["admission"]["quota"]["status"], + "within_quota" + ); + assert!(!peer_selection + .to_string() + .contains("ticket:internal-secret")); + assert!(!peer_selection + .to_string() + .contains("connect_ticket\": \"ticket")); } - let relay_endpoints = relay_only_ticket_endpoints(source); - for (index, endpoint) in relay_endpoints.into_iter().enumerate() { - match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { - Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { - Ok(bytes) => return Ok(bytes), - Err(err) => errors.push(format!("relay[{index}] fetch failed: {err}")), + #[tokio::test] + async fn test_carrier_peer_attestation_exchange_posts_signed_request_and_verifies_receipt() { + let signed_receipt = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:test", + "receipt_id": "peer-attestation-receipt:123", + "accepted": true, + })); + let (url, handle) = spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "peer-attestation:test", + "receipt_id": "peer-attestation-receipt:123", + "receipt": signed_receipt, + })); + let client = CarrierPeerAttestationExchangeClient::from_config(serde_json::json!({ + "url": url, + "authorization": "Bearer peer-attestation-test", + "timeout_secs": 5, + })) + .unwrap(); + let (signing_key, _) = elastos_identity::derive_did(&[47u8; 32]); + let proof = carrier_peer_attestation_test_proof(); + let request = carrier_peer_attestation_exchange_request( + &signing_key, + "bafyattest", + "elastos://carrier/content/test/availability", + "did:key:zLocal", + std::slice::from_ref(&proof), + true, + 1_700_000_002, + ) + .unwrap(); + + let receipt = client.exchange(&request).await.unwrap(); + + assert_eq!( + receipt["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!(receipt["status"], "accepted"); + assert_eq!(receipt["accepted"], true); + assert_eq!(receipt["signed_receipt"]["verified"], true); + assert_eq!( + receipt["signed_receipt"]["payload_schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!(receipt["exchange"]["credential_exposed"], false); + let peer_selection = carrier_peer_selection_json( + "elastos://carrier/content/test/availability", + "did:key:zLocal", + 1, + &[proof], + true, + CarrierPeerAttestationExchangeView { + configured: true, + receipt: Some(&receipt), }, - Err(err) => errors.push(format!("relay[{index}] connect failed: {err}")), - } + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["status"], + "attestation_exchange_accepted" + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["attestation_exchange"] + ["configured"], + true + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["attestation_exchange"] + ["signed_reputation_receipts"], + true + ); + + let request_text = handle.join().unwrap(); + assert!(request_text.starts_with("POST /peer-attestation/exchange HTTP/1.1")); + assert!(request_text + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer peer-attestation-test"))); + assert!(request_text.contains(CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA)); + assert!(request_text.contains("\"signature\"")); + assert!(request_text.contains("\"signer_did\"")); + assert!(!request_text + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("peer-attestation-test")); + assert!(!request_text.contains("ticket:internal-secret")); } - let node_id = source_node_id(source) - .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; - let addrs = source_carrier_addrs(source); - match CarrierClient::connect(&node_id, &addrs, connect_timeout_secs).await { - Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { - Ok(bytes) => Ok(bytes), - Err(err) => { - errors.push(format!("direct fetch failed: {err}")); - Err(anyhow::anyhow!( - "trusted source Carrier fetch failed: {}", - errors.join(" | ") - )) - } - }, - Err(err) => { - errors.push(format!("direct connect failed: {err}")); - Err(anyhow::anyhow!( - "trusted source Carrier fetch failed: {}", - errors.join(" | ") - )) - } + #[tokio::test] + async fn test_carrier_peer_attestation_exchange_accepts_endpoint_quorum() { + let signed_receipt_a = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:a", + "receipt_id": "peer-attestation-receipt:a", + "accepted": true, + })); + let (url_a, handle_a) = spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "peer-attestation:a", + "receipt_id": "peer-attestation-receipt:a", + "receipt": signed_receipt_a, + })); + let signed_receipt_b = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:b", + "receipt_id": "peer-attestation-receipt:b", + "accepted": true, + })); + let (url_b, handle_b) = spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "peer-attestation:b", + "receipt_id": "peer-attestation-receipt:b", + "receipt": signed_receipt_b, + })); + let client = CarrierPeerAttestationExchangeClient::from_config(serde_json::json!({ + "quorum": 2, + "endpoints": [ + { + "id": "peer-attestation-a", + "url": url_a, + "authorization": "Bearer peer-attestation-secret-a", + "timeout_secs": 5 + }, + { + "id": "peer-attestation-b", + "url": url_b, + "authorization": "Bearer peer-attestation-secret-b", + "timeout_secs": 5 + } + ] + })) + .unwrap(); + let (signing_key, _) = elastos_identity::derive_did(&[47u8; 32]); + let proof = carrier_peer_attestation_test_proof(); + let request = carrier_peer_attestation_exchange_request( + &signing_key, + "bafyattest", + "elastos://carrier/content/test/availability", + "did:key:zLocal", + std::slice::from_ref(&proof), + true, + 1_700_000_002, + ) + .unwrap(); + + let receipt = client.exchange(&request).await.unwrap(); + + assert_eq!(receipt["status"], "accepted"); + assert_eq!(receipt["accepted"], true); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["endpoint_count"], 2); + assert_eq!(receipt["quorum"]["accepted"], 2); + assert_eq!(receipt["signed_receipt"]["verified"], true); + assert_eq!(receipt["exchange"]["multi_endpoint"], true); + assert_eq!(receipt["exchange"]["endpoint_count"], 2); + assert!(!receipt.to_string().contains("peer-attestation-secret-a")); + assert!(!receipt.to_string().contains("peer-attestation-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a.lines().any( + |line| line.eq_ignore_ascii_case("authorization: Bearer peer-attestation-secret-a") + )); + assert!(request_b.lines().any( + |line| line.eq_ignore_ascii_case("authorization: Bearer peer-attestation-secret-b") + )); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("peer-attestation-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("peer-attestation-secret-b")); + assert!(!request_a.contains("ticket:internal-secret")); + assert!(!request_b.contains("ticket:internal-secret")); } -} -pub async fn try_p2p_discovery( - publisher_node_id: &str, - publisher_addrs: &[String], - timeout_secs: u64, -) -> Option { - let client = CarrierClient::connect(publisher_node_id, publisher_addrs, timeout_secs) - .await - .ok()?; - let release = client.release_head().await.ok()??; - release["head_cid"].as_str().map(|s| s.to_string()) -} + #[tokio::test] + async fn test_carrier_peer_attestation_exchange_rejects_endpoint_quorum_failure() { + let signed_receipt = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:a", + "receipt_id": "peer-attestation-receipt:a", + "accepted": true, + })); + let (accepted_url, accepted_handle) = + spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "receipt": signed_receipt, + })); + let (rejected_url, rejected_handle) = + spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": false, + "status": "rejected", + "reason": "reputation trust policy rejected peer", + })); + let client = CarrierPeerAttestationExchangeClient::from_config(serde_json::json!({ + "quorum": 2, + "endpoints": [ + {"id": "peer-attestation-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "peer-attestation-b", "url": rejected_url, "timeout_secs": 5} + ] + })) + .unwrap(); + let (signing_key, _) = elastos_identity::derive_did(&[47u8; 32]); + let proof = carrier_peer_attestation_test_proof(); + let request = carrier_peer_attestation_exchange_request( + &signing_key, + "bafyattest", + "elastos://carrier/content/test/availability", + "did:key:zLocal", + std::slice::from_ref(&proof), + true, + 1_700_000_002, + ) + .unwrap(); -#[cfg(test)] -mod tests { - use super::*; + let receipt = client.exchange(&request).await.unwrap(); + + assert_eq!(receipt["status"], "rejected"); + assert_eq!(receipt["accepted"], false); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["accepted"], 1); + assert_eq!(receipt["quorum"]["rejected"], 1); + assert_eq!(receipt["signed_receipt"]["verified"], true); + assert!(receipt["reason"] + .as_str() + .unwrap() + .contains("reputation trust policy rejected peer")); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA)); + } #[test] - fn test_topic_hash_deterministic() { - let h1 = topic_hash("#general"); - let h2 = topic_hash("#general"); - assert_eq!(h1, h2, "same topic name must produce same hash"); + fn test_remote_content_receipt_peer_selection_summary_redacts_replica_rows() { + let summary = remote_content_receipt_peer_selection_summary(Some(&serde_json::json!({ + "mode": "carrier_provider_replication", + "strategy": "signed_announcement_then_provider_invoke", + "live_multi_peer_proof": true, + "peer_reputation_policy": { + "schema": CARRIER_PEER_REPUTATION_SCHEMA, + "policy": "local_runtime_reputation", + "status": "local_history_applied", + "federation": { + "configured": false, + "cross_runtime_reputation": false + } + }, + "peer_attestation_exchange_policy": { + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": "no_cross_runtime_attestation_exchange", + "status": "live_peer_proof_without_attestation_exchange", + "attestation_exchange": { + "configured": false, + "signed_reputation_receipts": false + } + }, + "replicas": [ + { + "role": "local", + "node_did": "did:key:zLocal", + "status": "local_pinned" + }, + { + "role": "remote", + "node_did": "did:key:zRemote", + "endpoint_id": "remote-endpoint", + "score": 94, + "selection_reason": "signed_announcement+endpoint_advertised+fresh+local_reputation_positive", + "local_reputation": { + "scope": "local_runtime", + "score_delta": 4, + "reason": "local_runtime_successes:1;failures:0" + }, + "status": "local_pinned", + "transfer": { + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "connect_ticket": "ticket:internal-secret" + } + }, + "remote_receipt": { + "signer_did": "did:key:zRemoteContentProvider" + } + } + ] + }))); - let h3 = topic_hash("#other"); - assert_ne!(h1, h3, "different topics must produce different hashes"); + assert_eq!(summary["mode"], "carrier_provider_replication"); + assert_eq!( + summary["peer_reputation_policy"]["status"], + "local_history_applied" + ); + assert_eq!( + summary["peer_reputation_policy"]["federation"]["configured"], + false + ); + assert_eq!( + summary["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + summary["peer_attestation_exchange_policy"]["attestation_exchange"]["configured"], + false + ); + assert_eq!(summary["replica_count"], 2); + assert_eq!(summary["remote_replicas"], 1); + assert_eq!(summary["replica_summary_limit"], 5); + assert_eq!(summary["replicas_truncated"], false); + assert_eq!(summary["replicas"].as_array().unwrap().len(), 2); + assert_eq!(summary["replicas"][1]["node_did"], "did:key:zRemote"); + assert_eq!(summary["replicas"][1]["score"], 94); + assert_eq!( + summary["replicas"][1]["local_reputation"]["scope"], + "local_runtime" + ); + assert!(!summary.to_string().contains("ticket:internal-secret")); + assert!(!summary.to_string().contains("connect_ticket")); + assert!(!summary.to_string().contains("remote_receipt")); } #[test] - fn test_gossip_message_serialization() { - let msg = GossipMessage { - sender_id: "did:key:z6MkTest".to_string(), - sender_nick: "alice".to_string(), - content: "hello world".to_string(), - ts: 1700000000, - nonce: 42, - signature: None, - sender_session_id: None, + fn test_remote_content_receipt_peer_selection_summary_marks_truncated_rows() { + let replicas = (0..6) + .map(|index| { + serde_json::json!({ + "role": "remote", + "node_did": format!("did:key:zRemote{index}"), + "score": 80 + index, + "transfer": { + "carrier": { + "route": "connect_ticket", + "connect_ticket": format!("ticket:secret-{index}") + } + } + }) + }) + .collect::>(); + let summary = remote_content_receipt_peer_selection_summary(Some(&serde_json::json!({ + "mode": "carrier_provider_replication", + "live_multi_peer_proof": true, + "replicas": replicas + }))); + + assert_eq!(summary["replica_count"], 6); + assert_eq!(summary["remote_replicas"], 6); + assert_eq!(summary["replica_summary_limit"], 5); + assert_eq!(summary["replicas_truncated"], true); + assert_eq!(summary["replicas"].as_array().unwrap().len(), 5); + assert!(!summary.to_string().contains("ticket:secret")); + assert!(!summary.to_string().contains("connect_ticket")); + } + + #[test] + fn test_carrier_quota_marks_impossible_replica_requirements() { + let requirements = CarrierAvailabilityRequirements { + min_replicas: 4, + max_replicas: Some(2), + require_live_multi_peer_proof: true, + repair_graph_kind: CarrierRepairGraphKind::Auto, }; - let bytes = serde_json::to_vec(&msg).unwrap(); - let decoded: GossipMessage = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(decoded.sender_id, "did:key:z6MkTest"); - assert_eq!(decoded.sender_nick, "alice"); - assert_eq!(decoded.content, "hello world"); - assert_eq!(decoded.ts, 1700000000); - assert_eq!(decoded.nonce, 42); - assert!(decoded.signature.is_none()); + + let quota = carrier_quota_json(requirements, 2, 2); + + assert_eq!(quota["policy"], "carrier_provider_quota"); + assert_eq!(quota["enforced"], true); + assert_eq!(quota["status"], "requirements_exceed_quota"); + assert_eq!(quota["effective_max_replicas"], 2); + assert_eq!(quota["requirements_exceed_quota"], true); } #[test] - fn test_gossip_message_with_signature() { - let msg = GossipMessage { - sender_id: "did:key:z6MkTest".to_string(), - sender_nick: "bob".to_string(), - content: "signed msg".to_string(), - ts: 1700000000, - nonce: 1, - signature: Some("deadbeef".to_string()), - sender_session_id: None, + fn test_carrier_remote_candidate_limit_keeps_live_multi_peer_requirement() { + let requirements = CarrierAvailabilityRequirements { + min_replicas: 2, + max_replicas: None, + require_live_multi_peer_proof: true, + repair_graph_kind: CarrierRepairGraphKind::Auto, }; - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("\"signature\":\"deadbeef\"")); - let decoded: GossipMessage = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.signature, Some("deadbeef".to_string())); + assert_eq!(carrier_remote_candidate_limit(requirements, 2, 2), 1); + + let quota_blocked = CarrierAvailabilityRequirements { + min_replicas: 2, + max_replicas: Some(2), + require_live_multi_peer_proof: true, + repair_graph_kind: CarrierRepairGraphKind::Auto, + }; + assert_eq!(carrier_remote_candidate_limit(quota_blocked, 2, 2), 0); + } + + #[tokio::test] + async fn test_carrier_availability_ensure_proves_remote_replica_via_provider_plane() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let topic_name = content_availability_topic_name(cid); + let (remote_message, remote_did) = signed_content_availability_message( + cid, + [22u8; 32], + "ticket:remote-secret", + "remote-endpoint", + 1_700_000_000, + ); + let (local_sk, local_did) = elastos_identity::derive_did(&[21u8; 32]); + let endpoint = Endpoint::builder() + .secret_key(iroh::SecretKey::from_bytes(&local_sk.to_bytes())) + .bind() + .await + .unwrap(); + let memory_lookup = MemoryLookup::new(); + endpoint.address_lookup().add(memory_lookup.clone()); + let gossip = Gossip::builder().spawn(endpoint.clone()); + let state = Arc::new(Mutex::new(GossipState::new( + endpoint.clone(), + gossip, + memory_lookup, + Some(local_sk), + Some(local_did), + ))); + { + let mut guard = state.lock().await; + guard.joined_topics.insert(topic_name.clone()); + guard.buffers.lock().await.insert( + topic_name, + TopicBuffer { + messages: VecDeque::from([remote_message]), + base_index: 0, + }, + ); + } + + let registry = Arc::new(ProviderRegistry::new()); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + let provider = + CarrierAvailabilityProvider::with_provider_registry(state, Arc::downgrade(®istry)); + let response = provider + .send_raw(&serde_json::json!({ + "op": "ensure", + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "local": { + "status": "local_pinned", + "provider": "ipfs-provider", + "replicas": 1 + }, + "requirements": { + "min_replicas": 2, + "max_replicas": 2, + "require_live_multi_peer_proof": true + }, + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher" + })) + .await + .unwrap(); + + let availability = &response["data"]["availability"]; + assert_eq!(response["status"], "ok"); + assert_eq!(availability["status"], "network_available"); + assert_eq!(availability["replicas"], 2); + assert_eq!( + availability["peer_selection"]["mode"], + "carrier_provider_replication" + ); + assert_eq!( + availability["peer_selection"]["live_multi_peer_proof"], + true + ); + assert_eq!(availability["quota"]["policy"], "carrier_provider_quota"); + assert_eq!(availability["quota"]["enforced"], true); + assert_eq!(availability["quota"]["status"], "at_quota"); + assert_eq!(availability["quota"]["effective_max_replicas"], 2); + assert_eq!(availability["quota"]["requirements_exceed_quota"], false); + assert_eq!(availability["quota"]["used_replicas"], 2); + assert_eq!( + availability["abuse_controls"]["policy"], + "carrier_provider_invocation_guardrail" + ); + assert_eq!(availability["abuse_controls"]["enforced"], true); + assert_eq!(availability["abuse_controls"]["candidate_count"], 1); + assert_eq!(availability["abuse_controls"]["attempt_limit"], 1); + assert_eq!(availability["abuse_controls"]["attempted_operations"], 1); + assert_eq!(availability["abuse_controls"]["failed_operations"], 0); + assert_eq!(availability["abuse_controls"]["throttled"], false); + assert_eq!( + availability["peer_selection"]["replicas"][1]["remote_receipt"]["abuse_controls"] + ["policy"], + "carrier_provider_invocation_guardrail" + ); + assert_eq!( + availability["peer_selection"]["replicas"][1]["remote_receipt"]["abuse_controls"] + ["attempted_operations"], + 1 + ); + assert_eq!(availability["repair_worker"]["status"], "healthy"); + assert_eq!( + availability["repair_graph"]["schema"], + CONTENT_REPAIR_GRAPH_SCHEMA + ); + assert_eq!( + availability["repair_graph"]["status"], + "bounded_import_supported" + ); + assert_eq!( + availability["repair_graph"]["refuses_exact_fallback_for_arbitrary_dag"], + true + ); + assert_eq!( + availability["peer_selection"]["replicas"][1]["remote_receipt"]["repair_graph"] + ["status"], + "bounded_import_supported" + ); + assert_eq!( + availability["storage_market"]["mode"], + "carrier_provider_receipts" + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["remote"]["admission_preflight"], + true + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["remote"] + ["signed_admission_receipts"], + true + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["federation"]["configured"], + false + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["federation"] + ["signed_admission_receipt_exchange"], + true + ); + assert_eq!( + availability["peer_selection"]["replicas"][1]["admission"]["receipt"]["verified"], + true + ); + assert_eq!( + availability["storage_market"]["settlement"], + "not_configured" + ); + assert_eq!(availability["storage_market"]["escrow"], "not_configured"); + assert_eq!( + availability["storage_market"]["status"], + "receipt_proven_no_market_settlement" + ); + assert_eq!( + availability["storage_market"]["settlement_policy"]["schema"], + "elastos.content.storage-settlement-policy/v1" + ); + assert_eq!( + availability["storage_market"]["settlement_policy"]["production_federation"] + ["configured"], + false + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["status"], + "remote_admission_preflight_no_market_admission" + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["current_admission"] + ["remote_content_admission_preflight"], + true + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["current_admission"] + ["signed_admission_receipts"], + true + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["production_market"]["configured"], + false + ); + assert!(availability + .to_string() + .contains(&format!("\"node_did\":\"{remote_did}\""))); + assert!(!availability.to_string().contains("ticket:remote-secret")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["ticket"], "ticket:remote-secret"); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[0]["request"]["object_did"], "did:key:zObject"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[1]["request"]["object_did"], "did:key:zObject"); + assert_eq!(requests[2]["op"], "status"); + endpoint.close().await; + } + + #[tokio::test] + async fn test_carrier_availability_requires_remote_attempt_for_live_proof_when_min_met() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let topic_name = content_availability_topic_name(cid); + let (remote_message, _remote_did) = signed_content_availability_message( + cid, + [23u8; 32], + "ticket:remote-secret", + "remote-endpoint", + 1_700_000_000, + ); + let (local_sk, local_did) = elastos_identity::derive_did(&[24u8; 32]); + let endpoint = Endpoint::builder() + .secret_key(iroh::SecretKey::from_bytes(&local_sk.to_bytes())) + .bind() + .await + .unwrap(); + let memory_lookup = MemoryLookup::new(); + endpoint.address_lookup().add(memory_lookup.clone()); + let gossip = Gossip::builder().spawn(endpoint.clone()); + let state = Arc::new(Mutex::new(GossipState::new( + endpoint.clone(), + gossip, + memory_lookup, + Some(local_sk), + Some(local_did), + ))); + { + let mut guard = state.lock().await; + guard.joined_topics.insert(topic_name.clone()); + guard.buffers.lock().await.insert( + topic_name, + TopicBuffer { + messages: VecDeque::from([remote_message]), + base_index: 0, + }, + ); + } + + let registry = Arc::new(ProviderRegistry::new()); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + let provider = + CarrierAvailabilityProvider::with_provider_registry(state, Arc::downgrade(®istry)); + let response = provider + .send_raw(&serde_json::json!({ + "op": "ensure", + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "local": { + "status": "local_pinned", + "provider": "ipfs-provider", + "replicas": 2 + }, + "requirements": { + "min_replicas": 2, + "require_live_multi_peer_proof": true + } + })) + .await + .unwrap(); + + let availability = &response["data"]["availability"]; + assert_eq!(availability["status"], "network_available"); + assert_eq!(availability["replicas"], 3); + assert_eq!( + availability["peer_selection"]["live_multi_peer_proof"], + true + ); + assert_eq!(availability["abuse_controls"]["attempt_limit"], 1); + assert_eq!(availability["abuse_controls"]["attempted_operations"], 1); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["op"], "status"); + endpoint.close().await; + } + + #[tokio::test] + async fn test_carrier_content_fetch_reads_from_internal_ipfs_provider() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("ipfs", Arc::new(MockCarrierIpfsProvider)) + .await + .unwrap(); + + let bytes = carrier_content_fetch_bytes( + ®istry, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "docs/readme.md", + ) + .await + .unwrap(); + + assert_eq!(bytes, b"carrier content"); + let err = carrier_content_fetch_bytes( + ®istry, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "../secret", + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("invalid segment")); } #[test] diff --git a/elastos/crates/elastos-server/src/content.rs b/elastos/crates/elastos-server/src/content.rs index 4061c406..acc23e3e 100644 --- a/elastos/crates/elastos-server/src/content.rs +++ b/elastos/crates/elastos-server/src/content.rs @@ -1,15 +1,16 @@ //! Content availability provider. //! //! This is the capsule-facing `elastos://content/*` contract. The first -//! implementation delegates bytes to the existing low-level IPFS/Kubo backend -//! and reports honest local availability status. +//! implementation delegates bytes to the existing low-level IPFS/Kubo backend, +//! then asks the Carrier/provider availability plane to advertise or replicate +//! that CID without exposing raw IPFS/Kubo authority to ordinary capsules. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fs::OpenOptions; use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Weak}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use base64::Engine as _; @@ -17,7 +18,9 @@ use elastos_common::protected_content::{ validate_protected_content_key_envelope_algorithms, SealedObjectV1, SEALED_OBJECT_SCHEMA, }; use elastos_runtime::provider::{ - Provider, ProviderError, ProviderRegistry, ResourceRequest, ResourceResponse, + Provider, ProviderByteRange, ProviderError, ProviderInvocation, ProviderInvocationTransport, + ProviderProgress, ProviderRegistry, ProviderStreamOptions, ProviderTransfer, ResourceRequest, + ResourceResponse, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -25,6 +28,73 @@ use sha2::Digest; const AVAILABILITY_RECEIPT_SCHEMA: &str = "elastos.content.availability.receipt/v1"; const AVAILABILITY_RECEIPT_DOMAIN: &str = "elastos.content.availability.receipt.v1"; +const AVAILABILITY_DASHBOARD_SCHEMA: &str = "elastos.content.availability.dashboard/v1"; +const CONTENT_ADMISSION_DOMAIN: &str = "elastos.content.admission.v1"; +const CONTENT_ACCOUNTING_SCHEMA: &str = "elastos.content.accounting/v1"; +const CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA: &str = + "elastos.content.storage-accounting.ledger/v1"; +const CONTENT_STORAGE_ACCOUNTING_ENTRY_SCHEMA: &str = "elastos.content.storage-accounting.entry/v1"; +const CONTENT_STORAGE_QUOTA_SCHEMA: &str = "elastos.content.storage-quota/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA: &str = + "elastos.content.federated-quota-ledger-policy/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.content.federated-quota-ledger.exchange-request/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_DOMAIN: &str = + "elastos.content.federated-quota-ledger.exchange-request.v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.content.federated-quota-ledger.exchange-receipt/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_DOMAIN: &str = + "elastos.content.federated-quota-ledger.exchange-receipt.v1"; +const CONTENT_ADMISSION_SCHEMA: &str = "elastos.content.admission/v1"; +const CONTENT_ABUSE_CONTROLS_SCHEMA: &str = "elastos.content.abuse-controls/v1"; +const CONTENT_NETWORK_ABUSE_POLICY_SCHEMA: &str = "elastos.content.network-abuse-policy/v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.content.federated-abuse-control.exchange-request/v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_DOMAIN: &str = + "elastos.content.federated-abuse-control.exchange-request.v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.content.federated-abuse-control.exchange-receipt/v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_DOMAIN: &str = + "elastos.content.federated-abuse-control.exchange-receipt.v1"; +const CONTENT_OPERATOR_DASHBOARD_SCHEMA: &str = "elastos.content.operator-dashboard/v1"; +const CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA: &str = + "elastos.content.federated-operator-alerting-policy/v1"; +const CONTENT_OPERATOR_ALERT_SCHEMA: &str = "elastos.content.operator-alert/v1"; +const CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA: &str = "elastos.content.operator-alert.receipt/v1"; +const CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.content.federated-operator-alert.exchange-request/v1"; +const CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.content.federated-operator-alert.exchange-receipt/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA: &str = + "elastos.carrier.peer-attestation-exchange-policy/v1"; +const CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA: &str = + "elastos.content.storage-settlement-policy/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA: &str = + "elastos.content.storage-market-admission-policy/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA: &str = + "elastos.content.storage-market-admission.request/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA: &str = + "elastos.content.storage-market-admission.decision/v1"; +const REPAIR_TASK_SCHEMA: &str = "elastos.content.repair-task/v1"; +const REPAIR_WORKER_RUN_SCHEMA: &str = "elastos.content.repair-worker.run/v1"; +const REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA: &str = "elastos.content.repair-worker.abuse-controls/v1"; +const REPAIR_FLEET_SCHEMA: &str = "elastos.content.repair-fleet/v1"; +const EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA: &str = "elastos.content.external-repair-fleet-policy/v1"; +const EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA: &str = + "elastos.content.external-repair-fleet.dispatch-request/v1"; +const EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA: &str = + "elastos.content.external-repair-fleet.dispatch-receipt/v1"; +const REPAIR_RETRY_DELAY_SECS: u64 = 5 * 60; +const REPAIR_HEALTH_CHECK_DELAY_SECS: u64 = 60 * 60; +const REPAIR_WORKER_DEFAULT_LIMIT: usize = 25; +const REPAIR_WORKER_MAX_LIMIT: usize = 100; +const REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS: u32 = 3; +const REPAIR_WORKER_MAX_ATTEMPTS_LIMIT: u32 = 25; +const REPAIR_WORKER_DEFAULT_FAILURE_BUDGET: u32 = 10; +const REPAIR_WORKER_MAX_FAILURE_BUDGET: u32 = 100; +const IMPORT_EXACT_MAX_BYTES: usize = 64 * 1024 * 1024; +const IMPORT_OBJECT_MAX_FILES: usize = 512; +const AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT: usize = 10; const OBJECT_MANIFEST_SCHEMA: &str = "elastos.content.object.manifest/v1"; const OBJECT_MANIFEST_PATH: &str = "_elastos_object.json"; const SEALED_OBJECT_PATH: &str = "sealed.json"; @@ -34,1360 +104,8296 @@ pub const CONTENT_OBJECT_MANIFEST_PATH: &str = OBJECT_MANIFEST_PATH; pub struct ContentProvider { data_dir: PathBuf, registry: Weak, + operator_alert_sink: Option, + federated_abuse_control_exchange: Option, + federated_quota_ledger_exchange: Option, + federated_operator_alert_exchange: Option, + storage_market_admission: Option, + external_repair_fleet: Option, } -impl ContentProvider { - pub fn new(data_dir: PathBuf, registry: Weak) -> Self { - Self { data_dir, registry } - } +#[derive(Default)] +pub struct ContentProviderExternalConfigs { + pub operator_alert_sink: Option, + pub storage_market_admission: Option, + pub external_repair_fleet: Option, + pub federated_operator_alert_exchange: Option, + pub federated_quota_ledger_exchange: Option, + pub federated_abuse_control_exchange: Option, +} - fn registry(&self) -> Result, ProviderError> { - self.registry.upgrade().ok_or_else(|| { - ProviderError::Provider("content provider registry unavailable".to_string()) - }) - } +#[derive(Debug, Clone)] +struct ContentOperatorAlertSink { + url: String, + authorization: Option, + timeout_secs: u64, } -pub async fn publish_directory_via_provider( - registry: &ProviderRegistry, - dir: &Path, - object_did: Option<&str>, - publisher_did: Option<&str>, -) -> anyhow::Result { - publish_directory_via_provider_with_kind(registry, dir, "directory", object_did, publisher_did) - .await +#[derive(Debug, Clone)] +struct ContentFederatedAbuseControlExchangeClient { + endpoints: Vec, + quorum: usize, } -pub async fn publish_directory_via_provider_with_kind( - registry: &ProviderRegistry, - dir: &Path, - object_kind: &str, - object_did: Option<&str>, - publisher_did: Option<&str>, -) -> anyhow::Result { - publish_directory_via_provider_with_kind_and_links( - registry, - dir, - object_kind, - object_did, - publisher_did, - &[], - ) - .await +#[derive(Debug, Clone)] +struct ContentFederatedAbuseControlExchangeEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, } -pub async fn publish_directory_via_provider_with_kind_and_links( - registry: &ProviderRegistry, - dir: &Path, - object_kind: &str, - object_did: Option<&str>, - publisher_did: Option<&str>, - links: &[(String, String)], -) -> anyhow::Result { - let mut files = Vec::new(); - crate::ipfs::collect_files_for_ipfs(dir, dir, &mut files)?; - if files.is_empty() { - anyhow::bail!("No files found in {}", dir.display()); - } +#[derive(Debug, Clone)] +struct ContentFederatedQuotaLedgerExchangeClient { + endpoints: Vec, + quorum: usize, +} - let mut entries = Vec::new(); - for rel_path in &files { - let abs_path = dir.join(rel_path); - let bytes = std::fs::read(&abs_path)?; - entries.push(json!({ - "path": rel_path.to_string_lossy().replace('\\', "/"), - "data": base64::engine::general_purpose::STANDARD.encode(bytes), - })); - } +#[derive(Debug, Clone)] +struct ContentFederatedQuotaLedgerExchangeEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} - let mut request = json!({ - "op": "publish", - "kind": "directory", - "object_kind": object_kind, - "files": entries, - "pin": true, - }); - if let Some(object_did) = object_did { - request["object_did"] = Value::String(object_did.to_string()); - } - if let Some(publisher_did) = publisher_did { - request["publisher_did"] = Value::String(publisher_did.to_string()); - } - if !links.is_empty() { - request["links"] = Value::Array( - links - .iter() - .map(|(rel, cid)| { - json!({ - "rel": rel, - "cid": cid, - }) - }) - .collect(), - ); - } +#[derive(Debug, Clone)] +struct ContentFederatedOperatorAlertExchangeClient { + url: String, + authorization: Option, + timeout_secs: u64, +} - let response = registry - .send_raw("content", &request) - .await - .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; - content_response_cid(&response) +#[derive(Debug, Clone)] +struct ContentStorageMarketAdmissionClient { + endpoints: Vec, + quorum: usize, } -pub async fn publish_bytes_via_provider( - registry: &ProviderRegistry, - filename: &str, - bytes: &[u8], - object_did: Option<&str>, - publisher_did: Option<&str>, -) -> anyhow::Result { - let mut request = json!({ - "op": "publish", - "kind": "file", - "filename": filename, - "data": base64::engine::general_purpose::STANDARD.encode(bytes), - "pin": true, - }); - if let Some(object_did) = object_did { - request["object_did"] = Value::String(object_did.to_string()); - } - if let Some(publisher_did) = publisher_did { - request["publisher_did"] = Value::String(publisher_did.to_string()); - } +#[derive(Debug, Clone)] +struct ContentStorageMarketAdmissionEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} - let response = registry - .send_raw("content", &request) - .await - .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; - content_response_cid(&response) +#[derive(Debug, Clone)] +struct ContentExternalRepairFleetClient { + endpoints: Vec, + quorum: usize, } -pub async fn fetch_bytes_via_provider( - registry: &ProviderRegistry, - cid: &str, - path: Option<&str>, -) -> anyhow::Result> { - let mut request = json!({ - "op": "fetch", - "cid": cid, - }); - if let Some(path) = path.filter(|path| !path.is_empty()) { - request["path"] = Value::String(path.to_string()); - } +#[derive(Debug, Clone)] +struct ContentExternalRepairFleetEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} - let response = registry - .send_raw("content", &request) - .await - .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; - content_response_bytes(&response) +struct ContentFetchResult { + payload: ContentFetchPayload, + availability: Option, + transfer: Option, } -pub async fn fetch_content_object_manifest( - registry: &ProviderRegistry, - cid: &str, -) -> anyhow::Result { - let bytes = fetch_bytes_via_provider(registry, cid, Some(CONTENT_OBJECT_MANIFEST_PATH)).await?; - parse_content_object_manifest(cid, &bytes) +enum ContentFetchPayload { + Bytes(String), + Stream(Value), } -pub fn parse_content_object_manifest( - cid: &str, - bytes: &[u8], -) -> anyhow::Result { - let manifest: ContentObjectManifest = serde_json::from_slice(bytes).map_err(|err| { - anyhow::anyhow!("content object {cid} has invalid {CONTENT_OBJECT_MANIFEST_PATH}: {err}") - })?; - if manifest.schema != OBJECT_MANIFEST_SCHEMA { - anyhow::bail!( - "content object {cid} uses unsupported object manifest schema {}", - manifest.schema - ); - } - Ok(manifest) +#[derive(Debug, Clone)] +struct ContentFetchTransfer { + transfer: ProviderTransfer, + range: Option, + progress: Option, } -/// Materialize a published capsule through the content availability contract. -/// -/// Data capsules must carry `_elastos_object.json`; that manifest is the file -/// list and integrity contract above the low-level block backend. -pub async fn prepare_capsule_from_content_provider( - registry: &ProviderRegistry, - cid: &str, -) -> anyhow::Result { - let manifest_bytes = match fetch_bytes_via_provider(registry, cid, Some("capsule.json")).await { - Ok(bytes) => bytes, - Err(capsule_err) => { - if let Ok(object_manifest) = fetch_content_object_manifest(registry, cid).await { - anyhow::bail!( - "content object {cid} has kind '{}' and is not a launchable capsule; use `elastos open elastos://{cid}` to inspect release objects or open it with a matching viewer once one is installed", - object_manifest.kind - ); +impl ContentFetchTransfer { + fn from_request(request: &Value) -> Result { + let transfer = match request + .get("transfer") + .and_then(|value| value.as_str()) + .unwrap_or("bytes") + { + "bytes" => ProviderTransfer::Bytes, + "stream" => ProviderTransfer::Stream, + "json" => { + return Err(ProviderError::Provider( + "content fetch transfer must be bytes or stream".into(), + )); } - return Err(capsule_err); - } - }; - let manifest_data = String::from_utf8(manifest_bytes.clone()) - .map_err(|err| anyhow::anyhow!("Manifest is not valid UTF-8 for CID {}: {}", cid, err))?; - let manifest: elastos_common::CapsuleManifest = serde_json::from_str(&manifest_data)?; - manifest - .validate() - .map_err(|err| anyhow::anyhow!("Invalid manifest from CID {}: {}", cid, err))?; - - tracing::info!( - "Loading capsule '{}' ({:?}) through content availability", - manifest.name, - manifest.capsule_type - ); + value => { + return Err(ProviderError::Provider(format!( + "content fetch transfer must be bytes or stream, got {value}" + ))); + } + }; + let range = match request.get("range") { + Some(range) => { + let start = range + .get("start") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider("content fetch range requires start".into()) + })?; + let end = match range.get("end") { + Some(value) if value.is_null() => None, + Some(value) => Some(value.as_u64().ok_or_else(|| { + ProviderError::Provider( + "content fetch range end must be an unsigned integer".into(), + ) + })?), + None => None, + }; + Some(ProviderByteRange { start, end }) + } + None => None, + }; + let progress = match request.get("progress") { + Some(progress) => { + let request_id = progress + .get("request_id") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("content fetch progress requires request_id".into()) + })?; + let expected_bytes = match progress.get("expected_bytes") { + Some(value) if value.is_null() => None, + Some(value) => Some(value.as_u64().ok_or_else(|| { + ProviderError::Provider( + "content fetch progress expected_bytes must be an unsigned integer" + .into(), + ) + })?), + None => None, + }; + Some(ProviderProgress { + request_id: request_id.to_string(), + expected_bytes, + }) + } + None => None, + }; + Ok(Self { + transfer, + range, + progress, + }) + } +} - let temp_dir = tempfile::Builder::new() - .prefix("elastos-capsule-") - .tempdir()?; - let capsule_dir = temp_dir.path().to_path_buf(); - write_materialized_file(&capsule_dir, "capsule.json", &manifest_bytes).await?; +impl ContentProvider { + pub fn new(data_dir: PathBuf, registry: Weak) -> Self { + Self::new_with_operator_alert_sink_config(data_dir, registry, None) + } - match manifest.capsule_type { - elastos_common::CapsuleType::MicroVM => { - anyhow::bail!( - "MicroVM capsule opens still require the explicit operator path until content availability supports streamed large-object materialization" - ); - } - elastos_common::CapsuleType::Data => { - materialize_data_capsule(registry, cid, &manifest, &manifest_bytes, &capsule_dir) - .await?; - } - _ => { - let entrypoint_bytes = - fetch_bytes_via_provider(registry, cid, Some(&manifest.entrypoint)).await?; - write_materialized_file(&capsule_dir, &manifest.entrypoint, &entrypoint_bytes).await?; - } + pub fn new_with_operator_alert_sink_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + ) -> Self { + Self::new_with_operator_alert_and_storage_market_config( + data_dir, + registry, + operator_alert_sink_config, + None, + ) } - Ok(temp_dir.keep()) -} + pub fn new_with_operator_alert_and_storage_market_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + ) -> Self { + Self::new_with_operator_alert_storage_market_and_repair_fleet_config( + data_dir, + registry, + operator_alert_sink_config, + storage_market_admission_config, + None, + ) + } -#[async_trait] -impl Provider for ContentProvider { - async fn handle(&self, _request: ResourceRequest) -> Result { - Err(ProviderError::Provider( - "content provider only supports capability-scoped raw operations".into(), - )) + pub fn new_with_operator_alert_storage_market_and_repair_fleet_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + ) -> Self { + Self::new_with_operator_alert_storage_market_repair_fleet_and_alert_exchange_config( + data_dir, + registry, + operator_alert_sink_config, + storage_market_admission_config, + external_repair_fleet_config, + None, + ) } - fn schemes(&self) -> Vec<&'static str> { - vec!["content"] + pub fn new_with_operator_alert_storage_market_repair_fleet_and_alert_exchange_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + ) -> Self { + Self::new_with_operator_alert_storage_market_repair_fleet_alert_exchange_and_quota_ledger_config( + data_dir, + registry, + operator_alert_sink_config, + storage_market_admission_config, + external_repair_fleet_config, + federated_operator_alert_exchange_config, + None, + ) } - fn name(&self) -> &'static str { - "content-provider" + pub fn new_with_operator_alert_storage_market_repair_fleet_alert_exchange_and_quota_ledger_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + federated_quota_ledger_exchange_config: Option, + ) -> Self { + Self::new_with_external_configs( + data_dir, + registry, + ContentProviderExternalConfigs { + operator_alert_sink: operator_alert_sink_config, + storage_market_admission: storage_market_admission_config, + external_repair_fleet: external_repair_fleet_config, + federated_operator_alert_exchange: federated_operator_alert_exchange_config, + federated_quota_ledger_exchange: federated_quota_ledger_exchange_config, + federated_abuse_control_exchange: None, + }, + ) } - async fn send_raw(&self, request: &Value) -> Result { - match request.get("op").and_then(|op| op.as_str()) { - Some("publish") => self.publish(request).await, - Some("fetch") => self.fetch(request).await, - Some("ensure") => self.ensure(request).await, - Some("repair") => self.repair(request).await, - Some("unpublish") => self.unpublish(request).await, - Some("status") => self.status(request), - Some(op) => Ok(provider_error( - "unsupported_operation", - &format!("unsupported content operation: {op}"), - )), - None => Ok(provider_error( - "invalid_request", - "missing content operation", - )), + pub fn new_with_external_configs( + data_dir: PathBuf, + registry: Weak, + configs: ContentProviderExternalConfigs, + ) -> Self { + let operator_alert_sink = configs.operator_alert_sink.and_then(|config| { + match ContentOperatorAlertSink::from_config(config) { + Ok(sink) => Some(sink), + Err(err) => { + tracing::warn!("content operator alert sink disabled: {}", err); + None + } + } + }); + let storage_market_admission = configs.storage_market_admission.and_then(|config| { + match ContentStorageMarketAdmissionClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("content storage-market admission disabled: {}", err); + None + } + } + }); + let external_repair_fleet = configs.external_repair_fleet.and_then(|config| { + match ContentExternalRepairFleetClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("content external repair fleet disabled: {}", err); + None + } + } + }); + let federated_operator_alert_exchange = + configs + .federated_operator_alert_exchange + .and_then( + |config| match ContentFederatedOperatorAlertExchangeClient::from_config(config) + { + Ok(sink) => Some(sink), + Err(err) => { + tracing::warn!( + "content federated operator alert exchange disabled: {}", + err + ); + None + } + }, + ); + let federated_abuse_control_exchange = + configs.federated_abuse_control_exchange.and_then(|config| { + match ContentFederatedAbuseControlExchangeClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!( + "content federated abuse-control exchange disabled: {}", + err + ); + None + } + } + }); + let federated_quota_ledger_exchange = + configs.federated_quota_ledger_exchange.and_then(|config| { + match ContentFederatedQuotaLedgerExchangeClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("content federated quota-ledger exchange disabled: {}", err); + None + } + } + }); + Self { + data_dir, + registry, + operator_alert_sink, + federated_abuse_control_exchange, + federated_quota_ledger_exchange, + federated_operator_alert_exchange, + storage_market_admission, + external_repair_fleet, } } -} - -impl ContentProvider { - async fn fetch(&self, request: &Value) -> Result { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content fetch requires cid".into()))?; - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content fetch requires a valid CID", - )); - } - let path = request - .get("path") - .and_then(|path| path.as_str()) - .unwrap_or(""); - if let Err(message) = validate_content_path(path) { - return Ok(provider_error("invalid_path", &message)); - } + fn registry(&self) -> Result, ProviderError> { + self.registry.upgrade().ok_or_else(|| { + ProviderError::Provider("content provider registry unavailable".to_string()) + }) + } - let mut ipfs_request = json!({ - "op": "cat", - "cid": cid, - }); - if !path.is_empty() { - ipfs_request["path"] = Value::String(path.to_string()); + fn effective_publisher_did( + &self, + requested_publisher_did: Option<&str>, + ) -> Result { + if let Some(publisher_did) = + requested_publisher_did.filter(|value| !value.trim().is_empty()) + { + return Ok(publisher_did.to_string()); } - let registry = self.registry()?; - let ipfs_response = registry.send_raw("ipfs", &ipfs_request).await?; - provider_response_ok(&ipfs_response, "content fetch")?; - let data = ipfs_response - .get("data") - .and_then(|data| data.get("data")) - .and_then(|data| data.as_str()) - .ok_or_else(|| { - ProviderError::Provider("content backend response missing data".into()) + let (_signing_key, default_did) = elastos_identity::load_or_create_did(&self.data_dir) + .map_err(|err| { + ProviderError::Provider(format!("content default publisher DID unavailable: {err}")) })?; + Ok(default_did) + } +} - let availability = self - .latest_receipt_for_cid(cid) - .transpose()? - .map(|receipt| { - json!({ - "status": receipt.payload.status, - "provider": receipt.payload.provider, - "replicas": receipt.payload.replicas, - "checked_at": receipt.payload.checked_at, - }) - }) - .unwrap_or_else(|| { - json!({ - "status": "unknown", - "provider": "content-provider", - }) - }); +impl ContentOperatorAlertSink { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let url = payload + .get("url") + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content operator alert sink requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + Ok(Self { + url: url.to_string(), + authorization, + timeout_secs, + }) + } - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "path": path, - "data": data, - "availability": availability, - }))) + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "configured": true, + "delivery": "provider_local_webhook", + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) } - async fn publish(&self, request: &Value) -> Result { - let kind = request.get("kind").and_then(|kind| kind.as_str()); - let pin = request - .get("pin") - .and_then(|pin| pin.as_bool()) - .unwrap_or(true); - let registry = self.registry()?; + async fn deliver(&self, alert: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("operator alert client build failed: {err}"))?; + let mut request = client.post(&self.url).json(alert); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("operator alert delivery failed: {err}"))?; + let status = response.status(); + if status.is_success() { + Ok(json!({ + "configured": true, + "delivered": true, + "status": "delivered", + "http_status": status.as_u16(), + "sink": self.redacted_status_json(), + })) + } else { + Err(format!( + "operator alert sink returned HTTP {}", + status.as_u16() + )) + } + } +} - let ipfs_request = match kind { - Some("directory") => { - let files = request - .get("files") - .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())); - if !files.is_array() { - return Ok(provider_error("invalid_request", "files must be an array")); - } - let files = with_directory_object_manifest( - files, - request - .get("object_kind") - .and_then(|value| value.as_str()) - .unwrap_or("directory"), - request.get("object_did").and_then(|value| value.as_str()), - request - .get("publisher_did") - .and_then(|value| value.as_str()), - request.get("links"), - )?; - json!({ - "op": "add_directory", - "files": files, - "pin": pin, +impl ContentFederatedAbuseControlExchangeClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentFederatedAbuseControlExchangeEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) }) + .collect::, _>>()?, + _ => vec![ContentFederatedAbuseControlExchangeEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "content federated abuse-control exchange supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content federated abuse-control exchange quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "federated_abuse_control_exchange", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentFederatedAbuseControlExchangeEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn exchange(&self, signed_request: &Value) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut verified_receipts = 0_usize; + let mut first_verified_signed_receipt = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .exchange(signed_request) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + federated_abuse_control_endpoint_unavailable(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + } else if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + rejected_receipts = rejected_receipts.saturating_add(1); } - Some("file") => { - let data = request - .get("data") - .and_then(|data| data.as_str()) - .filter(|data| !data.trim().is_empty()) - .ok_or_else(|| { - ProviderError::Provider("content file publish requires data".into()) - })?; - let filename = request - .get("filename") - .and_then(|filename| filename.as_str()) - .filter(|filename| !filename.trim().is_empty()) - .unwrap_or("content.bin"); - json!({ - "op": "add_bytes", - "data": data, - "filename": filename, - "pin": pin, - }) + if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_receipts = verified_receipts.saturating_add(1); + if first_verified_signed_receipt.is_none() { + first_verified_signed_receipt = receipt.get("signed_receipt").cloned(); + } } - Some(_) | None => { - return Ok(provider_error( - "unsupported_content_kind", - "content publish supports kind=directory or kind=file", - )); + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); } + endpoint_receipts.push(receipt); + } + + let accepted = accepted_receipts >= self.quorum; + let reason = if accepted { + format!( + "federated abuse-control quorum accepted: {accepted_receipts}/{} verified endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "federated abuse-control quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "federated abuse-control quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) }; + let mut signed_receipt = first_verified_signed_receipt.unwrap_or_else(|| { + json!({ + "verified": false, + }) + }); + signed_receipt["verified"] = Value::Bool(verified_receipts > 0); + signed_receipt["verified_receipts"] = Value::from(verified_receipts); + + Ok(json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "exchange": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + "verified": verified_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "signed_receipt": signed_receipt, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + })) + } +} - let ipfs_response = registry.send_raw("ipfs", &ipfs_request).await?; - let cid = provider_response_cid(&ipfs_response)?; - let object_did = request - .get("object_did") +impl ContentFederatedAbuseControlExchangeEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) .and_then(|value| value.as_str()) - .map(str::to_string); - let publisher_did = request - .get("publisher_did") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "content federated abuse-control exchange endpoint requires url".to_string() + })?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) .map(str::to_string); - let local_outcome = AvailabilityOutcome::local_publish(pin); - let outcome = if pin { - self.ensure_network_availability( - ®istry, - &cid, - request, - object_did.as_deref(), - publisher_did.as_deref(), - &local_outcome, - ) - .await? - .unwrap_or(local_outcome) - } else { - local_outcome - }; - let receipt = self.write_receipt(ReceiptInput { - cid: cid.clone(), - object_did, - publisher_did, - provider: outcome.provider.clone(), - policy: outcome.policy.clone(), - status: outcome.status.clone(), - replicas: outcome.replicas, - })?; + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("abuse-control-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "availability": outcome.to_json(), - "receipt": receipt, - }))) + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) } - async fn unpublish(&self, request: &Value) -> Result { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content unpublish requires cid".into()))?; - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content unpublish requires a valid CID", - )); + async fn exchange(&self, signed_request: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| { + format!("federated abuse-control exchange client build failed: {err}") + })?; + let mut request = client.post(&self.url).json(signed_request); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); } - - let registry = self.registry()?; - let ipfs_response = registry - .send_raw( - "ipfs", - &json!({ - "op": "unpin", - "cid": cid, - }), - ) - .await?; - provider_response_ok(&ipfs_response, "content unpublish")?; - let receipt = self.write_receipt(ReceiptInput { - cid: cid.to_string(), - object_did: request - .get("object_did") - .and_then(|value| value.as_str()) - .map(str::to_string), - publisher_did: request - .get("publisher_did") - .and_then(|value| value.as_str()) - .map(str::to_string), - provider: "ipfs-provider".to_string(), - policy: "local_unpublish".to_string(), - status: "local_unpinned".to_string(), - replicas: 0, + let response = request + .send() + .await + .map_err(|err| format!("federated abuse-control exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "federated abuse-control exchange returned HTTP {}", + status.as_u16() + )); + } + let response_json = response.json::().await.map_err(|err| { + format!("federated abuse-control exchange response decode failed: {err}") })?; - - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "availability": { - "status": "local_unpinned", - "provider": "ipfs-provider", - "replicas": 0, - }, - "receipt": receipt, - }))) + federated_abuse_control_exchange_receipt_from_response( + &response_json, + self.redacted_status_json(), + status.as_u16(), + ) } +} - async fn ensure(&self, request: &Value) -> Result { - self.pin_for_availability(request, "local_ensure_pin", "local_ensure_failed") - .await +impl ContentFederatedQuotaLedgerExchangeClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentFederatedQuotaLedgerExchangeEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![ContentFederatedQuotaLedgerExchangeEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "content federated quota-ledger exchange supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content federated quota-ledger exchange quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) } - async fn repair(&self, request: &Value) -> Result { - self.pin_for_availability(request, "local_repair_pin", "local_repair_failed") - .await + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "federated_quota_ledger_exchange", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentFederatedQuotaLedgerExchangeEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) } - async fn pin_for_availability( - &self, - request: &Value, - success_policy: &str, - failure_policy: &str, - ) -> Result { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content repair requires cid".into()))?; - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content repair requires a valid CID", - )); + async fn exchange(&self, signed_request: &Value) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut verified_receipts = 0_usize; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .exchange(signed_request) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + federated_quota_ledger_endpoint_unavailable(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + } else if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + rejected_receipts = rejected_receipts.saturating_add(1); + } + if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_receipts = verified_receipts.saturating_add(1); + } + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); + } + endpoint_receipts.push(receipt); } - let registry = self.registry()?; - let ipfs_response = registry - .send_raw( - "ipfs", - &json!({ - "op": "pin", - "cid": cid, - }), + let accepted = accepted_receipts >= self.quorum; + let reason = if accepted { + format!( + "federated quota-ledger quorum accepted: {accepted_receipts}/{} verified endpoints accepted", + self.endpoints.len() ) - .await?; - - let (status, policy, replicas, reason) = if ipfs_response - .get("status") - .and_then(|status| status.as_str()) - == Some("error") - { - ( - "repair_needed", - failure_policy, - 0, - ipfs_response - .get("message") - .and_then(|message| message.as_str()) - .map(str::to_string), + } else if reasons.is_empty() { + format!( + "federated quota-ledger quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum ) } else { - ("local_pinned", success_policy, 1, None) + format!( + "federated quota-ledger quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) }; - let local_outcome = AvailabilityOutcome { - provider: "ipfs-provider".to_string(), - policy: policy.to_string(), - status: status.to_string(), - replicas, - reason, - }; - let object_did = request - .get("object_did") + Ok(json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "exchange": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + "verified": verified_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "signed_receipt": { + "verified": verified_receipts > 0, + "verified_receipts": verified_receipts, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + })) + } +} + +impl ContentFederatedQuotaLedgerExchangeEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) .and_then(|value| value.as_str()) - .map(str::to_string); - let publisher_did = request - .get("publisher_did") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "content federated quota-ledger exchange endpoint requires url".to_string() + })?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) .map(str::to_string); - let outcome = if local_outcome.status == "local_pinned" { - self.ensure_network_availability( - ®istry, - cid, - request, - object_did.as_deref(), - publisher_did.as_deref(), - &local_outcome, - ) - .await? - .unwrap_or(local_outcome) - } else { - local_outcome - }; + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("quota-ledger-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } - let receipt = self.write_receipt(ReceiptInput { - cid: cid.to_string(), - object_did, - publisher_did, - provider: outcome.provider.clone(), - policy: outcome.policy.clone(), - status: outcome.status.clone(), - replicas: outcome.replicas, - })?; + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "availability": outcome.to_json(), - "receipt": receipt, - }))) + async fn exchange(&self, signed_request: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("federated quota-ledger exchange client build failed: {err}"))?; + let mut request = client.post(&self.url).json(signed_request); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("federated quota-ledger exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "federated quota-ledger exchange returned HTTP {}", + status.as_u16() + )); + } + let response_json = response.json::().await.map_err(|err| { + format!("federated quota-ledger exchange response decode failed: {err}") + })?; + federated_quota_ledger_exchange_receipt_from_response( + &response_json, + self.redacted_status_json(), + status.as_u16(), + ) } +} - async fn ensure_network_availability( - &self, - registry: &ProviderRegistry, - cid: &str, - request: &Value, - object_did: Option<&str>, - publisher_did: Option<&str>, - local: &AvailabilityOutcome, - ) -> Result, ProviderError> { - let policy = request - .get("availability_policy") +impl ContentFederatedOperatorAlertExchangeClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("network_default"); - let mut availability_request = json!({ - "op": "ensure", - "cid": cid, - "uri": format!("elastos://{cid}"), - "policy": policy, - "local": local.to_json(), - }); - if let Some(object_did) = object_did { - availability_request["object_did"] = Value::String(object_did.to_string()); - } - if let Some(publisher_did) = publisher_did { - availability_request["publisher_did"] = Value::String(publisher_did.to_string()); + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content federated operator alert exchange requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + Ok(Self { + url: url.to_string(), + authorization, + timeout_secs, + }) + } + + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "configured": true, + "delivery": "federated_operator_alert_exchange", + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } - match registry - .send_raw("availability", &availability_request) + async fn exchange(&self, request_payload: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| { + format!("federated operator alert exchange client build failed: {err}") + })?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() .await - { - Ok(response) => Ok(Some(parse_availability_provider_response( - &response, policy, local, - ))), - Err(ProviderError::NoProvider(_)) => Ok(None), - Err(err) => Ok(Some(AvailabilityOutcome::repair_needed( - "availability-provider", - policy, - local.replicas, - err.to_string(), - ))), + .map_err(|err| format!("federated operator alert exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "federated operator alert exchange returned HTTP {}", + status.as_u16() + )); } + let response_json = response.json::().await.map_err(|err| { + format!("federated operator alert exchange response decode failed: {err}") + })?; + federated_operator_alert_exchange_receipt_from_response( + &response_json, + self, + status.as_u16(), + ) } +} - fn status(&self, request: &Value) -> Result { - if let Some(cid) = request.get("cid").and_then(|cid| cid.as_str()) { - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content status requires a valid CID", - )); +impl ContentStorageMarketAdmissionClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentStorageMarketAdmissionEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![ContentStorageMarketAdmissionEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "content storage-market admission supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content storage-market admission quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "external_storage_market_admission", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentStorageMarketAdmissionEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn decide(&self, request_payload: &Value) -> Result { + let mut endpoint_decisions = Vec::new(); + let mut accepted_decisions = 0_usize; + let mut rejected_decisions = 0_usize; + let mut failed_decisions = 0_usize; + let mut first_accepted_decision = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let decision = endpoint + .decide(request_payload) + .await + .unwrap_or_else(|err| { + failed_decisions = failed_decisions.saturating_add(1); + storage_market_admission_endpoint_unavailable(err, endpoint) + }); + if decision + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_decisions = accepted_decisions.saturating_add(1); + if first_accepted_decision.is_none() { + first_accepted_decision = Some(decision.clone()); + } + } else if decision + .get("status") + .and_then(|value| value.as_str()) + .is_some_and(|status| status == "rejected") + { + rejected_decisions = rejected_decisions.saturating_add(1); } - if let Some(receipt) = self.latest_receipt_for_cid(cid) { - let receipt = receipt?; - return Ok(provider_ok(json!({ - "cid": receipt.payload.cid, - "uri": receipt.payload.uri, - "availability": { - "status": receipt.payload.status, - "provider": receipt.payload.provider, - "replicas": receipt.payload.replicas, - "checked_at": receipt.payload.checked_at, - }, - "receipt": receipt, - }))); + if let Some(reason) = decision.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); } + endpoint_decisions.push(decision); } - Ok(provider_ok(json!({ - "availability": { - "status": "unknown", - "provider": "content-provider", - } - }))) + let accepted = accepted_decisions >= self.quorum; + let reason = if accepted { + format!( + "storage-market admission quorum accepted: {accepted_decisions}/{} endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "storage-market admission quorum rejected: {accepted_decisions}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "storage-market admission quorum rejected: {accepted_decisions}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) + }; + let first_accepted = first_accepted_decision.unwrap_or(Value::Null); + + Ok(json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "market_id": first_accepted.get("market_id").cloned().unwrap_or(Value::Null), + "offer_id": first_accepted.get("offer_id").cloned().unwrap_or(Value::Null), + "receipt": first_accepted.get("receipt").cloned().unwrap_or(Value::Null), + "client": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_decisions, + "rejected": rejected_decisions, + "failed": failed_decisions, + }, + "endpoint_decisions": endpoint_decisions, + "checked_at": now_unix_secs(), + "app_visible": false, + })) } +} - fn write_receipt( - &self, - input: ReceiptInput, - ) -> Result { - let (signing_key, default_did) = elastos_identity::load_or_create_did(&self.data_dir) - .map_err(|err| { - ProviderError::Provider(format!("content receipt signer unavailable: {err}")) - })?; - let publisher_did = input.publisher_did.unwrap_or(default_did); - let receipt = AvailabilityReceipt { - schema: AVAILABILITY_RECEIPT_SCHEMA.to_string(), - cid: input.cid.clone(), - uri: format!("elastos://{}", input.cid), - object_did: input.object_did, - publisher_did, - provider: input.provider, - policy: input.policy, - status: input.status, - replicas: input.replicas, - checked_at: now_unix_secs(), - }; - let payload_value = serde_json::to_value(&receipt).map_err(|err| { - ProviderError::Provider(format!("content receipt encode failed: {err}")) - })?; - let payload = serde_json::to_string(&payload_value).map_err(|err| { - ProviderError::Provider(format!("content receipt encode failed: {err}")) - })?; - let (signature, signer_did) = crate::crypto::domain_separated_sign( - &signing_key, - AVAILABILITY_RECEIPT_DOMAIN, - payload.as_bytes(), - ); - let signed = SignedAvailabilityReceipt { - payload: receipt, - signature, - signer_did, - }; - append_jsonl(&self.receipts_path(), &signed)?; - Ok(signed) +impl ContentStorageMarketAdmissionEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("admission_url")) + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content storage-market admission endpoint requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("storage-market-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) } - fn latest_receipt_for_cid( - &self, - cid: &str, - ) -> Option> { - let path = self.receipts_path(); - if !path.exists() { - return None; + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } + + async fn decide(&self, request_payload: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("storage-market admission client build failed: {err}"))?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("storage-market admission request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "storage-market admission returned HTTP {}", + status.as_u16() + )); } + let response_json = response + .json::() + .await + .map_err(|err| format!("storage-market admission response decode failed: {err}"))?; + storage_market_admission_decision_from_response(&response_json, self.redacted_status_json()) + } +} - let file = match std::fs::File::open(&path) { - Ok(file) => file, - Err(err) => return Some(Err(err.into())), +impl ContentExternalRepairFleetClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentExternalRepairFleetEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![ContentExternalRepairFleetEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], }; - let reader = std::io::BufReader::new(file); - let mut latest = None; - for line in reader.lines() { - let line = match line { - Ok(line) => line, - Err(err) => return Some(Err(err.into())), - }; - if line.trim().is_empty() { - continue; - } - let receipt: SignedAvailabilityReceipt = match serde_json::from_str(&line) { - Ok(receipt) => receipt, - Err(err) => { - return Some(Err(ProviderError::Provider(format!( - "content receipt ledger decode failed: {err}" - )))) - } - }; - if receipt.payload.cid == cid { - if let Err(err) = verify_signed_receipt(&receipt) { - return Some(Err(err)); + if endpoints.len() > 5 { + return Err("content external repair fleet supports at most 5 endpoints".to_string()); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content external repair fleet quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn endpoint_count(&self) -> usize { + self.endpoints.len() + } + + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "external_repair_fleet_dispatch", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentExternalRepairFleetEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn dispatch(&self, request_payload: &Value) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut first_accepted_receipt = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .dispatch(request_payload) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + external_repair_fleet_endpoint_dispatch_failed(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + if first_accepted_receipt.is_none() { + first_accepted_receipt = Some(receipt.clone()); } - latest = Some(receipt); + } else if receipt + .get("status") + .and_then(|value| value.as_str()) + .is_some_and(|status| status == "rejected") + { + rejected_receipts = rejected_receipts.saturating_add(1); + } + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); } + endpoint_receipts.push(receipt); } - latest.map(Ok) - } - fn receipts_path(&self) -> PathBuf { - self.data_dir - .join("ElastOS") - .join("SystemServices") - .join("Content") - .join("availability-receipts.jsonl") + let accepted = accepted_receipts >= self.quorum; + let status = if accepted { + "accepted" + } else { + "dispatch_failed" + }; + let reason = if accepted { + format!( + "external repair-fleet quorum accepted: {accepted_receipts}/{} endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "external repair-fleet quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "external repair-fleet quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) + }; + let first_accepted = first_accepted_receipt.unwrap_or(Value::Null); + + Ok(json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": status, + "reason": reason, + "fleet_id": first_accepted.get("fleet_id").cloned().unwrap_or(Value::Null), + "job_id": first_accepted.get("job_id").cloned().unwrap_or(Value::Null), + "receipt": first_accepted.get("receipt").cloned().unwrap_or(Value::Null), + "client": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "dispatched_at": now_unix_secs(), + "app_visible": false, + })) } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AvailabilityReceipt { - pub schema: String, - pub cid: String, - pub uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub object_did: Option, - pub publisher_did: String, - pub provider: String, - pub policy: String, - pub status: String, - pub replicas: u32, - pub checked_at: u64, -} +impl ContentExternalRepairFleetEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("dispatch_url")) + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content external repair fleet endpoint requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("repair-fleet-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignedAvailabilityReceipt { - pub payload: AvailabilityReceipt, - pub signature: String, - pub signer_did: String, + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } + + async fn dispatch(&self, request_payload: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("external repair fleet client build failed: {err}"))?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("external repair fleet dispatch failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "external repair fleet returned HTTP {}", + status.as_u16() + )); + } + let response_json = response + .json::() + .await + .map_err(|err| format!("external repair fleet response decode failed: {err}"))?; + external_repair_fleet_dispatch_receipt_from_response( + &response_json, + self.redacted_status_json(), + ) + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContentObjectManifest { - pub schema: String, - pub kind: String, - pub content_digest: String, - pub files: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub links: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub object_did: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub publisher_did: Option, +fn validate_operator_alert_sink_url(raw: &str) -> Result<(), String> { + let url = url::Url::parse(raw).map_err(|err| format!("invalid operator alert URL: {err}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err("operator alert URL must not contain inline credentials".to_string()); + } + match url.scheme() { + "https" => Ok(()), + "http" if matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) => Ok(()), + _ => Err("operator alert URL must use https or local loopback http".to_string()), + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContentObjectFile { - pub path: String, - pub sha256: String, - pub size: u64, +fn validate_operator_alert_header_value(value: &str) -> Result<(), String> { + if value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) { + return Err("operator alert authorization header contains invalid newline".to_string()); + } + Ok(()) } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContentObjectLink { - pub rel: String, - pub cid: String, +fn federated_operator_alert_exchange_request(alert: &Value, emitted_at: u64) -> Value { + json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "emitted_at": emitted_at, + "alert": alert, + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "credential_exposed": false, + "raw_backend_access": false, + }, + }) } -struct ReceiptInput { - cid: String, - object_did: Option, - publisher_did: Option, - provider: String, - policy: String, - status: String, - replicas: u32, +fn federated_operator_alert_exchange_receipt_from_response( + response: &Value, + client: &ContentFederatedOperatorAlertExchangeClient, + http_status: u16, +) -> Result { + let accepted = response + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "federated operator alert exchange response requires accepted boolean".to_string() + })?; + Ok(json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "delivered": accepted, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": client.redacted_status_json(), + "remote_schema": response.get("schema").cloned().unwrap_or(Value::Null), + "remote_exchange_id": response.get("exchange_id").cloned().unwrap_or(Value::Null), + "remote_receipt_id": response.get("receipt_id").cloned().unwrap_or(Value::Null), + "reason": response.get("reason").cloned().unwrap_or(Value::Null), + "credential_exposed": false, + })) } -#[derive(Debug, Clone)] -struct AvailabilityOutcome { - provider: String, - policy: String, - status: String, - replicas: u32, - reason: Option, +fn federated_abuse_control_exchange_request(local_admission: &Value, request: &Value) -> Value { + json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": local_admission.get("cid").cloned().unwrap_or(Value::Null), + "publisher_did": local_admission + .get("publisher_did") + .cloned() + .unwrap_or(Value::Null), + "estimated_content_bytes": local_admission + .get("estimated_content_bytes") + .cloned() + .unwrap_or(Value::Null), + "quota": local_admission.get("quota").cloned().unwrap_or(Value::Null), + "availability_requirements": request + .get("availability_requirements") + .cloned() + .unwrap_or_else(|| json!({})), + "local_admission": local_admission, + "requested_at": now_unix_secs(), + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "preflight_only": true, + "app_visible": false, + "credential_exposed": false, + "raw_backend_access": false, + "raw_peer_authority": false, + }, + }) } -impl AvailabilityOutcome { - fn local_publish(pin: bool) -> Self { - if pin { - Self { - provider: "ipfs-provider".to_string(), - policy: "local_pin".to_string(), - status: "local_pinned".to_string(), - replicas: 1, - reason: None, - } - } else { - Self { - provider: "ipfs-provider".to_string(), - policy: "local_add".to_string(), - status: "local_unpinned".to_string(), - replicas: 0, - reason: None, - } - } +fn federated_abuse_control_exchange_receipt_from_response( + response: &Value, + exchange: Value, + http_status: u16, +) -> Result { + let payload = response + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "federated abuse-control exchange response requires accepted boolean".to_string() + })?; + let signed_receipt = payload.get("receipt").ok_or_else(|| { + "federated abuse-control exchange response requires signed receipt".to_string() + })?; + let signer_did = signed_receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + "federated abuse-control exchange receipt requires signer_did".to_string() + })?; + let receipt_bytes = serde_json::to_vec(signed_receipt) + .map_err(|err| format!("federated abuse-control exchange receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_DOMAIN, + &expected_signers, + ) + .map_err(|err| { + format!("federated abuse-control exchange receipt verification failed: {err}") + })?; + let receipt_payload = signed_receipt + .get("payload") + .filter(|value| value.is_object()) + .ok_or_else(|| "federated abuse-control exchange receipt requires payload".to_string())?; + if receipt_payload + .get("schema") + .and_then(|value| value.as_str()) + != Some(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA) + { + return Err("federated abuse-control exchange receipt schema mismatch".to_string()); } - fn repair_needed(provider: &str, policy: &str, replicas: u32, reason: String) -> Self { - Self { - provider: provider.to_string(), - policy: policy.to_string(), - status: "repair_needed".to_string(), - replicas, - reason: Some(reason), - } - } + Ok(json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": exchange, + "remote_schema": payload.get("schema").cloned().unwrap_or(Value::Null), + "remote_exchange_id": payload.get("exchange_id").cloned().unwrap_or(Value::Null), + "remote_receipt_id": payload.get("receipt_id").cloned().unwrap_or(Value::Null), + "signed_receipt": { + "verified": true, + "signer_did": signer_did, + "payload_schema": receipt_payload + .get("schema") + .cloned() + .unwrap_or(Value::Null), + "exchange_id": receipt_payload + .get("exchange_id") + .cloned() + .unwrap_or(Value::Null), + "receipt_id": receipt_payload + .get("receipt_id") + .cloned() + .unwrap_or(Value::Null), + "abuse_ledger_id": receipt_payload + .get("abuse_ledger_id") + .cloned() + .unwrap_or(Value::Null), + }, + "reason": payload.get("reason").cloned().unwrap_or(Value::Null), + "credential_exposed": false, + "app_visible": false, + })) +} - fn to_json(&self) -> Value { - let mut availability = json!({ - "status": self.status, - "provider": self.provider, - "replicas": self.replicas, - }); - if let Some(reason) = &self.reason { - availability["reason"] = Value::String(reason.clone()); - } - availability - } +fn federated_abuse_control_exchange_unavailable( + reason: String, + client: &ContentFederatedAbuseControlExchangeClient, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "abuse_control_unavailable", + "exchange": client.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + }) } -fn provider_ok(data: Value) -> Value { +fn federated_abuse_control_endpoint_unavailable( + reason: String, + endpoint: &ContentFederatedAbuseControlExchangeEndpoint, +) -> Value { json!({ - "status": "ok", - "data": data, + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "abuse_control_unavailable", + "exchange": endpoint.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, }) } -fn provider_error(code: &str, message: &str) -> Value { +fn federated_quota_ledger_exchange_request(local_admission: &Value, request: &Value) -> Value { json!({ - "status": "error", - "code": code, - "message": message, + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": local_admission.get("cid").cloned().unwrap_or(Value::Null), + "publisher_did": local_admission + .get("publisher_did") + .cloned() + .unwrap_or(Value::Null), + "estimated_content_bytes": local_admission + .get("estimated_content_bytes") + .cloned() + .unwrap_or(Value::Null), + "quota": local_admission.get("quota").cloned().unwrap_or(Value::Null), + "availability_requirements": request + .get("availability_requirements") + .cloned() + .unwrap_or_else(|| json!({})), + "local_admission": local_admission, + "requested_at": now_unix_secs(), + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "preflight_only": true, + "app_visible": false, + "credential_exposed": false, + "raw_backend_access": false, + }, }) } -fn parse_availability_provider_response( +fn federated_quota_ledger_exchange_receipt_from_response( response: &Value, - requested_policy: &str, - local: &AvailabilityOutcome, -) -> AvailabilityOutcome { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("availability provider returned an error") - .to_string(); - return AvailabilityOutcome::repair_needed( - "availability-provider", - requested_policy, - local.replicas, - message, - ); - } - - let data = response.get("data").unwrap_or(response); - let availability = data.get("availability").unwrap_or(data); - let provider = availability - .get("provider") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("availability-provider"); - let policy = availability - .get("policy") + exchange: Value, + http_status: u16, +) -> Result { + let payload = response + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "federated quota-ledger exchange response requires accepted boolean".to_string() + })?; + let signed_receipt = payload.get("receipt").ok_or_else(|| { + "federated quota-ledger exchange response requires signed receipt".to_string() + })?; + let signer_did = signed_receipt + .get("signer_did") .and_then(|value| value.as_str()) .filter(|value| !value.trim().is_empty()) - .unwrap_or(requested_policy); - let replicas = availability - .get("replicas") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(local.replicas); - let status = availability - .get("status") + .ok_or_else(|| "federated quota-ledger exchange receipt requires signer_did".to_string())?; + let receipt_bytes = serde_json::to_vec(signed_receipt) + .map_err(|err| format!("federated quota-ledger exchange receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_DOMAIN, + &expected_signers, + ) + .map_err(|err| format!("federated quota-ledger exchange receipt verification failed: {err}"))?; + let receipt_payload = signed_receipt + .get("payload") + .filter(|value| value.is_object()) + .ok_or_else(|| "federated quota-ledger exchange receipt requires payload".to_string())?; + if receipt_payload + .get("schema") .and_then(|value| value.as_str()) - .unwrap_or(""); + != Some(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA) + { + return Err("federated quota-ledger exchange receipt schema mismatch".to_string()); + } - match status { - "network_available" if replicas > 0 => AvailabilityOutcome { - provider: provider.to_string(), - policy: policy.to_string(), - status: status.to_string(), - replicas, - reason: None, + Ok(json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": exchange, + "remote_schema": payload.get("schema").cloned().unwrap_or(Value::Null), + "remote_exchange_id": payload.get("exchange_id").cloned().unwrap_or(Value::Null), + "remote_receipt_id": payload.get("receipt_id").cloned().unwrap_or(Value::Null), + "signed_receipt": { + "verified": true, + "signer_did": signer_did, + "payload_schema": receipt_payload + .get("schema") + .cloned() + .unwrap_or(Value::Null), + "exchange_id": receipt_payload + .get("exchange_id") + .cloned() + .unwrap_or(Value::Null), + "receipt_id": receipt_payload + .get("receipt_id") + .cloned() + .unwrap_or(Value::Null), }, - "repair_needed" => AvailabilityOutcome::repair_needed( - provider, - policy, - replicas, - availability - .get("reason") - .and_then(|value| value.as_str()) - .unwrap_or("availability provider reported repair_needed") - .to_string(), - ), - "network_available" => AvailabilityOutcome::repair_needed( - provider, - policy, - local.replicas, - "availability provider reported network_available without replicas".to_string(), - ), - _ => AvailabilityOutcome::repair_needed( - provider, - policy, - local.replicas, - "availability provider returned an unsupported status".to_string(), - ), - } + "reason": payload.get("reason").cloned().unwrap_or(Value::Null), + "credential_exposed": false, + "app_visible": false, + })) } -fn provider_response_cid(response: &Value) -> Result { - provider_response_ok(response, "content publish")?; - response +fn federated_quota_ledger_exchange_unavailable( + reason: String, + client: &ContentFederatedQuotaLedgerExchangeClient, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "quota_ledger_unavailable", + "exchange": client.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + }) +} + +fn federated_quota_ledger_endpoint_unavailable( + reason: String, + endpoint: &ContentFederatedQuotaLedgerExchangeEndpoint, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "quota_ledger_unavailable", + "exchange": endpoint.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + }) +} + +fn storage_market_admission_request(local_admission: &Value, request: &Value) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": local_admission.get("cid").cloned().unwrap_or(Value::Null), + "publisher_did": local_admission + .get("publisher_did") + .cloned() + .unwrap_or(Value::Null), + "estimated_content_bytes": local_admission + .get("estimated_content_bytes") + .cloned() + .unwrap_or(Value::Null), + "quota": local_admission.get("quota").cloned().unwrap_or(Value::Null), + "availability_requirements": request + .get("availability_requirements") + .cloned() + .unwrap_or_else(|| json!({})), + "local_admission": local_admission, + "requested_at": now_unix_secs(), + "app_visible": false, + }) +} + +fn storage_market_admission_decision_from_response( + response: &Value, + client: Value, +) -> Result { + let payload = response .get("data") - .and_then(|data| data.get("cid")) - .and_then(|cid| cid.as_str()) + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| "storage-market admission response requires accepted boolean".to_string())?; + let status = payload + .get("status") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) .map(str::to_string) - .ok_or_else(|| ProviderError::Provider("content backend response missing cid".into())) + .unwrap_or_else(|| { + if accepted { + "accepted".to_string() + } else { + "rejected".to_string() + } + }); + Ok(json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": status, + "reason": payload + .get("reason") + .and_then(|value| value.as_str()) + .map(str::to_string), + "market_id": payload + .get("market_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "offer_id": payload + .get("offer_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "receipt": payload.get("receipt").cloned().unwrap_or(Value::Null), + "client": client, + "checked_at": now_unix_secs(), + "app_visible": false, + })) } -fn content_response_cid(response: &Value) -> anyhow::Result { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("unknown error"); - anyhow::bail!("content publish failed: {message}"); - } - response +fn storage_market_admission_endpoint_unavailable( + reason: String, + endpoint: &ContentStorageMarketAdmissionEndpoint, +) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "market_unavailable", + "reason": reason, + "receipt": Value::Null, + "client": endpoint.redacted_status_json(), + "checked_at": now_unix_secs(), + "app_visible": false, + }) +} + +fn storage_market_admission_unavailable(reason: String) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "market_unavailable", + "reason": reason, + "receipt": Value::Null, + "checked_at": now_unix_secs(), + "app_visible": false, + }) +} + +fn external_repair_fleet_dispatch_request(task: &ContentRepairTask, now: u64) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": task.cid, + "uri": format!("elastos://{}", task.cid), + "object_did": task.object_did.clone(), + "publisher_did": task.publisher_did.clone(), + "availability_policy": task.policy.clone(), + "availability_requirements": task.requirements.clone(), + "repair_task": { + "schema": REPAIR_TASK_SCHEMA, + "status": task.status.clone(), + "attempts": task.attempts, + "next_check_after": task.next_check_after, + "checked_at": task.checked_at, + }, + "requested_at": now, + "runtime_invocation_required": true, + "app_visible": false, + }) +} + +fn external_repair_fleet_dispatch_receipt_from_response( + response: &Value, + client: Value, +) -> Result { + let payload = response .get("data") - .and_then(|data| data.get("cid")) - .and_then(|cid| cid.as_str()) + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| "external repair fleet response requires accepted boolean".to_string())?; + let status = payload + .get("status") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("No CID in content provider response")) + .unwrap_or_else(|| { + if accepted { + "accepted".to_string() + } else { + "rejected".to_string() + } + }); + Ok(json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": status, + "reason": payload + .get("reason") + .and_then(|value| value.as_str()) + .map(str::to_string), + "fleet_id": payload + .get("fleet_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "job_id": payload + .get("job_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "receipt": payload.get("receipt").cloned().unwrap_or(Value::Null), + "client": client, + "dispatched_at": now_unix_secs(), + "app_visible": false, + })) } -fn content_response_bytes(response: &Value) -> anyhow::Result> { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("unknown error"); - anyhow::bail!("content fetch failed: {message}"); - } - let data = response - .get("data") - .and_then(|data| data.get("data")) - .and_then(|data| data.as_str()) - .ok_or_else(|| anyhow::anyhow!("No data in content provider response"))?; - base64::engine::general_purpose::STANDARD - .decode(data) - .map_err(|err| anyhow::anyhow!("Content provider returned invalid base64: {err}")) +fn external_repair_fleet_endpoint_dispatch_failed( + reason: String, + endpoint: &ContentExternalRepairFleetEndpoint, +) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "dispatch_failed", + "reason": reason, + "receipt": Value::Null, + "client": endpoint.redacted_status_json(), + "dispatched_at": now_unix_secs(), + "app_visible": false, + }) } -fn provider_response_ok(response: &Value, operation: &str) -> Result<(), ProviderError> { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("unknown error"); - return Err(ProviderError::Provider(format!( - "{operation} failed: {message}" - ))); - } - Ok(()) +fn external_repair_fleet_dispatch_failed(reason: String) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "dispatch_failed", + "reason": reason, + "receipt": Value::Null, + "dispatched_at": now_unix_secs(), + "app_visible": false, + }) } -fn is_valid_cid(value: &str) -> bool { - cid::Cid::try_from(value).is_ok() +pub async fn publish_directory_via_provider( + registry: &ProviderRegistry, + dir: &Path, + object_did: Option<&str>, + publisher_did: Option<&str>, +) -> anyhow::Result { + publish_directory_via_provider_with_kind(registry, dir, "directory", object_did, publisher_did) + .await } -fn validate_content_path(path: &str) -> Result<(), String> { - if path.is_empty() { - return Ok(()); +pub async fn publish_directory_via_provider_with_kind( + registry: &ProviderRegistry, + dir: &Path, + object_kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, +) -> anyhow::Result { + publish_directory_via_provider_with_kind_and_links( + registry, + dir, + object_kind, + object_did, + publisher_did, + &[], + ) + .await +} + +pub async fn publish_directory_via_provider_with_kind_and_links( + registry: &ProviderRegistry, + dir: &Path, + object_kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, + links: &[(String, String)], +) -> anyhow::Result { + let mut files = Vec::new(); + crate::ipfs::collect_files_for_ipfs(dir, dir, &mut files)?; + if files.is_empty() { + anyhow::bail!("No files found in {}", dir.display()); } - if path.starts_with('/') || path.starts_with('\\') { - return Err("content fetch path must be relative".to_string()); + + let mut entries = Vec::new(); + for rel_path in &files { + let abs_path = dir.join(rel_path); + let bytes = std::fs::read(&abs_path)?; + entries.push(json!({ + "path": rel_path.to_string_lossy().replace('\\', "/"), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })); } - if path.contains('\\') || path.contains('\0') { - return Err("content fetch path contains invalid characters".to_string()); + + let mut request = json!({ + "op": "publish", + "kind": "directory", + "object_kind": object_kind, + "files": entries, + "pin": true, + }); + if let Some(object_did) = object_did { + request["object_did"] = Value::String(object_did.to_string()); } - for segment in path.split('/') { - if segment.is_empty() || segment == "." || segment == ".." { - return Err("content fetch path contains an invalid segment".to_string()); - } + if let Some(publisher_did) = publisher_did { + request["publisher_did"] = Value::String(publisher_did.to_string()); } - Ok(()) + if !links.is_empty() { + request["links"] = Value::Array( + links + .iter() + .map(|(rel, cid)| { + json!({ + "rel": rel, + "cid": cid, + }) + }) + .collect(), + ); + } + + let response = registry + .send_raw("content", &request) + .await + .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; + content_response_cid(&response) } -fn with_directory_object_manifest( - files: Value, - kind: &str, +pub async fn publish_bytes_via_provider( + registry: &ProviderRegistry, + filename: &str, + bytes: &[u8], object_did: Option<&str>, publisher_did: Option<&str>, - links: Option<&Value>, -) -> Result { - let mut files = files - .as_array() - .cloned() - .ok_or_else(|| ProviderError::Provider("files must be an array".into()))?; - let manifest = directory_object_manifest(&files, kind, object_did, publisher_did, links)?; - let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| { - ProviderError::Provider(format!("content object manifest encode failed: {err}")) +) -> anyhow::Result { + let mut request = json!({ + "op": "publish", + "kind": "file", + "filename": filename, + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "pin": true, + }); + if let Some(object_did) = object_did { + request["object_did"] = Value::String(object_did.to_string()); + } + if let Some(publisher_did) = publisher_did { + request["publisher_did"] = Value::String(publisher_did.to_string()); + } + + let response = registry + .send_raw("content", &request) + .await + .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; + content_response_cid(&response) +} + +pub async fn fetch_bytes_via_provider( + registry: &ProviderRegistry, + cid: &str, + path: Option<&str>, +) -> anyhow::Result> { + let mut request = json!({ + "op": "fetch", + "cid": cid, + "transfer": "stream", + "progress": { + "request_id": format!("content-fetch:{cid}:{}", path.unwrap_or("root")), + "expected_bytes": Value::Null, + }, + }); + if let Some(path) = path.filter(|path| !path.is_empty()) { + request["path"] = Value::String(path.to_string()); + } + + let mut session = registry + .open_provider_stream( + ProviderInvocation { + source: "runtime-content-consumer".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request, + transfer: ProviderTransfer::Stream, + range: None, + progress: Some(ProviderProgress { + request_id: format!("content-fetch:{cid}:{}", path.unwrap_or("root")), + expected_bytes: None, + }), + transport: ProviderInvocationTransport::Local, + }, + ProviderStreamOptions::default(), + ) + .await + .map_err(|err| anyhow::anyhow!("content provider stream unavailable: {err}"))?; + session + .drain_to_vec() + .map_err(|err| anyhow::anyhow!("content provider stream read failed: {err}")) +} + +pub async fn fetch_content_object_manifest( + registry: &ProviderRegistry, + cid: &str, +) -> anyhow::Result { + let bytes = fetch_bytes_via_provider(registry, cid, Some(CONTENT_OBJECT_MANIFEST_PATH)).await?; + parse_content_object_manifest(cid, &bytes) +} + +pub fn parse_content_object_manifest( + cid: &str, + bytes: &[u8], +) -> anyhow::Result { + let manifest: ContentObjectManifest = serde_json::from_slice(bytes).map_err(|err| { + anyhow::anyhow!("content object {cid} has invalid {CONTENT_OBJECT_MANIFEST_PATH}: {err}") })?; - files.push(json!({ - "path": OBJECT_MANIFEST_PATH, - "data": base64::engine::general_purpose::STANDARD.encode(manifest_bytes), - })); - sort_directory_entries(&mut files)?; - Ok(Value::Array(files)) + if manifest.schema != OBJECT_MANIFEST_SCHEMA { + anyhow::bail!( + "content object {cid} uses unsupported object manifest schema {}", + manifest.schema + ); + } + Ok(manifest) } -fn directory_object_manifest( - files: &[Value], - kind: &str, - object_did: Option<&str>, - publisher_did: Option<&str>, - links: Option<&Value>, -) -> Result { - let kind = validate_content_object_kind(kind)?; - let links = parse_content_object_links(links)?; - let mut seen_paths = BTreeSet::new(); - let mut object_files = Vec::with_capacity(files.len()); - let mut sealed_object = None; - for file in files { - let path = file - .get("path") - .and_then(|path| path.as_str()) - .filter(|path| !path.trim().is_empty()) - .ok_or_else(|| { - ProviderError::Provider("directory publish file is missing path".into()) - })?; - if path == OBJECT_MANIFEST_PATH { - return Err(ProviderError::Provider(format!( - "{OBJECT_MANIFEST_PATH} is reserved for the content object manifest" - ))); +/// Materialize a published capsule through the content availability contract. +/// +/// Data capsules must carry `_elastos_object.json`; that manifest is the file +/// list and integrity contract above the low-level block backend. +pub async fn prepare_capsule_from_content_provider( + registry: &ProviderRegistry, + cid: &str, +) -> anyhow::Result { + let manifest_bytes = match fetch_bytes_via_provider(registry, cid, Some("capsule.json")).await { + Ok(bytes) => bytes, + Err(capsule_err) => { + if let Ok(object_manifest) = fetch_content_object_manifest(registry, cid).await { + anyhow::bail!( + "content object {cid} has kind '{}' and is not a launchable capsule; use `elastos open elastos://{cid}` to inspect release objects or open it with a matching viewer once one is installed", + object_manifest.kind + ); + } + return Err(capsule_err); } - validate_content_path(path).map_err(ProviderError::Provider)?; - if !seen_paths.insert(path.to_string()) { - return Err(ProviderError::Provider(format!( - "duplicate directory publish path: {path}" - ))); + }; + let manifest_data = String::from_utf8(manifest_bytes.clone()) + .map_err(|err| anyhow::anyhow!("Manifest is not valid UTF-8 for CID {}: {}", cid, err))?; + let manifest: elastos_common::CapsuleManifest = serde_json::from_str(&manifest_data)?; + manifest + .validate() + .map_err(|err| anyhow::anyhow!("Invalid manifest from CID {}: {}", cid, err))?; + + tracing::info!( + "Loading capsule '{}' ({:?}) through content availability", + manifest.name, + manifest.capsule_type + ); + + let temp_dir = tempfile::Builder::new() + .prefix("elastos-capsule-") + .tempdir()?; + let capsule_dir = temp_dir.path().to_path_buf(); + write_materialized_file(&capsule_dir, "capsule.json", &manifest_bytes).await?; + + match manifest.capsule_type { + elastos_common::CapsuleType::MicroVM => { + anyhow::bail!( + "MicroVM capsule opens still require the explicit operator path until content availability supports streamed large-object materialization" + ); } - let data = file - .get("data") - .and_then(|data| data.as_str()) - .ok_or_else(|| { - ProviderError::Provider(format!( - "directory publish file {path} is missing base64 data" - )) - })?; - let bytes = base64::engine::general_purpose::STANDARD - .decode(data) - .map_err(|err| { - ProviderError::Provider(format!( - "directory publish file {path} has invalid base64 data: {err}" - )) - })?; - if kind == "sealed" && path == SEALED_OBJECT_PATH { - let sealed: SealedObjectV1 = serde_json::from_slice(&bytes).map_err(|err| { - ProviderError::Provider(format!( - "sealed content object has invalid {SEALED_OBJECT_PATH}: {err}" - )) - })?; - validate_sealed_object_descriptor(&sealed)?; - sealed_object = Some(sealed); + elastos_common::CapsuleType::Data => { + materialize_data_capsule(registry, cid, &manifest, &manifest_bytes, &capsule_dir) + .await?; + } + _ => { + let entrypoint_bytes = + fetch_bytes_via_provider(registry, cid, Some(&manifest.entrypoint)).await?; + write_materialized_file(&capsule_dir, &manifest.entrypoint, &entrypoint_bytes).await?; } - object_files.push(ContentObjectFile { - path: path.to_string(), - sha256: format!("{:x}", sha2::Sha256::digest(&bytes)), - size: bytes.len() as u64, - }); } - object_files.sort_by(|a, b| a.path.cmp(&b.path)); - if kind == "sealed" { - let sealed_object = sealed_object.ok_or_else(|| { - ProviderError::Provider(format!( - "sealed content object requires {SEALED_OBJECT_PATH}" - )) - })?; - validate_sealed_content_links(&sealed_object, &links)?; + + Ok(temp_dir.keep()) +} + +#[async_trait] +impl Provider for ContentProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "content provider only supports capability-scoped raw operations".into(), + )) } - let mut hasher = sha2::Sha256::new(); - for file in &object_files { - hasher.update(file.path.as_bytes()); - hasher.update(b"\0"); - hasher.update(file.sha256.as_bytes()); - hasher.update(b"\0"); - hasher.update(file.size.to_string().as_bytes()); - hasher.update(b"\0"); + fn schemes(&self) -> Vec<&'static str> { + vec!["content"] } - Ok(ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind, - content_digest: format!("sha256:{:x}", hasher.finalize()), - files: object_files, - links, - object_did: object_did.map(str::to_string), - publisher_did: publisher_did.map(str::to_string), - }) -} + fn name(&self) -> &'static str { + "content-provider" + } -fn validate_content_object_kind(kind: &str) -> Result { - match kind { - "capsule" | "directory" | "document" | "release" | "sealed" | "share" | "site" => { - Ok(kind.to_string()) + async fn send_raw(&self, request: &Value) -> Result { + match request.get("op").and_then(|op| op.as_str()) { + Some("publish") => self.publish(request).await, + Some("fetch") => self.fetch(request).await, + Some("import_exact") => self.import_exact(request).await, + Some("import_object") => self.import_object(request).await, + Some("admission") => self.admission(request).await, + Some("ensure") => self.ensure(request).await, + Some("repair") => self.repair(request).await, + Some("repair_worker") => self.run_repair_worker(request).await, + Some("unpublish") => self.unpublish(request).await, + Some("status") => self.status(request).await, + Some(op) => Ok(provider_error( + "unsupported_operation", + &format!("unsupported content operation: {op}"), + )), + None => Ok(provider_error( + "invalid_request", + "missing content operation", + )), } - _ => Err(ProviderError::Provider(format!( - "unsupported content object kind: {kind}" - ))), } } -fn parse_content_object_links( - links: Option<&Value>, -) -> Result, ProviderError> { - let Some(links) = links else { - return Ok(Vec::new()); - }; - let links = links - .as_array() - .ok_or_else(|| ProviderError::Provider("content object links must be an array".into()))?; - let mut parsed = Vec::with_capacity(links.len()); - let mut seen = BTreeSet::new(); - for link in links { - let rel = link - .get("rel") - .and_then(|rel| rel.as_str()) - .filter(|rel| !rel.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content object link is missing rel".into()))?; - validate_content_object_link_rel(rel)?; - let cid = link +impl ContentProvider { + async fn invoke_provider( + &self, + registry: &ProviderRegistry, + target: &str, + op: &str, + request: Value, + transfer: ProviderTransfer, + ) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: self.name().to_string(), + target: target.to_string(), + op: op.to_string(), + request, + transfer, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + } + + async fn invoke_provider_with_fetch_transfer( + &self, + registry: &ProviderRegistry, + target: &str, + op: &str, + request: Value, + transfer: &ContentFetchTransfer, + ) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: self.name().to_string(), + target: target.to_string(), + op: op.to_string(), + request, + transfer: transfer.transfer, + range: transfer.range, + progress: transfer.progress.clone(), + transport: ProviderInvocationTransport::Local, + }) + .await + } + + async fn fetch(&self, request: &Value) -> Result { + let cid = request .get("cid") .and_then(|cid| cid.as_str()) .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content object link is missing cid".into()))?; - cid::Cid::try_from(cid).map_err(|err| { - ProviderError::Provider(format!("invalid content object link cid: {err}")) - })?; - if !seen.insert((rel.to_string(), cid.to_string())) { - return Err(ProviderError::Provider(format!( - "duplicate content object link: {rel} {cid}" - ))); + .ok_or_else(|| ProviderError::Provider("content fetch requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content fetch requires a valid CID", + )); } - parsed.push(ContentObjectLink { - rel: rel.to_string(), - cid: cid.to_string(), - }); - } - parsed.sort_by(|a, b| a.rel.cmp(&b.rel).then_with(|| a.cid.cmp(&b.cid))); - Ok(parsed) -} - -fn validate_sealed_object_descriptor(object: &SealedObjectV1) -> Result<(), ProviderError> { - if object.schema != SEALED_OBJECT_SCHEMA { - return Err(ProviderError::Provider( - "sealed content object schema is unsupported".to_string(), - )); - } - validate_linked_cid(&object.payload_cid, "payload_cid")?; - validate_linked_cid(&object.rights_policy_cid, "rights_policy_cid")?; - validate_linked_cid(&object.availability_receipt_cid, "availability_receipt_cid")?; - require_field(&object.key_envelope.scheme, "key_envelope.scheme")?; - require_field(&object.key_envelope.kid, "key_envelope.kid")?; - require_field(&object.key_envelope.wrapped_cek, "key_envelope.wrapped_cek")?; - require_field(&object.key_envelope.policy_hash, "key_envelope.policy_hash")?; - validate_protected_content_key_envelope_algorithms(&object.key_envelope.algorithms) - .map_err(|err| ProviderError::Provider(format!("sealed content object {err}")))?; - require_field( - &object.viewer.required_interface, - "viewer.required_interface", - ) -} -fn validate_sealed_content_links( - object: &SealedObjectV1, - links: &[ContentObjectLink], -) -> Result<(), ProviderError> { - require_link(links, "payload", &object.payload_cid)?; - require_link(links, "rights.policy", &object.rights_policy_cid)?; - require_link( - links, - "availability.receipt", - &object.availability_receipt_cid, - )?; - if !links.iter().any(|link| link.rel == "provenance") { - return Err(ProviderError::Provider( - "sealed content object requires provenance link".to_string(), - )); - } - Ok(()) -} + let path = request + .get("path") + .and_then(|path| path.as_str()) + .unwrap_or(""); + if let Err(message) = validate_content_path(path) { + return Ok(provider_error("invalid_path", &message)); + } -fn require_link(links: &[ContentObjectLink], rel: &str, cid: &str) -> Result<(), ProviderError> { - if links.iter().any(|link| link.rel == rel && link.cid == cid) { - Ok(()) - } else { - Err(ProviderError::Provider(format!( - "sealed content object requires {rel} link to {cid}" - ))) - } -} + let registry = self.registry()?; + let transfer = ContentFetchTransfer::from_request(request)?; + let result = match self + .fetch_from_local_backend(®istry, cid, path, &transfer) + .await + { + Ok(result) => result, + Err(local_err) + if request.get("local_only").and_then(|value| value.as_bool()) == Some(true) => + { + return Err(local_err); + } + Err(local_err) => match self + .fetch_from_availability_provider(®istry, cid, path, &transfer) + .await + { + Ok(Some(result)) => result, + Ok(None) => return Err(local_err), + Err(availability_err) => { + return Err(ProviderError::Provider(format!( + "{local_err}; availability fetch failed: {availability_err}" + ))) + } + }, + }; -fn validate_linked_cid(value: &str, field: &str) -> Result<(), ProviderError> { - require_field(value, field)?; - cid::Cid::try_from(value) - .map(|_| ()) - .map_err(|err| ProviderError::Provider(format!("invalid sealed object {field}: {err}"))) -} + let receipt_availability = self + .latest_receipt_for_cid(cid) + .transpose()? + .map(|receipt| { + json!({ + "status": receipt.payload.status, + "provider": receipt.payload.provider, + "replicas": receipt.payload.replicas, + "checked_at": receipt.payload.checked_at, + }) + }) + .unwrap_or_else(|| { + json!({ + "status": "unknown", + "provider": "content-provider", + }) + }); + let availability = result.availability.unwrap_or(receipt_availability); -fn require_field(value: &str, field: &str) -> Result<(), ProviderError> { - if value.trim().is_empty() { - Err(ProviderError::Provider(format!( - "sealed content object {field} is required" - ))) - } else { - Ok(()) + let mut response = json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "path": path, + "availability": availability, + }); + match result.payload { + ContentFetchPayload::Bytes(data) => { + response["data"] = Value::String(data); + } + ContentFetchPayload::Stream(stream) => { + response["stream"] = stream; + } + } + if let Some(transfer) = result.transfer { + response["transfer"] = transfer; + } + Ok(provider_ok(response)) } -} -fn validate_content_object_link_rel(rel: &str) -> Result<(), ProviderError> { - if rel.len() > 64 { - return Err(ProviderError::Provider( - "content object link rel is too long".into(), - )); - } - if !rel.bytes().all(|b| { - b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_' || b == b'.' - }) { - return Err(ProviderError::Provider( - "content object link rel must use lowercase ASCII, digits, '-', '_', or '.'".into(), - )); - } - Ok(()) -} + async fn fetch_from_local_backend( + &self, + registry: &ProviderRegistry, + cid: &str, + path: &str, + transfer: &ContentFetchTransfer, + ) -> Result { + let mut ipfs_request = json!({ + "op": "cat", + "cid": cid, + }); + if !path.is_empty() { + ipfs_request["path"] = Value::String(path.to_string()); + } -fn sort_directory_entries(files: &mut [Value]) -> Result<(), ProviderError> { - for file in files.iter() { - file.get("path") - .and_then(|path| path.as_str()) - .filter(|path| !path.trim().is_empty()) - .ok_or_else(|| { - ProviderError::Provider("directory publish file is missing path".into()) - })?; + let ipfs_response = self + .invoke_provider_with_fetch_transfer(registry, "ipfs", "cat", ipfs_request, transfer) + .await?; + provider_response_ok(&ipfs_response, "content fetch")?; + Ok(ContentFetchResult { + payload: provider_response_payload(&ipfs_response, "content backend", transfer)?, + availability: None, + transfer: provider_transfer_value(&ipfs_response), + }) } - files.sort_by(|a, b| { - let a = a.get("path").and_then(|path| path.as_str()).unwrap_or(""); - let b = b.get("path").and_then(|path| path.as_str()).unwrap_or(""); - a.cmp(b) - }); - Ok(()) -} - -async fn materialize_data_capsule( - registry: &ProviderRegistry, - cid: &str, - manifest: &elastos_common::CapsuleManifest, - manifest_bytes: &[u8], - capsule_dir: &Path, -) -> anyhow::Result<()> { - let object_manifest_bytes = - fetch_bytes_via_provider(registry, cid, Some(OBJECT_MANIFEST_PATH)) - .await - .map_err(|err| { - anyhow::anyhow!( - "published data capsule {cid} is missing {OBJECT_MANIFEST_PATH}; republish it through content availability: {err}" - ) - })?; - let object_manifest = parse_content_object_manifest(cid, &object_manifest_bytes)?; - - write_materialized_file(capsule_dir, OBJECT_MANIFEST_PATH, &object_manifest_bytes).await?; - let mut saw_capsule_manifest = false; - for file in &object_manifest.files { - validate_content_path(&file.path).map_err(|err| anyhow::anyhow!("{err}"))?; - if file.path == OBJECT_MANIFEST_PATH { - anyhow::bail!("{OBJECT_MANIFEST_PATH} cannot appear inside its own file list"); + async fn fetch_from_availability_provider( + &self, + registry: &ProviderRegistry, + cid: &str, + path: &str, + transfer: &ContentFetchTransfer, + ) -> Result, ProviderError> { + let mut request = json!({ + "op": "fetch", + "cid": cid, + "uri": format!("elastos://{cid}"), + }); + if !path.is_empty() { + request["path"] = Value::String(path.to_string()); } - let bytes = if file.path == "capsule.json" { - saw_capsule_manifest = true; - manifest_bytes.to_vec() - } else { - fetch_bytes_via_provider(registry, cid, Some(&file.path)).await? + let response = match self + .invoke_provider_with_fetch_transfer( + registry, + "availability", + "fetch", + request, + transfer, + ) + .await + { + Ok(response) => response, + Err(ProviderError::NoProvider(_)) => return Ok(None), + Err(err) => return Err(err), }; - verify_content_object_file(cid, file, &bytes)?; - write_materialized_file(capsule_dir, &file.path, &bytes).await?; + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + return Ok(None); + } + let availability = response + .get("data") + .and_then(|data| data.get("availability")) + .cloned(); + Ok(Some(ContentFetchResult { + payload: provider_response_payload(&response, "availability provider", transfer)?, + availability, + transfer: provider_transfer_value(&response), + })) } - if !saw_capsule_manifest { - anyhow::bail!("published data capsule {cid} object manifest is missing capsule.json"); - } + async fn publish(&self, request: &Value) -> Result { + let kind = request.get("kind").and_then(|kind| kind.as_str()); + let pin = request + .get("pin") + .and_then(|pin| pin.as_bool()) + .unwrap_or(true); + let registry = self.registry()?; - let entrypoint_path = capsule_dir.join(&manifest.entrypoint); - if !entrypoint_path.is_file() { + let ipfs_request = match kind { + Some("directory") => { + let files = request + .get("files") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + if !files.is_array() { + return Ok(provider_error("invalid_request", "files must be an array")); + } + let files = with_directory_object_manifest( + files, + request + .get("object_kind") + .and_then(|value| value.as_str()) + .unwrap_or("directory"), + request.get("object_did").and_then(|value| value.as_str()), + request + .get("publisher_did") + .and_then(|value| value.as_str()), + request.get("links"), + )?; + json!({ + "op": "add_directory", + "files": files, + "pin": pin, + }) + } + Some("file") => { + let data = request + .get("data") + .and_then(|data| data.as_str()) + .filter(|data| !data.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("content file publish requires data".into()) + })?; + let filename = request + .get("filename") + .and_then(|filename| filename.as_str()) + .filter(|filename| !filename.trim().is_empty()) + .unwrap_or("content.bin"); + json!({ + "op": "add_bytes", + "data": data, + "filename": filename, + "pin": pin, + }) + } + Some(_) | None => { + return Ok(provider_error( + "unsupported_content_kind", + "content publish supports kind=directory or kind=file", + )); + } + }; + + let ipfs_op = ipfs_request + .get("op") + .and_then(|value| value.as_str()) + .unwrap_or("publish") + .to_string(); + let accounting_observation = content_accounting_observation_from_publish_request(request); + let requirements = AvailabilityRequirements::from_request(request); + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = Some(self.effective_publisher_did(publisher_did.as_deref())?); + let storage_quota = self.principal_storage_quota_for_request( + publisher_did.as_deref().unwrap_or_default(), + &requirements, + if pin { + accounting_observation.bytes + } else { + Some(0) + }, + None, + )?; + if storage_quota.get("status").and_then(|value| value.as_str()) == Some("quota_exceeded") { + return Ok(provider_error( + "storage_quota_exceeded", + "content publish exceeds the principal storage quota", + )); + } + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + &ipfs_op, + ipfs_request, + ProviderTransfer::Bytes, + ) + .await?; + let cid = provider_response_cid(&ipfs_response)?; + let local_outcome = AvailabilityOutcome::local_publish(pin); + let outcome = if pin { + self.ensure_network_availability( + ®istry, + &cid, + request, + &local_outcome, + AvailabilityRequestContext { + object_did: object_did.as_deref(), + publisher_did: publisher_did.as_deref(), + accounting_observation, + }, + ) + .await? + .unwrap_or(local_outcome) + } else { + local_outcome + }; + let receipt = self.write_receipt(ReceiptInput { + cid: cid.clone(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: content_accounting_json_with_storage_quota( + "publish_request", + accounting_observation, + outcome.replicas, + storage_quota, + ), + })?; + let repair_task = self.record_repair_task(&receipt, &outcome, requirements, false)?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + }))) + } + + async fn admission(&self, request: &Value) -> Result { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content admission requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content admission requires a valid CID", + )); + } + + let requirements = AvailabilityRequirements::from_request(request); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = self.effective_publisher_did(publisher_did.as_deref())?; + let incoming_content_bytes = admission_content_bytes_from_request(request); + let mut storage_quota = if requirements.max_storage_bytes_per_principal.is_some() + && incoming_content_bytes.is_none() + { + json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "principal_storage_quota", + "scope": "content-availability", + "enforced": true, + "status": "known_size_required", + "principal_did": publisher_did, + "reason": "remote admission with a storage quota requires estimated content bytes before transfer", + }) + } else { + self.principal_storage_quota_for_request( + &publisher_did, + &requirements, + incoming_content_bytes, + Some(cid), + )? + }; + let quota_status = storage_quota + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let accepted = !matches!(quota_status, "quota_exceeded" | "known_size_required"); + let reason = match quota_status { + "quota_exceeded" => { + Some("remote content replica would exceed the principal storage quota") + } + "known_size_required" => Some("remote content admission requires known content bytes"), + _ => None, + }; + + let mut admission = json!({ + "schema": CONTENT_ADMISSION_SCHEMA, + "policy": "content_provider_principal_quota_preflight", + "scope": "content-availability", + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "cid": cid, + "publisher_did": publisher_did, + "estimated_content_bytes": incoming_content_bytes, + "quota": storage_quota, + "checked_at": now_unix_secs(), + "app_visible": false, + }); + if accepted { + if let Some(federated_abuse_control_exchange) = &self.federated_abuse_control_exchange { + let signed_exchange_request = + self.sign_federated_abuse_control_exchange_request(&admission, request)?; + let abuse_control_exchange = federated_abuse_control_exchange + .exchange(&signed_exchange_request) + .await + .unwrap_or_else(|err| { + federated_abuse_control_exchange_unavailable( + err, + federated_abuse_control_exchange, + ) + }); + if abuse_control_exchange + .get("accepted") + .and_then(|value| value.as_bool()) + != Some(true) + { + admission["accepted"] = Value::Bool(false); + admission["status"] = Value::String("rejected".to_string()); + let abuse_reason = abuse_control_exchange + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("federated abuse control rejected admission"); + admission["reason"] = Value::String(format!( + "federated abuse control rejected admission: {abuse_reason}" + )); + } + admission["federated_abuse_control_exchange"] = abuse_control_exchange; + } + } + if admission.get("accepted").and_then(|value| value.as_bool()) == Some(true) { + if let Some(federated_quota_ledger_exchange) = &self.federated_quota_ledger_exchange { + let signed_exchange_request = + self.sign_federated_quota_ledger_exchange_request(&admission, request)?; + let quota_ledger_exchange = federated_quota_ledger_exchange + .exchange(&signed_exchange_request) + .await + .unwrap_or_else(|err| { + federated_quota_ledger_exchange_unavailable( + err, + federated_quota_ledger_exchange, + ) + }); + let quota_ledger_policy = federated_quota_ledger_policy_from_exchange( + &storage_quota, + "a_ledger_exchange, + ); + if let Some(quota) = storage_quota.as_object_mut() { + quota.insert( + "federated_quota_ledger_policy".to_string(), + quota_ledger_policy, + ); + } + admission["quota"] = storage_quota.clone(); + if quota_ledger_exchange + .get("accepted") + .and_then(|value| value.as_bool()) + != Some(true) + { + admission["accepted"] = Value::Bool(false); + admission["status"] = Value::String("rejected".to_string()); + let quota_reason = quota_ledger_exchange + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("federated quota ledger rejected admission"); + admission["reason"] = Value::String(format!( + "federated quota ledger rejected admission: {quota_reason}" + )); + } + admission["federated_quota_ledger_exchange"] = quota_ledger_exchange; + } + } + if admission.get("accepted").and_then(|value| value.as_bool()) == Some(true) { + if let Some(storage_market_admission) = &self.storage_market_admission { + let market_request = storage_market_admission_request(&admission, request); + let market_decision = storage_market_admission + .decide(&market_request) + .await + .unwrap_or_else(storage_market_admission_unavailable); + if market_decision + .get("accepted") + .and_then(|value| value.as_bool()) + != Some(true) + { + admission["accepted"] = Value::Bool(false); + admission["status"] = Value::String("rejected".to_string()); + let market_reason = market_decision + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("external storage market rejected admission"); + admission["reason"] = Value::String(format!( + "storage market admission rejected: {market_reason}" + )); + } + admission["storage_market_admission"] = market_decision; + } + } + let receipt = self.sign_admission_receipt(&admission)?; + + Ok(provider_ok(json!({ + "cid": cid, + "admission": admission, + "receipt": receipt, + }))) + } + + async fn import_exact(&self, request: &Value) -> Result { + validate_import_exact_invocation(request)?; + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content import_exact requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content import_exact requires a valid CID", + )); + } + let bytes = import_exact_payload_bytes(request)?; + let requirements = AvailabilityRequirements::from_request(request); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = Some(self.effective_publisher_did(publisher_did.as_deref())?); + let storage_quota = self.principal_storage_quota_for_request( + publisher_did.as_deref().unwrap_or_default(), + &requirements, + Some(bytes.len() as u64), + Some(cid), + )?; + if storage_quota.get("status").and_then(|value| value.as_str()) == Some("quota_exceeded") { + return Ok(provider_error( + "storage_quota_exceeded", + "content import_exact exceeds the principal storage quota", + )); + } + let filename = request + .get("filename") + .and_then(|filename| filename.as_str()) + .filter(|filename| !filename.trim().is_empty()) + .unwrap_or("content.bin"); + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "add_bytes", + json!({ + "op": "add_bytes", + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "filename": filename, + "pin": true, + }), + ProviderTransfer::Bytes, + ) + .await?; + provider_response_ok(&ipfs_response, "content import_exact")?; + let imported_cid = provider_response_cid(&ipfs_response).map_err(|err| { + ProviderError::Provider(format!("content import_exact missing imported CID: {err}")) + })?; + if imported_cid != cid { + let _ = self + .invoke_provider( + ®istry, + "ipfs", + "unpin", + json!({ + "op": "unpin", + "cid": imported_cid, + }), + ProviderTransfer::Json, + ) + .await; + return Ok(provider_error( + "cid_mismatch", + "content import_exact produced a different CID; exact-CID import requires block-level compatible bytes", + )); + } + + let outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: "carrier_exact_import".to_string(), + status: "local_pinned".to_string(), + replicas: 1, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: content_accounting_json_with_storage_quota( + "carrier_exact_import", + ContentAccountingObservation { + files: Some(1), + bytes: Some(bytes.len() as u64), + }, + outcome.replicas, + storage_quota, + ), + })?; + let repair_task = self.record_repair_task(&receipt, &outcome, requirements, false)?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + "import": { + "schema": "elastos.content.import-exact/v1", + "method": "carrier_provider_stream", + "bytes": bytes.len(), + "verified_cid": true, + } + }))) + } + + async fn import_object(&self, request: &Value) -> Result { + validate_import_object_invocation(request)?; + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content import_object requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content import_object requires a valid CID", + )); + } + let files = request + .get("files") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let (file_count, total_bytes) = validate_import_object_payload_bounds(&files)?; + let requirements = AvailabilityRequirements::from_request(request); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = Some(self.effective_publisher_did(publisher_did.as_deref())?); + let storage_quota = self.principal_storage_quota_for_request( + publisher_did.as_deref().unwrap_or_default(), + &requirements, + Some(total_bytes as u64), + Some(cid), + )?; + if storage_quota.get("status").and_then(|value| value.as_str()) == Some("quota_exceeded") { + return Ok(provider_error( + "storage_quota_exceeded", + "content import_object exceeds the principal storage quota", + )); + } + let object_kind = request + .get("object_kind") + .or_else(|| request.get("kind")) + .and_then(|value| value.as_str()) + .unwrap_or("directory"); + let files = with_directory_object_manifest( + files, + object_kind, + request.get("object_did").and_then(|value| value.as_str()), + request + .get("publisher_did") + .and_then(|value| value.as_str()), + request.get("links"), + )?; + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "add_directory", + json!({ + "op": "add_directory", + "files": files, + "pin": true, + }), + ProviderTransfer::Json, + ) + .await?; + provider_response_ok(&ipfs_response, "content import_object")?; + let imported_cid = provider_response_cid(&ipfs_response).map_err(|err| { + ProviderError::Provider(format!("content import_object missing imported CID: {err}")) + })?; + if imported_cid != cid { + let _ = self + .invoke_provider( + ®istry, + "ipfs", + "unpin", + json!({ + "op": "unpin", + "cid": imported_cid, + }), + ProviderTransfer::Json, + ) + .await; + return Ok(provider_error( + "cid_mismatch", + "content import_object produced a different CID; exact object import requires matching manifest and bytes", + )); + } + + let outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: "carrier_object_import".to_string(), + status: "local_pinned".to_string(), + replicas: 1, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: content_accounting_json_with_storage_quota( + "carrier_object_import", + ContentAccountingObservation { + files: Some(file_count as u64), + bytes: Some(total_bytes as u64), + }, + outcome.replicas, + storage_quota, + ), + })?; + let repair_task = self.record_repair_task(&receipt, &outcome, requirements, false)?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + "import": { + "schema": "elastos.content.import-object/v1", + "method": "carrier_provider_object_manifest", + "files": file_count, + "bytes": total_bytes, + "verified_cid": true, + } + }))) + } + + async fn unpublish(&self, request: &Value) -> Result { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content unpublish requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content unpublish requires a valid CID", + )); + } + + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "unpin", + json!({ + "op": "unpin", + "cid": cid, + }), + ProviderTransfer::Json, + ) + .await?; + provider_response_ok(&ipfs_response, "content unpublish")?; + let previous_receipt = self.latest_receipt_for_cid(cid).transpose()?; + let outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: "local_unpublish".to_string(), + status: "local_unpinned".to_string(), + replicas: 0, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did: request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + previous_receipt + .as_ref() + .and_then(|receipt| receipt.payload.object_did.clone()) + }), + publisher_did: request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + previous_receipt + .as_ref() + .map(|receipt| receipt.payload.publisher_did.clone()) + }), + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: self.content_accounting_from_previous_or_unknown( + cid, + "local_unpublish", + outcome.replicas, + )?, + })?; + let repair_task = self.record_repair_task( + &receipt, + &outcome, + AvailabilityRequirements::from_request(request), + false, + )?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + }))) + } + + async fn ensure(&self, request: &Value) -> Result { + self.pin_for_availability(request, "local_ensure_pin", "local_ensure_failed", false) + .await + } + + async fn repair(&self, request: &Value) -> Result { + self.pin_for_availability(request, "local_repair_pin", "local_repair_failed", false) + .await + } + + async fn pin_for_availability( + &self, + request: &Value, + success_policy: &str, + failure_policy: &str, + repair_worker_attempt: bool, + ) -> Result { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content repair requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content repair requires a valid CID", + )); + } + + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "pin", + json!({ + "op": "pin", + "cid": cid, + }), + ProviderTransfer::Json, + ) + .await?; + + let (status, policy, replicas, reason) = if ipfs_response + .get("status") + .and_then(|status| status.as_str()) + == Some("error") + { + ( + "repair_needed", + failure_policy, + 0, + ipfs_response + .get("message") + .and_then(|message| message.as_str()) + .map(str::to_string), + ) + } else { + ("local_pinned", success_policy, 1, None) + }; + + let local_outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: policy.to_string(), + status: status.to_string(), + replicas, + reason, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(status == "repair_needed"), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let accounting_observation = self + .latest_receipt_for_cid(cid) + .transpose()? + .map(|receipt| content_accounting_observation_from_value(&receipt.payload.accounting)) + .unwrap_or_default(); + let outcome = if local_outcome.status == "local_pinned" { + self.ensure_network_availability( + ®istry, + cid, + request, + &local_outcome, + AvailabilityRequestContext { + object_did: object_did.as_deref(), + publisher_did: publisher_did.as_deref(), + accounting_observation, + }, + ) + .await? + .unwrap_or(local_outcome) + } else { + local_outcome + }; + + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: self.content_accounting_from_previous_or_unknown( + cid, + if repair_worker_attempt { + "repair_worker" + } else { + outcome.policy.as_str() + }, + outcome.replicas, + )?, + })?; + let repair_task = self.record_repair_task( + &receipt, + &outcome, + AvailabilityRequirements::from_request(request), + repair_worker_attempt, + )?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + }))) + } + + async fn ensure_network_availability( + &self, + registry: &ProviderRegistry, + cid: &str, + request: &Value, + local: &AvailabilityOutcome, + context: AvailabilityRequestContext<'_>, + ) -> Result, ProviderError> { + let policy = request + .get("availability_policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("network_default"); + let requirements = AvailabilityRequirements::from_request(request); + let mut availability_request = json!({ + "op": "ensure", + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": policy, + "local": local.to_json(), + "requirements": requirements.to_json(), + "accounting": content_accounting_json( + "availability_admission_estimate", + context.accounting_observation, + local.replicas, + ), + }); + if let Some(content_bytes) = context.accounting_observation.bytes { + availability_request["estimated_content_bytes"] = Value::from(content_bytes); + } + if let Some(object_did) = context.object_did { + availability_request["object_did"] = Value::String(object_did.to_string()); + } + if let Some(publisher_did) = context.publisher_did { + availability_request["publisher_did"] = Value::String(publisher_did.to_string()); + } + + match self + .invoke_provider( + registry, + "availability", + "ensure", + availability_request, + ProviderTransfer::Json, + ) + .await + { + Ok(response) => Ok(Some(parse_availability_provider_response( + &response, + policy, + local, + &requirements, + ))), + Err(ProviderError::NoProvider(_)) => Ok(None), + Err(err) => Ok(Some(AvailabilityOutcome::repair_needed( + "availability-provider", + policy, + local.replicas, + err.to_string(), + ))), + } + } + + async fn run_repair_worker(&self, request: &Value) -> Result { + validate_repair_worker_invocation(request)?; + let force = request + .get("force") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let include_healthy_check = request + .get("include_healthy_check") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let limit = request + .get("limit") + .and_then(|value| value.as_u64()) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(REPAIR_WORKER_DEFAULT_LIMIT) + .clamp(1, REPAIR_WORKER_MAX_LIMIT); + let max_attempts = request + .get("max_attempts") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS) + .clamp(1, REPAIR_WORKER_MAX_ATTEMPTS_LIMIT); + let failure_budget = request + .get("failure_budget") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(REPAIR_WORKER_DEFAULT_FAILURE_BUDGET) + .clamp(1, REPAIR_WORKER_MAX_FAILURE_BUDGET); + let now = now_unix_secs(); + let tasks = self.latest_repair_tasks()?; + let total_tasks = tasks.len(); + let mut checked = 0_u32; + let mut repaired = 0_u32; + let mut failed = 0_u32; + let mut skipped = 0_u32; + let mut exhausted_attempts_skipped = 0_u32; + let mut throttled = false; + let mut external_dispatches = 0_u32; + let mut external_dispatch_accepted = 0_u32; + let mut external_dispatch_failed = 0_u32; + let mut results = Vec::new(); + + for task in tasks { + if results.len() >= limit { + skipped = skipped.saturating_add(1); + continue; + } + if !task.is_repair_candidate(include_healthy_check) { + skipped = skipped.saturating_add(1); + continue; + } + if !force && !task.is_due(now) { + skipped = skipped.saturating_add(1); + continue; + } + if task.attempts >= max_attempts { + skipped = skipped.saturating_add(1); + exhausted_attempts_skipped = exhausted_attempts_skipped.saturating_add(1); + continue; + } + if failed >= failure_budget { + skipped = skipped.saturating_add(1); + throttled = true; + continue; + } + + checked = checked.saturating_add(1); + let external_dispatch = if let Some(external_repair_fleet) = &self.external_repair_fleet + { + external_dispatches = external_dispatches.saturating_add(1); + let dispatch_request = external_repair_fleet_dispatch_request(&task, now); + let receipt = external_repair_fleet + .dispatch(&dispatch_request) + .await + .unwrap_or_else(external_repair_fleet_dispatch_failed); + if receipt.get("accepted").and_then(|value| value.as_bool()) == Some(true) { + external_dispatch_accepted = external_dispatch_accepted.saturating_add(1); + } else { + external_dispatch_failed = external_dispatch_failed.saturating_add(1); + } + Some(receipt) + } else { + None + }; + let mut repair_request = json!({ + "op": "repair", + "cid": task.cid, + "availability_policy": task.policy, + "availability_requirements": task.requirements, + }); + if let Some(object_did) = task.object_did { + repair_request["object_did"] = Value::String(object_did); + } + if let Some(publisher_did) = task.publisher_did { + repair_request["publisher_did"] = Value::String(publisher_did); + } + + match self + .pin_for_availability( + &repair_request, + "local_repair_pin", + "local_repair_failed", + true, + ) + .await + { + Ok(response) + if response.get("status").and_then(|value| value.as_str()) == Some("ok") => + { + let availability = response + .get("data") + .and_then(|data| data.get("availability")) + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})); + let status = availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + if status != "repair_needed" { + repaired = repaired.saturating_add(1); + } else { + failed = failed.saturating_add(1); + } + results.push(json!({ + "cid": repair_request["cid"], + "status": status, + "availability": availability, + "external_repair_fleet_dispatch": external_dispatch, + })); + } + Ok(response) => { + failed = failed.saturating_add(1); + results.push(json!({ + "cid": repair_request["cid"], + "status": "failed", + "response": response, + "external_repair_fleet_dispatch": external_dispatch, + })); + } + Err(err) => { + failed = failed.saturating_add(1); + results.push(json!({ + "cid": repair_request["cid"], + "status": "failed", + "message": err.to_string(), + "external_repair_fleet_dispatch": external_dispatch, + })); + } + } + } + + Ok(provider_ok(json!({ + "schema": REPAIR_WORKER_RUN_SCHEMA, + "total_tasks": total_tasks, + "checked": checked, + "repaired": repaired, + "failed": failed, + "skipped": skipped, + "quota": { + "policy": "content_repair_worker_guardrail", + "scope": "content-availability", + "limit": limit, + "max_limit": REPAIR_WORKER_MAX_LIMIT, + "max_attempts": max_attempts, + "failure_budget": failure_budget, + }, + "abuse_controls": { + "schema": REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA, + "runtime_invocation_required": true, + "app_visible": false, + "force_due_override": force, + "exhausted_attempts_skipped": exhausted_attempts_skipped, + "throttled": throttled, + }, + "network_abuse_policy": network_abuse_policy_run_json( + checked, + failed, + exhausted_attempts_skipped, + throttled, + ), + "repair_fleet": repair_fleet_run_json( + checked, + repaired, + failed, + skipped, + exhausted_attempts_skipped, + throttled, + ), + "external_repair_fleet_policy": external_repair_fleet_run_policy_json( + ExternalRepairFleetRunSummary { + checked, + repaired, + failed, + skipped, + exhausted_attempts_skipped, + throttled, + external_dispatches, + external_dispatch_accepted, + external_dispatch_failed, + }, + self.external_repair_fleet.as_ref(), + ), + "results": results, + }))) + } + + async fn status(&self, request: &Value) -> Result { + if let Some(cid) = request.get("cid").and_then(|cid| cid.as_str()) { + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content status requires a valid CID", + )); + } + if let Some(receipt) = self.latest_receipt_for_cid(cid) { + let receipt = receipt?; + let repair_task = self.latest_repair_task_for_cid(cid).transpose()?; + let mut availability = json!({ + "status": receipt.payload.status, + "provider": receipt.payload.provider, + "policy": receipt.payload.policy, + "replicas": receipt.payload.replicas, + "peer_selection": receipt.payload.peer_selection, + "quota": receipt.payload.quota, + "federated_quota_ledger_policy": federated_quota_ledger_policy_from_quota_json( + &receipt.payload.quota, + false, + ), + "repair_worker": receipt.payload.repair_worker, + "storage_market": receipt.payload.storage_market, + "storage_settlement_policy": storage_settlement_policy_from_market_json( + &receipt.payload.storage_market, + ), + "storage_market_admission_policy": storage_market_admission_policy_from_market_json( + &receipt.payload.storage_market, + ), + "repair_graph": receipt.payload.repair_graph, + "abuse_controls": receipt.payload.abuse_controls, + "network_abuse_policy": network_abuse_policy_for_availability_json( + &receipt.payload.abuse_controls, + &receipt.payload.peer_selection, + ), + "accounting": receipt.payload.accounting, + "checked_at": receipt.payload.checked_at, + }); + if let Some(repair_task) = repair_task { + availability["repair_task"] = + serde_json::to_value(repair_task).map_err(|err| { + ProviderError::Provider(format!( + "content repair task encode failed: {err}" + )) + })?; + } + if let Some(storage_accounting) = self + .latest_storage_accounting_entry_for_cid(cid) + .transpose()? + { + availability["storage_accounting"] = serde_json::to_value(storage_accounting) + .map_err(|err| { + ProviderError::Provider(format!( + "content storage accounting encode failed: {err}" + )) + })?; + } + return Ok(provider_ok(json!({ + "cid": receipt.payload.cid, + "uri": receipt.payload.uri, + "availability": availability, + "receipt": receipt, + }))); + } + } + + let emit_operator_alert = request + .get("emit_operator_alert") + .or_else(|| request.get("emit_operator_alerts")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + self.availability_dashboard(emit_operator_alert).await + } + + fn write_receipt( + &self, + input: ReceiptInput, + ) -> Result { + let (signing_key, default_did) = elastos_identity::load_or_create_did(&self.data_dir) + .map_err(|err| { + ProviderError::Provider(format!("content receipt signer unavailable: {err}")) + })?; + let publisher_did = input.publisher_did.unwrap_or(default_did); + let receipt = AvailabilityReceipt { + schema: AVAILABILITY_RECEIPT_SCHEMA.to_string(), + cid: input.cid.clone(), + uri: format!("elastos://{}", input.cid), + object_did: input.object_did, + publisher_did, + provider: input.provider, + policy: input.policy, + status: input.status, + replicas: input.replicas, + peer_selection: input.peer_selection, + quota: input.quota, + repair_worker: input.repair_worker, + storage_market: input.storage_market, + repair_graph: input.repair_graph, + abuse_controls: input.abuse_controls, + accounting: input.accounting, + checked_at: now_unix_secs(), + }; + let payload_value = serde_json::to_value(&receipt).map_err(|err| { + ProviderError::Provider(format!("content receipt encode failed: {err}")) + })?; + let payload = serde_json::to_string(&payload_value).map_err(|err| { + ProviderError::Provider(format!("content receipt encode failed: {err}")) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + AVAILABILITY_RECEIPT_DOMAIN, + payload.as_bytes(), + ); + let signed = SignedAvailabilityReceipt { + payload: receipt, + signature, + signer_did, + }; + append_jsonl(&self.receipts_path(), &signed)?; + self.record_storage_accounting(&signed.payload)?; + Ok(signed) + } + + fn record_storage_accounting( + &self, + receipt: &AvailabilityReceipt, + ) -> Result { + let entry = content_storage_accounting_entry_from_receipt(receipt); + append_jsonl(&self.storage_accounting_path(), &entry)?; + Ok(entry) + } + + fn latest_receipt_for_cid( + &self, + cid: &str, + ) -> Option> { + match self.latest_receipts() { + Ok(receipts) => receipts + .into_iter() + .find(|receipt| receipt.payload.cid == cid) + .map(Ok), + Err(err) => Some(Err(err)), + } + } + + fn latest_receipts(&self) -> Result, ProviderError> { + let path = self.receipts_path(); + if !path.exists() { + return Ok(Vec::new()); + } + + let file = std::fs::File::open(&path)?; + let reader = std::io::BufReader::new(file); + let mut latest = BTreeMap::new(); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let receipt: SignedAvailabilityReceipt = + serde_json::from_str(&line).map_err(|err| { + ProviderError::Provider(format!("content receipt ledger decode failed: {err}")) + })?; + verify_signed_receipt(&receipt)?; + latest.insert(receipt.payload.cid.clone(), receipt); + } + Ok(latest.into_values().collect()) + } + + fn latest_storage_accounting_entry_for_cid( + &self, + cid: &str, + ) -> Option> { + match self.latest_storage_accounting_entries() { + Ok(entries) => entries.into_iter().find(|entry| entry.cid == cid).map(Ok), + Err(err) => Some(Err(err)), + } + } + + fn latest_storage_accounting_entries( + &self, + ) -> Result, ProviderError> { + let path = self.storage_accounting_path(); + if !path.exists() { + return Ok(Vec::new()); + } + + let file = std::fs::File::open(&path)?; + let reader = std::io::BufReader::new(file); + let mut latest = BTreeMap::new(); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let entry: ContentStorageAccountingEntry = + serde_json::from_str(&line).map_err(|err| { + ProviderError::Provider(format!( + "content storage accounting ledger decode failed: {err}" + )) + })?; + if entry.schema != CONTENT_STORAGE_ACCOUNTING_ENTRY_SCHEMA { + return Err(ProviderError::Provider(format!( + "content storage accounting ledger schema mismatch: {}", + entry.schema + ))); + } + latest.insert(entry.cid.clone(), entry); + } + Ok(latest.into_values().collect()) + } + + async fn availability_dashboard( + &self, + emit_operator_alert: bool, + ) -> Result { + let receipts = self.latest_receipts()?; + let tasks = self.latest_repair_tasks()?; + let storage_accounting_ledger = self.storage_accounting_ledger_status()?; + let storage_accounting_entries = self.latest_storage_accounting_entries()?; + let mut by_status: BTreeMap = BTreeMap::new(); + let mut by_provider: BTreeMap = BTreeMap::new(); + let mut by_quota_status: BTreeMap = BTreeMap::new(); + let mut total_replicas = 0_u32; + let mut latest_checked_at = 0_u64; + let mut quota_enforced = 0_u32; + let mut quota_requirements_exceeded = 0_u32; + let mut live_multi_peer_proofs = 0_u32; + let mut remote_replicas = 0_u32; + let mut remote_receipts = 0_u32; + let mut verified_remote_receipts = 0_u32; + let mut recent_remote_replicas = Vec::new(); + let mut peer_reputation_by_status: BTreeMap = BTreeMap::new(); + let mut peer_reputation_local_history_applied = 0_u32; + let mut peer_reputation_federated = 0_u32; + let mut accounted_objects = 0_u32; + let mut accounted_files = 0_u64; + let mut accounted_content_bytes = 0_u64; + let mut accounted_replica_bytes = 0_u64; + let mut storage_quota_enforced = 0_u32; + let mut by_accounting_source: BTreeMap = BTreeMap::new(); + let mut abuse_controls_enforced = 0_u32; + let mut abuse_controls_throttled = 0_u32; + let mut abuse_attempted_operations = 0_u64; + let mut abuse_failed_operations = 0_u64; + let mut by_abuse_policy: BTreeMap = BTreeMap::new(); + for receipt in &receipts { + *by_status.entry(receipt.payload.status.clone()).or_insert(0) += 1; + *by_provider + .entry(receipt.payload.provider.clone()) + .or_insert(0) += 1; + total_replicas = total_replicas.saturating_add(receipt.payload.replicas); + latest_checked_at = latest_checked_at.max(receipt.payload.checked_at); + + let quota_status = availability_quota_status(&receipt.payload.quota); + *by_quota_status.entry(quota_status).or_insert(0) += 1; + if receipt + .payload + .quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + quota_enforced = quota_enforced.saturating_add(1); + } + if receipt + .payload + .quota + .get("requirements_exceed_quota") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + quota_requirements_exceeded = quota_requirements_exceeded.saturating_add(1); + } + if receipt + .payload + .peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + live_multi_peer_proofs = live_multi_peer_proofs.saturating_add(1); + } + let peer_reputation_policy = + receipt.payload.peer_selection.get("peer_reputation_policy"); + let peer_attestation_exchange_policy = receipt + .payload + .peer_selection + .get("peer_attestation_exchange_policy"); + let peer_reputation_status = peer_reputation_policy + .and_then(|policy| policy.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *peer_reputation_by_status + .entry(peer_reputation_status.clone()) + .or_insert(0) += 1; + if peer_reputation_status == "local_history_applied" { + peer_reputation_local_history_applied = + peer_reputation_local_history_applied.saturating_add(1); + } + if peer_reputation_policy + .and_then(|policy| policy.get("federation")) + .and_then(|federation| federation.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + peer_reputation_federated = peer_reputation_federated.saturating_add(1); + } + for replica in receipt + .payload + .peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + if replica.get("role").and_then(|value| value.as_str()) == Some("remote") { + remote_replicas = remote_replicas.saturating_add(1); + let remote_receipt = replica.get("remote_receipt"); + recent_remote_replicas.push(json!({ + "cid": receipt.payload.cid, + "node_did": replica + .get("node_did") + .cloned() + .unwrap_or(Value::Null), + "endpoint_id": replica + .get("endpoint_id") + .cloned() + .unwrap_or(Value::Null), + "score": replica + .get("score") + .cloned() + .unwrap_or(Value::Null), + "selection_reason": replica + .get("selection_reason") + .cloned() + .unwrap_or(Value::Null), + "peer_reputation_policy": peer_reputation_policy + .cloned() + .unwrap_or(Value::Null), + "peer_attestation_exchange_policy": peer_attestation_exchange_policy + .cloned() + .unwrap_or(Value::Null), + "local_reputation": replica + .get("local_reputation") + .cloned() + .unwrap_or(Value::Null), + "status": replica + .get("status") + .cloned() + .unwrap_or(Value::Null), + "checked_at": receipt.payload.checked_at, + "remote_receipt": { + "verified": remote_receipt + .and_then(|receipt| receipt.get("verified")) + .cloned() + .unwrap_or(Value::Bool(false)), + "status": remote_receipt + .and_then(|receipt| receipt.get("status")) + .cloned() + .unwrap_or(Value::Null), + "signer_did": remote_receipt + .and_then(|receipt| receipt.get("signer_did")) + .cloned() + .unwrap_or(Value::Null), + "quota_status": remote_receipt + .and_then(|receipt| receipt.get("quota")) + .and_then(|quota| quota.get("status")) + .cloned() + .unwrap_or(Value::Null), + "content_bytes": remote_receipt + .and_then(|receipt| receipt.get("accounting")) + .and_then(|accounting| accounting.get("content_bytes")) + .cloned() + .unwrap_or(Value::Null), + "abuse_controls": remote_receipt + .and_then(|receipt| receipt.get("abuse_controls")) + .cloned() + .unwrap_or(Value::Null), + }, + })); + } + if let Some(remote_receipt) = replica.get("remote_receipt") { + remote_receipts = remote_receipts.saturating_add(1); + if remote_receipt + .get("verified") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_remote_receipts = verified_remote_receipts.saturating_add(1); + } + } + } + + let accounting = &receipt.payload.accounting; + if accounting.get("schema").and_then(|value| value.as_str()) + == Some(CONTENT_ACCOUNTING_SCHEMA) + && accounting + .get("observed") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accounted_objects = accounted_objects.saturating_add(1); + if let Some(files) = accounting.get("files").and_then(|value| value.as_u64()) { + accounted_files = accounted_files.saturating_add(files); + } + if let Some(bytes) = accounting + .get("content_bytes") + .and_then(|value| value.as_u64()) + { + accounted_content_bytes = accounted_content_bytes.saturating_add(bytes); + } + if let Some(bytes) = accounting + .get("replica_bytes_estimate") + .and_then(|value| value.as_u64()) + { + accounted_replica_bytes = accounted_replica_bytes.saturating_add(bytes); + } + if accounting + .get("storage_quota") + .and_then(|quota| quota.get("enforced")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + storage_quota_enforced = storage_quota_enforced.saturating_add(1); + } + let source = accounting + .get("source") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + *by_accounting_source.entry(source).or_insert(0) += 1; + } + + let abuse_controls = &receipt.payload.abuse_controls; + if abuse_controls + .get("schema") + .and_then(|value| value.as_str()) + == Some(CONTENT_ABUSE_CONTROLS_SCHEMA) + { + if abuse_controls + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + abuse_controls_enforced = abuse_controls_enforced.saturating_add(1); + } + if abuse_controls + .get("throttled") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + abuse_controls_throttled = abuse_controls_throttled.saturating_add(1); + } + if let Some(attempted) = abuse_controls + .get("attempted_operations") + .and_then(|value| value.as_u64()) + { + abuse_attempted_operations = + abuse_attempted_operations.saturating_add(attempted); + } + if let Some(failed) = abuse_controls + .get("failed_operations") + .and_then(|value| value.as_u64()) + { + abuse_failed_operations = abuse_failed_operations.saturating_add(failed); + } + let policy = abuse_controls + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + *by_abuse_policy.entry(policy).or_insert(0) += 1; + } + } + + let mut repair_status_counts: BTreeMap = BTreeMap::new(); + let mut queued = 0_u32; + let mut due = 0_u32; + let mut healthy = 0_u32; + let now = now_unix_secs(); + for task in &tasks { + *repair_status_counts.entry(task.status.clone()).or_insert(0) += 1; + if task.status == "queued" { + queued = queued.saturating_add(1); + if task.is_due(now) { + due = due.saturating_add(1); + } + } + if task.status == "healthy" { + healthy = healthy.saturating_add(1); + } + } + + let mut recent_tasks = tasks.clone(); + recent_tasks.sort_by(|a, b| { + b.checked_at + .cmp(&a.checked_at) + .then_with(|| a.cid.cmp(&b.cid)) + }); + let recent_tasks = recent_tasks + .iter() + .take(10) + .map(|task| { + json!({ + "cid": task.cid, + "status": task.status, + "attempts": task.attempts, + "checked_at": task.checked_at, + "next_check_after": task.next_check_after, + "due": task.is_due(now), + }) + }) + .collect::>(); + recent_remote_replicas.sort_by(|a, b| { + b.get("checked_at") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + .cmp( + &a.get("checked_at") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + ) + .then_with(|| { + a.get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("") + .cmp(b.get("cid").and_then(|value| value.as_str()).unwrap_or("")) + }) + }); + let recent_remote_replicas = recent_remote_replicas + .into_iter() + .take(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT) + .collect::>(); + let recent_remote_replicas_truncated = + remote_replicas as usize > recent_remote_replicas.len(); + + let mut dashboard = json!({ + "schema": AVAILABILITY_DASHBOARD_SCHEMA, + "provider": "content-provider", + "objects": { + "tracked": receipts.len(), + "by_status": by_status, + "by_provider": by_provider, + "total_replicas": total_replicas, + "latest_checked_at": latest_checked_at, + }, + "quota": { + "by_status": by_quota_status, + "enforced": quota_enforced, + "requirements_exceed_quota": quota_requirements_exceeded, + }, + "federated_quota_ledger_policy": federated_quota_ledger_policy_status_json( + &receipts, + &storage_accounting_ledger, + self.federated_quota_ledger_exchange.as_ref(), + ), + "proofs": { + "live_multi_peer": live_multi_peer_proofs, + "remote_replicas": remote_replicas, + "remote_receipts": remote_receipts, + "verified_remote_receipts": verified_remote_receipts, + "recent_remote_replica_limit": AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT, + "recent_remote_replicas_truncated": recent_remote_replicas_truncated, + "recent_remote_replicas": recent_remote_replicas, + "peer_reputation_policy": { + "schema": "elastos.carrier.peer-reputation/v1", + "policy": "local_runtime_reputation", + "scope": "content-availability", + "by_status": peer_reputation_by_status, + "local_history_applied": peer_reputation_local_history_applied, + "federated_policy_receipts": peer_reputation_federated, + "federation": { + "configured": peer_reputation_federated > 0, + "cross_runtime_reputation": peer_reputation_federated > 0, + "reason": if peer_reputation_federated > 0 { + "federated reputation receipts were observed" + } else { + "this branch records local Runtime success/failure reputation only" + }, + }, + }, + "peer_attestation_exchange_policy": peer_attestation_exchange_policy_status_json( + &receipts, + ), + }, + "accounting": { + "schema": CONTENT_ACCOUNTING_SCHEMA, + "accounted_objects": accounted_objects, + "accounted_files": accounted_files, + "content_bytes": accounted_content_bytes, + "replica_bytes_estimate": accounted_replica_bytes, + "by_source": by_accounting_source, + "storage_quota_enforced": storage_quota_enforced, + "storage_quota_policy": "principal_ledger", + "ledger": storage_accounting_ledger, + }, + "abuse_controls": { + "schema": CONTENT_ABUSE_CONTROLS_SCHEMA, + "enforced": abuse_controls_enforced, + "throttled": abuse_controls_throttled, + "attempted_operations": abuse_attempted_operations, + "failed_operations": abuse_failed_operations, + "by_policy": by_abuse_policy, + }, + "network_abuse_policy": network_abuse_policy_status_json( + NetworkAbusePolicyStatusCounters { + tracked_objects: receipts.len(), + remote_replicas, + remote_receipts, + abuse_controls_enforced, + abuse_controls_throttled, + abuse_attempted_operations, + abuse_failed_operations, + }, + self.federated_abuse_control_exchange.as_ref(), + ), + "storage_settlement_policy": storage_settlement_policy_status_json( + &receipts, + &storage_accounting_ledger, + ), + "storage_market_admission_policy": storage_market_admission_policy_status_json( + &receipts, + &storage_accounting_ledger, + self.storage_market_admission.as_ref(), + ), + "repair": { + "tracked_tasks": tasks.len(), + "by_status": repair_status_counts, + "queued": queued, + "due": due, + "healthy": healthy, + "recent": recent_tasks, + }, + "scheduler": { + "manual_trigger": "elastos content repair-worker", + "env": "ELASTOS_CONTENT_REPAIR_SCHEDULER", + "enabled_by_default": false, + "provider_invocation_required": true, + }, + "repair_fleet": repair_fleet_status_json(&tasks, now), + "external_repair_fleet_policy": external_repair_fleet_policy_json( + &tasks, + now, + self.external_repair_fleet.as_ref(), + ), + "federated_operator_alerting_policy": federated_operator_alerting_policy_json( + &receipts, + &tasks, + &storage_accounting_entries, + &storage_accounting_ledger, + now, + self.operator_alert_sink.as_ref(), + self.federated_operator_alert_exchange.as_ref(), + ), + "operator_dashboard": operator_dashboard_json( + &receipts, + &tasks, + &storage_accounting_entries, + &storage_accounting_ledger, + now, + ContentOperatorDashboardIntegrations { + operator_alert_sink: self.operator_alert_sink.as_ref(), + external_repair_fleet: self.external_repair_fleet.as_ref(), + federated_quota_ledger_exchange: self + .federated_quota_ledger_exchange + .as_ref(), + federated_operator_alert_exchange: self + .federated_operator_alert_exchange + .as_ref(), + }, + ), + }); + if emit_operator_alert { + dashboard["operator_alert_delivery"] = self.emit_operator_alert(&dashboard).await?; + } + Ok(provider_ok(dashboard)) + } + + fn content_accounting_from_previous_or_unknown( + &self, + cid: &str, + source: &str, + replicas: u32, + ) -> Result { + let observation = self + .latest_receipt_for_cid(cid) + .transpose()? + .map(|receipt| content_accounting_observation_from_value(&receipt.payload.accounting)) + .unwrap_or_default(); + Ok(content_accounting_json(source, observation, replicas)) + } + + fn principal_storage_quota_for_request( + &self, + principal_did: &str, + requirements: &AvailabilityRequirements, + incoming_content_bytes: Option, + exclude_cid: Option<&str>, + ) -> Result { + let Some(max_content_bytes) = requirements.max_storage_bytes_per_principal else { + return Ok(default_storage_quota_json()); + }; + let incoming_content_bytes = incoming_content_bytes.ok_or_else(|| { + ProviderError::Provider( + "content storage quota requires known incoming content bytes".to_string(), + ) + })?; + let active_content_bytes = + self.principal_active_content_bytes(principal_did, exclude_cid)?; + let projected_content_bytes = active_content_bytes.saturating_add(incoming_content_bytes); + let quota_exceeded = projected_content_bytes > max_content_bytes; + let status = if quota_exceeded { + "quota_exceeded" + } else { + "within_quota" + }; + Ok(json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "principal_storage_quota", + "scope": "content-availability", + "enforced": true, + "status": status, + "principal_did": principal_did, + "active_content_bytes": active_content_bytes, + "incoming_content_bytes": incoming_content_bytes, + "projected_content_bytes": projected_content_bytes, + "max_content_bytes": max_content_bytes, + "federated_quota_ledger_policy": federated_quota_ledger_policy_json( + "principal_storage_quota", + status, + true, + false, + true, + ), + })) + } + + fn principal_active_content_bytes( + &self, + principal_did: &str, + exclude_cid: Option<&str>, + ) -> Result { + Ok(self + .latest_storage_accounting_entries()? + .into_iter() + .filter(|entry| { + entry.principal_did == principal_did + && Some(entry.cid.as_str()) != exclude_cid + && entry.replicas > 0 + && entry.status != "local_unpinned" + }) + .filter_map(|entry| entry.content_bytes) + .fold(0_u64, u64::saturating_add)) + } + + fn record_repair_task( + &self, + receipt: &SignedAvailabilityReceipt, + outcome: &AvailabilityOutcome, + requirements: AvailabilityRequirements, + repair_worker_attempt: bool, + ) -> Result { + let previous_attempts = self + .latest_repair_task_for_cid(&receipt.payload.cid) + .transpose()? + .map(|task| task.attempts) + .unwrap_or(0); + let attempts = previous_attempts.saturating_add(if repair_worker_attempt { 1 } else { 0 }); + let status = repair_task_status_for_availability(&outcome.status).to_string(); + let next_check_after = + repair_task_next_check_after(&status, receipt.payload.checked_at, outcome); + let mut repair_worker = outcome.repair_worker.clone(); + if !repair_worker.is_object() { + repair_worker = repair_worker_json(status == "queued"); + } + if let Some(metadata) = repair_worker.as_object_mut() { + metadata.insert( + "worker".to_string(), + Value::String("content-provider".to_string()), + ); + metadata.insert("status".to_string(), Value::String(status.clone())); + metadata.insert( + "scheduled".to_string(), + Value::Bool(matches!(status.as_str(), "queued" | "healthy")), + ); + metadata.insert( + "next_check_after".to_string(), + Value::from(next_check_after), + ); + } + let task = ContentRepairTask { + schema: REPAIR_TASK_SCHEMA.to_string(), + cid: receipt.payload.cid.clone(), + uri: receipt.payload.uri.clone(), + object_did: receipt.payload.object_did.clone(), + publisher_did: Some(receipt.payload.publisher_did.clone()), + policy: outcome.policy.clone(), + status, + reason: outcome.reason.clone(), + attempts, + requirements: requirements.to_json(), + availability: outcome.to_json(), + repair_worker, + checked_at: receipt.payload.checked_at, + next_check_after, + }; + append_jsonl(&self.repair_tasks_path(), &task)?; + Ok(task) + } + + fn latest_repair_task_for_cid( + &self, + cid: &str, + ) -> Option> { + match self.latest_repair_tasks() { + Ok(tasks) => tasks.into_iter().find(|task| task.cid == cid).map(Ok), + Err(err) => Some(Err(err)), + } + } + + fn latest_repair_tasks(&self) -> Result, ProviderError> { + let path = self.repair_tasks_path(); + if !path.exists() { + return Ok(Vec::new()); + } + + let file = std::fs::File::open(&path)?; + let reader = std::io::BufReader::new(file); + let mut latest = BTreeMap::new(); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let task: ContentRepairTask = serde_json::from_str(&line).map_err(|err| { + ProviderError::Provider(format!("content repair task ledger decode failed: {err}")) + })?; + latest.insert(task.cid.clone(), task); + } + Ok(latest.into_values().collect()) + } + + fn receipts_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("availability-receipts.jsonl") + } + + fn repair_tasks_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("repair-tasks.jsonl") + } + + fn storage_accounting_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("storage-accounting.jsonl") + } + + fn operator_alert_receipts_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("operator-alert-receipts.jsonl") + } + + async fn emit_operator_alert(&self, dashboard: &Value) -> Result { + let emitted_at = now_unix_secs(); + let alert = operator_alert_payload(dashboard, emitted_at); + let local_delivery = match &self.operator_alert_sink { + Some(sink) => sink.deliver(&alert).await, + None => Ok(json!({ + "configured": false, + "delivered": false, + "status": "not_configured", + "reason": "no content operator alert sink is configured", + })), + }; + let delivery = match local_delivery { + Ok(delivery) => delivery, + Err(err) => json!({ + "configured": true, + "delivered": false, + "status": "failed", + "reason": err, + }), + }; + let federated_exchange = match &self.federated_operator_alert_exchange { + Some(exchange) => { + let request = federated_operator_alert_exchange_request(&alert, emitted_at); + match exchange.exchange(&request).await { + Ok(receipt) => receipt, + Err(err) => json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "delivered": false, + "accepted": false, + "status": "failed", + "reason": err, + "exchange": exchange.redacted_status_json(), + "credential_exposed": false, + }), + } + } + None => json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "configured": false, + "delivered": false, + "accepted": false, + "status": "not_configured", + "reason": "no federated operator alert exchange is configured", + }), + }; + let receipt = json!({ + "schema": CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "emitted_at": emitted_at, + "requested": true, + "alert": alert, + "delivery": delivery, + "federated_exchange": federated_exchange, + }); + append_jsonl(&self.operator_alert_receipts_path(), &receipt)?; + Ok(receipt) + } + + fn sign_admission_receipt(&self, admission: &Value) -> Result { + let (signing_key, _) = + elastos_identity::load_or_create_did(&self.data_dir).map_err(|err| { + ProviderError::Provider(format!( + "content admission receipt signer unavailable: {err}" + )) + })?; + let payload = serde_json::to_string(admission).map_err(|err| { + ProviderError::Provider(format!("content admission receipt encode failed: {err}")) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_ADMISSION_DOMAIN, + payload.as_bytes(), + ); + Ok(json!({ + "payload": admission, + "signature": signature, + "signer_did": signer_did, + })) + } + + fn sign_federated_quota_ledger_exchange_request( + &self, + local_admission: &Value, + request: &Value, + ) -> Result { + let (signing_key, _) = + elastos_identity::load_or_create_did(&self.data_dir).map_err(|err| { + ProviderError::Provider(format!( + "content federated quota-ledger exchange signer unavailable: {err}" + )) + })?; + let payload = federated_quota_ledger_exchange_request(local_admission, request); + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "content federated quota-ledger exchange request encode failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_DOMAIN, + canonical.as_bytes(), + ); + Ok(json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + })) + } + + fn sign_federated_abuse_control_exchange_request( + &self, + local_admission: &Value, + request: &Value, + ) -> Result { + let (signing_key, _) = + elastos_identity::load_or_create_did(&self.data_dir).map_err(|err| { + ProviderError::Provider(format!( + "content federated abuse-control exchange signer unavailable: {err}" + )) + })?; + let payload = federated_abuse_control_exchange_request(local_admission, request); + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "content federated abuse-control exchange request encode failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_DOMAIN, + canonical.as_bytes(), + ); + Ok(json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + })) + } + + fn storage_accounting_ledger_status(&self) -> Result { + #[derive(Default)] + struct PrincipalTotals { + tracked_objects: u32, + active_objects: u32, + files: u64, + content_bytes: u64, + replica_bytes_estimate: u64, + quota_enforced: u32, + latest_recorded_at: u64, + by_status: BTreeMap, + by_quota_status: BTreeMap, + } + + let entries = self.latest_storage_accounting_entries()?; + let mut by_principal: BTreeMap = BTreeMap::new(); + let mut active_objects = 0_u32; + let mut active_principals = BTreeSet::new(); + let mut content_bytes = 0_u64; + let mut replica_bytes_estimate = 0_u64; + let mut quota_enforced = 0_u32; + let mut latest_recorded_at = 0_u64; + + for entry in &entries { + let active = entry.replicas > 0 && entry.status != "local_unpinned"; + let quota_status = availability_quota_status(&entry.quota); + let storage_quota_enforced = entry + .storage_quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + || entry + .quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + + let principal = by_principal.entry(entry.principal_did.clone()).or_default(); + principal.tracked_objects = principal.tracked_objects.saturating_add(1); + *principal.by_status.entry(entry.status.clone()).or_insert(0) += 1; + *principal.by_quota_status.entry(quota_status).or_insert(0) += 1; + principal.latest_recorded_at = principal.latest_recorded_at.max(entry.recorded_at); + + if storage_quota_enforced { + quota_enforced = quota_enforced.saturating_add(1); + principal.quota_enforced = principal.quota_enforced.saturating_add(1); + } + + if active { + active_objects = active_objects.saturating_add(1); + active_principals.insert(entry.principal_did.clone()); + principal.active_objects = principal.active_objects.saturating_add(1); + if let Some(files) = entry.files { + principal.files = principal.files.saturating_add(files); + } + if let Some(bytes) = entry.content_bytes { + content_bytes = content_bytes.saturating_add(bytes); + principal.content_bytes = principal.content_bytes.saturating_add(bytes); + } + if let Some(bytes) = entry.replica_bytes_estimate { + replica_bytes_estimate = replica_bytes_estimate.saturating_add(bytes); + principal.replica_bytes_estimate = + principal.replica_bytes_estimate.saturating_add(bytes); + } + } + + latest_recorded_at = latest_recorded_at.max(entry.recorded_at); + } + + let by_principal = by_principal + .into_iter() + .map(|(principal_did, totals)| { + ( + principal_did, + json!({ + "tracked_objects": totals.tracked_objects, + "active_objects": totals.active_objects, + "files": totals.files, + "content_bytes": totals.content_bytes, + "replica_bytes_estimate": totals.replica_bytes_estimate, + "quota_enforced": totals.quota_enforced, + "by_status": totals.by_status, + "by_quota_status": totals.by_quota_status, + "latest_recorded_at": totals.latest_recorded_at, + }), + ) + }) + .collect::>(); + + Ok(json!({ + "schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "durable": true, + "source": "signed_availability_receipts", + "tracked_objects": entries.len(), + "active_objects": active_objects, + "tracked_principals": by_principal.len(), + "active_principals": active_principals.len(), + "content_bytes": content_bytes, + "replica_bytes_estimate": replica_bytes_estimate, + "quota_enforced": quota_enforced, + "latest_recorded_at": latest_recorded_at, + "by_principal": by_principal, + "market_policy": { + "schema": "elastos.content.storage-market/v1", + "mode": "provider_receipt_accounting", + "status": if quota_enforced > 0 { + "quota_policy_recorded_no_settlement" + } else { + "accounting_recorded_no_settlement" + }, + "settlement": "not_configured", + "escrow": "not_configured", + "admission_policy": storage_market_admission_policy_json( + "provider_receipt_accounting", + if quota_enforced > 0 { + "quota_policy_recorded_no_settlement" + } else { + "accounting_recorded_no_settlement" + }, + quota_enforced > 0, + false, + false, + ), + "settlement_policy": storage_settlement_policy_json( + "provider_receipt_accounting", + if quota_enforced > 0 { + "quota_policy_recorded_no_settlement" + } else { + "accounting_recorded_no_settlement" + }, + quota_enforced > 0, + ), + "next": "External pricing, escrow/settlement, storage-market admission, and cross-peer SLA policy remain production-network work." + }, + })) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailabilityReceipt { + pub schema: String, + pub cid: String, + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_did: Option, + pub publisher_did: String, + pub provider: String, + pub policy: String, + pub status: String, + pub replicas: u32, + pub peer_selection: Value, + pub quota: Value, + pub repair_worker: Value, + #[serde( + default = "default_content_storage_market_json", + skip_serializing_if = "is_default_content_storage_market_json" + )] + pub storage_market: Value, + #[serde( + default = "default_content_repair_graph_json", + skip_serializing_if = "is_default_content_repair_graph_json" + )] + pub repair_graph: Value, + #[serde(default = "default_content_abuse_controls_json")] + pub abuse_controls: Value, + #[serde(default = "default_content_accounting_json")] + pub accounting: Value, + pub checked_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedAvailabilityReceipt { + pub payload: AvailabilityReceipt, + pub signature: String, + pub signer_did: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ContentRepairTask { + schema: String, + cid: String, + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + object_did: Option, + #[serde(skip_serializing_if = "Option::is_none")] + publisher_did: Option, + policy: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + attempts: u32, + requirements: Value, + availability: Value, + repair_worker: Value, + checked_at: u64, + next_check_after: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ContentStorageAccountingEntry { + schema: String, + cid: String, + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + object_did: Option, + principal_did: String, + provider: String, + policy: String, + status: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + files: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content_bytes: Option, + replicas: u32, + #[serde(skip_serializing_if = "Option::is_none")] + replica_bytes_estimate: Option, + quota: Value, + storage_quota: Value, + recorded_at: u64, +} + +impl ContentRepairTask { + fn is_repair_candidate(&self, include_healthy_check: bool) -> bool { + matches!(self.status.as_str(), "queued" | "repair_needed") + || (include_healthy_check && self.status == "healthy") + } + + fn is_due(&self, now: u64) -> bool { + self.next_check_after == 0 || self.next_check_after <= now + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentObjectManifest { + pub schema: String, + pub kind: String, + pub content_digest: String, + pub files: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub links: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_did: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub publisher_did: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentObjectFile { + pub path: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentObjectLink { + pub rel: String, + pub cid: String, +} + +struct ReceiptInput { + cid: String, + object_did: Option, + publisher_did: Option, + provider: String, + policy: String, + status: String, + replicas: u32, + peer_selection: Value, + quota: Value, + repair_worker: Value, + storage_market: Value, + repair_graph: Value, + abuse_controls: Value, + accounting: Value, +} + +struct AvailabilityRequestContext<'a> { + object_did: Option<&'a str>, + publisher_did: Option<&'a str>, + accounting_observation: ContentAccountingObservation, +} + +#[derive(Debug, Clone)] +struct AvailabilityRequirements { + min_replicas: u32, + max_replicas: Option, + require_live_multi_peer_proof: bool, + max_storage_bytes_per_principal: Option, + repair_graph_kind: Option, +} + +impl AvailabilityRequirements { + fn from_request(request: &Value) -> Self { + let requirements = request + .get("availability_requirements") + .or_else(|| request.get("replication_requirements")); + let min_replicas = requirements + .and_then(|value| value.get("min_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1); + let max_replicas = requirements + .and_then(|value| value.get("max_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0); + let require_live_multi_peer_proof = requirements + .and_then(|value| value.get("require_live_multi_peer_proof")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let max_storage_bytes_per_principal = requirements + .and_then(|value| { + value + .get("max_storage_bytes_per_principal") + .or_else(|| value.get("max_principal_storage_bytes")) + .or_else(|| value.get("max_storage_bytes")) + .or_else(|| value.get("storage_quota_bytes")) + }) + .and_then(|value| value.as_u64()) + .filter(|value| *value > 0); + let repair_graph_kind = requirements + .and_then(|value| { + value + .get("repair_graph_kind") + .or_else(|| value.get("content_graph_kind")) + .or_else(|| value.get("graph_kind")) + .or_else(|| { + value + .get("repair_graph") + .and_then(|graph| graph.get("kind")) + }) + .or_else(|| { + value + .get("content_graph") + .and_then(|graph| graph.get("kind")) + }) + }) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + Self { + min_replicas, + max_replicas, + require_live_multi_peer_proof, + max_storage_bytes_per_principal, + repair_graph_kind, + } + } + + fn to_json(&self) -> Value { + json!({ + "min_replicas": self.min_replicas, + "max_replicas": self.max_replicas, + "require_live_multi_peer_proof": self.require_live_multi_peer_proof, + "max_storage_bytes_per_principal": self.max_storage_bytes_per_principal, + "repair_graph_kind": self.repair_graph_kind, + }) + } +} + +impl Default for AvailabilityRequirements { + fn default() -> Self { + Self { + min_replicas: 1, + max_replicas: None, + require_live_multi_peer_proof: false, + max_storage_bytes_per_principal: None, + repair_graph_kind: None, + } + } +} + +#[derive(Debug, Clone)] +struct AvailabilityOutcome { + provider: String, + policy: String, + status: String, + replicas: u32, + reason: Option, + peer_selection: Value, + quota: Value, + repair_worker: Value, + storage_market: Value, + repair_graph: Value, + abuse_controls: Value, +} + +impl AvailabilityOutcome { + fn local_publish(pin: bool) -> Self { + if pin { + Self { + provider: "ipfs-provider".to_string(), + policy: "local_pin".to_string(), + status: "local_pinned".to_string(), + replicas: 1, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + } + } else { + Self { + provider: "ipfs-provider".to_string(), + policy: "local_add".to_string(), + status: "local_unpinned".to_string(), + replicas: 0, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + } + } + } + + fn repair_needed(provider: &str, policy: &str, replicas: u32, reason: String) -> Self { + Self { + provider: provider.to_string(), + policy: policy.to_string(), + status: "repair_needed".to_string(), + replicas, + reason: Some(reason), + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(true), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + } + } + + fn to_json(&self) -> Value { + let mut availability = json!({ + "status": self.status, + "provider": self.provider, + "policy": self.policy, + "replicas": self.replicas, + "peer_selection": self.peer_selection, + "quota": self.quota, + "federated_quota_ledger_policy": federated_quota_ledger_policy_from_quota_json( + &self.quota, + false, + ), + "repair_worker": self.repair_worker, + "storage_market": self.storage_market, + "storage_settlement_policy": storage_settlement_policy_from_market_json( + &self.storage_market, + ), + "storage_market_admission_policy": storage_market_admission_policy_from_market_json( + &self.storage_market, + ), + "repair_graph": self.repair_graph, + "abuse_controls": self.abuse_controls, + "network_abuse_policy": network_abuse_policy_for_availability_json( + &self.abuse_controls, + &self.peer_selection, + ), + }); + if let Some(reason) = &self.reason { + availability["reason"] = Value::String(reason.clone()); + } + availability + } +} + +fn repair_task_status_for_availability(status: &str) -> &'static str { + match status { + "repair_needed" => "queued", + "network_available" | "carrier_announced" => "healthy", + "local_unpinned" => "retired", + "local_pinned" => "local_only", + _ => "observed", + } +} + +fn repair_task_next_check_after( + task_status: &str, + checked_at: u64, + outcome: &AvailabilityOutcome, +) -> u64 { + match task_status { + "queued" => checked_at.saturating_add(REPAIR_RETRY_DELAY_SECS), + "healthy" => checked_at.saturating_add(REPAIR_HEALTH_CHECK_DELAY_SECS), + "local_only" if outcome.status == "local_pinned" => 0, + _ => 0, + } +} + +fn network_abuse_policy_for_availability_json( + abuse_controls: &Value, + peer_selection: &Value, +) -> Value { + let enforced = abuse_controls + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let throttled = abuse_controls + .get("throttled") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let attempted_operations = abuse_controls + .get("attempted_operations") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let failed_operations = abuse_controls + .get("failed_operations") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let remote_replicas = peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .map(|replicas| { + replicas + .iter() + .filter(|replica| { + replica.get("role").and_then(|value| value.as_str()) == Some("remote") + }) + .count() + }) + .unwrap_or(0); + json!({ + "schema": CONTENT_NETWORK_ABUSE_POLICY_SCHEMA, + "policy": "provider_plane_local_guardrails", + "scope": "content-availability", + "status": if enforced { + "local_provider_guardrails_recorded" + } else { + "local_backend_no_network_guardrail" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "receipt": { + "abuse_controls_policy": abuse_controls + .get("policy") + .cloned() + .unwrap_or(Value::String("not_reported".to_string())), + "enforced": enforced, + "throttled": throttled, + "attempted_operations": attempted_operations, + "failed_operations": failed_operations, + "remote_replicas": remote_replicas, + }, + "local_guardrails": { + "signed_availability_receipts": true, + "carrier_provider_candidate_cap": enforced, + "carrier_provider_admission_preflight": enforced && remote_replicas > 0, + "repair_worker_attempt_budget": true, + "provider_invocation_required": true, + }, + "network_federation": { + "configured": false, + "cross_peer_rate_limit": false, + "federated_banlist": false, + "federated_abuse_ledger": false, + "reason": "this receipt exposes provider-owned local guardrails; production network-wide throttles, banlists, and abuse ledgers require a configured policy plane", + }, + }) +} + +struct NetworkAbusePolicyStatusCounters { + tracked_objects: usize, + remote_replicas: u32, + remote_receipts: u32, + abuse_controls_enforced: u32, + abuse_controls_throttled: u32, + abuse_attempted_operations: u64, + abuse_failed_operations: u64, +} + +fn network_abuse_policy_status_json( + counters: NetworkAbusePolicyStatusCounters, + federated_abuse_control_exchange: Option<&ContentFederatedAbuseControlExchangeClient>, +) -> Value { + json!({ + "schema": CONTENT_NETWORK_ABUSE_POLICY_SCHEMA, + "policy": "provider_plane_local_guardrails", + "scope": "content-availability", + "status": if federated_abuse_control_exchange.is_some() { + "configured_federated_abuse_control_exchange" + } else if counters.abuse_controls_enforced > 0 || counters.remote_replicas > 0 { + "local_guardrails_recorded_no_federated_throttle" + } else { + "local_only_no_network_activity" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "local_guardrails": { + "signed_availability_receipts": true, + "carrier_provider_candidate_cap": true, + "carrier_provider_admission_preflight": true, + "repair_worker_attempt_budget": true, + "repair_worker_failure_budget": true, + "provider_invocation_required": true, + }, + "counters": { + "tracked_objects": counters.tracked_objects, + "remote_replicas": counters.remote_replicas, + "remote_receipts": counters.remote_receipts, + "guardrail_receipts": counters.abuse_controls_enforced, + "throttled_receipts": counters.abuse_controls_throttled, + "attempted_provider_operations": counters.abuse_attempted_operations, + "failed_provider_operations": counters.abuse_failed_operations, + }, + "network_federation": { + "configured": federated_abuse_control_exchange.is_some(), + "cross_peer_rate_limit": federated_abuse_control_exchange.is_some(), + "federated_banlist": false, + "federated_abuse_ledger": federated_abuse_control_exchange.is_some(), + "exchange_client": federated_abuse_control_exchange + .map(ContentFederatedAbuseControlExchangeClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "reason": if federated_abuse_control_exchange.is_some() { + "configured federated abuse-control exchange is enforced by content/admission before remote bytes or DAG repair data move; production banlists and network-wide abuse policy remain external infrastructure" + } else { + "cross-peer throttles, banlists, and abuse ledgers are production-network policy, not app-visible Library authority" + }, + }, + }) +} + +fn network_abuse_policy_run_json( + checked: u32, + failed: u32, + exhausted_attempts_skipped: u32, + throttled: bool, +) -> Value { + json!({ + "schema": CONTENT_NETWORK_ABUSE_POLICY_SCHEMA, + "policy": "provider_plane_local_guardrails", + "scope": "content-availability", + "status": if throttled || exhausted_attempts_skipped > 0 { + "local_worker_throttled" + } else { + "local_worker_within_budget" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "local_guardrails": { + "repair_worker_attempt_budget": true, + "repair_worker_failure_budget": true, + "force_due_override_audited": true, + }, + "run": { + "checked": checked, + "failed": failed, + "exhausted_attempts_skipped": exhausted_attempts_skipped, + "throttled": throttled, + }, + "network_federation": { + "configured": false, + "cross_peer_rate_limit": false, + "federated_banlist": false, + "federated_abuse_ledger": false, + }, + }) +} + +fn peer_attestation_exchange_policy_status_json(receipts: &[SignedAvailabilityReceipt]) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut policy_receipts = 0_u32; + let mut configured_receipts = 0_u32; + let mut remote_provider_proofs = 0_u64; + let mut verified_remote_content_receipts = 0_u64; + + for receipt in receipts { + let policy = receipt + .payload + .peer_selection + .get("peer_attestation_exchange_policy"); + if policy.is_some() { + policy_receipts = policy_receipts.saturating_add(1); + } + let status = policy + .and_then(|policy| policy.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .and_then(|policy| policy.get("attestation_exchange")) + .and_then(|exchange| exchange.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + configured_receipts = configured_receipts.saturating_add(1); + } + if let Some(proofs) = policy + .and_then(|policy| policy.get("local_proof")) + .and_then(|proof| proof.get("remote_provider_proofs")) + .and_then(|value| value.as_u64()) + { + remote_provider_proofs = remote_provider_proofs.saturating_add(proofs); + } + if let Some(verified) = policy + .and_then(|policy| policy.get("local_proof")) + .and_then(|proof| proof.get("verified_remote_content_receipts")) + .and_then(|value| value.as_u64()) + { + verified_remote_content_receipts = + verified_remote_content_receipts.saturating_add(verified); + } + } + + json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": "provider_receipt_attestation_exchange_status", + "scope": "content-availability", + "status": if configured_receipts > 0 { + "attestation_exchange_observed" + } else if remote_provider_proofs > 0 { + "live_peer_proof_without_attestation_exchange" + } else { + "attestation_exchange_not_configured" + }, + "receipt_count": receipts.len(), + "policy_receipts": policy_receipts, + "by_status": by_status, + "local_proof": { + "signed_availability_announcements": true, + "remote_provider_proofs": remote_provider_proofs, + "verified_remote_content_receipts": verified_remote_content_receipts, + "local_runtime_reputation": true, + }, + "attestation_exchange": { + "configured": configured_receipts > 0, + "signed_reputation_receipts": configured_receipts > 0, + "third_party_attestations": false, + "cross_runtime_trust_policy": false, + "revocation": "not_configured", + "reason": if configured_receipts > 0 { + "one or more receipts reported cross-runtime reputation attestations" + } else { + "no signed cross-runtime reputation attestation exchange is configured" + }, + }, + }) +} + +#[derive(Clone, Copy)] +struct ContentOperatorDashboardIntegrations<'a> { + operator_alert_sink: Option<&'a ContentOperatorAlertSink>, + external_repair_fleet: Option<&'a ContentExternalRepairFleetClient>, + federated_quota_ledger_exchange: Option<&'a ContentFederatedQuotaLedgerExchangeClient>, + federated_operator_alert_exchange: Option<&'a ContentFederatedOperatorAlertExchangeClient>, +} + +fn operator_dashboard_json( + receipts: &[SignedAvailabilityReceipt], + tasks: &[ContentRepairTask], + storage_entries: &[ContentStorageAccountingEntry], + storage_ledger: &Value, + now: u64, + integrations: ContentOperatorDashboardIntegrations<'_>, +) -> Value { + #[derive(Default)] + struct PrincipalPressure { + active_objects: u32, + files: u64, + content_bytes: u64, + replica_bytes_estimate: u64, + quota_enforced: u32, + latest_recorded_at: u64, + } + + let mut principals: BTreeMap = BTreeMap::new(); + let mut quota_exceeded_records = 0_u32; + for entry in storage_entries { + let active = entry.replicas > 0 && entry.status != "local_unpinned"; + if entry + .storage_quota + .get("status") + .or_else(|| entry.quota.get("status")) + .and_then(|value| value.as_str()) + == Some("quota_exceeded") + { + quota_exceeded_records = quota_exceeded_records.saturating_add(1); + } + if !active { + continue; + } + + let principal = principals.entry(entry.principal_did.clone()).or_default(); + principal.active_objects = principal.active_objects.saturating_add(1); + principal.latest_recorded_at = principal.latest_recorded_at.max(entry.recorded_at); + if let Some(files) = entry.files { + principal.files = principal.files.saturating_add(files); + } + if let Some(bytes) = entry.content_bytes { + principal.content_bytes = principal.content_bytes.saturating_add(bytes); + } + if let Some(bytes) = entry.replica_bytes_estimate { + principal.replica_bytes_estimate = + principal.replica_bytes_estimate.saturating_add(bytes); + } + if entry + .storage_quota + .get("enforced") + .or_else(|| entry.quota.get("enforced")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + principal.quota_enforced = principal.quota_enforced.saturating_add(1); + } + } + + let mut top_principals = principals + .into_iter() + .map(|(principal_did, totals)| { + json!({ + "principal_did": principal_did, + "active_objects": totals.active_objects, + "files": totals.files, + "content_bytes": totals.content_bytes, + "replica_bytes_estimate": totals.replica_bytes_estimate, + "quota_enforced": totals.quota_enforced, + "latest_recorded_at": totals.latest_recorded_at, + }) + }) + .collect::>(); + top_principals.sort_by(|a, b| { + b.get("content_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + .cmp( + &a.get("content_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + ) + .then_with(|| { + a.get("principal_did") + .and_then(|value| value.as_str()) + .unwrap_or("") + .cmp( + b.get("principal_did") + .and_then(|value| value.as_str()) + .unwrap_or(""), + ) + }) + }); + let top_principals_truncated = top_principals.len() > AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT; + top_principals.truncate(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT); + + let active_objects = storage_ledger + .get("active_objects") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let quota_enforced = storage_ledger + .get("quota_enforced") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let pressure_level = if quota_exceeded_records > 0 { + "quota_exceeded" + } else if quota_enforced > 0 { + "quota_enforced" + } else if active_objects > 0 { + "accounting_observed" + } else { + "idle" + }; + + let mut by_task_status: BTreeMap = BTreeMap::new(); + let mut due = 0_u32; + let mut next_due_after: Option = None; + let mut total_attempts = 0_u64; + let mut tasks_with_failures = 0_u32; + for task in tasks { + *by_task_status.entry(task.status.clone()).or_insert(0) += 1; + total_attempts = total_attempts.saturating_add(task.attempts as u64); + if task.attempts > 0 && !matches!(task.status.as_str(), "healthy" | "local_only") { + tasks_with_failures = tasks_with_failures.saturating_add(1); + } + if task.is_repair_candidate(true) { + if task.is_due(now) { + due = due.saturating_add(1); + } else if task.next_check_after > 0 { + next_due_after = Some( + next_due_after + .map(|current| current.min(task.next_check_after)) + .unwrap_or(task.next_check_after), + ); + } + } + } + + let mut recent_tasks = tasks.to_vec(); + recent_tasks.sort_by(|a, b| { + b.checked_at + .cmp(&a.checked_at) + .then_with(|| a.cid.cmp(&b.cid)) + }); + let recent_fleet_history = recent_tasks + .iter() + .take(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT) + .map(|task| { + json!({ + "cid": task.cid, + "status": task.status, + "policy": task.policy, + "attempts": task.attempts, + "checked_at": task.checked_at, + "next_check_after": task.next_check_after, + "due": task.is_due(now), + "reason": task.reason.clone(), + }) + }) + .collect::>(); + + let live_multi_peer_proofs = receipts + .iter() + .filter(|receipt| { + receipt + .payload + .peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }) + .count(); + + json!({ + "schema": CONTENT_OPERATOR_DASHBOARD_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "authority": { + "runtime_invocation_required": true, + "app_visible": false, + "raw_backend_access": false, + }, + "storage_pressure": { + "status": pressure_level, + "tracked_objects": storage_ledger + .get("tracked_objects") + .cloned() + .unwrap_or(Value::from(storage_entries.len() as u64)), + "active_objects": active_objects, + "tracked_principals": storage_ledger + .get("tracked_principals") + .cloned() + .unwrap_or(Value::from(0)), + "active_principals": storage_ledger + .get("active_principals") + .cloned() + .unwrap_or(Value::from(0)), + "content_bytes": storage_ledger + .get("content_bytes") + .cloned() + .unwrap_or(Value::from(0)), + "replica_bytes_estimate": storage_ledger + .get("replica_bytes_estimate") + .cloned() + .unwrap_or(Value::from(0)), + "quota_enforced": quota_enforced, + "quota_exceeded_records": quota_exceeded_records, + "quota_ledger_policy": federated_quota_ledger_policy_status_json( + receipts, + storage_ledger, + integrations.federated_quota_ledger_exchange, + ), + "top_principal_limit": AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT, + "top_principals_truncated": top_principals_truncated, + "top_principals_by_content_bytes": top_principals, + "market_policy": storage_ledger + .get("market_policy") + .cloned() + .unwrap_or_else(default_content_storage_market_json), + "settlement_policy": storage_ledger + .get("market_policy") + .map(storage_settlement_policy_from_market_json) + .unwrap_or_else(default_storage_settlement_policy_json), + "market_admission_policy": storage_ledger + .get("market_policy") + .map(storage_market_admission_policy_from_market_json) + .unwrap_or_else(default_storage_market_admission_policy_json), + }, + "fleet_history": { + "tracked_tasks": tasks.len(), + "by_status": by_task_status, + "due": due, + "next_due_after": next_due_after, + "total_attempts": total_attempts, + "tasks_with_failures": tasks_with_failures, + "recent_limit": AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT, + "recent": recent_fleet_history, + "external_repair_fleet_policy": external_repair_fleet_policy_json( + tasks, + now, + integrations.external_repair_fleet, + ), + }, + "proof_summary": { + "signed_receipts": receipts.len(), + "live_multi_peer_proofs": live_multi_peer_proofs, + "peer_attestation_exchange_policy": peer_attestation_exchange_policy_status_json( + receipts, + ), + }, + "federated_operator_alerting_policy": federated_operator_alerting_policy_json( + receipts, + tasks, + storage_entries, + storage_ledger, + now, + integrations.operator_alert_sink, + integrations.federated_operator_alert_exchange, + ), + "production_federation": { + "configured": integrations.federated_operator_alert_exchange.is_some(), + "external_repair_fleets": false, + "federated_storage_pressure": false, + "operator_alerting": if integrations.federated_operator_alert_exchange.is_some() { + "configured_federated_alert_exchange" + } else if integrations.operator_alert_sink.is_some() { + "provider_local_webhook" + } else { + "provider_status_only" + }, + "reason": if integrations.federated_operator_alert_exchange.is_some() { + "this branch can exchange provider alert receipts with one configured operator-owned federated endpoint; production dashboards still need subscribed provider fleets, peer-health feeds, and operator UI" + } else if integrations.operator_alert_sink.is_some() { + "this branch can deliver provider-local alert receipts to one configured operator sink; production dashboards still need federated ledgers, repair fleets, and alert exchange across independent providers" + } else { + "this branch exposes provider-local storage pressure and fleet history; production dashboards need federated ledgers, repair fleets, and alerting across independent providers" + }, + }, + }) +} + +fn operator_alert_payload(dashboard: &Value, emitted_at: u64) -> Value { + let alerting_policy = dashboard + .get("federated_operator_alerting_policy") + .cloned() + .unwrap_or(Value::Null); + let local_signals = alerting_policy + .get("local_signals") + .cloned() + .unwrap_or(Value::Null); + let federation = alerting_policy + .get("federation") + .cloned() + .unwrap_or(Value::Null); + let operator_dashboard = dashboard.get("operator_dashboard").unwrap_or(&Value::Null); + json!({ + "schema": CONTENT_OPERATOR_ALERT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "emitted_at": emitted_at, + "dashboard_schema": dashboard.get("schema").cloned().unwrap_or(Value::Null), + "policy": alerting_policy + .get("policy") + .cloned() + .unwrap_or(Value::Null), + "status": alerting_policy + .get("status") + .cloned() + .unwrap_or(Value::Null), + "local_signals": local_signals, + "storage_pressure": operator_dashboard + .get("storage_pressure") + .cloned() + .unwrap_or(Value::Null), + "repair_pressure": operator_dashboard + .get("fleet_history") + .cloned() + .unwrap_or(Value::Null), + "authority": { + "runtime_invocation_required": true, + "provider_owned_sink": true, + "credential_exposed": false, + "raw_backend_access": false, + }, + "production_federation": { + "configured": federation + .get("configured") + .cloned() + .unwrap_or(Value::Bool(false)), + "cross_provider_dashboard": federation + .get("cross_provider_dashboard") + .cloned() + .unwrap_or(Value::Bool(false)), + "fleet_alert_exchange": federation + .get("fleet_alert_exchange") + .cloned() + .unwrap_or(Value::Bool(false)), + "federated_alert_receipts": federation + .get("federated_alert_receipts") + .cloned() + .unwrap_or(Value::Bool(false)), + "reason": federation + .get("reason") + .cloned() + .unwrap_or_else(|| Value::String("provider alert payload follows the current provider-owned alerting policy".to_string())), + }, + }) +} + +fn federated_operator_alerting_policy_json( + receipts: &[SignedAvailabilityReceipt], + tasks: &[ContentRepairTask], + storage_entries: &[ContentStorageAccountingEntry], + storage_ledger: &Value, + now: u64, + operator_alert_sink: Option<&ContentOperatorAlertSink>, + federated_operator_alert_exchange: Option<&ContentFederatedOperatorAlertExchangeClient>, +) -> Value { + let active_objects = storage_ledger + .get("active_objects") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let content_bytes = storage_ledger + .get("content_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let replica_bytes_estimate = storage_ledger + .get("replica_bytes_estimate") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let quota_enforced = storage_ledger + .get("quota_enforced") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let quota_exceeded_records = storage_entries + .iter() + .filter(|entry| { + entry + .storage_quota + .get("status") + .or_else(|| entry.quota.get("status")) + .and_then(|value| value.as_str()) + == Some("quota_exceeded") + }) + .count(); + let storage_pressure_status = if quota_exceeded_records > 0 { + "quota_exceeded" + } else if quota_enforced > 0 { + "quota_enforced" + } else if active_objects > 0 { + "accounting_observed" + } else { + "idle" + }; + + let queued_tasks = tasks.iter().filter(|task| task.status == "queued").count(); + let due_tasks = tasks + .iter() + .filter(|task| task.is_repair_candidate(true) && task.is_due(now)) + .count(); + let failed_tasks = tasks + .iter() + .filter(|task| { + task.attempts > 0 && !matches!(task.status.as_str(), "healthy" | "local_only") + }) + .count(); + let repair_pressure_status = if due_tasks > 0 { + "repair_due" + } else if queued_tasks > 0 { + "repair_queued" + } else if failed_tasks > 0 { + "repair_failures_observed" + } else { + "idle" + }; + + let live_multi_peer_proofs = receipts + .iter() + .filter(|receipt| { + receipt + .payload + .peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }) + .count(); + let mut remote_replicas = 0_u32; + let mut verified_remote_receipts = 0_u32; + for receipt in receipts { + for replica in receipt + .payload + .peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + if replica.get("role").and_then(|value| value.as_str()) == Some("remote") { + remote_replicas = remote_replicas.saturating_add(1); + } + if replica + .get("remote_receipt") + .and_then(|remote| remote.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_remote_receipts = verified_remote_receipts.saturating_add(1); + } + } + } + + json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA, + "policy": if federated_operator_alert_exchange.is_some() { + "provider_local_dashboard_with_federated_alert_exchange" + } else if operator_alert_sink.is_some() { + "provider_local_dashboard_with_operator_alert_sink" + } else { + "provider_local_dashboard_no_federated_alerting" + }, + "scope": "content-availability", + "status": if federated_operator_alert_exchange.is_some() { + "federated_alert_exchange_configured" + } else if operator_alert_sink.is_some() { + "provider_local_alert_sink_configured" + } else if receipts.is_empty() && tasks.is_empty() && active_objects == 0 { + "idle_no_federated_dashboard" + } else { + "provider_local_dashboard_only" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + "raw_backend_access": false, + }, + "local_dashboard": { + "available": true, + "schema": CONTENT_OPERATOR_DASHBOARD_SCHEMA, + "provider_wide_status": true, + "per_cid_status": true, + "repair_worker_runs": true, + "status_json_only": true, + "operator_ui": "not_configured", + }, + "operator_alert_sink": operator_alert_sink + .map(ContentOperatorAlertSink::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "federated_alert_exchange": federated_operator_alert_exchange + .map(ContentFederatedOperatorAlertExchangeClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "local_signals": { + "signed_receipts": receipts.len(), + "tracked_tasks": tasks.len(), + "queued_tasks": queued_tasks, + "due_tasks": due_tasks, + "failed_tasks": failed_tasks, + "storage_pressure_status": storage_pressure_status, + "active_objects": active_objects, + "content_bytes": content_bytes, + "replica_bytes_estimate": replica_bytes_estimate, + "quota_enforced": quota_enforced, + "quota_exceeded_records": quota_exceeded_records, + "live_multi_peer_proofs": live_multi_peer_proofs, + "remote_replicas": remote_replicas, + "verified_remote_receipts": verified_remote_receipts, + "repair_pressure_status": repair_pressure_status, + }, + "federation": { + "configured": federated_operator_alert_exchange.is_some(), + "cross_provider_dashboard": false, + "alert_delivery": operator_alert_sink.is_some() || federated_operator_alert_exchange.is_some(), + "fleet_alert_exchange": federated_operator_alert_exchange.is_some(), + "federated_alert_receipts": federated_operator_alert_exchange.is_some(), + "peer_health_subscription": false, + "storage_pressure_alerts": if federated_operator_alert_exchange.is_some() { + "federated_alert_exchange" + } else if operator_alert_sink.is_some() { + "provider_local_webhook" + } else { + "provider_status_only" + }, + "repair_pressure_alerts": if federated_operator_alert_exchange.is_some() { + "federated_alert_exchange" + } else if operator_alert_sink.is_some() { + "provider_local_webhook" + } else { + "provider_status_only" + }, + "reason": if federated_operator_alert_exchange.is_some() { + "provider alert receipts can be delivered to one configured operator-owned federated exchange; cross-provider dashboards, peer-health subscriptions, operator UI, and fleet-wide SLA policy remain production-network work" + } else if operator_alert_sink.is_some() { + "provider-local alert delivery can post signed local signals to one configured operator sink; configure a federated operator alert exchange for exchange receipts, while production dashboards, peer-health subscriptions, operator UI, and fleet-wide SLA policy remain production-network work" + } else { + "this branch exposes provider-local status JSON only; federated dashboards and alert delivery require independent provider networks, signed operator subscriptions, and cross-provider alert receipts" + }, + }, + }) +} + +fn repair_fleet_status_json(tasks: &[ContentRepairTask], now: u64) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut queued = 0_u32; + let mut due = 0_u32; + let mut healthy = 0_u32; + let mut eligible_now = 0_u32; + let mut next_due_after: Option = None; + + for task in tasks { + *by_status.entry(task.status.clone()).or_insert(0) += 1; + if task.status == "queued" { + queued = queued.saturating_add(1); + } + if task.status == "healthy" { + healthy = healthy.saturating_add(1); + } + if task.is_repair_candidate(false) && task.is_due(now) { + eligible_now = eligible_now.saturating_add(1); + } + if matches!(task.status.as_str(), "queued" | "healthy") { + if task.is_due(now) { + due = due.saturating_add(1); + } else if task.next_check_after > 0 { + next_due_after = Some( + next_due_after + .map(|current| current.min(task.next_check_after)) + .unwrap_or(task.next_check_after), + ); + } + } + } + + json!({ + "schema": REPAIR_FLEET_SCHEMA, + "policy": "single_runtime_provider_repair_fleet", + "scope": "content-availability", + "status": if due > 0 { + "work_due" + } else if queued > 0 { + "scheduled" + } else if healthy > 0 { + "healthy_monitoring" + } else { + "idle" + }, + "coordinator": { + "provider": "content-provider", + "authority": "provider-owned-repair-ledger", + "app_visible": false, + }, + "workers": [{ + "provider": "content-provider", + "role": "local_repair_worker", + "manual_trigger": "elastos content repair-worker", + "scheduler_env": "ELASTOS_CONTENT_REPAIR_SCHEDULER", + "enabled_by_default": false, + "runtime_invocation_required": true, + }], + "scheduling": { + "source": "content_repair_task_ledger", + "retry_delay_secs": REPAIR_RETRY_DELAY_SECS, + "healthy_check_delay_secs": REPAIR_HEALTH_CHECK_DELAY_SECS, + "default_limit": REPAIR_WORKER_DEFAULT_LIMIT, + "max_limit": REPAIR_WORKER_MAX_LIMIT, + "default_max_attempts": REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS, + "default_failure_budget": REPAIR_WORKER_DEFAULT_FAILURE_BUDGET, + "eligible_now": eligible_now, + "next_due_after": next_due_after, + }, + "task_pressure": { + "tracked": tasks.len(), + "queued": queued, + "due": due, + "healthy": healthy, + "by_status": by_status, + }, + "production_federation": { + "configured": false, + "external_workers": false, + "fleet_settlement": "not_configured", + "storage_market_admission": "not_configured", + "reason": "this branch exposes the provider-owned repair-fleet policy; external repair fleets and storage markets remain production-network work", + }, + }) +} + +fn external_repair_fleet_policy_json( + tasks: &[ContentRepairTask], + now: u64, + external_repair_fleet: Option<&ContentExternalRepairFleetClient>, +) -> Value { + let queued = tasks.iter().filter(|task| task.status == "queued").count(); + let due = tasks + .iter() + .filter(|task| task.is_repair_candidate(false) && task.is_due(now)) + .count(); + let healthy = tasks.iter().filter(|task| task.status == "healthy").count(); + json!({ + "schema": EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA, + "policy": if external_repair_fleet.is_some() { + "provider_owned_repair_with_external_dispatch" + } else { + "single_runtime_provider_owned_repair" + }, + "scope": "content-availability", + "status": if external_repair_fleet.is_some() { + "external_repair_fleet_dispatch_configured" + } else { + "external_repair_fleet_not_configured" + }, + "local_runtime": { + "coordinator": "content-provider", + "worker": "content-provider", + "task_ledger": REPAIR_TASK_SCHEMA, + "manual_trigger": "elastos content repair-worker", + "scheduler_env": "ELASTOS_CONTENT_REPAIR_SCHEDULER", + "tracked_tasks": tasks.len(), + "queued": queued, + "due": due, + "healthy": healthy, + }, + "external_fleet": { + "configured": external_repair_fleet.is_some(), + "coordinator": if external_repair_fleet.is_some() { + "operator_configured_dispatch_endpoint_quorum" + } else { + "not_configured" + }, + "dispatch": external_repair_fleet + .map(ContentExternalRepairFleetClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "workers": external_repair_fleet + .map(ContentExternalRepairFleetClient::endpoint_count) + .unwrap_or(0), + "volunteer_workers": false, + "supernode_workers": external_repair_fleet.is_some(), + "cross_provider_repair_queue": external_repair_fleet.is_some(), + "fleet_settlement": "not_configured", + "storage_market_admission": "not_configured", + }, + "federation": { + "configured": external_repair_fleet.is_some(), + "repair_task_exchange": external_repair_fleet.is_some(), + "worker_attestation_receipts": false, + "cross_provider_repair_sla": false, + "reason": if external_repair_fleet.is_some() { + "content repair-worker can dispatch due tasks to a configured external repair-fleet endpoint quorum; worker attestations, settlement, and cross-provider repair SLAs remain production work" + } else { + "this branch exposes a provider-owned local repair worker and scheduler posture only; production external repair fleets require cross-provider task exchange, worker attestations, and SLA policy" + }, + }, + }) +} + +struct ExternalRepairFleetRunSummary { + checked: u32, + repaired: u32, + failed: u32, + skipped: u32, + exhausted_attempts_skipped: u32, + throttled: bool, + external_dispatches: u32, + external_dispatch_accepted: u32, + external_dispatch_failed: u32, +} + +fn external_repair_fleet_run_policy_json( + run: ExternalRepairFleetRunSummary, + external_repair_fleet: Option<&ContentExternalRepairFleetClient>, +) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA, + "policy": if external_repair_fleet.is_some() { + "provider_owned_repair_with_external_dispatch" + } else { + "single_runtime_provider_owned_repair" + }, + "scope": "content-availability", + "status": if external_repair_fleet.is_some() { + "external_repair_fleet_dispatch_configured" + } else { + "external_repair_fleet_not_configured" + }, + "run": { + "worker": "content-provider", + "checked": run.checked, + "repaired": run.repaired, + "failed": run.failed, + "skipped": run.skipped, + "exhausted_attempts_skipped": run.exhausted_attempts_skipped, + "throttled": run.throttled, + "external_dispatches": run.external_dispatches, + "external_dispatch_accepted": run.external_dispatch_accepted, + "external_dispatch_failed": run.external_dispatch_failed, + }, + "external_fleet": { + "configured": external_repair_fleet.is_some(), + "coordinator": if external_repair_fleet.is_some() { + "operator_configured_dispatch_endpoint_quorum" + } else { + "not_configured" + }, + "dispatch": external_repair_fleet + .map(ContentExternalRepairFleetClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "workers": external_repair_fleet + .map(ContentExternalRepairFleetClient::endpoint_count) + .unwrap_or(0), + "cross_provider_repair_queue": external_repair_fleet.is_some(), + "fleet_settlement": "not_configured", + }, + }) +} + +fn repair_fleet_run_json( + checked: u32, + repaired: u32, + failed: u32, + skipped: u32, + exhausted_attempts_skipped: u32, + throttled: bool, +) -> Value { + json!({ + "schema": REPAIR_FLEET_SCHEMA, + "policy": "single_runtime_provider_repair_fleet", + "scope": "content-availability", + "run_mode": "provider_owned_worker", + "coordinator": "content-provider", + "worker": "content-provider", + "checked": checked, + "repaired": repaired, + "failed": failed, + "skipped": skipped, + "exhausted_attempts_skipped": exhausted_attempts_skipped, + "throttled": throttled, + "production_federation": { + "configured": false, + "external_workers": false, + "fleet_settlement": "not_configured", + }, + }) +} + +fn local_peer_selection_json() -> Value { + json!({ + "mode": "single_local", + "live_multi_peer_proof": false, + }) +} + +fn federated_quota_ledger_policy_json( + mode: &str, + quota_status: &str, + local_principal_ledger: bool, + remote_admission_preflight: bool, + enforced: bool, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "local_principal_ledger_plus_remote_admission_preflight", + "scope": "content-availability", + "status": "federated_quota_ledger_not_configured", + "quota": { + "mode": mode, + "status": quota_status, + "enforced": enforced, + }, + "local": { + "principal_storage_ledger": local_principal_ledger, + "ledger_schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "storage_quota_schema": CONTENT_STORAGE_QUOTA_SCHEMA, + }, + "remote": { + "admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "admission_schema": CONTENT_ADMISSION_SCHEMA, + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": false, + "cross_provider_quota_ledger": false, + "storage_admission_network": false, + "signed_admission_receipt_exchange": remote_admission_preflight, + "quota_receipt_exchange": false, + "production_quota_receipt_exchange": false, + "reason": if remote_admission_preflight { + "signed remote content/admission receipt exchange exists for the proof path; federated quota ledgers and production storage-admission networks remain unconfigured" + } else { + "local per-principal quota exists, but remote signed admission and federated quota ledgers are not configured for this path" + }, + }, + }) +} + +fn default_federated_quota_ledger_policy_json() -> Value { + federated_quota_ledger_policy_json("not_reported", "not_reported", false, false, false) +} + +fn federated_quota_ledger_policy_from_quota_json( + quota: &Value, + remote_admission_preflight: bool, +) -> Value { + if let Some(policy) = quota.get("federated_quota_ledger_policy") { + return policy.clone(); + } + let quota_policy = quota + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + let local_principal_ledger = matches!( + quota_policy, + "principal_storage_quota" | "carrier_provider_quota" + ); + federated_quota_ledger_policy_json( + quota_policy, + quota + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + local_principal_ledger, + remote_admission_preflight, + quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + ) +} + +fn federated_quota_ledger_policy_from_exchange(quota: &Value, exchange: &Value) -> Value { + let quota_policy = quota + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + let quota_status = quota + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + let local_principal_ledger = matches!( + quota_policy, + "principal_storage_quota" | "carrier_provider_quota" + ); + let enforced = quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let exchange_accepted = exchange + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let exchange_status = exchange + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "scope": "content-availability", + "status": if exchange_accepted { + "federated_quota_ledger_accepted" + } else { + "federated_quota_ledger_rejected" + }, + "quota": { + "mode": quota_policy, + "status": quota_status, + "enforced": enforced, + }, + "local": { + "principal_storage_ledger": local_principal_ledger, + "ledger_schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "storage_quota_schema": CONTENT_STORAGE_QUOTA_SCHEMA, + }, + "remote": { + "admission_preflight": true, + "signed_admission_receipts": true, + "admission_schema": CONTENT_ADMISSION_SCHEMA, + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": true, + "cross_provider_quota_ledger": true, + "storage_admission_network": false, + "signed_admission_receipt_exchange": true, + "quota_receipt_exchange": true, + "production_quota_receipt_exchange": false, + "exchange_schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA, + "exchange_receipt_schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "exchange_status": exchange_status, + "signed_exchange_receipt_verified": exchange + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false), + "reason": if exchange_accepted { + "configured federated quota-ledger exchange accepted this admission preflight" + } else { + "configured federated quota-ledger exchange rejected or could not verify this admission preflight" + }, + }, + }) +} + +fn federated_quota_ledger_policy_status_json( + receipts: &[SignedAvailabilityReceipt], + storage_ledger: &Value, + federated_quota_ledger_exchange: Option<&ContentFederatedQuotaLedgerExchangeClient>, +) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut local_quota_receipts = 0_u32; + let mut remote_admission_receipts = 0_u32; + let mut federated_policy_receipts = 0_u32; + + for receipt in receipts { + let mut receipt_remote_admission = false; + for replica in receipt + .payload + .peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + if replica.get("admission").is_some() { + receipt_remote_admission = true; + remote_admission_receipts = remote_admission_receipts.saturating_add(1); + } + } + let policy = federated_quota_ledger_policy_from_quota_json( + &receipt.payload.quota, + receipt_remote_admission, + ); + let status = policy + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .get("local") + .and_then(|value| value.get("principal_storage_ledger")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + local_quota_receipts = local_quota_receipts.saturating_add(1); + } + if policy + .get("federation") + .and_then(|value| value.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + federated_policy_receipts = federated_policy_receipts.saturating_add(1); + } + } + + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "provider_receipt_quota_ledger_status", + "scope": "content-availability", + "status": if federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some() { + "federated_quota_ledger_observed" + } else { + "federated_quota_ledger_not_configured" + }, + "receipt_count": receipts.len(), + "by_status": by_status, + "local": { + "principal_storage_ledger": true, + "ledger_schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "tracked_objects": storage_ledger + .get("tracked_objects") + .cloned() + .unwrap_or(Value::from(0)), + "quota_enforced": storage_ledger + .get("quota_enforced") + .cloned() + .unwrap_or(Value::from(0)), + "quota_receipts": local_quota_receipts, + }, + "remote": { + "admission_preflight": remote_admission_receipts > 0, + "admission_receipts": remote_admission_receipts, + "signed_admission_receipts": remote_admission_receipts, + "admission_schema": CONTENT_ADMISSION_SCHEMA, + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some(), + "cross_provider_quota_ledger": federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some(), + "storage_admission_network": false, + "signed_admission_receipt_exchange": remote_admission_receipts > 0, + "quota_receipt_exchange": federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some(), + "production_quota_receipt_exchange": false, + "exchange_client": federated_quota_ledger_exchange + .map(ContentFederatedQuotaLedgerExchangeClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "reason": if federated_policy_receipts > 0 { + "one or more receipts reported federated quota-ledger policy" + } else if federated_quota_ledger_exchange.is_some() { + "configured federated quota-ledger exchange is enforced by content/admission before remote bytes or DAG repair data move" + } else if remote_admission_receipts > 0 { + "signed remote content/admission receipts were observed, but no federated quota ledger or production storage-admission network is configured" + } else { + "no federated quota ledger or cross-provider quota-receipt exchange is configured" + }, + }, + }) +} + +fn local_quota_json() -> Value { + json!({ + "policy": "not_enforced", + "scope": "local_content_backend", + "federated_quota_ledger_policy": default_federated_quota_ledger_policy_json(), + }) +} + +fn storage_market_admission_policy_json( + mode: &str, + market_status: &str, + quota_enforced: bool, + live_multi_peer_proof: bool, + remote_admission_preflight: bool, +) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA, + "policy": "proof_path_admission_no_production_market", + "scope": "content-availability", + "status": if remote_admission_preflight { + "remote_admission_preflight_no_market_admission" + } else if quota_enforced { + "local_quota_admission_no_market_admission" + } else { + "production_storage_market_admission_not_configured" + }, + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + "live_multi_peer_proof": live_multi_peer_proof, + }, + "current_admission": { + "local_principal_quota_ledger": quota_enforced, + "remote_content_admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "content_admission_schema": CONTENT_ADMISSION_SCHEMA, + "content_admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + "provider_invocation_required": true, + "signed_availability_receipts": true, + }, + "production_market": { + "configured": false, + "provider_admission_network": false, + "provider_offer_receipts": false, + "price_discovery": false, + "sla_admission": false, + "abuse_economic_controls": false, + "reason": "this branch admits storage through local quota and signed bounded remote content/admission receipts; production storage-market admission needs provider offers, pricing, SLA, and trust policy receipts", + }, + }) +} + +fn default_storage_market_admission_policy_json() -> Value { + storage_market_admission_policy_json("not_reported", "not_reported", false, false, false) +} + +fn storage_market_admission_policy_from_market_json(storage_market: &Value) -> Value { + if let Some(policy) = storage_market.get("admission_policy") { + return policy.clone(); + } + storage_market_admission_policy_json( + storage_market + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("quota_enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + storage_market + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + storage_market + .get("remote_admission_preflight") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + ) +} + +fn storage_market_admission_policy_status_json( + receipts: &[SignedAvailabilityReceipt], + storage_ledger: &Value, + storage_market_admission: Option<&ContentStorageMarketAdmissionClient>, +) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut policy_receipts = 0_u32; + let mut production_configured = 0_u32; + let mut local_quota_admission = 0_u32; + let mut remote_admission_preflight = 0_u32; + + for receipt in receipts { + let policy = + storage_market_admission_policy_from_market_json(&receipt.payload.storage_market); + policy_receipts = policy_receipts.saturating_add(1); + let status = policy + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .get("production_market") + .and_then(|value| value.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + production_configured = production_configured.saturating_add(1); + } + if policy + .get("current_admission") + .and_then(|value| value.get("local_principal_quota_ledger")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + local_quota_admission = local_quota_admission.saturating_add(1); + } + if policy + .get("current_admission") + .and_then(|value| value.get("remote_content_admission_preflight")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + remote_admission_preflight = remote_admission_preflight.saturating_add(1); + } + } + + let ledger_policy = storage_ledger + .get("market_policy") + .map(storage_market_admission_policy_from_market_json) + .unwrap_or_else(default_storage_market_admission_policy_json); + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA, + "policy": "provider_receipt_storage_market_admission_status", + "scope": "content-availability", + "status": if production_configured > 0 || storage_market_admission.is_some() { + "production_storage_market_admission_observed" + } else if remote_admission_preflight > 0 { + "remote_admission_preflight_no_market_admission" + } else if local_quota_admission > 0 { + "local_quota_admission_no_market_admission" + } else { + "production_storage_market_admission_not_configured" + }, + "receipt_count": receipts.len(), + "policy_receipts": policy_receipts, + "by_status": by_status, + "ledger_policy": ledger_policy, + "current_admission": { + "local_quota_receipts": local_quota_admission, + "remote_admission_preflight_receipts": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "content_admission_schema": CONTENT_ADMISSION_SCHEMA, + "content_admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + "provider_invocation_required": true, + }, + "external_admission_client": storage_market_admission + .map(ContentStorageMarketAdmissionClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "production_market": { + "configured": production_configured > 0 || storage_market_admission.is_some(), + "admission_policy_receipts": production_configured, + "provider_admission_network": production_configured > 0 || storage_market_admission.is_some(), + "provider_offer_receipts": storage_market_admission.is_some(), + "price_discovery": false, + "sla_admission": false, + "reason": if production_configured > 0 { + "one or more receipts reported production storage-market admission" + } else if storage_market_admission.is_some() { + "external storage-market admission is configured and enforced by content/admission before remote bytes or DAG repair data move" + } else { + "no production storage-market admission, offer, pricing, or SLA policy is configured" + }, + }, + }) +} + +fn storage_settlement_policy_json(mode: &str, market_status: &str, quota_enforced: bool) -> Value { + json!({ + "schema": CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA, + "policy": "no_settlement_receipt_policy", + "scope": "content-availability", + "status": "settlement_not_configured", + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "settlement": { + "pricing": "not_configured", + "escrow": "not_configured", + "payment_settlement": "not_configured", + "sla_enforcement": "not_configured", + }, + "production_federation": { + "configured": false, + "storage_market_admission": false, + "cross_provider_escrow": false, + "settlement_receipts": false, + "reason": "this branch records availability/accounting/quota posture only; pricing, escrow, settlement, and SLA policy require production storage-market providers", + }, + }) +} + +fn default_storage_settlement_policy_json() -> Value { + storage_settlement_policy_json("not_reported", "not_reported", false) +} + +fn storage_settlement_policy_from_market_json(storage_market: &Value) -> Value { + if let Some(policy) = storage_market.get("settlement_policy") { + return policy.clone(); + } + storage_settlement_policy_json( + storage_market + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("quota_enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + ) +} + +fn storage_settlement_policy_status_json( + receipts: &[SignedAvailabilityReceipt], + storage_ledger: &Value, +) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut settlement_configured = 0_u32; + for receipt in receipts { + let policy = storage_settlement_policy_from_market_json(&receipt.payload.storage_market); + let status = policy + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .get("production_federation") + .and_then(|value| value.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + settlement_configured = settlement_configured.saturating_add(1); + } + } + let ledger_policy = storage_ledger + .get("market_policy") + .map(storage_settlement_policy_from_market_json) + .unwrap_or_else(default_storage_settlement_policy_json); + json!({ + "schema": CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA, + "policy": "provider_receipt_settlement_status", + "scope": "content-availability", + "status": if settlement_configured > 0 { + "settlement_policy_observed" + } else { + "settlement_not_configured" + }, + "receipt_count": receipts.len(), + "by_status": by_status, + "ledger_policy": ledger_policy, + "production_federation": { + "configured": settlement_configured > 0, + "settlement_policy_receipts": settlement_configured, + "storage_market_admission": false, + "cross_provider_escrow": false, + "reason": if settlement_configured > 0 { + "one or more receipts reported settlement policy" + } else { + "no production storage-market settlement, escrow, or pricing policy is configured" + }, + }, + }) +} + +fn local_storage_market_json() -> Value { + json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "local_content_backend", + "status": "local_no_market_settlement", + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + "admission_policy": storage_market_admission_policy_json( + "local_content_backend", + "local_no_market_settlement", + false, + false, + false, + ), + "settlement_policy": storage_settlement_policy_json( + "local_content_backend", + "local_no_market_settlement", + false, + ), + }) +} + +fn default_content_storage_market_json() -> Value { + json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "not_reported", + "status": "not_reported", + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + "admission_policy": default_storage_market_admission_policy_json(), + "settlement_policy": default_storage_settlement_policy_json(), + }) +} + +fn is_default_content_storage_market_json(value: &Value) -> bool { + value == &default_content_storage_market_json() +} + +fn local_repair_graph_json() -> Value { + json!({ + "schema": "elastos.content.repair-graph/v1", + "policy": "content_provider_local_backend", + "requested_kind": "auto", + "status": "local_backend_only", + "supported_import_fallbacks": ["object_manifest", "exact_bytes"], + "refuses_exact_fallback_for_arbitrary_dag": true, + }) +} + +fn default_content_repair_graph_json() -> Value { + json!({ + "schema": "elastos.content.repair-graph/v1", + "policy": "not_reported", + "requested_kind": "auto", + "status": "not_reported", + "supported_import_fallbacks": ["object_manifest", "exact_bytes"], + "refuses_exact_fallback_for_arbitrary_dag": true, + }) +} + +fn is_default_content_repair_graph_json(value: &Value) -> bool { + value == &default_content_repair_graph_json() +} + +fn local_abuse_controls_json() -> Value { + json!({ + "schema": CONTENT_ABUSE_CONTROLS_SCHEMA, + "policy": "local_content_backend", + "scope": "content-availability", + "enforced": false, + "throttled": false, + "attempted_operations": 0, + "failed_operations": 0, + }) +} + +fn provider_abuse_controls_json() -> Value { + json!({ + "schema": CONTENT_ABUSE_CONTROLS_SCHEMA, + "policy": "availability_provider_not_reported", + "scope": "content-availability", + "enforced": false, + "throttled": false, + "attempted_operations": 0, + "failed_operations": 0, + }) +} + +fn default_content_abuse_controls_json() -> Value { + provider_abuse_controls_json() +} + +#[derive(Debug, Clone, Copy, Default)] +struct ContentAccountingObservation { + files: Option, + bytes: Option, +} + +fn content_accounting_observation_from_publish_request( + request: &Value, +) -> ContentAccountingObservation { + match request.get("kind").and_then(|kind| kind.as_str()) { + Some("file") => ContentAccountingObservation { + files: Some(1), + bytes: request.get("data").and_then(decoded_base64_len), + }, + Some("directory") => content_accounting_observation_from_files( + request.get("files").unwrap_or(&Value::Array(Vec::new())), + ), + _ => ContentAccountingObservation::default(), + } +} + +fn content_accounting_observation_from_files(files: &Value) -> ContentAccountingObservation { + let Some(files) = files.as_array() else { + return ContentAccountingObservation::default(); + }; + let mut bytes = 0_u64; + for file in files { + let Some(file_bytes) = file.get("data").and_then(decoded_base64_len) else { + return ContentAccountingObservation { + files: Some(files.len() as u64), + bytes: None, + }; + }; + bytes = bytes.saturating_add(file_bytes); + } + ContentAccountingObservation { + files: Some(files.len() as u64), + bytes: Some(bytes), + } +} + +fn content_accounting_observation_from_value(accounting: &Value) -> ContentAccountingObservation { + ContentAccountingObservation { + files: accounting.get("files").and_then(|value| value.as_u64()), + bytes: accounting + .get("content_bytes") + .and_then(|value| value.as_u64()), + } +} + +fn admission_content_bytes_from_request(request: &Value) -> Option { + ["estimated_content_bytes", "incoming_content_bytes"] + .into_iter() + .find_map(|field| request.get(field).and_then(|value| value.as_u64())) + .or_else(|| { + request + .get("accounting") + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) + .or_else(|| { + request + .get("local") + .and_then(|local| local.get("accounting")) + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) +} + +fn decoded_base64_len(value: &Value) -> Option { + base64::engine::general_purpose::STANDARD + .decode(value.as_str()?) + .ok() + .map(|bytes| bytes.len() as u64) +} + +fn content_accounting_json( + source: &str, + observation: ContentAccountingObservation, + replicas: u32, +) -> Value { + content_accounting_json_with_storage_quota( + source, + observation, + replicas, + default_storage_quota_json(), + ) +} + +fn content_accounting_json_with_storage_quota( + source: &str, + observation: ContentAccountingObservation, + replicas: u32, + storage_quota: Value, +) -> Value { + let replica_bytes = observation + .bytes + .map(|bytes| bytes.saturating_mul(replicas as u64)); + json!({ + "schema": CONTENT_ACCOUNTING_SCHEMA, + "policy": "content_provider_local_accounting", + "scope": "content-availability", + "source": source, + "observed": observation.files.is_some() || observation.bytes.is_some(), + "files": observation.files, + "content_bytes": observation.bytes, + "replicas": replicas, + "replica_bytes_estimate": replica_bytes, + "storage_quota": storage_quota, + }) +} + +fn default_storage_quota_json() -> Value { + json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "observed_not_enforced", + "scope": "content-availability", + "enforced": false, + "status": "observed_not_enforced", + "reason": "content-provider records local storage accounting; no principal storage quota was requested", + "federated_quota_ledger_policy": federated_quota_ledger_policy_json( + "observed_not_enforced", + "observed_not_enforced", + true, + false, + false, + ), + }) +} + +fn content_storage_accounting_entry_from_receipt( + receipt: &AvailabilityReceipt, +) -> ContentStorageAccountingEntry { + let observation = content_accounting_observation_from_value(&receipt.accounting); + let replica_bytes_estimate = receipt + .accounting + .get("replica_bytes_estimate") + .and_then(|value| value.as_u64()) + .or_else(|| { + observation + .bytes + .map(|bytes| bytes.saturating_mul(receipt.replicas as u64)) + }); + let storage_quota = receipt + .accounting + .get("storage_quota") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "not_reported", + "scope": "content-availability", + "enforced": false, + "status": "not_reported", + }) + }); + + ContentStorageAccountingEntry { + schema: CONTENT_STORAGE_ACCOUNTING_ENTRY_SCHEMA.to_string(), + cid: receipt.cid.clone(), + uri: receipt.uri.clone(), + object_did: receipt.object_did.clone(), + principal_did: receipt.publisher_did.clone(), + provider: receipt.provider.clone(), + policy: receipt.policy.clone(), + status: receipt.status.clone(), + source: receipt + .accounting + .get("source") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(), + files: observation.files, + content_bytes: observation.bytes, + replicas: receipt.replicas, + replica_bytes_estimate, + quota: receipt.quota.clone(), + storage_quota, + recorded_at: receipt.checked_at, + } +} + +fn default_content_accounting_json() -> Value { + content_accounting_json("legacy_receipt", ContentAccountingObservation::default(), 0) +} + +fn availability_quota_status(quota: &Value) -> String { + quota + .get("status") + .and_then(|value| value.as_str()) + .or_else(|| { + quota + .get("policy") + .and_then(|value| value.as_str()) + .filter(|policy| *policy == "not_enforced") + }) + .unwrap_or("unknown") + .to_string() +} + +fn repair_worker_json(scheduled: bool) -> Value { + json!({ + "scheduled": scheduled, + "status": if scheduled { "needed" } else { "not_scheduled" }, + }) +} + +fn provider_ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn provider_error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +fn validate_import_exact_invocation(request: &Value) -> Result<(), ProviderError> { + let runtime = runtime_invocation_object( + request, + "content import_exact requires Runtime provider invocation metadata", + )?; + validate_runtime_invocation_fields( + runtime, + "content import_exact", + &[ + ("schema", "elastos.provider.invocation/v1"), + ("source", "carrier-availability"), + ("target", "content"), + ("op", "import_exact"), + ("transport", "carrier-provider-plane"), + ], + )?; + if !matches!( + runtime.get("transfer").and_then(|value| value.as_str()), + Some("json" | "bytes" | "stream") + ) { + return Err(ProviderError::Provider( + "content import_exact transfer must be json, bytes, or stream".into(), + )); + } + Ok(()) +} + +fn validate_import_object_invocation(request: &Value) -> Result<(), ProviderError> { + let runtime = runtime_invocation_object( + request, + "content import_object requires Runtime provider invocation metadata", + )?; + validate_runtime_invocation_fields( + runtime, + "content import_object", + &[ + ("schema", "elastos.provider.invocation/v1"), + ("source", "carrier-availability"), + ("target", "content"), + ("op", "import_object"), + ("transport", "carrier-provider-plane"), + ("transfer", "json"), + ], + ) +} + +fn validate_repair_worker_invocation(request: &Value) -> Result<(), ProviderError> { + let runtime = runtime_invocation_object( + request, + "content repair_worker requires Runtime provider invocation metadata", + )?; + validate_runtime_invocation_fields( + runtime, + "content repair_worker", + &[ + ("schema", "elastos.provider.invocation/v1"), + ("source", "content-provider"), + ("target", "content"), + ("op", "repair_worker"), + ("transport", "runtime-local-provider-plane"), + ("transfer", "json"), + ], + ) +} + +fn runtime_invocation_object<'a>( + request: &'a Value, + missing_message: &str, +) -> Result<&'a serde_json::Map, ProviderError> { + request + .get("_runtime_invocation") + .and_then(|value| value.as_object()) + .ok_or_else(|| ProviderError::Provider(missing_message.to_string())) +} + +fn validate_runtime_invocation_fields( + runtime: &serde_json::Map, + label: &str, + fields: &[(&str, &str)], +) -> Result<(), ProviderError> { + for (field, expected) in fields { + let actual = runtime + .get(*field) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if actual != *expected { + return Err(ProviderError::Provider(format!( + "{label} runtime field {field} mismatch: expected {expected}, got {actual}" + ))); + } + } + Ok(()) +} + +fn import_exact_payload_bytes(request: &Value) -> Result, ProviderError> { + let bytes = if let Some(data) = request.get("data").and_then(|value| value.as_str()) { + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| { + ProviderError::Provider(format!("content import_exact data must be base64: {err}")) + })? + } else if let Some(stream) = request.get("stream") { + provider_stream_payload_bytes(stream)? + } else { + return Err(ProviderError::Provider( + "content import_exact requires data or stream".into(), + )); + }; + if bytes.len() > IMPORT_EXACT_MAX_BYTES { + return Err(ProviderError::Provider(format!( + "content import_exact payload exceeds {} bytes", + IMPORT_EXACT_MAX_BYTES + ))); + } + Ok(bytes) +} + +fn validate_import_object_payload_bounds(files: &Value) -> Result<(usize, usize), ProviderError> { + let files = files.as_array().ok_or_else(|| { + ProviderError::Provider("content import_object files must be an array".into()) + })?; + if files.is_empty() { + return Err(ProviderError::Provider( + "content import_object requires at least one file".into(), + )); + } + if files.len() > IMPORT_OBJECT_MAX_FILES { + return Err(ProviderError::Provider(format!( + "content import_object file count exceeds {IMPORT_OBJECT_MAX_FILES}" + ))); + } + let mut total_bytes = 0_usize; + for file in files { + let path = file + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let data = file + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "content import_object file {path} is missing base64 data" + )) + })?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| { + ProviderError::Provider(format!( + "content import_object file {path} has invalid base64 data: {err}" + )) + })?; + total_bytes = total_bytes.saturating_add(decoded.len()); + if total_bytes > IMPORT_EXACT_MAX_BYTES { + return Err(ProviderError::Provider(format!( + "content import_object payload exceeds {} bytes", + IMPORT_EXACT_MAX_BYTES + ))); + } + } + Ok((files.len(), total_bytes)) +} + +fn provider_stream_payload_bytes(stream: &Value) -> Result, ProviderError> { + let object = stream.as_object().ok_or_else(|| { + ProviderError::Provider("content import_exact stream must be an object".into()) + })?; + let schema = object + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != "elastos.provider.stream/v1" { + return Err(ProviderError::Provider(format!( + "content import_exact stream schema mismatch: expected elastos.provider.stream/v1, got {schema}" + ))); + } + let encoding = object + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != "base64-chunks" { + return Err(ProviderError::Provider(format!( + "content import_exact stream encoding mismatch: expected base64-chunks, got {encoding}" + ))); + } + let chunks = object + .get("chunks") + .and_then(|value| value.as_array()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream missing chunks".into()) + })?; + let mut bytes = Vec::new(); + for (expected_index, chunk) in chunks.iter().enumerate() { + let chunk = chunk.as_object().ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk must be an object".into()) + })?; + let index = chunk + .get("index") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk missing index".into()) + })?; + if index != expected_index as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream chunk index mismatch: expected {expected_index}, got {index}" + ))); + } + let offset = chunk + .get("offset") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk missing offset".into()) + })?; + if offset != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream chunk {index} offset mismatch: expected {}, got {offset}", + bytes.len() + ))); + } + let encoded = chunk + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk missing data".into()) + })?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "content import_exact stream chunk has invalid base64: {err}" + )) + })?; + if bytes.len().saturating_add(decoded.len()) > IMPORT_EXACT_MAX_BYTES { + return Err(ProviderError::Provider(format!( + "content import_exact payload exceeds {} bytes", + IMPORT_EXACT_MAX_BYTES + ))); + } + if let Some(length) = chunk.get("length").and_then(|value| value.as_u64()) { + if length != decoded.len() as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream chunk {index} length {length} does not match decoded length {}", + decoded.len() + ))); + } + } + bytes.extend_from_slice(&decoded); + } + if let Some(total_bytes) = object.get("total_bytes").and_then(|value| value.as_u64()) { + if total_bytes != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream total_bytes {total_bytes} does not match decoded length {}", + bytes.len() + ))); + } + } + Ok(bytes) +} + +fn parse_availability_provider_response( + response: &Value, + requested_policy: &str, + local: &AvailabilityOutcome, + requirements: &AvailabilityRequirements, +) -> AvailabilityOutcome { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("availability provider returned an error") + .to_string(); + return AvailabilityOutcome::repair_needed( + "availability-provider", + requested_policy, + local.replicas, + message, + ); + } + + let data = response.get("data").unwrap_or(response); + let availability = data.get("availability").unwrap_or(data); + let provider = availability + .get("provider") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("availability-provider"); + let policy = availability + .get("policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(requested_policy); + let replicas = availability + .get("replicas") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(local.replicas); + let status = availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + match status { + "network_available" if replicas > 0 => { + match validate_network_availability_claim(status, replicas, availability, requirements) + { + Ok(policy_metadata) => AvailabilityOutcome { + provider: provider.to_string(), + policy: policy.to_string(), + status: status.to_string(), + replicas, + reason: None, + peer_selection: policy_metadata.0, + quota: policy_metadata.1, + repair_worker: policy_metadata.2, + storage_market: policy_metadata.3, + repair_graph: policy_metadata.4, + abuse_controls: policy_metadata.5, + }, + Err(reason) => { + AvailabilityOutcome::repair_needed(provider, policy, local.replicas, reason) + } + } + } + "carrier_announced" if replicas > 0 => { + match validate_network_availability_claim(status, replicas, availability, requirements) + { + Ok(policy_metadata) => AvailabilityOutcome { + provider: provider.to_string(), + policy: policy.to_string(), + status: status.to_string(), + replicas, + reason: availability + .get("reason") + .and_then(|value| value.as_str()) + .map(str::to_string), + peer_selection: policy_metadata.0, + quota: policy_metadata.1, + repair_worker: policy_metadata.2, + storage_market: policy_metadata.3, + repair_graph: policy_metadata.4, + abuse_controls: policy_metadata.5, + }, + Err(reason) => { + AvailabilityOutcome::repair_needed(provider, policy, local.replicas, reason) + } + } + } + "repair_needed" => AvailabilityOutcome::repair_needed( + provider, + policy, + replicas, + availability + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("availability provider reported repair_needed") + .to_string(), + ), + "network_available" => AvailabilityOutcome::repair_needed( + provider, + policy, + local.replicas, + "availability provider reported network_available without replicas".to_string(), + ), + "carrier_announced" => AvailabilityOutcome::repair_needed( + provider, + policy, + local.replicas, + "Carrier availability announcement did not include a local replica".to_string(), + ), + _ => AvailabilityOutcome::repair_needed( + provider, + policy, + local.replicas, + "availability provider returned an unsupported status".to_string(), + ), + } +} + +fn validate_network_availability_claim( + status: &str, + replicas: u32, + availability: &Value, + requirements: &AvailabilityRequirements, +) -> Result<(Value, Value, Value, Value, Value, Value), String> { + if replicas < requirements.min_replicas { + return Err(format!( + "availability provider reported {replicas} replicas below required {}", + requirements.min_replicas + )); + } + if let Some(max_replicas) = requirements.max_replicas { + if replicas > max_replicas { + return Err(format!( + "availability provider reported {replicas} replicas over quota {max_replicas}" + )); + } + } + + let peer_selection = availability + .get("peer_selection") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| "network availability requires peer_selection metadata".to_string())?; + let quota = availability + .get("quota") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| "network availability requires quota metadata".to_string())?; + let repair_worker = availability + .get("repair_worker") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| "network availability requires repair_worker metadata".to_string())?; + let abuse_controls = availability + .get("abuse_controls") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(provider_abuse_controls_json); + let storage_market = availability + .get("storage_market") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(default_content_storage_market_json); + let repair_graph = availability + .get("repair_graph") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(default_content_repair_graph_json); + + let peer_selection_mode = peer_selection + .get("mode") + .or_else(|| peer_selection.get("strategy")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if peer_selection_mode.is_none() { + return Err("network availability peer_selection requires mode or strategy".to_string()); + } + + let quota_policy = quota + .get("policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if quota_policy.is_none() { + return Err("network availability quota requires policy".to_string()); + } + if let Some(max_replicas) = quota + .get("max_replicas") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + { + if replicas > max_replicas { + return Err(format!( + "availability provider reported {replicas} replicas above quota max_replicas {max_replicas}" + )); + } + } + + let repair_status = repair_worker + .get("status") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if repair_status.is_none() { + return Err("network availability repair_worker requires status".to_string()); + } + + let live_proof = peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + if replicas > 1 && !live_proof { + return Err("multi-peer availability requires live_multi_peer_proof=true".to_string()); + } + if requirements.require_live_multi_peer_proof && !live_proof { + return Err("availability requirements demand live_multi_peer_proof=true".to_string()); + } + if status == "carrier_announced" { + let topic = peer_selection + .get("topic") + .or_else(|| availability.get("topic")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if topic.is_none() { + return Err("Carrier availability announcement requires a topic".to_string()); + } + } + + Ok(( + peer_selection, + quota, + repair_worker, + storage_market, + repair_graph, + abuse_controls, + )) +} + +fn provider_response_cid(response: &Value) -> Result { + provider_response_ok(response, "content publish")?; + response + .get("data") + .and_then(|data| data.get("cid")) + .and_then(|cid| cid.as_str()) + .map(str::to_string) + .ok_or_else(|| ProviderError::Provider("content backend response missing cid".into())) +} + +fn content_response_cid(response: &Value) -> anyhow::Result { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("content publish failed: {message}"); + } + response + .get("data") + .and_then(|data| data.get("cid")) + .and_then(|cid| cid.as_str()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("No CID in content provider response")) +} + +fn provider_response_ok(response: &Value, operation: &str) -> Result<(), ProviderError> { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown error"); + return Err(ProviderError::Provider(format!( + "{operation} failed: {message}" + ))); + } + Ok(()) +} + +fn provider_response_data(response: &Value, provider_name: &str) -> Result { + response + .get("data") + .and_then(|data| data.get("data")) + .and_then(|data| data.as_str()) + .map(str::to_string) + .ok_or_else(|| { + ProviderError::Provider(format!("{provider_name} response missing base64 data")) + }) +} + +fn provider_response_stream(response: &Value, provider_name: &str) -> Result { + response + .get("data") + .and_then(|data| data.get("stream")) + .cloned() + .ok_or_else(|| { + ProviderError::Provider(format!("{provider_name} response missing stream payload")) + }) +} + +fn provider_response_payload( + response: &Value, + provider_name: &str, + transfer: &ContentFetchTransfer, +) -> Result { + match transfer.transfer { + ProviderTransfer::Stream => Ok(ContentFetchPayload::Stream(provider_response_stream( + response, + provider_name, + )?)), + _ => Ok(ContentFetchPayload::Bytes(provider_response_data( + response, + provider_name, + )?)), + } +} + +fn provider_transfer_value(response: &Value) -> Option { + response.get("_runtime_transfer").cloned() +} + +fn is_valid_cid(value: &str) -> bool { + cid::Cid::try_from(value).is_ok() +} + +fn validate_content_path(path: &str) -> Result<(), String> { + if path.is_empty() { + return Ok(()); + } + if path.starts_with('/') || path.starts_with('\\') { + return Err("content fetch path must be relative".to_string()); + } + if path.contains('\\') || path.contains('\0') { + return Err("content fetch path contains invalid characters".to_string()); + } + for segment in path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err("content fetch path contains an invalid segment".to_string()); + } + } + Ok(()) +} + +fn with_directory_object_manifest( + files: Value, + kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, + links: Option<&Value>, +) -> Result { + let mut files = files + .as_array() + .cloned() + .ok_or_else(|| ProviderError::Provider("files must be an array".into()))?; + let manifest = directory_object_manifest(&files, kind, object_did, publisher_did, links)?; + let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| { + ProviderError::Provider(format!("content object manifest encode failed: {err}")) + })?; + files.push(json!({ + "path": OBJECT_MANIFEST_PATH, + "data": base64::engine::general_purpose::STANDARD.encode(manifest_bytes), + })); + sort_directory_entries(&mut files)?; + Ok(Value::Array(files)) +} + +fn directory_object_manifest( + files: &[Value], + kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, + links: Option<&Value>, +) -> Result { + let kind = validate_content_object_kind(kind)?; + let links = parse_content_object_links(links)?; + let mut seen_paths = BTreeSet::new(); + let mut object_files = Vec::with_capacity(files.len()); + let mut sealed_object = None; + for file in files { + let path = file + .get("path") + .and_then(|path| path.as_str()) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("directory publish file is missing path".into()) + })?; + if path == OBJECT_MANIFEST_PATH { + return Err(ProviderError::Provider(format!( + "{OBJECT_MANIFEST_PATH} is reserved for the content object manifest" + ))); + } + validate_content_path(path).map_err(ProviderError::Provider)?; + if !seen_paths.insert(path.to_string()) { + return Err(ProviderError::Provider(format!( + "duplicate directory publish path: {path}" + ))); + } + let data = file + .get("data") + .and_then(|data| data.as_str()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "directory publish file {path} is missing base64 data" + )) + })?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| { + ProviderError::Provider(format!( + "directory publish file {path} has invalid base64 data: {err}" + )) + })?; + if kind == "sealed" && path == SEALED_OBJECT_PATH { + let sealed: SealedObjectV1 = serde_json::from_slice(&bytes).map_err(|err| { + ProviderError::Provider(format!( + "sealed content object has invalid {SEALED_OBJECT_PATH}: {err}" + )) + })?; + validate_sealed_object_descriptor(&sealed)?; + sealed_object = Some(sealed); + } + object_files.push(ContentObjectFile { + path: path.to_string(), + sha256: format!("{:x}", sha2::Sha256::digest(&bytes)), + size: bytes.len() as u64, + }); + } + object_files.sort_by(|a, b| a.path.cmp(&b.path)); + if kind == "sealed" { + let sealed_object = sealed_object.ok_or_else(|| { + ProviderError::Provider(format!( + "sealed content object requires {SEALED_OBJECT_PATH}" + )) + })?; + validate_sealed_content_links(&sealed_object, &links)?; + } + + let mut hasher = sha2::Sha256::new(); + for file in &object_files { + hasher.update(file.path.as_bytes()); + hasher.update(b"\0"); + hasher.update(file.sha256.as_bytes()); + hasher.update(b"\0"); + hasher.update(file.size.to_string().as_bytes()); + hasher.update(b"\0"); + } + + Ok(ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind, + content_digest: format!("sha256:{:x}", hasher.finalize()), + files: object_files, + links, + object_did: object_did.map(str::to_string), + publisher_did: publisher_did.map(str::to_string), + }) +} + +fn validate_content_object_kind(kind: &str) -> Result { + match kind { + "capsule" | "directory" | "document" | "release" | "sealed" | "share" | "site" => { + Ok(kind.to_string()) + } + _ => Err(ProviderError::Provider(format!( + "unsupported content object kind: {kind}" + ))), + } +} + +fn parse_content_object_links( + links: Option<&Value>, +) -> Result, ProviderError> { + let Some(links) = links else { + return Ok(Vec::new()); + }; + let links = links + .as_array() + .ok_or_else(|| ProviderError::Provider("content object links must be an array".into()))?; + let mut parsed = Vec::with_capacity(links.len()); + let mut seen = BTreeSet::new(); + for link in links { + let rel = link + .get("rel") + .and_then(|rel| rel.as_str()) + .filter(|rel| !rel.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content object link is missing rel".into()))?; + validate_content_object_link_rel(rel)?; + let cid = link + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content object link is missing cid".into()))?; + cid::Cid::try_from(cid).map_err(|err| { + ProviderError::Provider(format!("invalid content object link cid: {err}")) + })?; + if !seen.insert((rel.to_string(), cid.to_string())) { + return Err(ProviderError::Provider(format!( + "duplicate content object link: {rel} {cid}" + ))); + } + parsed.push(ContentObjectLink { + rel: rel.to_string(), + cid: cid.to_string(), + }); + } + parsed.sort_by(|a, b| a.rel.cmp(&b.rel).then_with(|| a.cid.cmp(&b.cid))); + Ok(parsed) +} + +fn validate_sealed_object_descriptor(object: &SealedObjectV1) -> Result<(), ProviderError> { + if object.schema != SEALED_OBJECT_SCHEMA { + return Err(ProviderError::Provider( + "sealed content object schema is unsupported".to_string(), + )); + } + validate_linked_cid(&object.payload_cid, "payload_cid")?; + validate_linked_cid(&object.rights_policy_cid, "rights_policy_cid")?; + validate_linked_cid(&object.availability_receipt_cid, "availability_receipt_cid")?; + require_field(&object.key_envelope.scheme, "key_envelope.scheme")?; + require_field(&object.key_envelope.kid, "key_envelope.kid")?; + require_field(&object.key_envelope.wrapped_cek, "key_envelope.wrapped_cek")?; + require_field(&object.key_envelope.policy_hash, "key_envelope.policy_hash")?; + validate_protected_content_key_envelope_algorithms(&object.key_envelope.algorithms) + .map_err(|err| ProviderError::Provider(format!("sealed content object {err}")))?; + require_field( + &object.viewer.required_interface, + "viewer.required_interface", + ) +} + +fn validate_sealed_content_links( + object: &SealedObjectV1, + links: &[ContentObjectLink], +) -> Result<(), ProviderError> { + require_link(links, "payload", &object.payload_cid)?; + require_link(links, "rights.policy", &object.rights_policy_cid)?; + require_link( + links, + "availability.receipt", + &object.availability_receipt_cid, + )?; + if !links.iter().any(|link| link.rel == "provenance") { + return Err(ProviderError::Provider( + "sealed content object requires provenance link".to_string(), + )); + } + Ok(()) +} + +fn require_link(links: &[ContentObjectLink], rel: &str, cid: &str) -> Result<(), ProviderError> { + if links.iter().any(|link| link.rel == rel && link.cid == cid) { + Ok(()) + } else { + Err(ProviderError::Provider(format!( + "sealed content object requires {rel} link to {cid}" + ))) + } +} + +fn validate_linked_cid(value: &str, field: &str) -> Result<(), ProviderError> { + require_field(value, field)?; + cid::Cid::try_from(value) + .map(|_| ()) + .map_err(|err| ProviderError::Provider(format!("invalid sealed object {field}: {err}"))) +} + +fn require_field(value: &str, field: &str) -> Result<(), ProviderError> { + if value.trim().is_empty() { + Err(ProviderError::Provider(format!( + "sealed content object {field} is required" + ))) + } else { + Ok(()) + } +} + +fn validate_content_object_link_rel(rel: &str) -> Result<(), ProviderError> { + if rel.len() > 64 { + return Err(ProviderError::Provider( + "content object link rel is too long".into(), + )); + } + if !rel.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_' || b == b'.' + }) { + return Err(ProviderError::Provider( + "content object link rel must use lowercase ASCII, digits, '-', '_', or '.'".into(), + )); + } + Ok(()) +} + +fn sort_directory_entries(files: &mut [Value]) -> Result<(), ProviderError> { + for file in files.iter() { + file.get("path") + .and_then(|path| path.as_str()) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("directory publish file is missing path".into()) + })?; + } + files.sort_by(|a, b| { + let a = a.get("path").and_then(|path| path.as_str()).unwrap_or(""); + let b = b.get("path").and_then(|path| path.as_str()).unwrap_or(""); + a.cmp(b) + }); + Ok(()) +} + +async fn materialize_data_capsule( + registry: &ProviderRegistry, + cid: &str, + manifest: &elastos_common::CapsuleManifest, + manifest_bytes: &[u8], + capsule_dir: &Path, +) -> anyhow::Result<()> { + let object_manifest_bytes = + fetch_bytes_via_provider(registry, cid, Some(OBJECT_MANIFEST_PATH)) + .await + .map_err(|err| { + anyhow::anyhow!( + "published data capsule {cid} is missing {OBJECT_MANIFEST_PATH}; republish it through content availability: {err}" + ) + })?; + let object_manifest = parse_content_object_manifest(cid, &object_manifest_bytes)?; + + write_materialized_file(capsule_dir, OBJECT_MANIFEST_PATH, &object_manifest_bytes).await?; + + let mut saw_capsule_manifest = false; + for file in &object_manifest.files { + validate_content_path(&file.path).map_err(|err| anyhow::anyhow!("{err}"))?; + if file.path == OBJECT_MANIFEST_PATH { + anyhow::bail!("{OBJECT_MANIFEST_PATH} cannot appear inside its own file list"); + } + + let bytes = if file.path == "capsule.json" { + saw_capsule_manifest = true; + manifest_bytes.to_vec() + } else { + fetch_bytes_via_provider(registry, cid, Some(&file.path)).await? + }; + verify_content_object_file(cid, file, &bytes)?; + write_materialized_file(capsule_dir, &file.path, &bytes).await?; + } + + if !saw_capsule_manifest { + anyhow::bail!("published data capsule {cid} object manifest is missing capsule.json"); + } + + let entrypoint_path = capsule_dir.join(&manifest.entrypoint); + if !entrypoint_path.is_file() { anyhow::bail!( "Data capsule entrypoint '{}' missing after content materialization from CID {}", manifest.entrypoint, @@ -1395,253 +8401,3317 @@ async fn materialize_data_capsule( ); } - Ok(()) -} + Ok(()) +} + +pub fn verify_content_object_file( + cid: &str, + file: &ContentObjectFile, + bytes: &[u8], +) -> anyhow::Result<()> { + if file.size != bytes.len() as u64 { + anyhow::bail!( + "content object file size mismatch for {}/{}: expected {}, got {}", + cid, + file.path, + file.size, + bytes.len() + ); + } + let actual_hash = format!("{:x}", sha2::Sha256::digest(bytes)); + if file.sha256 != actual_hash { + anyhow::bail!( + "content object file hash mismatch for {}/{}", + cid, + file.path + ); + } + Ok(()) +} + +async fn write_materialized_file(base: &Path, rel_path: &str, bytes: &[u8]) -> anyhow::Result<()> { + validate_content_path(rel_path).map_err(|err| anyhow::anyhow!("{err}"))?; + let path = base.join(rel_path); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, bytes).await?; + Ok(()) +} + +fn append_jsonl(path: &Path, entry: &T) -> Result<(), ProviderError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + serde_json::to_writer(&mut file, entry) + .map_err(|err| ProviderError::Provider(format!("content receipt write failed: {err}")))?; + file.write_all(b"\n")?; + Ok(()) +} + +fn verify_signed_receipt(receipt: &SignedAvailabilityReceipt) -> Result<(), ProviderError> { + let envelope = serde_json::to_vec(receipt) + .map_err(|err| ProviderError::Provider(format!("content receipt encode failed: {err}")))?; + crate::crypto::verify_signed_json_envelope_against_dids( + &envelope, + AVAILABILITY_RECEIPT_DOMAIN, + std::slice::from_ref(&receipt.signer_did), + ) + .map_err(|err| { + ProviderError::Provider(format!("content receipt verification failed: {err}")) + })?; + Ok(()) +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tokio::sync::Mutex; + + const TEST_CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + + struct MockIpfsProvider { + add_count: Mutex, + added_files: Mutex>, + added_directories: Mutex>>, + requests: Mutex>, + cat_files: Mutex>>, + missing_paths: Mutex>, + pinned: Mutex>, + pin_error: Mutex>, + unpinned: Mutex>, + } + + struct MockAvailabilityProvider { + requests: Mutex>, + response: Mutex, + } + + #[async_trait] + impl Provider for MockIpfsProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock ipfs provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-ipfs-provider" + } + + async fn send_raw(&self, request: &Value) -> Result { + self.requests.lock().await.push(request.clone()); + match request.get("op").and_then(|op| op.as_str()) { + Some("add_directory") => { + *self.add_count.lock().await += 1; + self.added_directories + .lock() + .await + .push(request["files"].as_array().cloned().unwrap_or_default()); + Ok(provider_ok(json!({ "cid": TEST_CID }))) + } + Some("add_bytes") => { + let filename = request + .get("filename") + .and_then(|filename| filename.as_str()) + .unwrap_or_default() + .to_string(); + self.added_files.lock().await.push(filename); + Ok(provider_ok(json!({ "cid": TEST_CID }))) + } + Some("cat") => { + let path = request + .get("path") + .and_then(|path| path.as_str()) + .unwrap_or("") + .to_string(); + if self + .missing_paths + .lock() + .await + .iter() + .any(|item| item == &path) + { + return Ok(provider_error("not_found", "mock content path missing")); + } + let bytes = self + .cat_files + .lock() + .await + .get(&path) + .cloned() + .unwrap_or_else(|| b"hello content".to_vec()); + Ok(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(bytes) + }))) + } + Some("pin") => { + if let Some(message) = self.pin_error.lock().await.clone() { + return Ok(provider_error("pin_failed", &message)); + } + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .unwrap_or_default() + .to_string(); + self.pinned.lock().await.push(cid); + Ok(provider_ok(json!({}))) + } + Some("unpin") => { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .unwrap_or_default() + .to_string(); + self.unpinned.lock().await.push(cid); + Ok(provider_ok(json!({}))) + } + _ => Ok(provider_error("unsupported", "unsupported mock ipfs op")), + } + } + } + + #[async_trait] + impl Provider for MockAvailabilityProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock availability provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["availability"] + } + + fn name(&self) -> &'static str { + "mock-availability-provider" + } + + async fn send_raw(&self, request: &Value) -> Result { + self.requests.lock().await.push(request.clone()); + Ok(self.response.lock().await.clone()) + } + } + + fn decode_test_stream_payload(stream: &Value) -> Vec { + let chunks = stream["chunks"].as_array().unwrap(); + let mut bytes = Vec::new(); + for chunk in chunks { + let decoded = base64::engine::general_purpose::STANDARD + .decode(chunk["data"].as_str().unwrap()) + .unwrap(); + bytes.extend_from_slice(&decoded); + } + bytes + } + + fn test_stream_payload(bytes: &[u8]) -> Value { + json!({ + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": bytes.len(), + "completed": true, + "chunks": [{ + "index": 0, + "offset": 0, + "length": bytes.len(), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }], + }) + } + + fn carrier_import_exact_invocation() -> Value { + json!({ + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "import_exact", + "capability": "provider:carrier-availability->content:import_exact", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "stream", + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "chunk_size": 65536 + }, + "range": null, + "progress": null + }) + } + + fn carrier_import_object_invocation() -> Value { + json!({ + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "import_object", + "capability": "provider:carrier-availability->content:import_object", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "json", + "range": null, + "progress": null + }) + } + + async fn registry_with_content_and_ipfs() -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_alert_config(None).await + } + + async fn registry_with_content_and_ipfs_with_alert_config( + operator_alert_sink_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_configs(operator_alert_sink_config, None, None, None) + .await + } + + async fn registry_with_content_and_ipfs_with_federated_alert_exchange_config( + federated_operator_alert_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_configs( + None, + None, + None, + federated_operator_alert_exchange_config, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_configs( + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_all_configs( + operator_alert_sink_config, + storage_market_admission_config, + external_repair_fleet_config, + federated_operator_alert_exchange_config, + None, + None, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_quota_ledger_exchange_config( + federated_quota_ledger_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_all_configs( + None, + None, + None, + None, + federated_quota_ledger_exchange_config, + None, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_abuse_control_exchange_config( + federated_abuse_control_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_all_configs( + None, + None, + None, + None, + None, + federated_abuse_control_exchange_config, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_all_configs( + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + federated_quota_ledger_exchange_config: Option, + federated_abuse_control_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + let data_dir = tempfile::tempdir().unwrap(); + let registry = Arc::new(ProviderRegistry::new()); + let ipfs = Arc::new(MockIpfsProvider { + add_count: Mutex::new(0), + added_files: Mutex::new(Vec::new()), + added_directories: Mutex::new(Vec::new()), + requests: Mutex::new(Vec::new()), + cat_files: Mutex::new(HashMap::new()), + missing_paths: Mutex::new(Vec::new()), + pinned: Mutex::new(Vec::new()), + pin_error: Mutex::new(None), + unpinned: Mutex::new(Vec::new()), + }); + registry + .register_sub_provider("ipfs", ipfs.clone()) + .await + .unwrap(); + let content = Arc::new(ContentProvider::new_with_external_configs( + data_dir.path().to_path_buf(), + Arc::downgrade(®istry), + ContentProviderExternalConfigs { + operator_alert_sink: operator_alert_sink_config, + storage_market_admission: storage_market_admission_config, + external_repair_fleet: external_repair_fleet_config, + federated_operator_alert_exchange: federated_operator_alert_exchange_config, + federated_quota_ledger_exchange: federated_quota_ledger_exchange_config, + federated_abuse_control_exchange: federated_abuse_control_exchange_config, + }, + )); + registry.register(content.clone()).await; + registry + .register_sub_provider("content", content.clone()) + .await + .unwrap(); + (data_dir, registry, ipfs, content) + } + + fn spawn_operator_alert_sink() -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n", + ) + .unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/content-alerts"), handle) + } + + fn spawn_federated_operator_alert_exchange( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/alerts/exchange"), handle) + } + + fn signed_federated_quota_ledger_exchange_receipt( + accepted: bool, + reason: Option<&str>, + ) -> Value { + let (signing_key, _) = elastos_identity::derive_did(&[41_u8; 32]); + let payload = json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "provider": "test-quota-ledger", + "scope": "content-availability", + "exchange_id": "quota-exchange:test", + "receipt_id": if accepted { "quota-receipt:accepted" } else { "quota-receipt:rejected" }, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "checked_at": now_unix_secs(), + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_DOMAIN, + canonical.as_bytes(), + ); + json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } + + fn spawn_federated_quota_ledger_exchange_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/quota/exchange"), handle) + } + + fn signed_federated_abuse_control_exchange_receipt( + accepted: bool, + reason: Option<&str>, + ) -> Value { + let (signing_key, _) = elastos_identity::derive_did(&[43_u8; 32]); + let payload = json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "provider": "test-abuse-control", + "scope": "content-availability", + "exchange_id": "abuse-control-exchange:test", + "receipt_id": if accepted { + "abuse-control-receipt:accepted" + } else { + "abuse-control-receipt:rejected" + }, + "abuse_ledger_id": "abuse-ledger:test", + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "checked_at": now_unix_secs(), + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_DOMAIN, + canonical.as_bytes(), + ); + json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } + + fn spawn_federated_abuse_control_exchange_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/abuse/exchange"), handle) + } + + fn spawn_storage_market_admission_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/market/admission"), handle) + } + + fn spawn_external_repair_fleet_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/repair/dispatch"), handle) + } + + fn http_request_complete(request: &[u8]) -> bool { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + request.len() >= header_end + 4 + content_length + } + + async fn invoke_content_repair_worker( + registry: &Arc, + request: Value, + ) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "repair_worker".to_string(), + request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + } + + #[tokio::test] + async fn content_publish_wraps_ipfs_with_availability_status() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!(response["data"]["uri"], format!("elastos://{TEST_CID}")); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["availability"]["peer_selection"]["mode"], + "single_local" + ); + assert_eq!( + response["data"]["availability"]["peer_selection"]["live_multi_peer_proof"], + false + ); + assert_eq!( + response["data"]["availability"]["quota"]["policy"], + "not_enforced" + ); + assert_eq!( + response["data"]["availability"]["repair_worker"]["scheduled"], + false + ); + assert_eq!( + response["data"]["receipt"]["payload"]["schema"], + AVAILABILITY_RECEIPT_SCHEMA + ); + assert_eq!(response["data"]["receipt"]["payload"]["cid"], TEST_CID); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "local_pinned" + ); + assert!(response["data"]["receipt"]["signature"] + .as_str() + .is_some_and(|sig| !sig.is_empty())); + assert!(response["data"]["receipt"]["signer_did"] + .as_str() + .is_some_and(|did| did.starts_with("did:key:z6Mk"))); + let signer_did = response["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&response["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + AVAILABILITY_RECEIPT_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!(*ipfs.add_count.lock().await, 1); + } + + #[tokio::test] + async fn content_publish_records_local_only_repair_task() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["repair_task"]["schema"], + REPAIR_TASK_SCHEMA + ); + assert_eq!(response["data"]["repair_task"]["cid"], TEST_CID); + assert_eq!(response["data"]["repair_task"]["status"], "local_only"); + assert_eq!( + response["data"]["repair_task"]["repair_worker"]["scheduled"], + false + ); + + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); + assert_eq!( + status["data"]["availability"]["repair_task"]["status"], + "local_only" + ); + } + + #[tokio::test] + async fn content_import_exact_requires_runtime_provider_invocation() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let err = content + .send_raw(&json!({ + "op": "import_exact", + "cid": TEST_CID, + "stream": test_stream_payload(b"hello content"), + })) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("requires Runtime provider invocation metadata")); + } + + #[tokio::test] + async fn content_import_exact_accepts_matching_cid_stream() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "import_exact", + "cid": TEST_CID, + "stream": test_stream_payload(b"hello content"), + "_runtime_invocation": carrier_import_exact_invocation(), + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["availability"]["policy"], + "carrier_exact_import" + ); + assert_eq!(response["data"]["import"]["verified_cid"], true); + assert_eq!(response["data"]["import"]["bytes"], 13); + assert_eq!( + ipfs.added_files.lock().await.as_slice(), + ["content.bin".to_string()] + ); + } + + #[tokio::test] + async fn content_import_exact_rejects_cid_mismatch_and_unpins_import() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "import_exact", + "cid": "QmRSEtAyq7Xgr5YCFVWuYsBdqbR5X9fJDsdpNQuvm9yaic", + "stream": test_stream_payload(b"hello content"), + "_runtime_invocation": carrier_import_exact_invocation(), + })) + .await + .unwrap(); + + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "cid_mismatch"); + assert_eq!( + ipfs.unpinned.lock().await.as_slice(), + [TEST_CID.to_string()] + ); + } + + #[tokio::test] + async fn content_import_object_requires_runtime_provider_invocation() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let err = content + .send_raw(&json!({ + "op": "import_object", + "cid": TEST_CID, + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + })) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("requires Runtime provider invocation metadata")); + } + + #[tokio::test] + async fn content_import_object_reconstructs_manifest_directory() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "import_object", + "cid": TEST_CID, + "object_kind": "document", + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher", + "links": [{"rel": "provenance", "cid": TEST_CID}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "_runtime_invocation": carrier_import_object_invocation(), + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!( + response["data"]["availability"]["policy"], + "carrier_object_import" + ); + assert_eq!( + response["data"]["import"]["schema"], + "elastos.content.import-object/v1" + ); + assert_eq!(response["data"]["import"]["files"], 1); + assert_eq!(response["data"]["import"]["verified_cid"], true); + assert_eq!( + response["data"]["receipt"]["payload"]["accounting"]["source"], + "carrier_object_import" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["accounting"]["files"], + 1 + ); + assert_eq!( + response["data"]["receipt"]["payload"]["accounting"]["content_bytes"], + 7 + ); + + let directories = ipfs.added_directories.lock().await; + assert_eq!(directories.len(), 1); + let manifest_entry = directories[0] + .iter() + .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) + .expect("object manifest should be injected"); + let manifest_bytes = base64::engine::general_purpose::STANDARD + .decode(manifest_entry["data"].as_str().unwrap()) + .unwrap(); + let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); + assert_eq!(manifest.kind, "document"); + assert_eq!(manifest.object_did.as_deref(), Some("did:key:zObject")); + assert_eq!( + manifest.publisher_did.as_deref(), + Some("did:key:zPublisher") + ); + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.links[0].rel, "provenance"); + } + + #[tokio::test] + async fn content_publish_uses_registered_availability_provider() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 3, + "peer_selection": { + "mode": "carrier_topic", + "strategy": "closest_peer", + "live_multi_peer_proof": true, + "peer_reputation_policy": { + "schema": "elastos.carrier.peer-reputation/v1", + "policy": "local_runtime_reputation", + "status": "local_history_applied", + "federation": { + "configured": false + } + }, + "replicas": [{ + "role": "remote", + "node_did": "did:key:zRemote", + "score": 94, + "selection_reason": "signed_announcement+endpoint_advertised+fresh+local_reputation_positive", + "local_reputation": { + "scope": "local_runtime", + "score_delta": 4, + "reason": "local_runtime_successes:1;failures:0" + }, + "remote_receipt": { + "schema": "elastos.content.availability.receipt/v1", + "status": "local_pinned", + "verified": true, + "signer_did": "did:key:zRemoteContentProvider", + "quota": { + "status": "within_quota" + }, + "accounting": { + "content_bytes": 7 + }, + "abuse_controls": { + "policy": "carrier_provider_invocation_guardrail", + "enforced": true, + "attempted_operations": 1, + "failed_operations": 0, + "throttled": false + } + } + }] + }, + "quota": { + "policy": "operator_default", + "status": "within_quota", + "enforced": true, + "max_replicas": 3 + }, + "repair_worker": { + "scheduled": false, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["availability"]["status"], + "network_available" + ); + assert_eq!( + response["data"]["availability"]["provider"], + "elacity-supernode" + ); + assert_eq!(response["data"]["availability"]["replicas"], 3); + assert_eq!( + response["data"]["availability"]["peer_selection"]["mode"], + "carrier_topic" + ); + assert_eq!( + response["data"]["availability"]["peer_selection"]["live_multi_peer_proof"], + true + ); + assert_eq!( + response["data"]["availability"]["quota"]["policy"], + "operator_default" + ); + assert_eq!( + response["data"]["availability"]["repair_worker"]["status"], + "healthy" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "network_available" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["provider"], + "elacity-supernode" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "smartweb_default" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["peer_selection"]["strategy"], + "closest_peer" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["quota"]["max_replicas"], + 3 + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "ensure"); + assert_eq!(requests[0]["cid"], TEST_CID); + assert_eq!(requests[0]["uri"], format!("elastos://{TEST_CID}")); + assert_eq!(requests[0]["local"]["status"], "local_pinned"); + assert_eq!( + requests[0]["local"]["peer_selection"]["mode"], + "single_local" + ); + assert_eq!(requests[0]["object_did"], "did:key:z6Mkobject"); + assert_eq!(requests[0]["publisher_did"], "did:key:z6Mkpublisher"); + drop(requests); + + let dashboard = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + + assert_eq!(dashboard["data"]["quota"]["by_status"]["within_quota"], 1); + assert_eq!(dashboard["data"]["quota"]["enforced"], 1); + assert_eq!(dashboard["data"]["proofs"]["live_multi_peer"], 1); + assert_eq!(dashboard["data"]["proofs"]["remote_replicas"], 1); + assert_eq!(dashboard["data"]["proofs"]["remote_receipts"], 1); + assert_eq!(dashboard["data"]["proofs"]["verified_remote_receipts"], 1); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replica_limit"].as_u64(), + Some(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT as u64) + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas_truncated"], + false + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["node_did"], + "did:key:zRemote" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["score"], + 94 + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["local_reputation"]["scope"], + "local_runtime" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["peer_reputation_policy"] + ["status"], + "local_history_applied" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["local_reputation"] + ["score_delta"], + 4 + ); + assert_eq!( + dashboard["data"]["proofs"]["peer_reputation_policy"]["by_status"] + ["local_history_applied"], + 1 + ); + assert_eq!( + dashboard["data"]["proofs"]["peer_reputation_policy"]["local_history_applied"], + 1 + ); + assert_eq!( + dashboard["data"]["proofs"]["peer_reputation_policy"]["federation"]["configured"], + false + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"]["verified"], + true + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["quota_status"], + "within_quota" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["content_bytes"], + 7 + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["abuse_controls"]["policy"], + "carrier_provider_invocation_guardrail" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["abuse_controls"]["attempted_operations"], + 1 + ); + assert!(!dashboard.to_string().contains("connect_ticket")); + } + + #[tokio::test] + async fn content_repair_worker_requires_runtime_provider_invocation() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let err = content + .send_raw(&json!({ + "op": "repair_worker", + "force": true, + })) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("repair_worker requires Runtime provider invocation metadata")); + } + + #[tokio::test] + async fn content_repair_worker_retries_queued_availability_task() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let publish = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["availability"]["status"], "repair_needed"); + assert_eq!(publish["data"]["repair_task"]["status"], "queued"); + assert_eq!(publish["data"]["repair_task"]["attempts"], 0); + assert_eq!( + publish["data"]["repair_task"]["repair_worker"]["scheduled"], + true + ); + + *availability.response.lock().await = provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "healthy" + } + } + })); + + let worker = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + }), + ) + .await + .unwrap(); + + assert_eq!(worker["status"], "ok"); + assert_eq!(worker["data"]["schema"], REPAIR_WORKER_RUN_SCHEMA); + assert_eq!(worker["data"]["checked"], 1); + assert_eq!(worker["data"]["repaired"], 1); + assert_eq!(worker["data"]["failed"], 0); + assert_eq!( + worker["data"]["quota"]["policy"], + "content_repair_worker_guardrail" + ); + assert_eq!( + worker["data"]["abuse_controls"]["schema"], + REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA + ); + assert_eq!( + worker["data"]["abuse_controls"]["runtime_invocation_required"], + true + ); + assert_eq!( + worker["data"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + worker["data"]["network_abuse_policy"]["local_guardrails"] + ["repair_worker_attempt_budget"], + true + ); + assert_eq!( + worker["data"]["network_abuse_policy"]["network_federation"]["configured"], + false + ); + assert_eq!( + worker["data"]["repair_fleet"]["schema"], + REPAIR_FLEET_SCHEMA + ); + assert_eq!( + worker["data"]["repair_fleet"]["policy"], + "single_runtime_provider_repair_fleet" + ); + assert_eq!(worker["data"]["repair_fleet"]["checked"], 1); + assert_eq!( + worker["data"]["repair_fleet"]["production_federation"]["configured"], + false + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["schema"], + EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + false + ); + assert_eq!(worker["data"]["results"][0]["cid"], TEST_CID); + assert_eq!(worker["data"]["results"][0]["status"], "network_available"); + assert_eq!(ipfs.pinned.lock().await.as_slice(), &[TEST_CID.to_string()]); + + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); + assert_eq!( + status["data"]["availability"]["status"], + "network_available" + ); + assert_eq!( + status["data"]["availability"]["repair_task"]["status"], + "healthy" + ); + assert_eq!( + status["data"]["availability"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["availability"]["network_abuse_policy"]["network_federation"] + ["cross_peer_rate_limit"], + false + ); + assert_eq!(status["data"]["availability"]["repair_task"]["attempts"], 1); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 2); + assert_eq!(requests[0]["op"], "ensure"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[1]["requirements"]["min_replicas"], 1); + } + + #[tokio::test] + async fn content_repair_worker_dispatches_configured_external_repair_fleet() { + let (url, handle) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:supernode", + "job_id": "repair:123", + "receipt": { + "schema": "elastos.test.external-repair-fleet.receipt/v1", + "job_id": "repair:123" + } + })); + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs_with_configs( + None, + None, + Some(json!({ + "url": url, + "authorization": "Bearer fleet-test", + "timeout_secs": 5, + })), + None, + ) + .await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "repair_needed", + "provider": "carrier-availability", + "policy": "network_default", + "replicas": 1, + "reason": "remote peer could not pin content yet", + "peer_selection": { + "mode": "carrier_provider_replication", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "carrier_provider_quota", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "queued" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let publish = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + assert_eq!(publish["data"]["repair_task"]["status"], "queued"); + + *availability.response.lock().await = provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "healthy" + } + } + })); + + let worker = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + }), + ) + .await + .unwrap(); + + assert_eq!(worker["status"], "ok"); + assert_eq!(worker["data"]["checked"], 1); + assert_eq!(worker["data"]["repaired"], 1); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["status"], + "external_repair_fleet_dispatch_configured" + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["run"]["external_dispatches"], + 1 + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["run"]["external_dispatch_accepted"], + 1 + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + true + ); + assert_eq!( + worker["data"]["results"][0]["external_repair_fleet_dispatch"]["schema"], + EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA + ); + assert_eq!( + worker["data"]["results"][0]["external_repair_fleet_dispatch"]["job_id"], + "repair:123" + ); + assert_eq!( + worker["data"]["results"][0]["external_repair_fleet_dispatch"]["client"] + ["credential_exposed"], + false + ); + assert!(!worker.to_string().contains("fleet-test")); + + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + assert_eq!( + status["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + true + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["external_repair_fleet_policy"] + ["external_fleet"]["configured"], + true + ); + assert!(!status.to_string().contains("fleet-test")); + + let request = handle.join().unwrap(); + assert!(request.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer fleet-test"))); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("fleet-test")); + } + + #[tokio::test] + async fn content_external_repair_fleet_dispatch_accepts_endpoint_quorum() { + let (url_a, handle_a) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:a", + "job_id": "repair:a", + "receipt": { + "schema": "elastos.test.external-repair-fleet.receipt/v1", + "job_id": "repair:a" + } + })); + let (url_b, handle_b) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:b", + "job_id": "repair:b", + "receipt": { + "schema": "elastos.test.external-repair-fleet.receipt/v1", + "job_id": "repair:b" + } + })); + let client = ContentExternalRepairFleetClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "repair-a", + "url": url_a, + "authorization": "Bearer repair-secret-a", + "timeout_secs": 5 + }, + { + "id": "repair-b", + "url": url_b, + "authorization": "Bearer repair-secret-b", + "timeout_secs": 5 + } + ] + })) + .unwrap(); + + let receipt = client + .dispatch(&json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA, + "cid": TEST_CID, + "requested_at": 1_700_000_000, + })) + .await + .unwrap(); + + assert_eq!( + receipt["schema"], + EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA + ); + assert_eq!(receipt["accepted"], true); + assert_eq!(receipt["status"], "accepted"); + assert_eq!(receipt["job_id"], "repair:a"); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["endpoint_count"], 2); + assert_eq!(receipt["quorum"]["accepted"], 2); + assert_eq!(receipt["client"]["multi_endpoint"], true); + assert_eq!(receipt["client"]["endpoint_count"], 2); + assert!(!receipt.to_string().contains("repair-secret-a")); + assert!(!receipt.to_string().contains("repair-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer repair-secret-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer repair-secret-b"))); + assert!(request_a.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(request_b.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("repair-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("repair-secret-b")); + } + + #[tokio::test] + async fn content_external_repair_fleet_dispatch_rejects_endpoint_quorum_failure() { + let (accepted_url, accepted_handle) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:a", + "job_id": "repair:a", + })); + let (rejected_url, rejected_handle) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "fleet capacity exhausted", + })); + let client = ContentExternalRepairFleetClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + {"id": "repair-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "repair-b", "url": rejected_url, "timeout_secs": 5} + ] + })) + .unwrap(); + + let receipt = client + .dispatch(&json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA, + "cid": TEST_CID, + "requested_at": 1_700_000_000, + })) + .await + .unwrap(); + + assert_eq!(receipt["accepted"], false); + assert_eq!(receipt["status"], "dispatch_failed"); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["accepted"], 1); + assert_eq!(receipt["quorum"]["rejected"], 1); + assert!(receipt["reason"] + .as_str() + .unwrap() + .contains("fleet capacity exhausted")); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(rejected_request.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + } + + #[tokio::test] + async fn content_repair_worker_enforces_attempt_budget() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "repair_needed", + "provider": "carrier-availability", + "policy": "network_default", + "replicas": 1, + "reason": "remote peer could not pin content yet", + "peer_selection": { + "mode": "carrier_provider_replication", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "carrier_provider_quota", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "queued" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let publish = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + assert_eq!(publish["data"]["repair_task"]["status"], "queued"); + assert_eq!(publish["data"]["repair_task"]["attempts"], 0); + + let first = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + "max_attempts": 1, + }), + ) + .await + .unwrap(); + assert_eq!(first["data"]["checked"], 1); + assert_eq!(first["data"]["failed"], 1); + assert_eq!(first["data"]["results"][0]["status"], "repair_needed"); + + let second = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + "max_attempts": 1, + }), + ) + .await + .unwrap(); + assert_eq!(second["data"]["checked"], 0); + assert_eq!( + second["data"]["abuse_controls"]["exhausted_attempts_skipped"], + 1 + ); + assert_eq!( + second["data"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + second["data"]["network_abuse_policy"]["status"], + "local_worker_throttled" + ); + assert_eq!( + second["data"]["repair_fleet"]["exhausted_attempts_skipped"], + 1 + ); + assert_eq!( + second["data"]["repair_fleet"]["production_federation"]["external_workers"], + false + ); + assert_eq!( + second["data"]["external_repair_fleet_policy"]["run"]["exhausted_attempts_skipped"], + 1 + ); + assert_eq!( + second["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + false + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 2); + } + + #[tokio::test] + async fn content_publish_accepts_carrier_announced_availability() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "carrier_announced", + "provider": "carrier-availability", + "policy": "network_default", + "replicas": 1, + "transport": "carrier-gossip", + "topic": "elastos://carrier/content/test/availability", + "peer_selection": { + "mode": "carrier_topic", + "topic": "elastos://carrier/content/test/availability", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "carrier_policy", + "max_replicas": 1 + }, + "repair_worker": { + "scheduled": false, + "status": "carrier_announced" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBDYXJyaWVyCg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["availability"]["status"], + "carrier_announced" + ); + assert_eq!( + response["data"]["availability"]["provider"], + "carrier-availability" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "carrier_announced" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["provider"], + "carrier-availability" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "network_default" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["peer_selection"]["topic"], + "elastos://carrier/content/test/availability" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["quota"]["policy"], + "carrier_policy" + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "ensure"); + assert_eq!(requests[0]["local"]["replicas"], 1); + assert_eq!(requests[0]["policy"], "network_default"); + } + + #[tokio::test] + async fn content_publish_rejects_unproven_multi_peer_availability_claim() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "thin-availability-provider", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBVbnByb3Zlbgo="}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert_eq!( + response["data"]["availability"]["provider"], + "thin-availability-provider" + ); + assert!(response["data"]["availability"]["reason"] + .as_str() + .unwrap() + .contains("peer_selection")); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + } + + #[tokio::test] + async fn content_publish_enforces_availability_requirements() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": false, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBSZXF1aXJlbWVudAo="}], + "pin": true, + "availability_requirements": { + "min_replicas": 3, + "max_replicas": 3, + "require_live_multi_peer_proof": true + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert!(response["data"]["availability"]["reason"] + .as_str() + .unwrap() + .contains("below required 3")); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests[0]["requirements"]["min_replicas"], 3); + assert_eq!(requests[0]["requirements"]["max_replicas"], 3); + assert_eq!( + requests[0]["requirements"]["require_live_multi_peer_proof"], + true + ); + } + + #[tokio::test] + async fn content_publish_requires_peer_selection_policy_metadata() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "policyless-availability", + "policy": "network_default", + "replicas": 1, + "peer_selection": { + "live_multi_peer_proof": false + }, + "quota": { + "policy": "operator_default", + "max_replicas": 1 + }, + "repair_worker": { + "scheduled": false, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBQb2xpY3kK"}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert!(response["data"]["availability"]["reason"] + .as_str() + .unwrap() + .contains("peer_selection requires mode or strategy")); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + } + + #[tokio::test] + async fn content_publish_directory_injects_object_manifest() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "document", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let directories = ipfs.added_directories.lock().await; + let manifest_entry = directories[0] + .iter() + .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) + .expect("object manifest should be injected"); + let manifest_bytes = base64::engine::general_purpose::STANDARD + .decode(manifest_entry["data"].as_str().unwrap()) + .unwrap(); + let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); + + assert_eq!(manifest.schema, OBJECT_MANIFEST_SCHEMA); + assert_eq!(manifest.kind, "document"); + assert_eq!(manifest.object_did.as_deref(), Some("did:key:z6Mkobject")); + assert_eq!( + manifest.publisher_did.as_deref(), + Some("did:key:z6Mkpublisher") + ); + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.files[0].path, "index.md"); + assert!(manifest.content_digest.starts_with("sha256:")); + } + + fn sealed_object_value() -> Value { + json!({ + "schema": "elastos.sealed.object/v1", + "payload_cid": TEST_CID, + "rights_policy_cid": TEST_CID, + "availability_receipt_cid": TEST_CID, + "key_envelope": { + "scheme": "elastos-pq-hybrid-threshold-v0", + "kid": "kid:test", + "wrapped_cek": "wrapped", + "policy_hash": "sha256:test", + "algorithms": { + "cipher": "aes-256-gcm", + "signature": ["ed25519", "ml-dsa-65"], + "kem": ["x25519", "ml-kem-768"], + "share_scheme": "shamir-t-of-n" + } + }, + "viewer": { + "required_interface": "elastos.viewer/document@1" + } + }) + } + + fn sealed_object_data() -> String { + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&sealed_object_value()).unwrap()) + } + + fn sealed_object_data_from(value: &Value) -> String { + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(value).unwrap()) + } + + fn sealed_object_links() -> Vec { + vec![ + json!({"rel": "availability.receipt", "cid": TEST_CID}), + json!({"rel": "payload", "cid": TEST_CID}), + json!({"rel": "provenance", "cid": TEST_CID}), + json!({"rel": "rights.policy", "cid": TEST_CID}), + ] + } + + #[tokio::test] + async fn content_publish_directory_accepts_linked_release_and_sealed_manifests() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": sealed_object_links(), + "files": [{"path": "sealed.json", "data": sealed_object_data()}], + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let directories = ipfs.added_directories.lock().await; + let manifest_entry = directories[0] + .iter() + .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) + .expect("object manifest should be injected"); + let manifest_bytes = base64::engine::general_purpose::STANDARD + .decode(manifest_entry["data"].as_str().unwrap()) + .unwrap(); + let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); + + assert_eq!(manifest.kind, "sealed"); + assert_eq!(manifest.links.len(), 4); + assert_eq!(manifest.links[0].rel, "availability.receipt"); + assert_eq!(manifest.links[0].cid, TEST_CID); + assert_eq!(manifest.links[1].rel, "payload"); + assert_eq!(manifest.links[1].cid, TEST_CID); + assert_eq!(manifest.links[2].rel, "provenance"); + assert_eq!(manifest.links[2].cid, TEST_CID); + assert_eq!(manifest.links[3].rel, "rights.policy"); + assert_eq!(manifest.links[3].cid, TEST_CID); + drop(directories); + + let release_response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "release", + "links": [{"rel": "sealed", "cid": TEST_CID}], + "files": [{"path": "release.json", "data": "e30="}], + })) + .await + .unwrap(); + assert_eq!(release_response["status"], "ok"); + } + + #[tokio::test] + async fn content_publish_directory_rejects_incomplete_sealed_objects() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let missing_descriptor = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": sealed_object_links(), + "files": [{"path": "payload.bin", "data": "c2VhbGVkCg=="}], + })) + .await + .unwrap_err(); + assert!(missing_descriptor + .to_string() + .contains("sealed content object requires sealed.json")); + + let missing_provenance = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": [ + {"rel": "availability.receipt", "cid": TEST_CID}, + {"rel": "payload", "cid": TEST_CID}, + {"rel": "rights.policy", "cid": TEST_CID} + ], + "files": [{"path": "sealed.json", "data": sealed_object_data()}], + })) + .await + .unwrap_err(); + assert!(missing_provenance + .to_string() + .contains("sealed content object requires provenance link")); + + let mut weak_envelope = sealed_object_value(); + weak_envelope["key_envelope"]["algorithms"]["cipher"] = Value::String("aes-128-gcm".into()); + let weak_cipher = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": sealed_object_links(), + "files": [{"path": "sealed.json", "data": sealed_object_data_from(&weak_envelope)}], + })) + .await + .unwrap_err(); + assert!(weak_cipher + .to_string() + .contains("key_envelope.algorithms.cipher uses unsupported algorithm")); + } + + #[tokio::test] + async fn content_publish_directory_sorts_entries_for_stable_cids() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "share", + "files": [ + {"path": "z.md", "data": "eg=="}, + {"path": "a.md", "data": "YQ=="} + ], + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let directories = ipfs.added_directories.lock().await; + let paths = directories[0] + .iter() + .map(|entry| entry["path"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!(paths, vec![OBJECT_MANIFEST_PATH, "a.md", "z.md"]); + } + + #[tokio::test] + async fn content_publish_directory_rejects_ambiguous_object_shape() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let duplicate_path = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "share", + "files": [ + {"path": "index.md", "data": "YQ=="}, + {"path": "index.md", "data": "Yg=="} + ], + })) + .await + .unwrap_err(); + assert!(duplicate_path + .to_string() + .contains("duplicate directory publish path")); + + let unknown_kind = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "random", + "files": [{"path": "index.md", "data": "YQ=="}], + })) + .await + .unwrap_err(); + assert!(unknown_kind + .to_string() + .contains("unsupported content object kind")); + + let invalid_link = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "release", + "links": [{"rel": "Bad Rel", "cid": TEST_CID}], + "files": [{"path": "release.json", "data": "e30="}], + })) + .await + .unwrap_err(); + assert!(invalid_link.to_string().contains("content object link rel")); + + let invalid_link_cid = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "release", + "links": [{"rel": "release", "cid": "not-a-cid"}], + "files": [{"path": "release.json", "data": "e30="}], + })) + .await + .unwrap_err(); + assert!(invalid_link_cid + .to_string() + .contains("invalid content object link cid")); + } + + #[tokio::test] + async fn content_unpublish_wraps_ipfs_unpin() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "unpublish", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "local_unpinned" + ); + assert_eq!( + ipfs.unpinned.lock().await.as_slice(), + [TEST_CID.to_string()] + ); + } + + #[tokio::test] + async fn content_repair_pins_cid_and_records_receipt() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "repair", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "local_repair_pin" + ); + assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); + } + + #[tokio::test] + async fn content_ensure_pins_cid_and_records_policy() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "ensure", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "local_ensure_pin" + ); + assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); + } + + #[tokio::test] + async fn content_repair_records_repair_needed_when_pin_fails() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + *ipfs.pin_error.lock().await = Some("not available".to_string()); + + let response = content + .send_raw(&json!({ + "op": "repair", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert_eq!(response["data"]["availability"]["reason"], "not available"); + assert_eq!( + response["data"]["availability"]["repair_worker"]["scheduled"], + true + ); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + } + + #[tokio::test] + async fn content_publish_file_wraps_ipfs_bytes_with_receipt() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + let cid = publish_bytes_via_provider( + ®istry, + "provenance.json", + br#"{"ok":true}"#, + Some("did:key:z6Mkobject"), + Some("did:key:z6Mkpublisher"), + ) + .await + .unwrap(); + + assert_eq!(cid, TEST_CID); + assert_eq!( + ipfs.added_files.lock().await.as_slice(), + ["provenance.json".to_string()] + ); + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); + assert_eq!( + status["data"]["receipt"]["payload"]["accounting"]["source"], + "publish_request" + ); + assert_eq!( + status["data"]["receipt"]["payload"]["accounting"]["files"], + 1 + ); + assert_eq!( + status["data"]["receipt"]["payload"]["accounting"]["content_bytes"], + br#"{"ok":true}"#.len() as u64 + ); + assert_eq!( + status["data"]["availability"]["accounting"]["storage_quota"]["enforced"], + false + ); + } + + #[tokio::test] + async fn content_fetch_wraps_ipfs_cat() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let bytes = fetch_bytes_via_provider(®istry, TEST_CID, Some("capsule.json")) + .await + .unwrap(); + + assert_eq!(bytes, b"hello content"); + let requests = ipfs.requests.lock().await; + let cat = requests + .iter() + .find(|request| request["op"] == "cat") + .expect("content helper should fetch through ipfs cat"); + assert_eq!(cat["_runtime_invocation"]["transfer"], "stream"); + assert_eq!( + cat["_runtime_invocation"]["stream"]["mode"], + "runtime_stream_session" + ); + assert_eq!( + cat["_runtime_invocation"]["abi"]["backpressure"], + "read_next" + ); + assert_eq!(cat["_runtime_invocation"]["abi"]["cancel_supported"], true); + } + + #[tokio::test] + async fn content_fetch_propagates_range_progress_transfer_receipt() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "capsule.json", + "range": { + "start": 0, + "end": 4 + }, + "progress": { + "request_id": "content-fetch:test", + "expected_bytes": 5 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["data"], + base64::engine::general_purpose::STANDARD.encode(b"hello") + ); + assert_eq!( + response["data"]["transfer"]["schema"], + "elastos.provider.transfer/v1" + ); + assert_eq!(response["data"]["transfer"]["source"], "content-provider"); + assert_eq!(response["data"]["transfer"]["target"], "ipfs"); + assert_eq!(response["data"]["transfer"]["op"], "cat"); + assert_eq!( + response["data"]["transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["data"]["transfer"]["range"]["start"], 0); + assert_eq!(response["data"]["transfer"]["range"]["end"], 4); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "content-fetch:test" + ); + assert_eq!( + response["data"]["transfer"]["progress"]["expected_bytes"], + 5 + ); + } + + #[tokio::test] + async fn content_fetch_stream_returns_provider_stream_payload() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "capsule.json", + "transfer": "stream", + "range": { + "start": 0, + "end": 4 + }, + "progress": { + "request_id": "content-stream:test", + "expected_bytes": 5 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert!(response["data"].get("data").is_none()); + assert_eq!( + response["data"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!( + decode_test_stream_payload(&response["data"]["stream"]), + b"hello" + ); + assert_eq!(response["data"]["transfer"]["transfer"], "stream"); + assert_eq!( + response["data"]["transfer"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!(response["data"]["transfer"]["range"]["start"], 0); + assert_eq!(response["data"]["transfer"]["range"]["end"], 4); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "content-stream:test" + ); + assert_eq!( + response["data"]["transfer"]["progress"]["expected_bytes"], + 5 + ); + } + + #[tokio::test] + async fn content_fetch_uses_availability_provider_when_local_backend_misses() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; -pub fn verify_content_object_file( - cid: &str, - file: &ContentObjectFile, - bytes: &[u8], -) -> anyhow::Result<()> { - if file.size != bytes.len() as u64 { - anyhow::bail!( - "content object file size mismatch for {}/{}: expected {}, got {}", - cid, - file.path, - file.size, - bytes.len() + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["data"], + base64::engine::general_purpose::STANDARD.encode(b"remote content") + ); + assert_eq!( + response["data"]["availability"]["provider"], + "mock-availability" + ); + assert_eq!( + response["data"]["availability"]["status"], + "network_available" ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "fetch"); + assert_eq!(requests[0]["cid"], TEST_CID); + assert_eq!(requests[0]["uri"], format!("elastos://{TEST_CID}")); + assert_eq!(requests[0]["path"], "remote.md"); } - let actual_hash = format!("{:x}", sha2::Sha256::digest(bytes)); - if file.sha256 != actual_hash { - anyhow::bail!( - "content object file hash mismatch for {}/{}", - cid, - file.path + + #[tokio::test] + async fn content_fetch_ranges_availability_provider_when_local_backend_misses() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + "range": { + "start": 7, + "end": 13 + }, + "progress": { + "request_id": "availability-fetch:test", + "expected_bytes": 7 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["data"], + base64::engine::general_purpose::STANDARD.encode(b"content") + ); + assert_eq!(response["data"]["transfer"]["target"], "availability"); + assert_eq!( + response["data"]["transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["data"]["transfer"]["range"]["start"], 7); + assert_eq!(response["data"]["transfer"]["range"]["end"], 13); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "availability-fetch:test" + ); + + let requests = availability.requests.lock().await; + assert_eq!( + requests[0]["_runtime_invocation"]["source"], + "content-provider" ); + assert_eq!(requests[0]["_runtime_invocation"]["target"], "availability"); + assert_eq!(requests[0]["_runtime_invocation"]["range"]["start"], 7); } - Ok(()) -} -async fn write_materialized_file(base: &Path, rel_path: &str, bytes: &[u8]) -> anyhow::Result<()> { - validate_content_path(rel_path).map_err(|err| anyhow::anyhow!("{err}"))?; - let path = base.join(rel_path); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + #[tokio::test] + async fn content_fetch_stream_ranges_availability_provider_when_local_backend_misses() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + "transfer": "stream", + "range": { + "start": 7, + "end": 13 + }, + "progress": { + "request_id": "availability-stream:test", + "expected_bytes": 7 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert!(response["data"].get("data").is_none()); + assert_eq!( + decode_test_stream_payload(&response["data"]["stream"]), + b"content" + ); + assert_eq!( + response["data"]["availability"]["provider"], + "mock-availability" + ); + assert_eq!(response["data"]["transfer"]["target"], "availability"); + assert_eq!(response["data"]["transfer"]["transfer"], "stream"); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "availability-stream:test" + ); + + let requests = availability.requests.lock().await; + assert_eq!( + requests[0]["_runtime_invocation"]["source"], + "content-provider" + ); + assert_eq!(requests[0]["_runtime_invocation"]["target"], "availability"); + assert_eq!(requests[0]["_runtime_invocation"]["transfer"], "stream"); + assert_eq!( + requests[0]["_runtime_invocation"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!(requests[0]["_runtime_invocation"]["range"]["start"], 7); } - tokio::fs::write(path, bytes).await?; - Ok(()) -} -fn append_jsonl(path: &Path, entry: &T) -> Result<(), ProviderError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + #[tokio::test] + async fn content_fetch_local_only_skips_availability_provider() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability" + } + }))), + }); + registry.register(availability.clone()).await; + + let err = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + "local_only": true, + })) + .await + .expect_err("local_only fetch must fail on local backend miss"); + + assert!(err.to_string().contains("content fetch")); + assert!(availability.requests.lock().await.is_empty()); + } + + #[tokio::test] + async fn content_prepare_data_capsule_materializes_verified_manifest_files() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let capsule_json = serde_json::json!({ + "schema": elastos_common::SCHEMA_V1, + "version": "0.1.0", + "name": "shared-doc", + "role": "content", + "type": "data", + "entrypoint": "index.html" + }); + let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); + let index_bytes = b"viewer".to_vec(); + let markdown_bytes = b"# Hello\n".to_vec(); + let object_manifest = ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind: "share".to_string(), + content_digest: "sha256:test".to_string(), + files: vec![ + object_file("capsule.json", &capsule_bytes), + object_file("docs/readme.md", &markdown_bytes), + object_file("index.html", &index_bytes), + ], + links: Vec::new(), + object_did: None, + publisher_did: None, + }; + let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + + { + let mut cat_files = ipfs.cat_files.lock().await; + cat_files.insert("capsule.json".to_string(), capsule_bytes); + cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); + cat_files.insert("index.html".to_string(), index_bytes.clone()); + cat_files.insert("docs/readme.md".to_string(), markdown_bytes.clone()); + } + + let capsule_dir = prepare_capsule_from_content_provider(®istry, TEST_CID) + .await + .unwrap(); + assert_eq!( + std::fs::read(capsule_dir.join("index.html")).unwrap(), + index_bytes + ); + assert_eq!( + std::fs::read(capsule_dir.join("docs/readme.md")).unwrap(), + markdown_bytes + ); + assert!(capsule_dir.join(OBJECT_MANIFEST_PATH).is_file()); + std::fs::remove_dir_all(capsule_dir).unwrap(); + } + + #[tokio::test] + async fn content_prepare_data_capsule_rejects_object_hash_mismatch() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let capsule_json = serde_json::json!({ + "schema": elastos_common::SCHEMA_V1, + "version": "0.1.0", + "name": "shared-doc", + "role": "content", + "type": "data", + "entrypoint": "index.html" + }); + let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); + let original_index = b"viewer".to_vec(); + let tampered_index = b"viewed".to_vec(); + let object_manifest = ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind: "share".to_string(), + content_digest: "sha256:test".to_string(), + files: vec![ + object_file("capsule.json", &capsule_bytes), + object_file("index.html", &original_index), + ], + links: Vec::new(), + object_did: None, + publisher_did: None, + }; + let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + + { + let mut cat_files = ipfs.cat_files.lock().await; + cat_files.insert("capsule.json".to_string(), capsule_bytes); + cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); + cat_files.insert("index.html".to_string(), tampered_index); + } + + let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + .await + .unwrap_err(); + assert!(err + .to_string() + .contains("content object file hash mismatch")); } - let mut file = OpenOptions::new().create(true).append(true).open(path)?; - serde_json::to_writer(&mut file, entry) - .map_err(|err| ProviderError::Provider(format!("content receipt write failed: {err}")))?; - file.write_all(b"\n")?; - Ok(()) -} -fn verify_signed_receipt(receipt: &SignedAvailabilityReceipt) -> Result<(), ProviderError> { - let envelope = serde_json::to_vec(receipt) - .map_err(|err| ProviderError::Provider(format!("content receipt encode failed: {err}")))?; - crate::crypto::verify_signed_json_envelope_against_dids( - &envelope, - AVAILABILITY_RECEIPT_DOMAIN, - std::slice::from_ref(&receipt.signer_did), - ) - .map_err(|err| { - ProviderError::Provider(format!("content receipt verification failed: {err}")) - })?; - Ok(()) -} + #[tokio::test] + async fn content_prepare_capsule_rejects_release_object_as_not_launchable() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let release_bytes = br#"{"payload":{},"signature":"00","signer_did":"did:key:z6Mk"}"#; + let object_manifest = ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind: "release".to_string(), + content_digest: "sha256:test".to_string(), + files: vec![object_file("release.json", release_bytes)], + links: Vec::new(), + object_did: Some("elastos://release/stable/0.2.0".to_string()), + publisher_did: Some("did:key:z6Mkpublisher".to_string()), + }; + let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} + { + let mut cat_files = ipfs.cat_files.lock().await; + cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); + } + ipfs.missing_paths + .lock() + .await + .push("capsule.json".to_string()); -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use tokio::sync::Mutex; + let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + .await + .unwrap_err(); + assert!(err.to_string().contains("kind 'release'")); + assert!(err.to_string().contains("not a launchable capsule")); + } - const TEST_CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + #[tokio::test] + async fn content_fetch_rejects_invalid_cid_and_path() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let invalid_cid = content + .send_raw(&json!({ + "op": "fetch", + "cid": "not-a-cid", + })) + .await + .unwrap(); + assert_eq!(invalid_cid["status"], "error"); + assert_eq!(invalid_cid["code"], "invalid_cid"); - struct MockIpfsProvider { - add_count: Mutex, - added_files: Mutex>, - added_directories: Mutex>>, - cat_files: Mutex>>, - missing_paths: Mutex>, - pinned: Mutex>, - pin_error: Mutex>, - unpinned: Mutex>, + let invalid_path = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "../secret", + })) + .await + .unwrap(); + assert_eq!(invalid_path["status"], "error"); + assert_eq!(invalid_path["code"], "invalid_path"); } - struct MockAvailabilityProvider { - requests: Mutex>, - response: Mutex, + #[tokio::test] + async fn content_status_rejects_invalid_cid() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let invalid_cid = content + .send_raw(&json!({ + "op": "status", + "cid": "not-a-cid", + })) + .await + .unwrap(); + + assert_eq!(invalid_cid["status"], "error"); + assert_eq!(invalid_cid["code"], "invalid_cid"); } - #[async_trait] - impl Provider for MockIpfsProvider { - async fn handle( - &self, - _request: ResourceRequest, - ) -> Result { - Err(ProviderError::Provider( - "mock ipfs provider only supports raw operations".into(), - )) + fn object_file(path: &str, bytes: &[u8]) -> ContentObjectFile { + ContentObjectFile { + path: path.to_string(), + sha256: format!("{:x}", sha2::Sha256::digest(bytes)), + size: bytes.len() as u64, } + } - fn schemes(&self) -> Vec<&'static str> { - Vec::new() - } + #[tokio::test] + async fn content_status_reads_latest_availability_receipt() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + })) + .await + .unwrap(); + content + .send_raw(&json!({ + "op": "unpublish", + "cid": TEST_CID, + })) + .await + .unwrap(); - fn name(&self) -> &'static str { - "mock-ipfs-provider" - } + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); - async fn send_raw(&self, request: &Value) -> Result { - match request.get("op").and_then(|op| op.as_str()) { - Some("add_directory") => { - *self.add_count.lock().await += 1; - self.added_directories - .lock() - .await - .push(request["files"].as_array().cloned().unwrap_or_default()); - Ok(provider_ok(json!({ "cid": TEST_CID }))) - } - Some("add_bytes") => { - let filename = request - .get("filename") - .and_then(|filename| filename.as_str()) - .unwrap_or_default() - .to_string(); - self.added_files.lock().await.push(filename); - Ok(provider_ok(json!({ "cid": TEST_CID }))) - } - Some("cat") => { - let path = request - .get("path") - .and_then(|path| path.as_str()) - .unwrap_or("") - .to_string(); - if self - .missing_paths - .lock() - .await - .iter() - .any(|item| item == &path) - { - return Ok(provider_error("not_found", "mock content path missing")); - } - let bytes = self - .cat_files - .lock() - .await - .get(&path) - .cloned() - .unwrap_or_else(|| b"hello content".to_vec()); - Ok(provider_ok(json!({ - "data": base64::engine::general_purpose::STANDARD.encode(bytes) - }))) - } - Some("pin") => { - if let Some(message) = self.pin_error.lock().await.clone() { - return Ok(provider_error("pin_failed", &message)); - } - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .unwrap_or_default() - .to_string(); - self.pinned.lock().await.push(cid); - Ok(provider_ok(json!({}))) - } - Some("unpin") => { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .unwrap_or_default() - .to_string(); - self.unpinned.lock().await.push(cid); - Ok(provider_ok(json!({}))) - } - _ => Ok(provider_error("unsupported", "unsupported mock ipfs op")), - } - } + assert_eq!(status["status"], "ok"); + assert_eq!(status["data"]["cid"], TEST_CID); + assert_eq!(status["data"]["availability"]["status"], "local_unpinned"); + assert_eq!(status["data"]["availability"]["policy"], "local_unpublish"); + assert_eq!( + status["data"]["availability"]["peer_selection"]["mode"], + "single_local" + ); + assert_eq!( + status["data"]["availability"]["quota"]["policy"], + "not_enforced" + ); + assert_eq!( + status["data"]["availability"]["repair_worker"]["scheduled"], + false + ); + assert_eq!( + status["data"]["availability"]["abuse_controls"]["policy"], + "local_content_backend" + ); + assert_eq!( + status["data"]["availability"]["repair_task"]["status"], + "retired" + ); + assert_eq!( + status["data"]["receipt"]["payload"]["schema"], + AVAILABILITY_RECEIPT_SCHEMA + ); } - #[async_trait] - impl Provider for MockAvailabilityProvider { - async fn handle( - &self, - _request: ResourceRequest, - ) -> Result { - Err(ProviderError::Provider( - "mock availability provider only supports raw operations".into(), - )) - } - - fn schemes(&self) -> Vec<&'static str> { - vec!["availability"] - } + #[tokio::test] + async fn content_status_without_cid_returns_availability_dashboard() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); - fn name(&self) -> &'static str { - "mock-availability-provider" - } + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); - async fn send_raw(&self, request: &Value) -> Result { - self.requests.lock().await.push(request.clone()); - Ok(self.response.lock().await.clone()) - } + assert_eq!(status["status"], "ok"); + assert_eq!(status["data"]["schema"], AVAILABILITY_DASHBOARD_SCHEMA); + assert_eq!(status["data"]["objects"]["tracked"], 1); + assert_eq!(status["data"]["objects"]["by_status"]["local_pinned"], 1); + assert_eq!(status["data"]["objects"]["by_provider"]["ipfs-provider"], 1); + assert_eq!(status["data"]["quota"]["by_status"]["not_enforced"], 1); + assert_eq!(status["data"]["quota"]["enforced"], 0); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_not_configured" + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["configured"], + false + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["remote"]["signed_admission_receipts"], + 0 + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"] + ["signed_admission_receipt_exchange"], + false + ); + assert_eq!(status["data"]["proofs"]["live_multi_peer"], 0); + assert_eq!(status["data"]["proofs"]["remote_receipts"], 0); + assert_eq!( + status["data"]["proofs"]["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["proofs"]["peer_attestation_exchange_policy"]["status"], + "attestation_exchange_not_configured" + ); + assert_eq!( + status["data"]["proofs"]["peer_attestation_exchange_policy"]["attestation_exchange"] + ["configured"], + false + ); + assert_eq!(status["data"]["accounting"]["accounted_objects"], 1); + assert_eq!(status["data"]["accounting"]["accounted_files"], 1); + assert_eq!(status["data"]["accounting"]["content_bytes"], 7); + assert_eq!(status["data"]["accounting"]["replica_bytes_estimate"], 7); + assert_eq!( + status["data"]["accounting"]["by_source"]["publish_request"], + 1 + ); + assert_eq!( + status["data"]["accounting"]["storage_quota_policy"], + "principal_ledger" + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["schema"], + CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA + ); + assert_eq!(status["data"]["accounting"]["ledger"]["durable"], true); + assert_eq!(status["data"]["accounting"]["ledger"]["tracked_objects"], 1); + assert_eq!(status["data"]["accounting"]["ledger"]["active_objects"], 1); + assert_eq!( + status["data"]["accounting"]["ledger"]["tracked_principals"], + 1 + ); + assert_eq!(status["data"]["accounting"]["ledger"]["content_bytes"], 7); + assert_eq!( + status["data"]["accounting"]["ledger"]["replica_bytes_estimate"], + 7 + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["market_policy"]["settlement"], + "not_configured" + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["market_policy"]["admission_policy"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["market_policy"]["settlement_policy"]["schema"], + CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["storage_settlement_policy"]["schema"], + CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["storage_settlement_policy"]["status"], + "settlement_not_configured" + ); + assert_eq!( + status["data"]["storage_settlement_policy"]["production_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["status"], + "production_storage_market_admission_not_configured" + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["production_market"]["configured"], + false + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["current_admission"] + ["signed_admission_receipts"], + 0 + ); + let principals = status["data"]["accounting"]["ledger"]["by_principal"] + .as_object() + .expect("ledger should group by principal"); + assert_eq!(principals.len(), 1); + let principal = principals.values().next().unwrap(); + assert_eq!(principal["active_objects"], 1); + assert_eq!(principal["content_bytes"], 7); + assert_eq!(status["data"]["abuse_controls"]["enforced"], 0); + assert_eq!(status["data"]["abuse_controls"]["throttled"], 0); + assert_eq!( + status["data"]["abuse_controls"]["by_policy"]["local_content_backend"], + 1 + ); + assert_eq!( + status["data"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["network_abuse_policy"]["local_guardrails"] + ["provider_invocation_required"], + true + ); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_dashboard"]["schema"], + CONTENT_OPERATOR_DASHBOARD_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["status"], + "accounting_observed" + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["content_bytes"], + 7 + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["settlement_policy"]["status"], + "settlement_not_configured" + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["market_admission_policy"] + ["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["quota_ledger_policy"] + ["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"] + ["top_principals_by_content_bytes"][0]["content_bytes"], + 7 + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["tracked_tasks"], + 1 + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["recent"][0]["status"], + "local_only" + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["external_repair_fleet_policy"] + ["schema"], + EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["production_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_dashboard"]["proof_summary"] + ["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["proof_summary"] + ["peer_attestation_exchange_policy"]["status"], + "attestation_exchange_not_configured" + ); + assert_eq!(status["data"]["repair"]["tracked_tasks"], 1); + assert_eq!(status["data"]["repair"]["by_status"]["local_only"], 1); + assert_eq!( + status["data"]["scheduler"]["manual_trigger"], + "elastos content repair-worker" + ); + assert_eq!( + status["data"]["scheduler"]["provider_invocation_required"], + true + ); + assert_eq!( + status["data"]["repair_fleet"]["schema"], + REPAIR_FLEET_SCHEMA + ); + assert_eq!( + status["data"]["repair_fleet"]["policy"], + "single_runtime_provider_repair_fleet" + ); + assert_eq!( + status["data"]["repair_fleet"]["coordinator"]["provider"], + "content-provider" + ); + assert_eq!( + status["data"]["repair_fleet"]["workers"][0]["runtime_invocation_required"], + true + ); + assert_eq!( + status["data"]["repair_fleet"]["task_pressure"]["tracked"], + 1 + ); + assert_eq!( + status["data"]["repair_fleet"]["production_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["external_repair_fleet_policy"]["schema"], + EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + false + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["schema"], + CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["status"], + "provider_local_dashboard_only" + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["local_signals"] + ["storage_pressure_status"], + "accounting_observed" + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["local_signals"]["content_bytes"], + 7 + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_dashboard"]["federated_operator_alerting_policy"]["schema"], + CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["federated_operator_alerting_policy"] + ["local_dashboard"]["schema"], + CONTENT_OPERATOR_DASHBOARD_SCHEMA + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["operator_alert_sink"] + ["configured"], + false + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["alert_delivery"], + false + ); } - async fn registry_with_content_and_ipfs() -> ( - tempfile::TempDir, - Arc, - Arc, - Arc, - ) { - let data_dir = tempfile::tempdir().unwrap(); - let registry = Arc::new(ProviderRegistry::new()); - let ipfs = Arc::new(MockIpfsProvider { - add_count: Mutex::new(0), - added_files: Mutex::new(Vec::new()), - added_directories: Mutex::new(Vec::new()), - cat_files: Mutex::new(HashMap::new()), - missing_paths: Mutex::new(Vec::new()), - pinned: Mutex::new(Vec::new()), - pin_error: Mutex::new(None), - unpinned: Mutex::new(Vec::new()), - }); - registry - .register_sub_provider("ipfs", ipfs.clone()) + #[tokio::test] + async fn content_status_can_emit_operator_alert_receipt_without_sink() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) .await .unwrap(); - let content = Arc::new(ContentProvider::new( - data_dir.path().to_path_buf(), - Arc::downgrade(®istry), - )); - registry.register(content.clone()).await; - registry - .register_sub_provider("content", content.clone()) + + let status = content + .send_raw(&json!({ + "op": "status", + "emit_operator_alert": true, + })) .await .unwrap(); - (data_dir, registry, ipfs, content) + + assert_eq!( + status["data"]["operator_alert_delivery"]["schema"], + CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["status"], + "not_configured" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["alert"]["schema"], + CONTENT_OPERATOR_ALERT_SCHEMA + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["alert"]["local_signals"] + ["storage_pressure_status"], + "accounting_observed" + ); + let outbox = std::fs::read_to_string(content.operator_alert_receipts_path()).unwrap(); + assert!(outbox.contains(CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA)); + assert!(outbox.contains(CONTENT_OPERATOR_ALERT_SCHEMA)); } #[tokio::test] - async fn content_publish_wraps_ipfs_with_availability_status() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + async fn content_status_delivers_operator_alert_to_configured_loopback_sink() { + let (url, handle) = spawn_operator_alert_sink(); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_alert_config(Some(json!({ + "url": url, + "authorization": "Bearer operator-alert-test", + "timeout_secs": 5, + }))) + .await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", @@ -1651,691 +11721,1342 @@ mod tests { .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["cid"], TEST_CID); - assert_eq!(response["data"]["uri"], format!("elastos://{TEST_CID}")); - assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + let status = content + .send_raw(&json!({ + "op": "status", + "emit_operator_alert": true, + })) + .await + .unwrap(); + assert_eq!( - response["data"]["receipt"]["payload"]["schema"], - AVAILABILITY_RECEIPT_SCHEMA + status["data"]["federated_operator_alerting_policy"]["status"], + "provider_local_alert_sink_configured" ); - assert_eq!(response["data"]["receipt"]["payload"]["cid"], TEST_CID); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "local_pinned" + status["data"]["federated_operator_alerting_policy"]["operator_alert_sink"] + ["configured"], + true ); - assert!(response["data"]["receipt"]["signature"] - .as_str() - .is_some_and(|sig| !sig.is_empty())); - assert!(response["data"]["receipt"]["signer_did"] - .as_str() - .is_some_and(|did| did.starts_with("did:key:z6Mk"))); - let signer_did = response["data"]["receipt"]["signer_did"] - .as_str() - .unwrap() - .to_string(); - let signed_receipt = serde_json::to_vec(&response["data"]["receipt"]).unwrap(); - crate::crypto::verify_signed_json_envelope_against_dids( - &signed_receipt, - AVAILABILITY_RECEIPT_DOMAIN, - &[signer_did], - ) - .unwrap(); - assert_eq!(*ipfs.add_count.lock().await, 1); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["operator_alert_sink"] + ["credential_exposed"], + false + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["alert_delivery"], + true + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["status"], + "delivered" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["http_status"], + 204 + ); + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /content-alerts HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer operator-alert-test"))); + assert!(request.contains(CONTENT_OPERATOR_ALERT_SCHEMA)); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("operator-alert-test")); } #[tokio::test] - async fn content_publish_uses_registered_availability_provider() { - let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let availability = Arc::new(MockAvailabilityProvider { - requests: Mutex::new(Vec::new()), - response: Mutex::new(provider_ok(json!({ - "availability": { - "status": "network_available", - "provider": "elacity-supernode", - "policy": "smartweb_default", - "replicas": 3 - } - }))), - }); - registry.register(availability.clone()).await; - - let response = content + async fn content_status_exchanges_operator_alert_with_configured_federated_endpoint() { + let (url, handle) = spawn_federated_operator_alert_exchange(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "operator-alert-exchange:test", + "receipt_id": "operator-alert-receipt:123", + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_federated_alert_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer federated-alert-test", + "timeout_secs": 5, + }))) + .await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], - "object_did": "did:key:z6Mkobject", - "publisher_did": "did:key:z6Mkpublisher", "pin": true, })) .await .unwrap(); - assert_eq!(response["status"], "ok"); + let status = content + .send_raw(&json!({ + "op": "status", + "emit_operator_alert": true, + })) + .await + .unwrap(); + assert_eq!( - response["data"]["availability"]["status"], - "network_available" + status["data"]["federated_operator_alerting_policy"]["status"], + "federated_alert_exchange_configured" ); assert_eq!( - response["data"]["availability"]["provider"], - "elacity-supernode" + status["data"]["federated_operator_alerting_policy"]["federated_alert_exchange"] + ["configured"], + true ); - assert_eq!(response["data"]["availability"]["replicas"], 3); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "network_available" + status["data"]["federated_operator_alerting_policy"]["federated_alert_exchange"] + ["credential_exposed"], + false ); assert_eq!( - response["data"]["receipt"]["payload"]["provider"], - "elacity-supernode" + status["data"]["federated_operator_alerting_policy"]["federation"]["configured"], + true ); assert_eq!( - response["data"]["receipt"]["payload"]["policy"], - "smartweb_default" + status["data"]["federated_operator_alerting_policy"]["federation"] + ["fleet_alert_exchange"], + true ); - - let requests = availability.requests.lock().await; - assert_eq!(requests.len(), 1); - assert_eq!(requests[0]["op"], "ensure"); - assert_eq!(requests[0]["cid"], TEST_CID); - assert_eq!(requests[0]["uri"], format!("elastos://{TEST_CID}")); - assert_eq!(requests[0]["local"]["status"], "local_pinned"); - assert_eq!(requests[0]["object_did"], "did:key:z6Mkobject"); - assert_eq!(requests[0]["publisher_did"], "did:key:z6Mkpublisher"); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["status"], + "not_configured" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["federated_exchange"]["schema"], + CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["federated_exchange"]["status"], + "accepted" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["federated_exchange"]["remote_exchange_id"], + "operator-alert-exchange:test" + ); + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /alerts/exchange HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer federated-alert-test"))); + assert!(request.contains(CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_REQUEST_SCHEMA)); + assert!(request.contains(CONTENT_OPERATOR_ALERT_SCHEMA)); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("federated-alert-test")); } #[tokio::test] - async fn content_publish_directory_injects_object_manifest() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + async fn content_storage_accounting_ledger_is_durable_per_principal() { + let (data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "document", "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, "object_did": "did:key:z6Mkobject", "publisher_did": "did:key:z6Mkpublisher", })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - let directories = ipfs.added_directories.lock().await; - let manifest_entry = directories[0] - .iter() - .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) - .expect("object manifest should be injected"); - let manifest_bytes = base64::engine::general_purpose::STANDARD - .decode(manifest_entry["data"].as_str().unwrap()) + let restarted_content = + ContentProvider::new(data_dir.path().to_path_buf(), Arc::downgrade(®istry)); + let status = restarted_content + .send_raw(&json!({ + "op": "status", + })) + .await .unwrap(); - let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); - - assert_eq!(manifest.schema, OBJECT_MANIFEST_SCHEMA); - assert_eq!(manifest.kind, "document"); - assert_eq!(manifest.object_did.as_deref(), Some("did:key:z6Mkobject")); - assert_eq!( - manifest.publisher_did.as_deref(), - Some("did:key:z6Mkpublisher") - ); - assert_eq!(manifest.files.len(), 1); - assert_eq!(manifest.files[0].path, "index.md"); - assert!(manifest.content_digest.starts_with("sha256:")); - } - - fn sealed_object_value() -> Value { - json!({ - "schema": "elastos.sealed.object/v1", - "payload_cid": TEST_CID, - "rights_policy_cid": TEST_CID, - "availability_receipt_cid": TEST_CID, - "key_envelope": { - "scheme": "elastos-pq-hybrid-threshold-v0", - "kid": "kid:test", - "wrapped_cek": "wrapped", - "policy_hash": "sha256:test", - "algorithms": { - "cipher": "aes-256-gcm", - "signature": ["ed25519", "ml-dsa-65"], - "kem": ["x25519", "ml-kem-768"], - "share_scheme": "shamir-t-of-n" - } - }, - "viewer": { - "required_interface": "elastos.viewer/document@1" - } - }) - } - - fn sealed_object_data() -> String { - base64::engine::general_purpose::STANDARD - .encode(serde_json::to_vec(&sealed_object_value()).unwrap()) - } - - fn sealed_object_data_from(value: &Value) -> String { - base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(value).unwrap()) - } - - fn sealed_object_links() -> Vec { - vec![ - json!({"rel": "availability.receipt", "cid": TEST_CID}), - json!({"rel": "payload", "cid": TEST_CID}), - json!({"rel": "provenance", "cid": TEST_CID}), - json!({"rel": "rights.policy", "cid": TEST_CID}), - ] - } + let principal = + &status["data"]["accounting"]["ledger"]["by_principal"]["did:key:z6Mkpublisher"]; + assert_eq!(principal["tracked_objects"], 1); + assert_eq!(principal["active_objects"], 1); + assert_eq!(principal["content_bytes"], 7); + assert_eq!(principal["replica_bytes_estimate"], 7); - #[tokio::test] - async fn content_publish_directory_accepts_linked_release_and_sealed_manifests() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "sealed", - "links": sealed_object_links(), - "files": [{"path": "sealed.json", "data": sealed_object_data()}], + "op": "unpublish", + "cid": TEST_CID, })) .await .unwrap(); - - assert_eq!(response["status"], "ok"); - let directories = ipfs.added_directories.lock().await; - let manifest_entry = directories[0] - .iter() - .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) - .expect("object manifest should be injected"); - let manifest_bytes = base64::engine::general_purpose::STANDARD - .decode(manifest_entry["data"].as_str().unwrap()) + let status = restarted_content + .send_raw(&json!({ + "op": "status", + })) + .await .unwrap(); - let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); - - assert_eq!(manifest.kind, "sealed"); - assert_eq!(manifest.links.len(), 4); - assert_eq!(manifest.links[0].rel, "availability.receipt"); - assert_eq!(manifest.links[0].cid, TEST_CID); - assert_eq!(manifest.links[1].rel, "payload"); - assert_eq!(manifest.links[1].cid, TEST_CID); - assert_eq!(manifest.links[2].rel, "provenance"); - assert_eq!(manifest.links[2].cid, TEST_CID); - assert_eq!(manifest.links[3].rel, "rights.policy"); - assert_eq!(manifest.links[3].cid, TEST_CID); - drop(directories); - - let release_response = content + let ledger = &status["data"]["accounting"]["ledger"]; + assert_eq!(ledger["tracked_objects"], 1); + assert_eq!(ledger["active_objects"], 0); + let principal = &ledger["by_principal"]["did:key:z6Mkpublisher"]; + assert_eq!(principal["tracked_objects"], 1); + assert_eq!(principal["active_objects"], 0); + assert_eq!(principal["by_status"]["local_unpinned"], 1); + + let object_status = restarted_content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "release", - "links": [{"rel": "sealed", "cid": TEST_CID}], - "files": [{"path": "release.json", "data": "e30="}], + "op": "status", + "cid": TEST_CID, })) .await .unwrap(); - assert_eq!(release_response["status"], "ok"); + assert_eq!( + object_status["data"]["availability"]["storage_accounting"]["principal_did"], + "did:key:z6Mkpublisher" + ); } #[tokio::test] - async fn content_publish_directory_rejects_incomplete_sealed_objects() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let missing_descriptor = content + async fn content_publish_enforces_principal_storage_quota() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let rejected = content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "sealed", - "links": sealed_object_links(), - "files": [{"path": "payload.bin", "data": "c2VhbGVkCg=="}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", + "availability_requirements": { + "max_storage_bytes_per_principal": 6 + } })) .await - .unwrap_err(); - assert!(missing_descriptor - .to_string() - .contains("sealed content object requires sealed.json")); + .unwrap(); - let missing_provenance = content - .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "sealed", - "links": [ - {"rel": "availability.receipt", "cid": TEST_CID}, - {"rel": "payload", "cid": TEST_CID}, - {"rel": "rights.policy", "cid": TEST_CID} - ], - "files": [{"path": "sealed.json", "data": sealed_object_data()}], - })) - .await - .unwrap_err(); - assert!(missing_provenance - .to_string() - .contains("sealed content object requires provenance link")); + assert_eq!(rejected["status"], "error"); + assert_eq!(rejected["code"], "storage_quota_exceeded"); + assert_eq!(*ipfs.add_count.lock().await, 0); - let mut weak_envelope = sealed_object_value(); - weak_envelope["key_envelope"]["algorithms"]["cipher"] = Value::String("aes-128-gcm".into()); - let weak_cipher = content + let accepted = content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "sealed", - "links": sealed_object_links(), - "files": [{"path": "sealed.json", "data": sealed_object_data_from(&weak_envelope)}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", + "availability_requirements": { + "max_storage_bytes_per_principal": 7 + } })) .await - .unwrap_err(); - assert!(weak_cipher - .to_string() - .contains("key_envelope.algorithms.cipher uses unsupported algorithm")); - } + .unwrap(); - #[tokio::test] - async fn content_publish_directory_sorts_entries_for_stable_cids() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + assert_eq!(accepted["status"], "ok"); + assert_eq!( + accepted["data"]["receipt"]["payload"]["accounting"]["storage_quota"]["policy"], + "principal_storage_quota" + ); + assert_eq!( + accepted["data"]["receipt"]["payload"]["accounting"]["storage_quota"]["status"], + "within_quota" + ); + + let status = content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "share", - "files": [ - {"path": "z.md", "data": "eg=="}, - {"path": "a.md", "data": "YQ=="} - ], + "op": "status", })) .await .unwrap(); - - assert_eq!(response["status"], "ok"); - let directories = ipfs.added_directories.lock().await; - let paths = directories[0] - .iter() - .map(|entry| entry["path"].as_str().unwrap().to_string()) - .collect::>(); - assert_eq!(paths, vec![OBJECT_MANIFEST_PATH, "a.md", "z.md"]); + assert_eq!(status["data"]["accounting"]["storage_quota_enforced"], 1); + assert_eq!(status["data"]["accounting"]["ledger"]["quota_enforced"], 1); + assert_eq!( + status["data"]["accounting"]["ledger"]["by_principal"]["did:key:z6Mkpublisher"] + ["quota_enforced"], + 1 + ); } #[tokio::test] - async fn content_publish_directory_rejects_ambiguous_object_shape() { + async fn content_admission_accepts_within_principal_quota() { let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let duplicate_path = content - .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "share", - "files": [ - {"path": "index.md", "data": "YQ=="}, - {"path": "index.md", "data": "Yg=="} - ], - })) - .await - .unwrap_err(); - assert!(duplicate_path - .to_string() - .contains("duplicate directory publish path")); - - let unknown_kind = content + content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "random", - "files": [{"path": "index.md", "data": "YQ=="}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", })) .await - .unwrap_err(); - assert!(unknown_kind - .to_string() - .contains("unsupported content object kind")); + .unwrap(); - let invalid_link = content + let admission = content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "release", - "links": [{"rel": "Bad Rel", "cid": TEST_CID}], - "files": [{"path": "release.json", "data": "e30="}], + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await - .unwrap_err(); - assert!(invalid_link.to_string().contains("content object link rel")); + .unwrap(); - let invalid_link_cid = content + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!( + admission["data"]["admission"]["quota"]["status"], + "within_quota" + ); + assert_eq!( + admission["data"]["admission"]["quota"]["active_content_bytes"], + 7 + ); + assert_eq!( + admission["data"]["admission"]["quota"]["projected_content_bytes"], + 10 + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); + } + + #[tokio::test] + async fn content_admission_rejects_quota_exceeded() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "release", - "links": [{"rel": "release", "cid": "not-a-cid"}], - "files": [{"path": "release.json", "data": "e30="}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", })) .await - .unwrap_err(); - assert!(invalid_link_cid - .to_string() - .contains("invalid content object link cid")); - } + .unwrap(); - #[tokio::test] - async fn content_unpublish_wraps_ipfs_unpin() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + let admission = content .send_raw(&json!({ - "op": "unpublish", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 4, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "local_unpinned" + admission["data"]["admission"]["quota"]["status"], + "quota_exceeded" ); assert_eq!( - ipfs.unpinned.lock().await.as_slice(), - [TEST_CID.to_string()] + admission["data"]["admission"]["quota"]["projected_content_bytes"], + 11 + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] ); } #[tokio::test] - async fn content_repair_pins_cid_and_records_receipt() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + async fn content_admission_records_configured_federated_quota_ledger_acceptance() { + let receipt = signed_federated_quota_ledger_exchange_receipt(true, None); + let (url, handle) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "quota-exchange:test", + "receipt_id": "quota-receipt:accepted", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer quota-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content .send_raw(&json!({ - "op": "repair", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); assert_eq!( - response["data"]["receipt"]["payload"]["policy"], - "local_repair_pin" + admission["data"]["admission"]["federated_quota_ledger_exchange"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!( + admission["data"]["admission"]["federated_quota_ledger_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_accepted" + ); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["federation"] + ["configured"], + true + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] ); - assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); - } - #[tokio::test] - async fn content_ensure_pins_cid_and_records_policy() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + let status = content .send_raw(&json!({ - "op": "ensure", - "cid": TEST_CID, + "op": "status", })) .await .unwrap(); - - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["availability"]["status"], "local_pinned"); assert_eq!( - response["data"]["receipt"]["payload"]["policy"], - "local_ensure_pin" + status["data"]["federated_quota_ledger_policy"]["federation"]["configured"], + true ); - assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["exchange_client"] + ["authorization_configured"], + true + ); + assert!(!status.to_string().contains("quota-test")); + + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /quota/exchange HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer quota-test"))); + assert!(request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); + let body = request.split("\r\n\r\n").nth(1).unwrap_or(""); + assert!(!body.contains("quota-test")); + let signed_request: Value = serde_json::from_str(body).unwrap(); + assert_eq!( + signed_request["payload"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA + ); + assert_eq!( + signed_request["payload"]["authority"]["credential_exposed"], + false + ); + let request_signer = signed_request["signer_did"].as_str().unwrap().to_string(); + let signed_request_bytes = serde_json::to_vec(&signed_request).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_request_bytes, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_DOMAIN, + &[request_signer], + ) + .unwrap(); } #[tokio::test] - async fn content_repair_records_repair_needed_when_pin_fails() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - *ipfs.pin_error.lock().await = Some("not available".to_string()); + async fn content_admission_accepts_configured_federated_quota_ledger_quorum() { + let receipt_a = signed_federated_quota_ledger_exchange_receipt(true, None); + let (url_a, handle_a) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "quota-exchange:a", + "receipt_id": "quota-receipt:a", + "receipt": receipt_a, + })); + let receipt_b = signed_federated_quota_ledger_exchange_receipt(true, None); + let (url_b, handle_b) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "quota-exchange:b", + "receipt_id": "quota-receipt:b", + "receipt": receipt_b, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "ledger-a", + "url": url_a, + "authorization": "Bearer quota-a", + "timeout_secs": 5 + }, + { + "id": "ledger-b", + "url": url_b, + "authorization": "Bearer quota-b", + "timeout_secs": 5 + } + ] + }))) + .await; - let response = content + let admission = content .send_raw(&json!({ - "op": "repair", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["availability"]["status"], "repair_needed"); - assert_eq!(response["data"]["availability"]["reason"], "not available"); + let exchange = &admission["data"]["admission"]["federated_quota_ledger_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!(exchange["accepted"], true); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["endpoint_count"], 2); + assert_eq!(exchange["quorum"]["accepted"], 2); + assert_eq!(exchange["signed_receipt"]["verified"], true); + assert_eq!(exchange["exchange"]["multi_endpoint"], true); + assert_eq!(exchange["exchange"]["endpoint_count"], 2); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "repair_needed" + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_accepted" + ); + + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["exchange_client"] + ["endpoint_count"], + 2 + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["exchange_client"] + ["quorum_required"], + 2 ); + assert!(!status.to_string().contains("quota-a")); + assert!(!status.to_string().contains("quota-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer quota-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer quota-b"))); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("quota-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("quota-b")); } #[tokio::test] - async fn content_publish_file_wraps_ipfs_bytes_with_receipt() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let cid = publish_bytes_via_provider( - ®istry, - "provenance.json", - br#"{"ok":true}"#, - Some("did:key:z6Mkobject"), - Some("did:key:z6Mkpublisher"), - ) - .await - .unwrap(); + async fn content_admission_rejects_when_configured_federated_quota_ledger_rejects() { + let receipt = + signed_federated_quota_ledger_exchange_receipt(false, Some("ledger exhausted")); + let (url, handle) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "ledger exhausted", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer quota-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) + .await + .unwrap(); - assert_eq!(cid, TEST_CID); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("ledger exhausted")); assert_eq!( - ipfs.added_files.lock().await.as_slice(), - ["provenance.json".to_string()] + admission["data"]["admission"]["federated_quota_ledger_exchange"]["status"], + "rejected" + ); + assert_eq!( + admission["data"]["admission"]["federated_quota_ledger_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_rejected" ); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); } #[tokio::test] - async fn content_fetch_wraps_ipfs_cat() { - let (_data_dir, registry, _ipfs, _content) = registry_with_content_and_ipfs().await; - let bytes = fetch_bytes_via_provider(®istry, TEST_CID, Some("capsule.json")) + async fn content_admission_rejects_configured_federated_quota_ledger_quorum_failure() { + let accepted_receipt = signed_federated_quota_ledger_exchange_receipt(true, None); + let (accepted_url, accepted_handle) = + spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "receipt": accepted_receipt, + })); + let rejected_receipt = + signed_federated_quota_ledger_exchange_receipt(false, Some("ledger exhausted")); + let (rejected_url, rejected_handle) = + spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "ledger exhausted", + "receipt": rejected_receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + {"id": "ledger-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "ledger-b", "url": rejected_url, "timeout_secs": 5} + ] + }))) + .await; + + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) .await .unwrap(); - assert_eq!(bytes, b"hello content"); + let exchange = &admission["data"]["admission"]["federated_quota_ledger_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("ledger exhausted")); + assert_eq!(exchange["accepted"], false); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["accepted"], 1); + assert_eq!(exchange["quorum"]["rejected"], 1); + assert_eq!(exchange["signed_receipt"]["verified"], true); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_rejected" + ); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); } #[tokio::test] - async fn content_prepare_data_capsule_materializes_verified_manifest_files() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let capsule_json = serde_json::json!({ - "schema": elastos_common::SCHEMA_V1, - "version": "0.1.0", - "name": "shared-doc", - "role": "content", - "type": "data", - "entrypoint": "index.html" - }); - let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); - let index_bytes = b"viewer".to_vec(); - let markdown_bytes = b"# Hello\n".to_vec(); - let object_manifest = ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind: "share".to_string(), - content_digest: "sha256:test".to_string(), - files: vec![ - object_file("capsule.json", &capsule_bytes), - object_file("docs/readme.md", &markdown_bytes), - object_file("index.html", &index_bytes), - ], - links: Vec::new(), - object_did: None, - publisher_did: None, - }; - let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + async fn content_admission_records_configured_federated_abuse_control_acceptance() { + let receipt = signed_federated_abuse_control_exchange_receipt(true, None); + let (url, handle) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "abuse-control-exchange:test", + "receipt_id": "abuse-control-receipt:accepted", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer abuse-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) + .await + .unwrap(); - { - let mut cat_files = ipfs.cat_files.lock().await; - cat_files.insert("capsule.json".to_string(), capsule_bytes); - cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); - cat_files.insert("index.html".to_string(), index_bytes.clone()); - cat_files.insert("docs/readme.md".to_string(), markdown_bytes.clone()); - } + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["schema"], + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["signed_receipt"] + ["abuse_ledger_id"], + "abuse-ledger:test" + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); - let capsule_dir = prepare_capsule_from_content_provider(®istry, TEST_CID) + let status = content + .send_raw(&json!({ + "op": "status", + })) .await .unwrap(); assert_eq!( - std::fs::read(capsule_dir.join("index.html")).unwrap(), - index_bytes + status["data"]["network_abuse_policy"]["status"], + "configured_federated_abuse_control_exchange" ); assert_eq!( - std::fs::read(capsule_dir.join("docs/readme.md")).unwrap(), - markdown_bytes + status["data"]["network_abuse_policy"]["network_federation"]["configured"], + true ); - assert!(capsule_dir.join(OBJECT_MANIFEST_PATH).is_file()); - std::fs::remove_dir_all(capsule_dir).unwrap(); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["exchange_client"] + ["authorization_configured"], + true + ); + assert!(!status.to_string().contains("abuse-test")); + + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /abuse/exchange HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer abuse-test"))); + assert!(request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); + let body = request.split("\r\n\r\n").nth(1).unwrap_or(""); + assert!(!body.contains("abuse-test")); + let signed_request: Value = serde_json::from_str(body).unwrap(); + assert_eq!( + signed_request["payload"]["schema"], + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA + ); + assert_eq!( + signed_request["payload"]["authority"]["credential_exposed"], + false + ); + assert_eq!( + signed_request["payload"]["authority"]["raw_peer_authority"], + false + ); + let request_signer = signed_request["signer_did"].as_str().unwrap().to_string(); + let signed_request_bytes = serde_json::to_vec(&signed_request).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_request_bytes, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_DOMAIN, + &[request_signer], + ) + .unwrap(); } #[tokio::test] - async fn content_prepare_data_capsule_rejects_object_hash_mismatch() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let capsule_json = serde_json::json!({ - "schema": elastos_common::SCHEMA_V1, - "version": "0.1.0", - "name": "shared-doc", - "role": "content", - "type": "data", - "entrypoint": "index.html" - }); - let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); - let original_index = b"viewer".to_vec(); - let tampered_index = b"viewed".to_vec(); - let object_manifest = ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind: "share".to_string(), - content_digest: "sha256:test".to_string(), - files: vec![ - object_file("capsule.json", &capsule_bytes), - object_file("index.html", &original_index), - ], - links: Vec::new(), - object_did: None, - publisher_did: None, - }; - let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); - - { - let mut cat_files = ipfs.cat_files.lock().await; - cat_files.insert("capsule.json".to_string(), capsule_bytes); - cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); - cat_files.insert("index.html".to_string(), tampered_index); - } + async fn content_admission_accepts_configured_federated_abuse_control_quorum() { + let receipt_a = signed_federated_abuse_control_exchange_receipt(true, None); + let (url_a, handle_a) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "abuse-control-exchange:a", + "receipt_id": "abuse-control-receipt:a", + "receipt": receipt_a, + })); + let receipt_b = signed_federated_abuse_control_exchange_receipt(true, None); + let (url_b, handle_b) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "abuse-control-exchange:b", + "receipt_id": "abuse-control-receipt:b", + "receipt": receipt_b, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "abuse-a", + "url": url_a, + "authorization": "Bearer abuse-secret-a", + "timeout_secs": 5 + }, + { + "id": "abuse-b", + "url": url_b, + "authorization": "Bearer abuse-secret-b", + "timeout_secs": 5 + } + ] + }))) + .await; - let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) .await - .unwrap_err(); - assert!(err - .to_string() - .contains("content object file hash mismatch")); - } - - #[tokio::test] - async fn content_prepare_capsule_rejects_release_object_as_not_launchable() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let release_bytes = br#"{"payload":{},"signature":"00","signer_did":"did:key:z6Mk"}"#; - let object_manifest = ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind: "release".to_string(), - content_digest: "sha256:test".to_string(), - files: vec![object_file("release.json", release_bytes)], - links: Vec::new(), - object_did: Some("elastos://release/stable/0.2.0".to_string()), - publisher_did: Some("did:key:z6Mkpublisher".to_string()), - }; - let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + .unwrap(); - { - let mut cat_files = ipfs.cat_files.lock().await; - cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); - } - ipfs.missing_paths - .lock() - .await - .push("capsule.json".to_string()); + let exchange = &admission["data"]["admission"]["federated_abuse_control_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!(exchange["accepted"], true); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["endpoint_count"], 2); + assert_eq!(exchange["quorum"]["accepted"], 2); + assert_eq!(exchange["signed_receipt"]["verified"], true); + assert_eq!(exchange["exchange"]["multi_endpoint"], true); + assert_eq!(exchange["exchange"]["endpoint_count"], 2); - let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + let status = content + .send_raw(&json!({ + "op": "status", + })) .await - .unwrap_err(); - assert!(err.to_string().contains("kind 'release'")); - assert!(err.to_string().contains("not a launchable capsule")); + .unwrap(); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["exchange_client"] + ["endpoint_count"], + 2 + ); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["exchange_client"] + ["quorum_required"], + 2 + ); + assert!(!status.to_string().contains("abuse-secret-a")); + assert!(!status.to_string().contains("abuse-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer abuse-secret-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer abuse-secret-b"))); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("abuse-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("abuse-secret-b")); } #[tokio::test] - async fn content_fetch_rejects_invalid_cid_and_path() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let invalid_cid = content + async fn content_admission_rejects_when_configured_federated_abuse_control_rejects() { + let receipt = signed_federated_abuse_control_exchange_receipt( + false, + Some("abuse threshold exceeded"), + ); + let (url, handle) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "abuse threshold exceeded", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer abuse-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content .send_raw(&json!({ - "op": "fetch", - "cid": "not-a-cid", + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(invalid_cid["status"], "error"); - assert_eq!(invalid_cid["code"], "invalid_cid"); - let invalid_path = content + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("abuse threshold exceeded")); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["status"], + "rejected" + ); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); + } + + #[tokio::test] + async fn content_admission_rejects_configured_federated_abuse_control_quorum_failure() { + let accepted_receipt = signed_federated_abuse_control_exchange_receipt(true, None); + let (accepted_url, accepted_handle) = + spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "receipt": accepted_receipt, + })); + let rejected_receipt = signed_federated_abuse_control_exchange_receipt( + false, + Some("abuse threshold exceeded"), + ); + let (rejected_url, rejected_handle) = + spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "abuse threshold exceeded", + "receipt": rejected_receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + {"id": "abuse-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "abuse-b", "url": rejected_url, "timeout_secs": 5} + ] + }))) + .await; + + let admission = content .send_raw(&json!({ - "op": "fetch", - "cid": TEST_CID, - "path": "../secret", + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(invalid_path["status"], "error"); - assert_eq!(invalid_path["code"], "invalid_path"); + + let exchange = &admission["data"]["admission"]["federated_abuse_control_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("abuse threshold exceeded")); + assert_eq!(exchange["accepted"], false); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["accepted"], 1); + assert_eq!(exchange["quorum"]["rejected"], 1); + assert_eq!(exchange["signed_receipt"]["verified"], true); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); } #[tokio::test] - async fn content_status_rejects_invalid_cid() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let invalid_cid = content + async fn content_admission_records_configured_storage_market_acceptance() { + let (url, handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:test", + "offer_id": "offer:123", + "receipt": { + "schema": "elastos.test.storage-market.offer/v1", + "offer_id": "offer:123" + } + })); + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs_with_configs( + None, + Some(json!({ + "url": url, + "authorization": "Bearer market-test", + "timeout_secs": 5, + })), + None, + None, + ) + .await; + + let admission = content .send_raw(&json!({ - "op": "status", - "cid": "not-a-cid", + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(invalid_cid["status"], "error"); - assert_eq!(invalid_cid["code"], "invalid_cid"); - } + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA + ); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["accepted"], + true + ); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["offer_id"], + "offer:123" + ); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["client"] + ["credential_exposed"], + false + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); - fn object_file(path: &str, bytes: &[u8]) -> ContentObjectFile { - ContentObjectFile { - path: path.to_string(), - sha256: format!("{:x}", sha2::Sha256::digest(bytes)), - size: bytes.len() as u64, - } + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + assert_eq!( + status["data"]["storage_market_admission_policy"]["production_market"]["configured"], + true + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["external_admission_client"] + ["authorization_configured"], + true + ); + assert!(!status.to_string().contains("market-test")); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer market-test"))); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("market-test")); } #[tokio::test] - async fn content_status_reads_latest_availability_receipt() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - content - .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], - "pin": true, - "object_did": "did:key:z6Mkobject", - "publisher_did": "did:key:z6Mkpublisher", + async fn content_storage_market_admission_accepts_endpoint_quorum() { + let (url_a, handle_a) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:a", + "offer_id": "offer:a", + "receipt": { + "schema": "elastos.test.storage-market.offer/v1", + "offer_id": "offer:a" + } + })); + let (url_b, handle_b) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:b", + "offer_id": "offer:b", + "receipt": { + "schema": "elastos.test.storage-market.offer/v1", + "offer_id": "offer:b" + } + })); + let client = ContentStorageMarketAdmissionClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "market-a", + "url": url_a, + "authorization": "Bearer market-secret-a", + "timeout_secs": 5 + }, + { + "id": "market-b", + "url": url_b, + "authorization": "Bearer market-secret-b", + "timeout_secs": 5 + } + ] + })) + .unwrap(); + + let decision = client + .decide(&json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA, + "cid": TEST_CID, + "estimated_content_bytes": 22, })) .await .unwrap(); - content - .send_raw(&json!({ - "op": "unpublish", + + assert_eq!( + decision["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA + ); + assert_eq!(decision["accepted"], true); + assert_eq!(decision["status"], "accepted"); + assert_eq!(decision["offer_id"], "offer:a"); + assert_eq!(decision["quorum"]["required"], 2); + assert_eq!(decision["quorum"]["endpoint_count"], 2); + assert_eq!(decision["quorum"]["accepted"], 2); + assert_eq!(decision["client"]["multi_endpoint"], true); + assert_eq!(decision["client"]["endpoint_count"], 2); + assert!(!decision.to_string().contains("market-secret-a")); + assert!(!decision.to_string().contains("market-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer market-secret-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer market-secret-b"))); + assert!(request_a.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(request_b.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("market-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("market-secret-b")); + } + + #[tokio::test] + async fn content_storage_market_admission_rejects_endpoint_quorum_failure() { + let (accepted_url, accepted_handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:a", + "offer_id": "offer:a", + })); + let (rejected_url, rejected_handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "market capacity exhausted", + })); + let client = ContentStorageMarketAdmissionClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + {"id": "market-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "market-b", "url": rejected_url, "timeout_secs": 5} + ] + })) + .unwrap(); + + let decision = client + .decide(&json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA, "cid": TEST_CID, + "estimated_content_bytes": 22, })) .await .unwrap(); - let status = content + assert_eq!(decision["accepted"], false); + assert_eq!(decision["status"], "rejected"); + assert_eq!(decision["quorum"]["required"], 2); + assert_eq!(decision["quorum"]["accepted"], 1); + assert_eq!(decision["quorum"]["rejected"], 1); + assert!(decision["reason"] + .as_str() + .unwrap() + .contains("market capacity exhausted")); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + } + + #[tokio::test] + async fn content_admission_rejects_when_configured_storage_market_rejects() { + let (url, handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "capacity exhausted" + })); + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs_with_configs( + None, + Some(json!({ + "url": url, + "authorization": "Bearer market-test", + "timeout_secs": 5, + })), + None, + None, + ) + .await; + + let admission = content .send_raw(&json!({ - "op": "status", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(status["status"], "ok"); - assert_eq!(status["data"]["cid"], TEST_CID); - assert_eq!(status["data"]["availability"]["status"], "local_unpinned"); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("capacity exhausted")); assert_eq!( - status["data"]["receipt"]["payload"]["schema"], - AVAILABILITY_RECEIPT_SCHEMA + admission["data"]["admission"]["storage_market_admission"]["status"], + "rejected" + ); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] ); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); } } diff --git a/elastos/crates/elastos-server/src/content_cmd.rs b/elastos/crates/elastos-server/src/content_cmd.rs index 590a2e72..9d3243bf 100644 --- a/elastos/crates/elastos-server/src/content_cmd.rs +++ b/elastos/crates/elastos-server/src/content_cmd.rs @@ -2,6 +2,10 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use clap::Subcommand; +use elastos_runtime::provider::{ + ProviderInvocation, ProviderInvocationTransport, ProviderTransfer, +}; +use serde_json::{json, Value}; #[derive(Subcommand)] pub enum ContentCommand { @@ -31,6 +35,38 @@ pub enum ContentCommand { #[arg(long = "link")] links: Vec, }, + + /// Run the Runtime-provider-only content repair worker + #[command(name = "repair-worker")] + RepairWorker { + /// Ignore next-check timers and retry eligible tasks immediately + #[arg(long)] + force: bool, + + /// Also health-check currently healthy tasks + #[arg(long)] + include_healthy_check: bool, + + /// Maximum tasks to examine in one run + #[arg(long)] + limit: Option, + + /// Maximum retry attempts per CID before this run skips it + #[arg(long)] + max_attempts: Option, + + /// Maximum failed repairs allowed before this run throttles remaining tasks + #[arg(long)] + failure_budget: Option, + }, + + /// Print content availability, storage-accounting, and repair status + #[command(name = "status")] + Status { + /// Optional CID; omit for the provider-wide availability dashboard + #[arg(long)] + cid: Option, + }, } pub async fn run(cmd: ContentCommand) -> anyhow::Result<()> { @@ -82,10 +118,109 @@ pub async fn run(cmd: ContentCommand) -> anyhow::Result<()> { }; println!("{cid}"); } + ContentCommand::RepairWorker { + force, + include_healthy_check, + limit, + max_attempts, + failure_budget, + } => { + let registry = crate::get_content_registry().await?; + let response = run_repair_worker_via_provider( + ®istry, + repair_worker_request( + force, + include_healthy_check, + limit, + max_attempts, + failure_budget, + ), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + ContentCommand::Status { cid } => { + let registry = crate::get_content_registry().await?; + let response = + run_status_via_provider(®istry, status_request(cid.as_deref())).await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } } Ok(()) } +async fn run_status_via_provider( + registry: &elastos_runtime::provider::ProviderRegistry, + request: Value, +) -> anyhow::Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "status".to_string(), + request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("content status failed: {err}")) +} + +async fn run_repair_worker_via_provider( + registry: &elastos_runtime::provider::ProviderRegistry, + request: Value, +) -> anyhow::Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "repair_worker".to_string(), + request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("content repair-worker failed: {err}")) +} + +fn repair_worker_request( + force: bool, + include_healthy_check: bool, + limit: Option, + max_attempts: Option, + failure_budget: Option, +) -> Value { + let mut request = json!({ + "op": "repair_worker", + "force": force, + "include_healthy_check": include_healthy_check, + }); + if let Some(limit) = limit { + request["limit"] = Value::from(limit as u64); + } + if let Some(max_attempts) = max_attempts { + request["max_attempts"] = Value::from(u64::from(max_attempts)); + } + if let Some(failure_budget) = failure_budget { + request["failure_budget"] = Value::from(u64::from(failure_budget)); + } + request +} + +fn status_request(cid: Option<&str>) -> Value { + let mut request = json!({ + "op": "status", + }); + if let Some(cid) = cid.filter(|value| !value.trim().is_empty()) { + request["cid"] = Value::String(cid.to_string()); + } + request +} + async fn publish_object_dir( registry: &elastos_runtime::provider::ProviderRegistry, path: &Path, @@ -207,4 +342,27 @@ mod tests { assert!(validate_entry_name("../release.json").is_err()); assert!(validate_entry_name("nested/release.json").is_err()); } + + #[test] + fn content_command_builds_repair_worker_request() { + let request = repair_worker_request(true, true, Some(5), Some(2), Some(1)); + + assert_eq!(request["op"], "repair_worker"); + assert_eq!(request["force"], true); + assert_eq!(request["include_healthy_check"], true); + assert_eq!(request["limit"], 5); + assert_eq!(request["max_attempts"], 2); + assert_eq!(request["failure_budget"], 1); + } + + #[test] + fn content_command_builds_status_request() { + let dashboard = status_request(None); + assert_eq!(dashboard["op"], "status"); + assert!(dashboard.get("cid").is_none()); + + let object = status_request(Some(TEST_CID)); + assert_eq!(object["op"], "status"); + assert_eq!(object["cid"], TEST_CID); + } } diff --git a/elastos/crates/elastos-server/src/publish.rs b/elastos/crates/elastos-server/src/publish.rs index 1865ac20..a1d653cd 100644 --- a/elastos/crates/elastos-server/src/publish.rs +++ b/elastos/crates/elastos-server/src/publish.rs @@ -679,6 +679,7 @@ fn publish_profile_capsules(profile: &str, available: &[String]) -> anyhow::Resu "drm-provider".to_string(), "ipfs-provider".to_string(), "key-provider".to_string(), + "object-provider".to_string(), "localhost-provider".to_string(), "rights-provider".to_string(), "tunnel-provider".to_string(), @@ -1584,6 +1585,7 @@ mod tests { assert!(selected.contains(&"chain-provider".to_string())); assert!(selected.contains(&"wallet-provider".to_string())); + assert!(selected.contains(&"object-provider".to_string())); assert!(selected.contains(&"drm-provider".to_string())); assert!(selected.contains(&"rights-provider".to_string())); assert!(selected.contains(&"key-provider".to_string())); From 064a98b69a0fbbaf269993a21ccc5315b9534fac Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:45:06 +0000 Subject: [PATCH 05/18] feat(webspace): add Spaces federation adapters Add the WebSpace provider and operator drive adapter surfaces for mounted Spaces, resolver status, byte sync/traversal receipts, remote authority hints, and local Runtime command support. This establishes the Spaces/WebSpace foundation without claiming production storage-market federation or raw host filesystem exposure. --- capsules/operator-drive-adapter/Cargo.lock | 458 ++ capsules/operator-drive-adapter/Cargo.toml | 24 + capsules/operator-drive-adapter/capsule.json | 32 + capsules/operator-drive-adapter/src/main.rs | 1734 ++++++ capsules/webspace-provider/src/main.rs | 4724 ++++++++++++++++- .../crates/elastos-server/src/webspace_cmd.rs | 997 +++- 6 files changed, 7839 insertions(+), 130 deletions(-) create mode 100644 capsules/operator-drive-adapter/Cargo.lock create mode 100644 capsules/operator-drive-adapter/Cargo.toml create mode 100644 capsules/operator-drive-adapter/capsule.json create mode 100644 capsules/operator-drive-adapter/src/main.rs diff --git a/capsules/operator-drive-adapter/Cargo.lock b/capsules/operator-drive-adapter/Cargo.lock new file mode 100644 index 00000000..ce2eda7e --- /dev/null +++ b/capsules/operator-drive-adapter/Cargo.lock @@ -0,0 +1,458 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "operator-drive-adapter" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/capsules/operator-drive-adapter/Cargo.toml b/capsules/operator-drive-adapter/Cargo.toml new file mode 100644 index 00000000..ccb8dec8 --- /dev/null +++ b/capsules/operator-drive-adapter/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "operator-drive-adapter" +version = "0.1.0" +edition = "2021" +description = "ElastOS operator WebSpace resolver adapter capsule" +license = "MIT" + +[[bin]] +name = "operator-drive-adapter" +path = "src/main.rs" + +[dependencies] +base64 = "0.22" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +opt-level = "s" +lto = true + +[workspace] diff --git a/capsules/operator-drive-adapter/capsule.json b/capsules/operator-drive-adapter/capsule.json new file mode 100644 index 00000000..133e1238 --- /dev/null +++ b/capsules/operator-drive-adapter/capsule.json @@ -0,0 +1,32 @@ +{ + "schema": "elastos.capsule/v1", + "name": "operator-drive-adapter", + "version": "0.1.0", + "description": "Operator WebSpace resolver adapter for Runtime-mediated metadata, byte read, and mutable write-back tests", + "author": "elastos", + "role": "provider", + "type": "microvm", + "entrypoint": "rootfs.ext4", + "provides": "operator-drive-adapter://*", + "authority": { + "reason": "Attaches an operator-managed resolver namespace to localhost://WebSpaces without exposing resolver credentials, host paths, or raw backend APIs to app capsules.", + "capabilities": [ + { + "resource": "operator-drive-adapter://*", + "actions": ["read", "write"], + "operations": ["status", "metadata_index", "read_bytes", "write_bytes"] + } + ], + "audit_events": [ + "operator_drive_adapter.status", + "operator_drive_adapter.metadata_index", + "operator_drive_adapter.read_bytes", + "operator_drive_adapter.write_bytes" + ] + }, + "resources": { + "memory_mb": 64, + "gpu": false + }, + "permissions": {} +} diff --git a/capsules/operator-drive-adapter/src/main.rs b/capsules/operator-drive-adapter/src/main.rs new file mode 100644 index 00000000..18337588 --- /dev/null +++ b/capsules/operator-drive-adapter/src/main.rs @@ -0,0 +1,1734 @@ +//! ElastOS Operator Drive Adapter Capsule +//! +//! This is a real WebSpace resolver adapter package, not a Library UI helper. +//! It only accepts metadata/read/write calls when Runtime injects an explicit +//! provider invocation envelope, and it stores bytes in a provider-owned local +//! fixture namespace for deterministic development and release tests. + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; +use std::io::{self, BufRead, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const PROVIDER_VERSION: &str = match option_env!("ELASTOS_RELEASE_VERSION") { + Some(version) => version, + None => concat!(env!("CARGO_PKG_VERSION"), "-dev"), +}; +const ADAPTER_SCHEMA: &str = "elastos.webspace.operator-drive-adapter/v1"; +const TARGET_PREFIX: &str = "operator://drive"; +const PROVIDER_NAME: &str = "operator-drive-adapter"; +const RESOLVER_NAME: &str = "operator-drive"; +const SEED_BRIEF_TARGET: &str = "operator://drive/Projects/Brief.md"; +const SEED_BRIEF_BYTES: &[u8] = b"# Operator Brief\n\nAdapter-backed bytes.\n"; +const ENDPOINT_REQUEST_SCHEMA: &str = "elastos.webspace.operator-endpoint.request/v1"; +const DEFAULT_ENDPOINT_TIMEOUT_MS: u64 = 5_000; + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Init { + #[serde(default)] + config: ProviderConfig, + }, + Status, + MetadataIndex { + #[serde(default)] + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + #[serde(default, rename = "_runtime_invocation")] + runtime_invocation: Option, + }, + ReadBytes { + #[serde(default)] + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + #[serde(default, rename = "_runtime_invocation")] + runtime_invocation: Option, + }, + WriteBytes { + #[serde(default)] + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + data: String, + #[serde(default)] + if_head: Option, + #[serde(default, rename = "_runtime_invocation")] + runtime_invocation: Option, + }, + Shutdown, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ProviderConfig { + base_path: String, + extra: Value, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum Response { + Ok { + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + }, + Error { + code: String, + message: String, + }, +} + +impl Response { + fn ok(data: Value) -> Self { + Self::Ok { data: Some(data) } + } + + fn empty_ok() -> Self { + Self::Ok { data: None } + } + + fn error(code: &str, message: impl Into) -> Self { + Self::Error { + code: code.to_string(), + message: message.into(), + } + } +} + +#[derive(Debug)] +struct OperatorDriveAdapter { + root: PathBuf, + endpoint: Option, +} + +impl Default for OperatorDriveAdapter { + fn default() -> Self { + Self { + root: std::env::temp_dir().join("elastos-operator-drive-adapter"), + endpoint: None, + } + } +} + +#[derive(Debug, Clone)] +struct OperatorEndpoint { + host: String, + port: u16, + path: String, + authorization: Option, + timeout: Duration, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OperatorEndpointConfig { + url: String, + #[serde(default)] + authorization: Option, + #[serde(default)] + timeout_ms: Option, +} + +#[derive(Debug)] +struct EndpointFailure { + code: String, + message: String, +} + +impl EndpointFailure { + fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } +} + +impl OperatorDriveAdapter { + fn handle(&mut self, request: Request) -> Response { + match request { + Request::Init { config } => self.init(config), + Request::Status => self.status(), + Request::MetadataIndex { + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + } => self.metadata_index( + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + ), + Request::ReadBytes { + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + } => self.read_bytes( + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + ), + Request::WriteBytes { + schema, + mount, + resolver, + handle_uri, + target_uri, + data, + if_head, + runtime_invocation, + } => self.write_bytes( + schema, + mount, + resolver, + handle_uri, + target_uri, + data, + if_head, + runtime_invocation, + ), + Request::Shutdown => Response::empty_ok(), + } + } + + fn init(&mut self, config: ProviderConfig) -> Response { + let root = config + .extra + .get("operator_drive_root") + .and_then(Value::as_str) + .map(PathBuf::from) + .or_else(|| { + if config.base_path.trim().is_empty() { + None + } else { + Some(PathBuf::from(config.base_path).join("operator-drive-adapter")) + } + }) + .unwrap_or_else(|| self.root.clone()); + self.root = root; + self.endpoint = match operator_endpoint_from_extra(&config.extra) { + Ok(endpoint) => endpoint, + Err(err) => return Response::error("invalid_endpoint_config", err), + }; + if let Err(err) = fs::create_dir_all(self.object_root()) { + return Response::error( + "init_failed", + format!("cannot initialize adapter store: {err}"), + ); + } + Response::ok(json!({ + "schema": ADAPTER_SCHEMA, + "provider": PROVIDER_NAME, + "resolver": RESOLVER_NAME, + "version": PROVIDER_VERSION, + "configured": true, + "endpoint": endpoint_summary(self.endpoint.as_ref()), + "capabilities": ["metadata_index", "read_bytes", "write_bytes"], + "authority_boundary": "Runtime provider invocation only; no app-visible resolver credentials or host paths" + })) + } + + fn status(&self) -> Response { + Response::ok(json!({ + "schema": ADAPTER_SCHEMA, + "provider": PROVIDER_NAME, + "resolver": RESOLVER_NAME, + "version": PROVIDER_VERSION, + "configured": true, + "state": "connected", + "endpoint": endpoint_summary(self.endpoint.as_ref()), + "capabilities": ["metadata_index", "read_bytes", "write_bytes"], + "target_prefix": TARGET_PREFIX, + "blocked_authority": [ + "resolver_credentials", + "host_paths", + "raw_backend_sdk", + "carrier_tickets", + "kubo_ipfs_handles" + ], + "contract": { + "schema": "elastos.webspace.adapter/v1", + "operations": ["metadata_index", "read_bytes", "write_bytes"], + "requires_runtime_invocation": true, + "credential_policy": "provider-owned; never serialized to apps" + } + })) + } + + fn metadata_index( + &self, + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + runtime_invocation: Option, + ) -> Response { + if let Err(err) = validate_adapter_request( + schema.as_deref(), + &mount, + &resolver, + &handle_uri, + &target_uri, + runtime_invocation.as_ref(), + "metadata_index", + ) { + return Response::error("invalid_request", err); + } + let parts = match target_parts(&target_uri) { + Ok(parts) => parts, + Err(err) => return Response::error("invalid_target", err), + }; + if let Some(endpoint) = &self.endpoint { + return self.metadata_index_via_endpoint(endpoint, mount, handle_uri, target_uri); + } + let mut entries = Vec::new(); + if parts.is_empty() { + entries.push(index_entry( + "Projects", + "directory", + "operator://drive/Projects", + true, + )); + entries.push(index_entry( + "Projects/Brief.md", + "file", + SEED_BRIEF_TARGET, + true, + )); + entries.push(index_entry( + "Writable", + "directory", + "operator://drive/Writable", + false, + )); + } else if parts == ["Projects"] { + entries.push(index_entry("Brief.md", "file", SEED_BRIEF_TARGET, true)); + } + if let Err(err) = self.collect_stored_entries(&parts, &mut entries) { + return Response::error("index_failed", err); + } + entries.sort_by(|left, right| { + left.get("path") + .and_then(Value::as_str) + .cmp(&right.get("path").and_then(Value::as_str)) + }); + entries.dedup_by(|left, right| left.get("path") == right.get("path")); + Response::ok(json!({ + "schema": "elastos.webspace.adapter.metadata-index/v1", + "resolver": RESOLVER_NAME, + "mount": mount, + "handle_uri": handle_uri, + "target_uri": target_uri, + "entries": entries, + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "entry_count": entries.len(), + "credential_exposed": false + } + })) + } + + fn read_bytes( + &self, + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + runtime_invocation: Option, + ) -> Response { + if let Err(err) = validate_adapter_request( + schema.as_deref(), + &mount, + &resolver, + &handle_uri, + &target_uri, + runtime_invocation.as_ref(), + "read_bytes", + ) { + return Response::error("invalid_request", err); + } + if let Err(err) = target_parts(&target_uri) { + return Response::error("invalid_target", err); + } + if let Some(endpoint) = &self.endpoint { + return self.read_bytes_via_endpoint(endpoint, mount, handle_uri, target_uri); + } + match self.read_target_bytes(&target_uri) { + Ok(bytes) => Response::ok(json!({ + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "mime": mime_for_target(&target_uri), + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes": bytes.len(), + "credential_exposed": false + } + })), + Err(err) => Response::error("read_failed", err), + } + } + + #[allow(clippy::too_many_arguments)] + fn write_bytes( + &self, + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + data: String, + if_head: Option, + runtime_invocation: Option, + ) -> Response { + if let Err(err) = validate_adapter_request( + schema.as_deref(), + &mount, + &resolver, + &handle_uri, + &target_uri, + runtime_invocation.as_ref(), + "write_bytes", + ) { + return Response::error("invalid_request", err); + } + if !target_uri.starts_with("operator://drive/Writable/") { + return Response::error( + "readonly", + "operator adapter writes are limited to operator://drive/Writable/*", + ); + } + if target_uri.contains("/Conflict/") || if_head.as_deref() == Some("head:conflict") { + return Response::error( + "conflict", + "operator adapter rejected stale mutable fork write", + ); + } + let bytes = match base64::engine::general_purpose::STANDARD.decode(data.trim()) { + Ok(bytes) => bytes, + Err(err) => { + return Response::error("invalid_data", format!("data must be base64: {err}")) + } + }; + if let Some(endpoint) = &self.endpoint { + return self.write_bytes_via_endpoint( + endpoint, + mount, + handle_uri, + target_uri, + data, + if_head, + bytes.len(), + ); + } + let path = match self.target_path(&target_uri) { + Ok(path) => path, + Err(err) => return Response::error("invalid_target", err), + }; + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + return Response::error( + "write_failed", + format!("cannot create parent directory: {err}"), + ); + } + } + if let Err(err) = fs::write(&path, &bytes) { + return Response::error( + "write_failed", + format!("cannot write adapter object: {err}"), + ); + } + Response::ok(json!({ + "schema": "elastos.webspace.adapter.write-bytes/v1", + "receipt": { + "schema": "elastos.webspace.adapter.write-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes_accepted": bytes.len(), + "credential_exposed": false + } + })) + } + + fn metadata_index_via_endpoint( + &self, + endpoint: &OperatorEndpoint, + mount: String, + handle_uri: String, + target_uri: String, + ) -> Response { + let remote = match post_operator_endpoint( + endpoint, + "metadata_index", + json!({ + "mount": mount, + "resolver": RESOLVER_NAME, + "handle_uri": handle_uri, + "target_uri": target_uri + }), + ) { + Ok(data) => data, + Err(err) => return Response::error(&err.code, err.message), + }; + let entries = match sanitize_endpoint_entries(&remote) { + Ok(entries) => entries, + Err(err) => return Response::error("invalid_endpoint_response", err), + }; + Response::ok(json!({ + "schema": "elastos.webspace.adapter.metadata-index/v1", + "resolver": RESOLVER_NAME, + "mount": mount, + "handle_uri": handle_uri, + "target_uri": target_uri, + "entries": entries, + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "entry_count": entries.len(), + "federation_backend": "operator_private_http", + "credential_exposed": false, + "endpoint_authority_exposed": false + } + })) + } + + fn read_bytes_via_endpoint( + &self, + endpoint: &OperatorEndpoint, + mount: String, + handle_uri: String, + target_uri: String, + ) -> Response { + let remote = match post_operator_endpoint( + endpoint, + "read_bytes", + json!({ + "mount": mount, + "resolver": RESOLVER_NAME, + "handle_uri": handle_uri, + "target_uri": target_uri + }), + ) { + Ok(data) => data, + Err(err) => return Response::error(&err.code, err.message), + }; + let data = match remote.get("data").and_then(Value::as_str) { + Some(data) => data.to_string(), + None => { + return Response::error( + "invalid_endpoint_response", + "operator endpoint read response must include base64 data", + ) + } + }; + let bytes = match base64::engine::general_purpose::STANDARD.decode(data.trim()) { + Ok(bytes) => bytes, + Err(err) => { + return Response::error( + "invalid_endpoint_response", + format!("operator endpoint returned invalid base64 data: {err}"), + ) + } + }; + let mime = remote + .get("mime") + .and_then(Value::as_str) + .unwrap_or_else(|| mime_for_target(&target_uri)); + Response::ok(json!({ + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": data, + "mime": mime, + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes": bytes.len(), + "federation_backend": "operator_private_http", + "credential_exposed": false, + "endpoint_authority_exposed": false + } + })) + } + + #[allow(clippy::too_many_arguments)] + fn write_bytes_via_endpoint( + &self, + endpoint: &OperatorEndpoint, + mount: String, + handle_uri: String, + target_uri: String, + data: String, + if_head: Option, + byte_count: usize, + ) -> Response { + let remote = match post_operator_endpoint( + endpoint, + "write_bytes", + json!({ + "mount": mount, + "resolver": RESOLVER_NAME, + "handle_uri": handle_uri, + "target_uri": target_uri, + "data": data, + "if_head": if_head + }), + ) { + Ok(data) => data, + Err(err) => return Response::error(&err.code, err.message), + }; + Response::ok(json!({ + "schema": "elastos.webspace.adapter.write-bytes/v1", + "receipt": { + "schema": "elastos.webspace.adapter.write-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes_accepted": byte_count, + "remote_head": remote.get("head").cloned().unwrap_or(Value::Null), + "federation_backend": "operator_private_http", + "credential_exposed": false, + "endpoint_authority_exposed": false + } + })) + } + + fn object_root(&self) -> PathBuf { + self.root.join("objects") + } + + fn target_path(&self, target_uri: &str) -> Result { + let parts = target_parts(target_uri)?; + if parts.is_empty() { + return Err("target URI must reference a file".to_string()); + } + Ok(parts + .into_iter() + .fold(self.object_root(), |path, part| path.join(part))) + } + + fn read_target_bytes(&self, target_uri: &str) -> Result, String> { + if target_uri == SEED_BRIEF_TARGET { + return Ok(SEED_BRIEF_BYTES.to_vec()); + } + let path = self.target_path(target_uri)?; + fs::read(path).map_err(|err| format!("operator adapter target is unavailable: {err}")) + } + + fn collect_stored_entries( + &self, + prefix: &[String], + entries: &mut Vec, + ) -> Result<(), String> { + let root = prefix + .iter() + .fold(self.object_root(), |path, part| path.join(part)); + if !root.exists() { + return Ok(()); + } + collect_stored_entries_at(&self.object_root(), &root, prefix.len(), entries) + } +} + +fn validate_adapter_request( + schema: Option<&str>, + mount: &str, + resolver: &str, + handle_uri: &str, + target_uri: &str, + runtime_invocation: Option<&Value>, + op: &str, +) -> Result<(), String> { + let op_schema = op.replace('_', "-"); + let expected_schema = format!("elastos.webspace.adapter.{op_schema}-request/v1"); + if schema.unwrap_or(expected_schema.as_str()) != expected_schema { + return Err(format!("adapter request schema mismatch for {op}")); + } + require_non_empty(mount, "mount")?; + if resolver != RESOLVER_NAME { + return Err(format!("resolver must be {RESOLVER_NAME}")); + } + if !handle_uri.starts_with("localhost://WebSpaces/") { + return Err("handle_uri must be a localhost WebSpaces handle".to_string()); + } + target_parts(target_uri)?; + require_runtime_invocation(runtime_invocation, op) +} + +fn require_runtime_invocation(invocation: Option<&Value>, op: &str) -> Result<(), String> { + let Some(invocation) = invocation.and_then(Value::as_object) else { + return Err("operator adapter requires Runtime provider invocation".to_string()); + }; + if invocation.get("schema").and_then(Value::as_str) != Some("elastos.provider.invocation/v1") { + return Err("runtime invocation schema is unsupported".to_string()); + } + if invocation.get("source").and_then(Value::as_str) != Some("webspace-provider") { + return Err("runtime invocation source must be webspace-provider".to_string()); + } + if invocation.get("target").and_then(Value::as_str) != Some(PROVIDER_NAME) { + return Err("runtime invocation target must be operator-drive-adapter".to_string()); + } + if invocation.get("op").and_then(Value::as_str) != Some(op) { + return Err("runtime invocation op mismatch".to_string()); + } + Ok(()) +} + +fn require_non_empty(value: &str, field: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{field} is required")) + } else { + Ok(()) + } +} + +fn target_parts(target_uri: &str) -> Result, String> { + let rest = target_uri + .strip_prefix(TARGET_PREFIX) + .ok_or_else(|| format!("target_uri must start with {TARGET_PREFIX}"))?; + let rest = rest.strip_prefix('/').unwrap_or(rest); + if rest.is_empty() { + return Ok(Vec::new()); + } + rest.split('/') + .map(|segment| { + if segment.is_empty() + || segment == "." + || segment == ".." + || segment.contains('\\') + || segment.contains('\0') + { + Err("target_uri contains an unsafe path segment".to_string()) + } else { + Ok(segment.to_string()) + } + }) + .collect() +} + +fn index_entry(path: &str, kind: &str, target_uri: &str, readonly: bool) -> Value { + json!({ + "path": path, + "kind": kind, + "target_uri": target_uri, + "resolver_state": "indexed", + "readonly": readonly, + "description": "Operator drive adapter entry." + }) +} + +fn operator_endpoint_from_extra(extra: &Value) -> Result, String> { + let Some(raw) = extra.get("operator_endpoint") else { + return Ok(None); + }; + if raw.is_null() { + return Ok(None); + } + let config: OperatorEndpointConfig = serde_json::from_value(raw.clone()) + .map_err(|err| format!("invalid operator_endpoint config: {err}"))?; + parse_operator_endpoint(config) +} + +fn parse_operator_endpoint( + config: OperatorEndpointConfig, +) -> Result, String> { + let url = config.url.trim(); + if url.is_empty() { + return Ok(None); + } + if url.contains('?') || url.contains('#') { + return Err("operator endpoint URL must not contain query or fragment".to_string()); + } + let rest = url.strip_prefix("http://").ok_or_else(|| { + "operator endpoint backend only supports operator-private http:// loopback URLs".to_string() + })?; + let (authority, raw_path) = rest.split_once('/').unwrap_or((rest, "")); + if authority.contains('@') { + return Err("operator endpoint URL must not contain userinfo credentials".to_string()); + } + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) => { + let port = port + .parse::() + .map_err(|_| "operator endpoint URL has an invalid port".to_string())?; + (host.to_string(), port) + } + None => (authority.to_string(), 80), + }; + if host != "127.0.0.1" && host != "localhost" { + return Err( + "operator endpoint backend must use a loopback host owned by the operator service" + .to_string(), + ); + } + let path = if raw_path.is_empty() { + "/".to_string() + } else { + format!("/{raw_path}") + }; + let authorization = config + .authorization + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let timeout_ms = config + .timeout_ms + .unwrap_or(DEFAULT_ENDPOINT_TIMEOUT_MS) + .clamp(100, 30_000); + Ok(Some(OperatorEndpoint { + host, + port, + path, + authorization, + timeout: Duration::from_millis(timeout_ms), + })) +} + +fn endpoint_summary(endpoint: Option<&OperatorEndpoint>) -> Value { + match endpoint { + Some(endpoint) => json!({ + "schema": "elastos.webspace.operator-endpoint.summary/v1", + "configured": true, + "mode": "operator_private_http", + "scheme": "http", + "loopback_only": true, + "authorization_configured": endpoint.authorization.is_some(), + "credential_exposed": false, + "endpoint_authority_exposed": false, + "note": "Endpoint URL and credentials are provider-owned and redacted from app-visible status." + }), + None => json!({ + "schema": "elastos.webspace.operator-endpoint.summary/v1", + "configured": false, + "mode": "deterministic_local_store", + "credential_exposed": false, + "endpoint_authority_exposed": false + }), + } +} + +fn post_operator_endpoint( + endpoint: &OperatorEndpoint, + op: &str, + request: Value, +) -> Result { + let envelope = json!({ + "schema": ENDPOINT_REQUEST_SCHEMA, + "op": op, + "provider": PROVIDER_NAME, + "resolver": RESOLVER_NAME, + "target_prefix": TARGET_PREFIX, + "request": request + }); + let body = serde_json::to_string(&envelope).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_request_failed", + format!("cannot encode operator endpoint request: {err}"), + ) + })?; + let address_host = if endpoint.host == "localhost" { + "127.0.0.1" + } else { + endpoint.host.as_str() + }; + let mut addresses = format!("{address_host}:{}", endpoint.port) + .to_socket_addrs() + .map_err(|err| { + EndpointFailure::new( + "operator_endpoint_unavailable", + format!("cannot resolve operator endpoint: {err}"), + ) + })?; + let address = addresses + .find(|address| address.ip().is_loopback()) + .ok_or_else(|| { + EndpointFailure::new( + "operator_endpoint_unavailable", + "operator endpoint did not resolve to loopback", + ) + })?; + let mut stream = TcpStream::connect_timeout(&address, endpoint.timeout).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_unavailable", + format!("cannot connect to operator endpoint: {err}"), + ) + })?; + let _ = stream.set_read_timeout(Some(endpoint.timeout)); + let _ = stream.set_write_timeout(Some(endpoint.timeout)); + let mut wire_request = format!( + "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + endpoint.path, + endpoint.host, + endpoint.port, + body.len() + ); + if let Some(authorization) = &endpoint.authorization { + wire_request.push_str("Authorization: "); + wire_request.push_str(authorization); + wire_request.push_str("\r\n"); + } + wire_request.push_str("\r\n"); + wire_request.push_str(&body); + stream.write_all(wire_request.as_bytes()).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_request_failed", + format!("cannot write operator endpoint request: {err}"), + ) + })?; + let mut response = String::new(); + stream.read_to_string(&mut response).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_request_failed", + format!("cannot read operator endpoint response: {err}"), + ) + })?; + parse_operator_http_response(&response) +} + +fn parse_operator_http_response(response: &str) -> Result { + let (headers, body) = response.split_once("\r\n\r\n").ok_or_else(|| { + EndpointFailure::new( + "operator_endpoint_response_invalid", + "operator endpoint response is missing HTTP headers", + ) + })?; + let status_line = headers.lines().next().unwrap_or_default(); + let status_code = status_line + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + EndpointFailure::new( + "operator_endpoint_response_invalid", + "operator endpoint response has invalid HTTP status", + ) + })?; + if !(200..300).contains(&status_code) { + return Err(EndpointFailure::new( + "operator_endpoint_http_error", + format!("operator endpoint returned HTTP {status_code}"), + )); + } + let payload: Value = serde_json::from_str(body.trim()).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_response_invalid", + format!("operator endpoint response is not JSON: {err}"), + ) + })?; + match payload.get("status").and_then(Value::as_str) { + Some("ok") => Ok(payload.get("data").cloned().unwrap_or(Value::Null)), + Some("error") => Err(EndpointFailure::new( + payload + .get("code") + .and_then(Value::as_str) + .unwrap_or("operator_endpoint_error"), + payload + .get("message") + .and_then(Value::as_str) + .unwrap_or("operator endpoint returned an error"), + )), + _ => Err(EndpointFailure::new( + "operator_endpoint_response_invalid", + "operator endpoint response must use status ok or error", + )), + } +} + +fn sanitize_endpoint_entries(remote: &Value) -> Result, String> { + let entries = remote + .get("entries") + .and_then(Value::as_array) + .ok_or_else(|| "operator endpoint metadata response must include entries".to_string())?; + entries + .iter() + .map(|entry| { + let path = entry + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| "operator endpoint entry missing path".to_string())?; + let kind = entry + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| "operator endpoint entry missing kind".to_string())?; + if !matches!(kind, "file" | "directory") { + return Err("operator endpoint entry kind must be file or directory".to_string()); + } + let target_uri = entry + .get("target_uri") + .and_then(Value::as_str) + .ok_or_else(|| "operator endpoint entry missing target_uri".to_string())?; + target_parts(target_uri)?; + let readonly = entry + .get("readonly") + .and_then(Value::as_bool) + .unwrap_or(true); + Ok(json!({ + "path": path, + "kind": kind, + "target_uri": target_uri, + "resolver_state": "endpoint_indexed", + "readonly": readonly, + "description": entry + .get("description") + .and_then(Value::as_str) + .unwrap_or("Operator endpoint adapter entry.") + })) + }) + .collect() +} + +fn collect_stored_entries_at( + object_root: &Path, + current: &Path, + prefix_len: usize, + entries: &mut Vec, +) -> Result<(), String> { + let dir = fs::read_dir(current).map_err(|err| format!("cannot read adapter store: {err}"))?; + for entry in dir { + let entry = entry.map_err(|err| format!("cannot read adapter store entry: {err}"))?; + let path = entry.path(); + let metadata = entry + .metadata() + .map_err(|err| format!("cannot read adapter store metadata: {err}"))?; + let relative = path + .strip_prefix(object_root) + .map_err(|_| "adapter store path escaped object root".to_string())? + .iter() + .map(|part| part.to_string_lossy().to_string()) + .collect::>(); + let display = relative + .iter() + .skip(prefix_len) + .cloned() + .collect::>() + .join("/"); + if !display.is_empty() { + let target_uri = format!("{TARGET_PREFIX}/{}", relative.join("/")); + entries.push(index_entry( + &display, + if metadata.is_dir() { + "directory" + } else { + "file" + }, + &target_uri, + false, + )); + } + if metadata.is_dir() { + collect_stored_entries_at(object_root, &path, prefix_len, entries)?; + } + } + Ok(()) +} + +fn mime_for_target(target_uri: &str) -> &'static str { + let lower = target_uri.to_ascii_lowercase(); + if lower.ends_with(".md") || lower.ends_with(".txt") { + "text/plain" + } else if lower.ends_with(".json") { + "application/json" + } else if lower.ends_with(".pdf") { + "application/pdf" + } else { + "application/octet-stream" + } +} + +fn write_response(response: &Response) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + serde_json::to_writer(&mut stdout, response)?; + stdout.write_all(b"\n")?; + stdout.flush() +} + +fn main() { + let stdin = io::stdin(); + let mut adapter = OperatorDriveAdapter::default(); + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + let _ = write_response(&Response::error("io_error", err.to_string())); + break; + } + }; + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Ok(request) => adapter.handle(request), + Err(err) => Response::error("invalid_request", err.to_string()), + }; + let shutdown = matches!(response, Response::Ok { .. }) + && serde_json::from_str::(&line) + .map(|request| matches!(request, Request::Shutdown)) + .unwrap_or(false); + if let Err(err) = write_response(&response) { + eprintln!("operator-drive-adapter write error: {err}"); + break; + } + if shutdown { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::thread; + + fn runtime_invocation(op: &str) -> Value { + json!({ + "schema": "elastos.provider.invocation/v1", + "source": "webspace-provider", + "target": PROVIDER_NAME, + "op": op + }) + } + + fn init_with_temp(provider: &mut OperatorDriveAdapter) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let response = provider.init(ProviderConfig { + base_path: dir.path().display().to_string(), + extra: Value::Null, + }); + assert!(matches!(response, Response::Ok { .. })); + dir + } + + fn init_with_endpoint( + provider: &mut OperatorDriveAdapter, + endpoint_url: &str, + authorization: &str, + ) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let response = provider.init(ProviderConfig { + base_path: dir.path().display().to_string(), + extra: json!({ + "operator_endpoint": { + "url": endpoint_url, + "authorization": authorization, + "timeout_ms": 1_000 + } + }), + }); + assert!(matches!(response, Response::Ok { .. })); + dir + } + + fn spawn_operator_endpoint( + response_data: Value, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + sender.send(request).unwrap(); + let body = json!({ + "status": "ok", + "data": response_data + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + (format!("http://{address}/operator-drive"), receiver, handle) + } + + fn read_http_request(stream: &mut TcpStream) -> String { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 512]; + loop { + let read = stream.read(&mut chunk).unwrap(); + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(header_end) = buffer.windows(4).position(|item| item == b"\r\n\r\n") { + let header_end = header_end + 4; + let headers = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + }) + .unwrap_or(0); + if buffer.len() >= header_end + content_length { + break; + } + } + } + String::from_utf8(buffer).unwrap() + } + + fn request_body(request: &str) -> Value { + let (_, body) = request.split_once("\r\n\r\n").unwrap(); + serde_json::from_str(body).unwrap() + } + + fn spawn_filesystem_operator_endpoint( + root: PathBuf, + request_count: usize, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + for _ in 0..request_count { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let envelope = request_body(&request); + sender.send(envelope.clone()).unwrap(); + let data = filesystem_endpoint_response(&root, &envelope); + let body = json!({ + "status": "ok", + "data": data + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + (format!("http://{address}/operator-drive"), receiver, handle) + } + + fn filesystem_endpoint_response(root: &Path, envelope: &Value) -> Value { + assert_eq!(envelope["schema"], ENDPOINT_REQUEST_SCHEMA); + assert_eq!(envelope["provider"], PROVIDER_NAME); + assert_eq!(envelope["resolver"], RESOLVER_NAME); + assert_eq!(envelope["target_prefix"], TARGET_PREFIX); + assert!(envelope.get("_runtime_invocation").is_none()); + let request = &envelope["request"]; + match envelope["op"].as_str().unwrap() { + "metadata_index" => json!({ + "entries": filesystem_endpoint_entries(root) + }), + "read_bytes" => { + let target_uri = request["target_uri"].as_str().unwrap(); + let bytes = fs::read(filesystem_endpoint_path(root, target_uri)).unwrap(); + json!({ + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "mime": mime_for_target(target_uri) + }) + } + "write_bytes" => { + let target_uri = request["target_uri"].as_str().unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(request["data"].as_str().unwrap()) + .unwrap(); + let path = filesystem_endpoint_path(root, target_uri); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + json!({ + "head": format!("fs-head:{}", target_uri.replace('/', ":")) + }) + } + op => panic!("unsupported filesystem endpoint op: {op}"), + } + } + + fn filesystem_endpoint_entries(root: &Path) -> Vec { + let mut entries = Vec::new(); + filesystem_endpoint_collect(root, root, &mut entries); + entries.sort_by(|left, right| { + left["path"] + .as_str() + .unwrap() + .cmp(right["path"].as_str().unwrap()) + }); + entries + } + + fn filesystem_endpoint_collect(root: &Path, current: &Path, entries: &mut Vec) { + for entry in fs::read_dir(current).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let metadata = entry.metadata().unwrap(); + let relative = path + .strip_prefix(root) + .unwrap() + .iter() + .map(|part| part.to_string_lossy().to_string()) + .collect::>() + .join("/"); + if relative.is_empty() { + continue; + } + let target_uri = format!("{TARGET_PREFIX}/{relative}"); + entries.push(json!({ + "path": relative, + "kind": if metadata.is_dir() { "directory" } else { "file" }, + "target_uri": target_uri, + "readonly": !target_uri.starts_with("operator://drive/Writable/"), + "description": "Filesystem-backed operator endpoint entry." + })); + if metadata.is_dir() { + filesystem_endpoint_collect(root, &path, entries); + } + } + } + + fn filesystem_endpoint_path(root: &Path, target_uri: &str) -> PathBuf { + let parts = target_parts(target_uri).unwrap(); + assert!(!parts.is_empty()); + parts + .into_iter() + .fold(root.to_path_buf(), |path, part| path.join(part)) + } + + fn data(response: Response) -> Value { + match response { + Response::Ok { data: Some(data) } => data, + Response::Ok { data: None } => Value::Null, + Response::Error { code, message } => panic!("{code}: {message}"), + } + } + + fn error_code(response: Response) -> String { + match response { + Response::Error { code, .. } => code, + Response::Ok { .. } => panic!("expected error"), + } + } + + #[test] + fn status_exposes_contract_without_credentials() { + let provider = OperatorDriveAdapter::default(); + let data = data(provider.status()); + assert_eq!(data["schema"], ADAPTER_SCHEMA); + assert_eq!(data["provider"], PROVIDER_NAME); + assert_eq!(data["resolver"], RESOLVER_NAME); + assert_eq!(data["contract"]["requires_runtime_invocation"], true); + assert!(data["blocked_authority"] + .as_array() + .unwrap() + .iter() + .any(|item| item == "resolver_credentials")); + } + + #[test] + fn status_redacts_operator_endpoint_authority() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint( + &mut provider, + "http://127.0.0.1:39999/operator-secret-path", + "Bearer endpoint-secret", + ); + let data = data(provider.status()); + assert_eq!(data["endpoint"]["configured"], true); + assert_eq!(data["endpoint"]["mode"], "operator_private_http"); + assert_eq!(data["endpoint"]["authorization_configured"], true); + assert_eq!(data["endpoint"]["credential_exposed"], false); + assert_eq!(data["endpoint"]["endpoint_authority_exposed"], false); + let serialized = data.to_string(); + assert!(!serialized.contains("endpoint-secret")); + assert!(!serialized.contains("127.0.0.1")); + assert!(!serialized.contains("39999")); + assert!(!serialized.contains("operator-secret-path")); + } + + #[test] + fn endpoint_config_rejects_non_loopback_backend() { + let mut provider = OperatorDriveAdapter::default(); + let dir = tempfile::tempdir().unwrap(); + let response = provider.init(ProviderConfig { + base_path: dir.path().display().to_string(), + extra: json!({ + "operator_endpoint": { + "url": "http://example.com/operator-drive" + } + }), + }); + assert_eq!(error_code(response), "invalid_endpoint_config"); + } + + #[test] + fn metadata_index_requires_runtime_invocation() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + assert_eq!( + error_code(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + None, + )), + "invalid_request" + ); + } + + #[test] + fn metadata_index_lists_seeded_operator_entries() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + let data = data(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + Some(runtime_invocation("metadata_index")), + )); + assert_eq!(data["schema"], "elastos.webspace.adapter.metadata-index/v1"); + assert!(data["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Projects/Brief.md" + && entry["target_uri"] == SEED_BRIEF_TARGET)); + assert_eq!(data["receipt"]["credential_exposed"], false); + } + + #[test] + fn metadata_index_uses_operator_endpoint_backend_without_runtime_leakage() { + let (endpoint_url, request_rx, handle) = spawn_operator_endpoint(json!({ + "entries": [ + { + "path": "Projects/Federated.md", + "kind": "file", + "target_uri": "operator://drive/Projects/Federated.md", + "readonly": true, + "description": "Federated operator endpoint entry." + } + ] + })); + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let data = data(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + Some(runtime_invocation("metadata_index")), + )); + let request = request_rx.recv().unwrap(); + handle.join().unwrap(); + assert!(request.contains("Authorization: Bearer endpoint-secret")); + assert!(request.contains(r#""schema":"elastos.webspace.operator-endpoint.request/v1""#)); + assert!(request.contains(r#""op":"metadata_index""#)); + assert!(!request.contains("_runtime_invocation")); + assert!(!request.contains("elastos.provider.invocation/v1")); + assert_eq!( + data["receipt"]["federation_backend"], + "operator_private_http" + ); + assert_eq!(data["receipt"]["credential_exposed"], false); + assert_eq!(data["receipt"]["endpoint_authority_exposed"], false); + assert!(data["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Projects/Federated.md" + && entry["resolver_state"] == "endpoint_indexed")); + assert!(!data.to_string().contains("endpoint-secret")); + } + + #[test] + fn read_bytes_returns_seeded_brief() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + let data = data(provider.read_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Brief.md".to_string(), + SEED_BRIEF_TARGET.to_string(), + Some(runtime_invocation("read_bytes")), + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, SEED_BRIEF_BYTES); + assert_eq!(data["receipt"]["credential_exposed"], false); + } + + #[test] + fn read_bytes_uses_operator_endpoint_backend() { + let (endpoint_url, request_rx, handle) = spawn_operator_endpoint(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"federated bytes"), + "mime": "text/plain" + })); + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let data = data(provider.read_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Federated.md".to_string(), + "operator://drive/Projects/Federated.md".to_string(), + Some(runtime_invocation("read_bytes")), + )); + let request = request_rx.recv().unwrap(); + handle.join().unwrap(); + assert!(request.contains(r#""op":"read_bytes""#)); + assert!(!request.contains("_runtime_invocation")); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"federated bytes"); + assert_eq!( + data["receipt"]["federation_backend"], + "operator_private_http" + ); + assert_eq!(data["receipt"]["endpoint_authority_exposed"], false); + } + + #[test] + fn write_bytes_persists_under_writable_namespace() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + let target_uri = "operator://drive/Writable/Folder/note.txt"; + let write = data(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + target_uri.to_string(), + base64::engine::general_purpose::STANDARD.encode(b"operator bytes"), + None, + Some(runtime_invocation("write_bytes")), + )); + assert_eq!(write["schema"], "elastos.webspace.adapter.write-bytes/v1"); + assert_eq!(write["receipt"]["bytes_accepted"], 14); + + let read = data(provider.read_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + target_uri.to_string(), + Some(runtime_invocation("read_bytes")), + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"operator bytes"); + } + + #[test] + fn write_bytes_uses_operator_endpoint_backend() { + let (endpoint_url, request_rx, handle) = spawn_operator_endpoint(json!({ + "head": "remote-head-1" + })); + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let data = data(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + "operator://drive/Writable/Folder/note.txt".to_string(), + base64::engine::general_purpose::STANDARD.encode(b"federated write"), + None, + Some(runtime_invocation("write_bytes")), + )); + let request = request_rx.recv().unwrap(); + handle.join().unwrap(); + assert!(request.contains(r#""op":"write_bytes""#)); + assert!(request.contains("ZmVkZXJhdGVkIHdyaXRl")); + assert!(!request.contains("_runtime_invocation")); + assert_eq!(data["receipt"]["remote_head"], "remote-head-1"); + assert_eq!( + data["receipt"]["federation_backend"], + "operator_private_http" + ); + assert_eq!(data["receipt"]["bytes_accepted"], 15); + assert_eq!(data["receipt"]["credential_exposed"], false); + } + + #[test] + fn operator_endpoint_backend_traverses_reads_and_writes_real_filesystem_state() { + let backend = tempfile::tempdir().unwrap(); + let projects = backend.path().join("Projects"); + fs::create_dir_all(&projects).unwrap(); + fs::write( + projects.join("Federated.md"), + b"# Federated\n\nBackend bytes.\n", + ) + .unwrap(); + let (endpoint_url, request_rx, handle) = + spawn_filesystem_operator_endpoint(backend.path().to_path_buf(), 4); + + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let index = data(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + Some(runtime_invocation("metadata_index")), + )); + assert!(index["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Projects/Federated.md" + && entry["resolver_state"] == "endpoint_indexed")); + assert_eq!( + index["receipt"]["federation_backend"], + "operator_private_http" + ); + + let read = data(provider.read_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Federated.md".to_string(), + "operator://drive/Projects/Federated.md".to_string(), + Some(runtime_invocation("read_bytes")), + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"# Federated\n\nBackend bytes.\n"); + + let write = data(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + "operator://drive/Writable/Folder/note.txt".to_string(), + base64::engine::general_purpose::STANDARD.encode(b"backend write"), + None, + Some(runtime_invocation("write_bytes")), + )); + assert_eq!(write["receipt"]["bytes_accepted"], 13); + assert_eq!( + fs::read(backend.path().join("Writable/Folder/note.txt")).unwrap(), + b"backend write" + ); + + let reread = data(provider.read_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + "operator://drive/Writable/Folder/note.txt".to_string(), + Some(runtime_invocation("read_bytes")), + )); + let reread_bytes = base64::engine::general_purpose::STANDARD + .decode(reread["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(reread_bytes, b"backend write"); + + for _ in 0..4 { + let envelope = request_rx.recv().unwrap(); + assert_eq!(envelope["schema"], ENDPOINT_REQUEST_SCHEMA); + assert!(envelope.get("_runtime_invocation").is_none()); + assert!(!envelope.to_string().contains("endpoint-secret")); + assert!(!envelope + .to_string() + .contains(backend.path().to_string_lossy().as_ref())); + } + handle.join().unwrap(); + } + + #[test] + fn write_bytes_rejects_readonly_and_conflict_targets() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + assert_eq!( + error_code(provider.write_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Brief.md".to_string(), + SEED_BRIEF_TARGET.to_string(), + base64::engine::general_purpose::STANDARD.encode(b"no"), + None, + Some(runtime_invocation("write_bytes")), + )), + "readonly" + ); + assert_eq!( + error_code(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Conflict/stale.txt".to_string(), + "operator://drive/Writable/Conflict/stale.txt".to_string(), + base64::engine::general_purpose::STANDARD.encode(b"stale"), + None, + Some(runtime_invocation("write_bytes")), + )), + "conflict" + ); + } + + #[test] + fn wire_request_rejects_hidden_credentials() { + let payload = json!({ + "op": "read_bytes", + "mount": "Operator", + "resolver": RESOLVER_NAME, + "handle_uri": "localhost://WebSpaces/Operator/Projects/Brief.md", + "target_uri": SEED_BRIEF_TARGET, + "_runtime_invocation": runtime_invocation("read_bytes"), + "resolver_credentials": "must-not-be-accepted" + }); + let err = serde_json::from_value::(payload) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown field")); + } +} diff --git a/capsules/webspace-provider/src/main.rs b/capsules/webspace-provider/src/main.rs index f7898f2c..e4c6ee07 100644 --- a/capsules/webspace-provider/src/main.rs +++ b/capsules/webspace-provider/src/main.rs @@ -1,4 +1,8 @@ +use std::collections::BTreeMap; +use std::fs; use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use elastos_common::localhost::{parse_localhost_path, parse_localhost_uri}; use serde::{Deserialize, Serialize}; @@ -17,9 +21,47 @@ const SUPPORTED_OPS: &[&str] = &[ "list", "stat", "exists", + "mounts", + "adapters", + "register_adapter", + "unregister_adapter", + "check_adapter", + "mount", + "unmount", + "index", + "health", + "refresh", + "head", + "cache", + "cache_status", + "sync", + "sync_status", + "fork", + "write", + "delete", + "mkdir", ]; -const UNSUPPORTED_OPS: &[&str] = &["write", "delete", "mkdir"]; +const UNSUPPORTED_OPS: &[&str] = &[]; +const BUILTIN_MONIKER: &str = "Elastos"; +const MOUNT_TABLE_SCHEMA: &str = "elastos.webspace.mount-table/v1"; +const MOUNT_RECORD_SCHEMA: &str = "elastos.webspace.mount/v1"; +const INDEX_TABLE_SCHEMA: &str = "elastos.webspace.index-table/v1"; +const INDEX_ENTRY_SCHEMA: &str = "elastos.webspace.index-entry/v1"; +const HEAD_TABLE_SCHEMA: &str = "elastos.webspace.head-table/v1"; +const HEAD_RECORD_SCHEMA: &str = "elastos.webspace.object-head/v1"; +const OBJECT_TABLE_SCHEMA: &str = "elastos.webspace.object-table/v1"; +const OBJECT_RECORD_SCHEMA: &str = "elastos.webspace.object/v1"; +const ADAPTER_TABLE_SCHEMA: &str = "elastos.webspace.adapter-table/v1"; +const ADAPTER_RECORD_SCHEMA: &str = "elastos.webspace.adapter/v1"; +const DEFAULT_CACHE_POLICY: &str = "metadata-only"; +const DEFAULT_SYNC_POLICY: &str = "manual"; +const DEFAULT_EXTERNAL_RESOLVER: &str = "external"; +const DEFAULT_READONLY_ACCESS_POLICY: &str = "resolver-readonly"; +const DEFAULT_MUTABLE_ACCESS_POLICY: &str = "owner-writable"; +const DEFAULT_ADAPTER_STATE: &str = "configured"; +const PROVIDER_ID: &str = "webspace-provider"; +const ADAPTER_HEALTH_STALE_AFTER_SECS: u64 = 24 * 60 * 60; #[derive(Debug, Deserialize)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] @@ -58,28 +100,168 @@ enum Request { #[serde(rename = "token")] _token: String, }, + Mounts { + #[serde(default, rename = "token")] + _token: String, + }, + Adapters { + #[serde(default, rename = "token")] + _token: String, + }, + RegisterAdapter { + resolver: String, + #[serde(default)] + label: Option, + #[serde(default)] + endpoint_uri: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + state: Option, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + readonly_default: Option, + #[serde(default)] + description: Option, + #[serde(default, rename = "token")] + _token: String, + }, + UnregisterAdapter { + resolver: String, + #[serde(default, rename = "token")] + _token: String, + }, + CheckAdapter { + resolver: String, + #[serde(default)] + result: Option, + #[serde(default)] + state: Option, + #[serde(default)] + error_code: Option, + #[serde(default)] + capabilities: Vec, + #[serde(default, rename = "token")] + _token: String, + }, + Health { + #[serde(default)] + moniker: Option, + #[serde(default, rename = "token")] + _token: String, + }, + Mount { + moniker: String, + target_uri: String, + #[serde(default)] + namespace_uri: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + description: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default)] + access_policy: Option, + #[serde(default, rename = "token")] + _token: String, + }, + Unmount { + moniker: String, + #[serde(default, rename = "token")] + _token: String, + }, + Index { + moniker: String, + #[serde(default)] + entries: Vec, + #[serde(default, rename = "token")] + _token: String, + }, + Refresh { + path: String, + #[serde(default)] + entries: Option>, + #[serde(default, rename = "token")] + _token: String, + }, + Head { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + Cache { + path: String, + #[serde(default)] + content: Option>, + #[serde(default)] + mime: Option, + #[serde(default)] + source_receipt: Option, + #[serde(default, rename = "token")] + _token: String, + }, + CacheStatus { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + Sync { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + SyncStatus { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + Fork { + source_uri: String, + moniker: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + description: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default)] + access_policy: Option, + #[serde(default, rename = "token")] + _token: String, + }, Write { path: String, #[serde(rename = "token")] _token: String, - #[serde(rename = "content")] - _content: Vec, - #[serde(rename = "append")] - _append: bool, + content: Vec, + #[serde(default)] + append: bool, }, Delete { path: String, #[serde(rename = "token")] _token: String, - #[serde(rename = "recursive")] - _recursive: bool, + #[serde(default)] + recursive: bool, }, Mkdir { path: String, #[serde(rename = "token")] _token: String, - #[serde(rename = "parents")] - _parents: bool, + #[serde(default)] + parents: bool, }, Ping, Shutdown, @@ -104,6 +286,23 @@ struct DirEntry { is_file: bool, is_dir: bool, size: u64, + readonly: bool, + access_policy: String, + provider: String, + resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + kind: String, + traversable: bool, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + namespace_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target_uri: Option, } #[derive(Debug, Serialize)] @@ -113,6 +312,22 @@ struct FileStat { is_dir: bool, size: u64, readonly: bool, + access_policy: String, + provider: String, + resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + kind: String, + traversable: bool, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + namespace_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target_uri: Option, modified: Option, created: Option, } @@ -125,13 +340,226 @@ struct WebSpaceHandle { #[serde(skip_serializing_if = "Option::is_none")] target_uri: Option, resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + readonly: bool, + access_policy: String, kind: String, traversable: bool, + size: u64, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, description: String, #[serde(skip_serializing_if = "Option::is_none")] + forked_from: Option, + #[serde(skip_serializing_if = "Option::is_none")] next_step: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceMount { + #[serde(default = "mount_record_schema")] + schema: String, + moniker: String, + target_uri: String, + #[serde(default)] + namespace_uri: Option, + #[serde(default = "default_external_resolver")] + resolver: String, + #[serde(default = "default_true")] + readonly: bool, + #[serde(default = "default_access_policy")] + access_policy: String, + #[serde(default = "default_cache_policy")] + cache_policy: String, + #[serde(default = "default_sync_policy")] + sync_policy: String, + #[serde(default)] + description: String, + #[serde(default)] + forked_from: Option, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct MountTable { + #[serde(default = "mount_table_schema")] + schema: String, + #[serde(default)] + mounts: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct WebSpaceIndexInput { + path: String, + kind: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceIndexEntry { + #[serde(default = "index_entry_schema")] + schema: String, + moniker: String, + path: String, + name: String, + kind: String, + target_uri: String, + resolver: String, + resolver_state: String, + readonly: bool, + description: String, + updated_at: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct IndexTable { + #[serde(default = "index_table_schema")] + schema: String, + #[serde(default)] + entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceHead { + #[serde(default = "head_record_schema")] + schema: String, + handle_uri: String, + #[serde(default)] + target_uri: Option, + moniker: String, + resolver: String, + object_id: String, + head_id: String, + revision: String, + readonly: bool, + access_policy: String, + cache_policy: String, + sync_policy: String, + cache_state: String, + sync_state: String, + #[serde(default)] + forked_from: Option, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, + #[serde(default)] + last_cached_at: Option, + #[serde(default)] + last_synced_at: Option, + dirty: bool, + status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceObject { + #[serde(default = "object_record_schema")] + schema: String, + moniker: String, + path: String, + name: String, + kind: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + mime: String, + #[serde(default)] + content: Vec, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, + #[serde(default)] + revision: String, + #[serde(default)] + dirty: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ObjectTable { + #[serde(default = "object_table_schema")] + schema: String, + #[serde(default)] + objects: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceAdapter { + #[serde(default = "adapter_record_schema")] + schema: String, + resolver: String, + #[serde(default)] + label: String, + #[serde(default)] + endpoint_uri: Option, + #[serde(default)] + provider: Option, + #[serde(default = "default_adapter_state")] + state: String, + #[serde(default)] + capabilities: Vec, + #[serde(default = "default_true")] + readonly_default: bool, + #[serde(default)] + description: String, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, + #[serde(default)] + last_checked_at: Option, + #[serde(default)] + last_check_result: Option, + #[serde(default)] + last_check_error_code: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AdapterTable { + #[serde(default = "adapter_table_schema")] + schema: String, + #[serde(default)] + adapters: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct HeadTable { + #[serde(default = "head_table_schema")] + schema: String, + #[serde(default)] + heads: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct InitConfig { + #[serde(default)] + base_path: String, +} + +#[derive(Debug, Default)] +struct ProviderState { + base_path: Option, + mounts: Vec, + index_entries: Vec, + heads: Vec, + objects: Vec, + adapters: Vec, +} + #[derive(Debug, Clone)] enum ResolvedPath { Root, @@ -139,36 +567,2267 @@ enum ResolvedPath { Meta { handle: WebSpaceHandle }, } -fn meta_path(handle: &WebSpaceHandle) -> String { - format!("{}/_meta.json", handle.handle_uri.trim_end_matches('/')) +fn mount_record_schema() -> String { + MOUNT_RECORD_SCHEMA.to_string() } -fn known_mounts() -> Vec { - vec![mount_handle( - "Elastos", - Some("elastos://".to_string()), - "Local interpreted handle into the broader elastos:// namespace.", - Some( - "List this handle to discover typed child spaces such as content, peer, did, and ai." - .to_string(), - ), - )] +fn mount_table_schema() -> String { + MOUNT_TABLE_SCHEMA.to_string() } -fn normalize_moniker(moniker: &str) -> String { - moniker - .trim() - .trim_matches('/') - .trim_end_matches("://") - .to_string() +fn index_entry_schema() -> String { + INDEX_ENTRY_SCHEMA.to_string() +} + +fn index_table_schema() -> String { + INDEX_TABLE_SCHEMA.to_string() +} + +fn head_record_schema() -> String { + HEAD_RECORD_SCHEMA.to_string() +} + +fn head_table_schema() -> String { + HEAD_TABLE_SCHEMA.to_string() +} + +fn object_record_schema() -> String { + OBJECT_RECORD_SCHEMA.to_string() +} + +fn object_table_schema() -> String { + OBJECT_TABLE_SCHEMA.to_string() +} + +fn adapter_record_schema() -> String { + ADAPTER_RECORD_SCHEMA.to_string() +} + +fn adapter_table_schema() -> String { + ADAPTER_TABLE_SCHEMA.to_string() +} + +fn default_external_resolver() -> String { + DEFAULT_EXTERNAL_RESOLVER.to_string() +} + +fn default_cache_policy() -> String { + DEFAULT_CACHE_POLICY.to_string() +} + +fn default_sync_policy() -> String { + DEFAULT_SYNC_POLICY.to_string() +} + +fn default_access_policy() -> String { + DEFAULT_READONLY_ACCESS_POLICY.to_string() +} + +fn default_adapter_state() -> String { + DEFAULT_ADAPTER_STATE.to_string() +} + +fn default_true() -> bool { + true +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn infer_namespace_uri(target_uri: &str) -> Option { + let (scheme, _) = target_uri.split_once("://")?; + if scheme.trim().is_empty() { + None + } else { + Some(format!("{}://", scheme.trim())) + } +} + +fn append_target_uri(target_uri: &str, parts: &[&str]) -> String { + let suffix = parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/"); + if suffix.is_empty() { + target_uri.to_string() + } else { + format!("{}/{}", target_uri.trim_end_matches('/'), suffix) + } +} + +fn stable_hex(input: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn object_id_for(handle: &WebSpaceHandle) -> String { + format!( + "object:webspace:{}", + stable_hex(handle.target_uri.as_deref().unwrap_or(&handle.handle_uri)) + ) +} + +fn head_id_for(handle_uri: &str) -> String { + format!("head:webspace:{}", stable_hex(handle_uri)) +} + +fn revision_for(handle: &WebSpaceHandle, updated_at: u64) -> String { + format!( + "rev:webspace:{}", + stable_hex(&format!( + "{}:{}:{}:{}", + handle.handle_uri, + handle.target_uri.as_deref().unwrap_or(""), + handle.resolver, + updated_at + )) + ) +} + +fn render_external_descriptor_size(handle_uri: &str, target_uri: Option<&str>, kind: &str) -> u64 { + serde_json::to_vec(&serde_json::json!({ + "handle_uri": handle_uri, + "target_uri": target_uri, + "kind": kind, + })) + .map(|bytes| bytes.len() as u64) + .unwrap_or(0) +} + +fn cache_state_for(policy: &str, last_cached_at: Option) -> String { + if policy == "none" { + "cache_disabled".to_string() + } else if last_cached_at.is_some() { + "metadata_cached".to_string() + } else { + "not_cached".to_string() + } +} + +fn sync_state_for(policy: &str, dirty: bool, last_synced_at: Option) -> String { + match (policy, dirty, last_synced_at) { + ("none", _, _) => "sync_disabled".to_string(), + ("manual", true, _) => "manual_pending".to_string(), + ("manual", false, Some(_)) => "manual_synced".to_string(), + ("manual", false, None) => "manual_idle".to_string(), + (_, true, _) => "sync_pending".to_string(), + (_, false, Some(_)) => "synced".to_string(), + _ => "sync_idle".to_string(), + } +} + +fn valid_moniker(moniker: &str) -> bool { + let trimmed = moniker.trim(); + !trimmed.is_empty() + && !trimmed.contains('/') + && !trimmed.contains('\\') + && !trimmed.contains("://") + && trimmed != "." + && trimmed != ".." +} + +impl ProviderState { + fn configure(&mut self, config: serde_json::Value) -> Result { + let config: InitConfig = + serde_json::from_value(config).map_err(|err| format!("invalid init config: {err}"))?; + self.base_path = if config.base_path.trim().is_empty() { + None + } else { + Some(PathBuf::from(config.base_path.trim())) + }; + self.mounts = self.load_mounts()?; + self.index_entries = self.load_indexes()?; + self.heads = self.load_heads()?; + self.objects = self.load_objects()?; + self.adapters = self.load_adapters()?; + Ok(init_payload(self)) + } + + fn mount_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("mounts.json") + }) + } + + fn head_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("heads.json") + }) + } + + fn index_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("indexes.json") + }) + } + + fn object_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("objects.json") + }) + } + + fn adapter_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("adapters.json") + }) + } + + fn load_mounts(&self) -> Result, String> { + let Some(path) = self.mount_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace mount table {}: {err}", + path.display() + ) + })?; + let table: MountTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace mount table {}: {err}", path.display()))?; + table + .mounts + .into_iter() + .map(normalize_mount_record) + .collect() + } + + fn save_mounts(&self) -> Result<(), String> { + let Some(path) = self.mount_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace mount table directory {}: {err}", + parent.display() + ) + })?; + } + let table = MountTable { + schema: MOUNT_TABLE_SCHEMA.to_string(), + mounts: self.mounts.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace mount table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_indexes(&self) -> Result, String> { + let Some(path) = self.index_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace index table {}: {err}", + path.display() + ) + })?; + let table: IndexTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace index table {}: {err}", path.display()))?; + table + .entries + .into_iter() + .map(normalize_index_record) + .collect() + } + + fn save_indexes(&self) -> Result<(), String> { + let Some(path) = self.index_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace index table directory {}: {err}", + parent.display() + ) + })?; + } + let table = IndexTable { + schema: INDEX_TABLE_SCHEMA.to_string(), + entries: self.index_entries.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace index table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_heads(&self) -> Result, String> { + let Some(path) = self.head_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace head table {}: {err}", + path.display() + ) + })?; + let table: HeadTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace head table {}: {err}", path.display()))?; + Ok(table.heads.into_iter().map(normalize_head_record).collect()) + } + + fn save_heads(&self) -> Result<(), String> { + let Some(path) = self.head_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace head table directory {}: {err}", + parent.display() + ) + })?; + } + let table = HeadTable { + schema: HEAD_TABLE_SCHEMA.to_string(), + heads: self.heads.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace head table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_objects(&self) -> Result, String> { + let Some(path) = self.object_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace object table {}: {err}", + path.display() + ) + })?; + let table: ObjectTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace object table {}: {err}", path.display()))?; + table + .objects + .into_iter() + .map(normalize_object_record) + .collect() + } + + fn save_objects(&self) -> Result<(), String> { + let Some(path) = self.object_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace object table directory {}: {err}", + parent.display() + ) + })?; + } + let table = ObjectTable { + schema: OBJECT_TABLE_SCHEMA.to_string(), + objects: self.objects.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace object table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_adapters(&self) -> Result, String> { + let Some(path) = self.adapter_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace adapter table {}: {err}", + path.display() + ) + })?; + let table: AdapterTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace adapter table {}: {err}", path.display()))?; + table + .adapters + .into_iter() + .map(normalize_adapter_record) + .collect() + } + + fn save_adapters(&self) -> Result<(), String> { + let Some(path) = self.adapter_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace adapter table directory {}: {err}", + parent.display() + ) + })?; + } + let table = AdapterTable { + schema: ADAPTER_TABLE_SCHEMA.to_string(), + adapters: self.adapters.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace adapter table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn upsert_head_for_handle( + &mut self, + handle: &WebSpaceHandle, + status: &str, + dirty: bool, + ) -> Result { + self.upsert_lifecycle_head_for_handle(handle, status, dirty, false, false) + } + + fn upsert_lifecycle_head_for_handle( + &mut self, + handle: &WebSpaceHandle, + status: &str, + dirty: bool, + refresh_cache: bool, + mark_synced: bool, + ) -> Result { + let now = now_unix_secs(); + let existing = self + .heads + .iter() + .find(|head| head.handle_uri == handle.handle_uri) + .cloned(); + let created_at = existing.as_ref().map(|head| head.created_at).unwrap_or(now); + let last_cached_at = if handle.cache_policy == "none" { + None + } else if refresh_cache { + Some(now) + } else { + existing + .as_ref() + .and_then(|head| head.last_cached_at) + .or(Some(now)) + }; + let last_synced_at = if handle.sync_policy == "none" { + None + } else if mark_synced { + Some(now) + } else { + existing.as_ref().and_then(|head| head.last_synced_at) + }; + let dirty = if mark_synced { false } else { dirty }; + let head = WebSpaceHead { + schema: HEAD_RECORD_SCHEMA.to_string(), + handle_uri: handle.handle_uri.clone(), + target_uri: handle.target_uri.clone(), + moniker: handle.moniker.clone(), + resolver: handle.resolver.clone(), + object_id: object_id_for(handle), + head_id: head_id_for(&handle.handle_uri), + revision: revision_for(handle, now), + readonly: handle.readonly, + access_policy: handle.access_policy.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + cache_state: cache_state_for(&handle.cache_policy, last_cached_at), + sync_state: sync_state_for(&handle.sync_policy, dirty, last_synced_at), + forked_from: handle.forked_from.clone(), + created_at, + updated_at: now, + last_cached_at, + last_synced_at, + dirty, + status: status.to_string(), + }; + if self.head_table_path().is_some() { + self.heads + .retain(|existing| existing.handle_uri != head.handle_uri); + self.heads.push(head.clone()); + self.heads + .sort_by(|left, right| left.handle_uri.cmp(&right.handle_uri)); + self.save_heads()?; + } + Ok(head) + } + + fn refresh_handle( + &mut self, + path: String, + entries: Option>, + ) -> Result { + let handle = handle_from_resolved_path(resolve_path(self, &rooted_webspace_path(&path))?)?; + let indexed_entries = if let Some(entries) = entries { + if handle.moniker == BUILTIN_MONIKER { + return Err( + "built-in Elastos WebSpace does not accept external resolver indexes" + .to_string(), + ); + } + let mount_root = format!("localhost://WebSpaces/{}", handle.moniker); + if handle.handle_uri != mount_root { + return Err(format!( + "resolver index refresh must target the mounted WebSpace root: {mount_root}" + )); + } + let refreshed = self.replace_index(&handle.moniker, entries)?; + Some(refreshed) + } else { + None + }; + let head = self.upsert_lifecycle_head_for_handle( + &handle, + "resolver_refreshed", + false, + true, + false, + )?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.refresh-receipt/v1", + "action": "refreshed", + "handle_uri": handle.handle_uri, + "head": head, + "index_entry_count": indexed_entries.as_ref().map(|entries| entries.len()), + "entries": indexed_entries, + "byte_materialized": false, + "note": "Resolver metadata was refreshed. Remote bytes still require a resolver/cache worker." + })) + } + + fn cache_handle( + &mut self, + path: String, + content: Option>, + mime: Option, + source_receipt: Option, + ) -> Result { + let handle = handle_from_resolved_path(resolve_path(self, &rooted_webspace_path(&path))?)?; + if let Some(content) = content { + return self.cache_content_handle(handle, content, mime, source_receipt); + } + let dirty = self + .heads + .iter() + .find(|head| head.handle_uri == handle.handle_uri) + .map(|head| head.dirty) + .unwrap_or(false); + let head = + self.upsert_lifecycle_head_for_handle(&handle, "metadata_cached", dirty, true, false)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.cache-receipt/v1", + "action": "metadata_cached", + "handle_uri": handle.handle_uri, + "head": head, + "content_cached": false, + "note": "Metadata cache was refreshed. Content bytes remain resolver-owned." + })) + } + + fn cache_content_handle( + &mut self, + handle: WebSpaceHandle, + content: Vec, + mime: Option, + source_receipt: Option, + ) -> Result { + if handle.traversable { + return Err(format!( + "WebSpace content cache requires a file handle, not directory: {}", + handle.handle_uri + )); + } + if handle.kind == "metadata" { + return Err("WebSpace metadata handles cannot be byte-cached".to_string()); + } + let record = self + .mount_by_moniker(&handle.moniker) + .cloned() + .ok_or_else(|| format!("unknown WebSpace moniker: {}", handle.moniker))?; + if record.moniker == BUILTIN_MONIKER { + return Err( + "built-in Elastos WebSpace content is resolved by Runtime content providers, not cached here" + .to_string(), + ); + } + let parts = handle_index_parts(&handle); + if parts.is_empty() { + return Err("WebSpace content cache requires a child object path".to_string()); + } + if parts.iter().any(|part| part == "_meta.json") { + return Err("WebSpace metadata files cannot be byte-cached".to_string()); + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind == "directory" { + return Err(format!( + "cannot byte-cache over WebSpace directory: {}", + object.path + )); + } + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind == "directory" { + return Err(format!( + "cannot byte-cache over indexed WebSpace directory: {}", + entry.path + )); + } + } + let now = now_unix_secs(); + let existing = self.exact_object(&record.moniker, &refs); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: parts.last().cloned().unwrap_or_default(), + kind: "file".to_string(), + target_uri: handle + .target_uri + .clone() + .or_else(|| Some(append_target_uri(&record.target_uri, &refs))), + mime: mime + .map(|mime| mime.trim().to_string()) + .filter(|mime| !mime.is_empty()) + .unwrap_or_else(|| "application/octet-stream".to_string()), + content, + created_at: existing + .as_ref() + .map(|object| object.created_at) + .unwrap_or(now), + updated_at: now, + revision: String::new(), + dirty: false, + }; + let object = self.upsert_object(object)?; + let cached = materialized_object_handle(&record, &object); + let mut head = self.upsert_lifecycle_head_for_handle( + &cached, + "materialized_cached", + false, + true, + false, + )?; + head.cache_state = "content_cached".to_string(); + self.heads + .retain(|existing| existing.handle_uri != head.handle_uri); + self.heads.push(head.clone()); + self.heads + .sort_by(|left, right| left.handle_uri.cmp(&right.handle_uri)); + self.save_heads()?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.cache-receipt/v1", + "action": "content_cached", + "handle_uri": cached.handle_uri, + "object": cached, + "head": head, + "content_cached": true, + "dirty": false, + "source_receipt": source_receipt, + "note": "Resolver bytes were cached as clean provider-owned local content. This does not grant mutable sync authority." + })) + } + + fn sync_handle(&mut self, path: String) -> Result { + let handle = handle_from_resolved_path(resolve_path(self, &rooted_webspace_path(&path))?)?; + let head = + self.upsert_lifecycle_head_for_handle(&handle, "metadata_synced", false, true, true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.sync-receipt/v1", + "action": "metadata_synced", + "handle_uri": handle.handle_uri, + "head": head, + "content_synced": false, + "note": "Provider-owned metadata/fork head is synced. Content-byte sync requires a resolver/sync worker." + })) + } + + fn mutable_mount_for_path(&self, path: &str) -> Result<(WebSpaceMount, Vec), String> { + let rooted = rooted_webspace_path(path); + let (root, rest) = parse_localhost_uri(&rooted) + .or_else(|| parse_localhost_path(&rooted)) + .ok_or_else(|| format!("invalid WebSpace path: {path}"))?; + if root != "WebSpaces" { + return Err(format!( + "WebSpace path must be under localhost://WebSpaces: {path}" + )); + } + let raw_parts = rest + .trim_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + let Some(moniker) = raw_parts.first().map(|part| normalize_moniker(part)) else { + return Err("WebSpace mutation requires a mounted WebSpace moniker".to_string()); + }; + if moniker == BUILTIN_MONIKER { + return Err("built-in Elastos WebSpace is resolver-owned and read-only".to_string()); + } + let record = self + .mount_by_moniker(&moniker) + .cloned() + .ok_or_else(|| format!("unknown WebSpace moniker: {moniker}"))?; + if record.readonly { + return Err(format!( + "WebSpace {} is resolver-owned/read-only; fork or mount it as mutable first", + record.moniker + )); + } + let object_parts = raw_parts + .iter() + .skip(1) + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>(); + if object_parts.is_empty() { + return Err("WebSpace mutation requires a child object path".to_string()); + } + if object_parts.iter().any(|part| *part == "_meta.json") { + return Err( + "WebSpace metadata files are provider-owned and cannot be mutated".to_string(), + ); + } + let normalized = normalized_index_parts(&object_parts.join("/"))?; + Ok((record, normalized)) + } + + fn directory_exists_for_parts( + &self, + record: &WebSpaceMount, + parts: &[String], + ) -> Result { + if parts.is_empty() { + return Ok(true); + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + return Ok(object.kind == "directory"); + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + return Ok(entry.kind == "directory"); + } + Ok(self.has_object_children(&record.moniker, &refs) + || self.has_index_children(&record.moniker, &refs)) + } + + fn ensure_parent_directory( + &self, + record: &WebSpaceMount, + parts: &[String], + ) -> Result<(), String> { + if parts.is_empty() { + return Ok(()); + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind == "directory" { + return Ok(()); + } + return Err(format!( + "WebSpace parent is a materialized file, not a directory: {}", + parts.join("/") + )); + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind == "directory" { + return Ok(()); + } + return Err(format!( + "WebSpace parent is an indexed file, not a directory: {}", + parts.join("/") + )); + } + if self.has_object_children(&record.moniker, &refs) + || self.has_index_children(&record.moniker, &refs) + { + return Ok(()); + } + Err(format!( + "WebSpace parent directory does not exist locally or in the resolver index: {}", + parts.join("/") + )) + } + + fn ensure_parent_objects( + &mut self, + record: &WebSpaceMount, + parent_parts: &[String], + ) -> Result<(), String> { + for depth in 1..=parent_parts.len() { + let current = parent_parts[..depth].to_vec(); + if self.directory_exists_for_parts(record, ¤t)? { + continue; + } + let refs = current.iter().map(String::as_str).collect::>(); + let now = now_unix_secs(); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: current.last().cloned().unwrap_or_default(), + kind: "directory".to_string(), + target_uri: Some(append_target_uri(&record.target_uri, &refs)), + mime: "inode/directory".to_string(), + content: Vec::new(), + created_at: now, + updated_at: now, + revision: String::new(), + dirty: true, + }; + self.upsert_object(object)?; + } + Ok(()) + } + + fn write_handle( + &mut self, + path: String, + content: Vec, + append: bool, + ) -> Result { + let (record, parts) = self.mutable_mount_for_path(&path)?; + let parent_parts = parts[..parts.len().saturating_sub(1)].to_vec(); + self.ensure_parent_directory(&record, &parent_parts)?; + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind == "directory" { + return Err(format!( + "cannot write bytes over WebSpace directory: {}", + object.path + )); + } + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind == "directory" { + return Err(format!( + "cannot write bytes over indexed WebSpace directory: {}", + entry.path + )); + } + } + let existing = self.exact_object(&record.moniker, &refs); + let now = now_unix_secs(); + let mut bytes = if append { + existing + .as_ref() + .map(|object| object.content.clone()) + .unwrap_or_default() + } else { + Vec::new() + }; + bytes.extend(content); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: parts.last().cloned().unwrap_or_default(), + kind: "file".to_string(), + target_uri: Some(append_target_uri(&record.target_uri, &refs)), + mime: "application/octet-stream".to_string(), + content: bytes, + created_at: existing + .as_ref() + .map(|object| object.created_at) + .unwrap_or(now), + updated_at: now, + revision: String::new(), + dirty: true, + }; + let object = self.upsert_object(object)?; + let handle = materialized_object_handle(&record, &object); + let head = self.upsert_head_for_handle(&handle, "materialized_local", true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.write-receipt/v1", + "action": if append { "appended" } else { "written" }, + "handle_uri": handle.handle_uri, + "object": handle, + "head": head, + "byte_materialized": true, + })) + } + + fn mkdir_handle(&mut self, path: String, parents: bool) -> Result { + let (record, parts) = self.mutable_mount_for_path(&path)?; + let parent_parts = parts[..parts.len().saturating_sub(1)].to_vec(); + if parents { + self.ensure_parent_objects(&record, &parent_parts)?; + } else { + self.ensure_parent_directory(&record, &parent_parts)?; + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind != "directory" { + return Err(format!( + "WebSpace object already exists as a file: {}", + object.path + )); + } + let handle = materialized_object_handle(&record, &object); + return Ok(serde_json::json!({ + "schema": "elastos.webspace.mkdir-receipt/v1", + "action": "exists", + "handle_uri": handle.handle_uri, + "object": handle, + })); + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind != "directory" { + return Err(format!( + "WebSpace indexed object already exists as a file: {}", + entry.path + )); + } + } + let now = now_unix_secs(); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: parts.last().cloned().unwrap_or_default(), + kind: "directory".to_string(), + target_uri: Some(append_target_uri(&record.target_uri, &refs)), + mime: "inode/directory".to_string(), + content: Vec::new(), + created_at: now, + updated_at: now, + revision: String::new(), + dirty: true, + }; + let object = self.upsert_object(object)?; + let handle = materialized_object_handle(&record, &object); + let head = self.upsert_head_for_handle(&handle, "materialized_directory", true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.mkdir-receipt/v1", + "action": "created", + "handle_uri": handle.handle_uri, + "object": handle, + "head": head, + })) + } + + fn delete_handle( + &mut self, + path: String, + recursive: bool, + ) -> Result { + let (record, parts) = self.mutable_mount_for_path(&path)?; + let refs = parts.iter().map(String::as_str).collect::>(); + let handle = self + .exact_object(&record.moniker, &refs) + .map(|object| materialized_object_handle(&record, &object)) + .unwrap_or_else(|| materialized_virtual_folder_handle(&record, &refs)); + let removed = self.remove_objects(&record.moniker, &parts, recursive)?; + let head = self.upsert_head_for_handle(&handle, "materialized_deleted", true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.delete-receipt/v1", + "action": "deleted", + "handle_uri": handle.handle_uri, + "removed_count": removed.len(), + "removed": removed, + "head": head, + })) + } + + fn health_report(&self, moniker: Option) -> Result { + let handles = known_mounts(self); + let filtered = if let Some(moniker) = moniker { + let moniker = normalize_moniker(&moniker); + let matched = handles + .into_iter() + .filter(|handle| handle.moniker == moniker) + .collect::>(); + if matched.is_empty() { + return Err(format!("unknown WebSpace moniker: {moniker}")); + } + matched + } else { + handles + }; + let included_monikers = filtered + .iter() + .map(|handle| handle.moniker.as_str()) + .collect::>(); + let includes_moniker = |moniker: &str| { + included_monikers + .iter() + .any(|candidate| *candidate == moniker) + }; + let mounts = filtered + .iter() + .map(|handle| self.health_for_handle(handle)) + .collect::>(); + let index_entry_count = self + .index_entries + .iter() + .filter(|entry| includes_moniker(&entry.moniker)) + .count(); + let head_count = self + .heads + .iter() + .filter(|head| includes_moniker(&head.moniker)) + .count(); + let dirty_head_count = self + .heads + .iter() + .filter(|head| includes_moniker(&head.moniker) && head.dirty) + .count(); + let object_count = self + .objects + .iter() + .filter(|object| includes_moniker(&object.moniker)) + .count(); + let user_mount_count = self + .mounts + .iter() + .filter(|mount| includes_moniker(&mount.moniker)) + .count(); + let live_adapter_count = mounts + .iter() + .filter(|mount| mount["live_adapter"].as_bool().unwrap_or(false)) + .count(); + let configured_adapter_count = self.adapters.len(); + let connected_adapter_count = self + .adapters + .iter() + .filter(|adapter| adapter.state == "connected") + .count(); + let checked_adapter_count = self + .adapters + .iter() + .filter(|adapter| adapter.last_checked_at.is_some()) + .count(); + let state = if mounts.iter().any(|mount| { + matches!( + mount["state"].as_str(), + Some("dirty") | Some("mounted_no_index") + ) + }) { + "attention" + } else { + "metadata_ready" + }; + Ok(serde_json::json!({ + "schema": "elastos.webspace.health/v1", + "provider": PROVIDER_ID, + "state": state, + "persistent": self.mount_table_path().is_some(), + "mount_count": mounts.len(), + "user_mount_count": user_mount_count, + "index_entry_count": index_entry_count, + "head_count": head_count, + "dirty_head_count": dirty_head_count, + "object_count": object_count, + "live_adapter_count": live_adapter_count, + "configured_adapter_count": configured_adapter_count, + "connected_adapter_count": connected_adapter_count, + "checked_adapter_count": checked_adapter_count, + "adapters": self.adapter_summary_table(), + "mounts": mounts, + "note": "Health reports resolver metadata readiness, dirty heads, registered adapter state, and safe adapter liveness checks. Remote byte availability still requires connected resolver/cache workers." + })) + } + + fn health_for_handle(&self, handle: &WebSpaceHandle) -> serde_json::Value { + let index_entry_count = self + .index_entries + .iter() + .filter(|entry| entry.moniker == handle.moniker) + .count(); + let head_count = self + .heads + .iter() + .filter(|head| head.moniker == handle.moniker) + .count(); + let dirty_head_count = self + .heads + .iter() + .filter(|head| head.moniker == handle.moniker && head.dirty) + .count(); + let object_count = self + .objects + .iter() + .filter(|object| object.moniker == handle.moniker) + .count(); + let (live_adapter, adapter_state, adapter) = self.adapter_state_for(&handle.resolver); + let adapter_next_step = adapter_next_step( + &handle.resolver, + &adapter_state, + index_entry_count, + live_adapter, + ); + let state = if dirty_head_count > 0 { + "dirty" + } else if live_adapter || index_entry_count > 0 || head_count > 0 || object_count > 0 { + "metadata_ready" + } else { + "mounted_no_index" + }; + serde_json::json!({ + "schema": "elastos.webspace.resolver-health/v1", + "moniker": handle.moniker, + "handle_uri": handle.handle_uri, + "target_uri": handle.target_uri, + "resolver": handle.resolver, + "resolver_state": handle.resolver_state, + "state": state, + "live_adapter": live_adapter, + "adapter_state": adapter_state, + "adapter": adapter.as_ref().map(adapter_public_summary), + "readonly": handle.readonly, + "access_policy": handle.access_policy, + "cache_policy": handle.cache_policy, + "sync_policy": handle.sync_policy, + "index_entry_count": index_entry_count, + "head_count": head_count, + "dirty_head_count": dirty_head_count, + "object_count": object_count, + "cache_state": handle.cache_state, + "sync_state": handle.sync_state, + "next_step": adapter_next_step + }) + } + + fn user_mounts(&self) -> Vec { + self.mounts.clone() + } + + fn adapter_by_resolver(&self, resolver: &str) -> Option<&WebSpaceAdapter> { + let resolver = normalize_resolver_id(resolver); + self.adapters + .iter() + .find(|adapter| adapter.resolver == resolver) + } + + fn adapter_state_for(&self, resolver: &str) -> (bool, String, Option) { + let resolver = normalize_resolver_id(resolver); + if resolver == "builtin" { + return (true, "builtin".to_string(), None); + } + let Some(adapter) = self.adapter_by_resolver(&resolver).cloned() else { + return (false, "not_registered".to_string(), None); + }; + let live = adapter.state == "connected"; + (live, adapter.state.clone(), Some(adapter)) + } + + fn adapter_summary_table(&self) -> serde_json::Value { + serde_json::json!({ + "schema": ADAPTER_TABLE_SCHEMA, + "builtin": builtin_adapter_summary(), + "adapters": self.adapters.iter().map(adapter_public_summary).collect::>(), + "configured_adapter_count": self.adapters.len(), + "connected_adapter_count": self.adapters.iter().filter(|adapter| adapter.state == "connected").count(), + "checked_adapter_count": self.adapters.iter().filter(|adapter| adapter.last_checked_at.is_some()).count(), + "note": "Registered adapters describe resolver availability, liveness receipts, and operator policy. They do not expose credentials and do not by themselves grant remote byte access." + }) + } + + fn upsert_adapter( + &mut self, + resolver: String, + label: Option, + endpoint_uri: Option, + provider: Option, + state: Option, + capabilities: Vec, + readonly_default: Option, + description: Option, + ) -> Result { + let resolver = normalize_resolver_id(&resolver); + let now = now_unix_secs(); + let existing = self + .adapters + .iter() + .find(|adapter| adapter.resolver == resolver) + .cloned(); + let created_at = existing + .as_ref() + .map(|adapter| adapter.created_at) + .unwrap_or(now); + let adapter = normalize_adapter_record(WebSpaceAdapter { + schema: ADAPTER_RECORD_SCHEMA.to_string(), + resolver, + label: label + .or_else(|| existing.as_ref().map(|adapter| adapter.label.clone())) + .unwrap_or_default(), + endpoint_uri: endpoint_uri.or_else(|| { + existing + .as_ref() + .and_then(|adapter| adapter.endpoint_uri.clone()) + }), + provider: provider.or_else(|| { + existing + .as_ref() + .and_then(|adapter| adapter.provider.clone()) + }), + state: state + .or_else(|| existing.as_ref().map(|adapter| adapter.state.clone())) + .unwrap_or_else(default_adapter_state), + capabilities: if capabilities.is_empty() { + existing + .as_ref() + .map(|adapter| adapter.capabilities.clone()) + .unwrap_or_default() + } else { + capabilities + }, + readonly_default: readonly_default + .or_else(|| existing.as_ref().map(|adapter| adapter.readonly_default)) + .unwrap_or(true), + description: description + .or_else(|| existing.as_ref().map(|adapter| adapter.description.clone())) + .unwrap_or_default(), + created_at, + updated_at: now, + last_checked_at: existing + .as_ref() + .and_then(|adapter| adapter.last_checked_at), + last_check_result: existing + .as_ref() + .and_then(|adapter| adapter.last_check_result.clone()), + last_check_error_code: existing + .as_ref() + .and_then(|adapter| adapter.last_check_error_code.clone()), + })?; + self.adapters + .retain(|existing| existing.resolver != adapter.resolver); + self.adapters.push(adapter.clone()); + self.adapters + .sort_by(|left, right| left.resolver.cmp(&right.resolver)); + self.save_adapters()?; + Ok(adapter) + } + + fn check_adapter( + &mut self, + resolver: String, + result: Option, + state: Option, + error_code: Option, + capabilities: Vec, + ) -> Result { + let resolver = normalize_resolver_id(&resolver); + if resolver == "builtin" { + return Err("built-in WebSpace adapter health is provider-owned".to_string()); + } + let index = self + .adapters + .iter() + .position(|adapter| adapter.resolver == resolver) + .ok_or_else(|| format!("unknown WebSpace adapter resolver: {resolver}"))?; + let now = now_unix_secs(); + let previous = adapter_public_summary(&self.adapters[index]); + let mut adapter = self.adapters[index].clone(); + let result = normalize_adapter_check_result( + result + .as_deref() + .unwrap_or_else(|| default_check_result_for_state(&adapter.state)), + )?; + let next_state = match state { + Some(state) => normalize_adapter_state(&state)?, + None => adapter_state_for_check_result(&result, &adapter.state).to_string(), + }; + let next_error_code = if result == "failed" { + normalize_optional_error_code(error_code)? + .or_else(|| Some("adapter_unavailable".to_string())) + } else { + None + }; + adapter.state = next_state; + if !capabilities.is_empty() { + adapter.capabilities = normalize_adapter_capabilities(capabilities); + } + adapter.last_checked_at = Some(now); + adapter.last_check_result = Some(result); + adapter.last_check_error_code = next_error_code; + adapter.updated_at = now; + let adapter = normalize_adapter_record(adapter)?; + self.adapters[index] = adapter.clone(); + self.adapters + .sort_by(|left, right| left.resolver.cmp(&right.resolver)); + self.save_adapters()?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.adapter-health-receipt/v1", + "action": "checked", + "previous": previous, + "adapter": adapter_public_summary(&adapter), + "byte_traversal_enabled": false, + "note": "Adapter health is a safe resolver-readiness receipt. It does not expose credentials and does not by itself grant remote byte access." + })) + } + + fn unregister_adapter(&mut self, resolver: &str) -> Result { + let resolver = normalize_resolver_id(resolver); + if resolver == "builtin" { + return Err("built-in WebSpace adapter cannot be unregistered".to_string()); + } + let index = self + .adapters + .iter() + .position(|adapter| adapter.resolver == resolver) + .ok_or_else(|| format!("unknown WebSpace adapter resolver: {resolver}"))?; + let adapter = self.adapters.remove(index); + self.save_adapters()?; + Ok(adapter) + } + + fn mount_by_moniker(&self, moniker: &str) -> Option<&WebSpaceMount> { + self.mounts.iter().find(|mount| mount.moniker == moniker) + } + + fn replace_index( + &mut self, + moniker: &str, + entries: Vec, + ) -> Result, String> { + let record = self + .mount_by_moniker(moniker) + .cloned() + .ok_or_else(|| format!("unknown WebSpace moniker: {moniker}"))?; + let now = now_unix_secs(); + let mut normalized = entries + .into_iter() + .map(|entry| normalize_index_input(&record, entry, now)) + .collect::, _>>()?; + normalized.sort_by(|left, right| left.path.cmp(&right.path)); + normalized.dedup_by(|left, right| left.path == right.path); + self.index_entries + .retain(|entry| entry.moniker != record.moniker); + self.index_entries.extend(normalized.clone()); + self.index_entries.sort_by(|left, right| { + left.moniker + .cmp(&right.moniker) + .then_with(|| left.path.cmp(&right.path)) + }); + self.save_indexes()?; + Ok(normalized) + } + + fn exact_index_entry(&self, moniker: &str, parts: &[&str]) -> Option { + let path = normalized_index_path(parts).ok()?; + self.index_entries + .iter() + .find(|entry| entry.moniker == moniker && entry.path == path) + .cloned() + } + + fn has_index_children(&self, moniker: &str, parts: &[&str]) -> bool { + immediate_index_children(&self.index_entries, moniker, parts) + .next() + .is_some() + } + + fn exact_object(&self, moniker: &str, parts: &[&str]) -> Option { + let path = normalized_index_path(parts).ok()?; + self.objects + .iter() + .find(|object| object.moniker == moniker && object.path == path) + .cloned() + } + + fn object_for_handle(&self, handle: &WebSpaceHandle) -> Option { + let parts = handle_index_parts(handle); + let refs = parts.iter().map(String::as_str).collect::>(); + self.exact_object(&handle.moniker, &refs) + } + + fn has_object_children(&self, moniker: &str, parts: &[&str]) -> bool { + immediate_object_children(&self.objects, moniker, parts) + .next() + .is_some() + } + + fn upsert_object(&mut self, object: WebSpaceObject) -> Result { + let object = normalize_object_record(object)?; + self.objects.retain(|existing| { + !(existing.moniker == object.moniker && existing.path == object.path) + }); + self.objects.push(object.clone()); + self.objects.sort_by(|left, right| { + left.moniker + .cmp(&right.moniker) + .then_with(|| left.path.cmp(&right.path)) + }); + self.save_objects()?; + Ok(object) + } + + fn remove_objects( + &mut self, + moniker: &str, + parts: &[String], + recursive: bool, + ) -> Result, String> { + let refs = parts.iter().map(String::as_str).collect::>(); + let path = normalized_index_path(&refs)?; + let child_prefix = format!("{path}/"); + let has_children = self + .objects + .iter() + .any(|object| object.moniker == moniker && object.path.starts_with(&child_prefix)); + if has_children && !recursive { + return Err(format!( + "WebSpace directory is not empty; retry delete with recursive=true: {path}" + )); + } + let mut removed = Vec::new(); + self.objects.retain(|object| { + let matched = object.moniker == moniker + && (object.path == path || object.path.starts_with(&child_prefix)); + if matched { + removed.push(object.clone()); + false + } else { + true + } + }); + if removed.is_empty() { + return Err(format!( + "WebSpace object is resolver-owned or does not exist locally: {path}" + )); + } + self.save_objects()?; + Ok(removed) + } + + fn upsert_mount( + &mut self, + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: Option, + description: Option, + readonly: Option, + cache_policy: Option, + sync_policy: Option, + access_policy: Option, + ) -> Result { + self.upsert_mount_with_fork( + moniker, + target_uri, + namespace_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + fn upsert_mount_with_fork( + &mut self, + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: Option, + description: Option, + readonly: Option, + cache_policy: Option, + sync_policy: Option, + access_policy: Option, + forked_from: Option, + ) -> Result { + let moniker = moniker.trim().to_string(); + if moniker == BUILTIN_MONIKER { + return Err(format!( + "{BUILTIN_MONIKER} is a built-in WebSpace and cannot be remounted" + )); + } + if !valid_moniker(&moniker) { + return Err("WebSpace moniker must be a non-empty single path segment".to_string()); + } + let target_uri = target_uri.trim().trim_end_matches('/').to_string(); + if !target_uri.contains("://") { + return Err("WebSpace target_uri must be a scheme-qualified URI".to_string()); + } + let now = now_unix_secs(); + let readonly = readonly.unwrap_or(true); + let existing_created_at = self + .mounts + .iter() + .find(|mount| mount.moniker == moniker) + .map(|mount| mount.created_at) + .unwrap_or(now); + let record = normalize_mount_record(WebSpaceMount { + schema: MOUNT_RECORD_SCHEMA.to_string(), + moniker, + namespace_uri: namespace_uri.or_else(|| infer_namespace_uri(&target_uri)), + target_uri, + resolver: resolver.unwrap_or_else(default_external_resolver), + readonly, + access_policy: normalized_access_policy(access_policy.as_deref(), readonly), + cache_policy: cache_policy.unwrap_or_else(default_cache_policy), + sync_policy: sync_policy.unwrap_or_else(default_sync_policy), + description: description.unwrap_or_default(), + forked_from, + created_at: existing_created_at, + updated_at: now, + })?; + self.mounts.retain(|mount| mount.moniker != record.moniker); + self.mounts.push(record.clone()); + self.mounts + .sort_by(|left, right| left.moniker.cmp(&right.moniker)); + self.save_mounts()?; + Ok(record) + } + + #[allow(clippy::too_many_arguments)] + fn fork_mount( + &mut self, + source_uri: String, + moniker: String, + target_uri: Option, + resolver: Option, + description: Option, + readonly: Option, + cache_policy: Option, + sync_policy: Option, + access_policy: Option, + ) -> Result<(WebSpaceMount, WebSpaceHead), String> { + let source_uri = rooted_webspace_path(&source_uri); + let source = handle_from_resolved_path(resolve_path(self, &source_uri)?)?; + let moniker = moniker.trim().to_string(); + if self.mounts.iter().any(|mount| mount.moniker == moniker) || moniker == BUILTIN_MONIKER { + return Err(format!( + "WebSpace fork target moniker already exists: {moniker}" + )); + } + let target_uri = target_uri + .or_else(|| source.target_uri.clone()) + .unwrap_or_else(|| source.handle_uri.clone()); + let resolver = resolver.or_else(|| Some(source.resolver.clone())); + let description = description.or_else(|| { + Some(format!( + "Mutable fork of {}. Bytes are not copied until a resolver/sync worker materializes this head.", + source.handle_uri + )) + }); + let record = self.upsert_mount_with_fork( + moniker.clone(), + target_uri, + source.namespace_uri.clone(), + resolver, + description, + Some(readonly.unwrap_or(false)), + cache_policy.or_else(|| Some(source.cache_policy.clone())), + sync_policy.or_else(|| Some(source.sync_policy.clone())), + access_policy.or_else(|| Some(DEFAULT_MUTABLE_ACCESS_POLICY.to_string())), + Some(source.handle_uri.clone()), + )?; + let handle = mount_handle_from_record(&record); + let head = self.upsert_head_for_handle(&handle, "forked_metadata_only", true)?; + Ok((record, head)) + } + + fn unmount(&mut self, moniker: &str) -> Result { + let moniker = moniker.trim(); + if moniker == BUILTIN_MONIKER { + return Err(format!( + "{BUILTIN_MONIKER} is a built-in WebSpace and cannot be unmounted" + )); + } + let index = self + .mounts + .iter() + .position(|mount| mount.moniker == moniker) + .ok_or_else(|| format!("unknown WebSpace moniker: {moniker}"))?; + let record = self.mounts.remove(index); + self.save_mounts()?; + self.index_entries + .retain(|entry| entry.moniker != record.moniker); + if self.index_table_path().is_some() { + self.save_indexes()?; + } + self.heads.retain(|head| head.moniker != record.moniker); + if self.head_table_path().is_some() { + self.save_heads()?; + } + self.objects + .retain(|object| object.moniker != record.moniker); + if self.object_table_path().is_some() { + self.save_objects()?; + } + Ok(record) + } +} + +fn normalize_mount_record(mut record: WebSpaceMount) -> Result { + record.schema = MOUNT_RECORD_SCHEMA.to_string(); + record.moniker = record.moniker.trim().to_string(); + if record.moniker == BUILTIN_MONIKER { + return Err(format!( + "{BUILTIN_MONIKER} is reserved for the built-in WebSpace" + )); + } + if !valid_moniker(&record.moniker) { + return Err(format!("invalid WebSpace moniker: {}", record.moniker)); + } + record.target_uri = record.target_uri.trim().trim_end_matches('/').to_string(); + if !record.target_uri.contains("://") { + return Err(format!( + "WebSpace {} has invalid target_uri: {}", + record.moniker, record.target_uri + )); + } + if record + .namespace_uri + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + record.namespace_uri = infer_namespace_uri(&record.target_uri); + } + record.resolver = normalized_non_empty(&record.resolver, DEFAULT_EXTERNAL_RESOLVER); + record.access_policy = normalized_access_policy(Some(&record.access_policy), record.readonly); + record.cache_policy = normalized_non_empty(&record.cache_policy, DEFAULT_CACHE_POLICY); + record.sync_policy = normalized_non_empty(&record.sync_policy, DEFAULT_SYNC_POLICY); + if record.description.trim().is_empty() { + record.description = format!( + "Mounted WebSpace {} mapped to {}.", + record.moniker, record.target_uri + ); + } else { + record.description = record.description.trim().to_string(); + } + if record.created_at == 0 { + record.created_at = now_unix_secs(); + } + if record.updated_at == 0 { + record.updated_at = record.created_at; + } + Ok(record) +} + +fn normalized_access_policy(value: Option<&str>, readonly: bool) -> String { + let fallback = if readonly { + DEFAULT_READONLY_ACCESS_POLICY + } else { + DEFAULT_MUTABLE_ACCESS_POLICY + }; + let trimmed = value.unwrap_or("").trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn normalized_index_parts(path: &str) -> Result, String> { + let trimmed = path.trim().trim_matches('/'); + if trimmed.is_empty() { + return Err("WebSpace index path must not be empty".to_string()); + } + let mut parts = Vec::new(); + for part in trimmed.split('/').filter(|part| !part.is_empty()) { + let part = part.trim(); + if part == "." || part == ".." || part.contains('\\') || part.contains("://") { + return Err(format!("invalid WebSpace index path segment: {part}")); + } + parts.push(part.to_string()); + } + if parts.is_empty() { + Err("WebSpace index path must not be empty".to_string()) + } else { + Ok(parts) + } +} + +fn normalized_index_path(parts: &[&str]) -> Result { + let joined = parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/"); + normalized_index_parts(&joined).map(|parts| parts.join("/")) +} + +fn normalize_index_kind(kind: &str) -> Result { + match kind.trim().to_ascii_lowercase().as_str() { + "directory" | "folder" => Ok("directory".to_string()), + "file" | "object" => Ok("file".to_string()), + other => Err(format!( + "WebSpace index kind must be directory or file, got {other}" + )), + } +} + +fn normalize_index_input( + mount: &WebSpaceMount, + input: WebSpaceIndexInput, + now: u64, +) -> Result { + let parts = normalized_index_parts(&input.path)?; + let path = parts.join("/"); + let name = parts + .last() + .cloned() + .ok_or_else(|| "WebSpace index path must not be empty".to_string())?; + let kind = normalize_index_kind(&input.kind)?; + let target_uri = input + .target_uri + .map(|target| target.trim().trim_end_matches('/').to_string()) + .filter(|target| !target.is_empty()) + .unwrap_or_else(|| { + append_target_uri( + &mount.target_uri, + &parts.iter().map(String::as_str).collect::>(), + ) + }); + if !target_uri.contains("://") { + return Err(format!( + "WebSpace index target_uri must be scheme-qualified: {target_uri}" + )); + } + normalize_index_record(WebSpaceIndexEntry { + schema: INDEX_ENTRY_SCHEMA.to_string(), + moniker: mount.moniker.clone(), + path, + name, + kind, + target_uri, + resolver: mount.resolver.clone(), + resolver_state: input + .resolver_state + .unwrap_or_else(|| "indexed".to_string()), + readonly: input.readonly.unwrap_or(mount.readonly), + description: input.description.unwrap_or_default(), + updated_at: now, + }) +} + +fn normalize_index_record(mut record: WebSpaceIndexEntry) -> Result { + if !valid_moniker(&record.moniker) || record.moniker == BUILTIN_MONIKER { + return Err(format!( + "invalid WebSpace index moniker: {}", + record.moniker + )); + } + let parts = normalized_index_parts(&record.path)?; + record.schema = INDEX_ENTRY_SCHEMA.to_string(); + record.path = parts.join("/"); + record.name = parts.last().cloned().unwrap_or_else(|| record.path.clone()); + record.kind = normalize_index_kind(&record.kind)?; + record.target_uri = record.target_uri.trim().trim_end_matches('/').to_string(); + if !record.target_uri.contains("://") { + return Err(format!( + "WebSpace index target_uri must be scheme-qualified: {}", + record.target_uri + )); + } + if record.resolver.trim().is_empty() { + record.resolver = default_external_resolver(); + } + if record.resolver_state.trim().is_empty() { + record.resolver_state = "indexed".to_string(); + } + if record.description.trim().is_empty() { + record.description = format!( + "Indexed {} from the {} WebSpace resolver.", + record.kind, record.moniker + ); + } + if record.updated_at == 0 { + record.updated_at = now_unix_secs(); + } + Ok(record) +} + +fn normalize_head_record(mut record: WebSpaceHead) -> WebSpaceHead { + record.schema = HEAD_RECORD_SCHEMA.to_string(); + if record.object_id.trim().is_empty() { + record.object_id = format!( + "object:webspace:{}", + stable_hex(record.target_uri.as_deref().unwrap_or(&record.handle_uri)) + ); + } + if record.head_id.trim().is_empty() { + record.head_id = head_id_for(&record.handle_uri); + } + if record.revision.trim().is_empty() { + record.revision = format!( + "rev:webspace:{}", + stable_hex(&format!("{}:{}", record.handle_uri, record.updated_at)) + ); + } + record.access_policy = normalized_access_policy(Some(&record.access_policy), record.readonly); + record.cache_policy = normalized_non_empty(&record.cache_policy, DEFAULT_CACHE_POLICY); + record.sync_policy = normalized_non_empty(&record.sync_policy, DEFAULT_SYNC_POLICY); + record.cache_state = normalized_non_empty( + &record.cache_state, + &cache_state_for(&record.cache_policy, record.last_cached_at), + ); + record.sync_state = normalized_non_empty( + &record.sync_state, + &sync_state_for(&record.sync_policy, record.dirty, record.last_synced_at), + ); + record.status = normalized_non_empty(&record.status, "metadata_only"); + record +} + +fn normalize_object_record(mut record: WebSpaceObject) -> Result { + if !valid_moniker(&record.moniker) || record.moniker == BUILTIN_MONIKER { + return Err(format!( + "invalid WebSpace object moniker: {}", + record.moniker + )); + } + let parts = normalized_index_parts(&record.path)?; + record.schema = OBJECT_RECORD_SCHEMA.to_string(); + record.path = parts.join("/"); + record.name = parts.last().cloned().unwrap_or_else(|| record.path.clone()); + record.kind = normalize_index_kind(&record.kind)?; + if record.kind == "directory" { + record.content.clear(); + record.mime = "inode/directory".to_string(); + } else if record.mime.trim().is_empty() { + record.mime = "application/octet-stream".to_string(); + } else { + record.mime = record.mime.trim().to_string(); + } + record.target_uri = record + .target_uri + .map(|target_uri| target_uri.trim().trim_end_matches('/').to_string()) + .filter(|target_uri| !target_uri.is_empty()); + if record.created_at == 0 { + record.created_at = now_unix_secs(); + } + if record.updated_at == 0 { + record.updated_at = record.created_at; + } + if record.revision.trim().is_empty() { + record.revision = object_revision(&record); + } + Ok(record) +} + +fn object_revision(object: &WebSpaceObject) -> String { + format!( + "rev:webspace-object:{}", + stable_hex(&format!( + "{}:{}:{}:{}:{}", + object.moniker, + object.path, + object.kind, + object.updated_at, + object.content.len() + )) + ) +} + +fn normalized_non_empty(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn write_json_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, bytes).map_err(|err| { + format!( + "failed to write WebSpace mount table {}: {err}", + tmp.display() + ) + })?; + fs::rename(&tmp, path).map_err(|err| { + format!( + "failed to replace WebSpace mount table {} from {}: {err}", + path.display(), + tmp.display() + ) + }) +} + +fn meta_path(handle: &WebSpaceHandle) -> String { + format!("{}/_meta.json", handle.handle_uri.trim_end_matches('/')) +} + +fn known_mounts(state: &ProviderState) -> Vec { + let mut handles = vec![mount_handle( + "Elastos", + Some("elastos://".to_string()), + "Local interpreted handle into the broader elastos:// namespace.", + Some( + "List this handle to discover typed child spaces such as content, peer, did, and ai." + .to_string(), + ), + )]; + handles.extend(state.user_mounts().iter().map(mount_handle_from_record)); + handles +} + +fn normalize_moniker(moniker: &str) -> String { + moniker + .trim() + .trim_matches('/') + .trim_end_matches("://") + .to_string() +} + +fn normalize_resolver_id(resolver: &str) -> String { + resolver.trim().to_ascii_lowercase() +} + +fn valid_resolver_id(resolver: &str) -> bool { + let trimmed = resolver.trim(); + !trimmed.is_empty() + && !trimmed.contains('/') + && !trimmed.contains('\\') + && !trimmed.contains("://") + && !trimmed.chars().any(char::is_control) +} + +fn normalize_adapter_state(state: &str) -> Result { + match state.trim().to_ascii_lowercase().as_str() { + "" => Ok(DEFAULT_ADAPTER_STATE.to_string()), + "configured" | "connected" | "unavailable" | "disabled" => { + Ok(state.trim().to_ascii_lowercase()) + } + other => Err(format!( + "WebSpace adapter state must be configured, connected, unavailable, or disabled; got {other}" + )), + } +} + +fn normalize_adapter_check_result(result: &str) -> Result { + match result.trim().to_ascii_lowercase().as_str() { + "" => Ok("unknown".to_string()), + "ok" | "failed" | "skipped" | "unknown" => Ok(result.trim().to_ascii_lowercase()), + other => Err(format!( + "WebSpace adapter check result must be ok, failed, skipped, or unknown; got {other}" + )), + } +} + +fn default_check_result_for_state(state: &str) -> &'static str { + match state { + "connected" => "ok", + "unavailable" => "failed", + "disabled" => "skipped", + _ => "unknown", + } +} + +fn adapter_state_for_check_result<'a>(result: &str, current_state: &'a str) -> &'a str { + match result { + "ok" => "connected", + "failed" => "unavailable", + "skipped" => "disabled", + _ => current_state, + } +} + +fn normalize_optional_error_code(value: Option) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim().to_ascii_lowercase(); + if value.is_empty() { + return Ok(None); + } + if value.len() > 96 + || !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + { + return Err( + "WebSpace adapter error_code must be a short opaque code, not a credential or message" + .to_string(), + ); + } + Ok(Some(value)) +} + +fn normalize_adapter_capabilities(capabilities: Vec) -> Vec { + let mut capabilities = capabilities + .into_iter() + .map(|capability| capability.trim().to_ascii_lowercase()) + .filter(|capability| !capability.is_empty()) + .filter(|capability| { + capability + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + }) + .collect::>(); + if capabilities.is_empty() { + capabilities.push("metadata_index".to_string()); + } + capabilities.sort(); + capabilities.dedup(); + capabilities +} + +fn normalize_optional_uri(value: Option) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let trimmed = value.trim().trim_end_matches('/').to_string(); + if trimmed.is_empty() { + return Ok(None); + } + if !trimmed.contains("://") && !trimmed.starts_with("provider:") { + return Err(format!( + "WebSpace adapter endpoint_uri must be scheme-qualified or provider-qualified: {trimmed}" + )); + } + Ok(Some(trimmed)) +} + +fn normalize_optional_label(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_optional_provider(value: Option) -> Option { + value + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) +} + +fn normalize_adapter_record(mut record: WebSpaceAdapter) -> Result { + record.schema = ADAPTER_RECORD_SCHEMA.to_string(); + record.resolver = normalize_resolver_id(&record.resolver); + if record.resolver == "builtin" { + return Err( + "built-in WebSpace adapter is provider-owned and cannot be registered".to_string(), + ); + } + if !valid_resolver_id(&record.resolver) { + return Err(format!( + "invalid WebSpace adapter resolver id: {}", + record.resolver + )); + } + record.label = normalize_optional_label(Some(record.label)) + .unwrap_or_else(|| record.resolver.replace('-', " ")); + record.endpoint_uri = normalize_optional_uri(record.endpoint_uri)?; + record.provider = normalize_optional_provider(record.provider); + record.state = normalize_adapter_state(&record.state)?; + record.capabilities = normalize_adapter_capabilities(record.capabilities); + record.last_check_result = match record.last_check_result.take() { + Some(result) => Some(normalize_adapter_check_result(&result)?), + None => None, + }; + record.last_check_error_code = normalize_optional_error_code(record.last_check_error_code)?; + if record.description.trim().is_empty() { + record.description = format!( + "External WebSpace resolver adapter for {}.", + record.resolver + ); + } else { + record.description = record.description.trim().to_string(); + } + if record.created_at == 0 { + record.created_at = now_unix_secs(); + } + if record.updated_at == 0 { + record.updated_at = record.created_at; + } + Ok(record) +} + +fn redact_endpoint_uri(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() { + return None; + } + let Some((scheme, rest)) = value.split_once("://") else { + return Some(value.to_string()); + }; + let (authority, suffix) = rest.split_once('/').unwrap_or((rest, "")); + let redacted_authority = authority + .rsplit_once('@') + .map(|(_, host)| format!("redacted@{host}")) + .unwrap_or_else(|| authority.to_string()); + if suffix.is_empty() { + Some(format!("{scheme}://{redacted_authority}")) + } else { + Some(format!("{scheme}://{redacted_authority}/{suffix}")) + } +} + +fn builtin_adapter_summary() -> serde_json::Value { + serde_json::json!({ + "schema": ADAPTER_RECORD_SCHEMA, + "resolver": "builtin", + "label": "Built-in ElastOS resolver", + "state": "connected", + "live": true, + "health": { + "schema": "elastos.webspace.adapter-health/v1", + "status": "healthy", + "last_checked_at": serde_json::Value::Null, + "last_result": "ok", + "stale": false, + "next": "Built-in resolver health is provider-owned." + }, + "capabilities": ["metadata_index", "read_descriptor", "local_runtime"], + "readonly_default": true, + "description": "Provider-owned resolver for localhost://WebSpaces/Elastos typed handles." + }) +} + +fn adapter_public_summary(adapter: &WebSpaceAdapter) -> serde_json::Value { + serde_json::json!({ + "schema": ADAPTER_RECORD_SCHEMA, + "resolver": adapter.resolver.as_str(), + "label": adapter.label.as_str(), + "endpoint_uri": redact_endpoint_uri(adapter.endpoint_uri.as_deref()), + "provider": adapter.provider.as_deref(), + "state": adapter.state.as_str(), + "live": adapter.state == "connected", + "health": adapter_health_summary(adapter), + "capabilities": adapter.capabilities.clone(), + "readonly_default": adapter.readonly_default, + "description": adapter.description.as_str(), + "created_at": adapter.created_at, + "updated_at": adapter.updated_at, + }) +} + +fn adapter_health_summary(adapter: &WebSpaceAdapter) -> serde_json::Value { + let now = now_unix_secs(); + let stale = adapter + .last_checked_at + .map(|checked| now.saturating_sub(checked) > ADAPTER_HEALTH_STALE_AFTER_SECS) + .unwrap_or(false); + let result = adapter.last_check_result.as_deref().unwrap_or("unknown"); + let status = if adapter.state == "disabled" { + "disabled" + } else if adapter.state == "unavailable" || result == "failed" { + "unavailable" + } else if stale { + "stale" + } else if result == "ok" { + "healthy" + } else if adapter.state == "connected" { + "connected_unverified" + } else if adapter.state == "configured" { + "configured_unchecked" + } else { + "unknown" + }; + serde_json::json!({ + "schema": "elastos.webspace.adapter-health/v1", + "status": status, + "last_checked_at": adapter.last_checked_at, + "last_result": adapter.last_check_result.as_deref().unwrap_or("unknown"), + "last_error_code": adapter.last_check_error_code.as_deref(), + "stale": stale, + "stale_after_seconds": ADAPTER_HEALTH_STALE_AFTER_SECS, + "next": adapter_health_next_step(status) + }) +} + +fn adapter_health_next_step(status: &str) -> &'static str { + match status { + "healthy" => "Adapter is recently checked; resolver traversal still depends on the adapter implementing requested capabilities.", + "connected_unverified" => "Adapter is marked connected but has no recorded health check; run check_adapter from the adapter/operator plane.", + "configured_unchecked" => "Adapter is configured but unchecked; start the adapter and record check_adapter before relying on live traversal.", + "stale" => "Adapter health is stale; refresh check_adapter before claiming live traversal.", + "unavailable" => "Adapter reported unavailable; inspect adapter/provider health before refresh/cache/sync.", + "disabled" => "Adapter is disabled by policy.", + _ => "Inspect adapter registration before live traversal.", + } +} + +fn adapter_next_step( + resolver: &str, + adapter_state: &str, + index_entry_count: usize, + live_adapter: bool, +) -> String { + if resolver == "builtin" { + return "Built-in resolver metadata is available.".to_string(); + } + if live_adapter { + return "Resolver adapter is connected; refresh/cache/sync can use its provider surface when the adapter implements those capabilities.".to_string(); + } + match adapter_state { + "configured" => { + "Start or connect the registered resolver adapter, then refresh/cache/sync this WebSpace.".to_string() + } + "unavailable" => { + "Registered resolver adapter is unavailable; inspect adapter health before refreshing this WebSpace.".to_string() + } + "disabled" => { + "Registered resolver adapter is disabled by policy; enable or replace it before live traversal.".to_string() + } + "not_registered" if index_entry_count == 0 => { + "Register the named resolver adapter or run a resolver index/refresh for metadata-only traversal.".to_string() + } + "not_registered" => { + "Indexed metadata is available; register/connect the resolver adapter before live byte traversal.".to_string() + } + _ => "Inspect resolver adapter registration before live byte traversal.".to_string(), + } +} + +fn rooted_webspace_path(target: &str) -> String { + if target.starts_with("localhost://") { + target.to_string() + } else { + format!("localhost://WebSpaces/{}", target.trim_matches('/')) + } } -fn resolve_handle(moniker: &str) -> Result { +fn resolve_handle(state: &ProviderState, moniker: &str) -> Result { let normalized = normalize_moniker(moniker); if normalized.is_empty() { return Err("missing WebSpace moniker".to_string()); } - resolve_handle_segments(std::slice::from_ref(&normalized.as_str())) + resolve_handle_segments(state, std::slice::from_ref(&normalized.as_str())) } fn mount_handle( @@ -183,13 +2842,178 @@ fn mount_handle( namespace_uri, target_uri: None, resolver_state: "mounted".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), kind: "dynamic-webspace".to_string(), traversable: true, + size: 0, + object_id: format!( + "object:webspace:{}", + stable_hex(&format!("localhost://WebSpaces/{}", moniker)) + ), + head_id: head_id_for(&format!("localhost://WebSpaces/{}", moniker)), + cache_state: cache_state_for(DEFAULT_CACHE_POLICY, Some(0)), + sync_state: sync_state_for(DEFAULT_SYNC_POLICY, false, None), description: description.to_string(), + forked_from: None, next_step, } } +fn mount_handle_from_record(record: &WebSpaceMount) -> WebSpaceHandle { + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: format!("localhost://WebSpaces/{}", record.moniker), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(record.target_uri.clone()), + resolver_state: if record.readonly { + "mounted-readonly".to_string() + } else { + "mounted-mutable".to_string() + }, + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "mounted-webspace".to_string(), + traversable: true, + size: 0, + object_id: format!("object:webspace:{}", stable_hex(&record.target_uri)), + head_id: head_id_for(&format!("localhost://WebSpaces/{}", record.moniker)), + cache_state: cache_state_for(&record.cache_policy, Some(record.updated_at)), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: record.description.clone(), + forked_from: record.forked_from.clone(), + next_step: Some( + "This mount resolves local WebSpace handles to its target URI. Actual network traversal requires the named resolver/provider to be available." + .to_string(), + ), + } +} + +fn mounted_child_handle(record: &WebSpaceMount, parts: &[&str]) -> WebSpaceHandle { + let target_uri = append_target_uri(&record.target_uri, parts); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + ); + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "mapped-unavailable".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "external-object-handle".to_string(), + traversable: false, + size: render_external_descriptor_size(&handle_uri, Some(&target_uri), "external-object-handle"), + object_id: format!("object:webspace:{}", stable_hex(&target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: cache_state_for(&record.cache_policy, Some(record.updated_at)), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: format!( + "Typed handle mapped through the {} WebSpace. Attach resolver '{}' to open or sync live content.", + record.moniker, record.resolver + ), + forked_from: record.forked_from.clone(), + next_step: Some( + "Live resolver invocation and provider streaming are required before this external handle can stream live content." + .to_string(), + ), + } +} + +fn indexed_entry_handle(record: &WebSpaceMount, entry: &WebSpaceIndexEntry) -> WebSpaceHandle { + let handle_uri = format!("localhost://WebSpaces/{}/{}", record.moniker, entry.path); + let traversable = entry.kind == "directory"; + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(entry.target_uri.clone()), + resolver_state: entry.resolver_state.clone(), + resolver: entry.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: entry.readonly, + access_policy: normalized_access_policy(None, entry.readonly), + kind: if traversable { + "indexed-directory".to_string() + } else { + "indexed-file".to_string() + }, + traversable, + size: if traversable { + 0 + } else { + render_external_descriptor_size(&handle_uri, Some(&entry.target_uri), "indexed-file") + }, + object_id: format!("object:webspace:{}", stable_hex(&entry.target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: cache_state_for(&record.cache_policy, Some(entry.updated_at)), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: entry.description.clone(), + forked_from: record.forked_from.clone(), + next_step: Some( + "This handle came from a resolver index. Attach provider streaming before opening remote bytes." + .to_string(), + ), + } +} + +fn indexed_virtual_folder_handle(record: &WebSpaceMount, parts: &[&str]) -> WebSpaceHandle { + let target_uri = append_target_uri(&record.target_uri, parts); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + ); + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "indexed-virtual".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "indexed-directory".to_string(), + traversable: true, + size: 0, + object_id: format!("object:webspace:{}", stable_hex(&target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: cache_state_for(&record.cache_policy, None), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: format!( + "Virtual folder inferred from the {} WebSpace resolver index.", + record.moniker + ), + forked_from: record.forked_from.clone(), + next_step: Some("List this folder to inspect indexed resolver children.".to_string()), + } +} + fn folder_handle( moniker: &str, handle_uri: String, @@ -197,15 +3021,31 @@ fn folder_handle( description: &str, next_step: Option, ) -> WebSpaceHandle { + let object_id = format!( + "object:webspace:{}", + stable_hex(target_uri.as_deref().unwrap_or(&handle_uri)) + ); + let head_id = head_id_for(&handle_uri); WebSpaceHandle { moniker: moniker.to_string(), - handle_uri, + handle_uri: handle_uri.clone(), namespace_uri: Some("elastos://".to_string()), - target_uri, + target_uri: target_uri.clone(), resolver_state: "resolved".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), kind: "folder-handle".to_string(), traversable: true, + size: 0, + object_id, + head_id, + cache_state: cache_state_for(DEFAULT_CACHE_POLICY, Some(0)), + sync_state: sync_state_for(DEFAULT_SYNC_POLICY, false, None), description: description.to_string(), + forked_from: None, next_step, } } @@ -216,15 +3056,28 @@ fn file_handle( target_uri: String, description: &str, ) -> WebSpaceHandle { + let object_id = format!("object:webspace:{}", stable_hex(&target_uri)); + let head_id = head_id_for(&handle_uri); WebSpaceHandle { moniker: moniker.to_string(), - handle_uri, + handle_uri: handle_uri.clone(), namespace_uri: Some("elastos://".to_string()), - target_uri: Some(target_uri), + target_uri: Some(target_uri.clone()), resolver_state: "resolved".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), kind: "file-endpoint".to_string(), traversable: false, + size: render_external_descriptor_size(&handle_uri, Some(&target_uri), "file-endpoint"), + object_id, + head_id, + cache_state: cache_state_for(DEFAULT_CACHE_POLICY, Some(0)), + sync_state: sync_state_for(DEFAULT_SYNC_POLICY, false, None), description: description.to_string(), + forked_from: None, next_step: Some( "Read this handle for the current descriptor view, or inspect _meta.json for structured metadata." .to_string(), @@ -327,7 +3180,10 @@ fn resolve_elastos_handle(parts: &[&str]) -> Result { } } -fn resolve_handle_segments(parts: &[&str]) -> Result { +fn resolve_handle_segments( + state: &ProviderState, + parts: &[&str], +) -> Result { let Some((moniker, rest)) = parts.split_first() else { return Err("missing WebSpace moniker".to_string()); }; @@ -337,11 +3193,28 @@ fn resolve_handle_segments(parts: &[&str]) -> Result { } match normalized.as_str() { "Elastos" => resolve_elastos_handle(rest), - _ => Err(format!("unknown WebSpace moniker: {}", normalized)), + _ => { + let Some(record) = state.mount_by_moniker(&normalized) else { + return Err(format!("unknown WebSpace moniker: {}", normalized)); + }; + if rest.is_empty() { + Ok(mount_handle_from_record(record)) + } else if let Some(object) = state.exact_object(&record.moniker, rest) { + Ok(materialized_object_handle(record, &object)) + } else if let Some(entry) = state.exact_index_entry(&record.moniker, rest) { + Ok(indexed_entry_handle(record, &entry)) + } else if state.has_object_children(&record.moniker, rest) { + Ok(materialized_virtual_folder_handle(record, rest)) + } else if state.has_index_children(&record.moniker, rest) { + Ok(indexed_virtual_folder_handle(record, rest)) + } else { + Ok(mounted_child_handle(record, rest)) + } + } } } -fn resolve_path(path: &str) -> Result { +fn resolve_path(state: &ProviderState, path: &str) -> Result { let trimmed = path.trim(); let (_, rest) = parse_localhost_uri(trimmed) .or_else(|| parse_localhost_path(trimmed)) @@ -361,7 +3234,7 @@ fn resolve_path(path: &str) -> Result { parts.pop(); } - let handle = resolve_handle_segments(&parts)?; + let handle = resolve_handle_segments(state, &parts)?; if wants_meta { Ok(ResolvedPath::Meta { handle }) } else { @@ -380,9 +3253,227 @@ fn handle_from_resolved_path(resolved: ResolvedPath) -> Result, moniker: Option) -> Result { +fn handle_index_parts(handle: &WebSpaceHandle) -> Vec { + let prefix = format!("localhost://WebSpaces/{}", handle.moniker); + handle + .handle_uri + .strip_prefix(&prefix) + .unwrap_or_default() + .trim_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn immediate_index_children<'a>( + entries: &'a [WebSpaceIndexEntry], + moniker: &'a str, + prefix_parts: &'a [&'a str], +) -> impl Iterator { + entries.iter().filter(move |entry| { + if entry.moniker != moniker { + return false; + } + let Ok(parts) = normalized_index_parts(&entry.path) else { + return false; + }; + parts.len() > prefix_parts.len() + && parts + .iter() + .take(prefix_parts.len()) + .zip(prefix_parts.iter()) + .all(|(left, right)| left == right) + }) +} + +fn immediate_object_children<'a>( + objects: &'a [WebSpaceObject], + moniker: &'a str, + prefix_parts: &'a [&'a str], +) -> impl Iterator { + objects.iter().filter(move |object| { + if object.moniker != moniker { + return false; + } + let Ok(parts) = normalized_index_parts(&object.path) else { + return false; + }; + parts.len() > prefix_parts.len() + && parts + .iter() + .take(prefix_parts.len()) + .zip(prefix_parts.iter()) + .all(|(left, right)| left == right) + }) +} + +fn indexed_child_handles( + state: &ProviderState, + record: &WebSpaceMount, + prefix_parts: &[String], +) -> Vec<(String, WebSpaceHandle)> { + let prefix_refs = prefix_parts.iter().map(String::as_str).collect::>(); + let mut children = BTreeMap::new(); + for entry in immediate_index_children(&state.index_entries, &record.moniker, &prefix_refs) { + let Ok(parts) = normalized_index_parts(&entry.path) else { + continue; + }; + let Some(child_name) = parts.get(prefix_parts.len()).cloned() else { + continue; + }; + if parts.len() == prefix_parts.len() + 1 { + children.insert(child_name, indexed_entry_handle(record, entry)); + } else { + let child_parts = prefix_parts + .iter() + .cloned() + .chain(std::iter::once(child_name.clone())) + .collect::>(); + let child_refs = child_parts.iter().map(String::as_str).collect::>(); + children + .entry(child_name) + .or_insert_with(|| indexed_virtual_folder_handle(record, &child_refs)); + } + } + children.into_iter().collect() +} + +fn materialized_child_handles( + state: &ProviderState, + record: &WebSpaceMount, + prefix_parts: &[String], +) -> Vec<(String, WebSpaceHandle)> { + let prefix_refs = prefix_parts.iter().map(String::as_str).collect::>(); + let mut children = BTreeMap::new(); + for object in immediate_object_children(&state.objects, &record.moniker, &prefix_refs) { + let Ok(parts) = normalized_index_parts(&object.path) else { + continue; + }; + let Some(child_name) = parts.get(prefix_parts.len()).cloned() else { + continue; + }; + if parts.len() == prefix_parts.len() + 1 { + children.insert(child_name, materialized_object_handle(record, object)); + } else { + let child_parts = prefix_parts + .iter() + .cloned() + .chain(std::iter::once(child_name.clone())) + .collect::>(); + let child_refs = child_parts.iter().map(String::as_str).collect::>(); + children + .entry(child_name) + .or_insert_with(|| materialized_virtual_folder_handle(record, &child_refs)); + } + } + children.into_iter().collect() +} + +fn materialized_object_handle(record: &WebSpaceMount, object: &WebSpaceObject) -> WebSpaceHandle { + let parts = normalized_index_parts(&object.path).unwrap_or_else(|_| vec![object.name.clone()]); + let refs = parts.iter().map(String::as_str).collect::>(); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts.join("/") + ); + let target_uri = object + .target_uri + .clone() + .unwrap_or_else(|| append_target_uri(&record.target_uri, &refs)); + let traversable = object.kind == "directory"; + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "materialized-local".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: if traversable { + "materialized-directory".to_string() + } else { + "materialized-file".to_string() + }, + traversable, + size: if traversable { + 0 + } else { + object.content.len() as u64 + }, + object_id: format!( + "object:webspace:{}", + stable_hex(&format!( + "{}:{}:{}", + object.moniker, object.path, object.revision + )) + ), + head_id: head_id_for(&handle_uri), + cache_state: "content_cached".to_string(), + sync_state: sync_state_for(&record.sync_policy, object.dirty, None), + description: format!( + "Local materialized {} in the {} WebSpace.", + object.kind, record.moniker + ), + forked_from: record.forked_from.clone(), + next_step: if object.dirty { + Some("Sync this mutable local object through its resolver when a sync worker is available.".to_string()) + } else { + Some("This object is materialized locally.".to_string()) + }, + } +} + +fn materialized_virtual_folder_handle(record: &WebSpaceMount, parts: &[&str]) -> WebSpaceHandle { + let target_uri = append_target_uri(&record.target_uri, parts); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + ); + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "materialized-virtual".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "materialized-directory".to_string(), + traversable: true, + size: 0, + object_id: format!("object:webspace:{}", stable_hex(&target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: "content_cached".to_string(), + sync_state: sync_state_for(&record.sync_policy, true, None), + description: format!( + "Virtual folder inferred from local materialized objects in the {} WebSpace.", + record.moniker + ), + forked_from: record.forked_from.clone(), + next_step: Some("List this folder to inspect local materialized children.".to_string()), + } +} + +fn resolve_handle_request( + state: &ProviderState, + path: Option, + moniker: Option, +) -> Result { match (path, moniker) { - (Some(path), _) => handle_from_resolved_path(resolve_path(&path)?), + (Some(path), _) => handle_from_resolved_path(resolve_path(state, &path)?), (None, Some(moniker)) => { if moniker.starts_with("localhost://") || moniker.contains('/') { let rooted = if moniker.starts_with("localhost://") { @@ -390,9 +3481,9 @@ fn resolve_handle_request(path: Option, moniker: Option) -> Resu } else { format!("localhost://WebSpaces/{}", moniker) }; - handle_from_resolved_path(resolve_path(&rooted)?) + handle_from_resolved_path(resolve_path(state, &rooted)?) } else { - resolve_handle(&moniker) + resolve_handle(state, &moniker) } } (None, None) => Err("resolve requires path or moniker".to_string()), @@ -406,9 +3497,20 @@ fn render_meta(handle: &WebSpaceHandle) -> Vec { "namespace_uri": handle.namespace_uri, "target_uri": handle.target_uri, "resolver_state": handle.resolver_state, + "resolver": handle.resolver, + "cache_policy": handle.cache_policy, + "sync_policy": handle.sync_policy, + "readonly": handle.readonly, + "access_policy": handle.access_policy, "kind": handle.kind, "traversable": handle.traversable, + "size": handle.size, + "object_id": handle.object_id, + "head_id": handle.head_id, + "cache_state": handle.cache_state, + "sync_state": handle.sync_state, "description": handle.description, + "forked_from": handle.forked_from, "next_step": handle.next_step, "note": "The WebSpace daemon owns the moniker first and returns a typed handle before any further traversal.", })) @@ -422,10 +3524,57 @@ fn render_endpoint(handle: &WebSpaceHandle) -> Vec { "kind": handle.kind, "description": handle.description, "resolver_state": handle.resolver_state, + "resolver": handle.resolver, + "cache_policy": handle.cache_policy, + "sync_policy": handle.sync_policy, + "readonly": handle.readonly, + "access_policy": handle.access_policy, + "size": handle.size, + "object_id": handle.object_id, + "head_id": handle.head_id, + "cache_state": handle.cache_state, + "sync_state": handle.sync_state, })) .unwrap_or_else(|_| b"{}".to_vec()) } +fn cache_status_from_head(head: &WebSpaceHead) -> serde_json::Value { + let content_cached = head.status.starts_with("materialized_"); + serde_json::json!({ + "schema": "elastos.webspace.cache-status/v1", + "handle_uri": head.handle_uri, + "target_uri": head.target_uri, + "object_id": head.object_id, + "head_id": head.head_id, + "access_policy": head.access_policy, + "policy": head.cache_policy, + "state": head.cache_state, + "last_cached_at": head.last_cached_at, + "content_cached": content_cached, + "note": if content_cached { + "Local materialized bytes are present in the WebSpace object table." + } else { + "This slice caches resolver metadata only. Content bytes require a resolver/cache worker." + } + }) +} + +fn sync_status_from_head(head: &WebSpaceHead) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.webspace.sync-status/v1", + "handle_uri": head.handle_uri, + "target_uri": head.target_uri, + "object_id": head.object_id, + "head_id": head.head_id, + "access_policy": head.access_policy, + "policy": head.sync_policy, + "state": head.sync_state, + "dirty": head.dirty, + "last_synced_at": head.last_synced_at, + "note": "This slice records sync intent and dirty state. Live sync requires a resolver/sync worker." + }) +} + fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { match resolved { ResolvedPath::Root => FileStat { @@ -434,6 +3583,20 @@ fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { is_dir: true, size: 0, readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), + provider: PROVIDER_ID.to_string(), + resolver_state: "root".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + kind: "webspace-root".to_string(), + traversable: true, + object_id: "object:webspace:root".to_string(), + head_id: "head:webspace:root".to_string(), + cache_state: "metadata_cached".to_string(), + sync_state: "manual_idle".to_string(), + namespace_uri: None, + target_uri: None, modified: None, created: None, }, @@ -441,12 +3604,22 @@ fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { path: original_path.to_string(), is_file: !handle.traversable, is_dir: handle.traversable, - size: if handle.traversable { - 0 - } else { - render_endpoint(handle).len() as u64 - }, - readonly: true, + size: handle.size, + readonly: handle.readonly, + access_policy: handle.access_policy.clone(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: handle.kind.clone(), + traversable: handle.traversable, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), modified: None, created: None, }, @@ -456,66 +3629,115 @@ fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { is_dir: false, size: render_meta(handle).len() as u64, readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: "metadata".to_string(), + traversable: false, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), modified: None, created: None, }, } } -fn list_for(resolved: &ResolvedPath) -> Result, String> { +fn dir_entry_from_handle(name: &str, handle: WebSpaceHandle) -> DirEntry { + DirEntry { + name: name.to_string(), + is_file: !handle.traversable, + is_dir: handle.traversable, + size: handle.size, + readonly: handle.readonly, + access_policy: handle.access_policy.clone(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: handle.kind.clone(), + traversable: handle.traversable, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), + } +} + +fn meta_dir_entry(handle: &WebSpaceHandle) -> DirEntry { + DirEntry { + name: "_meta.json".to_string(), + is_file: true, + is_dir: false, + size: render_meta(handle).len() as u64, + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: "metadata".to_string(), + traversable: false, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), + } +} + +fn list_for(state: &ProviderState, resolved: &ResolvedPath) -> Result, String> { match resolved { - ResolvedPath::Root => Ok(known_mounts() + ResolvedPath::Root => Ok(known_mounts(state) .into_iter() - .map(|entry| DirEntry { - name: entry.moniker, - is_file: false, - is_dir: true, - size: 0, + .map(|entry| { + let name = entry.moniker.clone(); + dir_entry_from_handle(&name, entry) }) .collect()), ResolvedPath::Handle { handle } if !handle.traversable => { Err(format!("not a directory: {}", handle.handle_uri)) } ResolvedPath::Handle { handle } => { - let mut entries = vec![DirEntry { - name: "_meta.json".to_string(), - is_file: true, - is_dir: false, - size: render_meta(handle).len() as u64, - }]; + let mut entries = vec![meta_dir_entry(handle)]; match handle.handle_uri.as_str() { "localhost://WebSpaces/Elastos" => { - entries.extend([ - DirEntry { - name: "content".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - DirEntry { - name: "peer".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - DirEntry { - name: "did".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - DirEntry { - name: "ai".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - ]); + for child in ["content", "peer", "did", "ai"] { + entries.push(dir_entry_from_handle( + child, + resolve_elastos_handle(&[child])?, + )); + } } _ => {} } + if let Some(record) = state.mount_by_moniker(&handle.moniker) { + let prefix_parts = handle_index_parts(handle); + let mut children = BTreeMap::new(); + for (name, child) in indexed_child_handles(state, record, &prefix_parts) { + children.insert(name, child); + } + for (name, child) in materialized_child_handles(state, record, &prefix_parts) { + children.insert(name, child); + } + for (name, child) in children { + entries.push(dir_entry_from_handle(&name, child)); + } + } + Ok(entries) } ResolvedPath::Meta { handle } => Err(format!("not a directory: {}", meta_path(handle))), @@ -533,15 +3755,36 @@ fn error(code: &str, message: impl Into) -> Response { } } -fn init_payload(config: serde_json::Value) -> serde_json::Value { +fn init_payload(state: &ProviderState) -> serde_json::Value { serde_json::json!({ "protocol_version": "1.0", "provider": "webspace", "kind": "dynamic-resolver", - "config": config, + "schema": "elastos.webspace.provider-init/v1", + "persistent": state.mount_table_path().is_some(), + "mount_table": state + .mount_table_path() + .map(|path| path.display().to_string()), + "head_table": state + .head_table_path() + .map(|path| path.display().to_string()), + "index_table": state + .index_table_path() + .map(|path| path.display().to_string()), + "object_table": state + .object_table_path() + .map(|path| path.display().to_string()), + "adapter_table": state + .adapter_table_path() + .map(|path| path.display().to_string()), + "mount_count": known_mounts(state).len(), + "index_entry_count": state.index_entries.len(), + "head_count": state.heads.len(), + "object_count": state.objects.len(), + "configured_adapter_count": state.adapters.len(), "supported_ops": SUPPORTED_OPS, "unsupported_ops": UNSUPPORTED_OPS, - "surface_note": "Read-only resolver slice: resolve, read, list, stat, and exists return mounted handles or metadata views. Mutation ops are explicit errors.", + "surface_note": "Resolver lifecycle slice: built-in and persisted mounts resolve to typed handles. Registered adapters describe external resolver readiness. Mutable user mounts can materialize local objects; live external traversal and provider streaming remain resolver responsibilities.", }) } @@ -555,6 +3798,7 @@ fn main() { } let stdin = io::stdin(); let mut stdout = io::stdout(); + let mut state = ProviderState::default(); for line in stdin.lock().lines() { let line = match line { @@ -572,14 +3816,29 @@ fn main() { }; let response = match serde_json::from_str::(&line) { - Ok(Request::Init { config }) => ok(init_payload(config)), + Ok(Request::Init { config }) => match state.configure(config) { + Ok(payload) => ok(payload), + Err(err) => error("init_failed", err), + }, Ok(Request::Resolve { path, moniker }) => { - match resolve_handle_request(path, moniker) { + match resolve_handle_request(&state, path, moniker) { Ok(handle) => ok(serde_json::to_value(handle).unwrap_or(serde_json::json!({}))), Err(err) => error("resolve_failed", err), } } - Ok(Request::Read { path, .. }) => match resolve_path(&path) { + Ok(Request::Read { path, .. }) => match resolve_path(&state, &path) { + Ok(ResolvedPath::Handle { handle }) if handle.kind == "materialized-file" => { + match state.object_for_handle(&handle) { + Some(object) => { + let size = object.content.len(); + ok(serde_json::json!({ + "content": object.content, + "size": size, + })) + } + None => error("read_failed", format!("materialized object missing from local table: {}", handle.handle_uri)), + } + } Ok(ResolvedPath::Handle { handle }) if !handle.traversable => ok(serde_json::json!({ "content": render_endpoint(&handle), "size": render_endpoint(&handle).len(), @@ -594,32 +3853,226 @@ fn main() { ), Err(err) => error("read_failed", err), }, - Ok(Request::List { path, .. }) => match resolve_path(&path) { - Ok(resolved) => match list_for(&resolved) { + Ok(Request::List { path, .. }) => match resolve_path(&state, &path) { + Ok(resolved) => match list_for(&state, &resolved) { Ok(entries) => ok(serde_json::to_value(entries).unwrap_or(serde_json::json!([]))), Err(err) => error("list_failed", err), }, Err(err) => error("list_failed", err), }, - Ok(Request::Stat { path, .. }) => match resolve_path(&path) { + Ok(Request::Stat { path, .. }) => match resolve_path(&state, &path) { Ok(resolved) => ok(serde_json::to_value(stat_for(&resolved, &path)).unwrap_or(serde_json::json!({}))), Err(err) => error("stat_failed", err), }, Ok(Request::Exists { path, .. }) => ok(serde_json::json!({ - "exists": resolve_path(&path).is_ok(), + "exists": resolve_path(&state, &path).is_ok(), })), - Ok(Request::Write { path, .. }) => error( - "write_failed", - format!("WebSpaces are resolver-owned handles, not ordinary writable storage: {}", path), - ), - Ok(Request::Delete { path, .. }) => error( - "delete_failed", - format!("WebSpaces are resolver-owned handles, not ordinary deletable storage: {}", path), - ), - Ok(Request::Mkdir { path, .. }) => error( - "mkdir_failed", - format!("WebSpaces are resolver-owned handles, not ordinary directories: {}", path), - ), + Ok(Request::Head { path, .. }) => match resolve_path(&state, &path) + .and_then(handle_from_resolved_path) + .and_then(|handle| state.upsert_head_for_handle(&handle, "metadata_only", false)) + { + Ok(head) => ok(serde_json::to_value(head).unwrap_or(serde_json::json!({}))), + Err(err) => error("head_failed", err), + }, + Ok(Request::CacheStatus { path, .. }) => match resolve_path(&state, &path) + .and_then(handle_from_resolved_path) + .and_then(|handle| state.upsert_head_for_handle(&handle, "metadata_only", false)) + { + Ok(head) => ok(cache_status_from_head(&head)), + Err(err) => error("cache_status_failed", err), + }, + Ok(Request::SyncStatus { path, .. }) => match resolve_path(&state, &path) + .and_then(handle_from_resolved_path) + .and_then(|handle| state.upsert_head_for_handle(&handle, "metadata_only", false)) + { + Ok(head) => ok(sync_status_from_head(&head)), + Err(err) => error("sync_status_failed", err), + }, + Ok(Request::Mounts { .. }) => ok(serde_json::json!({ + "schema": MOUNT_TABLE_SCHEMA, + "mounts": known_mounts(&state), + "user_mounts": state.user_mounts(), + })), + Ok(Request::Adapters { .. }) => ok(state.adapter_summary_table()), + Ok(Request::RegisterAdapter { + resolver, + label, + endpoint_uri, + provider, + state: adapter_state, + capabilities, + readonly_default, + description, + .. + }) => match state.upsert_adapter( + resolver, + label, + endpoint_uri, + provider, + adapter_state, + capabilities, + readonly_default, + description, + ) { + Ok(adapter) => ok(serde_json::json!({ + "schema": "elastos.webspace.adapter-receipt/v1", + "action": "registered", + "adapter": adapter_public_summary(&adapter), + })), + Err(err) => error("register_adapter_failed", err), + }, + Ok(Request::UnregisterAdapter { resolver, .. }) => { + match state.unregister_adapter(&resolver) { + Ok(adapter) => ok(serde_json::json!({ + "schema": "elastos.webspace.adapter-receipt/v1", + "action": "unregistered", + "adapter": adapter_public_summary(&adapter), + })), + Err(err) => error("unregister_adapter_failed", err), + } + } + Ok(Request::CheckAdapter { + resolver, + result, + state: adapter_state, + error_code, + capabilities, + .. + }) => match state.check_adapter( + resolver, + result, + adapter_state, + error_code, + capabilities, + ) { + Ok(receipt) => ok(receipt), + Err(err) => error("check_adapter_failed", err), + }, + Ok(Request::Health { moniker, .. }) => match state.health_report(moniker) { + Ok(report) => ok(report), + Err(err) => error("health_failed", err), + }, + Ok(Request::Mount { + moniker, + target_uri, + namespace_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + .. + }) => match state.upsert_mount( + moniker, + target_uri, + namespace_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + ) { + Ok(record) => ok(serde_json::json!({ + "schema": "elastos.webspace.mount-receipt/v1", + "action": "mounted", + "mount": record, + })), + Err(err) => error("mount_failed", err), + }, + Ok(Request::Unmount { moniker, .. }) => match state.unmount(&moniker) { + Ok(record) => ok(serde_json::json!({ + "schema": "elastos.webspace.mount-receipt/v1", + "action": "unmounted", + "mount": record, + })), + Err(err) => error("unmount_failed", err), + }, + Ok(Request::Index { + moniker, entries, .. + }) => match state.replace_index(&moniker, entries) { + Ok(entries) => ok(serde_json::json!({ + "schema": "elastos.webspace.index-receipt/v1", + "action": "indexed", + "moniker": moniker, + "entry_count": entries.len(), + "entries": entries, + })), + Err(err) => error("index_failed", err), + }, + Ok(Request::Refresh { path, entries, .. }) => match state.refresh_handle(path, entries) + { + Ok(receipt) => ok(receipt), + Err(err) => error("refresh_failed", err), + }, + Ok(Request::Fork { + source_uri, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + .. + }) => match state.fork_mount( + source_uri, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + ) { + Ok((record, head)) => ok(serde_json::json!({ + "schema": "elastos.webspace.fork-receipt/v1", + "action": "forked", + "mount": record, + "head": head, + "materialized": false, + "next_step": "Attach a resolver/cache worker to materialize bytes and sync the fork." + })), + Err(err) => error("fork_failed", err), + }, + Ok(Request::Cache { + path, + content, + mime, + source_receipt, + .. + }) => match state.cache_handle(path, content, mime, source_receipt) { + Ok(receipt) => ok(receipt), + Err(err) => error("cache_failed", err), + }, + Ok(Request::Sync { path, .. }) => match state.sync_handle(path) { + Ok(receipt) => ok(receipt), + Err(err) => error("sync_failed", err), + }, + Ok(Request::Write { + path, + content, + append, + .. + }) => match state.write_handle(path, content, append) { + Ok(receipt) => ok(receipt), + Err(err) => error("write_failed", err), + }, + Ok(Request::Delete { + path, recursive, .. + }) => match state.delete_handle(path, recursive) { + Ok(receipt) => ok(receipt), + Err(err) => error("delete_failed", err), + }, + Ok(Request::Mkdir { + path, parents, .. + }) => match state.mkdir_handle(path, parents) { + Ok(receipt) => ok(receipt), + Err(err) => error("mkdir_failed", err), + }, Ok(Request::Ping) => ok(serde_json::json!({ "pong": true })), Ok(Request::Shutdown) => { let response = ok(serde_json::json!({ @@ -645,15 +4098,30 @@ fn main() { mod tests { use super::*; + fn state() -> ProviderState { + ProviderState::default() + } + + fn temp_base(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "elastos-webspace-provider-{name}-{}", + now_unix_secs() + )); + let _ = fs::remove_dir_all(&path); + path + } + #[test] fn resolves_elastos_mount() { - let resolved = - resolve_path("localhost://WebSpaces/Elastos").expect("should resolve Elastos mount"); + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos") + .expect("should resolve Elastos mount"); match resolved { ResolvedPath::Handle { handle } => { assert_eq!(handle.handle_uri, "localhost://WebSpaces/Elastos"); assert!(handle.traversable); assert_eq!(handle.kind, "dynamic-webspace"); + assert_eq!(handle.resolver, "builtin"); } _ => panic!("expected mounted handle"), } @@ -661,7 +4129,8 @@ mod tests { #[test] fn resolves_content_endpoint() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/content/QmExampleCid") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/content/QmExampleCid") .expect("should resolve content endpoint"); match resolved { ResolvedPath::Handle { handle } => { @@ -675,7 +4144,8 @@ mod tests { #[test] fn resolves_peer_folder() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/peer/alice") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/peer/alice") .expect("should resolve peer folder"); match resolved { ResolvedPath::Handle { handle } => { @@ -689,28 +4159,34 @@ mod tests { #[test] fn rejects_deeper_peer_traversal() { - let err = resolve_path("localhost://WebSpaces/Elastos/peer/alice/messages") + let state = state(); + let err = resolve_path(&state, "localhost://WebSpaces/Elastos/peer/alice/messages") .expect_err("deeper peer traversal should fail"); assert!(err.contains("peer handles do not support traversal")); } #[test] fn rejects_deeper_did_traversal() { - let err = resolve_path("localhost://WebSpaces/Elastos/did/did:key:z6Mk/example") - .expect_err("deeper did traversal should fail"); + let state = state(); + let err = resolve_path( + &state, + "localhost://WebSpaces/Elastos/did/did:key:z6Mk/example", + ) + .expect_err("deeper did traversal should fail"); assert!(err.contains("did handles do not support traversal")); } #[test] fn rejects_deeper_ai_traversal() { - let err = resolve_path("localhost://WebSpaces/Elastos/ai/openai/gpt-5.4") + let state = state(); + let err = resolve_path(&state, "localhost://WebSpaces/Elastos/ai/openai/gpt-5.4") .expect_err("deeper ai traversal should fail"); assert!(err.contains("ai handles do not support traversal")); } #[test] fn init_payload_advertises_supported_and_unsupported_ops() { - let payload = init_payload(serde_json::json!({"seeded": true})); + let payload = init_payload(&state()); let supported = payload["supported_ops"] .as_array() .expect("supported ops should be an array"); @@ -720,43 +4196,105 @@ mod tests { assert!(supported.iter().any(|value| value == "resolve")); assert!(supported.iter().any(|value| value == "read")); - assert!(unsupported.iter().any(|value| value == "write")); - assert!(unsupported.iter().any(|value| value == "delete")); - assert!(unsupported.iter().any(|value| value == "mkdir")); + assert!(supported.iter().any(|value| value == "mount")); + assert!(supported.iter().any(|value| value == "adapters")); + assert!(supported.iter().any(|value| value == "register_adapter")); + assert!(supported.iter().any(|value| value == "unregister_adapter")); + assert!(supported.iter().any(|value| value == "check_adapter")); + assert!(supported.iter().any(|value| value == "unmount")); + assert!(supported.iter().any(|value| value == "index")); + assert!(supported.iter().any(|value| value == "health")); + assert!(supported.iter().any(|value| value == "refresh")); + assert!(supported.iter().any(|value| value == "head")); + assert!(supported.iter().any(|value| value == "cache")); + assert!(supported.iter().any(|value| value == "cache_status")); + assert!(supported.iter().any(|value| value == "sync")); + assert!(supported.iter().any(|value| value == "sync_status")); + assert!(supported.iter().any(|value| value == "fork")); + assert!(supported.iter().any(|value| value == "write")); + assert!(supported.iter().any(|value| value == "delete")); + assert!(supported.iter().any(|value| value == "mkdir")); + assert!(unsupported.is_empty()); + } + + #[test] + fn lists_root_mounts() { + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces").expect("should resolve root"); + let entries = list_for(&state, &resolved).expect("should list root mounts"); + let names: Vec<_> = entries.iter().map(|entry| entry.name.clone()).collect(); + assert!(names.contains(&"Elastos".to_string())); } #[test] fn lists_elastos_children() { - let resolved = - resolve_path("localhost://WebSpaces/Elastos").expect("should resolve Elastos mount"); - let entries = list_for(&resolved).expect("should list Elastos children"); - let names: Vec<_> = entries.into_iter().map(|entry| entry.name).collect(); + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos") + .expect("should resolve Elastos mount"); + let entries = list_for(&state, &resolved).expect("should list Elastos children"); + let names: Vec<_> = entries.iter().map(|entry| entry.name.clone()).collect(); assert!(names.contains(&"_meta.json".to_string())); assert!(names.contains(&"content".to_string())); assert!(names.contains(&"peer".to_string())); assert!(names.contains(&"did".to_string())); assert!(names.contains(&"ai".to_string())); + let content = entries + .iter() + .find(|entry| entry.name == "content") + .expect("content child should be listed"); + assert_eq!(content.provider, "webspace-provider"); + assert_eq!(content.resolver_state, "resolved"); + assert_eq!(content.resolver, "builtin"); + assert_eq!(content.kind, "folder-handle"); + assert_eq!(content.target_uri.as_deref(), Some("elastos://")); + assert!(content.readonly); + } + + #[test] + fn stat_exposes_resolver_metadata() { + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/content/QmExampleCid") + .expect("should resolve content endpoint"); + let stat = stat_for( + &resolved, + "localhost://WebSpaces/Elastos/content/QmExampleCid", + ); + assert_eq!(stat.provider, "webspace-provider"); + assert_eq!(stat.resolver_state, "resolved"); + assert_eq!(stat.resolver, "builtin"); + assert_eq!(stat.kind, "file-endpoint"); + assert_eq!(stat.target_uri.as_deref(), Some("elastos://QmExampleCid")); + assert!(stat.object_id.starts_with("object:webspace:")); + assert!(stat.head_id.starts_with("head:webspace:")); + assert_eq!(stat.cache_state, "metadata_cached"); + assert_eq!(stat.sync_state, "manual_idle"); + assert!(!stat.traversable); + assert!(stat.readonly); } #[test] fn listing_content_endpoint_fails() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/content/QmExampleCid") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/content/QmExampleCid") .expect("should resolve content endpoint"); - assert!(list_for(&resolved).is_err()); + assert!(list_for(&state, &resolved).is_err()); } #[test] fn listing_meta_path_fails() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/_meta.json") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/_meta.json") .expect("should resolve metadata path"); - let err = list_for(&resolved).expect_err("metadata path should not list"); + let err = list_for(&state, &resolved).expect_err("metadata path should not list"); assert!(err.contains("not a directory")); assert!(err.contains("_meta.json")); } #[test] fn resolve_request_rejects_meta_path() { + let state = state(); let err = resolve_handle_request( + &state, Some("localhost://WebSpaces/Elastos/_meta.json".to_string()), None, ) @@ -764,4 +4302,934 @@ mod tests { assert!(err.contains("not metadata files")); assert!(err.contains("_meta.json")); } + + #[test] + fn custom_mount_persists_and_resolves_mapped_handles() { + let base = temp_base("persist"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + let record = state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + assert_eq!(record.moniker, "Google"); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + let root = resolve_path(&reloaded, "localhost://WebSpaces").expect("root should resolve"); + let entries = list_for(&reloaded, &root).expect("root should list"); + assert!(entries.iter().any(|entry| entry.name == "Google")); + + let resolved = resolve_path( + &reloaded, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("mapped child should resolve to external handle"); + match resolved { + ResolvedPath::Handle { handle } => { + assert_eq!(handle.kind, "external-object-handle"); + assert_eq!( + handle.target_uri.as_deref(), + Some("google://drive/Drive/Project X/file.pdf") + ); + assert_eq!(handle.resolver, "google-drive"); + assert_eq!(handle.resolver_state, "mapped-unavailable"); + assert!(!handle.traversable); + } + _ => panic!("expected external mapped handle"), + } + let _ = fs::remove_dir_all(base); + } + + #[test] + fn head_persists_for_custom_mapped_handle() { + let base = temp_base("heads"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + let handle = handle_from_resolved_path( + resolve_path( + &state, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("mapped child should resolve"), + ) + .expect("resolved path should be a handle"); + let head = state + .upsert_head_for_handle(&handle, "metadata_only", false) + .expect("head should persist"); + assert_eq!(head.schema, HEAD_RECORD_SCHEMA); + assert_eq!(head.handle_uri, handle.handle_uri); + assert!(head.object_id.starts_with("object:webspace:")); + assert!(head.head_id.starts_with("head:webspace:")); + assert_eq!(head.cache_state, "metadata_cached"); + assert_eq!(head.sync_state, "manual_idle"); + assert!(!head.dirty); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert_eq!(reloaded.heads.len(), 1); + assert_eq!(reloaded.heads[0].handle_uri, head.handle_uri); + assert_eq!(reloaded.heads[0].head_id, head.head_id); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn custom_mount_index_persists_and_lists_children() { + let base = temp_base("index"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + let indexed = state + .replace_index( + "Google", + vec![ + WebSpaceIndexInput { + path: "Drive".to_string(), + kind: "directory".to_string(), + target_uri: None, + resolver_state: Some("indexed".to_string()), + readonly: None, + description: Some("Drive folder from resolver index.".to_string()), + }, + WebSpaceIndexInput { + path: "Drive/Project X/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("indexed".to_string()), + readonly: None, + description: Some("Project file from resolver index.".to_string()), + }, + WebSpaceIndexInput { + path: "Shared/report.md".to_string(), + kind: "file".to_string(), + target_uri: Some("google://drive/shared/report.md".to_string()), + resolver_state: Some("indexed".to_string()), + readonly: None, + description: Some("Shared report from resolver index.".to_string()), + }, + ], + ) + .expect("index should persist"); + assert_eq!(indexed.len(), 3); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert_eq!(reloaded.index_entries.len(), 3); + + let google = resolve_path(&reloaded, "localhost://WebSpaces/Google") + .expect("Google mount should resolve"); + let entries = list_for(&reloaded, &google).expect("Google mount should list"); + let names: Vec<_> = entries.iter().map(|entry| entry.name.as_str()).collect(); + assert!(names.contains(&"_meta.json")); + assert!(names.contains(&"Drive")); + assert!(names.contains(&"Shared")); + let shared = entries + .iter() + .find(|entry| entry.name == "Shared") + .expect("implicit parent folder should be listed"); + assert_eq!(shared.kind, "indexed-directory"); + assert_eq!(shared.resolver_state, "indexed-virtual"); + + let project = resolve_path(&reloaded, "localhost://WebSpaces/Google/Drive/Project X") + .expect("indexed virtual project folder should resolve"); + let project_entries = list_for(&reloaded, &project).expect("project folder should list"); + assert!(project_entries.iter().any(|entry| entry.name == "file.pdf")); + + let file = resolve_path( + &reloaded, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("indexed file should resolve"); + match file { + ResolvedPath::Handle { handle } => { + assert_eq!(handle.kind, "indexed-file"); + assert_eq!(handle.resolver_state, "indexed"); + assert!(!handle.traversable); + assert_eq!( + handle.target_uri.as_deref(), + Some("google://drive/Drive/Project X/file.pdf") + ); + assert_eq!(handle.resolver, "google-drive"); + } + _ => panic!("expected indexed file handle"), + } + let _ = fs::remove_dir_all(base); + } + + #[test] + fn refresh_replaces_index_and_persists_refreshed_head() { + let base = temp_base("refresh"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + state + .replace_index( + "Google", + vec![WebSpaceIndexInput { + path: "Old/file.txt".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("stale".to_string()), + readonly: None, + description: None, + }], + ) + .expect("initial index should persist"); + + let receipt = state + .refresh_handle( + "localhost://WebSpaces/Google".to_string(), + Some(vec![ + WebSpaceIndexInput { + path: "Drive".to_string(), + kind: "directory".to_string(), + target_uri: None, + resolver_state: Some("refreshed".to_string()), + readonly: None, + description: Some("Drive folder refreshed from resolver.".to_string()), + }, + WebSpaceIndexInput { + path: "Drive/Project X/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("refreshed".to_string()), + readonly: None, + description: Some("Project file refreshed from resolver.".to_string()), + }, + ]), + ) + .expect("refresh should persist index and head"); + + assert_eq!(receipt["schema"], "elastos.webspace.refresh-receipt/v1"); + assert_eq!(receipt["index_entry_count"], 2); + assert_eq!(receipt["head"]["status"], "resolver_refreshed"); + assert_eq!(receipt["head"]["cache_state"], "metadata_cached"); + assert!(receipt["head"]["last_cached_at"].as_u64().unwrap_or(0) > 0); + assert_eq!(state.index_entries.len(), 2); + assert!(!state + .index_entries + .iter() + .any(|entry| entry.path == "Old/file.txt")); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert_eq!(reloaded.index_entries.len(), 2); + assert_eq!(reloaded.heads.len(), 1); + assert_eq!(reloaded.heads[0].status, "resolver_refreshed"); + + let project = resolve_path(&reloaded, "localhost://WebSpaces/Google/Drive/Project X") + .expect("refreshed project folder should resolve"); + let entries = list_for(&reloaded, &project).expect("project folder should list"); + let file = entries + .iter() + .find(|entry| entry.name == "file.pdf") + .expect("refreshed file should be listed"); + assert_eq!(file.resolver_state, "refreshed"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn health_reports_external_resolver_attention_and_metadata_readiness() { + let base = temp_base("health"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + + let attention = state.health_report(None).expect("health should report"); + assert_eq!(attention["schema"], "elastos.webspace.health/v1"); + assert_eq!(attention["state"], "attention"); + assert_eq!(attention["live_adapter_count"], 1); + let google = attention["mounts"] + .as_array() + .unwrap() + .iter() + .find(|mount| mount["moniker"] == "Google") + .expect("Google health should be listed"); + assert_eq!(google["state"], "mounted_no_index"); + assert_eq!(google["live_adapter"], false); + assert_eq!(google["adapter_state"], "not_registered"); + + state + .refresh_handle( + "Google".to_string(), + Some(vec![WebSpaceIndexInput { + path: "Drive/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("refreshed".to_string()), + readonly: None, + description: None, + }]), + ) + .expect("refresh should make metadata ready"); + let ready = state + .health_report(Some("Google".to_string())) + .expect("filtered health should report"); + assert_eq!(ready["state"], "metadata_ready"); + assert_eq!(ready["mount_count"], 1); + assert_eq!(ready["user_mount_count"], 1); + assert_eq!(ready["index_entry_count"], 1); + assert_eq!(ready["head_count"], 1); + assert_eq!(ready["dirty_head_count"], 0); + assert_eq!(ready["mounts"][0]["state"], "metadata_ready"); + assert_eq!(ready["mounts"][0]["index_entry_count"], 1); + + state + .fork_mount( + "localhost://WebSpaces/Google/Drive/file.pdf".to_string(), + "ProjectFork".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect("fork should create a dirty mutable head"); + let dirty = state + .health_report(None) + .expect("dirty fork health should report"); + assert_eq!(dirty["state"], "attention"); + assert_eq!(dirty["dirty_head_count"], 1); + let fork = dirty["mounts"] + .as_array() + .unwrap() + .iter() + .find(|mount| mount["moniker"] == "ProjectFork") + .expect("fork health should be listed"); + assert_eq!(fork["state"], "dirty"); + assert_eq!(fork["dirty_head_count"], 1); + + let google_still_ready = state + .health_report(Some("Google".to_string())) + .expect("filtered Google health should ignore fork heads"); + assert_eq!(google_still_ready["state"], "metadata_ready"); + assert_eq!(google_still_ready["dirty_head_count"], 0); + + state + .sync_handle("localhost://WebSpaces/ProjectFork".to_string()) + .expect("sync should clear the dirty fork head"); + let synced = state + .health_report(Some("ProjectFork".to_string())) + .expect("synced fork health should report"); + assert_eq!(synced["state"], "metadata_ready"); + assert_eq!(synced["dirty_head_count"], 0); + assert_eq!(synced["mounts"][0]["state"], "metadata_ready"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn adapter_registry_persists_and_informs_health() { + let base = temp_base("adapter_registry"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + let adapter = state + .upsert_adapter( + "google-drive".to_string(), + Some("Google Drive".to_string()), + Some("https://token:secret@example.test/drive".to_string()), + Some("google-drive-provider".to_string()), + Some("connected".to_string()), + vec!["metadata_index".to_string(), "read_bytes".to_string()], + Some(true), + Some("Google Drive resolver adapter.".to_string()), + ) + .expect("adapter should register"); + assert_eq!(adapter.resolver, "google-drive"); + assert_eq!(adapter.state, "connected"); + assert_eq!(adapter.capabilities, vec!["metadata_index", "read_bytes"]); + + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + None, + Some(true), + None, + None, + None, + ) + .expect("mount should persist"); + + let health = state.health_report(None).expect("health should report"); + assert_eq!(health["live_adapter_count"], 2); + assert_eq!(health["connected_adapter_count"], 1); + assert_eq!(health["checked_adapter_count"], 0); + let google = health["mounts"] + .as_array() + .unwrap() + .iter() + .find(|mount| mount["moniker"] == "Google") + .expect("Google mount should be listed"); + assert_eq!(google["live_adapter"], true); + assert_eq!(google["adapter_state"], "connected"); + assert_eq!(google["adapter"]["provider"], "google-drive-provider"); + assert_eq!( + google["adapter"]["health"]["status"], + "connected_unverified" + ); + assert_eq!( + google["adapter"]["endpoint_uri"], + "https://redacted@example.test/drive" + ); + + let failed = state + .check_adapter( + "google-drive".to_string(), + Some("failed".to_string()), + None, + Some("upstream_timeout".to_string()), + Vec::new(), + ) + .expect("adapter health check should persist"); + assert_eq!( + failed["schema"], + "elastos.webspace.adapter-health-receipt/v1" + ); + assert_eq!(failed["adapter"]["state"], "unavailable"); + assert_eq!(failed["adapter"]["health"]["status"], "unavailable"); + assert_eq!( + failed["adapter"]["health"]["last_error_code"], + "upstream_timeout" + ); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("reload should configure persistent path"); + assert_eq!(reloaded.adapters.len(), 1); + assert_eq!(reloaded.adapters[0].resolver, "google-drive"); + assert_eq!(reloaded.adapters[0].state, "unavailable"); + assert_eq!( + reloaded.adapters[0].last_check_error_code.as_deref(), + Some("upstream_timeout") + ); + let checked_health = reloaded + .health_report(Some("Google".to_string())) + .expect("checked health should report"); + assert_eq!(checked_health["connected_adapter_count"], 0); + assert_eq!(checked_health["checked_adapter_count"], 1); + assert_eq!(checked_health["mounts"][0]["adapter_state"], "unavailable"); + assert_eq!( + checked_health["mounts"][0]["adapter"]["health"]["status"], + "unavailable" + ); + + let removed = reloaded + .unregister_adapter("google-drive") + .expect("adapter should unregister"); + assert_eq!(removed.resolver, "google-drive"); + assert!(reloaded.adapters.is_empty()); + let health = reloaded + .health_report(Some("Google".to_string())) + .expect("health should report after unregister"); + assert_eq!(health["mounts"][0]["adapter_state"], "not_registered"); + assert_eq!(health["mounts"][0]["live_adapter"], false); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn unmount_removes_index_and_head_state() { + let base = temp_base("unmount-index"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + state + .replace_index( + "Google", + vec![WebSpaceIndexInput { + path: "Drive/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("indexed".to_string()), + readonly: None, + description: None, + }], + ) + .expect("index should persist"); + let handle = handle_from_resolved_path( + resolve_path(&state, "localhost://WebSpaces/Google/Drive/file.pdf") + .expect("indexed file should resolve"), + ) + .expect("resolved path should be a handle"); + state + .upsert_head_for_handle(&handle, "metadata_only", false) + .expect("head should persist"); + assert_eq!(state.index_entries.len(), 1); + assert_eq!(state.heads.len(), 1); + + state.unmount("Google").expect("unmount should succeed"); + assert!(state.index_entries.is_empty()); + assert!(state.heads.is_empty()); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert!(reloaded.index_entries.is_empty()); + assert!(reloaded.heads.is_empty()); + let root = resolve_path(&reloaded, "localhost://WebSpaces").expect("root should resolve"); + let entries = list_for(&reloaded, &root).expect("root should list"); + assert!(!entries.iter().any(|entry| entry.name == "Google")); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn fork_creates_mutable_mount_and_dirty_head() { + let base = temp_base("fork"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + + let (record, head) = state + .fork_mount( + "localhost://WebSpaces/Google/Drive/Project X/file.pdf".to_string(), + "ProjectFork".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect("fork should create mutable WebSpace mount"); + + assert_eq!(record.moniker, "ProjectFork"); + assert!(!record.readonly); + assert_eq!( + record.forked_from.as_deref(), + Some("localhost://WebSpaces/Google/Drive/Project X/file.pdf") + ); + assert_eq!(head.status, "forked_metadata_only"); + assert!(head.dirty); + assert_eq!(head.sync_state, "manual_pending"); + assert_eq!(head.handle_uri, "localhost://WebSpaces/ProjectFork"); + + let fork = handle_from_resolved_path( + resolve_path(&state, "localhost://WebSpaces/ProjectFork") + .expect("forked mount should resolve"), + ) + .expect("forked mount should be a handle"); + assert_eq!(fork.resolver_state, "mounted-mutable"); + assert_eq!( + fork.forked_from.as_deref(), + Some("localhost://WebSpaces/Google/Drive/Project X/file.pdf") + ); + let cache = cache_status_from_head(&head); + assert_eq!(cache["content_cached"], false); + let sync = sync_status_from_head(&head); + assert_eq!(sync["dirty"], true); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn sync_clears_dirty_fork_head_without_claiming_byte_sync() { + let base = temp_base("sync-fork"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + let (_record, forked_head) = state + .fork_mount( + "localhost://WebSpaces/Google/Drive/Project X/file.pdf".to_string(), + "ProjectFork".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect("fork should create dirty head"); + assert!(forked_head.dirty); + + let receipt = state + .sync_handle("localhost://WebSpaces/ProjectFork".to_string()) + .expect("sync should persist a clean metadata head"); + assert_eq!(receipt["schema"], "elastos.webspace.sync-receipt/v1"); + assert_eq!(receipt["content_synced"], false); + assert_eq!(receipt["head"]["status"], "metadata_synced"); + assert_eq!(receipt["head"]["dirty"], false); + assert_eq!(receipt["head"]["sync_state"], "manual_synced"); + assert!(receipt["head"]["last_synced_at"].as_u64().unwrap_or(0) > 0); + + let health = state + .health_report(Some("ProjectFork".to_string())) + .expect("fork health should report"); + assert_eq!(health["state"], "metadata_ready"); + assert_eq!(health["mounts"][0]["dirty_head_count"], 0); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + let synced = reloaded + .heads + .iter() + .find(|head| head.handle_uri == "localhost://WebSpaces/ProjectFork") + .expect("synced fork head should persist"); + assert!(!synced.dirty); + assert_eq!(synced.status, "metadata_synced"); + assert_eq!(synced.sync_state, "manual_synced"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn mutable_mount_materializes_objects_and_persists_them() { + let base = temp_base("materialized"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + let record = state + .upsert_mount( + "Project".to_string(), + "local://project".to_string(), + None, + Some("local-materialized".to_string()), + Some("Mutable project WebSpace.".to_string()), + Some(false), + Some("metadata-and-bytes".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mutable mount should persist"); + assert!(!record.readonly); + assert_eq!(record.access_policy, DEFAULT_MUTABLE_ACCESS_POLICY); + + let mkdir = state + .mkdir_handle("localhost://WebSpaces/Project/Docs".to_string(), false) + .expect("mkdir should materialize a local directory"); + assert_eq!(mkdir["schema"], "elastos.webspace.mkdir-receipt/v1"); + assert_eq!(mkdir["object"]["kind"], "materialized-directory"); + + let write = state + .write_handle( + "localhost://WebSpaces/Project/Docs/notes.txt".to_string(), + b"hello".to_vec(), + false, + ) + .expect("write should materialize local bytes"); + assert_eq!(write["schema"], "elastos.webspace.write-receipt/v1"); + assert_eq!(write["object"]["kind"], "materialized-file"); + assert_eq!(write["object"]["size"], 5); + assert_eq!(write["head"]["status"], "materialized_local"); + assert_eq!( + cache_status_from_head( + &serde_json::from_value::(write["head"].clone()) + .expect("write head should deserialize") + )["content_cached"], + true + ); + + state + .write_handle( + "localhost://WebSpaces/Project/Docs/notes.txt".to_string(), + b" world".to_vec(), + true, + ) + .expect("append should update local bytes"); + let resolved = resolve_path(&state, "localhost://WebSpaces/Project/Docs/notes.txt") + .expect("materialized file should resolve"); + let handle = handle_from_resolved_path(resolved).expect("resolved path should be a handle"); + assert_eq!(handle.kind, "materialized-file"); + assert_eq!(handle.size, 11); + let object = state + .object_for_handle(&handle) + .expect("materialized object should be stored"); + assert_eq!(object.content, b"hello world"); + + let docs = resolve_path(&state, "localhost://WebSpaces/Project/Docs") + .expect("materialized directory should resolve"); + let entries = list_for(&state, &docs).expect("materialized directory should list"); + assert!(entries.iter().any(|entry| entry.name == "notes.txt")); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent object table"); + let persisted = handle_from_resolved_path( + resolve_path(&reloaded, "localhost://WebSpaces/Project/Docs/notes.txt") + .expect("persisted materialized file should resolve"), + ) + .expect("persisted path should be a handle"); + assert_eq!(persisted.kind, "materialized-file"); + assert_eq!(persisted.size, 11); + + let delete = reloaded + .delete_handle( + "localhost://WebSpaces/Project/Docs/notes.txt".to_string(), + false, + ) + .expect("delete should remove local file"); + assert_eq!(delete["schema"], "elastos.webspace.delete-receipt/v1"); + assert_eq!(delete["removed_count"], 1); + let entries = list_for( + &reloaded, + &resolve_path(&reloaded, "localhost://WebSpaces/Project/Docs") + .expect("directory should still resolve"), + ) + .expect("directory should list after delete"); + assert!(!entries.iter().any(|entry| entry.name == "notes.txt")); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn cache_handle_materializes_adapter_bytes_without_dirty_sync_debt() { + let base = temp_base("cache-bytes"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_adapter( + "google-drive".to_string(), + Some("Google Drive".to_string()), + Some("provider:google-drive-adapter".to_string()), + Some("google-drive-adapter".to_string()), + Some("connected".to_string()), + vec!["metadata_index".to_string(), "read_bytes".to_string()], + Some(true), + Some("Google Drive adapter.".to_string()), + ) + .expect("adapter should persist"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-bytes".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + state + .replace_index( + "Google", + vec![WebSpaceIndexInput { + path: "Drive/Project X/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: Some("google://drive/Drive/Project X/file.pdf".to_string()), + resolver_state: Some("indexed".to_string()), + readonly: Some(true), + description: Some("Indexed file.".to_string()), + }], + ) + .expect("index should persist"); + + let receipt = state + .cache_handle( + "localhost://WebSpaces/Google/Drive/Project X/file.pdf".to_string(), + Some(b"adapter bytes".to_vec()), + Some("application/pdf".to_string()), + Some(serde_json::json!({ + "schema": "elastos.webspace.adapter-cache-source/v1", + "provider": "google-drive-adapter" + })), + ) + .expect("cache should materialize adapter bytes"); + assert_eq!(receipt["schema"], "elastos.webspace.cache-receipt/v1"); + assert_eq!(receipt["action"], "content_cached"); + assert_eq!(receipt["content_cached"], true); + assert_eq!(receipt["dirty"], false); + assert_eq!(receipt["object"]["kind"], "materialized-file"); + assert_eq!(receipt["object"]["size"], 13); + assert_eq!(receipt["head"]["status"], "materialized_cached"); + assert_eq!(receipt["head"]["dirty"], false); + assert_eq!(receipt["head"]["cache_state"], "content_cached"); + + let resolved = handle_from_resolved_path( + resolve_path( + &state, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("cached path should resolve"), + ) + .expect("cached path should be a handle"); + assert_eq!(resolved.kind, "materialized-file"); + let object = state + .object_for_handle(&resolved) + .expect("cached object should be stored"); + assert_eq!(object.content, b"adapter bytes"); + assert!(!object.dirty); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn custom_mount_rejects_reserved_elastos_moniker() { + let mut state = ProviderState::default(); + let err = state + .upsert_mount( + "Elastos".to_string(), + "elastos://override".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect_err("built-in moniker should be reserved"); + assert!(err.contains("built-in WebSpace")); + } + + #[test] + fn unmount_removes_persisted_mount() { + let base = temp_base("unmount"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Docs".to_string(), + "https://example.com/docs".to_string(), + None, + Some("https".to_string()), + None, + Some(true), + None, + None, + None, + ) + .expect("mount should persist"); + state.unmount("Docs").expect("unmount should persist"); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + let err = resolve_path(&reloaded, "localhost://WebSpaces/Docs") + .expect_err("unmounted WebSpace should be gone"); + assert!(err.contains("unknown WebSpace moniker")); + let _ = fs::remove_dir_all(base); + } } diff --git a/elastos/crates/elastos-server/src/webspace_cmd.rs b/elastos/crates/elastos-server/src/webspace_cmd.rs index ac3ac8aa..2fb377f3 100644 --- a/elastos/crates/elastos-server/src/webspace_cmd.rs +++ b/elastos/crates/elastos-server/src/webspace_cmd.rs @@ -1,8 +1,10 @@ +use std::fs; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use elastos_runtime::provider::{BridgeProviderConfig, ProviderBridge}; +use elastos_server::sources::default_data_dir; use crate::WebspaceCommand; @@ -13,9 +15,18 @@ struct WebSpaceHandle { namespace_uri: Option, target_uri: Option, resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + readonly: bool, kind: String, traversable: bool, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, description: String, + forked_from: Option, next_step: Option, } @@ -25,6 +36,94 @@ struct DirEntry { is_file: bool, is_dir: bool, size: u64, + #[serde(default)] + resolver: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default)] + object_id: Option, + #[serde(default)] + head_id: Option, + #[serde(default)] + cache_state: Option, + #[serde(default)] + sync_state: Option, + #[serde(default)] + target_uri: Option, + #[serde(default)] + readonly: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceMount { + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: String, + readonly: bool, + cache_policy: String, + sync_policy: String, + description: String, + #[serde(default)] + forked_from: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceMounts { + schema: String, + mounts: Vec, + user_mounts: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceAdapterTable { + schema: String, + builtin: serde_json::Value, + adapters: Vec, + configured_adapter_count: usize, + connected_adapter_count: usize, + #[serde(default)] + checked_adapter_count: usize, + note: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceIndexInput { + path: String, + kind: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceHead { + handle_uri: String, + target_uri: Option, + moniker: String, + resolver: String, + object_id: String, + head_id: String, + revision: String, + readonly: bool, + cache_policy: String, + sync_policy: String, + cache_state: String, + sync_state: String, + forked_from: Option, + created_at: u64, + updated_at: u64, + last_cached_at: Option, + last_synced_at: Option, + dirty: bool, + status: String, } struct WebSpaceBridge { @@ -62,6 +161,289 @@ impl WebSpaceBridge { parse_webspace_list_response(resp, "list") } + async fn mounts(&self) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "mounts", + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider mounts error: {}", e))?; + parse_webspace_mounts_response(resp, "mounts") + } + + async fn adapters(&self) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "adapters", + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider adapters error: {}", e))?; + serde_json::from_value(parse_webspace_value_response(resp, "adapters")?) + .map_err(|e| anyhow::anyhow!("Invalid webspace-provider adapters response: {}", e)) + } + + #[allow(clippy::too_many_arguments)] + async fn register_adapter( + &self, + resolver: String, + label: Option, + endpoint_uri: Option, + provider: Option, + state: Option, + capabilities: Vec, + mutable_default: bool, + description: Option, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "register_adapter", + "resolver": resolver, + "label": label, + "endpoint_uri": endpoint_uri, + "provider": provider, + "state": state, + "capabilities": capabilities, + "readonly_default": !mutable_default, + "description": description, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider register_adapter error: {}", e))?; + parse_webspace_value_response(resp, "register_adapter") + } + + async fn unregister_adapter(&self, resolver: String) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "unregister_adapter", + "resolver": resolver, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider unregister_adapter error: {}", e))?; + parse_webspace_value_response(resp, "unregister_adapter") + } + + async fn check_adapter( + &self, + resolver: String, + result: Option, + state: Option, + error_code: Option, + capabilities: Vec, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "check_adapter", + "resolver": resolver, + "result": result, + "state": state, + "error_code": error_code, + "capabilities": capabilities, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider check_adapter error: {}", e))?; + parse_webspace_value_response(resp, "check_adapter") + } + + async fn health(&self, moniker: Option) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "health", + "moniker": moniker, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider health error: {}", e))?; + parse_webspace_value_response(resp, "health") + } + + #[allow(clippy::too_many_arguments)] + async fn mount( + &self, + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: Option, + description: Option, + mutable: bool, + cache_policy: Option, + sync_policy: Option, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "mount", + "moniker": moniker, + "target_uri": target_uri, + "namespace_uri": namespace_uri, + "resolver": resolver, + "description": description, + "readonly": !mutable, + "cache_policy": cache_policy, + "sync_policy": sync_policy, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider mount error: {}", e))?; + parse_webspace_value_response(resp, "mount") + } + + async fn unmount(&self, moniker: String) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "unmount", + "moniker": moniker, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider unmount error: {}", e))?; + parse_webspace_value_response(resp, "unmount") + } + + async fn index( + &self, + moniker: String, + entries: Vec, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "index", + "moniker": moniker, + "entries": entries, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider index error: {}", e))?; + parse_webspace_value_response(resp, "index") + } + + async fn refresh( + &self, + target: String, + entries: Option>, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "refresh", + "path": rooted_webspace_path(&target), + "entries": entries, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider refresh error: {}", e))?; + parse_webspace_value_response(resp, "refresh") + } + + async fn head(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "head", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider head error: {}", e))?; + serde_json::from_value(parse_webspace_value_response(resp, "head")?) + .map_err(|e| anyhow::anyhow!("Invalid webspace-provider head response: {}", e)) + } + + async fn cache(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "cache", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider cache error: {}", e))?; + parse_webspace_value_response(resp, "cache") + } + + async fn cache_status(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "cache_status", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider cache_status error: {}", e))?; + parse_webspace_value_response(resp, "cache_status") + } + + async fn sync(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "sync", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider sync error: {}", e))?; + parse_webspace_value_response(resp, "sync") + } + + async fn sync_status(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "sync_status", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider sync_status error: {}", e))?; + parse_webspace_value_response(resp, "sync_status") + } + + #[allow(clippy::too_many_arguments)] + async fn fork( + &self, + source: String, + moniker: String, + target_uri: Option, + resolver: Option, + description: Option, + readonly: bool, + cache_policy: Option, + sync_policy: Option, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "fork", + "source_uri": rooted_webspace_path(&source), + "moniker": moniker, + "target_uri": target_uri, + "resolver": resolver, + "description": description, + "readonly": readonly, + "cache_policy": cache_policy, + "sync_policy": sync_policy, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider fork error: {}", e))?; + parse_webspace_value_response(resp, "fork") + } + async fn shutdown(&self) -> anyhow::Result<()> { self.bridge .send_raw(&serde_json::json!({ "op": "shutdown" })) @@ -75,6 +457,390 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { let bridge = spawn_webspace_bridge().await?; let result = match cmd { + WebspaceCommand::Mounts { json } => { + let mounts = bridge.mounts().await?; + if json { + println!("{}", serde_json::to_string_pretty(&mounts)?); + } else { + println!("WebSpace mounts:"); + for handle in mounts.mounts { + println!( + " - {} -> {} [{}; cache {}; sync {}]", + handle.moniker, + handle.target_uri.as_deref().unwrap_or("(resolver-owned)"), + handle.resolver, + handle.cache_policy, + handle.sync_policy + ); + } + if mounts.user_mounts.is_empty() { + println!("Persistent custom mounts: none"); + } else { + println!("Persistent custom mounts: {}", mounts.user_mounts.len()); + } + } + Ok(()) + } + WebspaceCommand::Adapters { json } => { + let adapters = bridge.adapters().await?; + if json { + println!("{}", serde_json::to_string_pretty(&adapters)?); + } else { + println!("WebSpace adapters:"); + println!( + " - builtin [{}]", + adapters + .builtin + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("connected") + ); + for adapter in &adapters.adapters { + println!( + " - {} [{}]{}{}{}", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + adapter + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + adapter + .get("health") + .and_then(|value| value.get("status")) + .and_then(|value| value.as_str()) + .map(|health| format!(" health={health}")) + .unwrap_or_default(), + adapter + .get("provider") + .and_then(|value| value.as_str()) + .map(|provider| format!(" provider={provider}")) + .unwrap_or_default(), + adapter + .get("endpoint_uri") + .and_then(|value| value.as_str()) + .map(|endpoint| format!(" endpoint={endpoint}")) + .unwrap_or_default() + ); + } + println!( + "Configured external adapters: {} (connected: {})", + adapters.configured_adapter_count, adapters.connected_adapter_count + ); + println!( + "Checked external adapters: {}", + adapters.checked_adapter_count + ); + if let Some(note) = adapters.note.as_deref() { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::RegisterAdapter { + resolver, + label, + endpoint_uri, + provider, + state, + capabilities, + mutable_default, + description, + json, + } => { + let receipt = bridge + .register_adapter( + resolver, + label, + endpoint_uri, + provider, + state, + capabilities, + mutable_default, + description, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let adapter = receipt.get("adapter").unwrap_or(&serde_json::Value::Null); + println!( + "Registered adapter {} [{}]", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + adapter + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + if let Some(endpoint) = adapter.get("endpoint_uri").and_then(|value| value.as_str()) + { + println!("Endpoint: {endpoint}"); + } + } + Ok(()) + } + WebspaceCommand::UnregisterAdapter { resolver, json } => { + let receipt = bridge.unregister_adapter(resolver).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let adapter = receipt.get("adapter").unwrap_or(&serde_json::Value::Null); + println!( + "Unregistered adapter {}", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::CheckAdapter { + resolver, + result, + state, + error_code, + capabilities, + json, + } => { + let receipt = bridge + .check_adapter(resolver, result, state, error_code, capabilities) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let adapter = receipt.get("adapter").unwrap_or(&serde_json::Value::Null); + let health = adapter.get("health").unwrap_or(&serde_json::Value::Null); + println!( + "Checked adapter {} [{}; health {}]", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + adapter + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + health + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + if let Some(next) = health.get("next").and_then(|value| value.as_str()) { + println!("Next: {next}"); + } + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Health { moniker, json } => { + let report = bridge.health(moniker).await?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "WebSpace health: {}", + report + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + println!( + "Mounts: {} (dirty heads: {}, live adapters: {})", + report + .get("mount_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + report + .get("dirty_head_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + report + .get("live_adapter_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + ); + for mount in report + .get("mounts") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + println!( + " - {} [{}; resolver {}; adapter {}]", + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + mount + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + mount + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + mount + .get("adapter_state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + } + if let Some(note) = report.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Mount { + moniker, + target_uri, + namespace_uri, + resolver, + description, + mutable, + cache_policy, + sync_policy, + json, + } => { + let receipt = bridge + .mount( + moniker, + target_uri, + namespace_uri, + resolver, + description, + mutable, + cache_policy, + sync_policy, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let mount = receipt.get("mount").unwrap_or(&serde_json::Value::Null); + println!( + "Mounted {} -> {}", + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + mount + .get("target_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::Unmount { moniker, json } => { + let receipt = bridge.unmount(moniker).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let mount = receipt.get("mount").unwrap_or(&serde_json::Value::Null); + println!( + "Unmounted {}", + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::Index { + moniker, + entries_json, + json, + } => { + let bytes = fs::read(&entries_json).map_err(|err| { + anyhow::anyhow!( + "failed to read WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?; + let entries: Vec = + serde_json::from_slice(&bytes).map_err(|err| { + anyhow::anyhow!( + "invalid WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?; + let receipt = bridge.index(moniker, entries).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Indexed {} entries for {}", + receipt + .get("entry_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + receipt + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::Refresh { + target, + entries_json, + json, + } => { + let entries = if let Some(entries_json) = entries_json { + let bytes = fs::read(&entries_json).map_err(|err| { + anyhow::anyhow!( + "failed to read WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?; + Some( + serde_json::from_slice::>(&bytes).map_err(|err| { + anyhow::anyhow!( + "invalid WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?, + ) + } else { + None + }; + let receipt = bridge.refresh(target, entries).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Refreshed {}", + receipt + .get("handle_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown handle)") + ); + if let Some(count) = receipt + .get("index_entry_count") + .and_then(|value| value.as_u64()) + { + println!("Index entries: {count}"); + } + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } WebspaceCommand::Resolve { target, json } => { let handle = bridge.resolve(&target).await?; if json { @@ -88,6 +854,13 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { handle.namespace_uri.as_deref().unwrap_or("(not mapped)") ); println!("State: {}", handle.resolver_state); + println!("Resolver: {}", handle.resolver); + println!("Cache: {}", handle.cache_policy); + println!("CacheState: {}", handle.cache_state); + println!("Sync: {}", handle.sync_policy); + println!("SyncState: {}", handle.sync_state); + println!("Object: {}", handle.object_id); + println!("Head: {}", handle.head_id); println!("Kind: {}", handle.kind); println!( "Contract: {}", @@ -101,6 +874,7 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { "Target: {}", handle.target_uri.as_deref().unwrap_or("(resolver-owned)") ); + println!("Readonly: {}", if handle.readonly { "yes" } else { "no" }); println!( "Traverse: {}", if handle.traversable { "yes" } else { "no" } @@ -112,6 +886,177 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { } Ok(()) } + WebspaceCommand::Head { target, json } => { + let head = bridge.head(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&head)?); + } else { + println!("Handle: {}", head.handle_uri); + println!("Object: {}", head.object_id); + println!("Head: {}", head.head_id); + println!("Revision: {}", head.revision); + println!("Resolver: {}", head.resolver); + println!("Cache: {} ({})", head.cache_policy, head.cache_state); + println!("Sync: {} ({})", head.sync_policy, head.sync_state); + println!("Dirty: {}", if head.dirty { "yes" } else { "no" }); + println!("Status: {}", head.status); + if let Some(target_uri) = head.target_uri.as_deref() { + println!("Target: {target_uri}"); + } + if let Some(forked_from) = head.forked_from.as_deref() { + println!("ForkedFrom:{forked_from}"); + } + } + Ok(()) + } + WebspaceCommand::Cache { target, json } => { + let receipt = bridge.cache(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Cached metadata for {}", + receipt + .get("handle_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown handle)") + ); + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::CacheStatus { target, json } => { + let status = bridge.cache_status(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&status)?); + } else { + println!( + "Cache: {} ({})", + status + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + status + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + println!( + "Content cached: {}", + status + .get("content_cached") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + ); + if let Some(note) = status.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Sync { target, json } => { + let receipt = bridge.sync(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Synced metadata for {}", + receipt + .get("handle_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown handle)") + ); + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::SyncStatus { target, json } => { + let status = bridge.sync_status(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&status)?); + } else { + println!( + "Sync: {} ({})", + status + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + status + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + println!( + "Dirty: {}", + status + .get("dirty") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + ); + if let Some(note) = status.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Fork { + source, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + json, + } => { + let receipt = bridge + .fork( + source, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let mount = receipt.get("mount").unwrap_or(&serde_json::Value::Null); + let head = receipt.get("head").unwrap_or(&serde_json::Value::Null); + println!( + "Forked {} -> {}", + mount + .get("forked_from") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown source)"), + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown mount)") + ); + println!( + "Head: {} [{}]", + head.get("head_id") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown head)"), + head.get("sync_state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + if let Some(next_step) = receipt.get("next_step").and_then(|value| value.as_str()) { + println!("Next: {next_step}"); + } + } + Ok(()) + } WebspaceCommand::List { path, json } => { let entries = bridge.list(path.as_deref()).await?; if json { @@ -133,7 +1078,21 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { } else { "entry" }; - println!(" - [{}] {}", kind, entry.name); + println!( + " - [{}] {}{}{}", + kind, + entry.name, + entry + .target_uri + .as_deref() + .map(|target| format!(" -> {target}")) + .unwrap_or_default(), + entry + .cache_state + .as_deref() + .map(|state| format!(" [{state}]")) + .unwrap_or_default() + ); } } Ok(()) @@ -146,7 +1105,12 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { async fn spawn_webspace_bridge() -> anyhow::Result { let binary = resolve_webspace_provider_binary()?; - let bridge = ProviderBridge::spawn(&binary, BridgeProviderConfig::default()) + let config = BridgeProviderConfig { + base_path: default_data_dir().to_string_lossy().to_string(), + read_only: false, + ..BridgeProviderConfig::default() + }; + let bridge = ProviderBridge::spawn(&binary, config) .await .map_err(|e| anyhow::anyhow!("Failed to spawn webspace-provider: {}", e))?; Ok(WebSpaceBridge { bridge }) @@ -207,6 +1171,35 @@ fn parse_webspace_list_response( .map_err(|e| anyhow::anyhow!("Invalid webspace-provider {} response: {}", op, e)) } +fn parse_webspace_mounts_response( + resp: serde_json::Value, + op: &str, +) -> anyhow::Result { + serde_json::from_value(parse_webspace_value_response(resp, op)?) + .map_err(|e| anyhow::anyhow!("Invalid webspace-provider {} response: {}", op, e)) +} + +fn parse_webspace_value_response( + resp: serde_json::Value, + op: &str, +) -> anyhow::Result { + if let Some("error") = resp.get("status").and_then(|v| v.as_str()) { + let code = resp + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("unknown_error"); + let message = resp + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("webspace-provider {} failed [{}]: {}", op, code, message); + } + + resp.get("data") + .cloned() + .ok_or_else(|| anyhow::anyhow!("webspace-provider {} response missing data", op)) +} + fn is_handle_path(target: &str) -> bool { target.starts_with("localhost://") || target.contains('/') } From d2d84944de60bfbd4745c672b0cc1fd92444d44d Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:45:12 +0000 Subject: [PATCH 06/18] feat(archive): add Archive Manager capsule Add a dedicated Archive Manager capsule for archive inspection and extraction UX, wired to the Library object/archive operations added in the Explorer slice. The supported release surface remains intentional: safe ZIP/tar/tar.gz/tgz handling, with broader archive families left for dependency and release-policy review. --- capsules/archive-manager/capsule.json | 14 + capsules/archive-manager/index.html | 964 ++++++++++++++++++++++++++ 2 files changed, 978 insertions(+) create mode 100644 capsules/archive-manager/capsule.json create mode 100644 capsules/archive-manager/index.html diff --git a/capsules/archive-manager/capsule.json b/capsules/archive-manager/capsule.json new file mode 100644 index 00000000..afb2ceea --- /dev/null +++ b/capsules/archive-manager/capsule.json @@ -0,0 +1,14 @@ +{ + "schema": "elastos.capsule/v1", + "name": "archive-manager", + "version": "0.1.0", + "description": "Inspect archive objects and ElastOS archive policy without unsafe extraction", + "role": "viewer", + "type": "data", + "author": "elastos", + "entrypoint": "index.html", + "resources": { + "memory_mb": 24, + "gpu": false + } +} diff --git a/capsules/archive-manager/index.html b/capsules/archive-manager/index.html new file mode 100644 index 00000000..febb0e35 --- /dev/null +++ b/capsules/archive-manager/index.html @@ -0,0 +1,964 @@ + + + + + + Archive Manager - ElastOS + + + +
+
+
+

ElastOS Archive Manager

+

Archive policy

+

+ This viewer inspects archive object identity and release policy. It does + not extract unsupported archive families or touch raw host storage. +

+
+
+
+

Object

+
+
+ Object URI + - +
+
+ Content CID + - +
+
+ MIME + - +
+
+ Provider + Runtime-mediated object metadata +
+
+
+
+

Archive Policy

+

Policy loading

+
+
+ Family + - +
+
+ Status + - +
+
+
+
+
+

Archive Contents

+ Not loaded +
+
+ + + + + + + + +
+
+

Select safe entries to import them into Library.

+
+

Archive entries are loaded through the Runtime viewer route when this archive family is supported.

+
+
+
+

Implemented Safely

+
    +
    +
    +

    Blocked Until Review

    +
      +
      +
      +
      + +
      + + + From 2c18681782e88f7b475c27003bbc3838291af629 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:45:19 +0000 Subject: [PATCH 07/18] chore(release): register WCI provider capsules Update component metadata and release build/publish scripts for the Library release capsule set, including object-provider, archive-manager, content-block-graph-provider, WebSpace, operator drive, and protected-content provider capsules. This is packaging metadata only; provider behavior lives in the feature commits. --- components.json | 92 +++++++++++++++++++++++++++++++++++++- scripts/build.sh | 7 ++- scripts/publish-release.sh | 7 ++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/components.json b/components.json index b19b7bbe..34f8de29 100644 --- a/components.json +++ b/components.json @@ -91,6 +91,24 @@ "aarch64-linux" ] }, + "object-provider": { + "cid": "", + "sha256": "", + "size": 0, + "platforms": [ + "x86_64-linux", + "aarch64-linux" + ] + }, + "content-block-graph-provider": { + "cid": "", + "sha256": "", + "size": 0, + "platforms": [ + "x86_64-linux", + "aarch64-linux" + ] + }, "drm-provider": { "cid": "", "sha256": "", @@ -423,6 +441,46 @@ } } }, + "object-provider": { + "install_path": "bin/object-provider", + "description": "Host object provider binary for principal-root Explorer/Library object authority", + "platforms": { + "linux-amd64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "object-provider-linux-amd64", + "install_path": "bin/object-provider" + }, + "linux-arm64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "object-provider-linux-arm64", + "install_path": "bin/object-provider" + } + } + }, + "content-block-graph-provider": { + "install_path": "bin/content-block-graph-provider", + "description": "Host block graph provider binary for Runtime-mediated arbitrary DAG repair", + "platforms": { + "linux-amd64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "content-block-graph-provider-linux-amd64", + "install_path": "bin/content-block-graph-provider" + }, + "linux-arm64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "content-block-graph-provider-linux-arm64", + "install_path": "bin/content-block-graph-provider" + } + } + }, "drm-provider": { "install_path": "bin/drm-provider", "description": "Host DRM provider binary for Runtime-mediated protected content requests", @@ -505,7 +563,7 @@ }, "webspace-provider": { "install_path": "bin/webspace-provider", - "description": "Host WebSpace resolver binary required for localhost://WebSpaces//... handles", + "description": "Host WebSpace resolver binary for mounted localhost://WebSpaces//... handles", "platforms": { "linux-amd64": { "cid": "", @@ -523,6 +581,26 @@ } } }, + "operator-drive-adapter": { + "install_path": "bin/operator-drive-adapter", + "description": "Runtime-only operator WebSpace resolver adapter for metadata, byte reads, and mutable write-back", + "platforms": { + "linux-amd64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "operator-drive-adapter-linux-amd64", + "install_path": "bin/operator-drive-adapter" + }, + "linux-arm64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "operator-drive-adapter-linux-arm64", + "install_path": "bin/operator-drive-adapter" + } + } + }, "home-cli": { "install_path": "capsules/home-cli", "description": "First-party Home CLI capsule required for the default elastos surface", @@ -753,7 +831,7 @@ }, "library": { "install_path": "capsules/library", - "description": "Library capsule for browsing local document objects", + "description": "Library capsule for PC2-style Explorer file management through the Runtime object provider", "platforms": { "*": { "cid": "", @@ -962,6 +1040,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "documents", "library", @@ -988,6 +1068,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "chat", "chat-wasm", @@ -1057,6 +1139,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "kubo", "ipfs-provider", @@ -1089,6 +1173,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "kubo", "ipfs-provider", @@ -1117,6 +1203,8 @@ "browser-stream-bridge", "browser-local-exit", "wallet-provider", + "object-provider", + "content-block-graph-provider", "drm-provider", "rights-provider", "key-provider", diff --git a/scripts/build.sh b/scripts/build.sh index f1dccff3..48581675 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -45,7 +45,7 @@ show_help() { echo "" echo -e "${BOLD}Capsule locations:${NC}" echo " Core capsules: elastos/capsules/ + provider capsules" - echo " App capsules: capsules/ (chat, did-provider, chain-provider, wallet-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, ai-provider, llama-provider, agent, ipfs-provider, site-provider, tunnel-provider)" + echo " App capsules: capsules/ (chat, did-provider, chain-provider, wallet-provider, object-provider, content-block-graph-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, ai-provider, llama-provider, agent, ipfs-provider, site-provider, tunnel-provider)" echo " Data capsules: capsules/ (gba-emulator, gba-ucity)" echo " Data capsules don't need building — they're static assets." echo "" @@ -103,11 +103,14 @@ APP_CAPSULES=( "did-provider:capsules/did-provider" "chain-provider:capsules/chain-provider" "wallet-provider:capsules/wallet-provider" + "object-provider:capsules/object-provider" + "content-block-graph-provider:capsules/content-block-graph-provider" "drm-provider:capsules/drm-provider" "rights-provider:capsules/rights-provider" "key-provider:capsules/key-provider" "decrypt-provider:capsules/decrypt-provider" "availability-provider:capsules/availability-provider" + "operator-drive-adapter:capsules/operator-drive-adapter" "ai-provider:capsules/ai-provider" "llama-provider:capsules/llama-provider" "agent:capsules/agent" @@ -277,6 +280,6 @@ fi echo "" echo -e "${GREEN}Done.${NC}" if [ "$BUILD_ALL" = false ]; then - echo -e "${DIM}Run with --all to also build chat, did-provider, chain-provider, wallet-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, ai-provider, llama-provider, agent, ipfs-provider.${NC}" + echo -e "${DIM}Run with --all to also build chat, did-provider, chain-provider, wallet-provider, object-provider, content-block-graph-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, operator-drive-adapter, ai-provider, llama-provider, agent, ipfs-provider.${NC}" fi echo "" diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 115a1b4b..4963e39b 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -39,7 +39,9 @@ DIM='\033[2m' NC='\033[0m' # Default publish scope: runtime core + chat demo (3 modes: native, microVM, WASM). -# ipfs-provider, availability-provider, wallet-provider, drm-provider, rights-provider, key-provider, decrypt-provider, and tunnel-provider are supported direct command assets. +# ipfs-provider, availability-provider, wallet-provider, object-provider, +# content-block-graph-provider, drm-provider, rights-provider, key-provider, +# decrypt-provider, and tunnel-provider are supported direct command assets. # They are not part of the managed user runtime, but fresh installs must # provision them for share/open/public-share. DEFAULT_CAPSULES=( @@ -71,12 +73,15 @@ SUPPORT_BINARY_ASSETS=( webspace-provider chain-provider wallet-provider + object-provider + content-block-graph-provider drm-provider rights-provider key-provider decrypt-provider ipfs-provider availability-provider + operator-drive-adapter site-provider tunnel-provider ) From b7c95db72751b4e1b951d59750fdd55aedd4275f Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sun, 7 Jun 2026 01:45:26 +0000 Subject: [PATCH 08/18] test(library): add WCI release smoke coverage Add gateway tests and browser-facing smoke scripts for Library object flows, Home projection, archive operations, provider menus, release entropy checks, live Home/Library smoke, and protected-content provider contracts. These checks are the branch-local proof surface for the Library release and the first line of defense against PC2 UX and ElastOS authority regressions. --- .../src/api/gateway_tests/home_system.rs | 50 +- .../src/api/gateway_tests/library.rs | 5575 +++++++++++++++++ .../src/api/gateway_tests/mod.rs | 107 +- .../src/api/gateway_tests/site_publication.rs | 29 + .../api/gateway_tests/support_providers.rs | 1313 +++- scripts/check-wci-alignment.sh | 10 +- scripts/home-entropy-check.mjs | 1111 +++- scripts/home-live-smoke.sh | 99 + scripts/library-live-smoke.sh | 442 ++ scripts/library-menu-smoke.mjs | 2306 +++++++ scripts/library-performance-smoke.mjs | 225 + ...otected-content-provider-contract-smoke.sh | 29 +- 12 files changed, 11265 insertions(+), 31 deletions(-) create mode 100644 elastos/crates/elastos-server/src/api/gateway_tests/library.rs create mode 100755 scripts/home-live-smoke.sh create mode 100755 scripts/library-live-smoke.sh create mode 100644 scripts/library-menu-smoke.mjs create mode 100644 scripts/library-performance-smoke.mjs diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs b/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs index f4e3d60a..6c48e0cb 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs @@ -100,8 +100,10 @@ async fn test_home_static_route_serves_browser_surface() { async fn test_home_summary_reports_identity_and_launch_targets() { let dir = tempfile::tempdir().unwrap(); - let app = gateway_router(test_state(dir.path())); + let state = library_test_state(dir.path()).await; + let app = gateway_router(state); let authority = passkey_authority_with_name(dir.path(), Some("anders")); + let library_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); let public = app .clone() .oneshot( @@ -125,6 +127,15 @@ async fn test_home_summary_reports_identity_and_launch_targets() { assert!(public_payload["identity"]["device_did"].is_null()); assert_eq!(public_payload["browser_state"]["principal_id"], ""); assert_eq!(public_payload["browser_state"]["localhost_root"], ""); + assert_eq!( + public_payload["desktop_objects"]["schema"], + "elastos.home.desktop-objects/v1" + ); + assert_eq!(public_payload["desktop_objects"]["uri"], ""); + assert!(public_payload["desktop_objects"]["objects"] + .as_array() + .unwrap() + .is_empty()); assert!(public_payload["browser_state"]["layout"].is_null()); assert!(public_payload["browser_state"]["session"].is_null()); assert!(public_payload["browser_state"]["recent_targets"] @@ -144,6 +155,27 @@ async fn test_home_summary_reports_identity_and_launch_targets() { .iter() .any(|target| target["target"] == "system")); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/mkdir") + .header("x-elastos-home-token", library_token.as_str()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ + "parent_uri": format!("{}/Desktop", crate::auth::principal_localhost_root(&authority.principal_id)), + "name": "Test Folder", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let resp = app .oneshot( Request::builder() @@ -172,6 +204,22 @@ async fn test_home_summary_reports_identity_and_launch_targets() { assert_eq!(payload["site"]["root_uri"], MY_WEBSITE_URI); assert_eq!(payload["room"]["pending_count"], 0); assert_eq!(payload["notifications"]["unread_count"], 0); + assert_eq!( + payload["desktop_objects"]["schema"], + "elastos.home.desktop-objects/v1" + ); + assert_eq!( + payload["desktop_objects"]["uri"], + format!( + "{}/Desktop", + crate::auth::principal_localhost_root(&authority.principal_id) + ) + ); + assert!(payload["desktop_objects"]["objects"] + .as_array() + .unwrap() + .iter() + .any(|object| object["name"] == "Test Folder" && object["kind"] == "directory")); let targets = payload["targets"].as_array().unwrap(); let system = targets .iter() diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/library.rs b/elastos/crates/elastos-server/src/api/gateway_tests/library.rs new file mode 100644 index 00000000..88e5116f --- /dev/null +++ b/elastos/crates/elastos-server/src/api/gateway_tests/library.rs @@ -0,0 +1,5575 @@ +use super::*; + +fn provider_body(value: serde_json::Value) -> Body { + Body::from(serde_json::to_vec(&value).unwrap()) +} + +async fn post_library( + app: axum::Router, + token: &str, + op: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/provider/object/{op}")) + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(provider_body(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, payload) +} + +async fn put_library_upload( + app: axum::Router, + token: &str, + uri: &str, + body: &'static [u8], +) -> (StatusCode, HeaderMap, serde_json::Value) { + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload?uri={encoded_uri}")) + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, headers, payload) +} + +async fn post_library_upload_start( + app: axum::Router, + token: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/upload/start") + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(provider_body(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, payload) +} + +async fn put_library_upload_chunk( + app: axum::Router, + token: &str, + upload_id: &str, + offset: u64, + body: &'static [u8], +) -> (StatusCode, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload/{upload_id}/chunk")) + .header("x-elastos-home-token", token) + .header("x-elastos-upload-offset", offset.to_string()) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, payload) +} + +async fn post_library_upload_finish( + app: axum::Router, + token: &str, + upload_id: &str, +) -> (StatusCode, HeaderMap, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/provider/object/upload/{upload_id}/finish")) + .header("x-elastos-home-token", token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, headers, payload) +} + +async fn get_library_download( + app: axum::Router, + token: &str, + uri: &str, +) -> (StatusCode, HeaderMap, Vec) { + get_library_download_with_range(app, token, uri, None).await +} + +async fn get_library_download_with_range( + app: axum::Router, + token: &str, + uri: &str, + range: Option<&str>, +) -> (StatusCode, HeaderMap, Vec) { + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + let mut request = Request::builder() + .method("GET") + .uri(format!( + "/api/provider/object/download/raw?uri={encoded_uri}" + )) + .header("x-elastos-home-token", token); + if let Some(range) = range { + request = request.header(axum::http::header::RANGE, range); + } + let response = app + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, headers, bytes) +} + +async fn get_library_download_many( + app: axum::Router, + token: &str, + uris: &[String], +) -> (StatusCode, HeaderMap, Vec) { + get_library_download_many_with_archive(app, token, uris, None).await +} + +async fn get_library_download_many_with_archive( + app: axum::Router, + token: &str, + uris: &[String], + archive: Option<&str>, +) -> (StatusCode, HeaderMap, Vec) { + let mut query_parts = uris + .iter() + .map(|uri| { + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + format!("uri={encoded_uri}") + }) + .collect::>(); + if let Some(archive) = archive { + query_parts.push(format!("archive={archive}")); + } + let query = query_parts.join("&"); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/provider/object/download/raw?{query}")) + .header("x-elastos-home-token", token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, headers, bytes) +} + +fn transfer_receipt(headers: &HeaderMap) -> serde_json::Value { + serde_json::from_str( + headers + .get("x-elastos-transfer-receipt") + .and_then(|value| value.to_str().ok()) + .unwrap(), + ) + .unwrap() +} + +fn zip_text_files(bytes: &[u8]) -> std::collections::BTreeMap { + use std::io::Read as _; + + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut files = std::collections::BTreeMap::new(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + if entry.is_dir() { + continue; + } + let name = entry.name().to_string(); + let mut body = String::new(); + entry.read_to_string(&mut body).unwrap(); + files.insert(name, body); + } + files +} + +#[tokio::test] +async fn test_library_provider_requires_library_token() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + + let denied = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/roots") + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + let documents_token = issue_home_launch_token(dir.path(), DOCUMENTS_CAPSULE_ID).unwrap(); + let rejected = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/roots") + .header("x-elastos-home-token", documents_token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_library_download_route_requires_library_token_and_streams_download_bytes() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let documents_token = app_token_for_authority(dir.path(), DOCUMENTS_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/raw-download.txt"); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"raw download body"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let denied = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/provider/object/download/raw?uri={encoded_uri}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + let (rejected_status, _headers, _bytes) = + get_library_download(app.clone(), &documents_token, &uri).await; + assert_eq!(rejected_status, StatusCode::FORBIDDEN); + + let (download_status, headers, bytes) = get_library_download(app.clone(), &token, &uri).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!(bytes, b"raw download body"); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"raw-download.txt\""), + ); + assert_eq!( + headers + .get(axum::http::header::ACCEPT_RANGES) + .and_then(|value| value.to_str().ok()), + Some("bytes"), + ); + assert!(headers + .get("x-elastos-request-id") + .and_then(|value| value.to_str().ok()) + .unwrap() + .starts_with("object:download:")); + let receipt = transfer_receipt(&headers); + assert_eq!(receipt["schema"], "elastos.object.transfer.receipt/v1"); + assert_eq!(receipt["op"], "download"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["bytes"], 17); + assert_eq!(receipt["total_bytes"], 17); + assert_eq!(receipt["transport"], "http-body-stream"); + assert_eq!( + receipt["stream"]["schema"], + "elastos.object.download-stream/v1" + ); + assert_eq!(receipt["stream"]["mode"], "response_body_chunks"); + assert_eq!(receipt["stream"]["backpressure"], "http_body_poll"); + assert_eq!(receipt["stream"]["cancel"], "drop_body"); + + let (range_status, range_headers, range_bytes) = + get_library_download_with_range(app, &token, &uri, Some("bytes=4-11")).await; + assert_eq!(range_status, StatusCode::PARTIAL_CONTENT); + assert_eq!(range_bytes, b"download"); + assert_eq!( + range_headers + .get(axum::http::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()), + Some("bytes 4-11/17"), + ); + assert_eq!( + range_headers + .get(axum::http::header::ACCEPT_RANGES) + .and_then(|value| value.to_str().ok()), + Some("bytes"), + ); + let range_receipt = transfer_receipt(&range_headers); + assert_eq!( + range_receipt["schema"], + "elastos.object.transfer.receipt/v1" + ); + assert_eq!(range_receipt["op"], "download"); + assert_eq!(range_receipt["status"], "completed"); + assert_eq!(range_receipt["bytes"], 8); + assert_eq!(range_receipt["total_bytes"], 17); + assert_eq!(range_receipt["range"]["start"], 4); + assert_eq!(range_receipt["range"]["end"], 11); + assert_eq!(range_receipt["transport"], "http-body-stream"); + assert_eq!(range_receipt["stream"]["mode"], "response_body_chunks"); +} + +#[tokio::test] +async fn test_library_upload_route_requires_library_token_and_writes_raw_body() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let documents_token = app_token_for_authority(dir.path(), DOCUMENTS_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/raw-upload.txt"); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + + let denied = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload?uri={encoded_uri}")) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from("no token")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload?uri={encoded_uri}")) + .header("x-elastos-home-token", documents_token) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from("wrong app")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + + let (upload_status, upload_headers, upload) = + put_library_upload(app.clone(), &token, &uri, b"raw upload body").await; + assert_eq!(upload_status, StatusCode::OK); + assert_eq!(upload["status"], "ok"); + assert_eq!(upload["data"]["transport"], "raw-body"); + assert_eq!(upload["data"]["object"]["uri"], uri); + assert!(upload["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .starts_with("bafkrei")); + assert_eq!(upload["data"]["object"].get("published_cid"), None); + assert_eq!(upload["data"]["object"]["published"], false); + assert!(upload["data"]["request_id"] + .as_str() + .unwrap() + .starts_with("object:upload:")); + assert_eq!( + upload["data"]["receipt"]["schema"], + "elastos.object.transfer.receipt/v1" + ); + assert_eq!(upload["data"].get("provider_receipt"), None); + assert_eq!(upload["data"]["receipt"]["op"], "upload"); + assert_eq!(upload["data"]["receipt"]["status"], "completed"); + assert_eq!(upload["data"]["receipt"]["bytes"], 15); + assert_eq!(upload["data"]["receipt"]["total_bytes"], 15); + assert_eq!( + upload_headers + .get("x-elastos-request-id") + .and_then(|value| value.to_str().ok()), + upload["data"]["request_id"].as_str() + ); + assert_eq!( + transfer_receipt(&upload_headers)["schema"], + "elastos.object.transfer.receipt/v1" + ); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let data = read["data"]["data"].as_str().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(), + b"raw upload body" + ); +} + +#[tokio::test] +async fn test_library_chunked_upload_session_writes_object_and_emits_receipt() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/chunked-upload.txt"); + let body = b"chunked upload body"; + + let (start_status, start) = post_library_upload_start( + app.clone(), + &token, + json!({ + "uri": uri, + "mime": "text/plain", + "size_bytes": body.len(), + }), + ) + .await; + assert_eq!(start_status, StatusCode::OK); + assert_eq!(start["status"], "ok"); + assert_eq!(start["data"]["schema"], "elastos.object.upload-session/v1"); + assert_eq!(start["data"]["transport"], "http-chunk-session"); + assert_eq!(start["data"]["received_bytes"], 0); + assert!(start["data"]["chunk_size"].as_u64().unwrap() < 1024 * 1024); + let upload_id = start["data"]["upload_id"].as_str().unwrap(); + + let (first_status, first_chunk) = + put_library_upload_chunk(app.clone(), &token, upload_id, 0, b"chunked ").await; + assert_eq!(first_status, StatusCode::OK); + assert_eq!(first_chunk["data"]["received_bytes"], 8); + assert_eq!(first_chunk["data"]["chunk_count"], 1); + + let (second_status, second_chunk) = + put_library_upload_chunk(app.clone(), &token, upload_id, 8, b"upload body").await; + assert_eq!(second_status, StatusCode::OK); + assert_eq!(second_chunk["data"]["received_bytes"], body.len()); + assert_eq!(second_chunk["data"]["chunk_count"], 2); + + let (finish_status, finish_headers, finish) = + post_library_upload_finish(app.clone(), &token, upload_id).await; + assert_eq!(finish_status, StatusCode::OK); + assert_eq!(finish["status"], "ok"); + assert_eq!(finish["data"]["object"]["uri"], uri); + assert!(finish["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .starts_with("bafkrei")); + assert_eq!(finish["data"]["object"].get("published_cid"), None); + assert_eq!(finish["data"]["object"]["published"], false); + assert_eq!(finish["data"]["transport"], "raw-body"); + assert_eq!(finish["data"]["browser_transport"], "http-chunk-session"); + assert_eq!(finish["data"]["upload_session"]["chunk_count"], 2); + assert_eq!(finish["data"]["receipt"]["op"], "upload"); + assert_eq!(finish["data"]["receipt"]["transport"], "http-chunk-session"); + assert_eq!( + finish["data"]["receipt"]["stream"]["backpressure"], + "client_waits_for_chunk_ack" + ); + assert_eq!( + transfer_receipt(&finish_headers)["transport"], + "http-chunk-session" + ); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let data = read["data"]["data"].as_str().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(), + body + ); +} + +#[tokio::test] +async fn test_documents_viewer_route_can_read_and_save_library_file_only() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let library_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let documents_token = app_token_for_authority(dir.path(), DOCUMENTS_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/from-library.md"); + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + + let (write_status, write) = post_library( + app.clone(), + &library_token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"# From Library"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let direct_read = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/read") + .header("x-elastos-home-token", documents_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": uri, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_read.status(), StatusCode::FORBIDDEN); + + let read = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/documents/library-object?uri={encoded_uri}" + )) + .header("x-elastos-home-token", documents_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(read.status(), StatusCode::OK); + let read_body = axum::body::to_bytes(read.into_body(), usize::MAX) + .await + .unwrap(); + let read: serde_json::Value = serde_json::from_slice(&read_body).unwrap(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, b"# From Library"); + + let revision = read["data"]["object"]["revision"].as_str().unwrap(); + let save = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!( + "/api/viewers/documents/library-object?uri={encoded_uri}" + )) + .header("x-elastos-home-token", documents_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "if_revision": revision, + "data": base64::engine::general_purpose::STANDARD + .encode(b"# Saved From Documents"), + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(save.status(), StatusCode::OK); + let save_body = axum::body::to_bytes(save.into_body(), usize::MAX) + .await + .unwrap(); + let save: serde_json::Value = serde_json::from_slice(&save_body).unwrap(); + assert_eq!(save["status"], "ok"); +} + +#[tokio::test] +async fn test_library_provider_rejects_unknown_operation() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let token = issue_home_launch_token(dir.path(), LIBRARY_CAPSULE_ID).unwrap(); + + let rejected = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/raw_host_path") + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_library_provider_object_lifecycle() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + + let (roots_status, roots) = post_library(app.clone(), &token, "roots", json!({})).await; + assert_eq!(roots_status, StatusCode::OK); + assert_eq!(roots["status"], "ok"); + assert!(roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| root["id"] == "documents" && root["label"] == "Documents")); + assert!(roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| root["id"] == "desktop" && root["label"] == "Desktop")); + assert!(roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| { + root["id"] == "webspaces" + && root["label"] == "Spaces" + && root["uri"] == "localhost://WebSpaces" + })); + assert!(!roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| root["id"] == "trash")); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!(mkdir["status"], "ok"); + assert_eq!(mkdir["data"]["object"]["uri"], documents_uri); + + let notes_uri = format!("{documents_uri}/notes.txt"); + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": notes_uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"hello library"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!(write["data"]["object"]["name"], "notes.txt"); + + let (list_status, list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": documents_uri, + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert!(list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .any(|object| object["name"] == "notes.txt" && object["kind"] == "file")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": notes_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let data = read["data"]["data"].as_str().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(), + b"hello library" + ); + + let (rename_status, rename) = post_library( + app.clone(), + &token, + "rename", + json!({ + "uri": notes_uri, + "name": "renamed.txt", + }), + ) + .await; + assert_eq!(rename_status, StatusCode::OK); + let renamed_uri = rename["data"]["object"]["uri"] + .as_str() + .unwrap() + .to_string(); + assert!(renamed_uri.ends_with("/Documents/renamed.txt")); + + let (trash_status, trash) = post_library( + app.clone(), + &token, + "trash", + json!({ + "uri": renamed_uri, + }), + ) + .await; + assert_eq!(trash_status, StatusCode::OK); + let trash_uri = trash["data"]["object"]["uri"].as_str().unwrap().to_string(); + assert!(trash_uri.contains("/.Trash/")); + + let restored_uri = format!("{documents_uri}/restored.txt"); + let (restore_status, restore) = post_library( + app.clone(), + &token, + "restore", + json!({ + "uri": trash_uri, + "target_uri": restored_uri, + }), + ) + .await; + assert_eq!(restore_status, StatusCode::OK); + assert_eq!(restore["data"]["object"]["uri"], restored_uri); + + let (trash_again_status, trash_again) = post_library( + app.clone(), + &token, + "trash", + json!({ + "uri": restored_uri, + }), + ) + .await; + assert_eq!(trash_again_status, StatusCode::OK); + let deleted_uri = trash_again["data"]["object"]["uri"].as_str().unwrap(); + let (delete_status, deleted) = post_library( + app, + &token, + "delete_permanently", + json!({ + "uri": deleted_uri, + }), + ) + .await; + assert_eq!(delete_status, StatusCode::OK); + assert_eq!(deleted["data"]["deleted_uri"], deleted_uri); +} + +#[tokio::test] +async fn test_library_provider_separates_public_placement_from_publish_visibility() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let public_uri = format!("{root}/Public"); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Public", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!( + mkdir["data"]["object"]["metadata"]["visibility"]["schema"], + "elastos.library.visibility/v1" + ); + assert_eq!( + mkdir["data"]["object"]["metadata"]["visibility"]["placement"], + "public_folder" + ); + assert_eq!( + mkdir["data"]["object"]["metadata"]["visibility"]["effective_access"], + "principal_private" + ); + + let file_uri = format!("{public_uri}/draft.txt"); + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": file_uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"public placement draft"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["schema"], + "elastos.library.visibility/v1" + ); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["placement"], + "public_folder" + ); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["effective_access"], + "principal_private" + ); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["publish_required_for_public_link"], + true + ); + assert_eq!(write["data"]["object"]["published"], false); + assert!(write["data"]["object"]["published_cid"].is_null()); + assert!(write["data"]["object"]["metadata"]["visibility"]["published_cid"].is_null()); + + let (publish_status, publish) = post_library( + app, + &token, + "publish", + json!({ + "uri": file_uri, + "if_revision": write["data"]["object"]["revision"], + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["cid"], TEST_CIDV1); + assert_eq!(publish["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["placement"], + "public_folder" + ); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["effective_access"], + "public_content_link" + ); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["publish_required_for_public_link"], + false + ); + assert_eq!(publish["data"]["object"]["published"], true); + assert_eq!(publish["data"]["object"]["published_cid"], TEST_CIDV1); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["published_cid"], + TEST_CIDV1 + ); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["published_link"], + format!("elastos://{TEST_CIDV1}") + ); +} + +#[tokio::test] +async fn test_library_provider_downloads_directory_archive() { + use std::io::Read as _; + + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert!(mkdir["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "download")); + + let (root_stat_status, root_stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": root, + }), + ) + .await; + assert_eq!(root_stat_status, StatusCode::OK); + assert!(!root_stat["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "download")); + + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + ( + format!("{documents_uri}/notes.txt"), + b"folder archive".as_slice(), + ), + ( + format!("{nested_uri}/deep.txt"), + b"nested archive".as_slice(), + ), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let (download_status, download) = post_library( + app, + &token, + "download", + json!({ + "uri": documents_uri, + }), + ) + .await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!(download["data"]["filename"], "Documents.tar.gz"); + assert_eq!(download["data"]["object"]["mime"], "application/gzip"); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(download["data"]["data"].as_str().unwrap()) + .unwrap(); + let decoder = flate2::read::GzDecoder::new(archive_bytes.as_slice()); + let mut archive = tar::Archive::new(decoder); + let mut files = std::collections::BTreeMap::new(); + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry.path().unwrap().to_string_lossy().to_string(); + let mut body = String::new(); + entry.read_to_string(&mut body).unwrap(); + files.insert(path, body); + } + assert_eq!( + files.get("Documents/notes.txt").map(String::as_str), + Some("folder archive") + ); + assert_eq!( + files.get("Documents/Nested/deep.txt").map(String::as_str), + Some("nested archive") + ); +} + +#[tokio::test] +async fn test_library_download_route_archives_selected_objects() { + use std::io::Read as _; + + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + let alpha_uri = format!("{documents_uri}/alpha.txt"); + let deep_uri = format!("{nested_uri}/deep.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (alpha_uri.clone(), b"selected alpha".as_slice()), + (deep_uri, b"selected nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let selected = vec![alpha_uri, nested_uri]; + let (download_status, headers, archive_bytes) = + get_library_download_many(app, &token, &selected).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/gzip"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"Documents Selection.tar.gz\""), + ); + let receipt = transfer_receipt(&headers); + assert_eq!(receipt["schema"], "elastos.object.transfer.receipt/v1"); + assert_eq!(receipt["op"], "download"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["uri"], "selection:2"); + + let decoder = flate2::read::GzDecoder::new(archive_bytes.as_slice()); + let mut archive = tar::Archive::new(decoder); + let mut files = std::collections::BTreeMap::new(); + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry.path().unwrap().to_string_lossy().to_string(); + let mut body = String::new(); + entry.read_to_string(&mut body).unwrap(); + files.insert(path, body); + } + assert_eq!( + files.get("alpha.txt").map(String::as_str), + Some("selected alpha") + ); + assert_eq!( + files.get("Nested/deep.txt").map(String::as_str), + Some("selected nested") + ); +} + +#[tokio::test] +async fn test_library_download_route_archives_directory_as_zip() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + ( + format!("{documents_uri}/alpha.txt"), + b"zip alpha".as_slice(), + ), + (format!("{nested_uri}/deep.txt"), b"zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let (download_status, headers, archive_bytes) = + get_library_download_many_with_archive(app, &token, &[documents_uri], Some("zip")).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/zip"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"Documents.zip\""), + ); + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("Documents/alpha.txt").map(String::as_str), + Some("zip alpha") + ); + assert_eq!( + files.get("Documents/Nested/deep.txt").map(String::as_str), + Some("zip nested") + ); +} + +#[tokio::test] +async fn test_library_download_route_archives_selected_objects_as_zip() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + let alpha_uri = format!("{documents_uri}/alpha.txt"); + let deep_uri = format!("{nested_uri}/deep.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (alpha_uri.clone(), b"selected zip alpha".as_slice()), + (deep_uri, b"selected zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let selected = vec![alpha_uri, nested_uri]; + let (download_status, headers, archive_bytes) = + get_library_download_many_with_archive(app, &token, &selected, Some("zip")).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/zip"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"Documents Selection.zip\""), + ); + let receipt = transfer_receipt(&headers); + assert_eq!(receipt["schema"], "elastos.object.transfer.receipt/v1"); + assert_eq!(receipt["op"], "download"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["uri"], "selection:2"); + + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("alpha.txt").map(String::as_str), + Some("selected zip alpha") + ); + assert_eq!( + files.get("Nested/deep.txt").map(String::as_str), + Some("selected zip nested") + ); +} + +#[tokio::test] +async fn test_library_download_route_rejects_unknown_archive_format() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let (download_status, _headers, body) = + get_library_download_many_with_archive(app, &token, &[documents_uri], Some("rar")).await; + assert_eq!(download_status, StatusCode::BAD_REQUEST); + assert!(String::from_utf8(body) + .unwrap() + .contains("unsupported Library archive format: rar")); +} + +#[tokio::test] +async fn test_library_provider_marks_generic_archive_families_policy_gated() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/Bundle.7z"); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive Manager viewer", + "Archive Manager", + ); + write_test_static_capsule( + dir.path(), + "documents", + "viewer", + "Test Documents viewer", + "Documents", + ); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"not a real 7z archive"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (stat_status, stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + let object = &stat["data"]["object"]; + assert_eq!( + object["metadata"]["archive_support"]["schema"], + "elastos.library.archive-support/v1" + ); + assert_eq!(object["metadata"]["archive_support"]["family"], "7z"); + assert_eq!( + object["metadata"]["archive_support"]["status"], + "policy_gated_unsupported_archive_family" + ); + assert!(!object["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + assert_eq!(object["viewer"], "archive-manager"); + assert_eq!(object["viewers"][0]["id"], "archive-manager"); + + let direct_read = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/read") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": uri, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_read.status(), StatusCode::FORBIDDEN); + + let viewer_stat = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}&stat_only=true" + )) + .header("x-elastos-home-token", archive_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(viewer_stat.status(), StatusCode::OK); + let viewer_body = axum::body::to_bytes(viewer_stat.into_body(), usize::MAX) + .await + .unwrap(); + let viewer_stat: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!( + viewer_stat["data"]["object"]["metadata"]["archive_support"]["status"], + "policy_gated_unsupported_archive_family" + ); + assert!(viewer_stat["data"].get("data").is_none()); + + let viewer_read = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}" + )) + .header( + "x-elastos-home-token", + app_token_for_authority(dir.path(), "archive-manager", &authority), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(viewer_read.status(), StatusCode::FORBIDDEN); + + let (extract_status, extract) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extract["status"], "error"); + assert!(extract["message"] + .as_str() + .unwrap() + .contains("only supports .tar, .tar.gz, .tgz, and .zip")); + + let (entries_status, entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(entries_status, StatusCode::OK); + assert_eq!(entries["status"], "error"); + assert!(entries["message"] + .as_str() + .unwrap() + .contains("archive listing only supports .tar, .tar.gz, .tgz, and .zip")); +} + +#[tokio::test] +async fn test_library_provider_compresses_folder_to_zip_object() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let projects_uri = format!("{documents_uri}/Projects"); + let nested_uri = format!("{projects_uri}/Nested"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (projects_status, projects) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Projects", + }), + ) + .await; + assert_eq!(projects_status, StatusCode::OK); + assert!(projects["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "compress_archive")); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": projects_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (format!("{projects_uri}/alpha.txt"), b"zip alpha".as_slice()), + (format!("{nested_uri}/deep.txt"), b"zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "compress_archive")); + } + + let (compress_status, compressed) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uri": projects_uri, + }), + ) + .await; + assert_eq!(compress_status, StatusCode::OK); + assert_eq!(compressed["status"], "ok"); + assert_eq!( + compressed["data"]["object"]["uri"], + format!("{documents_uri}/Projects.zip") + ); + assert_eq!(compressed["data"]["object"]["mime"], "application/zip"); + assert!(compressed["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (compress_again_status, compressed_again) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uri": projects_uri, + }), + ) + .await; + assert_eq!(compress_again_status, StatusCode::OK); + let second_zip_uri = compressed_again["data"]["object"]["uri"].as_str().unwrap(); + assert_ne!(second_zip_uri, format!("{documents_uri}/Projects.zip")); + assert!(second_zip_uri.starts_with(&format!("{documents_uri}/Projects ("))); + assert!(second_zip_uri.ends_with(").zip")); + + let zip_uri = compressed["data"]["object"]["uri"].as_str().unwrap(); + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("Projects/alpha.txt").map(String::as_str), + Some("zip alpha") + ); + assert_eq!( + files.get("Projects/Nested/deep.txt").map(String::as_str), + Some("zip nested") + ); + + let (events_status, events) = post_library( + app, + &token, + "events", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert!(events["data"]["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["op"] == "compress_archive")); +} + +#[tokio::test] +async fn test_library_provider_stores_incompressible_zip_entries() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let video_uri = format!("{documents_uri}/Screen Recording.mp4"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let video_bytes = vec![0x5a; 2048]; + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": video_uri, + "mime": "video/mp4", + "data": base64::engine::general_purpose::STANDARD.encode(&video_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let (compress_status, compressed) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uri": video_uri, + }), + ) + .await; + assert_eq!(compress_status, StatusCode::OK); + let zip_uri = compressed["data"]["object"]["uri"].as_str().unwrap(); + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive_bytes)).unwrap(); + let mut entry = archive.by_name("Screen Recording.mp4").unwrap(); + assert_eq!(entry.compression(), zip::CompressionMethod::Stored); + let mut roundtrip = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut roundtrip).unwrap(); + assert_eq!(roundtrip, video_bytes); +} + +#[tokio::test] +async fn test_library_provider_compresses_selected_objects_to_zip_object() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + let alpha_uri = format!("{documents_uri}/alpha.txt"); + let deep_uri = format!("{nested_uri}/deep.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (alpha_uri.clone(), b"selected zip alpha".as_slice()), + (deep_uri, b"selected zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let (compress_status, compressed) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uris": [alpha_uri, nested_uri], + }), + ) + .await; + assert_eq!(compress_status, StatusCode::OK); + assert_eq!(compressed["status"], "ok"); + assert_eq!( + compressed["data"]["object"]["uri"], + format!("{documents_uri}/Documents Selection.zip") + ); + assert_eq!(compressed["data"]["object"]["mime"], "application/zip"); + + let zip_uri = compressed["data"]["object"]["uri"].as_str().unwrap(); + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("alpha.txt").map(String::as_str), + Some("selected zip alpha") + ); + assert_eq!( + files.get("Nested/deep.txt").map(String::as_str), + Some("selected zip nested") + ); +} + +#[tokio::test] +async fn test_library_provider_extracts_tar_gz_archive() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let archive_uri = format!("{documents_uri}/Bundle.tar.gz"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + let mut alpha = b"extracted alpha".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "alpha.txt", &mut alpha) + .unwrap(); + let mut deep = b"extracted nested".as_slice(); + let mut deep_header = tar::Header::new_gnu(); + deep_header.set_size(deep.len() as u64); + deep_header.set_mode(0o644); + deep_header.set_cksum(); + builder + .append_data(&mut deep_header, "Nested/deep.txt", &mut deep) + .unwrap(); + let archive_bytes = builder.into_inner().unwrap().finish().unwrap(); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": archive_uri, + "mime": "application/gzip", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["data"]["object"]["mime"], "application/gzip"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (extract_status, extract) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": archive_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + let extracted_uri = extract["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(extracted_uri, format!("{documents_uri}/Bundle")); + + for (path, expected) in [ + ("alpha.txt", "extracted alpha"), + ("Nested/deep.txt", "extracted nested"), + ] { + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{extracted_uri}/{path}"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let body = String::from_utf8(bytes).unwrap(); + assert_eq!(body, expected); + } +} + +#[tokio::test] +async fn test_library_provider_extracts_plain_tar_archive() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let archive_uri = format!("{documents_uri}/Bundle.tar"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let mut builder = tar::Builder::new(Vec::new()); + let mut alpha = b"plain tar alpha".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "alpha.txt", &mut alpha) + .unwrap(); + let archive_bytes = builder.into_inner().unwrap(); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": archive_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["data"]["object"]["mime"], "application/x-tar"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (extract_status, extract) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": archive_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + let extracted_uri = extract["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(extracted_uri, format!("{documents_uri}/Bundle")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{extracted_uri}/alpha.txt"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let body = String::from_utf8(bytes).unwrap(); + assert_eq!(body, "plain tar alpha"); +} + +#[tokio::test] +async fn test_library_gateway_lists_webspaces_through_runtime_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (root_status, root) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces", + }), + ) + .await; + assert_eq!(root_status, StatusCode::OK); + assert_eq!(root["status"], "ok"); + assert_eq!(root["data"]["uri"], "localhost://WebSpaces"); + let root_objects = root["data"]["objects"].as_array().unwrap(); + assert!(root_objects.iter().any(|object| { + object["uri"] == "localhost://WebSpaces/Elastos" + && object["kind"] == "directory" + && object["availability"] == "resolver-owned" + && object["metadata"]["schema"] == "elastos.library.webspace-object/v1" + && object["metadata"]["mount"] == "Elastos" + && object["metadata"]["resolver"] == "builtin" + && object["metadata"]["cache_policy"] == "metadata-only" + && object["metadata"]["sync_policy"] == "manual" + && object["metadata"]["object_id"] == "object:webspace:elastos" + && object["metadata"]["head_id"] == "head:webspace:elastos" + && object["metadata"]["cache_state"] == "metadata_cached" + && object["metadata"]["sync_state"] == "manual_idle" + && object["metadata"]["webspace_kind"] == "dynamic-webspace" + && object["metadata"]["readonly"] == true + && object["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "list") + })); + let google = root_objects + .iter() + .find(|object| object["uri"] == "localhost://WebSpaces/Google") + .expect("indexed Google WebSpace mount should be listed"); + assert_eq!(google["kind"], "directory"); + assert_eq!(google["metadata"]["target_uri"], "google://drive"); + assert_eq!(google["metadata"]["resolver"], "google-drive"); + assert_eq!( + google["metadata"]["cache_policy"], + "metadata-and-thumbnails" + ); + assert_eq!(google["metadata"]["webspace_kind"], "mounted-webspace"); + + let (elastos_status, elastos) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Elastos", + }), + ) + .await; + assert_eq!(elastos_status, StatusCode::OK); + assert_eq!(elastos["status"], "ok"); + let names: Vec<&str> = elastos["data"]["objects"] + .as_array() + .unwrap() + .iter() + .filter_map(|object| object["name"].as_str()) + .collect(); + for expected in ["_meta.json", "content", "peer", "did", "ai"] { + assert!( + names.contains(&expected), + "missing WebSpace child {expected}" + ); + } + let content = elastos["data"]["objects"] + .as_array() + .unwrap() + .iter() + .find(|object| object["name"] == "content") + .expect("content WebSpace child should be listed"); + assert_eq!(content["metadata"]["target_uri"], "elastos://"); + assert_eq!(content["metadata"]["resolver"], "builtin"); + assert_eq!(content["metadata"]["object_id"], "object:webspace:content"); + assert_eq!(content["metadata"]["head_id"], "head:webspace:content"); + assert_eq!(content["metadata"]["cache_state"], "metadata_cached"); + assert_eq!(content["metadata"]["sync_state"], "manual_idle"); + assert_eq!(content["metadata"]["webspace_kind"], "folder-handle"); + + let (google_status, google_list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Google", + }), + ) + .await; + assert_eq!(google_status, StatusCode::OK); + let google_names: Vec<&str> = google_list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .filter_map(|object| object["name"].as_str()) + .collect(); + for expected in ["_meta.json", "Drive", "Shared"] { + assert!( + google_names.contains(&expected), + "missing indexed Google child {expected}" + ); + } + + let (project_status, project) = post_library( + app, + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Google/Drive/Project X", + }), + ) + .await; + assert_eq!(project_status, StatusCode::OK); + let file = project["data"]["objects"] + .as_array() + .unwrap() + .iter() + .find(|object| object["name"] == "file.pdf") + .expect("indexed Google file should be listed"); + assert_eq!( + file["metadata"]["target_uri"], + "google://drive/Drive/Project X/file.pdf" + ); + assert_eq!(file["metadata"]["resolver"], "google-drive"); + assert_eq!(file["metadata"]["webspace_kind"], "indexed-file"); + assert_eq!(file["availability"], "resolver-owned"); +} + +#[tokio::test] +async fn test_library_provider_extracts_zip_archive() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Bundle.zip"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let archive_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.add_directory("Nested/", options).unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() + }; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!(write["data"]["object"]["mime"], "application/zip"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (extract_status, extracted) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extracted["status"], "ok"); + let extracted_uri = extracted["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(extracted_uri, format!("{documents_uri}/Bundle")); + + for (path, expected) in [ + ("alpha.txt", "zip alpha"), + ("Nested/deep.txt", "zip nested"), + ] { + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{extracted_uri}/{path}"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), expected); + } +} + +#[tokio::test] +async fn test_library_provider_lists_supported_archive_entries_through_viewer_route() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Bundle.zip"); + let tar_uri = format!("{documents_uri}/Bundle.tar"); + let encoded_zip_uri = zip_uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive Manager viewer", + "Archive Manager", + ); + write_test_static_capsule( + dir.path(), + "documents", + "viewer", + "Test Documents viewer", + "Documents", + ); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.add_directory("Nested/", options).unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, zip_write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + assert_eq!(zip_write["data"]["object"]["viewer"], "archive-manager"); + + let (entries_status, entries) = post_library( + app.clone(), + &token, + "archive_entries", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(entries_status, StatusCode::OK); + assert_eq!(entries["status"], "ok"); + assert_eq!( + entries["data"]["schema"], + "elastos.library.archive-entries/v1" + ); + assert_eq!(entries["data"]["family"], "zip"); + assert_eq!(entries["data"]["limits"]["truncated"], false); + let entry_rows = entries["data"]["entries"].as_array().unwrap(); + assert!(entry_rows.iter().any(|entry| { + entry["path"] == "alpha.txt" + && entry["kind"] == "file" + && entry["safety"]["status"] == "safe" + && entry["size"] == 9 + && entry["compressed_size"].as_u64().is_some() + })); + assert!(entry_rows + .iter() + .any(|entry| { entry["path"] == "Nested" && entry["kind"] == "directory" })); + assert!(entry_rows.iter().any(|entry| { + entry["path"] == "Nested/deep.txt" && entry["safety"]["status"] == "safe" + })); + + let direct_entries = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/archive_entries") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": zip_uri, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_entries.status(), StatusCode::FORBIDDEN); + + let direct_preview = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/archive_preview_entry") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": zip_uri, + "entry": "Nested/deep.txt", + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_preview.status(), StatusCode::FORBIDDEN); + + let direct_roots = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/roots") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_roots.status(), StatusCode::FORBIDDEN); + + let viewer_entries = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_zip_uri}&entries=true" + )) + .header("x-elastos-home-token", archive_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_status = viewer_entries.status(); + let viewer_body = axum::body::to_bytes(viewer_entries.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_entries: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!( + viewer_entries["data"]["schema"], + "elastos.library.archive-entries/v1" + ); + assert!(viewer_entries["data"]["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Nested/deep.txt")); + + let viewer_preview = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_zip_uri}&preview_entry=Nested%2Fdeep.txt" + )) + .header("x-elastos-home-token", archive_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_preview_status = viewer_preview.status(); + let viewer_preview_body = axum::body::to_bytes(viewer_preview.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_preview_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_preview_body) + ); + let viewer_preview: serde_json::Value = serde_json::from_slice(&viewer_preview_body).unwrap(); + assert_eq!( + viewer_preview["data"]["schema"], + "elastos.library.archive-preview-entry/v1" + ); + assert_eq!(viewer_preview["data"]["entry"]["path"], "Nested/deep.txt"); + assert_eq!(viewer_preview["data"]["entry"]["mime"], "text/plain"); + assert_eq!( + viewer_preview["data"]["entry"]["viewers"][0]["id"], + "documents" + ); + assert_eq!(viewer_preview["data"]["preview"]["text"], "zip nested"); + assert_eq!(viewer_preview["data"]["preview"]["truncated"], false); + + let viewer_roots = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/viewers/archive-manager/library-roots") + .header("x-elastos-home-token", archive_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_roots_status = viewer_roots.status(); + let viewer_roots_body = axum::body::to_bytes(viewer_roots.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_roots_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_roots_body) + ); + let viewer_roots: serde_json::Value = serde_json::from_slice(&viewer_roots_body).unwrap(); + assert!(viewer_roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| root["label"] == "Documents" && root["uri"] == documents_uri)); + + let tar_bytes = { + let mut builder = tar::Builder::new(Vec::new()); + let mut alpha = b"tar alpha".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_mtime(1_780_000_000); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "alpha.txt", &mut alpha) + .unwrap(); + builder.into_inner().unwrap() + }; + let (tar_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": tar_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(tar_bytes), + }), + ) + .await; + assert_eq!(tar_write_status, StatusCode::OK); + let (tar_entries_status, tar_entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": tar_uri, + }), + ) + .await; + assert_eq!(tar_entries_status, StatusCode::OK); + assert_eq!(tar_entries["data"]["family"], "tar"); + assert!(tar_entries["data"]["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| { + entry["path"] == "alpha.txt" + && entry["size"] == 9 + && entry["modified_at"] == 1_780_000_000u64 + && entry["safety"]["status"] == "safe" + })); +} + +#[tokio::test] +async fn test_library_provider_lists_unsafe_archive_entries_as_blocked() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Unsafe.zip"); + let tar_uri = format!("{documents_uri}/Unsafe.tar"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file("../escape.txt", options).unwrap(); + writer.write_all(b"escape").unwrap(); + writer.start_file("/absolute.txt", options).unwrap(); + writer.write_all(b"absolute").unwrap(); + writer.start_file("safe.txt", options).unwrap(); + writer.write_all(b"safe").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + let (zip_entries_status, zip_entries) = post_library( + app.clone(), + &token, + "archive_entries", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(zip_entries_status, StatusCode::OK); + assert_eq!(zip_entries["status"], "ok"); + let zip_rows = zip_entries["data"]["entries"].as_array().unwrap(); + assert!(zip_rows.iter().any(|entry| { + entry["path"] == "../escape.txt" + && entry["kind"] == "blocked" + && entry["safety"]["status"] == "blocked" + && entry["safety"]["reason"] + .as_str() + .unwrap() + .contains("relative and safe") + })); + assert!(zip_rows + .iter() + .any(|entry| entry["path"] == "safe.txt" && entry["safety"]["status"] == "safe")); + assert!(zip_rows.iter().any(|entry| { + entry["path"] == "/absolute.txt" + && entry["kind"] == "blocked" + && entry["safety"]["reason"] + .as_str() + .unwrap() + .contains("relative and safe") + })); + + let tar_bytes = { + let mut builder = tar::Builder::new(Vec::new()); + let mut alpha = b"tar safe".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "safe.txt", &mut alpha) + .unwrap(); + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Symlink); + link_header.set_size(0); + link_header.set_mode(0o644); + link_header.set_cksum(); + builder + .append_link(&mut link_header, "link.txt", "safe.txt") + .unwrap(); + builder.into_inner().unwrap() + }; + let (tar_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": tar_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(tar_bytes), + }), + ) + .await; + assert_eq!(tar_write_status, StatusCode::OK); + let (tar_entries_status, tar_entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": tar_uri, + }), + ) + .await; + assert_eq!(tar_entries_status, StatusCode::OK); + let tar_rows = tar_entries["data"]["entries"].as_array().unwrap(); + assert!(tar_rows + .iter() + .any(|entry| entry["path"] == "safe.txt" && entry["safety"]["status"] == "safe")); + assert!(tar_rows.iter().any(|entry| { + entry["path"] == "link.txt" + && entry["kind"] == "blocked" + && entry["safety"]["reason"] + .as_str() + .unwrap() + .contains("non-file") + })); +} + +#[tokio::test] +async fn test_library_provider_selectively_extracts_archive_entries_through_viewer_route() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let imports_uri = format!("{documents_uri}/Imports"); + let nested_imports_uri = format!("{imports_uri}/Nested"); + let zip_uri = format!("{documents_uri}/Bundle.zip"); + let encoded_zip_uri = zip_uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive Manager viewer", + "Archive Manager", + ); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (imports_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Imports", + }), + ) + .await; + assert_eq!(imports_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": imports_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + let (existing_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": format!("{nested_imports_uri}/deep.txt"), + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"existing"), + }), + ) + .await; + assert_eq!(existing_status, StatusCode::OK); + + let direct_extract = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/archive_extract_entries") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": zip_uri, + "destination_uri": imports_uri, + "entries": ["Nested/deep.txt"], + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_extract.status(), StatusCode::FORBIDDEN); + + let viewer_extract = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_zip_uri}" + )) + .header("x-elastos-home-token", archive_token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "destination_uri": imports_uri, + "entries": ["Nested/deep.txt"], + "conflict_policy": "replace", + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + let viewer_status = viewer_extract.status(); + let viewer_body = axum::body::to_bytes(viewer_extract.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_extract: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!(viewer_extract["status"], "ok"); + assert_eq!( + viewer_extract["data"]["schema"], + "elastos.library.archive-extract-entries/v1" + ); + assert_eq!(viewer_extract["data"]["receipt"]["status"], "completed"); + assert_eq!( + viewer_extract["data"]["receipt"]["progress"]["requested_entries"], + 1 + ); + assert_eq!( + viewer_extract["data"]["receipt"]["progress"]["written_entries"], + 1 + ); + assert_eq!( + viewer_extract["data"]["receipt"]["cancel"]["status"], + "not_requested" + ); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{nested_imports_uri}/deep.txt"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "zip nested"); + + let (cancel_status, cancel) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": zip_uri, + "destination_uri": imports_uri, + "entries": ["alpha.txt"], + "cancel": true, + }), + ) + .await; + assert_eq!(cancel_status, StatusCode::OK); + assert_eq!(cancel["status"], "ok"); + assert_eq!(cancel["data"]["receipt"]["status"], "cancelled"); + assert_eq!( + cancel["data"]["receipt"]["cancel"]["status"], + "cancelled_before_write" + ); + + let (list_status, list) = post_library( + app, + &token, + "list", + json!({ + "uri": imports_uri, + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert!(!list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .any(|object| object["name"] == "alpha.txt")); +} + +#[tokio::test] +async fn test_library_provider_selective_extract_blocks_unsafe_entries() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let imports_uri = format!("{documents_uri}/Imports"); + let tar_uri = format!("{documents_uri}/Unsafe.tar"); + let zip_uri = format!("{documents_uri}/Unsafe.zip"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (imports_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Imports", + }), + ) + .await; + assert_eq!(imports_status, StatusCode::OK); + + let tar_bytes = { + let mut builder = tar::Builder::new(Vec::new()); + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Symlink); + link_header.set_size(0); + link_header.set_mode(0o644); + link_header.set_cksum(); + builder + .append_link(&mut link_header, "link.txt", "safe.txt") + .unwrap(); + builder.into_inner().unwrap() + }; + let (tar_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": tar_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(tar_bytes), + }), + ) + .await; + assert_eq!(tar_write_status, StatusCode::OK); + let (extract_status, extract) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": tar_uri, + "destination_uri": imports_uri, + "entries": ["link.txt"], + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extract["status"], "ok"); + assert_eq!( + extract["data"]["receipt"]["status"], + "completed_with_blocked_entries" + ); + assert_eq!(extract["data"]["receipt"]["progress"]["blocked_entries"], 1); + assert!(extract["data"]["blocked"][0]["reason"] + .as_str() + .unwrap() + .contains("non-file")); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file("../escape.txt", options).unwrap(); + writer.write_all(b"escape").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + let (unsafe_select_status, unsafe_select) = post_library( + app, + &token, + "archive_extract_entries", + json!({ + "uri": zip_uri, + "destination_uri": imports_uri, + "entries": ["../escape.txt"], + }), + ) + .await; + assert_eq!(unsafe_select_status, StatusCode::OK); + assert_eq!(unsafe_select["status"], "error"); + assert!(unsafe_select["message"] + .as_str() + .unwrap() + .contains("relative and safe")); +} + +#[tokio::test] +async fn test_library_provider_rejects_unsafe_zip_entries() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Unsafe.zip"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let archive_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file("../escape.txt", options).unwrap(); + writer.write_all(b"escape").unwrap(); + writer.finish().unwrap().into_inner() + }; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (extract_status, extracted) = post_library( + app, + &token, + "extract_archive", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extracted["status"], "error"); + assert!(extracted["message"] + .as_str() + .unwrap() + .contains("relative and safe")); +} + +#[tokio::test] +async fn test_library_gateway_reads_webspace_files_through_runtime_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let uri = format!("localhost://WebSpaces/Elastos/content/{TEST_CIDV1}"); + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + assert_eq!(read["data"]["object"]["kind"], "file"); + assert_eq!( + read["data"]["object"]["metadata"]["target_uri"], + format!("elastos://{TEST_CIDV1}") + ); + assert_eq!( + read["data"]["object"]["metadata"]["provider"], + "content-provider" + ); + assert_eq!( + read["data"]["object"]["metadata"]["webspace_kind"], + "file-endpoint" + ); + assert_eq!(read["data"]["object"]["metadata"]["resolver"], "builtin"); + assert_eq!(read["data"]["encoding"], "base64"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["target_uri"], format!("elastos://{TEST_CIDV1}")); + + let (download_status, headers, download_bytes) = get_library_download(app, &token, &uri).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!(download_bytes, bytes); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + ); + let expected_disposition = format!("attachment; filename=\"{TEST_CIDV1}\""); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some(expected_disposition.as_str()), + ); +} + +#[tokio::test] +async fn test_library_gateway_reads_external_webspace_file_through_adapter_cache() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let uri = "localhost://WebSpaces/Google/Drive/Project X/file.pdf"; + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + assert_eq!(read["data"]["object"]["kind"], "file"); + assert_eq!( + read["data"]["object"]["metadata"]["resolver"], + "google-drive" + ); + assert_eq!( + read["data"]["object"]["metadata"]["target_uri"], + "google://drive/Drive/Project X/file.pdf" + ); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"google adapter bytes"); +} + +#[tokio::test] +async fn test_library_gateway_operator_webspace_adapter_caches_bytes_and_viewer() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + + let uri = "localhost://WebSpaces/Operator/Projects/Brief.md"; + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + let object = &read["data"]["object"]; + assert_eq!(object["kind"], "file"); + assert_eq!(object["mime"], "text/plain"); + assert_eq!(object["viewer"], "documents"); + assert_eq!(object["viewers"][0]["id"], "documents"); + assert_eq!(object["metadata"]["resolver"], "operator-drive"); + assert_eq!( + object["metadata"]["target_uri"], + "operator://drive/Projects/Brief.md" + ); + assert_eq!(object["metadata"]["cache_state"], "content_cached"); + assert_eq!(object["metadata"]["resolver_state"], "materialized-local"); + assert_eq!(object["metadata"]["webspace_kind"], "materialized-file"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"# Operator Brief\n\nAdapter-backed bytes.\n"); + + let (stat_status, stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + assert_eq!(stat["status"], "ok"); + assert_eq!( + stat["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!(stat["data"]["object"]["viewer"], "documents"); + + let (second_read_status, second_read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(second_read_status, StatusCode::OK); + let second_bytes = base64::engine::general_purpose::STANDARD + .decode(second_read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(second_bytes, bytes); + assert_eq!( + second_read["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); +} + +#[tokio::test] +async fn test_library_gateway_lists_external_webspace_archive_entries_without_resolver_leak() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive Manager viewer", + "Archive Manager", + ); + + let uri = "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + let viewer_entries = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}&entries=true" + )) + .header("x-elastos-home-token", archive_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_entries_status = viewer_entries.status(); + let viewer_body = axum::body::to_bytes(viewer_entries.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_entries_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_entries: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!(viewer_entries["status"], "ok"); + assert_eq!( + viewer_entries["data"]["schema"], + "elastos.library.archive-entries/v1" + ); + assert_eq!(viewer_entries["data"]["family"], "zip"); + assert_eq!( + viewer_entries["data"]["object"]["metadata"]["resolver_target_redacted"], + true + ); + assert_eq!( + viewer_entries["data"]["object"]["metadata"]["target_uri"], + serde_json::Value::Null + ); + assert!(viewer_entries["data"]["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Nested/deep.txt" && entry["safety"]["status"] == "safe")); + assert!(!serde_json::to_string(&viewer_entries) + .unwrap() + .contains("operator://")); + + let viewer_preview = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}&preview_entry=Nested%2Fdeep.txt" + )) + .header("x-elastos-home-token", archive_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_preview_status = viewer_preview.status(); + let viewer_preview_body = axum::body::to_bytes(viewer_preview.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_preview_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_preview_body) + ); + let viewer_preview: serde_json::Value = serde_json::from_slice(&viewer_preview_body).unwrap(); + assert_eq!( + viewer_preview["data"]["schema"], + "elastos.library.archive-preview-entry/v1" + ); + assert_eq!(viewer_preview["data"]["preview"]["text"], "zip nested"); + assert!(!serde_json::to_string(&viewer_preview) + .unwrap() + .contains("operator://")); + + let (provider_status, provider_entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(provider_status, StatusCode::OK); + assert_eq!(provider_entries["status"], "ok"); + assert!(!serde_json::to_string(&provider_entries) + .unwrap() + .contains("operator://")); +} + +#[tokio::test] +async fn test_library_gateway_imports_external_webspace_archive_entries_to_local_library() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let imports_uri = format!("{documents_uri}/Imports"); + let source_uri = "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + let encoded_source_uri = source_uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive Manager viewer", + "Archive Manager", + ); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (imports_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Imports", + }), + ) + .await; + assert_eq!(imports_status, StatusCode::OK); + + let viewer_extract = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_source_uri}" + )) + .header("x-elastos-home-token", archive_token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "destination_uri": imports_uri, + "entries": ["Nested/deep.txt"], + "conflict_policy": "replace", + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + let viewer_extract_status = viewer_extract.status(); + let viewer_body = axum::body::to_bytes(viewer_extract.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_extract_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_extract: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!(viewer_extract["status"], "ok"); + assert_eq!( + viewer_extract["data"]["schema"], + "elastos.library.archive-extract-entries/v1" + ); + assert_eq!(viewer_extract["data"]["receipt"]["status"], "completed"); + assert_eq!( + viewer_extract["data"]["written"][0]["uri"], + format!("{imports_uri}/Nested/deep.txt") + ); + assert!(!serde_json::to_string(&viewer_extract) + .unwrap() + .contains("operator://")); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": format!("{imports_uri}/Nested/deep.txt"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "zip nested"); +} + +#[tokio::test] +async fn test_library_gateway_webspace_archive_writeback_requires_mutable_write_adapter() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let source_uri = "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + + let (readonly_status, readonly) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": source_uri, + "destination_uri": "localhost://WebSpaces/Operator/Projects", + "entries": ["alpha.txt"], + "conflict_policy": "replace", + }), + ) + .await; + assert_eq!(readonly_status, StatusCode::OK); + assert_eq!(readonly["status"], "error"); + assert!( + readonly["message"] + .as_str() + .unwrap() + .contains("mutable destination Space"), + "{readonly}" + ); + + let (writeback_status, writeback) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": source_uri, + "destination_uri": "localhost://WebSpaces/OperatorMutable/Folder", + "entries": ["alpha.txt"], + "conflict_policy": "replace", + }), + ) + .await; + assert_eq!(writeback_status, StatusCode::OK); + assert_eq!(writeback["status"], "ok"); + assert_eq!(writeback["data"]["receipt"]["status"], "completed"); + assert_eq!( + writeback["data"]["written"][0]["webspace"]["write_back"], + "resolver_synced" + ); + assert_eq!( + writeback["data"]["written"][0]["uri"], + "localhost://WebSpaces/OperatorMutable/Folder/alpha.txt" + ); + assert!(!serde_json::to_string(&writeback) + .unwrap() + .contains("operator://")); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": "localhost://WebSpaces/OperatorMutable/Folder/alpha.txt", + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "zip alpha"); +} + +#[tokio::test] +async fn test_library_gateway_webspace_sync_caches_adapter_bytes_without_foreground_read() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + + let uri = "localhost://WebSpaces/Operator/Projects/Brief.md"; + let expected = b"# Operator Brief\n\nAdapter-backed bytes.\n"; + let (sync_status, sync) = post_library( + app.clone(), + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!(receipt["schema"], "elastos.webspace.byte-sync-receipt/v1"); + assert_eq!(receipt["action"], "bytes_cached_from_adapter"); + assert_eq!(receipt["foreground_read"], false); + assert_eq!(receipt["bytes_exposed"], false); + assert_eq!(receipt["content_synced"], true); + assert_eq!(receipt["bytes_cached"], expected.len()); + assert_eq!( + receipt["availability_hint"]["schema"], + "elastos.webspace.availability-hint/v1" + ); + assert_eq!(receipt["availability_hint"]["status"], "resolver_cached"); + assert_eq!( + receipt["availability_hint"]["target_uri"], + "operator://drive/Projects/Brief.md" + ); + assert_eq!( + receipt["availability_hint"]["not_content_availability"], + true + ); + assert!(receipt.get("data").is_none()); + assert_eq!(sync["data"]["object"]["availability"], "resolver-cached"); + assert_eq!( + sync["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!( + sync["data"]["object"]["metadata"]["availability_hint"]["status"], + "resolver_cached" + ); + assert_eq!(sync["data"]["object"]["viewer"], "documents"); + + let (stat_status, stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + assert_eq!( + stat["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!( + stat["data"]["object"]["metadata"]["webspace_kind"], + "materialized-file" + ); + assert_eq!(stat["data"]["object"]["availability"], "resolver-cached"); + assert_eq!( + stat["data"]["object"]["metadata"]["availability_hint"]["scope"], + "resolver" + ); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, expected); + assert_eq!( + read["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!(read["data"]["object"]["availability"], "resolver-cached"); +} + +#[tokio::test] +async fn test_library_gateway_syncs_operator_mutable_webspace_file_to_resolver() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let uri = "localhost://WebSpaces/OperatorMutable/Folder/note.txt"; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"operator mutable bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); + assert_eq!( + write["data"]["object"]["metadata"]["target_uri"], + "operator://drive/Writable/Folder/note.txt" + ); + + let (sync_status, sync) = post_library( + app.clone(), + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!( + receipt["schema"], + "elastos.webspace.resolver-sync-receipt/v1" + ); + assert_eq!(receipt["action"], "resolver_write_synced"); + assert_eq!(receipt["resolver_synced"], true); + assert_eq!(receipt["content_synced"], true); + assert_eq!(receipt["fail_closed"], false); + assert_eq!(receipt["conflict"], false); + assert_eq!(receipt["bytes_exposed"], false); + assert_eq!(receipt["bytes_synced"], b"operator mutable bytes".len()); + assert_eq!(receipt["provider"], "operator-drive-adapter"); + assert_eq!( + receipt["availability_hint"]["schema"], + "elastos.webspace.availability-hint/v1" + ); + assert_eq!(receipt["availability_hint"]["status"], "resolver_synced"); + assert_eq!( + receipt["availability_hint"]["not_content_availability"], + true + ); + assert_eq!( + receipt["target_uri"], + "operator://drive/Writable/Folder/note.txt" + ); + assert_eq!( + receipt["adapter_receipt"]["schema"], + "elastos.webspace.adapter.write-bytes-receipt/v1" + ); + assert_eq!( + sync["data"]["object"]["metadata"]["sync_state"], + "manual_synced" + ); + assert_eq!(sync["data"]["object"]["availability"], "resolver-synced"); + assert_eq!( + sync["data"]["object"]["metadata"]["availability_hint"]["status"], + "resolver_synced" + ); + + let (stat_status, stat) = post_library( + app, + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + assert_eq!( + stat["data"]["object"]["metadata"]["sync_state"], + "manual_synced" + ); + assert_eq!(stat["data"]["object"]["availability"], "resolver-synced"); +} + +#[tokio::test] +async fn test_library_gateway_webspace_sync_fails_closed_without_write_adapter() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let uri = "localhost://WebSpaces/Mutable/Folder/no-adapter.txt"; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"local mutable bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); + + let (sync_status, sync) = post_library( + app, + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!( + receipt["schema"], + "elastos.webspace.resolver-sync-receipt/v1" + ); + assert_eq!(receipt["action"], "resolver_write_unavailable"); + assert_eq!(receipt["resolver_synced"], false); + assert_eq!(receipt["content_synced"], false); + assert_eq!(receipt["fail_closed"], true); + assert_eq!(receipt["conflict"], false); + assert_eq!(receipt["bytes_exposed"], false); + assert_eq!( + sync["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); +} + +#[tokio::test] +async fn test_library_gateway_webspace_sync_reports_resolver_conflict() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let uri = "localhost://WebSpaces/OperatorMutable/Conflict/stale.txt"; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"stale fork bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (sync_status, sync) = post_library( + app, + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!( + receipt["schema"], + "elastos.webspace.resolver-sync-receipt/v1" + ); + assert_eq!(receipt["action"], "resolver_write_conflict"); + assert_eq!(receipt["resolver_synced"], false); + assert_eq!(receipt["content_synced"], false); + assert_eq!(receipt["fail_closed"], true); + assert_eq!(receipt["conflict"], true); + assert_eq!(receipt["provider"], "operator-drive-adapter"); + assert_eq!( + receipt["adapter_response"]["data"]["schema"], + "elastos.webspace.adapter.write-conflict/v1" + ); + assert_eq!( + sync["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); +} + +#[tokio::test] +async fn test_library_gateway_rejects_webspace_mutation_as_read_only() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (write_status, write) = post_library( + app, + &token, + "write", + json!({ + "uri": "localhost://WebSpaces/Elastos/not-allowed.txt", + "data": base64::engine::general_purpose::STANDARD.encode(b"must fail"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "error"); + assert!(write["message"] + .as_str() + .unwrap() + .contains("resolver-owned and read-only")); +} + +#[tokio::test] +async fn test_library_gateway_mutates_writable_webspace_through_runtime_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (list_status, list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Mutable", + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert_eq!(list["status"], "ok"); + assert_eq!(list["data"]["object"]["metadata"]["readonly"], false); + assert_eq!( + list["data"]["object"]["metadata"]["access_policy"], + "owner-writable" + ); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": "localhost://WebSpaces/Mutable", + "name": "Folder", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!(mkdir["status"], "ok"); + assert_eq!( + mkdir["data"]["receipt"]["schema"], + "elastos.webspace.mkdir-receipt/v1" + ); + assert_eq!(mkdir["data"]["object"]["metadata"]["readonly"], false); + + let note_uri = "localhost://WebSpaces/Mutable/Folder/note.txt"; + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": note_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"mutable bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["receipt"]["schema"], + "elastos.webspace.write-receipt/v1" + ); + assert_eq!( + write["data"]["object"]["metadata"]["webspace_kind"], + "materialized-file" + ); + assert_eq!(write["data"]["object"]["metadata"]["readonly"], false); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "delete_permanently")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": note_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"mutable bytes"); + + let upload_uri = "localhost://WebSpaces/Mutable/Folder/upload.txt"; + let (upload_status, _upload_headers, upload) = + put_library_upload(app.clone(), &token, upload_uri, b"uploaded bytes").await; + assert_eq!(upload_status, StatusCode::OK); + assert_eq!(upload["status"], "ok"); + assert_eq!( + upload["data"]["receipt"]["schema"], + "elastos.object.transfer.receipt/v1" + ); + assert_eq!( + upload["data"]["provider_receipt"]["schema"], + "elastos.webspace.write-receipt/v1" + ); + assert_eq!( + upload["data"]["object"]["metadata"]["webspace_kind"], + "materialized-file" + ); + + let (delete_status, deleted) = post_library( + app, + &token, + "delete_permanently", + json!({ + "uri": note_uri, + }), + ) + .await; + assert_eq!(delete_status, StatusCode::OK); + assert_eq!(deleted["status"], "ok"); + assert_eq!( + deleted["data"]["receipt"]["schema"], + "elastos.webspace.delete-receipt/v1" + ); + assert_eq!(deleted["data"]["deleted_uri"], note_uri); +} + +#[tokio::test] +async fn test_library_provider_move_is_principal_scoped_and_audited() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let target_uri = format!("{documents_uri}/Moved"); + let source_uri = format!("{documents_uri}/move-me.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Moved", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": source_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"moved bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let revision = write["data"]["object"]["revision"].as_str().unwrap(); + + let (move_status, moved) = post_library( + app.clone(), + &token, + "move", + json!({ + "uri": source_uri, + "target_parent_uri": target_uri, + "if_revision": revision, + }), + ) + .await; + assert_eq!(move_status, StatusCode::OK); + let moved_uri = moved["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(moved_uri, format!("{target_uri}/move-me.txt")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": moved_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(), + b"moved bytes" + ); + + let (events_status, events) = post_library( + app, + &token, + "events", + json!({ + "uri": target_uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert!(events["data"]["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["op"] == "move" && event["details"]["old_uri"] == source_uri)); +} + +#[tokio::test] +async fn test_library_provider_copy_preserves_source_and_audits() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let target_uri = format!("{documents_uri}/Copied"); + let source_uri = format!("{documents_uri}/copy-me.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Copied", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": source_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"copied bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let revision = write["data"]["object"]["revision"].as_str().unwrap(); + + let (copy_status, copied) = post_library( + app.clone(), + &token, + "copy", + json!({ + "uri": source_uri, + "target_parent_uri": target_uri, + "if_revision": revision, + }), + ) + .await; + assert_eq!(copy_status, StatusCode::OK); + let copied_uri = copied["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(copied_uri, format!("{target_uri}/copy-me.txt")); + + for uri in [&source_uri, copied_uri] { + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(), + b"copied bytes" + ); + } + + let (events_status, events) = post_library( + app, + &token, + "events", + json!({ + "uri": target_uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert!(events["data"]["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["op"] == "copy" && event["details"]["source_uri"] == source_uri)); +} + +#[tokio::test] +async fn test_library_provider_events_returns_typed_object_events() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let notes_uri = format!("{root}/Documents/events.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": notes_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"evented"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Event Folder", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!(mkdir["status"], "ok"); + + let (events_status, events) = post_library(app.clone(), &token, "events", json!({})).await; + assert_eq!(events_status, StatusCode::OK); + assert_eq!(events["status"], "ok"); + assert_eq!(events["data"]["schema"], "elastos.library.events/v1"); + let events = events["data"]["events"].as_array().unwrap(); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| { + event["schema"] == "elastos.library.event/v1" + && event["event_id"] + .as_str() + .unwrap() + .starts_with("library:event:") + })); + assert_eq!(events[0]["op"], "write"); + assert_eq!(events[0]["uri"], notes_uri); + assert_eq!(events[0]["details"]["object"]["name"], "events.txt"); + assert_eq!(events[1]["op"], "mkdir"); + + let (filtered_status, filtered) = post_library( + app.clone(), + &token, + "events", + json!({ + "uri": notes_uri, + }), + ) + .await; + assert_eq!(filtered_status, StatusCode::OK); + let filtered_events = filtered["data"]["events"].as_array().unwrap(); + assert_eq!(filtered_events.len(), 1); + assert_eq!(filtered_events[0]["op"], "write"); + + let (limited_status, limited) = post_library( + app, + &token, + "events", + json!({ + "limit": 1, + }), + ) + .await; + assert_eq!(limited_status, StatusCode::OK); + let limited_events = limited["data"]["events"].as_array().unwrap(); + assert_eq!(limited_events.len(), 1); + assert_eq!(limited_events[0]["op"], "mkdir"); +} + +#[tokio::test] +async fn test_library_provider_events_stream_requires_library_token_and_serves_sse() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let unauthorized = app + .clone() + .oneshot( + Request::builder() + .uri("/api/provider/object/events/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + + let authorized = app + .oneshot( + Request::builder() + .uri(format!( + "/api/provider/object/events/stream?home_token={token}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authorized.status(), StatusCode::OK); + assert!( + authorized + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/event-stream")), + "Library event stream should be served as SSE" + ); + assert_eq!( + authorized + .headers() + .get(axum::http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-cache, no-transform"), + "Library SSE must not be cached or transformed by proxies" + ); + assert_eq!( + authorized + .headers() + .get("x-accel-buffering") + .and_then(|value| value.to_str().ok()), + Some("no"), + "nginx must not buffer realtime Library events" + ); +} + +#[tokio::test] +async fn test_library_provider_viewers_only_include_installed_viewer_capsules() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/view-me.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"viewer routing"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (without_viewer_status, without_viewer) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(without_viewer_status, StatusCode::OK); + assert!(without_viewer["data"]["object"]["viewer"].is_null()); + assert!(without_viewer["data"]["object"]["viewers"].is_null()); + + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + + let (with_viewer_status, with_viewer) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(with_viewer_status, StatusCode::OK); + assert_eq!( + with_viewer["data"]["object"]["viewer"], + DOCUMENTS_CAPSULE_ID + ); + assert_eq!( + with_viewer["data"]["object"]["viewers"][0]["id"], + DOCUMENTS_CAPSULE_ID + ); + assert_eq!( + with_viewer["data"]["object"]["viewers"][0]["label"], + "Documents" + ); + + let rom_uri = format!("{root}/Documents/game.gba"); + let (rom_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": rom_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"rom bytes"), + }), + ) + .await; + assert_eq!(rom_write_status, StatusCode::OK); + + write_test_static_capsule( + dir.path(), + GBA_EMULATOR_CAPSULE_ID, + "viewer", + "Test GBA emulator", + "GBA Emulator", + ); + + let (rom_viewer_status, rom_viewer) = post_library( + app, + &token, + "stat", + json!({ + "uri": rom_uri, + }), + ) + .await; + assert_eq!(rom_viewer_status, StatusCode::OK); + assert_eq!( + rom_viewer["data"]["object"]["viewer"], + GBA_EMULATOR_CAPSULE_ID + ); + assert_eq!( + rom_viewer["data"]["object"]["viewers"][0]["label"], + "GBA Emulator" + ); +} + +#[tokio::test] +async fn test_library_provider_rejects_traversal_segments() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + + let (status, payload) = post_library( + app, + &token, + "read", + json!({ + "uri": format!("{root}/Documents/../Secrets.txt"), + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["status"], "error"); + assert!(payload["message"].as_str().unwrap().contains("traversal")); +} + +#[tokio::test] +async fn test_library_provider_scopes_to_launch_principal() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let admin = passkey_authority_with_name(dir.path(), Some("admin")); + let guest = passkey_authority_with_name_role( + dir.path(), + Some("guest"), + crate::auth::RuntimePrincipalRole::Guest, + ); + let admin_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &admin); + let guest_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &guest); + let admin_root = crate::auth::principal_localhost_root(&admin.principal_id); + let guest_root = crate::auth::principal_localhost_root(&guest.principal_id); + let admin_uri = format!("{admin_root}/Documents/private.txt"); + + let (write_status, write) = post_library( + app.clone(), + &admin_token, + "write", + json!({ + "uri": admin_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"admin only"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (guest_status, guest_read) = post_library( + app, + &guest_token, + "read", + json!({ + "uri": admin_uri, + }), + ) + .await; + assert_eq!(guest_status, StatusCode::OK); + assert_eq!(guest_read["status"], "error"); + assert!(guest_read["message"] + .as_str() + .unwrap() + .contains("outside the active principal root")); + assert_ne!(admin_root, guest_root); +} + +#[tokio::test] +async fn test_library_provider_audits_provider_operations() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (status, payload) = post_library(app, &token, "roots", json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["status"], "ok"); + + let auth_state = crate::auth::load_auth_state(dir.path()).unwrap(); + let library_events: Vec<_> = auth_state + .audit + .iter() + .filter(|event| event.event_type.starts_with("object.provider.")) + .collect(); + assert_eq!(library_events.len(), 2); + assert_eq!(library_events[0].event_type, "object.provider.requested"); + assert_eq!(library_events[0].result, "requested"); + assert_eq!(library_events[1].event_type, "object.provider.completed"); + assert_eq!(library_events[1].result, "completed"); + assert_eq!( + library_events[0].challenge_id, + library_events[1].challenge_id + ); + assert_eq!( + library_events[0].capsule_id.as_deref(), + Some(LIBRARY_CAPSULE_ID) + ); + assert!(library_events[0].reason.contains("roots")); +} + +#[tokio::test] +async fn test_library_provider_writes_protected_principal_objects() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + crate::auth::store_test_principal_root_protection(dir.path(), &authority.principal_id); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"encrypted object"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let raw_path = rooted_localhost_fs_path(dir.path(), &uri).unwrap(); + let raw = std::fs::read_to_string(raw_path).unwrap(); + assert!(!raw.contains("encrypted object")); + assert!(raw.contains("elastos.principal-root.object/v1")); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let decoded = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, b"encrypted object"); +} + +#[tokio::test] +async fn test_library_provider_auto_protects_plaintext_legacy_objects() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + crate::auth::store_test_principal_root_protection(dir.path(), &authority.principal_id); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let secret_uri = format!("{documents_uri}/secret.md"); + let secret_path = + elastos_common::localhost::rooted_localhost_fs_path(dir.path(), &secret_uri).unwrap(); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(&secret_path, b"plaintext from an older runtime").unwrap(); + + let (list_status, list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": documents_uri, + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert_eq!(list["status"], "ok"); + let object = list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .find(|object| object["uri"] == secret_uri) + .expect("legacy object should still appear in folder listing"); + assert!(object["blocked_reason"].is_null()); + assert_eq!(object["availability"], "local-only"); + assert!(object["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "read")); + assert_ne!( + std::fs::read(&secret_path).unwrap(), + b"plaintext from an older runtime", + "listing should migrate protected-root plaintext to encrypted storage", + ); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": secret_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, b"plaintext from an older runtime"); +} + +#[tokio::test] +async fn test_library_provider_publish_fails_closed_without_content_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state_without_content(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/no-content.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"publish me"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (publish_status, publish) = post_library( + app, + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "error"); + assert!(publish["message"] + .as_str() + .unwrap() + .contains("content provider unavailable")); +} + +#[tokio::test] +async fn test_library_provider_publish_uses_content_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/publish.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"publish me"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let local_cid = write["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .to_string(); + assert!(local_cid.starts_with("bafkrei")); + assert_eq!(write["data"]["object"].get("published_cid"), None); + assert_eq!(write["data"]["object"]["published"], false); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["cid"], TEST_CIDV1); + assert_eq!(publish["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!(publish["data"]["object"]["content_cid"], local_cid); + assert_eq!(publish["data"]["object"]["published_cid"], TEST_CIDV1); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["data"]["object"]["published"], true); + assert_eq!(status["data"]["object"]["content_cid"], local_cid); + assert_eq!(status["data"]["object"]["published_cid"], TEST_CIDV1); + assert_eq!(status["data"]["published"]["cid"], TEST_CIDV1); +} + +#[tokio::test] +async fn test_library_gateway_coordinates_content_for_external_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_external_provider_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/external-publish.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"publish me externally"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["cid"], TEST_CIDV1); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["status"], "ok"); + assert_eq!(status["data"]["object"]["published"], true); + assert_eq!(status["data"]["published"]["cid"], TEST_CIDV1); +} + +#[tokio::test] +async fn test_library_provider_share_requires_active_publish_record() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/share.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"share me"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (share_draft_status, share_draft) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(share_draft_status, StatusCode::OK); + assert_eq!(share_draft["status"], "error"); + assert!(share_draft["message"] + .as_str() + .unwrap() + .contains("published object")); + + let (publish_status, _) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["schema"], "elastos.library.share/v1"); + assert_eq!(share["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!(share["data"]["policy"], "public_link"); + assert!(share["data"]["recipients"].as_array().unwrap().is_empty()); + assert!(share["data"]["grants"].as_array().unwrap().is_empty()); + assert_eq!(share["data"]["object"]["shared"], true); +} + +#[tokio::test] +async fn test_library_provider_records_recipient_scoped_share_grants() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/scoped-share.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"share me to a recipient"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, _) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + "recipients": [ + authority.principal_id, + "did:key:z6MkRecipient111111111111111111111111111111111", + "did:key:z6MkRecipient111111111111111111111111111111111", + "person:local:alice" + ] + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["policy"], "recipient_scoped"); + assert_eq!(share["data"]["recipients"].as_array().unwrap().len(), 3); + assert_eq!(share["data"]["grants"].as_array().unwrap().len(), 3); + assert_eq!( + share["data"]["grants"][0]["schema"], + "elastos.library.share-grant/v1" + ); + assert_eq!(share["data"]["grants"][0]["cid"], TEST_CIDV1); + assert_eq!(share["data"]["grants"][0]["policy"], "recipient_scoped"); + assert_eq!( + share["data"]["content_security"]["schema"], + "elastos.library.published-content-security/v1" + ); + assert_eq!( + share["data"]["content_security"]["published_payload"], + "plain_content" + ); + assert_eq!( + share["data"]["key_release"]["schema"], + "elastos.library.key-release/v1" + ); + assert_eq!(share["data"]["key_release"]["required"], false); + assert_eq!( + share["data"]["grants"][0]["key_release"]["status"], + "not_required_for_plain_published_content" + ); + assert_eq!( + share["data"]["remote_enforcement"]["required_providers"]["schema"], + "elastos.library.protected-content-provider-requirements/v1" + ); + assert_eq!( + share["data"]["remote_enforcement"]["provider_invocation"]["drm"], + "drm-provider.open" + ); + assert_eq!( + share["data"]["remote_enforcement"]["provider_invocation"]["rights"], + "rights-provider.has_access_by_content_id" + ); + assert_eq!( + share["data"]["protected_content"]["schema"], + "elastos.library.protected-content-provider-status/v1" + ); + assert_eq!( + share["data"]["protected_content"]["encrypted_recipient_sharing"]["status"], + "blocked_until_drm_rights_key_decrypt_providers_configured" + ); + assert_eq!( + share["data"]["protected_content"]["required_provider_count"], + 4 + ); + + let (status_status, status) = post_library( + app.clone(), + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_status, StatusCode::OK); + assert_eq!(status["status"], "ok"); + assert_eq!( + status["data"]["protected_content"]["schema"], + "elastos.library.protected-content-provider-status/v1" + ); + assert_eq!( + status["data"]["published"]["protected_content"]["encrypted_recipient_sharing"]["status"], + "blocked_until_drm_rights_key_decrypt_providers_configured" + ); + + let (access_status, access) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": authority.principal_id, + }), + ) + .await; + assert_eq!(access_status, StatusCode::OK); + assert_eq!(access["status"], "ok"); + assert_eq!(access["data"]["schema"], "elastos.library.shared-access/v1"); + assert_eq!(access["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!(access["data"]["access"]["policy"], "recipient_scoped"); + assert_eq!( + access["data"]["access"]["recipient"], + authority.principal_id + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["schema"], + "elastos.library.recipient-proof-state/v1" + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["verified"], + true + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["source"], + "runtime-launch-grant" + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["proof_binding_id"], + authority.proof_binding_id + ); + assert_eq!(access["data"]["access"]["decision"]["allowed"], true); + assert_eq!( + access["data"]["access"]["decision"]["schema"], + "elastos.library.access-decision/v1" + ); + assert_eq!( + access["data"]["access"]["open"]["schema"], + "elastos.library.shared-open/v1" + ); + assert_eq!( + access["data"]["access"]["open"]["provider"], + "content-provider" + ); + assert_eq!( + access["data"]["access"]["open"]["transport"], + "runtime-provider-fetch" + ); + assert_eq!( + access["data"]["access"]["open"]["status"], + "ready_for_plain_content_fetch" + ); + assert_eq!( + access["data"]["access"]["open"]["key_release_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["drm_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["recipient_proof_verified"], + true + ); + assert_eq!( + access["data"]["access"]["open"]["rights_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["key_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["decrypt_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["required_providers"]["providers"] + .as_array() + .unwrap() + .len(), + 4 + ); + assert_eq!( + access["data"]["protected_content"]["schema"], + "elastos.library.protected-content-provider-status/v1" + ); + assert_eq!( + access["data"]["access"]["key_release"]["status"], + "not_required_for_plain_published_content" + ); + assert_eq!( + access["data"]["access"]["content_security"]["published_payload"], + "plain_content" + ); + + let (missing_proof_status, missing_proof) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": "person:local:alice", + "recipient_proof": { + "schema": "elastos.library.recipient-proof/v1", + "source": "runtime-launch-grant", + "recipient": "person:local:alice" + } + }), + ) + .await; + assert_eq!(missing_proof_status, StatusCode::OK); + assert_eq!(missing_proof["status"], "error"); + assert!(missing_proof["message"] + .as_str() + .unwrap() + .contains("requires Runtime recipient_proof")); + + let (blocked_status, blocked) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": "person:local:bob", + }), + ) + .await; + assert_eq!(blocked_status, StatusCode::OK); + assert_eq!(blocked["status"], "error"); + assert!(blocked["message"] + .as_str() + .unwrap() + .contains("not authorized")); + + let (events_status, events) = post_library( + app.clone(), + &token, + "events", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert_eq!(events["status"], "ok"); + let shared_access_events = events["data"]["events"] + .as_array() + .unwrap() + .iter() + .filter(|event| event["op"] == "shared_access") + .collect::>(); + assert!(shared_access_events.iter().any(|event| { + event["details"]["recipient"] == authority.principal_id + && event["details"]["allowed"] == true + && event["details"]["open"]["status"] == "ready_for_plain_content_fetch" + && event["details"]["open"]["recipient_proof_verified"] == true + && event["details"]["key_release"]["required"] == false + })); + assert!(shared_access_events.iter().any(|event| { + event["details"]["recipient"] == "person:local:bob" + && event["details"]["allowed"] == false + && event["details"]["reason"] + .as_str() + .unwrap_or_default() + .contains("not authorized") + })); + assert!(shared_access_events.iter().any(|event| { + event["details"]["recipient"] == "person:local:alice" + && event["details"]["allowed"] == false + && event["details"]["reason"] + .as_str() + .unwrap_or_default() + .contains("requires Runtime recipient_proof") + })); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["status"], "ok"); + assert_eq!( + status["data"]["published"]["share_policy"], + "recipient_scoped" + ); + assert_eq!( + status["data"]["published"]["share_grants"] + .as_array() + .unwrap() + .len(), + 3 + ); +} + +#[tokio::test] +async fn test_library_provider_rejects_key_release_policy_until_provider_exists() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected-share.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"share with key release"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, _) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + + let (share_status, share) = post_library( + app, + &token, + "share", + json!({ + "uri": uri, + "recipients": ["person:local:alice"], + "key_release_policy": "recipient_key_release", + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "error"); + assert!(share["message"] + .as_str() + .unwrap() + .contains("drm/rights/key/decrypt providers")); +} + +#[tokio::test] +async fn test_library_provider_runs_protected_content_receipt_chain_for_recipient() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_protected_content_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected-fixture.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"protected fixture"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + "protected_content_fixture": true, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!( + publish["data"]["content_security"]["published_payload"], + "protected_content_fixture" + ); + assert_eq!( + publish["data"]["content_security"]["key_release_required"], + true + ); + assert_eq!( + publish["data"]["content_security"]["production_encryption"], + false + ); + assert_eq!( + publish["data"]["content_security"]["sealed_object"]["schema"], + "elastos.sealed.object/v1" + ); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + "recipients": [authority.principal_id], + "key_release_policy": "recipient_key_release", + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["policy"], "recipient_scoped"); + assert_eq!(share["data"]["key_release"]["required"], true); + assert_eq!( + share["data"]["key_release"]["status"], + "provider_receipt_chain_required" + ); + assert_eq!( + share["data"]["protected_content"]["configured_provider_count"], + 4 + ); + assert_eq!( + share["data"]["protected_content"]["encrypted_recipient_sharing"]["status"], + "provider_chain_ready" + ); + + let (access_status, access) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": authority.principal_id, + }), + ) + .await; + assert_eq!(access_status, StatusCode::OK); + assert_eq!( + access["status"], "ok", + "protected access response: {access}" + ); + assert_eq!( + access["data"]["access"]["open"]["status"], + "ready_for_protected_viewer_session" + ); + assert_eq!( + access["data"]["access"]["open"]["provider"], + "decrypt-provider" + ); + assert_eq!( + access["data"]["access"]["open"]["transport"], + "runtime-protected-provider-chain" + ); + let protected = &access["data"]["access"]["open"]["protected_content"]; + assert_eq!(protected["schema"], "elastos.library.protected-open/v1"); + assert_eq!( + protected["drm_receipt"]["schema"], + "elastos.drm.open.receipt/v1" + ); + assert_eq!( + protected["rights_receipt"]["schema"], + "elastos.rights.decision.receipt/v1" + ); + assert_eq!(protected["rights_receipt"]["allowed"], true); + assert_eq!( + protected["key_release_receipt"]["schema"], + "elastos.release.receipt/v1" + ); + assert_eq!(protected["key_release_receipt"]["provider"], "key-provider"); + assert_eq!( + protected["decrypt_session"]["schema"], + "elastos.decrypt.session/v1" + ); + assert_eq!( + protected["viewer"]["required_interface"], + "elastos.viewer/document@1" + ); + assert_eq!(protected["raw_cek_exposed"], false); + assert_eq!(protected["raw_plaintext_exposed"], false); + let protected_text = protected.to_string(); + assert!(!protected_text.contains("fixture-wrapped")); + assert!(!protected_text.contains("provider_credentials")); +} + +#[tokio::test] +async fn test_library_protected_shared_access_fails_closed_without_providers() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected-missing-providers.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"protected fixture"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + "protected_content_fixture": true, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + "recipients": [authority.principal_id], + "key_release_policy": "recipient_key_release", + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["key_release"]["required"], true); + + let (access_status, access) = post_library( + app, + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": authority.principal_id, + }), + ) + .await; + assert_eq!(access_status, StatusCode::OK); + assert_eq!(access["status"], "error"); + assert!(access["message"] + .as_str() + .unwrap() + .contains("drm provider unavailable")); +} + +#[tokio::test] +async fn test_library_provider_unpublish_and_repair_update_status() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/availability.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"availability"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + let revision = publish["data"]["object"]["revision"].as_str().unwrap(); + + let (unpublish_status, unpublish) = post_library( + app.clone(), + &token, + "unpublish", + json!({ + "uri": uri, + "if_revision": revision, + }), + ) + .await; + assert_eq!(unpublish_status, StatusCode::OK); + assert_eq!(unpublish["status"], "ok"); + assert_eq!(unpublish["data"]["object"]["published"], false); + assert!(unpublish["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .starts_with("bafkrei")); + assert_eq!(unpublish["data"]["object"].get("published_cid"), None); + assert_eq!( + unpublish["data"]["object"]["availability"], + "local_unpinned" + ); + + let (repair_status, repair) = post_library( + app.clone(), + &token, + "repair", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(repair_status, StatusCode::OK); + assert_eq!(repair["status"], "ok"); + assert_eq!(repair["data"]["object"]["published"], true); + assert_eq!(repair["data"]["object"]["availability"], "local_pinned"); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["data"]["object"]["published"], true); + assert_eq!( + status["data"]["published"]["availability"]["status"], + "local_pinned" + ); +} diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs index ef526d21..91aeb3f5 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs @@ -18,7 +18,7 @@ use k256::ecdsa::SigningKey as EvmSigningKey; use serde_json::json; use sha2::Sha256; use sha3::Keccak256; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; #[cfg(unix)] use tokio::io::AsyncReadExt as _; use tokio::net::TcpListener; @@ -59,6 +59,110 @@ async fn documents_test_state(cache_dir: &std::path::Path) -> GatewayState { } } +async fn library_test_state(cache_dir: &std::path::Path) -> GatewayState { + library_test_state_with_content(cache_dir, true).await +} + +async fn library_test_state_without_content(cache_dir: &std::path::Path) -> GatewayState { + library_test_state_with_content(cache_dir, false).await +} + +async fn library_protected_content_test_state(cache_dir: &std::path::Path) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + registry + .register_sub_provider("content", Arc::new(MockContentProvider)) + .await + .unwrap(); + registry.register(Arc::new(MockDrmProvider)).await; + registry.register(Arc::new(MockRightsProvider)).await; + registry.register(Arc::new(MockKeyProvider)).await; + registry.register(Arc::new(MockDecryptProvider)).await; + registry + .register(Arc::new(crate::library::ObjectProvider::new( + cache_dir.to_path_buf(), + Arc::downgrade(®istry), + ))) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + +async fn library_external_provider_test_state(cache_dir: &std::path::Path) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + registry + .register_sub_provider("content", Arc::new(MockContentProvider)) + .await + .unwrap(); + registry + .register(Arc::new(MockExternalObjectProvider { + data_dir: cache_dir.to_path_buf(), + })) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + +async fn library_webspace_test_state(cache_dir: &std::path::Path) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + registry + .register(Arc::new(MockExternalObjectProvider { + data_dir: cache_dir.to_path_buf(), + })) + .await; + registry + .register(Arc::new(MockWebSpaceProvider::default())) + .await; + registry + .register(Arc::new(MockWebSpaceAdapterProvider)) + .await; + registry + .register(Arc::new(MockOperatorWebSpaceAdapterProvider)) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + +async fn library_test_state_with_content( + cache_dir: &std::path::Path, + with_content: bool, +) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + if with_content { + registry + .register_sub_provider("content", Arc::new(MockContentProvider)) + .await + .unwrap(); + } + registry + .register(Arc::new(crate::library::ObjectProvider::new( + cache_dir.to_path_buf(), + Arc::downgrade(®istry), + ))) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + async fn chain_test_state(cache_dir: &std::path::Path) -> GatewayState { seed_test_browser_capsules(cache_dir); let registry = Arc::new(ProviderRegistry::new()); @@ -273,6 +377,7 @@ mod documents; #[path = "../gateway_browser_route_tests.rs"] mod gateway_browser_route_tests; mod home_system; +mod library; mod recovery; mod room; mod site_publication; diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs b/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs index c715cf0d..9c5e8882 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs @@ -264,6 +264,35 @@ async fn test_cid_file_fetches_through_content_provider() { assert_eq!(&body[..], b"content provider"); } +#[tokio::test] +async fn test_content_cid_root_fetches_raw_file_through_content_provider() { + let dir = tempfile::tempdir().unwrap(); + let state = content_test_state(dir.path()).await; + let app = gateway_router(state); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/content/{}", TEST_CIDV1)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert_eq!(ct, "application/octet-stream"); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"raw-content-provider-bytes"); +} + #[tokio::test] async fn test_ipfs_cid_root_without_provider_registry_fails_closed() { let dir = tempfile::tempdir().unwrap(); diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs b/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs index 2d9764e4..d6bb4ed7 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs @@ -348,6 +348,86 @@ impl Provider for MockContentProvider { } } })), + (Some("fetch"), Some(TEST_CIDV1), None) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "path": "", + "data": base64::engine::general_purpose::STANDARD.encode(b"raw-content-provider-bytes"), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + } + } + })), + (Some("publish"), _, _) => { + if request.get("object_kind").and_then(|value| value.as_str()) == Some("sealed") { + validate_mock_sealed_publish_request(request)?; + } + Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + }, + "receipt": { + "schema": "elastos.content.availability.receipt/v1", + "cid": TEST_CIDV1 + } + } + })) + } + (Some("unpublish"), _, _) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_unpinned", + "provider": "mock-content-provider", + "replicas": 0 + }, + "receipt": { + "schema": "elastos.content.availability.receipt/v1", + "cid": TEST_CIDV1, + "status": "local_unpinned" + } + } + })), + (Some("repair"), _, _) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + }, + "receipt": { + "schema": "elastos.content.availability.receipt/v1", + "cid": TEST_CIDV1, + "status": "local_pinned" + } + } + })), + (Some("status"), _, _) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + } + } + })), _ => Ok(json!({ "status": "error", "code": "not_found", @@ -357,6 +437,1225 @@ impl Provider for MockContentProvider { } } +fn validate_mock_sealed_publish_request(request: &serde_json::Value) -> Result<(), ProviderError> { + let files = request + .get("files") + .and_then(|value| value.as_array()) + .ok_or_else(|| ProviderError::Provider("sealed publish files are required".into()))?; + let sealed_entry = files + .iter() + .find(|entry| entry.get("path").and_then(|value| value.as_str()) == Some("sealed.json")) + .ok_or_else(|| ProviderError::Provider("sealed publish requires sealed.json".into()))?; + let sealed_data = sealed_entry + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| ProviderError::Provider("sealed.json data is required".into()))?; + let sealed_bytes = base64::engine::general_purpose::STANDARD + .decode(sealed_data) + .map_err(|err| ProviderError::Provider(err.to_string()))?; + let sealed_object: elastos_common::protected_content::SealedObjectV1 = + serde_json::from_slice(&sealed_bytes) + .map_err(|err| ProviderError::Provider(err.to_string()))?; + let links = request + .get("links") + .and_then(|value| value.as_array()) + .ok_or_else(|| ProviderError::Provider("sealed publish links are required".into()))?; + for (rel, cid) in [ + ("availability.receipt", sealed_object.availability_receipt_cid.as_str()), + ("payload", sealed_object.payload_cid.as_str()), + ("rights.policy", sealed_object.rights_policy_cid.as_str()), + ] { + if !links.iter().any(|link| { + link.get("rel").and_then(|value| value.as_str()) == Some(rel) + && link.get("cid").and_then(|value| value.as_str()) == Some(cid) + }) { + return Err(ProviderError::Provider(format!( + "sealed publish missing {rel} link" + ))); + } + } + if !links + .iter() + .any(|link| link.get("rel").and_then(|value| value.as_str()) == Some("provenance")) + { + return Err(ProviderError::Provider( + "sealed publish missing provenance link".into(), + )); + } + if serde_json::to_string(&sealed_object) + .map_err(|err| ProviderError::Provider(err.to_string()))? + .contains("raw_cek") + { + return Err(ProviderError::Provider( + "sealed publish must not expose raw CEK".into(), + )); + } + Ok(()) +} + +struct MockDrmProvider; +struct MockRightsProvider; +struct MockKeyProvider; +struct MockDecryptProvider; + +#[async_trait::async_trait] +impl Provider for MockDrmProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock drm provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["drm"] + } + + fn name(&self) -> &'static str { + "mock-drm-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "drm", + "configured": true, + "supported_operations": ["status", "open"], + "blocked_authority": ["raw_cek", "chain_rpc", "wallet_rpc"], + "contract": { + "schema": "elastos.protected-content.drm-provider/v1", + "fixture": true + } + } + })), + Some("open") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("drm request is required".into()))?; + let object = request + .get("object") + .ok_or_else(|| ProviderError::Provider("sealed object is required".into()))?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.drm.open.receipt/v1", + "provider": "drm-provider", + "status": "accepted", + "payload_cid": object + .get("payload_cid") + .and_then(|value| value.as_str()) + .unwrap_or(TEST_CIDV1), + "principal_id": required_test_str(request, "principal_id")?, + "session_id": required_test_str(request, "session_id")?, + "action": required_test_str(request, "action")?, + "fixture": true + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock drm op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockRightsProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock rights provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["rights"] + } + + fn name(&self) -> &'static str { + "mock-rights-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "rights", + "configured": true, + "supported_operations": ["status", "has_access_by_content_id"], + "blocked_authority": ["chain_rpc", "wallet_rpc", "raw_cek"], + "contract": { + "schema": "elastos.protected-content.rights-provider/v1", + "fixture": true + } + } + })), + Some("has_access_by_content_id") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("rights request is required".into()))?; + let content_id = required_test_str(request, "content_id")?; + let principal_id = required_test_str(request, "principal_id")?; + let session_id = required_test_str(request, "session_id")?; + let right = required_test_str(request, "right")?; + let allowed = right == "view" && !principal_id.contains("blocked"); + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.rights.decision.receipt/v1", + "request_id": "rights:fixture", + "content_id": content_id, + "principal_id": principal_id, + "session_id": session_id, + "right": right, + "provider": "rights-provider", + "allowed": allowed, + "issued_at": 1_800_000_000u64, + "expires_at": 1_900_000_000u64 + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock rights op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockKeyProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock key provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["key"] + } + + fn name(&self) -> &'static str { + "mock-key-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "key", + "configured": true, + "supported_operations": ["status", "release"], + "blocked_authority": ["raw_cek", "kms_node_credentials"], + "contract": { + "schema": "elastos.protected-content.key-provider/v1", + "fixture": true + } + } + })), + Some("release") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("key request is required".into()))?; + if request + .get("rights_receipt") + .and_then(|receipt| receipt.get("allowed")) + .and_then(|value| value.as_bool()) + != Some(true) + { + return Ok(json!({ + "status": "error", + "code": "denied", + "message": "rights receipt denied key release" + })); + } + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.release.receipt/v1", + "request_id": required_test_str(request, "request_id")?, + "object_cid": required_test_str(request, "object_cid")?, + "principal_id": required_test_str(request, "principal_id")?, + "session_id": required_test_str(request, "session_id")?, + "action": required_test_str(request, "action")?, + "provider": "key-provider", + "status": "released", + "issued_at": 1_800_000_000u64, + "expires_at": request + .get("expires_at") + .and_then(|value| value.as_u64()) + .unwrap_or(1_900_000_000u64) + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock key op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockDecryptProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock decrypt provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["decrypt"] + } + + fn name(&self) -> &'static str { + "mock-decrypt-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "decrypt", + "configured": true, + "supported_operations": ["status", "open_session"], + "blocked_authority": ["raw_cek", "raw_plaintext", "filesystem"], + "contract": { + "schema": "elastos.protected-content.decrypt-provider/v1", + "fixture": true + } + } + })), + Some("open_session") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("decrypt request is required".into()))?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.decrypt.session/v1", + "session_id": "decrypt-session:fixture", + "object_cid": required_test_str(request, "object_cid")?, + "viewer_interface": required_test_str(request, "viewer_interface")?, + "output": "viewer_capsule_session:fixture", + "expires_at": request + .get("expires_at") + .and_then(|value| value.as_u64()) + .unwrap_or(1_900_000_000u64) + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock decrypt op" + })), + } + } +} + +struct MockExternalObjectProvider { + data_dir: std::path::PathBuf, +} + +#[async_trait::async_trait] +impl Provider for MockExternalObjectProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock external object provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["object"] + } + + fn name(&self) -> &'static str { + "mock-external-object-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + Ok(crate::library::handle_object_provider_raw_request( + &self.data_dir, + request, + )) + } +} + +#[derive(Clone)] +struct MockCachedWebSpaceObject { + bytes: Vec, + sync_state: &'static str, +} + +#[derive(Default)] +struct MockWebSpaceProvider { + cached: std::sync::Mutex>, +} + +struct MockWebSpaceAdapterProvider; +struct MockOperatorWebSpaceAdapterProvider; + +fn mock_operator_archive_zip_bytes() -> Vec { + use std::io::Write as _; + + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.add_directory("Nested/", options).unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() +} + +impl MockWebSpaceProvider { + fn cached_object(&self, path: &str) -> Option { + self.cached.lock().ok()?.get(path).cloned() + } + + fn store_cached_object( + &self, + path: &str, + request: &serde_json::Value, + ) -> Result { + let bytes = request + .get("content") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| ProviderError::Provider("mock cache missing content".into()))? + .iter() + .map(|value| { + value + .as_u64() + .filter(|byte| *byte <= u8::MAX as u64) + .map(|byte| byte as u8) + .ok_or_else(|| ProviderError::Provider("mock cache byte out of range".into())) + }) + .collect::, _>>()?; + let cached = MockCachedWebSpaceObject { + bytes, + sync_state: "manual_idle", + }; + self.cached + .lock() + .map_err(|_| ProviderError::Provider("mock cache lock poisoned".into()))? + .insert(path.to_string(), cached.clone()); + Ok(cached) + } + + fn store_written_object( + &self, + path: &str, + request: &serde_json::Value, + ) -> Result { + let bytes = request + .get("content") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| ProviderError::Provider("mock write missing content".into()))? + .iter() + .map(|value| { + value + .as_u64() + .filter(|byte| *byte <= u8::MAX as u64) + .map(|byte| byte as u8) + .ok_or_else(|| ProviderError::Provider("mock write byte out of range".into())) + }) + .collect::, _>>()?; + let cached = MockCachedWebSpaceObject { + bytes, + sync_state: "manual_pending", + }; + self.cached + .lock() + .map_err(|_| ProviderError::Provider("mock cache lock poisoned".into()))? + .insert(path.to_string(), cached.clone()); + Ok(cached) + } + + fn mark_synced(&self, path: &str) -> Result, ProviderError> { + let mut cached = self + .cached + .lock() + .map_err(|_| ProviderError::Provider("mock cache lock poisoned".into()))?; + let Some(object) = cached.get_mut(path) else { + return Ok(None); + }; + object.sync_state = "manual_synced"; + Ok(Some(object.clone())) + } +} + +#[async_trait::async_trait] +impl Provider for MockWebSpaceProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock WebSpace provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["webspace"] + } + + fn name(&self) -> &'static str { + "mock-webspace-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + let path = request + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("localhost://WebSpaces"); + match request.get("op").and_then(|value| value.as_str()) { + Some("list") if path == "localhost://WebSpaces" => Ok(json!({ + "status": "ok", + "data": [ + { + "name": "Elastos", + "is_file": false, + "is_dir": true, + "size": 0, + "provider": "mock-webspace-provider", + "resolver_state": "resolved", + "resolver": "builtin", + "cache_policy": "metadata-only", + "sync_policy": "manual", + "object_id": "object:webspace:elastos", + "head_id": "head:webspace:elastos", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "dynamic-webspace", + "readonly": true + }, + { + "name": "Google", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "google://drive", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-readonly", + "resolver": "google-drive", + "cache_policy": "metadata-and-thumbnails", + "sync_policy": "manual", + "object_id": "object:webspace:google", + "head_id": "head:webspace:google", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": true + }, + { + "name": "Operator", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "operator://drive", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-readonly", + "resolver": "operator-drive", + "cache_policy": "metadata-and-bytes", + "sync_policy": "manual", + "object_id": "object:webspace:operator", + "head_id": "head:webspace:operator", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": true + }, + { + "name": "OperatorMutable", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "operator://drive/Writable", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-mutable", + "resolver": "operator-drive", + "cache_policy": "metadata-and-bytes", + "sync_policy": "manual", + "object_id": "object:webspace:operator-mutable", + "head_id": "head:webspace:operator-mutable", + "cache_state": "content_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": false, + "access_policy": "owner-writable" + }, + { + "name": "Mutable", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "local://mutable", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-mutable", + "resolver": "local-materialized", + "cache_policy": "metadata-and-bytes", + "sync_policy": "manual", + "object_id": "object:webspace:mutable", + "head_id": "head:webspace:mutable", + "cache_state": "content_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": false, + "access_policy": "owner-writable" + } + ] + })), + Some("list") if path == "localhost://WebSpaces/Elastos" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:elastos-meta", "head_id": "head:webspace:elastos-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "content", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:content", "head_id": "head:webspace:content", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" }, + { "name": "peer", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://peer/", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:peer", "head_id": "head:webspace:peer", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" }, + { "name": "did", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://did/", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:did", "head_id": "head:webspace:did", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" }, + { "name": "ai", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://ai/", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:ai", "head_id": "head:webspace:ai", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Google" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "google-drive", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-meta", "head_id": "head:webspace:google-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Drive", "is_file": false, "is_dir": true, "size": 0, "target_uri": "google://drive/Drive", "resolver": "google-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-drive", "head_id": "head:webspace:google-drive", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true }, + { "name": "Shared", "is_file": false, "is_dir": true, "size": 0, "target_uri": "google://drive/shared", "resolver": "google-drive", "resolver_state": "indexed-virtual", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-shared", "head_id": "head:webspace:google-shared", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Google/Drive" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "google-drive", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-drive-meta", "head_id": "head:webspace:google-drive-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Project X", "is_file": false, "is_dir": true, "size": 0, "target_uri": "google://drive/Drive/Project X", "resolver": "google-drive", "resolver_state": "indexed-virtual", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-project", "head_id": "head:webspace:google-project", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Google/Drive/Project X" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "google-drive", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-project-meta", "head_id": "head:webspace:google-project-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "file.pdf", "is_file": true, "is_dir": false, "size": 256, "target_uri": "google://drive/Drive/Project X/file.pdf", "resolver": "google-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-project-file", "head_id": "head:webspace:google-project-file", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-file", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Operator" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "operator-drive", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-meta", "head_id": "head:webspace:operator-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Projects", "is_file": false, "is_dir": true, "size": 0, "target_uri": "operator://drive/Projects", "resolver": "operator-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-projects", "head_id": "head:webspace:operator-projects", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Operator/Projects" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "operator-drive", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-projects-meta", "head_id": "head:webspace:operator-projects-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Brief.md", "is_file": true, "is_dir": false, "size": 512, "target_uri": "operator://drive/Projects/Brief.md", "resolver": "operator-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-brief", "head_id": "head:webspace:operator-brief", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-file", "readonly": true }, + { "name": "Bundle.zip", "is_file": true, "is_dir": false, "size": mock_operator_archive_zip_bytes().len(), "target_uri": "operator://drive/Projects/Bundle.zip", "resolver": "operator-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-bundle", "head_id": "head:webspace:operator-bundle", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-file", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/OperatorMutable" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "operator-drive", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-mutable-meta", "head_id": "head:webspace:operator-mutable-meta", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "metadata", "readonly": true, "access_policy": "resolver-readonly" }, + { "name": "Folder", "is_file": false, "is_dir": true, "size": 0, "target_uri": "operator://drive/Writable/Folder", "resolver": "operator-drive", "resolver_state": "materialized-local", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-mutable-folder", "head_id": "head:webspace:operator-mutable-folder", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "materialized-directory", "readonly": false, "access_policy": "owner-writable" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Mutable" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "local-materialized", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-meta", "head_id": "head:webspace:mutable-meta", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "metadata", "readonly": true, "access_policy": "resolver-readonly" }, + { "name": "Folder", "is_file": false, "is_dir": true, "size": 0, "target_uri": "local://mutable/Folder", "resolver": "local-materialized", "resolver_state": "materialized-local", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-folder", "head_id": "head:webspace:mutable-folder", "cache_state": "content_cached", "sync_state": "manual_pending", "kind": "materialized-directory", "readonly": false, "access_policy": "owner-writable" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Mutable/Folder" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "local-materialized", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-folder-meta", "head_id": "head:webspace:mutable-folder-meta", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "metadata", "readonly": true, "access_policy": "resolver-readonly" }, + { "name": "note.txt", "is_file": true, "is_dir": false, "size": 13, "target_uri": "local://mutable/Folder/note.txt", "resolver": "local-materialized", "resolver_state": "materialized-local", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-note", "head_id": "head:webspace:mutable-note", "cache_state": "content_cached", "sync_state": "manual_pending", "kind": "materialized-file", "readonly": false, "access_policy": "owner-writable" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Elastos/content" => Ok(json!({ + "status": "ok", + "data": [ + { + "name": TEST_CIDV1, + "is_file": true, + "is_dir": false, + "size": 128, + "target_uri": format!("elastos://{TEST_CIDV1}"), + "provider": "content-provider", + "resolver_state": "resolved", + "resolver": "builtin", + "cache_policy": "metadata-only", + "sync_policy": "manual", + "object_id": "object:webspace:content-test-cid", + "head_id": "head:webspace:content-test-cid", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "file-endpoint", + "readonly": true + } + ] + })), + Some("stat") => { + let stat = self + .cached_object(path) + .map(|cached| mock_cached_webspace_stat(path, &cached)) + .unwrap_or_else(|| mock_webspace_stat(path)); + Ok(json!({ + "status": "ok", + "data": stat + })) + }, + Some("health") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.health/v1", + "state": "metadata_ready", + "mounts": [ + { + "moniker": "Google", + "resolver": "google-drive", + "live_adapter": true, + "adapter_state": "connected", + "adapter": { + "schema": "elastos.webspace.adapter/v1", + "resolver": "google-drive", + "provider": "google-drive-adapter", + "state": "connected", + "live": true, + "capabilities": ["metadata_index", "read_bytes"] + } + }, + { + "moniker": "Operator", + "resolver": "operator-drive", + "live_adapter": true, + "adapter_state": "connected", + "adapter": { + "schema": "elastos.webspace.adapter/v1", + "resolver": "operator-drive", + "provider": "operator-drive-adapter", + "state": "connected", + "live": true, + "capabilities": ["metadata_index", "read_bytes", "write_bytes"] + } + }, + { + "moniker": "OperatorMutable", + "resolver": "operator-drive", + "live_adapter": true, + "adapter_state": "connected", + "adapter": { + "schema": "elastos.webspace.adapter/v1", + "resolver": "operator-drive", + "provider": "operator-drive-adapter", + "state": "connected", + "live": true, + "capabilities": ["metadata_index", "read_bytes", "write_bytes"] + } + } + ] + } + })), + Some("refresh") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.refresh-receipt/v1", + "action": "refreshed", + "handle_uri": path, + "byte_materialized": false + } + })), + Some("cache") => { + let cached = self.store_cached_object(path, request)?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.cache-receipt/v1", + "action": "content_cached", + "handle_uri": path, + "content_cached": true, + "dirty": false, + "size": cached.bytes.len() + } + })) + }, + Some("write") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + let cached = self.store_written_object(path, request)?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.write-receipt/v1", + "action": "written", + "handle_uri": path, + "byte_materialized": true, + "size": cached.bytes.len() + } + })) + } + Some("mkdir") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.mkdir-receipt/v1", + "action": "created", + "handle_uri": path + } + })) + } + Some("delete") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.delete-receipt/v1", + "action": "deleted", + "handle_uri": path, + "removed_count": 1 + } + })) + } + Some("sync") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + let synced = self.mark_synced(path)?.ok_or_else(|| { + ProviderError::Provider("mock sync target was not materialized".into()) + })?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.sync-receipt/v1", + "action": "resolver_synced", + "handle_uri": path, + "content_synced": true, + "dirty": false, + "size": synced.bytes.len() + } + })) + } + Some("write" | "mkdir" | "delete") => Ok(json!({ + "status": "error", + "code": "readonly", + "message": "built-in or readonly WebSpace is resolver-owned and read-only" + })), + Some("read") if self.cached_object(path).is_some() => { + let bytes = self.cached_object(path).unwrap().bytes; + let size = bytes.len(); + Ok(json!({ + "status": "ok", + "data": { + "content": bytes, + "size": size + } + })) + }, + Some("read") if path.ends_with("_meta.json") || path.contains("/content/") => { + let bytes = serde_json::to_vec_pretty(&json!({ + "handle_uri": path.trim_end_matches("/_meta.json"), + "target_uri": if path.contains("/content/") { + Some(format!( + "elastos://{}", + path.rsplit('/').next().unwrap_or_default() + )) + } else { + None + }, + "resolver_state": "resolved", + "resolver": "builtin", + "cache_policy": "metadata-only", + "sync_policy": "manual", + "object_id": "object:webspace:read", + "head_id": "head:webspace:read", + "cache_state": "metadata_cached", + "sync_state": "manual_idle" + })) + .unwrap(); + Ok(json!({ + "status": "ok", + "data": { + "content": bytes, + "size": bytes.len() + } + })) + } + Some("read") if path.starts_with("localhost://WebSpaces/Mutable/") => { + let bytes = b"mutable bytes".to_vec(); + let size = bytes.len(); + Ok(json!({ + "status": "ok", + "data": { + "content": bytes, + "size": size + } + })) + } + Some(op) => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": format!("unsupported mock WebSpace op: {op}") + })), + None => Ok(json!({ + "status": "error", + "code": "invalid_request", + "message": "missing mock WebSpace op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockWebSpaceAdapterProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock WebSpace adapter provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["google-drive-adapter"] + } + + fn name(&self) -> &'static str { + "mock-webspace-adapter-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("_runtime_invocation").is_none() { + return Ok(json!({ + "status": "error", + "code": "missing_runtime_invocation", + "message": "WebSpace adapter requires Runtime provider invocation" + })); + } + match request.get("op").and_then(|value| value.as_str()) { + Some("metadata_index") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.metadata-index/v1", + "entries": [ + { + "path": "Drive/Project X/file.pdf", + "kind": "file", + "target_uri": "google://drive/Drive/Project X/file.pdf", + "resolver_state": "indexed", + "readonly": true, + "description": "Adapter indexed Google Drive file." + } + ], + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": "google-drive" + } + } + })), + Some("read_bytes") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": base64::engine::general_purpose::STANDARD.encode(b"google adapter bytes"), + "mime": "application/pdf", + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": "google-drive", + "target_uri": request.get("target_uri").cloned() + } + } + })), + Some(op) => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": format!("unsupported mock WebSpace adapter op: {op}") + })), + None => Ok(json!({ + "status": "error", + "code": "invalid_request", + "message": "missing mock WebSpace adapter op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockOperatorWebSpaceAdapterProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock operator WebSpace adapter only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["operator-drive-adapter"] + } + + fn name(&self) -> &'static str { + "mock-operator-webspace-adapter" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("_runtime_invocation").is_none() { + return Ok(json!({ + "status": "error", + "code": "missing_runtime_invocation", + "message": "Operator WebSpace adapter requires Runtime provider invocation" + })); + } + match request.get("op").and_then(|value| value.as_str()) { + Some("metadata_index") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.metadata-index/v1", + "entries": [ + { + "path": "Projects/Brief.md", + "kind": "file", + "target_uri": "operator://drive/Projects/Brief.md", + "resolver_state": "indexed", + "readonly": true, + "description": "Operator fixture indexed markdown brief." + }, + { + "path": "Projects/Bundle.zip", + "kind": "file", + "target_uri": "operator://drive/Projects/Bundle.zip", + "resolver_state": "indexed", + "readonly": true, + "description": "Operator fixture indexed archive bundle." + } + ], + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": "operator-drive", + "operator_fixture": true + } + } + })), + Some("read_bytes") => { + let target_uri = request + .get("target_uri") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let (bytes, mime) = if target_uri.ends_with("/Bundle.zip") { + (mock_operator_archive_zip_bytes(), "application/zip") + } else { + ( + b"# Operator Brief\n\nAdapter-backed bytes.\n".to_vec(), + "text/plain", + ) + }; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "mime": mime, + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": "operator-drive", + "operator_fixture": true, + "target_uri": request.get("target_uri").cloned() + } + } + })) + } + Some("write_bytes") => { + let target_uri = request + .get("target_uri") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if target_uri.contains("Conflict") { + return Ok(json!({ + "status": "error", + "code": "conflict", + "message": "operator fixture rejected stale mutable fork write", + "data": { + "schema": "elastos.webspace.adapter.write-conflict/v1", + "resolver": "operator-drive", + "target_uri": target_uri, + "reason": "head_mismatch" + } + })); + } + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.write-bytes/v1", + "receipt": { + "schema": "elastos.webspace.adapter.write-bytes-receipt/v1", + "resolver": "operator-drive", + "operator_fixture": true, + "target_uri": target_uri, + "bytes_accepted": request + .get("data") + .and_then(serde_json::Value::as_str) + .and_then(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded).ok()) + .map(|bytes| bytes.len()) + .unwrap_or(0) + } + } + })) + } + Some(op) => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": format!("unsupported mock operator WebSpace adapter op: {op}") + })), + None => Ok(json!({ + "status": "error", + "code": "invalid_request", + "message": "missing mock operator WebSpace adapter op" + })), + } + } +} + +fn mock_webspace_stat(path: &str) -> serde_json::Value { + let is_google_file = path == "localhost://WebSpaces/Google/Drive/Project X/file.pdf"; + let is_operator_mutable = path.starts_with("localhost://WebSpaces/OperatorMutable"); + let is_operator = path.starts_with("localhost://WebSpaces/Operator") && !is_operator_mutable; + let is_operator_file = path == "localhost://WebSpaces/Operator/Projects/Brief.md"; + let is_operator_archive_file = path == "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + let is_operator_projects_dir = path == "localhost://WebSpaces/Operator/Projects"; + let is_mutable = path.starts_with("localhost://WebSpaces/Mutable"); + let is_mutable_file = is_mutable && path.ends_with(".txt"); + let is_operator_mutable_file = + is_operator_mutable && (path.ends_with(".txt") || path.ends_with(".md")); + let is_file = path.ends_with("_meta.json") + || path.contains("/content/") + || is_google_file + || is_operator_file + || is_operator_archive_file + || is_operator_mutable_file + || is_mutable_file; + let is_google = path.starts_with("localhost://WebSpaces/Google"); + json!({ + "path": path, + "is_file": is_file, + "is_dir": !is_file, + "size": if is_mutable_file { 13 } else if is_operator_mutable_file { 0 } else if is_file { 128 } else { 0 }, + "readonly": !(is_mutable || is_operator_mutable), + "access_policy": if is_mutable || is_operator_mutable { "owner-writable" } else { "resolver-readonly" }, + "target_uri": if is_google_file { + Some("google://drive/Drive/Project X/file.pdf".to_string()) + } else if is_operator_archive_file { + Some("operator://drive/Projects/Bundle.zip".to_string()) + } else if is_google { + Some(path.replacen("localhost://WebSpaces/Google", "google://drive", 1)) + } else if is_operator_mutable { + Some(path.replacen("localhost://WebSpaces/OperatorMutable", "operator://drive/Writable", 1)) + } else if is_operator { + Some(path.replacen("localhost://WebSpaces/Operator", "operator://drive", 1)) + } else if is_mutable { + Some(path.replacen("localhost://WebSpaces/Mutable", "local://mutable", 1)) + } else if path.contains("/content/") { + Some(format!( + "elastos://{}", + path.rsplit('/').next().unwrap_or_default() + )) + } else { + None + }, + "provider": if path.contains("/content/") { "content-provider" } else { "mock-webspace-provider" }, + "resolver_state": if is_mutable || is_operator_mutable { + if path == "localhost://WebSpaces/Mutable" || path == "localhost://WebSpaces/OperatorMutable" { "mounted-mutable" } else { "materialized-local" } + } else if is_operator_file || is_operator_archive_file || is_operator_projects_dir { + "indexed" + } else if is_operator { + "indexed-virtual" + } else if is_google_file { "indexed" } else if is_google { "indexed-virtual" } else { "resolved" }, + "resolver": if is_mutable { "local-materialized" } else if is_operator || is_operator_mutable { "operator-drive" } else if is_google { "google-drive" } else { "builtin" }, + "cache_policy": if is_mutable || is_operator || is_operator_mutable { "metadata-and-bytes" } else if is_google { "metadata-and-thumbnails" } else { "metadata-only" }, + "sync_policy": "manual", + "object_id": format!("object:webspace:{}", path.replace('/', ":")), + "head_id": format!("head:webspace:{}", path.replace('/', ":")), + "cache_state": if is_mutable || is_operator_mutable { "content_cached" } else { "metadata_cached" }, + "sync_state": if is_mutable || is_operator_mutable { "manual_pending" } else { "manual_idle" }, + "kind": if path.ends_with("_meta.json") { + "metadata" + } else if is_mutable_file || is_operator_mutable_file { + "materialized-file" + } else if is_operator_mutable && path == "localhost://WebSpaces/OperatorMutable" { + "mounted-webspace" + } else if is_operator_mutable { + "materialized-directory" + } else if is_mutable && path == "localhost://WebSpaces/Mutable" { + "mounted-webspace" + } else if is_mutable { + "materialized-directory" + } else if is_operator_file || is_operator_archive_file { + "indexed-file" + } else if is_operator && path == "localhost://WebSpaces/Operator" { + "mounted-webspace" + } else if is_operator { + "indexed-directory" + } else if is_google_file { + "indexed-file" + } else if is_google { + "indexed-directory" + } else if path.contains("/content/") { + "file-endpoint" + } else if path == "localhost://WebSpaces" { + "webspace-root" + } else { + "folder-handle" + }, + "modified": 1, + "created": 1 + }) +} + +fn mock_cached_webspace_stat( + path: &str, + cached: &MockCachedWebSpaceObject, +) -> serde_json::Value { + let mut stat = mock_webspace_stat(path); + stat["size"] = json!(cached.bytes.len()); + stat["resolver_state"] = json!("materialized-local"); + stat["cache_state"] = json!("content_cached"); + stat["sync_state"] = json!(cached.sync_state); + stat["kind"] = json!("materialized-file"); + stat +} + struct MockNetProvider; struct MockMalformedNetProvider; @@ -1595,9 +2894,7 @@ impl Provider for MockWalletProvider { "message": "recovery_key is required" })); }; - if recovery_key - .get("schema") - .and_then(|value| value.as_str()) + if recovery_key.get("schema").and_then(|value| value.as_str()) != Some("elastos.wallet.recovery-key/v1") { return Ok(json!({ @@ -1609,9 +2906,7 @@ impl Provider for MockWalletProvider { let account_id = required_test_str(recovery_key, "account_id")?; let chain_namespace = required_test_str(recovery_key, "chain_namespace")?; let address = required_test_str(recovery_key, "address")?; - let proof_type = if chain_namespace - == "bip122:000000000019d6689c085ae165831e93" - { + let proof_type = if chain_namespace == "bip122:000000000019d6689c085ae165831e93" { "managed_btc_p2wpkh" } else { "managed_evm" @@ -2011,8 +3306,10 @@ impl Provider for MockWalletProvider { "message": "wallet approval request is not a transaction" })); } - let mut signed_result = - approval.get("signed_result").cloned().unwrap_or_else(|| json!({})); + let mut signed_result = approval + .get("signed_result") + .cloned() + .unwrap_or_else(|| json!({})); signed_result["transaction_hash"] = json!(transaction_hash); signed_result["broadcast_recorded_at"] = json!(crate::auth::now_ts()); approval["signed_result"] = signed_result; diff --git a/scripts/check-wci-alignment.sh b/scripts/check-wci-alignment.sh index d8f6ebbb..a9899f42 100755 --- a/scripts/check-wci-alignment.sh +++ b/scripts/check-wci-alignment.sh @@ -169,7 +169,8 @@ check_required 'edge_release_channel_path' elastos/crates/elastos-server/src/sit check_required 'publisher_release_manifest_path' elastos/crates/elastos-server/src/api/gateway_site.rs 'gateway must read release manifests from Publisher state' check_required 'publish_to_content_availability' elastos/crates/elastos-server/src/gateway_cmd.rs 'public gateway publish must route through content availability' check_forbidden_in_path 'publish_to_ipfs|no CID in ipfs-provider response' elastos/crates/elastos-server/src/gateway_cmd.rs 'public gateway publish must not bind directly to ipfs-provider' -check_required 'send_raw\("availability"' elastos/crates/elastos-server/src/content.rs 'content provider must keep the internal availability provider seam' +check_required 'ProviderInvocation' elastos/crates/elastos-server/src/content.rs 'content provider must use the provider invocation envelope' +check_required '"availability",' elastos/crates/elastos-server/src/content.rs 'content provider must keep the internal availability provider seam' check_required '"availability",' elastos/crates/elastos-runtime/src/provider/registry.rs 'provider registry must reserve elastos://availability for the availability provider' check_required '"wallet",' elastos/crates/elastos-runtime/src/provider/registry.rs 'provider registry must reserve elastos://wallet for the wallet provider' check_required '"drm",' elastos/crates/elastos-runtime/src/provider/registry.rs 'provider registry must reserve elastos://drm for the protected-content provider' @@ -431,13 +432,14 @@ ordinary_roles = {"app", "viewer", "content"} # System is the runtime-owned approval/diagnostic surface. Dedicated wallet # connector capsules and the Browser shell are privileged adapter UIs, not # general app authority. -ordinary_capsules_with_privileged_authority_ui = {"system", "wallet-metamask", "wallet-unisat", "wallet", "wallet-walletconnect", "browser"} +ordinary_capsules_with_privileged_authority_ui = {"system", "wallet-metamask", "wallet-unisat", "wallet", "wallet-walletconnect", "browser", "library"} system_only_elastos_backends = { "elacity", "elacity-sdk", "gateway", "chain", "wallet", + "library", "ipfs", "ipfs-cluster", "ipfs-provider", @@ -540,6 +542,7 @@ for path in manifest_paths: "elastos://exit": "raw Browser Exit provider namespace", "elastos://browser-engine": "raw Browser Engine provider namespace", "elastos://wallet": "raw wallet provider namespace", + "elastos://object": "raw object provider namespace", "elastos://ipfs": "raw IPFS backend namespace", "elastos://availability": "raw availability backend namespace", "elastos://drm": "raw protected-content backend namespace", @@ -554,6 +557,7 @@ for path in manifest_paths: "browser-stream-bridge": "raw Browser Engine byte transport bridge", "browser-local-exit": "raw Browser local Exit daemon", "wallet-provider": "raw wallet backend provider", + "object-provider": "raw object backend provider", "ipfs-provider": "raw IPFS backend provider", "availability-provider": "raw availability backend provider", "drm-provider": "raw protected-content backend provider", @@ -627,6 +631,7 @@ required = { "browser-stream-bridge", "browser-local-exit", "webspace-provider", + "object-provider", "home-cli", "home", "system", @@ -654,6 +659,7 @@ required_demo = { "browser-stream-bridge", "browser-local-exit", "webspace-provider", + "object-provider", "home-cli", "home", "system", diff --git a/scripts/home-entropy-check.mjs b/scripts/home-entropy-check.mjs index f09dd93c..351fc5ed 100755 --- a/scripts/home-entropy-check.mjs +++ b/scripts/home-entropy-check.mjs @@ -656,7 +656,6 @@ const lightTokenFiles = [ "capsules/system/browser/style.css", "capsules/documents/index.html", "capsules/inbox/index.html", - "capsules/library/index.html", ]; const lightTokens = new Map([ @@ -793,6 +792,7 @@ const browserCapsulesApi = read( const viewerGatewayApi = read( "elastos/crates/elastos-server/src/api/viewer_gateway.rs", ); +const archivePolicyDoc = read("docs/ARCHIVE_POLICY.md"); const documentsProvider = read( "elastos/crates/elastos-server/src/documents.rs", ); @@ -829,6 +829,7 @@ const notifications = read( const carrierBridge = read( "elastos/crates/elastos-server/src/carrier_bridge.rs", ); +const carrierRuntime = read("elastos/crates/elastos-server/src/carrier.rs"); const runtimeCore = read("elastos/crates/elastos-server/src/runtime.rs"); const runtimeControl = read( "elastos/crates/elastos-server/src/runtime_control.rs", @@ -1026,7 +1027,7 @@ const gatewayBrowserRouteTests = read( ); const debugPolicy = read("DEBUG.md"); const gbaScript = read("scripts/gba.sh"); -const homeAssetVersion = "home-20260526d"; +const homeAssetVersion = "home-20260603c"; assertUsersSelfReferencesAreApproved(); assert( shellIndex.includes('role="listbox"'), @@ -1100,10 +1101,11 @@ assert( "Home PWA manifest must include a 512px install icon", ); assert( - shellServiceWorker.includes( - `elastos-home-${homeAssetVersion.slice("home-".length)}`, - ), - "Home service worker cache key must match the browser asset version", + shellServiceWorker.includes('const CACHE_PREFIX = "elastos-home-";') && + shellServiceWorker.includes("caches.delete(key)") && + shellServiceWorker.includes("self.registration.unregister()") && + shellServiceWorker.includes('self.addEventListener("fetch", () => {});'), + "Home service worker must actively remove old Home caches and unregister instead of preserving stale shell assets", ); assert( !shellServiceWorker.includes("elastos-home-shell"), @@ -1182,6 +1184,10 @@ assert( shellJs.includes("SHELL_MESSAGE_OPEN_TARGET_SOURCES"), "Home open-target messages must stay source-gated", ); +assert( + shellJs.includes('library: new Set(["documents", "library"])'), + "Home must allow Library sidebar Open in New Window while keeping the source-gated policy explicit", +); assert( shellIndex.includes(`shell.js?v=${homeAssetVersion}`), "Home entry module must cache-bust after shell browser changes", @@ -1927,8 +1933,71 @@ for (const component of ["home", "system", "documents", "library", "inbox"]) { } const documents = read("capsules/documents/index.html"); +const archiveManager = read("capsules/archive-manager/index.html"); +const archiveManagerManifest = read("capsules/archive-manager/capsule.json"); const inbox = read("capsules/inbox/index.html"); -const library = read("capsules/library/index.html"); +const libraryIndex = read("capsules/library/index.html"); +const libraryCss = read("capsules/library/library.css"); +const libraryApp = read("capsules/library/src/app.js"); +const libraryActions = read("capsules/library/src/actions.js"); +const libraryApi = read("capsules/library/src/api.js"); +const libraryDialog = read("capsules/library/src/dialog.js"); +const libraryEditor = read("capsules/library/src/editor.js"); +const libraryEvents = read("capsules/library/src/events.js"); +const libraryMenu = read("capsules/library/src/menu.js"); +const libraryModel = read("capsules/library/src/model.js"); +const libraryNavigation = read("capsules/library/src/navigation.js"); +const libraryPreview = read("capsules/library/src/preview.js"); +const libraryRealtime = read("capsules/library/src/realtime.js"); +const libraryRender = read("capsules/library/src/render.js"); +const librarySelection = read("capsules/library/src/selection.js"); +const libraryState = read("capsules/library/src/state.js"); +const libraryUploads = read("capsules/library/src/uploads.js"); +const library = readAll([ + "capsules/library/index.html", + "capsules/library/library.css", + "capsules/library/src/app.js", + "capsules/library/src/actions.js", + "capsules/library/src/api.js", + "capsules/library/src/dialog.js", + "capsules/library/src/editor.js", + "capsules/library/src/events.js", + "capsules/library/src/menu.js", + "capsules/library/src/model.js", + "capsules/library/src/navigation.js", + "capsules/library/src/preview.js", + "capsules/library/src/realtime.js", + "capsules/library/src/render.js", + "capsules/library/src/selection.js", + "capsules/library/src/state.js", + "capsules/library/src/uploads.js", +]); +const objectProviderManifest = read("capsules/object-provider/capsule.json"); +const objectProviderImpl = read("elastos/crates/elastos-server/src/library.rs"); +const gatewayProviderProxy = read("elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs"); +const libraryGatewayTests = read("elastos/crates/elastos-server/src/api/gateway_tests/library.rs"); +const retiredObjectProviderMarkers = { + oldBinary: ["library", "provider"].join("-"), + oldNamespace: ["elastos://", "library"].join(""), + oldSchemeRegistration: [ + 'CapsuleProvider::with_scheme(bridge, "', + "library", + '")', + ].join(""), +}; +const contentProvider = read("elastos/crates/elastos-server/src/content.rs"); +const contentCmd = read("elastos/crates/elastos-server/src/content_cmd.rs"); +const availabilityProvider = read("capsules/availability-provider/src/main.rs"); +const webspaceProvider = read("capsules/webspace-provider/src/main.rs"); +const webspaceCmd = read("elastos/crates/elastos-server/src/webspace_cmd.rs"); +const serverMain = read("elastos/crates/elastos-server/src/main.rs"); +const providerRegistry = read("elastos/crates/elastos-runtime/src/provider/registry.rs"); +const libraryDesktopIcon = read("capsules/library/icons/sidebar-folder-desktop.svg"); +const libraryMenuSmoke = read("scripts/library-menu-smoke.mjs"); +const libraryPerformanceSmoke = read("scripts/library-performance-smoke.mjs"); +const libraryLiveSmoke = read("scripts/library-live-smoke.sh"); +const fileManagerMigrationDoc = read("docs/FILE_MANAGER_MIGRATION.md"); +const todayLibraryTracker = read("TODAY.md"); const chatStyle = read("capsules/chat-room/browser/style.css"); const gba = read("capsules/gba-emulator/index.html"); const gbaStyle = read("capsules/gba-emulator/style.css"); @@ -2169,28 +2238,965 @@ assert( "Inbox mobile panels must use compact Home-aligned spacing", ); assert( - library.includes("home:open-target"), + libraryApi.includes("home:open-target"), "Library opens must use Home orchestration", ); +{ + const openObjectStart = libraryActions.indexOf("async function openObject(object)"); + const viewerIndex = libraryActions.indexOf("const viewer = viewerOptions(object)[0];", openObjectStart); + const previewIndex = libraryActions.indexOf("if (canPreviewObject(object))", openObjectStart); + assert( + openObjectStart >= 0 && viewerIndex > openObjectStart && previewIndex > viewerIndex, + "Library double-click must prefer the installed default viewer before falling back to internal preview", + ); +} assert( - library.includes("home:deliver-to-target"), + libraryApi.includes("home:deliver-to-target"), "Library picker returns must use Home orchestration", ); assert( - library.includes("home:close-self"), + libraryApi.includes("home:close-self"), "Library picker must close itself through Home after a successful attach", ); assert( - library.includes("chat-room:attach-library-item"), + libraryActions.includes("chat-room:attach-library-item"), "Library must return selected documents using the Chat Room attach contract", ); assert( - library.includes("Publish the document before attaching it."), - "Library must fail clearly when a draft is selected for Chat Room attachment", + libraryActions.includes("Publish this object before attaching it."), + "Library must fail clearly when a local-only object is selected for Chat Room attachment", ); assert( - library.includes("data-attach-uri"), - "Library attach mode must expose published URI selection state", + libraryActions.includes("publishedCid(object)") && + libraryActions.includes("object.published") && + libraryActions.includes("elastos://"), + "Library attach mode must return published elastos:// object URIs", +); +assert( + libraryApp.includes('desktop: "icons/sidebar-folder-desktop.svg"') && + !libraryApp.includes('trash: "icons/trash.svg"'), + "Library sidebar must expose Desktop and keep Trash as a hidden operation, not a visible root", +); +assert( + libraryDesktopIcon.includes('width="12px"') && libraryDesktopIcon.includes('height="12px"'), + "Library Desktop sidebar icon must match the compact PC2 sidebar icon sizing", +); +assert( + objectProviderManifest.includes('"role": "provider"') && + objectProviderManifest.includes('"name": "object-provider"') && + objectProviderManifest.includes('"provides": "elastos://object/*"') && + objectProviderManifest.includes('"resource": "elastos://object/*"') && + !objectProviderManifest.includes(retiredObjectProviderMarkers.oldNamespace) && + objectProviderManifest.includes('"audit_events"') && + ["publish", "unpublish", "repair", "share", "events"].every((op) => + objectProviderManifest.includes(`"${op}"`), + ), + "Object provider must be the only provider capsule authority metadata for every routed operation", +); +assert( + serverInfra.includes('find_installed_provider_binary("object-provider")') && + !serverInfra.includes( + `find_installed_provider_binary("${retiredObjectProviderMarkers.oldBinary}")`, + ) && + serverInfra.includes('CapsuleProvider::with_scheme(bridge.clone(), "object")') && + !serverInfra.includes(retiredObjectProviderMarkers.oldSchemeRegistration) && + !serverInfra.includes("ObjectProvider::new("), + "Runtime server must register object-provider only, without the retired object provider alias", +); +assert( + objectProviderImpl.includes('("desktop", "Desktop", format!("{root}/Desktop"), "directory")') && + !objectProviderImpl.includes('("trash", "Trash", format!("{root}/.Trash"), "directory")'), + "Object provider roots must expose Desktop and omit visible Trash", +); +assert( + gatewayApi.includes('"/api/provider/object/upload"') && + gatewayApi.includes('"/api/provider/object/upload/start"') && + gatewayApi.includes('"/api/provider/object/upload/:upload_id/chunk"') && + gatewayApi.includes('"/api/provider/object/upload/:upload_id/finish"') && + gatewayApi.includes("LIBRARY_UPLOAD_CHUNK_MAX_BYTES") && + gatewayApi.includes("pub(super) async fn gateway_library_upload") && + gatewayApi.includes("pub(super) async fn gateway_library_upload_start") && + gatewayApi.includes("pub(super) async fn gateway_library_upload_chunk") && + gatewayApi.includes("pub(super) async fn gateway_library_upload_finish") && + gatewayApi.includes("elastos.object.upload-session/v1") && + gatewayApi.includes("http-chunk-session") && + gatewayApi.includes("client_waits_for_chunk_ack") && + gatewayApi.includes("LIBRARY_TRANSFER_RECEIPT_SCHEMA") && + gatewayApi.includes("x-elastos-transfer-receipt") && + objectProviderImpl.includes("pub fn handle_library_upload_bytes(") && + objectProviderImpl.includes("fn write_library_file_bytes("), + "Object-provider raw browser uploads must route through Runtime auth/audit, emit transfer receipts, and use the shared object-provider byte-write helper", +); +assert( + gatewayApi.includes('"/api/provider/object/download/raw"') && + gatewayApi.includes("pub(super) async fn gateway_library_download") && + gatewayApi.includes("LIBRARY_TRANSFER_RECEIPT_SCHEMA") && + gatewayApi.includes("x-elastos-transfer-receipt") && + gatewayApi.includes("fn library_download_byte_range(") && + gatewayApi.includes("StatusCode::PARTIAL_CONTENT") && + gatewayApi.includes("StatusCode::RANGE_NOT_SATISFIABLE") && + gatewayApi.includes("LibraryArchiveFormat::parse") && + gatewayApi.includes('"archive" => archive_format_value = Some(value.into_owned())') && + objectProviderImpl.includes("pub(crate) enum LibraryArchiveFormat") && + objectProviderImpl.includes("pub(crate) fn handle_library_download_bytes_with_format(") && + objectProviderImpl.includes("pub(crate) async fn handle_library_download_bytes_runtime(") && + objectProviderImpl.includes("archive_format: LibraryArchiveFormat") && + objectProviderImpl.includes("async fn webspace_download_bytes(") && + objectProviderImpl.includes("fn library_download_object(") && + libraryActions.includes("downloadObjectRaw({ uri: object.uri })") && + libraryActions.includes('downloadObjectRaw({ uri: object.uri, archive: "zip" })') && + !libraryActions.includes('providerApi("download"') && + !libraryActions.includes("base64ToBlob"), + "Library raw browser downloads must route through Runtime auth/audit and the shared Library/WebSpace byte-read helpers without JSON/base64 app fallbacks", +); +assert( + gatewayApi.includes('"compress_archive"') && + objectProviderImpl.includes("CompressArchive {") && + objectProviderImpl.includes("fn compress_library_archive(") && + objectProviderImpl.includes("fn archive_library_single_zip(") && + objectProviderImpl.includes("archive_library_selection_zip(data_dir, principal_id, &targets)") && + objectProviderImpl.includes('capabilities.push("compress_archive")') && + libraryActions.includes('providerApi("compress_archive"') && + libraryActions.includes("async function compressObjectToZip(object)") && + libraryActions.includes("async function compressSelectedObjectsToZip()") && + libraryApp.includes('menuAction("Compress to ZIP"') && + libraryApp.includes('menuAction("Compress Selected to ZIP"') && + libraryState.includes('"compress_archive"'), + "Library Compress to ZIP must be provider-owned, capability-gated, cache-invalidating, and available for single objects and same-folder selections", +); +assert( + archiveManagerManifest.includes('"name": "archive-manager"') && + archiveManagerManifest.includes('"role": "viewer"') && + archiveManager.includes("ElastOS Archive Manager") && + archiveManager.includes("/api/viewers/archive-manager/library-object") && + archiveManager.includes('url.searchParams.set("stat_only", "true")') && + archiveManager.includes('url.searchParams.set("entries", "true")') && + archiveManager.includes("/api/viewers/archive-manager/library-roots") && + archiveManager.includes('url.searchParams.set("preview_entry", path)') && + archiveManager.includes("Archive Contents") && + archiveManager.includes("Extract Selected") && + archiveManager.includes("Select all safe") && + archiveManager.includes("Invert") && + archiveManager.includes("Cancel pending") && + archiveManager.includes("handleEntryKeyboard") && + archiveManager.includes("Provider preview loaded.") && + archiveManager.includes("async function extractSelectedEntries()") && + archiveManager.includes("async function cancelPendingExtract()") && + archiveManager.includes("async function selectPreviewEntry(path)") && + archiveManager.includes("renderEntries()") && + archiveManager.includes("Extraction is policy-gated") && + objectProviderImpl.includes("LIBRARY_ARCHIVE_ENTRIES_SCHEMA") && + objectProviderImpl.includes("LIBRARY_ARCHIVE_EXTRACT_ENTRIES_SCHEMA") && + objectProviderImpl.includes("LIBRARY_ARCHIVE_PREVIEW_ENTRY_SCHEMA") && + objectProviderImpl.includes("MAX_ARCHIVE_LIST_ENTRIES") && + objectProviderImpl.includes("MAX_ARCHIVE_PREVIEW_BYTES") && + objectProviderImpl.includes("ArchiveEntries {") && + objectProviderImpl.includes("ArchivePreviewEntry {") && + objectProviderImpl.includes("ArchiveExtractEntries {") && + objectProviderImpl.includes("fn library_archive_entries(") && + objectProviderImpl.includes("fn archive_preview_entry(") && + objectProviderImpl.includes("fn archive_preview_entry_for_object(") && + objectProviderImpl.includes("fn preview_zip_archive_entry(") && + objectProviderImpl.includes("fn preview_tar_archive_entry(") && + objectProviderImpl.includes("fn extract_library_archive_entries(") && + objectProviderImpl.includes("fn extract_archive_entries_to_webspace_destination(") && + objectProviderImpl.includes("fn ensure_webspace_archive_write_allowed(") && + objectProviderImpl.includes("fn webspace_archive_object(") && + objectProviderImpl.includes("resolver_target_redacted") && + objectProviderImpl.includes("enum ArchiveConflictPolicy") && + objectProviderImpl.includes("fn normalized_archive_entry_path(") && + objectProviderImpl.includes('vec!["archive-manager"]') && + objectProviderImpl.includes('"archive-manager" => "Archive Manager"') && + viewerGatewayApi.includes("stat_only") && + viewerGatewayApi.includes("entries: bool") && + viewerGatewayApi.includes("preview_entry: Option") && + viewerGatewayApi.includes('"archive_entries"') && + viewerGatewayApi.includes('"archive_preview_entry"') && + viewerGatewayApi.includes('"archive_extract_entries"') && + viewerGatewayApi.includes("viewer_library_roots_get") && + viewerGatewayApi.includes("viewer_library_object_post") && + viewerGatewayApi.includes("ensure_viewer_can_view_library_object") && + viewerGatewayApi.includes("handle_object_provider_runtime_request") && + viewerGatewayApi.includes("viewer supports Library object metadata only") && + viewerGatewayApi.includes("viewer does not support Library object writes") && + libraryActions.includes("query.archiveSupport = JSON.stringify") && + libraryActions.includes("contentCid(object)") && + libraryApp.includes('viewer?.id === "archive-manager"') && + libraryMenuSmoke.includes("Legacy.7z") && + libraryMenuSmoke.includes("archive_entries") && + libraryMenuSmoke.includes("archive_preview_entry") && + libraryMenuSmoke.includes("archive_extract_entries") && + libraryMenuSmoke.includes("Nested/deep.txt") && + libraryMenuSmoke.includes("#destination-roots") && + libraryMenuSmoke.includes("#entry-preview") && + libraryMenuSmoke.includes("#select-all-safe") && + libraryMenuSmoke.includes("#cancel-extract") && + libraryMenuSmoke.includes("#extract-status") && + libraryMenuSmoke.includes("policy_gated_unsupported_archive_family") && + libraryMenuSmoke.includes('message?.target === "archive-manager"') && + libraryGatewayTests.includes("/api/viewers/archive-manager/library-object?uri=") && + libraryGatewayTests.includes("test_library_provider_lists_supported_archive_entries_through_viewer_route") && + libraryGatewayTests.includes("test_library_provider_lists_unsafe_archive_entries_as_blocked") && + libraryGatewayTests.includes("test_library_provider_selectively_extracts_archive_entries_through_viewer_route") && + libraryGatewayTests.includes("test_library_provider_selective_extract_blocks_unsafe_entries") && + libraryGatewayTests.includes("test_library_gateway_lists_external_webspace_archive_entries_without_resolver_leak") && + libraryGatewayTests.includes("test_library_gateway_imports_external_webspace_archive_entries_to_local_library") && + libraryGatewayTests.includes("test_library_gateway_webspace_archive_writeback_requires_mutable_write_adapter") && + libraryGatewayTests.includes("elastos.library.archive-preview-entry/v1") && + libraryGatewayTests.includes("/api/viewers/archive-manager/library-roots") && + libraryGatewayTests.includes("/api/provider/object/archive_preview_entry") && + libraryGatewayTests.includes("resolver_target_redacted") && + libraryGatewayTests.includes("mutable destination Space") && + libraryGatewayTests.includes("elastos.library.archive-entries/v1") && + libraryGatewayTests.includes("elastos.library.archive-extract-entries/v1") && + libraryGatewayTests.includes("StatusCode::FORBIDDEN") && + archivePolicyDoc.includes("No generic non-tar/non-zip family is approved in this branch") && + archivePolicyDoc.includes(".7z") && + archivePolicyDoc.includes(".rar") && + archivePolicyDoc.includes("Unsupported families remain visible as policy-gated archives"), + "Archive Manager must provide an installed viewer shell, stat-only/archive-entry/preview/root viewer routes, supported-family browsing, preview, selective extraction, policy-gated unsupported archive UX, conflict/cancel receipts, policy documentation, and no direct viewer provider access or unsafe extraction", +); +assert( + gatewayApi.includes("HOME_DESKTOP_OBJECTS_SCHEMA") && + gatewayApi.includes("home_desktop_objects_summary") && + gatewayApi.includes("home_desktop_events_signature") && + gatewayApi.includes('registry.send_raw("object", &request).await') && + gatewayApi.includes('"op": "list"') && + gatewayApi.includes('"op": "events"') && + gatewayApi.includes('"uri": uri'), + "Home desktop must project localhost://Users/self/Desktop through the registered object provider, not direct filesystem access or server-local helpers", +); +assert( + shellCore.includes("function desktopObjects(summary)") && + shellCore.includes("function desktopObjectEntryId(object)") && + shellCore.includes("function desktopLayoutEntries(summary)"), + "Home layout state must treat Library Desktop objects as first-class desktop entries", +); +assert( + shellSurface.includes("attachDesktopObjectInteractions") && + shellSurface.includes("export function openSelectedDesktopEntry()") && + shellSurface.includes('openTarget("library", { query: { uri: object.uri } })') && + shellSurface.includes("desktopObjectViewer(object)") && + shellSurface.includes('openTarget(viewer,') && + shellSurface.includes('objectUri: object.uri') && + shellSurface.includes("function desktopObjectContextMenuItems(target)") && + shellSurface.includes('action: "open-desktop-object-new-window"') && + shellSurface.includes('action: "download-desktop-object"') && + shellSurface.includes('action: "properties-desktop-object"') && + shellSurface.includes("function libraryActionForObject(object, action)") && + shellSurface.includes('action === "download-desktop-object" ? "download" : "properties"') && + shellJs.includes("openSelectedDesktopEntry()") && + !shellJs.includes("openTarget(shellState.selectedDesktopTargetId)"), + "Home desktop objects must open and expose PC2-style object actions through Library/Documents orchestration", +); +assert( + libraryState.includes("initialObjectUri: queryParams.get(\"objectUri\")") && + libraryState.includes("initialAction: queryParams.get(\"action\")") && + libraryState.includes("initialActionHandled: false") && + libraryApp.includes("async function runInitialObjectAction()") && + libraryApp.includes("const object = objectByUri(state.initialObjectUri)") && + libraryApp.includes("state.currentObject?.uri === state.initialObjectUri") && + libraryApp.includes('state.initialAction === "properties"') && + libraryApp.includes('state.initialAction === "download"') && + libraryApp.includes("await downloadObject(object)"), + "Library must accept Home-delegated object actions by launch query without moving object-provider authority into Home", +); +assert( + shellJs.includes("desktopObjectsChanged(previous, summary)") && + shellJs.includes('kind === "home.desktop.changed"'), + "Home shell must refresh when Library Desktop objects change", +); +assert( + libraryApp.includes('menuAction("Open With", null') && + libraryApp.includes("children: viewers.map") && + libraryApp.includes('menuAction("Sort By", null') && + libraryApp.includes('menuAction("Name"') && + libraryApp.includes('menuAction("Date Modified"') && + libraryApp.includes('menuAction("New", null') && + libraryApp.includes('menuAction("Folder"') && + libraryApp.includes('menuAction("Show Hidden"') && + libraryApp.includes("library.showHidden") && + libraryApp.includes('menuAction("Paste Into Folder"') && + libraryApp.includes('menuAction("Properties"') && + libraryApp.includes('menuAction("Delete"') && + libraryApp.includes('if (canPasteInto(state.currentUri)) actions.push(menuAction("Paste"') && + libraryMenu.includes('if (!menuActions.length)') && + !libraryApp.includes('menuAction("Undo"') && + !libraryApp.includes("Copy Runtime URI") && + !libraryApp.includes("Get Info") && + !/[⌘⌫⇧]/.test(library) && + !/(Apple|Finder|-apple|BlinkMac|SF Pro)/.test(library), + "Library context menus must follow PC2 Explorer labels without platform-branded shortcuts", +); +assert( + libraryDialog.includes("properties-card") && + libraryDialog.includes("window-item-properties") && + libraryDialog.includes("item-props-tabview") && + libraryDialog.includes("item-props-tab-btn") && + libraryDialog.includes("item-props-tab-content") && + libraryDialog.includes("item-props-tbl") && + libraryDialog.includes('data-tab="general"') && + libraryDialog.includes('data-tab="runtime"') && + libraryDialog.includes("propertiesPanel(\"general\"") && + libraryDialog.includes("propertiesPanel(\"runtime\"") && + libraryDialog.includes("propertiesPanel(\"archive\"") && + libraryDialog.includes("smartWebIdentity") && + libraryDialog.includes("safeAvailabilitySummary") && + libraryDialog.includes("safePublishReceiptSummary") && + libraryDialog.includes("safeShareReceiptSummary") && + libraryDialog.includes("Published CID") && + libraryDialog.includes("Published Link") && + libraryDialog.includes("publishedCid(object)") && + libraryDialog.includes("propertiesVisibilitySummary") && + libraryDialog.includes("copyableValue(identity.contentId") && + libraryDialog.includes("data-prop-copy") && + libraryDialog.includes("props-copy-btn") && + libraryDialog.includes("item-prop-badge") && + libraryDialog.includes("Availability Summary") && + libraryDialog.includes("Publish Receipt Summary") && + libraryDialog.includes("Share Receipt Summary") && + !libraryDialog.includes("Availability Receipt") && + !libraryDialog.includes("Share Receipt") && + !libraryDialog.includes("item-props-tab-btn-versions") && + libraryDialog.includes("Resolver Target") && + libraryCss.includes(".window-item-properties") && + libraryCss.includes(".item-props-tab-content-selected") && + libraryCss.includes(".item-prop-label") && + libraryCss.includes(".item-prop-val") && + libraryCss.includes(".props-copy-btn") && + libraryCss.includes(".item-prop-badge") && + !libraryCss.includes(".properties-hero") && + !libraryCss.includes(".properties-icon"), + "Library Properties must render the PC2-style tabbed property table instead of a wide diagnostic metadata grid", +); +assert( + objectProviderImpl.includes("published_cid: Option") && + objectProviderImpl.includes("fn raw_sha256_cid(") && + objectProviderImpl.includes("\"current_cid\"") && + libraryApp.includes("publishedCid(object)") && + libraryApp.includes("Copy Published Link") && + libraryApp.includes("Copy Content CID") && + libraryActions.includes("publishedCid(object)") && + libraryMenuSmoke.includes("SMOKE_LOCAL_CONTENT_CID") && + libraryMenuSmoke.includes("SMOKE_PUBLISHED_CID"), + "Library object model must separate current file-byte content_cid from published_cid/public elastos:// links", +); +assert( + fileManagerMigrationDoc.includes("Local mutable storage") && + fileManagerMigrationDoc.includes("CID is content identity, not a storage-location guarantee") && + fileManagerMigrationDoc.includes("Private files are SmartWeb object heads") && + todayLibraryTracker.includes("Library object identity is split deliberately") && + todayLibraryTracker.includes("current immutable raw-byte `content_cid`") && + todayLibraryTracker.includes("public `elastos://` links use `published_cid`"), + "Docs must explain that local files are provider-owned mutable storage with CIDs, while published_cid is the public SmartWeb availability identity", +); +assert( + libraryApi.includes("/api/provider/object/upload") && + libraryApi.includes("/api/provider/object/upload/start") && + libraryApi.includes("/api/provider/object/upload/${encodeURIComponent(uploadId)}/chunk") && + libraryApi.includes("CHUNKED_UPLOAD_THRESHOLD_BYTES") && + libraryApi.includes("CHUNKED_UPLOAD_BYTES") && + libraryApi.includes("uploadFailureMessage") && + libraryApi.includes("public gateway body-size limit") && + !libraryApi.includes("/api/provider/library/upload"), + "Library upload must use object-provider upload only, use chunk sessions for large files, and explain edge-proxy 413 body-size failures", +); +assert( + library.includes("elements.statusText.classList.toggle(\"hidden\", !text)") && + libraryMenuSmoke.includes("public gateway body-size limit"), + "Library status messages, including upload body-size failures, must be visible to users", +); +assert( + libraryMenuSmoke.includes("/api/provider/object/") && + libraryMenuSmoke.includes("/api/provider/object/upload/start") && + libraryMenuSmoke.includes("LargeVideo.mp4") && + libraryMenuSmoke.includes("http-chunk-session") && + !libraryMenuSmoke.includes("/api/provider/library/"), + "Library menu smoke must exercise canonical object-provider routes without retired library-provider fallback paths", +); +assert( + !library.includes("moveObjectWithPrompt") && + !library.includes("copyObjectWithPrompt") && + !library.includes("window.prompt") && + !library.includes("window.confirm") && + !library.includes("Restore to URI") && + !library.includes("repairSelectedObjects") && + !library.includes("Repair availability"), + "Library must not ship browser-native prompts/confirms, raw create/move/copy/restore prompts, or repair-only implementation leftovers", +); +assert( + !libraryIndex.includes('id="selection-actions"') && + !library.includes("renderSelectionActions") && + !library.includes("data-selection-action"), + "Library must keep actions in the Explorer right-click menu, not a disruptive selection strip", +); +assert( + libraryApp.includes("function activeRootForUri(uri)") && + libraryApp.includes(".sort((left, right) => right.uri.length - left.uri.length)") && + libraryApp.includes('sidebar: document.querySelector(".sidebar")') && + libraryApp.includes("function orderRoots(roots)") && + libraryApp.includes("function reorderPlace(sourceRootId, targetRootId") && + libraryApp.includes("localStorage.setItem(\"library.sidebarOrder\"") && + libraryState.includes("sidebarOrder: readStoredStringArray(storage.getItem(\"library.sidebarOrder\"))") && + libraryApp.includes("function showPlaceMenu(uri, x, y)") && + libraryApp.includes('menuAction("Open in New Window", () => openTarget("library", { uri: root.uri }))') && + libraryEvents.includes('elements.sidebar?.addEventListener("contextmenu"') && + libraryEvents.includes('elements.places.addEventListener("contextmenu"') && + libraryEvents.includes('application/x-elastos-library-root-id') && + libraryEvents.includes("markPlaceDropTarget(elements, button, event)") && + libraryEvents.includes("showPlaceMenu(button.dataset.uri, event.clientX, event.clientY)") && + libraryEvents.includes("event.stopPropagation();\n hideMenu();") && + libraryCss.includes(".place.window-sidebar-item-dragging") && + libraryCss.includes(".place[data-drop-position=\"before\"]"), + "Library sidebar must suppress browser right-click on chrome, expose Open/Open in New Window on place items, persist user root ordering, and mark only the most specific active place", +); +assert( + libraryRender.includes('const badgesMarkup = badges ? `${badges}` : "";') && + libraryCss.includes("grid-template-rows: 45px auto 18px;") && + libraryCss.includes('.content[data-view="grid"] .badges') && + libraryCss.includes('.content[data-view="list"] .badges') && + !libraryCss.includes(".badges {\n position: absolute") && + !libraryCss.includes(".content[data-view=\"list\"] .badges {\n display: none;"), + "Library Published/blocked/trash badges must be layout participants and remain visible in list view instead of overlaying icons or disappearing", +); +assert( + libraryRender.includes('elements.content.dataset.empty = "true"') && + libraryRender.includes('class="empty-inner"') && + libraryCss.includes('.content[data-empty="true"]') && + libraryRender.includes("No connected spaces") && + libraryRender.includes("Provider-backed spaces") && + libraryRender.includes("writable spaces use provider-owned storage") && + libraryActions.includes("This Space is read-only.") && + !libraryActions.includes("Mounted WebSpaces are read-only resolver handles."), + "Library empty Spaces states must be centered and explicit instead of rendering a cramped generic folder message", +); +assert( + ['"refresh"', '"cache"', '"sync"'].every((op) => webspaceProvider.includes(op)) && + webspaceProvider.includes("Refresh {") && + webspaceProvider.includes("Cache {") && + webspaceProvider.includes("Sync {") && + webspaceProvider.includes("fn refresh_handle(") && + webspaceProvider.includes("fn cache_handle(") && + webspaceProvider.includes("fn sync_handle(") && + webspaceProvider.includes("elastos.webspace.refresh-receipt/v1") && + webspaceProvider.includes("elastos.webspace.cache-receipt/v1") && + webspaceProvider.includes("elastos.webspace.sync-receipt/v1") && + webspaceProvider.includes("refresh_replaces_index_and_persists_refreshed_head") && + webspaceProvider.includes("sync_clears_dirty_fork_head_without_claiming_byte_sync") && + webspaceCmd.includes("async fn refresh(") && + webspaceCmd.includes("async fn cache(") && + webspaceCmd.includes("async fn sync(") && + serverMain.includes("Refresh {") && + serverMain.includes("Cache {") && + serverMain.includes("Sync {"), + "WebSpace provider must expose real provider-owned refresh/cache/sync lifecycle operations and CLI verbs instead of only static head/status metadata", +); +assert( + webspaceProvider.includes('"health"') && + webspaceProvider.includes("Health {") && + webspaceProvider.includes("fn health_report(") && + webspaceProvider.includes("fn health_for_handle(") && + webspaceProvider.includes("elastos.webspace.health/v1") && + webspaceProvider.includes("elastos.webspace.resolver-health/v1") && + webspaceProvider.includes( + "health_reports_external_resolver_attention_and_metadata_readiness", + ) && + webspaceProvider.includes("dirty_head_count") && + webspaceCmd.includes("async fn health(") && + webspaceCmd.includes("WebSpace health:") && + serverMain.includes("Health {"), + "WebSpace provider must expose resolver health as a provider/CLI contract with metadata-ready, mounted-no-index, and dirty-head coverage", +); +assert( + ['"write"', '"mkdir"', '"delete"'].every((op) => webspaceProvider.includes(op)) && + webspaceProvider.includes("OBJECT_TABLE_SCHEMA") && + webspaceProvider.includes("struct WebSpaceObject") && + webspaceProvider.includes("fn write_handle(") && + webspaceProvider.includes("fn mkdir_handle(") && + webspaceProvider.includes("fn delete_handle(") && + webspaceProvider.includes("materialized_object_handle") && + webspaceProvider.includes("elastos.webspace.write-receipt/v1") && + webspaceProvider.includes("elastos.webspace.mkdir-receipt/v1") && + webspaceProvider.includes("elastos.webspace.delete-receipt/v1") && + webspaceProvider.includes("DEFAULT_MUTABLE_ACCESS_POLICY") && + webspaceProvider.includes("mutable_mount_materializes_objects_and_persists_them") && + objectProviderImpl.includes("async fn webspace_write_bytes(") && + objectProviderImpl.includes("async fn webspace_mkdir(") && + objectProviderImpl.includes("async fn webspace_delete_permanently(") && + objectProviderImpl.includes("handle_library_upload_bytes_runtime") && + gatewayProviderProxy.includes("handle_library_upload_bytes_runtime") && + libraryGatewayTests.includes( + "test_library_gateway_mutates_writable_webspace_through_runtime_provider", + ), + "WebSpace provider and Library gateway must support local materialized mutable WebSpace objects without exposing raw resolver authority", +); +assert( + providerRegistry.includes("fn apply_provider_byte_range(") && + providerRegistry.includes("fn apply_provider_stream_response(") && + providerRegistry.includes("fn decode_provider_stream_payload(") && + providerRegistry.includes('const PROVIDER_STREAM_SCHEMA: &str = "elastos.provider.stream/v1"') && + providerRegistry.includes('const PROVIDER_STREAM_ENCODING: &str = "base64-chunks"') && + providerRegistry.includes("fn attach_provider_invocation_envelope(") && + providerRegistry.includes("elastos.provider.invocation/v1") && + providerRegistry.includes("runtime-local-provider-plane") && + providerRegistry.includes("carrier-provider-plane") && + providerRegistry.includes("struct ProviderCarrierRoute") && + providerRegistry.includes("enum ProviderInvocationTransport") && + providerRegistry.includes("trait ProviderCarrierInvoker") && + providerRegistry.includes("set_carrier_invoker") && + providerRegistry.includes("provider_carrier_route_receipt") && + providerRegistry.includes("route.peer_did.as_deref()") && + providerRegistry.includes( + "Carrier provider invocation requires registered Carrier invoker", + ) && + providerRegistry.includes("fn provider_invocation_capability(") && + providerRegistry.includes("provider byte range requires response data.data base64 payload") && + providerRegistry.includes("provider stream expected {expected} bytes") && + providerRegistry.includes("provider stream chunk index mismatch") && + providerRegistry.includes("base64::engine::general_purpose::STANDARD") && + providerRegistry.includes("provider byte range expected {expected} bytes") && + providerRegistry.includes("provider invocation request must not predeclare runtime field") && + providerRegistry.includes('response["data"]["runtime_invocation"]["schema"]') && + providerRegistry.includes('response["_runtime_transfer"]["capability"]') && + providerRegistry.includes('response["_runtime_transfer"]["transport"]') && + providerRegistry.includes("test_provider_invocation_attaches_range_progress_transfer_receipt") && + providerRegistry.includes('assert_eq!(sliced, b"abcdefghij");') && + providerRegistry.includes( + "test_provider_invocation_stream_normalizes_range_progress_transfer_receipt", + ) && + providerRegistry.includes("test_provider_invocation_rejects_malformed_stream_payload") && + providerRegistry.includes("test_provider_invocation_rejects_predeclared_runtime_metadata") && + providerRegistry.includes("test_provider_invocation_carrier_routes_through_registered_invoker") && + providerRegistry.includes("test_provider_invocation_carrier_requires_registered_invoker"), + "Runtime provider invocation must send typed source/target/capability envelopes to target providers, enforce byte-range and stream receipts, expose Carrier provider-plane transport, and reject malformed provider stream payloads", +); +assert( + carrierRuntime.includes('"provider_invoke"') && + carrierRuntime.includes("MAX_CARRIER_REPLICATION_CANDIDATES") && + carrierRuntime.includes("MAX_CARRIER_AVAILABILITY_TICKET_LEN") && + carrierRuntime.includes("MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN") && + carrierRuntime.includes("struct CarrierAvailabilityRequirements") && + carrierRuntime.includes("struct CarrierReplicationProof") && + carrierRuntime.includes("remote_receipt: Option") && + carrierRuntime.includes("carrier_provider_invoke_registry") && + carrierRuntime.includes("validate_carrier_provider_invocation") && + carrierRuntime.includes("decode_carrier_provider_stream_payload") && + carrierRuntime.includes("remote_content_provider_response_bytes") && + carrierRuntime.includes("remote_content_receipt_peer_selection_summary") && + carrierRuntime.includes("remote_content_receipt_peer_selection_replicas_summary") && + carrierRuntime.includes("remote_content_receipt_accounting_summary") && + carrierRuntime.includes("remote_content_receipt_abuse_controls_summary") && + carrierRuntime.includes("carrier_provider_target_allowed") && + carrierRuntime.includes("fetch_content_via_carrier_provider_invocation") && + carrierRuntime.includes("ensure_content_via_carrier_provider_invocation") && + carrierRuntime.includes("import_content_via_carrier_provider_invocation") && + carrierRuntime.includes("import_object_content_via_carrier_provider_invocation") && + carrierRuntime.includes("import_exact_content_via_carrier_provider_invocation") && + carrierRuntime.includes("MAX_CARRIER_OBJECT_IMPORT_FILES") && + carrierRuntime.includes("MAX_CARRIER_OBJECT_IMPORT_BYTES") && + carrierRuntime.includes("remote_content_receipt_summary") && + carrierRuntime.includes("content_availability_replicas") && + carrierRuntime.includes("carrier_replica_candidate_score") && + carrierRuntime.includes("CarrierPeerReputation") && + carrierRuntime.includes("CarrierPeerReputationStore") && + carrierRuntime.includes("carrier_reputation_score") && + carrierRuntime.includes("carrier-peer-reputation.json") && + carrierRuntime.includes("with_provider_registry_and_data_dir") && + carrierRuntime.includes("save_carrier_peer_reputation") && + carrierRuntime.includes("content_availability_replicas_with_reputation") && + carrierRuntime.includes('"selection_reason"') && + carrierRuntime.includes('"local_reputation"') && + carrierRuntime.includes('"replica_summary_limit"') && + carrierRuntime.includes('"replicas_truncated"') && + carrierRuntime.includes("carrier_peer_selection_json") && + carrierRuntime.includes("carrier_provider_quota") && + carrierRuntime.includes("carrier_abuse_controls_json") && + carrierRuntime.includes("carrier_remote_candidate_limit") && + carrierRuntime.includes('["remote_receipt"]["abuse_controls"]') && + carrierRuntime.includes('"requirements_exceed_quota"') && + carrierRuntime.includes('"effective_max_replicas"') && + carrierRuntime.includes('"carrier_provider_invocation_guardrail"') && + carrierRuntime.includes("test_carrier_quota_marks_impossible_replica_requirements") && + carrierRuntime.includes( + "test_carrier_remote_candidate_limit_keeps_live_multi_peer_requirement", + ) && + carrierRuntime.includes("carrier_provider_replication") && + carrierRuntime.includes('"remote_receipt"') && + carrierRuntime.includes('"storage_quota_status"') && + carrierRuntime.includes('"content_bytes"') && + carrierRuntime.includes('"local_only": true') && + carrierRuntime.includes('"transfer": "stream"') && + carrierRuntime.includes("ProviderTransfer::Stream") && + carrierRuntime.includes('"carrier_provider_invoke"') && + carrierRuntime.includes('"content" | "availability" | "rights" | "key" | "decrypt" | "drm"') && + carrierRuntime.includes("CarrierProviderInvoker") && + carrierRuntime.includes("ProviderCarrierInvoker for CarrierProviderInvoker") && + carrierRuntime.includes("invoke_provider(") && + carrierRuntime.includes("carrier_endpoint_matches_peer") && + carrierRuntime.includes("provider_invoke carrier metadata must not expose connect_ticket") && + carrierRuntime.includes("test_carrier_provider_invoke_dispatches_runtime_enveloped_request") && + carrierRuntime.includes("test_carrier_provider_invoke_accepts_stream_contract_metadata") && + carrierRuntime.includes("test_carrier_provider_invoke_rejects_stream_without_contract_metadata") && + carrierRuntime.includes("test_carrier_provider_invoke_rejects_raw_backend_target") && + carrierRuntime.includes("test_carrier_availability_fetch_uses_provider_invocation_transport") && + carrierRuntime.includes("test_carrier_replication_proof_uses_remote_content_provider_invocation") && + carrierRuntime.includes("test_carrier_replication_falls_back_to_exact_import_when_remote_pin_fails") && + carrierRuntime.includes("test_carrier_replication_prefers_object_import_when_manifest_exists") && + carrierRuntime.includes("test_carrier_availability_ensure_proves_remote_replica_via_provider_plane") && + carrierRuntime.includes( + "test_carrier_availability_requires_remote_attempt_for_live_proof_when_min_met", + ) && + carrierRuntime.includes("test_carrier_peer_selection_proof_redacts_connect_tickets") && + carrierRuntime.includes( + "test_remote_content_receipt_peer_selection_summary_redacts_replica_rows", + ) && + carrierRuntime.includes( + "test_remote_content_receipt_peer_selection_summary_marks_truncated_rows", + ) && + carrierRuntime.includes("test_content_availability_replicas_are_scored_and_sorted") && + carrierRuntime.includes("test_content_availability_replicas_apply_local_runtime_reputation") && + carrierRuntime.includes("test_carrier_peer_reputation_persists_local_history") && + carrierRuntime.includes("test_content_availability_replicas_ignore_signed_repair_only_announcements") && + carrierRuntime.includes("test_content_availability_replicas_ignore_oversized_candidate_metadata") && + carrierRuntime.includes('remote_transfer["transfer"], "stream"') && + serverInfra.includes("set_carrier_invoker") && + serverInfra.includes("CarrierAvailabilityProvider::with_provider_registry") && + serverInfra.includes("CarrierProviderInvoker::new()") && + serverInfra.includes("maybe_spawn_content_repair_scheduler") && + serverInfra.includes("ELASTOS_CONTENT_REPAIR_SCHEDULER") && + serverInfra.includes("invoke_content_repair_worker(") && + serverInfra.includes("content_repair_scheduler_config_clamps_operator_env") && + serverInfra.includes("content_repair_scheduler_is_opt_in"), + "Carrier provider invocation must be Runtime-mediated over provider_invoke, service-provider-only, Stream-contract validated for Carrier availability fetch, registered only with the built-in Carrier node, prove remote replicas through remote content/ensure+status, enforce bounded peer-selection/quota metadata, ignore repair-only announcements as candidates, provide an opt-in bounded content repair scheduler, and must not leak raw connect tickets", +); +assert( + contentProvider.includes("struct AvailabilityRequirements") && + contentProvider.includes("struct ContentRepairTask") && + contentProvider.includes("struct ContentFetchTransfer") && + contentProvider.includes("IMPORT_EXACT_MAX_BYTES") && + contentProvider.includes("IMPORT_OBJECT_MAX_FILES") && + contentProvider.includes("AVAILABILITY_DASHBOARD_SCHEMA") && + contentProvider.includes("CONTENT_ACCOUNTING_SCHEMA") && + contentProvider.includes("CONTENT_STORAGE_QUOTA_SCHEMA") && + contentProvider.includes("CONTENT_ABUSE_CONTROLS_SCHEMA") && + contentProvider.includes("REPAIR_TASK_SCHEMA") && + contentProvider.includes("REPAIR_WORKER_RUN_SCHEMA") && + contentProvider.includes("REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA") && + contentProvider.includes("REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS") && + contentProvider.includes('"repair_worker"') && + contentProvider.includes("fn record_repair_task(") && + contentProvider.includes("fn availability_dashboard(") && + contentProvider.includes("fn availability_quota_status(") && + contentProvider.includes("fn content_accounting_json(") && + contentProvider.includes("fn content_accounting_observation_from_publish_request(") && + contentProvider.includes("fn content_accounting_from_previous_or_unknown(") && + contentProvider.includes("fn local_abuse_controls_json(") && + contentProvider.includes("fn provider_abuse_controls_json(") && + contentProvider.includes('"verified_remote_receipts"') && + contentProvider.includes('"recent_remote_replicas"') && + contentProvider.includes('"recent_remote_replica_limit"') && + contentProvider.includes('"recent_remote_replicas_truncated"') && + contentProvider.includes('"local_reputation"') && + contentProvider.includes('"local_runtime"') && + contentProvider.includes('"replica_bytes_estimate"') && + contentProvider.includes('"storage_quota_policy"') && + contentProvider.includes('"abuse_controls"') && + contentProvider.includes('dashboard["data"]["proofs"]["live_multi_peer"]') && + contentProvider.includes('dashboard["data"]["quota"]["by_status"]["within_quota"]') && + contentProvider.includes("async fn run_repair_worker(") && + contentProvider.includes("fn validate_repair_worker_invocation(") && + contentProvider.includes("repair-tasks.jsonl") && + contentProvider.includes("fn provider_transfer_value(") && + contentProvider.includes("async fn import_exact(") && + contentProvider.includes("async fn import_object(") && + contentProvider.includes("fn validate_import_exact_invocation(") && + contentProvider.includes("fn validate_import_object_invocation(") && + contentProvider.includes("fn validate_import_object_payload_bounds(") && + contentProvider.includes("fn validate_runtime_invocation_fields(") && + contentProvider.includes("fn provider_stream_payload_bytes(") && + contentProvider.includes("fn validate_network_availability_claim(") && + contentProvider.includes("content_repair_worker_guardrail") && + contentProvider.includes("runtime_invocation_required") && + contentProvider.includes('"requirements": requirements.to_json()') && + contentProvider.includes('ProviderTransfer::Stream =>') && + contentProvider.includes('content fetch transfer must be bytes or stream') && + contentProvider.includes('fn provider_response_stream(') && + contentProvider.includes("content_fetch_propagates_range_progress_transfer_receipt") && + contentProvider.includes("content_fetch_stream_returns_provider_stream_payload") && + contentProvider.includes("content_fetch_ranges_availability_provider_when_local_backend_misses") && + contentProvider.includes( + "content_fetch_stream_ranges_availability_provider_when_local_backend_misses", + ) && + contentProvider.includes("content_fetch_local_only_skips_availability_provider") && + contentProvider.includes("multi-peer availability requires live_multi_peer_proof=true") && + contentProvider.includes("network availability peer_selection requires mode or strategy") && + contentProvider.includes("Carrier availability announcement requires a topic") && + contentProvider.includes("content_publish_rejects_unproven_multi_peer_availability_claim") && + contentProvider.includes("content_publish_requires_peer_selection_policy_metadata") && + contentProvider.includes("content_publish_records_local_only_repair_task") && + contentProvider.includes("content_import_exact_accepts_matching_cid_stream") && + contentProvider.includes("content_import_exact_rejects_cid_mismatch_and_unpins_import") && + contentProvider.includes("content_import_exact_requires_runtime_provider_invocation") && + contentProvider.includes("content_import_object_requires_runtime_provider_invocation") && + contentProvider.includes("content_import_object_reconstructs_manifest_directory") && + contentProvider.includes('"carrier_object_import"') && + contentProvider.includes('status["data"]["accounting"]["content_bytes"]') && + contentProvider.includes("content_repair_worker_requires_runtime_provider_invocation") && + contentProvider.includes("content_repair_worker_retries_queued_availability_task") && + contentProvider.includes("content_repair_worker_enforces_attempt_budget") && + contentProvider.includes("content_status_without_cid_returns_availability_dashboard") && + contentProvider.includes('dashboard["data"]["proofs"]["recent_remote_replicas"]') && + contentCmd.includes("#[command(name = \"repair-worker\")]") && + contentCmd.includes("fn repair_worker_request(") && + contentCmd.includes("fn content_command_builds_repair_worker_request(") && + contentCmd.includes("ProviderInvocationTransport::Local") && + contentCmd.includes("ProviderTransfer::Json") && + contentProvider.includes("content_publish_enforces_availability_requirements") && + availabilityProvider.includes("requirements: Value") && + availabilityProvider.includes("fn upstream_peer_selection(") && + availabilityProvider.includes("multi-replica availability target response requires peer_selection metadata") && + availabilityProvider.includes("upstream_multi_replica_network_available_requires_peer_selection"), + "Content availability must enforce requested replica/quota/live-proof requirements before recording network/Carrier availability claims, persist repair task state, keep repair-worker passes Runtime-provider-internal with bounded guardrails, and propagate provider byte-range/progress receipts plus optional Stream payloads through fetch", +); +assert( + objectProviderImpl.includes("fn shared_access_decision(") && + objectProviderImpl.includes("fn shared_access_open_contract(") && + objectProviderImpl.includes("fn validate_shared_access_recipient_proof(") && + objectProviderImpl.includes("fn shared_access_recipient_proof_state(") && + objectProviderImpl.includes("elastos.library.recipient-proof/v1") && + objectProviderImpl.includes("elastos.library.recipient-proof-state/v1") && + objectProviderImpl.includes("recipient_proof requires proof_binding_id") && + objectProviderImpl.includes("requires passkey proof binding") && + gatewayProviderProxy.includes('object.remove("recipient_proof")') && + gatewayProviderProxy.includes('"proof_binding_id": context.proof_binding_id.as_deref().unwrap_or_default()') && + gatewayProviderProxy.includes('"source": "runtime-launch-grant"') && + objectProviderImpl.includes("elastos.library.access-decision/v1") && + objectProviderImpl.includes("elastos.library.shared-open/v1") && + objectProviderImpl.includes("runtime-provider-fetch") && + objectProviderImpl.includes('"allowed": false') && + objectProviderImpl.includes('"reason": err.to_string()') && + libraryDialog.includes("Share Grants / Key Release") && + libraryDialog.includes("Recipient Grants / Key Release") && + libraryDialog.includes("contentSecurity?.published_payload") && + libraryMenuSmoke.includes("Share Grants / Key Release") && + libraryMenuSmoke.includes("Recipient Grants / Key Release") && + libraryMenuSmoke.includes("not_required_for_plain_published_content") && + libraryGatewayTests.includes("ready_for_plain_content_fetch") && + libraryGatewayTests.includes("recipient_proof_verified") && + libraryGatewayTests.includes("authority.proof_binding_id") && + libraryGatewayTests.includes("requires Runtime recipient_proof") && + libraryGatewayTests.includes("not authorized"), + "Library recipient sharing must expose explicit access decisions, Runtime recipient-proof state, open contracts, key-release state, and denied-access audit coverage", +); +assert( + libraryApp.includes("function syncPlacesActive()") && + libraryRender.includes('loading="eager" decoding="async"') && + !library.includes("hydrateInlineIcons") && + !library.includes("loadIconMarkup") && + !/function renderAll\(\) \{\s*renderPlaces\(\);/.test(library), + "Library must not rebuild the sidebar or run async icon hydration on every folder render", +); +assert( + libraryNavigation.includes("LIBRARY_HISTORY_SCHEMA") && + libraryNavigation.includes("LIBRARY_HISTORY_GUARD_SCHEMA") && + libraryNavigation.includes("window.history.pushState") && + libraryNavigation.includes("window.history.replaceState") && + libraryEvents.includes('window.addEventListener("popstate"') && + libraryNavigation.includes("window.history.back()") && + libraryNavigation.includes("window.history.forward()"), + "Library must take over browser Back/Forward for Explorer navigation instead of letting native history leave the capsule", +); +assert( + libraryRender.includes("document.createDocumentFragment()") && + libraryRender.includes("elements.content.replaceChildren(fragment)"), + "Library content pane must redraw with one DOM replacement to avoid sluggish view changes", +); +assert( + libraryCss.includes("--explorer-list-columns: 30px minmax(260px, 1fr) 170px 86px 180px;") && + libraryCss.includes(".content[data-view=\"list\"] .item-date {\n grid-column: 3;") && + libraryCss.includes(".explore-table-headers-th--size {\n grid-column: 4;\n padding: 0 10px;\n text-align: right;") && + libraryCss.includes(".explore-table-headers-th--type {\n grid-column: 5;"), + "Library list rows and headers must share one Explorer column grid", +); +assert( + libraryCss.includes("max-height: calc(100vh - 16px);") && + libraryMenu.includes("event.stopPropagation();") && + libraryMenu.includes("contextMenu.style.visibility = \"hidden\"") && + libraryMenu.includes("function normalizedMenuActions(actions)") && + libraryMenu.includes("divider.setAttribute(\"role\", \"separator\")"), + "Library context menu must use one viewport-aware renderer whose action buttons do not get swallowed by document click handling", +); +assert( + libraryMenuSmoke.includes("PASS Library menu smoke") && + libraryMenuSmoke.includes("openBackgroundMenu") && + libraryMenuSmoke.includes("openItemMenu") && + libraryMenuSmoke.includes("Spaces background menu") && + libraryMenuSmoke.includes('label: "Spaces"') && + !libraryMenuSmoke.includes('label: "WebSpaces"') && + libraryMenuSmoke.includes("right-click must not open the Library context menu") && + libraryMenuSmoke.includes("right-click must cancel the browser context menu") && + libraryMenuSmoke.includes("sidebar place menu") && + libraryMenuSmoke.includes("Open in New Window") && + libraryMenuSmoke.includes("sidebar must mark only") && + libraryMenuSmoke.includes("empty state must be centered horizontally") && + libraryMenuSmoke.includes("Published badge must be visible in ${view} view") && + libraryMenuSmoke.includes("New Folder must use inline naming, not window.prompt") && + libraryMenuSmoke.includes("window.history.back()") && + libraryMenuSmoke.includes("window.history.forward()") && + libraryMenuSmoke.includes("Upload must use the raw Library upload transport") && + libraryMenuSmoke.includes("Drop upload must use the raw Library upload transport") && + libraryMenuSmoke.includes("F2 rename must call rename") && + libraryMenuSmoke.includes("Selected-name click must not start rename; rename is explicit through context menu or F2") && + libraryMenuSmoke.includes("Folder Download must use the raw Library download transport") && + libraryMenuSmoke.includes("Download must use the raw Library download transport") && + libraryMenuSmoke.includes("Compress to ZIP must call compress_archive") && + libraryMenuSmoke.includes("Compress Selected to ZIP must call compress_archive") && + libraryMenuSmoke.includes("mounted WebSpace menu") && + libraryMenuSmoke.includes("indexed WebSpace file menu") && + libraryMenuSmoke.includes("/WebSpaces/Google/Drive/Project X/file.pdf") && + libraryMenuSmoke.includes("Indexed WebSpace Download must use the raw Library download transport") && + libraryMenuSmoke.includes("Elastos content WebSpace file menu") && + libraryMenuSmoke.includes("Status must call status") && + libraryMenuSmoke.includes("Repair must call repair") && + libraryMenuSmoke.includes("Share must copy the published elastos:// link") && + libraryMenuSmoke.includes("Double-clicking a selected folder name must open it instead of starting rename") && + libraryMenuSmoke.includes("List-view double-clicking a selected folder name must open it instead of starting rename") && + libraryMenuSmoke.includes("List-view double-clicking a selected file name must open it without later starting rename") && + libraryMenuSmoke.includes("List-view double-clicking the active rename editor must not also open/read the file") && + libraryMenuSmoke.includes("Shift-click must select a visible PC2-style range in list view") && + libraryMenuSmoke.includes("Enter on a selected file must open/read it like PC2") && + libraryMenuSmoke.includes("Shift-F10 selected file menu") && + libraryMenuSmoke.includes("Enter on multiple selected files must open every selected item like PC2") && + libraryMenuSmoke.includes("Double-clicking a selected file name must open it instead of starting rename") && + libraryMenuSmoke.includes("Double-click preview without an installed viewer must call read") && + libraryMenuSmoke.includes("Copy/Paste Into Folder must call copy") && + libraryMenuSmoke.includes("Drag/drop move must call move") && + libraryMenuSmoke.includes("Alt-drag/drop copy must call copy") && + libraryMenuSmoke.includes("Publish must call publish") && + libraryMenuSmoke.includes("Delete Permanently must use in-app confirmation, not window.confirm") && + libraryMenuSmoke.includes("Delete must move the object to Trash"), + "Library must have an operator smoke for context-menu reliability, WebSpaces read-only behavior, and core object journeys", +); +assert( + !libraryEvents.includes("NAME_CLICK_RENAME_DELAY_MS") && + !libraryEvents.includes("cancelPendingNameClickRename") && + !libraryEvents.includes("clickedName") && + !sourceBlock( + libraryEvents, + 'elements.content.addEventListener("click"', + "Library click handler", + ).includes("startRename(") && + libraryEvents.includes("function isNameEditorTarget(target)") && + sourceBlock( + libraryEvents, + 'elements.content.addEventListener("dblclick"', + "Library double-click handler", + ).includes("isNameEditorTarget(event.target)") && + libraryEvents.includes("selectRangeTo(item.dataset.uri") && + libraryEvents.includes('event.key === "Enter"') && + libraryEvents.includes("openSelectedObjects(objects, openObject, showError)") && + libraryEvents.includes("async function openSelectedObjects(objects, openObject, showError)") && + libraryEvents.includes('event.key === "ContextMenu"') && + libraryEvents.includes('event.shiftKey && event.key === "F10"') && + libraryEvents.includes("isMenuOpen(elements)") && + libraryEvents.includes('if (event.key === "F2" && !editable)') && + libraryApp.includes('menuAction("Rename", () => startRename(object))'), + "Library rename must be explicit through context menu/F2; content clicks must never start rename, and PC2-style range selection/keyboard open/context menu must stay wired", +); +assert( + libraryLiveSmoke.includes("verify_public_library_assets") && + libraryLiveSmoke.includes("/apps/library/src/uploads.js") && + libraryLiveSmoke.includes("/apps/library/src/api.js") && + libraryLiveSmoke.includes("/api/provider/object/upload") && + libraryLiveSmoke.includes("/api/provider/object/upload/start") && + libraryLiveSmoke.includes("CHUNKED_UPLOAD_THRESHOLD_BYTES") && + libraryLiveSmoke.includes("http-chunk-session") && + libraryLiveSmoke.includes("/api/provider/object/download/raw") && + libraryLiveSmoke.includes("raw download readback mismatch") && + libraryLiveSmoke.includes("elastos.object.transfer.receipt/v1") && + libraryLiveSmoke.includes("function isNameEditorTarget(target)") && + libraryLiveSmoke.includes("NAME_CLICK_RENAME_DELAY_MS") && + libraryLiveSmoke.includes("clickedName") && + libraryLiveSmoke.includes("cancelPendingNameClickRename") && + libraryLiveSmoke.includes("selectRangeTo(item.dataset.uri") && + libraryLiveSmoke.includes('event.key === "Enter"') && + libraryLiveSmoke.includes("openSelectedObjects(objects, openObject, showError)") && + libraryLiveSmoke.includes('event.key === "ContextMenu"') && + libraryLiveSmoke.includes('event.shiftKey && event.key === "F10"') && + libraryLiveSmoke.includes("signed provider path skipped") && + libraryLiveSmoke.includes("PASS Library live publish/share smoke"), + "Library live smoke must verify public static assets without a signed session and provider publish/share with a signed Home session", +); +assert( + libraryIndex.includes('rel="stylesheet" href="library.css"') && + libraryIndex.includes('type="module" src="src/app.js"') && + !libraryIndex.includes(" -
      -
      -
      -

      ElastOS Archive Manager

      -

      Archive policy

      -

      - This viewer inspects archive object identity and release policy. It does - not extract unsupported archive families or touch raw host storage. -

      -
      -
      -
      -

      Object

      -
      -
      - Object URI - - -
      -
      - Content CID - - -
      -
      - MIME - - -
      -
      - Provider - Runtime-mediated object metadata -
      -
      -
      -
      -

      Archive Policy

      -

      Policy loading

      -
      -
      - Family - - -
      -
      - Status - - -
      + + +
      +
      +
      + +
      +

      Archive

      +

      Archive

      +
      +
      +
      + Loading + +
      +
      + +
      +
      +
      +
      + Files + Check files to extract. Click a row to preview.
      -
      -
      -
      -

      Archive Contents

      - Not loaded +
      + + + Not loaded
      -
      - - - - - - - - +
      +
      + -
      -

      Select safe entries to import them into Library.

      -

      Archive entries are loaded through the Runtime viewer route when this archive family is supported.

      +

      Archive files load when this format is supported.

      -
      -
      -

      Implemented Safely

      -
        -
        -
        -

        Blocked Until Review

        -
          -
          +
          + + -
          + + diff --git a/capsules/home/browser/shell-auth.js b/capsules/home/browser/shell-auth.js index 1d3244ce..d852f592 100644 --- a/capsules/home/browser/shell-auth.js +++ b/capsules/home/browser/shell-auth.js @@ -1,4 +1,4 @@ -import { fetchJson } from "./shell-core.js?v=home-20260603c"; +import { fetchJson } from "./shell-core.js?v=home-20260607d"; const unlockPanel = document.querySelector("#home-unlock"); const unlockCard = document.querySelector(".home-unlock-card"); diff --git a/capsules/home/browser/shell-chrome.js b/capsules/home/browser/shell-chrome.js index f46133ee..b22b7ff8 100644 --- a/capsules/home/browser/shell-chrome.js +++ b/capsules/home/browser/shell-chrome.js @@ -1,4 +1,4 @@ -import { clockNode } from "./shell-core.js?v=home-20260603c"; +import { clockNode } from "./shell-core.js?v=home-20260607d"; export function syncIdentity(_summary) {} diff --git a/capsules/home/browser/shell-core.js b/capsules/home/browser/shell-core.js index 352d96a1..6dc424e9 100644 --- a/capsules/home/browser/shell-core.js +++ b/capsules/home/browser/shell-core.js @@ -25,6 +25,12 @@ export const taskbarItemTemplate = document.querySelector("#taskbar-item-templat export const SHELL_APP_ID = "home"; export const SYSTEM_APP_ID = "system"; +const TARGET_TITLE_OVERRIDES = Object.freeze({ + "archive-manager": "Archive", +}); +const STALE_TARGET_TITLES = Object.freeze({ + "archive-manager": new Set(["Archive Manager"]), +}); const MAX_RECENT_TARGETS = 10; export const ICON_DRAG_THRESHOLD = 6; const DESKTOP_ICON_WIDTH = 92; @@ -126,7 +132,12 @@ export async function fetchJson(url, init) { } export function allVisibleTargets(summary) { - return (summary && Array.isArray(summary.targets)) ? summary.targets : []; + return (summary && Array.isArray(summary.targets)) + ? summary.targets.map((target) => ({ + ...target, + title: canonicalTargetTitle(target?.target, target?.title), + })) + : []; } export function desktopObjects(summary) { @@ -216,7 +227,20 @@ export function shellAppId(summary) { export function targetTitle(summary, targetId) { const target = targetById(summary, targetId); - return target ? target.title : targetId; + return canonicalTargetTitle(targetId, target?.title); +} + +export function canonicalTargetTitle(targetId, title) { + const normalizedTitle = normalizeText(title); + const override = TARGET_TITLE_OVERRIDES[targetId]; + if (!override) { + return normalizedTitle || targetId; + } + const staleTitles = STALE_TARGET_TITLES[targetId]; + if (!normalizedTitle || staleTitles?.has(normalizedTitle)) { + return override; + } + return normalizedTitle; } export function desktopLabelForTarget(summary, targetId) { @@ -410,6 +434,9 @@ function normalizeDesktopLabels(labels, summary) { if (!knownTargets.has(targetId) || nextLabel === "") { continue; } + if (STALE_TARGET_TITLES[targetId]?.has(nextLabel)) { + continue; + } normalized[targetId] = nextLabel; } return normalized; @@ -618,6 +645,9 @@ export function mountGlyph(container, targetId, forcedTone) { } export function glyphTone(targetId) { + if (targetId === "trash" || targetId === "trash-full") { + return "system"; + } if (targetId === SYSTEM_APP_ID) { return "system"; } @@ -646,6 +676,17 @@ export function glyphTone(targetId) { } function glyphSvg(targetId) { + if (targetId === "trash" || targetId === "trash-full") { + const full = targetId === "trash-full"; + return ` + + `; + } if (targetId === SYSTEM_APP_ID) { return `
          - -
          -

          Archive

          -

          Archive

          -
          +

          Archive

          - Loading + +
          @@ -630,7 +623,6 @@

          Archive

          Files - Check files to extract. Click a row to preview.
          @@ -644,7 +636,6 @@

          Archive

          Name Size Modified - State

          Archive files load when this format is supported.

          @@ -658,51 +649,11 @@

          Archive

          Preview

          - No file selected - Click a safe file to preview text or metadata before extraction. + Select a file + Preview appears here.
          - -
          - Safety details -

          Enabled

          -
            -

            Needs review

            -
              -
              - -
              - Technical details -
              -
              - Format - - -
              -
              - Status - - -
              -
              - Content ID - - -
              -
              - Object URI - - -
              -
              - MIME - - -
              -
              - Authority - Runtime and Library services -
              -
              -
              - -

              Waiting for launch context.

              @@ -720,7 +671,7 @@

              Extract

              -

              Select files to extract, or extract all safe files.

              +

              Select files to extract.

              @@ -728,7 +679,7 @@

              Extract