diff --git a/.sqlx/query-03c0a65ec465d513f0c366143ec91c94213a52a6c3cee04df6cb4ed3a01204ac.json b/.sqlx/query-03c0a65ec465d513f0c366143ec91c94213a52a6c3cee04df6cb4ed3a01204ac.json new file mode 100644 index 0000000..56efbd8 --- /dev/null +++ b/.sqlx/query-03c0a65ec465d513f0c366143ec91c94213a52a6c3cee04df6cb4ed3a01204ac.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT canonical_path FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal", + "describe": { + "columns": [ + { + "name": "canonical_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "canonical_path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "03c0a65ec465d513f0c366143ec91c94213a52a6c3cee04df6cb4ed3a01204ac" +} diff --git a/.sqlx/query-1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115.json b/.sqlx/query-ab292cdba60a459dbd285ab15c365b2fc87dafaa3db91cc9521458f1aa81e95b.json similarity index 92% rename from .sqlx/query-1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115.json rename to .sqlx/query-ab292cdba60a459dbd285ab15c365b2fc87dafaa3db91cc9521458f1aa81e95b.json index 1f93dcc..c497510 100644 --- a/.sqlx/query-1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115.json +++ b/.sqlx/query-ab292cdba60a459dbd285ab15c365b2fc87dafaa3db91cc9521458f1aa81e95b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity FROM ldraw_routes WHERE token_digest = ? AND revoked_at IS NULL AND expires_at > ?", + "query": "SELECT principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity FROM ldraw_routes WHERE token_digest = ?", "describe": { "columns": [ { @@ -82,7 +82,7 @@ } ], "parameters": { - "Right": 2 + "Right": 1 }, "nullable": [ true, @@ -94,5 +94,5 @@ false ] }, - "hash": "1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115" + "hash": "ab292cdba60a459dbd285ab15c365b2fc87dafaa3db91cc9521458f1aa81e95b" } diff --git a/.sqlx/query-faef1e77d7ff7c353af437158b38b44e8df0e40685b185c55a1c4b00e6563638.json b/.sqlx/query-faef1e77d7ff7c353af437158b38b44e8df0e40685b185c55a1c4b00e6563638.json new file mode 100644 index 0000000..b3a8aae --- /dev/null +++ b/.sqlx/query-faef1e77d7ff7c353af437158b38b44e8df0e40685b185c55a1c4b00e6563638.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ldraw_routes SET revoked_at = ? WHERE token_digest = ? AND (principal_did = ? OR principal_did IS NULL) AND revoked_at IS NULL AND expires_at > ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "faef1e77d7ff7c353af437158b38b44e8df0e40685b185c55a1c4b00e6563638" +} diff --git a/Cargo.lock b/Cargo.lock index 574e689..ab3d9bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6339,6 +6339,7 @@ dependencies = [ "base64 0.22.1", "bytes", "chrono", + "cid", "console_error_panic_hook", "dioxus", "dioxus-sdk", diff --git a/Cargo.toml b/Cargo.toml index 410fbd3..d59f4d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,6 @@ server = [ "dep:axum-extra", "dep:keyring", - "dep:sha2", "dep:zip", # PM-43: server-side decode of uploaded image dimensions (for #image # aspectRatio) and opaque draft id generation. @@ -125,7 +124,9 @@ axum-extra = { version = "0.10", optional = true, features = ["cookie"] } keyring = { version = "3", optional = true } base64 = { version = "0.22" } -sha2 = { version = "0.10", optional = true } +sha2 = { version = "0.10" } +# CID/multihash verification for each fetched LDraw chunk. +cid = { version = "0.11", default-features = false, features = ["std"] } zip = { version = "2", default-features = false, optional = true } # PM-43: header-only image dimension probe (no full decode) for #image # aspectRatio; ULID for opaque, sortable draft ids. Both server-only. diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index cef71e5..f454705 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -19,6 +19,199 @@ pub use load_state::{ ResourceToken, TransitionOutcome, TransitionResult, }; +/// Current wire protocol version. The envelope is mandatory for every postcard message. +pub const PROTOCOL_VERSION: u16 = 1; + +/// Capability names negotiated before scene-dependent commands are accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Capability { + CurrentFormats, + CompoundLdraw, + Preview, + Cancellation, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtocolHello { + pub min_version: u16, + pub max_version: u16, + pub required: Vec, + pub optional: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NegotiatedProtocol { + pub version: u16, + pub capabilities: Vec, + pub session_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandshakeState { + Created, + Negotiating, + Negotiated(NegotiatedProtocol), + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandshakeError { + InvalidRange, + NoVersionOverlap, + MissingRequiredCapability(Capability), + DuplicateCapability(Capability), + ConflictingCapability(Capability), + OutOfOrder, +} + +impl ProtocolHello { + pub fn validate(&self) -> Result<(), HandshakeError> { + if self.min_version == 0 || self.min_version > self.max_version { + return Err(HandshakeError::InvalidRange); + } + let mut seen = Vec::new(); + for capability in self.required.iter().chain(&self.optional) { + if seen.contains(capability) { + return Err( + if self.required.contains(capability) && self.optional.contains(capability) { + HandshakeError::ConflictingCapability(*capability) + } else { + HandshakeError::DuplicateCapability(*capability) + }, + ); + } + seen.push(*capability); + } + Ok(()) + } +} + +pub fn negotiate( + local: &ProtocolHello, + remote: &ProtocolHello, + session_id: u64, +) -> Result { + local.validate()?; + remote.validate()?; + let version = local.max_version.min(remote.max_version); + if version < local.min_version || version < remote.min_version { + return Err(HandshakeError::NoVersionOverlap); + } + for capability in &local.required { + if !remote.required.contains(capability) && !remote.optional.contains(capability) { + return Err(HandshakeError::MissingRequiredCapability(*capability)); + } + } + for capability in &remote.required { + if !local.required.contains(capability) && !local.optional.contains(capability) { + return Err(HandshakeError::MissingRequiredCapability(*capability)); + } + } + let capabilities = local + .required + .iter() + .chain(local.optional.iter()) + .copied() + .filter(|capability| { + remote.required.contains(capability) || remote.optional.contains(capability) + }) + .collect(); + Ok(NegotiatedProtocol { + version, + capabilities, + session_id, + }) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CommandPayload { + Hello(ProtocolHello), + Accept(NegotiatedProtocol), + Command(RendererCommand), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum EventPayload { + Hello(ProtocolHello), + Negotiated(NegotiatedProtocol), + Failed(String), + Event(RendererEvent), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommandEnvelope { + pub version: u16, + pub session_id: u64, + pub payload: CommandPayload, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EventEnvelope { + pub version: u16, + pub session_id: u64, + pub payload: EventPayload, +} + +impl HandshakeState { + pub fn accept_command( + &mut self, + envelope: &CommandEnvelope, + ) -> Result, HandshakeError> { + if envelope.version != PROTOCOL_VERSION { + *self = Self::Failed; + return Err(HandshakeError::NoVersionOverlap); + } + match (&*self, &envelope.payload) { + (Self::Created, CommandPayload::Hello(_)) => { + *self = Self::Negotiating; + Ok(None) + } + (Self::Negotiating, CommandPayload::Accept(negotiated)) => { + *self = Self::Negotiated(negotiated.clone()); + Ok(None) + } + (Self::Negotiated(session), CommandPayload::Command(command)) + if session.session_id == envelope.session_id => + { + Ok(Some(command.clone())) + } + _ => { + *self = Self::Failed; + Err(HandshakeError::OutOfOrder) + } + } + } + + pub fn accept_event( + &mut self, + envelope: &EventEnvelope, + ) -> Result, HandshakeError> { + if envelope.version != PROTOCOL_VERSION { + *self = Self::Failed; + return Err(HandshakeError::NoVersionOverlap); + } + match (&*self, &envelope.payload) { + (Self::Created, EventPayload::Hello(_)) => { + *self = Self::Negotiating; + Ok(None) + } + (Self::Negotiating, EventPayload::Negotiated(negotiated)) => { + *self = Self::Negotiated(negotiated.clone()); + Ok(None) + } + (Self::Negotiated(session), EventPayload::Event(event)) + if session.session_id == envelope.session_id => + { + Ok(Some(event.clone())) + } + _ => { + *self = Self::Failed; + Err(HandshakeError::OutOfOrder) + } + } + } +} + // --------------------------------------------------------------------------- // Shared mesh-format types (relocated from polymodel-mesh::contract) // --------------------------------------------------------------------------- @@ -74,6 +267,10 @@ pub enum LengthUnit { Thou, Foot, Unknown, + /// LDraw units (LDU): exactly 1/64 inch, or 0.396875 mm. + /// + /// This variant is appended to preserve every existing postcard discriminant. + LdrawUnit, } /// Unit handling for a loaded model. @@ -181,6 +378,13 @@ pub enum RendererCommand { expected_size: Option, expected_digest: Option>, }, + /// Begin an authenticated LDraw load from a complete, server-issued plan. + /// The worker never derives companion URLs from filenames. + StartCompoundLoad { + namespace: RendererNamespace, + identity: LoadIdentity, + plan: CompoundLoadPlan, + }, /// Cancel the current identity. Late results are rejected by the reducer. Cancel { namespace: RendererNamespace, @@ -219,6 +423,45 @@ pub enum RendererCommand { DebugCrash, } +/// One opaque, same-origin resource route supplied by the authenticated appview. +/// The worker never derives routes from filenames or contacts a publisher directly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerFetchDescriptor { + pub key: String, + pub route: String, + pub path: String, + pub root: String, + pub byte_length: u64, + pub sha256: Vec, + pub mime_type: String, + pub target_cids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompoundLoadPlan { + pub version: u16, + pub primary: String, + pub descriptors: Vec, +} + +impl WorkerFetchDescriptor { + pub fn validate(&self) -> Result<(), &'static str> { + if self.key.is_empty() || self.key != self.path || self.route.is_empty() { + return Err("descriptor identity is incomplete"); + } + if !self.route.starts_with('/') + || self.route.starts_with("//") + || self.route.contains("://") + { + return Err("descriptor route is not a relative same-origin path"); + } + if self.sha256.len() != 32 || self.mime_type.is_empty() || self.target_cids.is_empty() { + return Err("descriptor integrity metadata is incomplete"); + } + Ok(()) + } +} + /// Keyboard modifier flags accompanying a pointer state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct KeyModifiers { @@ -283,23 +526,55 @@ pub enum RendererEvent { // Postcard ser/deser helpers // --------------------------------------------------------------------------- -/// Serialize a [`RendererCommand`] to postcard bytes. +/// Serialize a command in the mandatory versioned envelope. pub fn serialize_command(cmd: &RendererCommand) -> Result, postcard::Error> { - postcard::to_allocvec(cmd) + postcard::to_allocvec(&CommandEnvelope { + version: PROTOCOL_VERSION, + session_id: 0, + payload: CommandPayload::Command(cmd.clone()), + }) } -/// Serialize a [`RendererEvent`] to postcard bytes. +/// Serialize an event in the mandatory versioned envelope. pub fn serialize_event(evt: &RendererEvent) -> Result, postcard::Error> { - postcard::to_allocvec(evt) + postcard::to_allocvec(&EventEnvelope { + version: PROTOCOL_VERSION, + session_id: 0, + payload: EventPayload::Event(evt.clone()), + }) } -/// Deserialize a [`RendererCommand`] from postcard bytes. +/// Deserialize a command only from the mandatory versioned envelope. pub fn deserialize_command(bytes: &[u8]) -> Result { - postcard::from_bytes(bytes) + let envelope: CommandEnvelope = postcard::from_bytes(bytes)?; + match envelope.payload { + CommandPayload::Command(command) if envelope.version == PROTOCOL_VERSION => Ok(command), + _ => Err(postcard::Error::DeserializeUnexpectedEnd), + } } -/// Deserialize a [`RendererEvent`] from postcard bytes. +/// Deserialize an event only from the mandatory versioned envelope. pub fn deserialize_event(bytes: &[u8]) -> Result { + let envelope: EventEnvelope = postcard::from_bytes(bytes)?; + match envelope.payload { + EventPayload::Event(event) if envelope.version == PROTOCOL_VERSION => Ok(event), + _ => Err(postcard::Error::DeserializeUnexpectedEnd), + } +} + +pub fn serialize_command_envelope(envelope: &CommandEnvelope) -> Result, postcard::Error> { + postcard::to_allocvec(envelope) +} + +pub fn serialize_event_envelope(envelope: &EventEnvelope) -> Result, postcard::Error> { + postcard::to_allocvec(envelope) +} + +pub fn deserialize_command_envelope(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes) +} + +pub fn deserialize_event_envelope(bytes: &[u8]) -> Result { postcard::from_bytes(bytes) } @@ -522,6 +797,65 @@ mod tests { } } + #[test] + fn ldraw_unit_is_appended_and_exact_conversion_is_one_boundary() { + assert_eq!(LengthUnit::Unknown as u8 + 1, LengthUnit::LdrawUnit as u8); + assert_eq!(1000.0_f64 * 0.396875, 396.875); + } + + #[test] + fn handshake_rejects_pre_negotiation_and_stale_sessions() { + let hello = ProtocolHello { + min_version: 1, + max_version: 1, + required: vec![Capability::CurrentFormats], + optional: vec![Capability::Preview], + }; + let negotiated = negotiate(&hello, &hello, 44).unwrap(); + let mut state = HandshakeState::Created; + let command = CommandEnvelope { + version: 1, + session_id: 44, + payload: CommandPayload::Command(RendererCommand::Dispose), + }; + assert_eq!( + state.accept_command(&command), + Err(HandshakeError::OutOfOrder) + ); + let mut state = HandshakeState::Negotiated(negotiated); + let stale = CommandEnvelope { + session_id: 45, + ..command + }; + assert_eq!( + state.accept_command(&stale), + Err(HandshakeError::OutOfOrder) + ); + } + + #[test] + fn old_bare_postcard_messages_are_not_accepted() { + let bare = postcard::to_allocvec(&RendererCommand::Dispose).unwrap(); + assert!(deserialize_command(&bare).is_err()); + } + + #[test] + fn descriptor_rejects_absolute_and_incomplete_routes() { + let mut descriptor = WorkerFetchDescriptor { + key: "models/main.ldr".into(), + route: "/app/route/opaque".into(), + path: "models/main.ldr".into(), + root: "models".into(), + byte_length: 1, + sha256: vec![0; 32], + mime_type: "text/plain".into(), + target_cids: vec!["cid".into()], + }; + assert!(descriptor.validate().is_ok()); + descriptor.route = "https://publisher.example/file".into(); + assert!(descriptor.validate().is_err()); + } + #[test] fn deserialize_garbage_returns_error() { assert!(deserialize_command(&[0xFF, 0xFE, 0xFD]).is_err()); diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 9e016b9..4da0570 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -5,6 +5,7 @@ //! and the postcard message protocol bridge to the Dioxus main thread. use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; use std::sync::Arc; @@ -923,7 +924,6 @@ fn handle_load_mesh( ) { return; } - st.load_epoch = identity.load_epoch; if let Some(renderer) = st.renderer.as_mut() { renderer.model = None; } @@ -940,12 +940,10 @@ fn handle_load_mesh( } let state_clone = state.clone(); - let load_epoch = identity.load_epoch; - let scene_generation = state.borrow().scene_generation; spawn_local(async move { let load_reservations = { let st = state_clone.borrow(); - let owner = owner(load_epoch, scene_generation); + let owner = owner(identity.load_epoch, st.scene_generation); st.ledger.composite( owner, &[ @@ -967,28 +965,13 @@ fn handle_load_mesh( ) }; let Ok(_load_reservations) = load_reservations else { - if let Ok(mut st) = state_clone.try_borrow_mut() { - let error = LoadError { - code: LoadErrorCode::Renderer, - detail: "renderer admission rejected mesh load".into(), - }; - let result = st.load_reducer.reduce(LoadEvent::LoadFailed { + if let Ok(st) = state_clone.try_borrow() { + st.post_event(&RendererEvent::LoadFailed { namespace, identity, - error: error.clone(), - candidate: None, + code: LoadErrorCode::Fetch, + detail: "renderer admission rejected mesh load".into(), }); - if matches!( - result.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { - st.post_event(&RendererEvent::LoadFailed { - namespace, - identity, - code: error.code, - detail: error.detail, - }); - } } return; }; @@ -1023,58 +1006,50 @@ fn handle_load_mesh( identity, }); let (pw, ph) = (st.pixel_width, st.pixel_height); - let context = match st - .renderer - .as_ref() - .map(|renderer| renderer.context.clone()) - { - Some(context) => context, - None => { - let error = LoadError { - code: LoadErrorCode::Renderer, - detail: "renderer context unavailable".into(), - }; - st.load_reducer.reduce(LoadEvent::InstallFailed { - namespace, - identity, - error: error.clone(), - candidate: None, - }); - st.post_event(&RendererEvent::LoadFailed { - namespace, - identity, - code: error.code, - detail: error.detail, - }); - return; - } - }; st.scene_generation = st.scene_generation.saturating_add(1); let scene_owner = owner(st.load_epoch, st.scene_generation); + let Some(renderer) = st.renderer.as_ref() else { + let error = LoadError { + code: LoadErrorCode::Renderer, + detail: "renderer context unavailable".into(), + }; + st.load_reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error: error.clone(), + candidate: None, + }); + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: error.code, + detail: error.detail, + }); + return; + }; let (scene_reservations, texture_reservations) = match admit_scene(&st.ledger, scene_owner, &model) { Ok(reservations) => reservations, - Err(detail) => { - let error = LoadError { - code: LoadErrorCode::Install, - detail, - }; + Err(message) => { st.load_reducer.reduce(LoadEvent::InstallFailed { namespace, identity, - error: error.clone(), + error: LoadError { + code: LoadErrorCode::Install, + detail: message.clone(), + }, candidate: None, }); st.post_event(&RendererEvent::LoadFailed { namespace, identity, - code: error.code, - detail: error.detail, + code: LoadErrorCode::Install, + detail: message, }); return; } }; - let candidate = match build_scene(&context, &model, pw, ph) { + let candidate = match build_scene(&renderer.context, &model, pw, ph) { Ok(candidate) => candidate, Err(detail) => { drop(scene_reservations); @@ -1219,6 +1194,280 @@ async fn load_mesh_async( // RenderPreviewImage (async) // --------------------------------------------------------------------------- +fn handle_compound_load( + state: &Rc>, + namespace: RendererNamespace, + identity: LoadIdentity, + plan: polymodel_renderer_protocol::CompoundLoadPlan, +) { + let state_clone = state.clone(); + spawn_local(async move { + let result = load_compound_mesh(&state_clone, identity, plan).await; + finish_loaded_mesh(&state_clone, namespace, identity, result); + }); +} + +async fn load_compound_mesh( + state: &Rc>, + identity: LoadIdentity, + plan: polymodel_renderer_protocol::CompoundLoadPlan, +) -> Result { + if plan.version != polymodel_renderer_protocol::PROTOCOL_VERSION { + return Err("compound plan protocol version mismatch".into()); + } + if plan.descriptors.is_empty() { + return Err("compound plan has no descriptors".into()); + } + let mut descriptors = HashMap::with_capacity(plan.descriptors.len()); + for descriptor in plan.descriptors { + descriptor.validate().map_err(str::to_owned)?; + if descriptors + .insert(descriptor.key.clone(), descriptor) + .is_some() + { + return Err("compound plan contains duplicate descriptor keys".into()); + } + } + let primary = descriptors + .get(&plan.primary) + .ok_or_else(|| "compound plan primary is missing".to_string())?; + let owner = { + let st = state.borrow(); + owner(identity.load_epoch, st.scene_generation) + }; + let total_bytes = descriptors.values().try_fold(0_u64, |total, descriptor| { + total + .checked_add(descriptor.byte_length) + .ok_or("compound plan byte bound overflow") + })?; + if total_bytes > MAX_UNKNOWN_FETCH_BYTES { + return Err("compound plan exceeds fetch bound".into()); + } + let reservation = { + let st = state.borrow(); + st.ledger + .composite( + owner, + &[ + (ResourceClass::NetworkInFlight, total_bytes), + (ResourceClass::RetainedFetchedBytes, total_bytes), + (ResourceClass::DecodedSourceData, total_bytes), + (ResourceClass::WasmPages, WASM_FIXED_BYTES), + ], + ) + .map_err(|error| format!("compound load admission: {error}"))? + }; + let mut bytes_by_key = HashMap::with_capacity(descriptors.len()); + for (key, descriptor) in &descriptors { + let bytes = fetch_descriptor_bytes( + &descriptor.route, + descriptor.byte_length, + &descriptor.sha256, + ) + .await?; + bytes_by_key.insert(key.clone(), bytes); + } + let primary_bytes = bytes_by_key + .remove(&primary.key) + .ok_or_else(|| "compound plan primary bytes are missing".to_string())?; + let mut companions = Vec::with_capacity(bytes_by_key.len()); + for (key, bytes) in bytes_by_key { + companions.push((key, bytes)); + } + let format = mesh_format_for_descriptor(primary); + let source = polymodel_mesh::parser::MeshSource { + format, + primary: &primary_bytes, + resources: polymodel_mesh::parser::MeshResources::new(&companions), + }; + let model = polymodel_mesh::parser::load_mesh(&source).map_err(|error| error.to_string()); + drop(reservation); + model +} + +async fn fetch_descriptor_bytes( + route: &str, + expected_length: u64, + expected_sha256: &[u8], +) -> Result, String> { + let bytes = fetch_bytes(route).await?; + let expected_size = i64::try_from(expected_length) + .map_err(|_| "compound descriptor byte length is too large".to_string())?; + polymodel_renderer_protocol::verify_part_file(&bytes, expected_size, Some(expected_sha256)) + .map_err(|error| format!("compound descriptor integrity check failed: {error}"))?; + Ok(bytes) +} + +fn mesh_format_for_descriptor( + descriptor: &polymodel_renderer_protocol::WorkerFetchDescriptor, +) -> MeshFormat { + if descriptor.mime_type.contains("stl") || descriptor.key.ends_with(".dat") { + MeshFormat::Stl + } else if descriptor.mime_type.contains("obj") || descriptor.key.ends_with(".obj") { + MeshFormat::Obj + } else if descriptor.mime_type.contains("3mf") || descriptor.key.ends_with(".3mf") { + MeshFormat::Threemf + } else { + MeshFormat::Gltf + } +} + +fn finish_loaded_mesh( + state: &Rc>, + namespace: RendererNamespace, + identity: LoadIdentity, + result: Result, +) { + let mut st = match state.try_borrow_mut() { + Ok(st) => st, + Err(_) => return, + }; + if st.disposed { + return; + } + let model = match result { + Ok(model) => model, + Err(detail) => { + let error = LoadError { + code: LoadErrorCode::Fetch, + detail: detail.clone(), + }; + if matches!( + st.load_reducer + .reduce(LoadEvent::LoadFailed { + namespace, + identity, + error, + candidate: None + }) + .outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Fetch, + detail, + }); + } + return; + } + }; + let parsed = st.load_reducer.reduce(LoadEvent::LoadSucceeded { + namespace, + identity, + candidate: Some(ResourceToken(identity.scene_generation as u64)), + }); + if !matches!( + parsed.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + st.post_event(&RendererEvent::LoadSucceeded { + namespace, + identity, + }); + let (pw, ph) = (st.pixel_width, st.pixel_height); + st.scene_generation = st.scene_generation.saturating_add(1); + let scene_owner = owner(st.load_epoch, st.scene_generation); + let Some(renderer) = st.renderer.as_ref() else { + let error = LoadError { + code: LoadErrorCode::Renderer, + detail: "renderer context unavailable".into(), + }; + st.load_reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error: error.clone(), + candidate: None, + }); + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: error.code, + detail: error.detail, + }); + return; + }; + let (scene_reservations, texture_reservations) = + match admit_scene(&st.ledger, scene_owner, &model) { + Ok(reservations) => reservations, + Err(message) => { + st.load_reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error: LoadError { + code: LoadErrorCode::Install, + detail: message.clone(), + }, + candidate: None, + }); + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail: message, + }); + return; + } + }; + let candidate = match build_scene(&renderer.context, &model, pw, ph) { + Ok(candidate) => candidate, + Err(detail) => { + drop(scene_reservations); + drop(texture_reservations); + st.load_reducer.reduce(LoadEvent::InstallFailed { + namespace, + identity, + error: LoadError { + code: LoadErrorCode::Install, + detail: detail.clone(), + }, + candidate: None, + }); + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Install, + detail, + }); + return; + } + }; + st.pending_scene = Some(candidate); + let committed = st.load_reducer.reduce(LoadEvent::InstallSucceeded { + namespace, + identity, + scene: Some(ResourceToken(identity.scene_generation as u64 + 1)), + }); + if !matches!( + committed.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.pending_scene = None; + return; + } + if let Some((render_model, camera, control)) = st.pending_scene.take() { + if let Some(renderer) = st.renderer.as_mut() { + renderer.model = Some(render_model); + renderer.camera = camera; + renderer.control = control; + renderer.scene_reservations = Some(scene_reservations); + renderer.texture_reservations = texture_reservations; + } + } + st.needs_render = true; + st.post_event(&RendererEvent::InstallSucceeded { + namespace, + identity, + stats: MeshStats { + vertices: model.vertex_count(), + triangles: model.triangle_count(), + }, + }); +} + fn handle_render_preview_image( state: &Rc>, request_id: u32, @@ -1432,6 +1681,45 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { expected_digest, ); } + RendererCommand::StartCompoundLoad { + namespace, + identity, + plan, + } => { + if st.disposed || namespace != RendererNamespace::Interactive { + return; + } + let previous_identity = st.load_reducer.current_identity(); + let result = st.load_reducer.reduce(LoadEvent::StartLoad { + namespace, + identity, + }); + for _token in result.disposed { + st.pending_scene = None; + st.renderer.as_mut().map(|renderer| renderer.model = None); + } + if !matches!( + result.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + return; + } + if let Some(renderer) = st.renderer.as_mut() { + renderer.model = None; + } + st.pending_scene = None; + st.needs_render = true; + if let Some(previous_identity) = previous_identity + && identity > previous_identity + { + st.post_event(&RendererEvent::LoadCancelled { + namespace, + identity: previous_identity, + }); + } + drop(st); + handle_compound_load(state, namespace, identity, plan); + } RendererCommand::Cancel { namespace, identity, diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index ffffa0f..a2f3c5c 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -14,13 +14,26 @@ use jacquard_common::xrpc::XrpcExt; use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +fn verify_chunk_cid(cid_text: &str, bytes: &[u8]) -> Result<(), &'static str> { + let cid = cid::Cid::try_from(cid_text).map_err(|_| "malformed CID")?; + if cid.hash().code() != 0x12 { + return Err("unsupported CID multihash"); + } + let digest = Sha256::digest(bytes); + if cid.hash().digest() != digest.as_slice() { + return Err("CID multihash mismatch"); + } + Ok(()) +} use super::error::{AppResult, db, internal, invalid_request, not_found, unauthorized}; use super::state::AppState; use super::writes::{ExtractSession, authenticated_did}; use crate::ldraw::resolve::{SqliteResolver, serialize_source_identity}; use crate::ldraw::verification::{VerificationRecord, VerificationState, claim, complete}; -use crate::ldraw::{CanonicalPath, LdrawResourceResolver}; +use crate::ldraw::{CanonicalPath, LdrawResourceResolver, PathDiagnostic}; #[derive(Debug, Deserialize)] pub(super) struct MintRequest { @@ -40,6 +53,19 @@ pub(super) struct FetchQuery { pub token: String, } +#[derive(Debug, Deserialize)] +pub(super) struct CompoundRequest { + pub project: AtUri, + pub path: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct CompoundResponse { + pub version: u16, + pub primary: String, + pub descriptors: Vec, +} + pub(super) async fn mint( State(state): State, ExtractOAuthSession(session): ExtractSession, @@ -54,47 +80,135 @@ pub(super) async fn mint( .await .map_err(resolve_error)?; - let mut token_bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut token_bytes); - let digest = Sha256::digest(token_bytes); - let digest_bytes: &[u8] = digest.as_ref(); - let now = Utc::now().timestamp_nanos_opt().unwrap_or_default(); - let expires_at = now + 300_000_000_000_i64; - db(sqlx::query!( - "INSERT INTO ldraw_routes (token_digest, principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity, expires_at, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", - digest_bytes, - principal.as_ref(), - request.project.as_ref(), - resource.resource.uri.as_ref(), - serialize_source_identity(&resource.cache_key.source), - resource.root.as_str(), - resource.path.as_str(), - serde_json::to_string(&resource.target.blob_cids) - .map_err(|e| internal(format!("LDraw target identity serialization failed: {e}")))?, - expires_at, - now, - ).execute(&state.pool).await)?; - + let descriptor = issue_descriptor(&state, &principal, &request.project, resource).await?; + let token = descriptor + .route + .split("token=") + .nth(1) + .ok_or_else(|| internal("issued LDraw route missing token"))?; + let expires_at = chrono::DateTime::from_timestamp_nanos(now_nanos() + 300_000_000_000_i64); Ok(Json(MintResponse { - token: encode_token(&token_bytes), - expires_at: chrono::DateTime::from_timestamp_nanos(expires_at).to_rfc3339(), + token: token.to_owned(), + expires_at: expires_at.to_rfc3339(), + })) +} + +pub(super) async fn compound( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Json(request): Json, +) -> AppResult> { + let agent = Agent::from(session); + let principal = authenticated_did(&agent).await?; + let scoped = crate::ldraw::LdrawScopedPath::parse(&request.path) + .map_err(|diagnostic| invalid_request(format!("invalid LDraw path: {diagnostic:?}")))?; + let resolver = SqliteResolver::new(state.pool.clone()); + let primary = ensure_verified( + &state, + &resolver, + Some(&principal), + &request.project, + &scoped.path, + ) + .await + .map_err(resolve_error)?; + if !scoped.contains(&primary.path, primary.root) { + return Err(not_found()); + } + let mut resources = Vec::new(); + let mut seen_paths = HashSet::new(); + let mut pending = vec![scoped.path.clone()]; + while let Some(path) = pending.pop() { + let resource = + ensure_verified(&state, &resolver, Some(&principal), &request.project, &path) + .await + .map_err(resolve_error)?; + if !scoped.contains(&resource.path, resource.root) { + return Err(not_found()); + } + if !seen_paths.insert((resource.root, resource.path.clone())) { + continue; + } + if resources.len() >= 16_384 { + return Err(invalid_request("compound LDraw descriptor bound exceeded")); + } + if resource.root == crate::ldraw::RootId::Mpd { + let bytes = fetch_verified_bytes(&state, &resource).await?; + let paths = collect_mpd_type1_paths(&bytes).map_err(|_| not_found())?; + pending.extend(paths.into_iter().rev()); + } + let manifest_rows = db(sqlx::query!( + "SELECT canonical_path FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal", + resource.resource.uri.as_ref(), + ).fetch_all(&state.pool).await)?; + for row in manifest_rows.into_iter().rev() { + let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; + pending.push(path); + } + resources.push(resource); + } + let mut tx = state + .pool + .begin() + .await + .map_err(|error| internal(format!("LDraw route transaction begin failed: {error}")))?; + let mut descriptors = Vec::with_capacity(resources.len()); + for resource in resources { + descriptors.push( + issue_descriptor_in_transaction(&mut tx, &principal, &request.project, resource) + .await?, + ); + } + tx.commit() + .await + .map_err(|error| internal(format!("LDraw route transaction commit failed: {error}")))?; + let primary = descriptors + .first() + .map(|descriptor| descriptor.key.clone()) + .ok_or_else(not_found)?; + Ok(Json(CompoundResponse { + version: polymodel_renderer_protocol::PROTOCOL_VERSION, + primary, + descriptors, })) } pub(super) async fn fetch( State(state): State, + ExtractOAuthSession(session): ExtractSession, Query(query): Query, ) -> AppResult { + let agent = Agent::from(session); + let caller = authenticated_did(&agent).await?; let token = decode_token(&query.token).ok_or_else(|| invalid_request("invalid LDraw route token"))?; let digest = Sha256::digest(token); let digest_bytes: &[u8] = digest.as_ref(); let now = Utc::now().timestamp_nanos_opt().unwrap_or_default(); - let row = db(sqlx::query!( - "SELECT principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity FROM ldraw_routes WHERE token_digest = ? AND revoked_at IS NULL AND expires_at > ?", + // Route tokens are single-use capabilities. Claiming by digest, principal, expiry, + // and an unconsumed marker in one UPDATE closes the replay race before any bytes + // are resolved or fetched. A failed claim intentionally looks like not-found. + let claimed = db(sqlx::query!( + "UPDATE ldraw_routes SET revoked_at = ? WHERE token_digest = ? AND (principal_did = ? OR principal_did IS NULL) AND revoked_at IS NULL AND expires_at > ?", + now, digest_bytes, + caller.as_ref(), now, + ).execute(&state.pool).await)?; + if claimed.rows_affected() != 1 { + return Err(not_found()); + } + let row = db(sqlx::query!( + "SELECT principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity FROM ldraw_routes WHERE token_digest = ?", + digest_bytes, ).fetch_optional(&state.pool).await)?.ok_or_else(not_found)?; + if row + .principal_did + .as_deref() + .is_some_and(|principal| principal != caller.as_ref()) + { + return Err(not_found()); + } let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; let project = AtUri::new_owned(&row.project_uri).map_err(|_| not_found())?; let principal = row @@ -125,6 +239,70 @@ pub(super) async fn fetch( .map_err(|error| internal(format!("LDraw response build failed: {error}"))) } +async fn issue_descriptor( + state: &AppState, + principal: &jacquard_common::types::string::Did, + project: &AtUri, + resource: crate::ldraw::resolve::ResolvedResource, +) -> AppResult { + let mut tx = state + .pool + .begin() + .await + .map_err(|error| internal(format!("LDraw route transaction begin failed: {error}")))?; + let descriptor = issue_descriptor_in_transaction(&mut tx, principal, project, resource).await?; + tx.commit() + .await + .map_err(|error| internal(format!("LDraw route transaction commit failed: {error}")))?; + Ok(descriptor) +} + +async fn issue_descriptor_in_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + principal: &jacquard_common::types::string::Did, + project: &AtUri, + resource: crate::ldraw::resolve::ResolvedResource, +) -> AppResult { + let mut token_bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut token_bytes); + let digest = Sha256::digest(token_bytes); + let digest_bytes: &[u8] = digest.as_ref(); + let now = now_nanos(); + let expires_at = now + 300_000_000_000_i64; + let target_identity = serde_json::to_string(&resource.target.blob_cids) + .map_err(|e| internal(format!("LDraw target identity serialization failed: {e}")))?; + sqlx::query!( + "INSERT INTO ldraw_routes (token_digest, principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity, expires_at, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", + digest_bytes, + principal.as_ref(), + project.as_ref(), + resource.resource.uri.as_ref(), + serialize_source_identity(&resource.cache_key.source), + resource.root.as_str(), + resource.path.as_str(), + target_identity, + expires_at, + now, + ) + .execute(&mut **tx) + .await + .map_err(|error| internal(format!("LDraw route insertion failed: {error}")))?; + let token = encode_token(&token_bytes); + Ok(polymodel_renderer_protocol::WorkerFetchDescriptor { + key: resource.path.to_string(), + route: format!("/xrpc/space.polymodel.library.getLdrawResource?token={token}"), + path: resource.path.to_string(), + root: resource.root.as_str().to_owned(), + byte_length: resource + .byte_length + .try_into() + .map_err(|_| internal("invalid LDraw byte length"))?, + sha256: resource.sha256, + mime_type: resource.mime_type, + target_cids: resource.target.blob_cids, + }) +} + async fn ensure_verified( state: &AppState, resolver: &SqliteResolver, @@ -293,6 +471,8 @@ async fn fetch_verified_bytes( }) .await .map_err(|e| internal(format!("blob stream failed: {e}")))?; + verify_chunk_cid(value, &chunked) + .map_err(|error| internal(format!("LDraw chunk CID verification failed: {error}")))?; bytes.extend_from_slice(&chunked); } if bytes.len() as i64 != resource.byte_length { @@ -326,6 +506,29 @@ fn resolve_error(error: crate::ldraw::ResolveError) -> super::error::AppError { } } +fn collect_mpd_type1_paths(bytes: &[u8]) -> Result, PathDiagnostic> { + let text = std::str::from_utf8(bytes).map_err(|_| PathDiagnostic::ControlOrColon)?; + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + for line in text.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + if fields.first() != Some(&"1") { + continue; + } + let Some(raw_path) = fields.get(14) else { + return Err(PathDiagnostic::Segment); + }; + let path = CanonicalPath::parse(raw_path)?; + if seen.insert(path.clone()) { + paths.push(path); + } + if paths.len() >= 16_384 { + return Err(PathDiagnostic::Segment); + } + } + Ok(paths) +} + fn encode_token(token: &[u8; 32]) -> String { token.iter().map(|byte| format!("{byte:02x}")).collect() } @@ -342,7 +545,27 @@ fn decode_token(value: &str) -> Option> { #[cfg(test)] mod tests { - use super::{decode_token, encode_token}; + use super::{collect_mpd_type1_paths, decode_token, encode_token, verify_chunk_cid}; + use crate::ldraw::CanonicalPath; + use sha2::Digest; + use sha2::Sha256; + + #[test] + fn chunk_cid_verification_covers_valid_substitution_malformed_and_codec() { + let bytes = b"ldraw chunk"; + let digest = cid::multihash::Multihash::<64>::wrap(0x12, &Sha256::digest(bytes)).unwrap(); + let valid = cid::Cid::new_v1(0x55, digest).to_string(); + assert!(verify_chunk_cid(&valid, bytes).is_ok()); + assert!(verify_chunk_cid("not-a-cid", bytes).is_err()); + assert!(verify_chunk_cid(&valid, b"substituted").is_err()); + let unsupported_hash = cid::Cid::new_v1( + 0x55, + cid::multihash::Multihash::<64>::wrap(0x13, &Sha256::digest(bytes)).unwrap(), + ) + .to_string(); + assert!(verify_chunk_cid(&unsupported_hash, bytes).is_err()); + assert!(verify_chunk_cid("bafkrei", bytes).is_err()); + } #[test] fn route_tokens_are_fixed_width_hex() { @@ -351,4 +574,20 @@ mod tests { assert_eq!(decode_token(&encoded), Some(token.to_vec())); assert!(decode_token("short").is_none()); } + + #[test] + fn mpd_type1_paths_are_first_seen_deduplicated_and_ignore_other_lines() { + let bytes = b"0 FILE main.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/3001.dat\n2 line\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/3001.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/3002.dat\n"; + let paths = collect_mpd_type1_paths(bytes).expect("valid type-1 references"); + assert_eq!( + paths.iter().map(CanonicalPath::as_str).collect::>(), + ["parts/3001.dat", "parts/3002.dat"] + ); + } + + #[test] + fn mpd_type1_paths_reject_missing_filename() { + let error = collect_mpd_type1_paths(b"1 16 0 0 0 1 0 0 0 1 0 0 0").unwrap_err(); + assert_eq!(error, crate::ldraw::PathDiagnostic::Segment); + } } diff --git a/src/appview/mod.rs b/src/appview/mod.rs index ce20c7c..79f8140 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -132,6 +132,10 @@ pub fn router() -> Router { "/xrpc/space.polymodel.library.getLdrawResource", axum::routing::get(ldraw::fetch), ) + .route( + "/app/ldraw/compound-resources", + axum::routing::post(ldraw::compound), + ) // PM-43 app-internal draft store + image upload. Not federated lexicons, // so these use a plain `/app/*` namespace (cookie-authenticated, // same-origin) rather than `/xrpc/space.polymodel.*`. diff --git a/src/ldraw/mod.rs b/src/ldraw/mod.rs index cc9a429..638e20d 100644 --- a/src/ldraw/mod.rs +++ b/src/ldraw/mod.rs @@ -4,5 +4,5 @@ pub mod paths; pub mod resolve; pub mod verification; -pub use paths::{CanonicalPath, PathDiagnostic, RootId}; +pub use paths::{CanonicalPath, LdrawScopedPath, LdrawSubroot, PathDiagnostic, RootId}; pub use resolve::{CacheKey, LdrawResourceResolver, ResolveError, ResolvedResource}; diff --git a/src/ldraw/paths.rs b/src/ldraw/paths.rs index f051b10..0a86992 100644 --- a/src/ldraw/paths.rs +++ b/src/ldraw/paths.rs @@ -2,6 +2,99 @@ use std::fmt; use unicode_normalization::UnicodeNormalization; +/// Standard LDraw library subroots. Prefixes are policy, not alternate roots. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum LdrawSubroot { + /// Models and unofficial parts under `s/`. + Subparts, + /// Primitives under `8/`. + Primitives, + /// Hi-resolution primitives under `48/`. + HiResPrimitives, +} + +impl LdrawSubroot { + pub fn as_str(self) -> &'static str { + match self { + Self::Subparts => "s", + Self::Primitives => "8", + Self::HiResPrimitives => "48", + } + } +} + +/// A validated prefix and path pair for a compound LDraw lookup. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct LdrawScopedPath { + pub subroot: Option, + pub path: CanonicalPath, +} + +impl LdrawScopedPath { + pub fn parse(input: &str) -> Result { + let mut segments = input.split('/'); + let first = segments.next().unwrap_or_default(); + let (subroot, path) = match first { + "s" | "8" | "48" => { + let remainder = segments.collect::>().join("/"); + let subroot = match first { + "s" => LdrawSubroot::Subparts, + "8" => LdrawSubroot::Primitives, + "48" => LdrawSubroot::HiResPrimitives, + _ => unreachable!(), + }; + (Some(subroot), remainder) + } + value + if value.eq_ignore_ascii_case("s") + || value.eq_ignore_ascii_case("8") + || value.eq_ignore_ascii_case("48") => + { + return Err(PathDiagnostic::InvalidLdrawSubroot); + } + _ => (None, input.to_owned()), + }; + if subroot.is_some() && path.is_empty() { + return Err(PathDiagnostic::InvalidLdrawSubroot); + } + Ok(Self { + subroot, + path: CanonicalPath::parse(&path)?, + }) + } + + pub fn canonical(&self) -> String { + match self.subroot { + Some(subroot) => format!("{}/{}", subroot.as_str(), self.path), + None => self.path.to_string(), + } + } + + pub fn permits_root(&self, root: RootId) -> bool { + match self.subroot { + None => true, + Some(LdrawSubroot::Subparts) => matches!(root, RootId::Models | RootId::Parts), + Some(LdrawSubroot::Primitives | LdrawSubroot::HiResPrimitives) => { + matches!( + root, + RootId::Parts | RootId::P | RootId::OfficialParts | RootId::OfficialP + ) + } + } + } + + pub fn contains(&self, candidate: &CanonicalPath, root: RootId) -> bool { + self.permits_root(root) + && match self.subroot { + Some(_) => { + candidate == &self.path + || candidate.as_str().starts_with(&format!("{}/", self.path)) + } + None => true, + } + } +} + /// A validated, NFC-preserving virtual filename. It is never interpreted as a URL. #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct CanonicalPath(String); @@ -87,6 +180,7 @@ pub enum PathDiagnostic { ControlOrColon, NotNfc, Segment, + InvalidLdrawSubroot, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -155,6 +249,44 @@ mod tests { assert_eq!(CanonicalPath::parse("café").unwrap().as_str(), "café"); } + #[test] + fn ldraw_subroot_prefixes_are_explicit_and_contained() { + let scoped = LdrawScopedPath::parse("s/models/3001.dat").unwrap(); + assert_eq!(scoped.subroot, Some(LdrawSubroot::Subparts)); + assert_eq!(scoped.canonical(), "s/models/3001.dat"); + assert!(scoped.contains( + &CanonicalPath::parse("models/3001.dat").unwrap(), + RootId::Models + )); + assert!(!scoped.contains( + &CanonicalPath::parse("parts/3001.dat").unwrap(), + RootId::OfficialParts + )); + assert!( + LdrawScopedPath::parse("8/3001.dat") + .unwrap() + .permits_root(RootId::Parts) + ); + assert!( + LdrawScopedPath::parse("48/3001.dat") + .unwrap() + .permits_root(RootId::OfficialP) + ); + for invalid in ["S/models/3001.dat", "8/", "48", "s"] { + assert!(LdrawScopedPath::parse(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn unprefixed_paths_do_not_claim_subroot_containment() { + let scoped = LdrawScopedPath::parse("models/3001.dat").unwrap(); + assert_eq!(scoped.subroot, None); + assert!(scoped.contains( + &CanonicalPath::parse("unrelated/3001.dat").unwrap(), + RootId::Parts + )); + } + #[test] fn precedence_and_ascii_case_policy_are_stable() { let path = CanonicalPath::parse("parts/3001.dat").unwrap();