diff --git a/.sqlx/query-d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b.json b/.sqlx/query-1c21359f955e7b76137b1fb1c33f1c64f133897b48878e9463e63762aa17bf82.json similarity index 78% rename from .sqlx/query-d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b.json rename to .sqlx/query-1c21359f955e7b76137b1fb1c33f1c64f133897b48878e9463e63762aa17bf82.json index b4c7fce..acb3a9a 100644 --- a/.sqlx/query-d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b.json +++ b/.sqlx/query-1c21359f955e7b76137b1fb1c33f1c64f133897b48878e9463e63762aa17bf82.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal ASC", + "query": "SELECT root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type, ordinal FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal ASC", "describe": { "columns": [ { @@ -79,6 +79,17 @@ "name": "mime_type" } } + }, + { + "name": "ordinal", + "ordinal": 7, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "ordinal" + } + } } ], "parameters": { @@ -91,8 +102,9 @@ false, false, false, + false, false ] }, - "hash": "d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b" + "hash": "1c21359f955e7b76137b1fb1c33f1c64f133897b48878e9463e63762aa17bf82" } diff --git a/Cargo.lock b/Cargo.lock index ab3d9bb..7c21538 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6358,6 +6358,7 @@ dependencies = [ "keyring", "libsqlite3-sys", "polymodel-api", + "polymodel-ldraw-core", "polymodel-renderer-protocol", "rand 0.9.4", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index d59f4d9..7b8ca34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ server = [ # aspectRatio) and opaque draft id generation. "dep:imagesize", "dep:ulid", + "dep:polymodel-ldraw-core", ] [dependencies] dioxus = { version = "0.7", features = ["router", "fullstack"] } @@ -75,6 +76,7 @@ jacquard-lexicon = { workspace = true } # Generated lexicon bindings (typed request/response/view types for XRPC routes). polymodel-api = { path = "crates/polymodel-api" } polymodel-renderer-protocol = { path = "crates/polymodel-renderer-protocol" } +polymodel-ldraw-core = { path = "crates/polymodel-ldraw-core", optional = true } serde = { workspace = true } serde_json = "1.0" thiserror = { workspace = true } diff --git a/crates/polymodel-ldraw-core/src/mpd.rs b/crates/polymodel-ldraw-core/src/mpd.rs index 540fd58..ec94cd2 100644 --- a/crates/polymodel-ldraw-core/src/mpd.rs +++ b/crates/polymodel-ldraw-core/src/mpd.rs @@ -4,7 +4,7 @@ use crate::types::{ }; use serde::{Deserialize, Serialize}; use std::borrow::Cow; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; pub(crate) fn duplicate_file_name_spans(files: &[VirtualFile<'_>]) -> Vec { let mut names = BTreeSet::new(); @@ -50,6 +50,99 @@ impl<'src> From> for OwnedVirtualFile { pub type MpdDocument<'src> = Vec>; +/// A type-1 dependency observed in one MPD virtual file. +/// +/// The core owns scanning and MPD partitioning; callers decide how the +/// filename is joined to their own resolver roots. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MpdReference { + pub file_name: String, + pub target: String, + /// True when the source contained explicit `0 FILE` sections. This lets a + /// resolver distinguish an MPD virtual-file namespace from an ordinary + /// single-file resource without parsing scene geometry. + pub virtual_file: bool, +} + +/// Extract ordered type-1 references from verified LDraw source bytes. +/// +/// This deliberately stops at the syntax/reference boundary. It does not +/// resolve filenames, fetch bytes, or build scene geometry. Every type-1 line +/// must satisfy the same transform/token shape used by the semantic parser so +/// malformed source cannot silently produce a partial compound plan. +pub fn extract_references(bytes: &[u8]) -> Result, ParseError> { + let limits = LdrawLimits::default(); + let lines = crate::scanner::scan_lines(bytes, &limits)?; + extract_references_from_lines(&lines, &limits) +} + +/// Extract references from already-scanned source while preserving each MPD +/// FILE section's identity and first-seen order. Callers that need to traverse +/// a compound resource must use `file_name` to select the section's namespace; +/// flattening all sections into one anonymous list loses the MPD graph. +pub fn extract_references_from_lines( + lines: &[ScannedLine<'_>], + limits: &LdrawLimits, +) -> Result, ParseError> { + let has_virtual_files = lines.iter().any(|line| { + line.line_type == Some(LineType::Zero) + && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::File) + && line.tokens.len() >= 3 + }); + let files = split_mpd(lines, limits)?; + let mut names = HashSet::new(); + let mut references = Vec::new(); + for file in files { + if !names.insert(file.name.to_string()) { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::DuplicateFileName, + severity: Severity::Error, + message: "MPD contains a duplicate FILE name".into(), + span: Some(file.source_span), + })); + } + for line in file.lines { + if line.line_type != Some(LineType::One) { + continue; + } + if line.tokens.len() < 15 + || (2..14).any(|index| { + line.tokens + .get(index) + .and_then(|token| token.number) + .is_none() + }) + { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::InvalidType1, + severity: Severity::Error, + message: "malformed type-1 include".into(), + span: Some(line.span), + })); + } + let target = line.tokens[14..] + .iter() + .map(|token| token.text) + .collect::>() + .join(" "); + if target.is_empty() { + return Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::InvalidType1, + severity: Severity::Error, + message: "type-1 include has no target filename".into(), + span: Some(line.span), + })); + } + references.push(MpdReference { + file_name: file.name.to_string(), + target, + virtual_file: has_virtual_files, + }); + } + } + Ok(references) +} + pub fn split_mpd<'src>( lines: &[ScannedLine<'src>], limits: &LdrawLimits, @@ -135,3 +228,71 @@ pub fn split_mpd<'src>( } Ok(files) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_ordered_references_and_marks_mpd_sources() { + let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 FILE child.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 root.ldr\n0 NOFILE\n"; + let references = extract_references(bytes).expect("valid MPD references"); + assert_eq!( + references, + vec![ + MpdReference { + file_name: "root.ldr".into(), + target: "child.dat".into(), + virtual_file: true, + }, + MpdReference { + file_name: "child.dat".into(), + target: "root.ldr".into(), + virtual_file: true, + }, + ] + ); + } + + #[test] + fn extracts_single_file_references_without_virtual_namespace() { + let bytes = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n"; + let references = extract_references(bytes).expect("valid reference"); + assert_eq!(references.len(), 1); + assert_eq!(references[0].file_name, DEFAULT_ROOT_NAME); + assert_eq!(references[0].target, "child.dat"); + assert!(!references[0].virtual_file); + } + + #[test] + fn malformed_type_one_and_missing_target_fail_closed() { + let malformed = b"1 16 0 0 0 1 0 0 0 1 0 0 0 child.dat\n"; + assert!(matches!( + extract_references(malformed), + Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::InvalidType1, + .. + })) + )); + let missing_target = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0\n"; + assert!(matches!( + extract_references(missing_target), + Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::InvalidType1, + .. + })) + )); + } + + #[test] + fn duplicate_mpd_file_names_fail_closed() { + let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 other.dat\n0 NOFILE\n"; + assert!(matches!( + extract_references(bytes), + Err(ParseError::Diagnostic(Diagnostic { + code: DiagnosticCode::DuplicateFileName, + .. + })) + )); + } +} diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index 836b543..0f66739 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -24,7 +24,7 @@ pub use load_state::{ pub const PROTOCOL_VERSION: u16 = 1; /// Capability names negotiated before scene-dependent commands are accepted. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Capability { CurrentFormats, CompoundLdraw, @@ -62,6 +62,7 @@ pub enum HandshakeError { MissingRequiredCapability(Capability), DuplicateCapability(Capability), ConflictingCapability(Capability), + InvalidSession, OutOfOrder, } @@ -70,18 +71,35 @@ impl ProtocolHello { 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) - }, - ); + let mut required = HashSet::with_capacity(self.required.len()); + for capability in &self.required { + if !required.insert(*capability) { + return Err(HandshakeError::DuplicateCapability(*capability)); + } + } + let mut optional = HashSet::with_capacity(self.optional.len()); + for capability in &self.optional { + if !optional.insert(*capability) { + return Err(HandshakeError::DuplicateCapability(*capability)); + } + if required.contains(capability) { + return Err(HandshakeError::ConflictingCapability(*capability)); + } + } + Ok(()) + } +} + +impl NegotiatedProtocol { + fn validate(&self) -> Result<(), HandshakeError> { + if self.version != PROTOCOL_VERSION || self.session_id == 0 { + return Err(HandshakeError::InvalidSession); + } + let mut seen = HashSet::with_capacity(self.capabilities.len()); + for capability in &self.capabilities { + if !seen.insert(*capability) { + return Err(HandshakeError::DuplicateCapability(*capability)); } - seen.push(*capability); } Ok(()) } @@ -94,6 +112,9 @@ pub fn negotiate( ) -> Result { local.validate()?; remote.validate()?; + if session_id == 0 { + return Err(HandshakeError::InvalidSession); + } let version = local.max_version.min(remote.max_version); if version < local.min_version || version < remote.min_version { return Err(HandshakeError::NoVersionOverlap); @@ -183,12 +204,7 @@ impl HandshakeState { Ok(None) } (Self::Negotiating, CommandPayload::Accept(negotiated)) - if negotiated.version == PROTOCOL_VERSION - && negotiated.session_id != 0 - && negotiated - .capabilities - .windows(2) - .all(|pair| pair[0] != pair[1]) => + if envelope.session_id == 0 && negotiated.validate().is_ok() => { *self = Self::Negotiated(negotiated.clone()); Ok(None) @@ -214,16 +230,26 @@ impl HandshakeState { return Err(HandshakeError::NoVersionOverlap); } match (&*self, &envelope.payload) { - (Self::Created, EventPayload::Hello(_)) => { + (Self::Created, EventPayload::Hello(hello)) => { + hello.validate()?; + if envelope.session_id != 0 { + *self = Self::Failed; + return Err(HandshakeError::InvalidSession); + } *self = Self::Negotiating; Ok(None) } - (Self::Created | Self::Negotiating, EventPayload::Negotiated(negotiated)) => { + (Self::Negotiating, EventPayload::Negotiated(negotiated)) + if envelope.session_id == negotiated.session_id + && negotiated.validate().is_ok() => + { *self = Self::Negotiated(negotiated.clone()); Ok(None) } (Self::Negotiated(session), EventPayload::Negotiated(negotiated)) - if session == negotiated => + if envelope.session_id == session.session_id + && session == negotiated + && negotiated.validate().is_ok() => { Ok(None) } @@ -579,7 +605,7 @@ impl CompoundLoadPlan { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CompoundSceneSource { pub primary: String, pub descriptors: Vec<(WorkerFetchDescriptor, Vec)>, @@ -653,6 +679,13 @@ pub enum RendererEvent { namespace: RendererNamespace, identity: LoadIdentity, }, + /// Verified compound bytes have reached the PM-79 parser/mesh boundary. + /// G does not convert LDraw text into geometry. + CompoundResourcesReady { + namespace: RendererNamespace, + identity: LoadIdentity, + source: CompoundSceneSource, + }, /// A preview render completed successfully. `png` is raw PNG bytes. PreviewImageRendered { request_id: u32, png: Vec }, /// A preview render failed. Preview failures are non-fatal and do not affect diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 6436bec..cf3475c 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -1302,8 +1302,36 @@ fn finish_compound_source( ) { if let Ok(mut st) = state.try_borrow_mut() { st.compound_abort = None; + match result { + Ok(source) => st.post_event(&RendererEvent::CompoundResourcesReady { + namespace, + identity, + source, + }), + Err(detail) => { + let transition = st.load_reducer.reduce(LoadEvent::LoadFailed { + namespace, + identity, + error: LoadError { + code: LoadErrorCode::Fetch, + detail: detail.clone(), + }, + candidate: None, + }); + if matches!( + transition.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) { + st.post_event(&RendererEvent::LoadFailed { + namespace, + identity, + code: LoadErrorCode::Fetch, + detail, + }); + } + } + } } - finish_loaded_mesh(state, namespace, identity, result.map(|_| unreachable!())); } async fn fetch_descriptor_bytes( diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index 90d1723..d17f1e4 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -9,6 +9,7 @@ use jacquard::identity::resolver::IdentityResolver; use jacquard_common::types::string::{AtUri, Cid, Did}; use jacquard_common::xrpc::XrpcExt; use polymodel_api::com_atproto::sync::get_blob::GetBlob; +use polymodel_ldraw_core::extract_references; use polymodel_renderer_protocol::{ CompoundLoadPlan, MAX_COMPOUND_DESCRIPTORS, MAX_COMPOUND_METADATA_BYTES, MAX_COMPOUND_TOTAL_BYTES, PROTOCOL_VERSION, WorkerFetchDescriptor, @@ -26,13 +27,24 @@ const ROUTE_PREFIX: &str = "/app/ldraw/resources/"; const ROUTE_VERSION: u8 = 1; const MAX_MANIFEST_EDGES: usize = MAX_COMPOUND_DESCRIPTORS * 8; +#[derive(Debug)] +struct ManifestEntry { + root: RootId, + path: CanonicalPath, + target_resource_uri: String, + target_cid: String, + byte_length: i64, + sha256: Vec, + mime_type: String, +} + #[derive(Debug, Deserialize)] pub(super) struct CompoundRequest { pub project: AtUri, pub path: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] struct RouteBinding { version: u8, project: AtUri, @@ -79,71 +91,84 @@ pub(super) async fn compound_resources( .map_err(|diagnostic| invalid_request(format!("invalid LDraw path: {diagnostic:?}")))?; let resolver = SqliteResolver::new(state.pool.clone()); let mut resources = Vec::new(); - let mut seen_paths = HashSet::new(); + let mut seen_bindings = HashSet::new(); let mut queue = VecDeque::from([path.clone()]); let mut manifest_edges = 0_usize; while let Some(current_path) = queue.pop_front() { - if !seen_paths.insert(current_path.clone()) { - continue; - } - if seen_paths.len() > MAX_COMPOUND_DESCRIPTORS { - return Err(invalid_request( - "compound resource descriptor bound exceeded", - )); - } let current = resolver .resolve(&request.project, ¤t_path) .await .map_err(resolve_error)?; - let current_key = (current.root, current.path.clone()); - if resources.iter().any(|resource: &ResolvedResource| { - (resource.root, resource.path.clone()) == current_key - }) { + let current_binding = binding_for(&request.project, ¤t)?; + if !seen_bindings.insert(current_binding.clone()) { continue; } - resources.push(current.clone()); - + if seen_bindings.len() > MAX_COMPOUND_DESCRIPTORS { + return Err(invalid_request( + "compound resource descriptor bound exceeded", + )); + } + let bytes = fetch_verified_bytes(&state, ¤t).await?; + let references = extract_references(&bytes).map_err(|_| not_found())?; let rows = db( sqlx::query!( - "SELECT root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal ASC", + "SELECT root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type, ordinal FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal ASC", current.resource.uri.as_ref(), ) .fetch_all(&state.pool) .await, )?; - for row in rows { + let manifest = rows + .into_iter() + .map(|row| { + Ok::<_, super::error::AppError>(ManifestEntry { + root: parse_root(&row.root).ok_or_else(not_found)?, + path: CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?, + target_resource_uri: row.target_resource_uri, + target_cid: row.target_cid, + byte_length: row.byte_length, + sha256: row.sha256, + mime_type: row.mime_type, + }) + }) + .collect::>>()?; + let mut children = Vec::with_capacity(manifest.len()); + let mut referenced_paths = HashSet::new(); + for reference in references + .iter() + .filter(|reference| reference_is_in_current_file(¤t.path, reference)) + { manifest_edges = manifest_edges .checked_add(1) .ok_or_else(|| invalid_request("compound manifest edge bound overflow"))?; if manifest_edges > MAX_MANIFEST_EDGES { return Err(invalid_request("compound manifest edge bound exceeded")); } - let child_path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; - let child_root = parse_root(&row.root).ok_or_else(not_found)?; - if !contains_in_scope( - current.root, - current.path.as_str(), - child_root, - child_path.as_str(), - ) { - return Err(not_found()); - } + let child_path = reference_path(¤t.path, reference).ok_or_else(not_found)?; let child = resolver .resolve(&request.project, &child_path) .await .map_err(resolve_error)?; - if child.root != child_root - || child.path != child_path - || child.resource.uri.as_ref() != row.target_resource_uri - || child.target.blob_cids.first().map(String::as_str) - != Some(row.target_cid.as_str()) - || child.byte_length != row.byte_length - || child.sha256 != row.sha256 - || child.mime_type != row.mime_type - { - return Err(not_found()); + validate_manifest_child(¤t, &child, &manifest)?; + push_first_seen(&mut children, &mut referenced_paths, child.path); + } + for entry in &manifest { + manifest_edges = manifest_edges + .checked_add(1) + .ok_or_else(|| invalid_request("compound manifest edge bound overflow"))?; + if manifest_edges > MAX_MANIFEST_EDGES { + return Err(invalid_request("compound manifest edge bound exceeded")); } + let child = resolver + .resolve(&request.project, &entry.path) + .await + .map_err(resolve_error)?; + validate_manifest_child(¤t, &child, &manifest)?; + push_first_seen(&mut children, &mut referenced_paths, child.path); + } + resources.push(current); + for child_path in children { queue.push_back(child_path); } } @@ -319,17 +344,156 @@ fn parse_root(value: &str) -> Option { }) } +fn validate_manifest_child( + parent: &ResolvedResource, + child: &ResolvedResource, + manifest: &[ManifestEntry], +) -> AppResult<()> { + let entry = manifest + .iter() + .find(|entry| entry.root == child.root && entry.path == child.path) + .ok_or_else(not_found)?; + let child_cid = child.target.blob_cids.first().map(String::as_str); + if entry.target_resource_uri.is_empty() + || entry.target_cid.is_empty() + || entry.byte_length < 0 + || entry.sha256.len() != 32 + || entry.mime_type.is_empty() + || child_cid.is_none() + || !contains_in_scope( + parent.root, + parent.path.as_str(), + child.root, + child.path.as_str(), + ) + || child.resource.uri.as_ref() != entry.target_resource_uri + || child_cid != Some(entry.target_cid.as_str()) + || child.byte_length != entry.byte_length + || child.sha256 != entry.sha256 + || child.mime_type != entry.mime_type + { + return Err(not_found()); + } + Ok(()) +} + +fn reference_is_in_current_file( + current: &CanonicalPath, + reference: &polymodel_ldraw_core::MpdReference, +) -> bool { + if !reference.virtual_file { + return true; + } + let section_name = current + .as_str() + .split_once('/') + .map_or(current.as_str(), |(_, remainder)| remainder); + section_name == reference.file_name +} + +fn push_first_seen( + children: &mut Vec, + seen: &mut HashSet, + path: CanonicalPath, +) { + if seen.insert(path.clone()) { + children.push(path); + } +} + +/// Join one syntax-level type-1 target to the resolver's root-qualified key. +/// +/// MPD virtual files live in the exact-case `mpd/` namespace. Explicit standard +/// subroots (`s/`, `8/`, and `48/`) belong to `parts/`; all other bare filenames +/// are joined to the containing resource directory. No normalization or +/// parent-directory fallback is performed: `CanonicalPath` rejects traversal +/// and PM-76 remains the sole precedence authority after this join. +fn explicit_reference_root(value: &str) -> Option { + [ + RootId::Mpd, + RootId::Manifest, + RootId::Models, + RootId::Parts, + RootId::P, + RootId::OfficialParts, + RootId::OfficialP, + ] + .into_iter() + .find(|root| { + value == root.as_str() + || (root.case_insensitive() && value.eq_ignore_ascii_case(root.as_str())) + }) +} + +fn reference_path( + current: &CanonicalPath, + reference: &polymodel_ldraw_core::MpdReference, +) -> Option { + let target = reference.target.trim(); + if target.is_empty() { + return None; + } + if reference.virtual_file { + return CanonicalPath::parse(&format!("mpd/{target}")).ok(); + } + let first = target.split('/').next()?; + if explicit_reference_root(first).is_some() { + return CanonicalPath::parse(target).ok(); + } + if ["s", "8", "48"] + .iter() + .any(|prefix| first.eq_ignore_ascii_case(prefix)) + { + return CanonicalPath::parse(&format!("parts/{target}")).ok(); + } + let mut joined = current.segments().collect::>(); + joined.pop()?; + joined.push(target); + CanonicalPath::parse(&joined.join("/")).ok() +} + +/// Enforce the LDraw root and subroot policy after PM-76 has selected a candidate. +/// +/// Manifest references may cross ordinary LDraw roots (for example a model may +/// reference a part), but they may not escape an explicit `s/`, `8/`, or `48/` +/// subroot. There is deliberately no parent-directory fallback: a missing +/// exact candidate is a resolver miss, not permission to reinterpret its name. fn contains_in_scope(parent_root: RootId, parent: &str, child_root: RootId, child: &str) -> bool { - if parent_root != child_root { + let Ok(parent_scope) = subroot_scope(parent_root, parent) else { return false; + }; + let Ok(child_scope) = subroot_scope(child_root, child) else { + return false; + }; + match (parent_scope, child_scope) { + (Some(parent), Some(child)) => parent_root == child_root && parent == child, + (Some(_), None) | (None, Some(_)) => false, + (None, None) => true, + } +} + +fn subroot_scope(root: RootId, path: &str) -> Result, ()> { + let segments = path.split('/').collect::>(); + let first = segments.first().copied().ok_or(())?; + let expected = root.as_str(); + if first != expected && !(root.case_insensitive() && first.eq_ignore_ascii_case(expected)) { + return Err(()); + } + if segments.len() <= 2 { + return Ok(None); + } + if !matches!( + root, + RootId::Parts | RootId::P | RootId::OfficialParts | RootId::OfficialP + ) { + return Err(()); + } + match segments.get(1).copied() { + Some(prefix) if prefix.eq_ignore_ascii_case("s") => Ok(Some("s")), + Some(prefix) if prefix.eq_ignore_ascii_case("8") => Ok(Some("8")), + Some(prefix) if prefix.eq_ignore_ascii_case("48") => Ok(Some("48")), + _ => Err(()), } - child == parent - || child - .strip_prefix(parent) - .is_some_and(|suffix| suffix.starts_with('/')) - || parent - .rsplit_once('/') - .is_some_and(|(prefix, _)| child == prefix || child.starts_with(&format!("{prefix}/"))) } async fn fetch_verified_bytes(state: &AppState, resource: &ResolvedResource) -> AppResult> { @@ -445,6 +609,78 @@ mod tests { assert!(decode_binding(std::str::from_utf8(&changed).unwrap()).is_err()); } + #[test] + fn reference_joins_cover_mpd_roots_subroots_and_relative_paths() { + let current = CanonicalPath::parse("models/sub/main.ldr").unwrap(); + let relative = polymodel_ldraw_core::MpdReference { + file_name: "main.ldr".into(), + target: "child.dat".into(), + virtual_file: false, + }; + assert_eq!( + reference_path(¤t, &relative).unwrap().as_str(), + "models/sub/child.dat" + ); + for target in ["models/other.ldr", "parts/3001.dat", "p/foo.dat"] { + let explicit = polymodel_ldraw_core::MpdReference { + target: target.into(), + ..relative.clone() + }; + assert_eq!( + reference_path(¤t, &explicit).unwrap().as_str(), + target + ); + } + for prefix in ["s", "8", "48", "S", "8", "48"] { + let explicit = polymodel_ldraw_core::MpdReference { + target: format!("{prefix}/3001.dat"), + ..relative.clone() + }; + assert_eq!( + reference_path(¤t, &explicit).unwrap().as_str(), + format!("parts/{prefix}/3001.dat") + ); + } + let mpd = polymodel_ldraw_core::MpdReference { + file_name: "main.ldr".into(), + target: "Child.DAT".into(), + virtual_file: true, + }; + assert_eq!( + reference_path(¤t, &mpd).unwrap().as_str(), + "mpd/Child.DAT" + ); + } + + #[test] + fn reference_joins_fail_closed_and_mpd_sections_are_isolated() { + let current = CanonicalPath::parse("mpd/sub/root.ldr").unwrap(); + let malformed = polymodel_ldraw_core::MpdReference { + file_name: "sub/root.ldr".into(), + target: "../escape.dat".into(), + virtual_file: false, + }; + assert!(reference_path(¤t, &malformed).is_none()); + let first = polymodel_ldraw_core::MpdReference { + file_name: "sub/root.ldr".into(), + target: "child.dat".into(), + virtual_file: true, + }; + let second = polymodel_ldraw_core::MpdReference { + file_name: "child.dat".into(), + target: "root.ldr".into(), + virtual_file: true, + }; + assert!(reference_is_in_current_file(¤t, &first)); + assert!(!reference_is_in_current_file(¤t, &second)); + let mut children = Vec::new(); + let mut seen = HashSet::new(); + let child = CanonicalPath::parse("parts/3001.dat").unwrap(); + push_first_seen(&mut children, &mut seen, child.clone()); + push_first_seen(&mut children, &mut seen, child); + assert_eq!(children.len(), 1); + } + #[test] fn scope_requires_same_root_and_containment() { assert!(contains_in_scope( @@ -453,7 +689,7 @@ mod tests { RootId::Models, "models/main.ldr" )); - assert!(!contains_in_scope( + assert!(contains_in_scope( RootId::Models, "models/main.ldr", RootId::Parts, @@ -465,5 +701,71 @@ mod tests { RootId::Models, "official-parts/x.dat" )); + assert!(contains_in_scope( + RootId::Models, + "models/main.ldr", + RootId::Models, + "models/other.ldr" + )); + } + + #[test] + fn scope_preserves_explicit_standard_subroots() { + for subroot in ["s", "8", "48"] { + let parent = format!("parts/{subroot}/parent.dat"); + let child = format!("parts/{subroot}/child.dat"); + assert!(contains_in_scope( + RootId::Parts, + &parent, + RootId::Parts, + &child + )); + assert!(!contains_in_scope( + RootId::Parts, + &parent, + RootId::Parts, + "parts/other/child.dat" + )); + assert!(!contains_in_scope( + RootId::Parts, + &parent, + RootId::Models, + &child + )); + } + assert!(contains_in_scope( + RootId::P, + "p/S/parent.dat", + RootId::P, + "P/S/child.dat" + )); + assert!(contains_in_scope( + RootId::Parts, + "parts/s/parent.dat", + RootId::Parts, + "parts/S/child.dat" + )); + } + + #[test] + fn scope_rejects_unknown_or_malformed_root_prefixes() { + assert!(!contains_in_scope( + RootId::Parts, + "parts/s/parent.dat", + RootId::Parts, + "models/s/child.dat" + )); + assert!(!contains_in_scope( + RootId::Parts, + "parts/s/parent.dat", + RootId::Parts, + "parts/unknown/child.dat" + )); + assert!(!contains_in_scope( + RootId::Parts, + "wrong/s/parent.dat", + RootId::Parts, + "parts/s/child.dat" + )); } } diff --git a/src/publish.rs b/src/publish.rs index 5877c49..957514e 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -2462,8 +2462,12 @@ async fn generate_preview( #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod renderer_preview { use polymodel_renderer_protocol::{ - MeshFormat, RendererCommand, RendererEvent, deserialize_event, serialize_command, + Capability, CommandEnvelope, CommandPayload, EventPayload, HandshakeState, MeshFormat, + ProtocolHello, RendererCommand, RendererEvent, deserialize_event_envelope, + serialize_command_envelope, }; + use std::cell::RefCell; + use std::rc::Rc; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::*; @@ -2646,25 +2650,47 @@ mod renderer_preview { width, height, }; - - let command_bytes = - serialize_command(&command).map_err(|e| format!("preview command serialize: {e}"))?; + let local_hello = ProtocolHello { + min_version: polymodel_renderer_protocol::PROTOCOL_VERSION, + max_version: polymodel_renderer_protocol::PROTOCOL_VERSION, + required: vec![Capability::CurrentFormats, Capability::Cancellation], + optional: vec![Capability::Preview], + }; + let handshake = Rc::new(RefCell::new(HandshakeState::Created)); let worker_for_message = worker.clone(); let worker_for_error = worker.clone(); let promise = js_sys::Promise::new(&mut move |resolve, reject| { - let command_bytes = command_bytes.clone(); + let local_hello = local_hello.clone(); + let handshake = handshake.clone(); + let command = command.clone(); let worker_for_message = worker_for_message.clone(); let resolve_message = resolve.clone(); let reject_message = reject.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); if data.as_string().as_deref() == Some("__worker_ready") { - let array = js_sys::Uint8Array::from(command_bytes.as_slice()); - if let Err(e) = worker_for_message.post_message(&array) { - let _ = reject_message.call1( - &JsValue::NULL, - &JsValue::from_str(&format!("preview command post: {e:?}")), - ); + let hello = CommandEnvelope { + version: polymodel_renderer_protocol::PROTOCOL_VERSION, + session_id: 0, + payload: CommandPayload::Hello(local_hello.clone()), + }; + match serialize_command_envelope(&hello) { + Ok(bytes) => { + if let Err(error) = worker_for_message + .post_message(&js_sys::Uint8Array::from(bytes.as_slice())) + { + let _ = reject_message.call1( + &JsValue::NULL, + &JsValue::from_str(&format!("preview hello post: {error:?}")), + ); + } + } + Err(error) => { + let _ = reject_message.call1( + &JsValue::NULL, + &JsValue::from_str(&format!("preview hello serialize: {error}")), + ); + } } return; } @@ -2672,28 +2698,93 @@ mod renderer_preview { let Ok(array) = data.dyn_into::() else { return; }; - let bytes = array.to_vec(); - match deserialize_event(&bytes) { - Ok(RendererEvent::PreviewImageRendered { + let envelope = match deserialize_event_envelope(&array.to_vec()) { + Ok(envelope) => envelope, + Err(error) => { + let _ = reject_message.call1( + &JsValue::NULL, + &JsValue::from_str(&format!("preview event deserialize: {error}")), + ); + return; + } + }; + let negotiated = match &envelope.payload { + EventPayload::Negotiated(protocol) => Some(protocol.clone()), + _ => None, + }; + let event = match handshake.borrow_mut().accept_event(&envelope) { + Ok(Some(event)) => event, + Ok(None) => { + if let Some(protocol) = negotiated { + if !protocol.capabilities.contains(&Capability::Preview) { + let _ = reject_message.call1( + &JsValue::NULL, + &JsValue::from_str("preview capability was not negotiated"), + ); + return; + } + let accept = CommandEnvelope { + version: polymodel_renderer_protocol::PROTOCOL_VERSION, + session_id: 0, + payload: CommandPayload::Accept(protocol.clone()), + }; + let command_envelope = CommandEnvelope { + version: polymodel_renderer_protocol::PROTOCOL_VERSION, + session_id: protocol.session_id, + payload: CommandPayload::Command(command.clone()), + }; + for envelope in [accept, command_envelope] { + match serialize_command_envelope(&envelope) { + Ok(bytes) => { + if let Err(error) = worker_for_message.post_message( + &js_sys::Uint8Array::from(bytes.as_slice()), + ) { + let _ = reject_message.call1( + &JsValue::NULL, + &JsValue::from_str(&format!( + "preview command post: {error:?}" + )), + ); + return; + } + } + Err(error) => { + let _ = reject_message.call1( + &JsValue::NULL, + &JsValue::from_str(&format!( + "preview command serialize: {error}" + )), + ); + return; + } + } + } + } + return; + } + Err(error) => { + let _ = reject_message.call1( + &JsValue::NULL, + &JsValue::from_str(&format!("preview handshake rejected: {error:?}")), + ); + return; + } + }; + match event { + RendererEvent::PreviewImageRendered { request_id: event_request_id, png, - }) if event_request_id == request_id => { + } if event_request_id == request_id => { let array = js_sys::Uint8Array::from(png.as_slice()); let _ = resolve_message.call1(&JsValue::NULL, &array); } - Ok(RendererEvent::PreviewImageError { + RendererEvent::PreviewImageError { request_id: event_request_id, message, - }) if event_request_id == request_id => { + } if event_request_id == request_id => { let _ = reject_message.call1(&JsValue::NULL, &JsValue::from_str(&message)); } - Ok(_) => {} - Err(e) => { - let _ = reject_message.call1( - &JsValue::NULL, - &JsValue::from_str(&format!("preview event deserialize: {e}")), - ); - } + _ => {} } }) as Box); diff --git a/src/viewer.rs b/src/viewer.rs index 0f1f94b..280e6f2 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1448,7 +1448,8 @@ impl WorkerBridgeState { status_msg.set(ViewerStatus::Cancelled(session)); } } - RendererEvent::PreviewImageRendered { .. } + RendererEvent::CompoundResourcesReady { .. } + | RendererEvent::PreviewImageRendered { .. } | RendererEvent::PreviewImageError { .. } => {} RendererEvent::Error { fatal, message } => { if fatal {