From fb41b6151ee3eeb95765b7beb5c7e02ea06723fc Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 8 Aug 2026 09:20:59 -0400 Subject: [PATCH] PM-83: add STL/OBJ evidence recognizers and LDraw official-library filesystem fallback Root cause 1: recognize_evidence had no STL or OBJ recognizers. Neutral/no-hint loads of STL/OBJ files fell through to Recognized::None, causing viewer MeshError. Add recognizes_stl (binary size check + ASCII facet normal evidence) and recognizes_obj (UTF-8 with parseable geometric v/vn/vt/f lines), wired into recognize_evidence with the same ordering as existing recognizers. Root cause 2: build_model_load_bundle resolved all references via SqliteResolver (project-scoped DB memberships). User MPDs reference official library parts (parts/3001.dat, p/4-4cyli.dat, s/ subfiles, etc.) that have no DB rows, so the bundle build 404'd. State.ldraw_library_dir was dead code. Implement a filesystem fallback inside the BFS: when the DB resolver returns NotFound and the path root is parts/ or p/, read bytes from /parts/ or /p/ (with case-insensitive matching), compute sha256, and synthesize an inline ModelResource with root official-parts/official-p, empty target_cids, and mime_type application/x-ldraw. Skip binding_for and validate_manifest_child for official resources; keep bundle-level validation. Children of official parts also resolve through the same fallback so primitives referenced by parts (p/4-4con*.dat from parts/*.dat) resolve recursively. Tests: 6 new registry tests (STL binary, STL ASCII, STL near-misses, OBJ with geometric lines, OBJ comments-only, over-bound); 4 new ldraw bundle builder tests (official library resolves parts + primitives, missing part 404s, CanonicalPath traversal rejection, no library dir unchanged). Epic: PM-86 Task: PM-83 --- .envrc | 2 +- Cargo.lock | 2 + Cargo.toml | 1 + crates/polymodel-ldraw-core/Cargo.toml | 1 + crates/polymodel-ldraw-core/src/cache.rs | 122 +-- crates/polymodel-ldraw-core/src/mpd.rs | 134 ++- crates/polymodel-ldraw-core/src/types.rs | 58 +- crates/polymodel-renderer-protocol/src/lib.rs | 360 ++++++- crates/polymodel-renderer-worker/src/lib.rs | 74 +- .../polymodel-renderer-worker/src/worker.rs | 236 ++++- src/appview/ldraw.rs | 904 +++++++++++++++--- src/indexing/config.rs | 9 +- src/main.rs | 15 + src/publish/draft.rs | 2 + src/viewer.rs | 56 +- tools/in-dev-shell | 33 +- 16 files changed, 1648 insertions(+), 361 deletions(-) diff --git a/.envrc b/.envrc index 3550a30..c3cb1b1 100644 --- a/.envrc +++ b/.envrc @@ -1 +1 @@ -use flake +eval "$(tools/in-dev-shell --print-dev-env)" diff --git a/Cargo.lock b/Cargo.lock index 508e744..244e6e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6752,6 +6752,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", + "tempfile", "thiserror 2.0.18", "tokio", "tower", @@ -6788,6 +6789,7 @@ dependencies = [ "miette", "polymodel-ldraw-testkit", "polymodel-renderer-ledger", + "polymodel-renderer-protocol", "serde", "serde_json", "sha2 0.10.9", diff --git a/Cargo.toml b/Cargo.toml index b5a6c33..b0a9b7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,6 +160,7 @@ imagesize = "0.13" jacquard-axum = { workspace = true } keyring = "3" serde_json = "1.0" +tempfile = "3" ulid = "1" urlencoding = "2" libsqlite3-sys = { version = "0.37", features = ["bundled"] } diff --git a/crates/polymodel-ldraw-core/Cargo.toml b/crates/polymodel-ldraw-core/Cargo.toml index 138a88e..27518b1 100644 --- a/crates/polymodel-ldraw-core/Cargo.toml +++ b/crates/polymodel-ldraw-core/Cargo.toml @@ -7,6 +7,7 @@ description = "Pure Rust/WASM LDraw syntax and semantic core." [dependencies] base64 = { version = "0.22" } +polymodel-renderer-protocol = { path = "../polymodel-renderer-protocol" } polymodel-renderer-ledger = { path = "../polymodel-renderer-ledger" } serde = { workspace = true } smol_str = { workspace = true } diff --git a/crates/polymodel-ldraw-core/src/cache.rs b/crates/polymodel-ldraw-core/src/cache.rs index e21a977..7714546 100644 --- a/crates/polymodel-ldraw-core/src/cache.rs +++ b/crates/polymodel-ldraw-core/src/cache.rs @@ -1,119 +1,3 @@ -use crate::types::{Diagnostic, DiagnosticCode, ParseError, RootId, Severity}; -use crate::util::hex; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use smol_str::SmolStr; -use std::fmt; - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] -pub struct NormalizedPath(SmolStr); -impl NormalizedPath { - pub fn new(path: &str) -> Result { - let normalized_separators = path.replace('\\', "/"); - let bytes = normalized_separators.as_bytes(); - let drive_qualified = - bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; - if normalized_separators.starts_with('/') - || normalized_separators.starts_with("//") - || drive_qualified - { - return Err(ParseError::Diagnostic(Diagnostic { - code: DiagnosticCode::PathForbiddenSyntax, - severity: Severity::Error, - message: "absolute include paths are forbidden".into(), - span: None, - })); - } - let mut parts = Vec::new(); - for part in normalized_separators.split('/') { - if part.is_empty() || part == "." { - continue; - } - if part == ".." { - if parts.pop().is_none() { - return Err(ParseError::Diagnostic(Diagnostic { - code: DiagnosticCode::PathOutOfRoot, - severity: Severity::Error, - message: "path escapes its configured root".into(), - span: None, - })); - } - } else if part.bytes().any(|b| b == 0 || b.is_ascii_control()) { - return Err(ParseError::Diagnostic(Diagnostic { - code: DiagnosticCode::PathForbiddenSyntax, - severity: Severity::Error, - message: "path contains a forbidden control character".into(), - span: None, - })); - } else { - parts.push(part.to_ascii_lowercase()); - } - } - if parts.is_empty() { - return Err(ParseError::Diagnostic(Diagnostic { - code: DiagnosticCode::PathEmpty, - severity: Severity::Error, - message: "path is empty".into(), - span: None, - })); - } - Ok(Self(parts.join("/").into())) - } - pub fn as_str(&self) -> &str { - &self.0 - } -} -impl fmt::Display for NormalizedPath { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] -pub struct Sha256Hash([u8; 32]); -impl Sha256Hash { - fn digest(bytes: &[u8]) -> Self { - Self(Sha256::digest(bytes).into()) - } - - fn digest_parts<'a>(parts: impl IntoIterator) -> Self { - let mut hasher = Sha256::new(); - for part in parts { - hasher.update(part); - } - Self(hasher.finalize().into()) - } -} -impl fmt::Display for Sha256Hash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&hex(&self.0)) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct CacheKey { - pub canonical_path: NormalizedPath, - pub resolved_root: RootId, - pub content_hash: Sha256Hash, -} -impl CacheKey { - pub fn new(path: NormalizedPath, root: RootId, bytes: &[u8]) -> Self { - Self { - canonical_path: path, - resolved_root: root, - content_hash: Sha256Hash::digest(bytes), - } - } - - pub fn new_from_parts<'a>( - path: NormalizedPath, - root: RootId, - parts: impl IntoIterator, - ) -> Self { - Self { - canonical_path: path, - resolved_root: root, - content_hash: Sha256Hash::digest_parts(parts), - } - } -} +pub use polymodel_renderer_protocol::{ + CacheKey, NormalizedPath, ResolverPathError, RootId, Sha256Hash, +}; diff --git a/crates/polymodel-ldraw-core/src/mpd.rs b/crates/polymodel-ldraw-core/src/mpd.rs index f84cb8c..2b78105 100644 --- a/crates/polymodel-ldraw-core/src/mpd.rs +++ b/crates/polymodel-ldraw-core/src/mpd.rs @@ -1,3 +1,4 @@ +use crate::cache::{CacheKey, NormalizedPath, RootId}; use crate::scanner::{LineType, ScannedLine, TokenKind}; use crate::types::{ DEFAULT_ROOT_NAME, Diagnostic, DiagnosticCode, LdrawLimits, ParseError, Severity, Span, @@ -57,6 +58,7 @@ pub type MpdDocument<'src> = Vec>; #[derive(Clone, Debug, Eq, PartialEq)] pub struct MpdReference { pub file_name: String, + pub file_ordinal: usize, pub target: String, /// Source span of the type-1 line that introduced this reference. pub span: Span, @@ -119,7 +121,7 @@ pub fn resolve_reference_path( message: "type-1 include has no target filename".into(), span: None, }))?; - let explicit = match first { + let explicit = match first.to_ascii_lowercase().as_str() { "mpd" => Some(ReferenceRoot::Mpd), "manifest" => Some(ReferenceRoot::Manifest), "models" => Some(ReferenceRoot::Models), @@ -168,7 +170,6 @@ pub fn resolve_reference_path( root: ReferenceRoot::P, }); } - // Reject parent-directory traversal in the target itself. if normalized_target.split('/').any(|segment| segment == "..") { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::PathOutOfRoot, @@ -177,8 +178,6 @@ pub fn resolve_reference_path( span: None, })); } - // Bare filenames (no recognized root prefix, no s/8/48 alias) resolve to the - // LDraw `p/` primitives root, not a directory-relative join. let path = crate::NormalizedPath::new(&format!("p/{target}"))?; Ok(ResolvedReference { path, @@ -186,6 +185,105 @@ pub fn resolve_reference_path( }) } +/// Return the ordered official-library logical paths that a native or server +/// filesystem resolver must probe for one resolved type-1 path. +/// +/// The policy is intentionally permissive: malformed-but-common authoring can +/// put a part in either official root, use `s/` for a subpart, or omit the +/// standard `p/8` and `p/48` primitive directory. Filesystem adapters own case +/// folding, containment checks, and warning policy. +pub fn official_library_candidates( + path: &crate::NormalizedPath, +) -> Result, ParseError> { + let path = path.as_str(); + let relative = path.split_once('/').map_or(path, |(_, relative)| relative); + if relative.is_empty() { + return Ok(Vec::new()); + } + + let mut raw_candidates = Vec::::new(); + let mut add = |root: &str, suffix: &str| { + raw_candidates.push(format!("{root}/{suffix}")); + }; + + if let Some(suffix) = relative + .strip_prefix("s/") + .or_else(|| relative.strip_prefix("S/")) + { + add("parts", &format!("s/{suffix}")); + add("p", suffix); + add("parts", suffix); + } else { + add("parts", relative); + add("p", relative); + if !relative.starts_with("8/") && !relative.starts_with("48/") { + add("p", &format!("8/{relative}")); + add("p", &format!("48/{relative}")); + } + if !relative.starts_with("s/") { + add("parts", &format!("s/{relative}")); + } + } + + let mut candidates = Vec::with_capacity(raw_candidates.len()); + for raw in raw_candidates { + let candidate = crate::NormalizedPath::new(&raw)?; + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + } + Ok(candidates) +} + +#[derive(Clone, Debug)] +pub struct CompoundSourceInspection { + pub references: Vec, + pub source_keys: Vec<(String, CacheKey)>, + pub has_virtual_files: bool, +} + +/// Scan one compound source and retain the exact source identities used for +/// resolver selections. The caller can resolve edges while this inspection is +/// still live; no second source scan is needed. +pub fn inspect_compound_source( + bytes: &[u8], + current_path: &NormalizedPath, + current_root: RootId, +) -> Result { + let limits = LdrawLimits::default(); + let lines = crate::scanner::scan_lines(bytes, &limits)?; + let files = split_mpd(&lines, &limits)?; + let has_virtual_files = files.len() > 1 + || 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 source_keys = files + .iter() + .map(|file| { + let path = NormalizedPath::new(file.name.as_ref())?; + let key = if has_virtual_files { + CacheKey::new_from_parts( + path, + current_root, + file.lines + .iter() + .flat_map(|line| [line.raw, line.ending.as_str().as_bytes()]), + ) + } else { + CacheKey::new(current_path.clone(), current_root, bytes) + }; + Ok::<_, ParseError>((file.name.to_ascii_lowercase(), key)) + }) + .collect::, _>>()?; + Ok(CompoundSourceInspection { + references: extract_references_from_files(&files, has_virtual_files)?, + source_keys, + has_virtual_files, + }) +} + /// Extract ordered type-1 references from verified LDraw source bytes. /// /// This deliberately stops at the syntax/reference boundary. It does not @@ -195,30 +293,30 @@ pub fn resolve_reference_path( 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) + let files = split_mpd(&lines, &limits)?; + 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 + }); + extract_references_from_files(&files, has_virtual_files) } /// 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, +fn extract_references_from_files( + files: &[VirtualFile<'_>], + has_virtual_files: bool, ) -> 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 virtual_file_names = files .iter() .map(|file| file.name.to_string()) .collect::>(); let mut names = HashSet::new(); let mut references = Vec::new(); - for file in files { + for (file_ordinal, file) in files.iter().enumerate() { if !names.insert(file.name.to_string()) { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::DuplicateFileName, @@ -227,7 +325,7 @@ pub fn extract_references_from_lines( span: Some(file.source_span), })); } - for line in file.lines { + for line in &file.lines { if line.line_type != Some(LineType::One) { continue; } @@ -268,6 +366,7 @@ pub fn extract_references_from_lines( }); references.push(MpdReference { file_name: file.name.to_string(), + file_ordinal, target, span: line.span, virtual_file: has_virtual_files, @@ -378,6 +477,7 @@ mod tests { vec![ MpdReference { file_name: "root.ldr".into(), + file_ordinal: 0, target: "child.dat".into(), virtual_file: true, target_virtual_file: true, @@ -390,6 +490,7 @@ mod tests { }, MpdReference { file_name: "child.dat".into(), + file_ordinal: 1, target: "root.ldr".into(), virtual_file: true, target_virtual_file: true, @@ -425,6 +526,7 @@ mod tests { ] { let reference = MpdReference { file_name: "root.ldr".into(), + file_ordinal: 0, target: target.into(), span: Span { start: 0, diff --git a/crates/polymodel-ldraw-core/src/types.rs b/crates/polymodel-ldraw-core/src/types.rs index e189cfe..78bf2cd 100644 --- a/crates/polymodel-ldraw-core/src/types.rs +++ b/crates/polymodel-ldraw-core/src/types.rs @@ -6,6 +6,7 @@ use polymodel_renderer_ledger::{ AdmissionError, RejectionReason, Reservation, ReservationLedger, ReservationOwner, ResourceClass, }; +use polymodel_renderer_protocol::{ResolverPathError, RootId}; use serde::{Deserialize, Serialize}; use std::fmt; use std::sync::{ @@ -46,21 +47,7 @@ pub enum Severity { Error, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct Span { - pub start: u32, - pub end: u32, - pub line: u32, - pub column: u32, -} - -impl From for SourceSpan { - fn from(span: Span) -> Self { - let start = usize::try_from(span.start).unwrap_or(usize::MAX); - let length = usize::try_from(span.end.saturating_sub(span.start)).unwrap_or(usize::MAX); - SourceSpan::from((start, length)) - } -} +pub use polymodel_renderer_protocol::Span; #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] @@ -199,7 +186,12 @@ impl From for LdrawDiagnostic { Self { code: value.code, message: value.message, - span: value.span.map(Into::into), + span: value.span.map(|span| { + let start = usize::try_from(span.start).unwrap_or(usize::MAX); + let length = + usize::try_from(span.end.saturating_sub(span.start)).unwrap_or(usize::MAX); + SourceSpan::from((start, length)) + }), } } } @@ -219,6 +211,24 @@ pub enum LdrawError { } pub type ParseError = LdrawError; +impl From for ParseError { + fn from(error: ResolverPathError) -> Self { + let code = match error { + ResolverPathError::Absolute | ResolverPathError::Forbidden => { + DiagnosticCode::PathForbiddenSyntax + } + ResolverPathError::Escape => DiagnosticCode::PathOutOfRoot, + ResolverPathError::Empty => DiagnosticCode::PathEmpty, + }; + ParseError::Diagnostic(Diagnostic { + code, + severity: Severity::Error, + message: code.to_string(), + span: None, + }) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] pub enum LimitKind { ResourceBytes, @@ -436,12 +446,7 @@ impl Materialization { } } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TargetSelection { - pub source: CacheKey, - pub span: Span, - pub target: CacheKey, -} +pub use polymodel_renderer_protocol::TargetSelection; #[derive(Clone)] pub struct ParseOptions<'a> { @@ -520,15 +525,6 @@ impl<'a> ParseOptions<'a> { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RootId { - CurrentMpd, - UploadedManifest, - UploadedLdraw, - OfficialLibrary, -} - #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct LimitCounters { pub resource_bytes: u64, diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index 943ab99..059cd42 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -10,8 +10,10 @@ //! worker crates, never here. use serde::{Deserialize, Serialize}; -use sha2::Digest; +use sha2::{Digest, Sha256}; +use smol_str::SmolStr; use std::collections::HashSet; +use std::fmt; mod load_state; pub mod format_registry { @@ -330,6 +332,9 @@ pub mod format_registry { if recognizes_threemf(bytes) { candidates.push(FormatId::Threemf); } + if recognizes_stl(bytes) { + candidates.push(FormatId::Stl); + } if let Ok(text) = core::str::from_utf8(bytes) { if recognizes_gltf_json(text) { candidates.push(FormatId::Gltf); @@ -337,6 +342,9 @@ pub mod format_registry { if recognizes_ldraw(text) { candidates.push(FormatId::Ldraw); } + if recognizes_obj(text) { + candidates.push(FormatId::Obj); + } } candidates.sort_unstable_by_key(|format| *format as u8); candidates.dedup(); @@ -389,6 +397,92 @@ pub mod format_registry { && (compact.contains("\"version\":\"2.0\"") || compact.contains("\"version\":\"1.0\"")) } + /// Binary STL: 80-byte header + little-endian u32 triangle count at [80..84] + /// whose implied total size equals the buffer length (80 + 4 + 50*count). + /// ASCII STL: starts with `solid` and contains `facet normal` within the + /// first evidence window. The binary size check takes priority so that + /// `solid`-prefixed binaries that fail the size check are rejected (known + /// ambiguous case; many binary STLs abuse the header keyword). + fn recognizes_stl(bytes: &[u8]) -> bool { + if bytes.len() >= 84 { + let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap_or_default()) as usize; + let expected = 84_usize.saturating_add(count.saturating_mul(50)); + if expected == bytes.len() { + return true; + } + } + // ASCII STL: must start with "solid" followed by whitespace, and + // contain "facet normal" within the evidence window. Binary STLs + // that abuse the `solid` header keyword but fail the size check + // will not contain `facet normal` as ASCII text, so they correctly + // fall through to `false`. + if bytes.starts_with(b"solid") { + match bytes.get(5) { + Some(b' ') | Some(b'\r') | Some(b'\n') => {} + _ => return false, + } + let text = core::str::from_utf8(bytes).unwrap_or(""); + // Search for `facet normal` in the first 8 KiB of evidence. + let window = &text[..text.len().min(8 * 1024)]; + if window.contains("facet normal") { + return true; + } + } + false + } + + /// OBJ: UTF-8 text with at least one geometric statement (v/vn/vt/f). + /// Lines starting with `v `, `vn `, `vt ` require parseable float fields; + /// `f `, `l `, `p `, `o `, `g `, `s `, `usemtl `, `mtllib ` provide + /// structural evidence. Comments/names alone are insufficient. + fn recognizes_obj(text: &str) -> bool { + let mut has_geometric = false; + for line in text.lines() { + let trimmed = line.trim_start(); + // v — requires parseable floats + if let Some(rest) = trimmed.strip_prefix("v ") { + if rest + .split_ascii_whitespace() + .take(3) + .all(|f| f.parse::().is_ok()) + { + has_geometric = true; + } + continue; + } + // vn + if let Some(rest) = trimmed.strip_prefix("vn ") { + if rest + .split_ascii_whitespace() + .take(3) + .all(|f| f.parse::().is_ok()) + { + has_geometric = true; + } + continue; + } + // vt [float] + if let Some(rest) = trimmed.strip_prefix("vt ") { + if rest + .split_ascii_whitespace() + .take(2) + .all(|f| f.parse::().is_ok()) + { + has_geometric = true; + } + continue; + } + // f — geometric + if trimmed.starts_with("f ") { + has_geometric = true; + continue; + } + // Other OBJ statements (l, p, vp, o, g, s, usemtl, mtllib) are + // structural, not geometric. + } + has_geometric + } + fn recognizes_ldraw(text: &str) -> bool { text.lines().any(|line| { let mut fields = line.split_ascii_whitespace(); @@ -608,6 +702,86 @@ pub mod format_registry { Evidence::One(FormatId::Ldraw) ); } + + #[test] + fn stl_binary_round_trip_and_near_misses() { + // Minimal valid binary STL: 80-byte header + 4-byte count (1) + 50 bytes per triangle. + let mut bin = vec![0u8; 84 + 50]; + bin[80..84].copy_from_slice(&1u32.to_le_bytes()); + assert_eq!(recognize_evidence(&bin), Evidence::One(FormatId::Stl)); + assert_eq!(canonical_mime_for_evidence(&bin), Some("model/stl")); + + // Truncated binary STL (wrong size for count=1). + let truncated = &bin[..84 + 40]; + assert_eq!(recognize_evidence(truncated), Evidence::None); + + // Binary with count=0 and no triangle data has exact size 84. + let zero_tri = vec![0u8; 84]; + assert_eq!(recognize_evidence(&zero_tri), Evidence::One(FormatId::Stl)); + + // Binary with count claiming 1 but buffer too small → not STL. + let mut too_short = vec![0u8; 84 + 10]; + too_short[80..84].copy_from_slice(&1u32.to_le_bytes()); + assert_eq!(recognize_evidence(&too_short), Evidence::None); + } + + #[test] + fn stl_ascii_recognized() { + let ascii = b"solid mesh\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid mesh\n"; + assert_eq!(recognize_evidence(ascii), Evidence::One(FormatId::Stl)); + assert_eq!(canonical_mime_for_evidence(ascii), Some("model/stl")); + } + + #[test] + fn stl_near_misses_do_not_classify() { + // ASCII `solid` without any `facet normal` → not STL. + assert_eq!( + recognize_evidence(b"solid mesh\nsome other content\nendsolid mesh\n"), + Evidence::None + ); + + // Plain text starting with "solid state drive manual" → not STL. + assert_eq!( + recognize_evidence(b"solid state drive manual contents here"), + Evidence::None + ); + + // `solid` without whitespace following → not STL. + assert_eq!(recognize_evidence(b"solidarity forever\n"), Evidence::None); + + // Plain text with no structure markers → None. + assert_eq!(recognize_evidence(b"hello world\n"), Evidence::None); + } + + #[test] + fn obj_recognized_with_geometric_lines() { + let obj = b"o Cube\nv 1.0 2.0 3.0\nv 4.0 5.0 6.0\nv 7.0 8.0 9.0\nf 1 2 3\n"; + assert_eq!(recognize_evidence(obj), Evidence::One(FormatId::Obj)); + assert_eq!(canonical_mime_for_evidence(obj), Some("model/obj")); + + // OBJ with v, vn, vt, f → recognized. + let obj2 = b"v 0 0 0\nvn 1 0 0\nvt 0.5 0.5\nf 1/1/1 2/2/1 3/3/1\n"; + assert_eq!(recognize_evidence(obj2), Evidence::One(FormatId::Obj)); + } + + #[test] + fn obj_with_only_comments_and_names_is_none() { + let comments = + b"# Blender v3.0\no Cube\ng Group1\ns 1\nusemtl Material\nmtllib cube.mtl\n"; + assert_eq!(recognize_evidence(comments), Evidence::None); + + // OBJ with a `v ` line that has non-parseable floats (letters) → no geometric evidence. + let bad_float = b"v abc def ghi\no Cube\n"; + assert_eq!(recognize_evidence(bad_float), Evidence::None); + } + + #[test] + fn over_bound_evidence_stays_over_bound() { + assert_eq!( + recognize_evidence(&[0; MAX_EVIDENCE_BYTES + 1]), + Evidence::OverBound + ); + } } } pub mod scene; @@ -1092,15 +1266,165 @@ pub struct ModelResource { pub bytes: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResolverPathError { + Absolute, + Escape, + Forbidden, + Empty, +} + +impl fmt::Display for ResolverPathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Absolute => "absolute path", + Self::Escape => "path escapes its root", + Self::Forbidden => "path contains forbidden syntax", + Self::Empty => "path is empty", + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +pub struct NormalizedPath(SmolStr); + +impl NormalizedPath { + pub fn new(path: &str) -> Result { + let normalized = path.replace('\\', "/"); + if normalized.starts_with('/') + || normalized.starts_with("//") + || (normalized.len() >= 2 + && normalized.as_bytes()[0].is_ascii_alphabetic() + && normalized.as_bytes()[1] == b':') + { + return Err(ResolverPathError::Absolute); + } + let mut parts = Vec::new(); + for part in normalized.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + if parts.pop().is_none() { + return Err(ResolverPathError::Escape); + } + } else if part + .bytes() + .any(|byte| byte == 0 || byte.is_ascii_control()) + { + return Err(ResolverPathError::Forbidden); + } else { + parts.push(part.to_ascii_lowercase()); + } + } + if parts.is_empty() { + return Err(ResolverPathError::Empty); + } + Ok(Self(parts.join("/").into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for NormalizedPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RootId { + CurrentMpd, + UploadedManifest, + UploadedLdraw, + OfficialLibrary, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +pub struct Sha256Hash([u8; 32]); + +impl Sha256Hash { + pub fn digest(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Display for Sha256Hash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct CacheKey { + pub canonical_path: NormalizedPath, + pub resolved_root: RootId, + pub content_hash: Sha256Hash, +} + +impl CacheKey { + pub fn new(path: NormalizedPath, root: RootId, bytes: &[u8]) -> Self { + Self { + canonical_path: path, + resolved_root: root, + content_hash: Sha256Hash::digest(bytes), + } + } + + pub fn new_from_parts<'a>( + path: NormalizedPath, + root: RootId, + parts: impl IntoIterator, + ) -> Self { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part); + } + Self { + canonical_path: path, + resolved_root: root, + content_hash: Sha256Hash(hasher.finalize().into()), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct Span { + pub start: u32, + pub end: u32, + pub line: u32, + pub column: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TargetSelection { + pub source: CacheKey, + pub span: Span, + pub target: CacheKey, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelLoadBundle { pub version: u16, pub format: MeshFormat, pub primary: String, pub resources: Vec, + pub source_identities: Vec, + pub target_selections: Vec, } pub const MAX_MODEL_RESOURCES: usize = 16_384; +pub const MAX_MODEL_TARGET_SELECTIONS: usize = 131_072; pub const MAX_MODEL_BUNDLE_BYTES: usize = 136 * 1024 * 1024; pub const MAX_MODEL_METADATA_BYTES: usize = 8 * 1024 * 1024; pub const MAX_MODEL_RESOURCE_BYTES: u64 = 128 * 1024 * 1024; @@ -1118,6 +1442,7 @@ pub enum ModelBundleError { InvalidResource, SizeMismatch, DigestMismatch, + InvalidTargetSelection, } impl ModelResource { @@ -1162,6 +1487,9 @@ impl ModelLoadBundle { if self.resources.len() > MAX_MODEL_RESOURCES { return Err(ModelBundleError::TooManyResources); } + if self.target_selections.len() > MAX_MODEL_TARGET_SELECTIONS { + return Err(ModelBundleError::MetadataExceeded); + } let mut keys = HashSet::with_capacity(self.resources.len()); let mut metadata = self.primary.len(); let mut total_bytes = 0u64; @@ -1190,6 +1518,34 @@ impl ModelLoadBundle { if !keys.contains(&self.primary) { return Err(ModelBundleError::PrimaryMissing); } + let identities = self + .resources + .iter() + .map(|resource| { + let path = NormalizedPath::new(&resource.path) + .map_err(|_| ModelBundleError::InvalidTargetSelection)?; + let root = match resource.root.as_str() { + "mpd" => RootId::CurrentMpd, + "manifest" => RootId::UploadedManifest, + "models" | "parts" | "p" | "external" => RootId::UploadedLdraw, + "official-parts" | "official-p" => RootId::OfficialLibrary, + _ => return Err(ModelBundleError::InvalidTargetSelection), + }; + Ok(CacheKey::new(path, root, &resource.bytes)) + }) + .collect::, _>>()?; + let source_identities = self + .source_identities + .iter() + .chain(identities.iter()) + .collect::>(); + for selection in &self.target_selections { + if !source_identities.contains(&selection.source) + || !identities.contains(&selection.target) + { + return Err(ModelBundleError::InvalidTargetSelection); + } + } Ok(()) } @@ -1469,6 +1825,8 @@ mod tests { model_resource("models/main.ldr", 1), model_resource("models/child.dat", 2), ], + source_identities: Vec::new(), + target_selections: Vec::new(), } } diff --git a/crates/polymodel-renderer-worker/src/lib.rs b/crates/polymodel-renderer-worker/src/lib.rs index acd7694..3ecb1a1 100644 --- a/crates/polymodel-renderer-worker/src/lib.rs +++ b/crates/polymodel-renderer-worker/src/lib.rs @@ -1328,9 +1328,6 @@ fn precision_aware_clip_planes( scale * CAMERA_DEPTH_ROUNDING_FLOOR }; let far = (max_depth + margin).max(near * (1.0 + f64::from(f32::EPSILON))); - if min_depth > 0.0 && far / CAMERA_DEPTH_RATIO_LIMIT > min_depth { - return Err(CameraDepthError::DepthRangeExceedsPrecision); - } near = near.max(far / CAMERA_DEPTH_RATIO_LIMIT); let near_f32 = finite_f32(near).map_err(|_| CameraDepthError::F32Conversion)?; let far_f32 = finite_f32(far).map_err(|_| CameraDepthError::F32Conversion)?; @@ -1388,7 +1385,11 @@ pub fn scene_camera_fit( position, near: projection.near, far: projection.far, - control_min: (distance * 0.02).max(0.001), + control_min: finite_f32( + (scene_radius_f64(bounds)? * CAMERA_DEPTH_ROUNDING_FLOOR).max(f64::from(f32::EPSILON)), + ) + .map_err(|_| CameraDepthError::F32Conversion)? + .min(distance), control_max: projection.far, }) } @@ -1574,7 +1575,7 @@ pub fn official_materializations( root_path: &NormalizedPath, root_bytes: &[u8], ) -> Result { - use polymodel_ldraw_core::{CacheKey, LdrawLimits}; + use polymodel_ldraw_core::CacheKey; use std::collections::{HashSet, VecDeque}; const MAX_FILES: usize = 4096; @@ -1619,37 +1620,15 @@ pub fn official_materializations( if current.depth > MAX_DEPTH { return Err(format!("official closure depth exceeded {MAX_DEPTH}")); } - let scanned = polymodel_ldraw_core::scan_lines(¤t.bytes, &LdrawLimits::default()) - .map_err(|error| format!("scan {}: {error}", current.path.as_str()))?; - let files = polymodel_ldraw_core::split_mpd(&scanned, &LdrawLimits::default()) - .map_err(|error| format!("split {}: {error}", current.path.as_str()))?; - let has_virtual_files = files.len() > 1 - || scanned.iter().any(|line| { - line.line_type == Some(polymodel_ldraw_core::LineType::Zero) - && line.tokens.get(1).map(|token| token.kind) - == Some(polymodel_ldraw_core::TokenKind::File) - }); - let source_keys = files - .iter() - .map(|file| { - let path = NormalizedPath::new(file.name.as_ref()) - .map_err(|error| format!("normalize source {}: {error}", file.name))?; - let key = if has_virtual_files { - CacheKey::new_from_parts( - path, - RootId::CurrentMpd, - file.lines - .iter() - .flat_map(|line| [line.raw, line.ending.as_bytes()]), - ) - } else { - current.key.clone() - }; - Ok::<_, String>((file.name.to_ascii_lowercase(), key)) - }) - .collect::, _>>()?; - let references = polymodel_ldraw_core::extract_references(¤t.bytes) - .map_err(|error| format!("scan {}: {error}", current.path.as_str()))?; + let inspection = polymodel_ldraw_core::inspect_compound_source( + ¤t.bytes, + ¤t.path, + current.key.resolved_root, + ) + .map_err(|error| format!("inspect {}: {error}", current.path.as_str()))?; + let source_keys = inspection.source_keys; + let has_virtual_files = inspection.has_virtual_files; + let references = inspection.references; for reference in references { if reference.target_virtual_file { continue; @@ -6495,6 +6474,7 @@ mod tests { let fit = scene_camera_fit(bounds, 1280, 720).expect("valid fit"); assert!((fit.center.x + 1_421.101_7).abs() < 1.0e-3); assert!(fit.near > 0.0 && fit.far > fit.near && fit.control_min > 0.0); + assert!(fit.control_min < (fit.position - fit.center).magnitude() * 0.02); assert_eq!(fit.control_max, fit.far); let projection = camera_space_clip_planes(bounds, fit.position, fit.center).expect("valid planes"); @@ -6503,24 +6483,20 @@ mod tests { } #[test] - fn clipping_preserves_wide_positive_scene_or_reports_precision_inability() { + fn clipping_raises_near_plane_for_close_positive_scene_without_precision_failure() { let bounds = SceneBounds { min: [-1.0, -1.0, -1.0], max: [1.0, 1.0, 1.0], }; - let position = three_d::vec3(0.0, -5_001.0, 0.0); + let position = three_d::vec3(0.0, -1.0001, 0.0); let target = three_d::vec3(0.0, 0.0, 0.0); - match camera_space_clip_planes(bounds, position, target) { - Ok(projection) => { - assert!(projection.near <= projection.min_depth); - assert!( - f64::from(projection.far) / f64::from(projection.near) - <= CAMERA_DEPTH_RATIO_LIMIT * 1.001 - ); - } - Err(CameraDepthError::DepthRangeExceedsPrecision) => {} - Err(error) => panic!("unexpected policy error: {error}"), - } + let projection = camera_space_clip_planes(bounds, position, target) + .expect("close-up clipping should remain renderable"); + assert!(projection.near > projection.min_depth); + assert!( + f64::from(projection.far) / f64::from(projection.near) + <= CAMERA_DEPTH_RATIO_LIMIT * 1.001 + ); } #[test] diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 2f69af1..6e06b4f 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -95,6 +95,8 @@ struct PreviewTarget { struct VerifiedCompoundSource { primary: String, resources: Vec, + source_identities: Vec, + target_selections: Vec, } fn descriptor_root(root: &str) -> Result { @@ -136,6 +138,27 @@ fn validate_bundle_resources( Ok(()) } +fn compound_materializations( + resources: &[polymodel_renderer_protocol::ModelResource], + primary: &str, +) -> Result, String> { + let mut materializations = Vec::with_capacity(resources.len().saturating_sub(1)); + for resource in resources { + if resource.path == primary { + continue; + } + let path = NormalizedPath::new(&resource.path).map_err(|error| error.to_string())?; + materializations.push(Materialization::new( + path, + descriptor_root(&resource.root)?, + resource.bytes.clone(), + true, + Some(format!("worker:{}", resource.key)), + )); + } + Ok(materializations) +} + fn parse_compound_source( source: VerifiedCompoundSource, owner: polymodel_renderer_ledger::ReservationOwner, @@ -152,18 +175,18 @@ fn parse_compound_source( .iter() .find(|resource| resource.path == source.primary) .ok_or_else(|| "model bundle primary resource missing".to_string())?; - let primary_root = descriptor_root(&primary.root)?; - let mut materializations = Vec::new(); - for resource in &source.resources { - let path = NormalizedPath::new(&resource.path).map_err(|error| error.to_string())?; - materializations.push(Materialization::new( - path, - descriptor_root(&resource.root)?, - resource.bytes.clone(), - true, - Some(format!("worker:{}", resource.key)), - )); - } + let primary_root = source + .source_identities + .first() + .map(|identity| identity.resolved_root) + .unwrap_or(descriptor_root(&primary.root)?); + let materializations = compound_materializations(&source.resources, &source.primary)?; + let target_selections = source.target_selections; + tracing::debug!( + primary = %source.primary, + resources = source.resources.len(), + "renderer worker: invoking compound parser" + ); let result = InProcessRustAdapter::parse(AdapterRequest { fixture_id: &source.primary, bytes: &primary.bytes, @@ -175,7 +198,7 @@ fn parse_compound_source( root_name: &source.primary, root: primary_root, materializations: &materializations, - target_selections: &[], + target_selections: &target_selections, cancellation: CancellationPolicy::default(), }) .map_err(|error| error.to_string())?; @@ -781,6 +804,84 @@ mod tests { } } + #[wasm_bindgen_test] + fn compound_materializations_exclude_the_primary_source() { + let resources = vec![ + polymodel_renderer_protocol::ModelResource { + key: "models/omr-10030-1.mpd".into(), + path: "models/omr-10030-1.mpd".into(), + root: "mpd".into(), + byte_length: 1, + sha256: vec![0; 32], + mime_type: "application/x-ldraw".into(), + target_cids: Vec::new(), + bytes: vec![0], + }, + polymodel_renderer_protocol::ModelResource { + key: "parts/2625.dat".into(), + path: "parts/2625.dat".into(), + root: "official-parts".into(), + byte_length: 1, + sha256: vec![0; 32], + mime_type: "application/x-ldraw".into(), + target_cids: Vec::new(), + bytes: vec![0], + }, + ]; + let materializations = compound_materializations(&resources, "models/omr-10030-1.mpd") + .expect("compound materializations should normalize"); + assert_eq!(materializations.len(), 1); + assert_eq!(materializations[0].path.as_str(), "parts/2625.dat"); + } + + #[wasm_bindgen_test] + fn compound_parser_uses_bundle_virtual_source_root_for_indexed_selections() { + let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 child.ldr\n0 FILE child.ldr\n3 16 0 0 0 1 0 0 0 1 0\n"; + let inspection = polymodel_ldraw_core::inspect_compound_source( + bytes, + &NormalizedPath::new("models/fixture.mpd").expect("fixture path"), + RootId::UploadedLdraw, + ) + .expect("compound inspection should succeed"); + let root_key = inspection.source_keys[0].1.clone(); + let child_key = inspection.source_keys[1].1.clone(); + let reference = &inspection.references[0]; + let source = VerifiedCompoundSource { + primary: "models/fixture.mpd".into(), + resources: vec![polymodel_renderer_protocol::ModelResource { + key: "models/fixture.mpd".into(), + path: "models/fixture.mpd".into(), + root: "models".into(), + byte_length: bytes.len() as u64, + sha256: vec![0; 32], + mime_type: "application/x-ldraw".into(), + target_cids: Vec::new(), + bytes: bytes.to_vec(), + }], + source_identities: inspection + .source_keys + .iter() + .map(|(_, key)| key.clone()) + .collect(), + target_selections: vec![polymodel_renderer_protocol::TargetSelection { + source: root_key, + span: polymodel_renderer_protocol::Span { + start: reference.span.start, + end: reference.span.end, + line: reference.span.line, + column: reference.span.column, + }, + target: child_key, + }], + }; + let ledger = ReservationLedger::new(); + let (_stats, scene) = + parse_compound_source(source, ReservationOwner::preview(79, 1, 1), &ledger) + .expect("uploaded-root MPD selections should materialize"); + assert_eq!(scene.stats.triangles, 1); + assert!(scene.bounds.is_some()); + } + #[wasm_bindgen_test] fn descriptor_roots_reject_malformed_and_colliding_labels() { for root in ["", "manifest/", "official", "models/../parts", "unknown"] { @@ -1757,6 +1858,9 @@ fn handle_compound_load( let state_clone = state.clone(); spawn_local(async move { let result = load_compound_source(&state_clone, namespace, identity, bundle, abort).await; + if let Err(detail) = &result { + tracing::error!(?namespace, ?identity, %detail, "renderer worker: compound bundle load failed"); + } finish_compound_source(&state_clone, namespace, identity, result); }); } @@ -1995,6 +2099,14 @@ async fn load_compound_source( bundle: polymodel_renderer_protocol::ModelLoadBundle, abort: Option, ) -> Result { + tracing::info!( + ?namespace, + ?identity, + primary = %bundle.primary, + resources = bundle.resources.len(), + bytes = bundle.resources.iter().map(|resource| resource.bytes.len()).sum::(), + "renderer worker: compound bundle received" + ); if bundle.version != polymodel_renderer_protocol::PROTOCOL_VERSION { return Err("model bundle protocol version mismatch".into()); } @@ -2005,6 +2117,13 @@ async fn load_compound_source( .validate() .map_err(|error| format!("model bundle rejected: {error:?}"))?; validate_bundle_resources(&bundle.primary, &bundle.resources)?; + tracing::debug!( + ?namespace, + ?identity, + primary = %bundle.primary, + resources = bundle.resources.len(), + "renderer worker: compound bundle validated" + ); compound_is_current(state, namespace, identity, abort.as_ref())?; let total_bytes = bundle.resources.iter().try_fold(0_u64, |total, resource| { total @@ -2031,7 +2150,16 @@ async fn load_compound_source( let source = VerifiedCompoundSource { primary: bundle.primary, resources: bundle.resources, + source_identities: bundle.source_identities, + target_selections: bundle.target_selections, }; + tracing::debug!( + ?namespace, + ?identity, + primary = %source.primary, + resources = source.resources.len(), + "renderer worker: compound bundle admitted" + ); drop(reservation); Ok(source) } @@ -2094,9 +2222,30 @@ fn finish_compound_source( return; } let parse_owner = load_owner(namespace, identity); + tracing::debug!( + ?namespace, + ?identity, + "renderer worker: parsing compound bundle" + ); let (stats, scene) = match parse_compound_source(source, parse_owner, &st.ledger) { - Ok(result) => result, + Ok((stats, scene)) => { + tracing::info!( + ?namespace, + ?identity, + vertices = stats.vertices, + triangles = stats.triangles, + has_bounds = scene.bounds.is_some(), + "renderer worker: compound parse succeeded" + ); + (stats, scene) + } Err(detail) => { + tracing::error!( + ?namespace, + ?identity, + error = %detail, + "renderer worker: compound parse failed" + ); let error = LoadError { code: LoadErrorCode::Parse, detail: detail.clone(), @@ -2688,6 +2837,63 @@ async fn bounded_error_body(response: &web_sys::Response) -> String { // Command dispatch // --------------------------------------------------------------------------- +fn log_command_summary(command: &RendererCommand) { + match command { + RendererCommand::StartLoad { + namespace, + identity, + source, + output, + } => match source { + polymodel_renderer_protocol::LoadSource::Bundle(bundle) => { + let keys = bundle + .resources + .iter() + .take(12) + .map(|resource| resource.key.as_str()) + .collect::>(); + tracing::info!( + ?namespace, + ?identity, + ?output, + primary = %bundle.primary, + resources = bundle.resources.len(), + bytes = bundle.resources.iter().map(|resource| resource.bytes.len()).sum::(), + resource_keys = ?keys, + keys_truncated = bundle.resources.len() > 12, + "renderer worker: compound StartLoad received" + ); + } + polymodel_renderer_protocol::LoadSource::Url { + format, + primary_url, + .. + } => tracing::debug!( + ?namespace, + ?identity, + ?output, + ?format, + primary_url = %primary_url, + "renderer worker: URL StartLoad received" + ), + }, + RendererCommand::Resize { css_w, css_h, dpr } => { + tracing::debug!(css_w, css_h, dpr, "renderer worker: Resize received") + } + RendererCommand::Cancel { + namespace, + identity, + } => { + tracing::debug!(?namespace, ?identity, "renderer worker: Cancel received") + } + RendererCommand::SetPointerState { .. } + | RendererCommand::PointerDown { .. } + | RendererCommand::PointerUp { .. } => {} + RendererCommand::Dispose => tracing::debug!("renderer worker: Dispose received"), + RendererCommand::DebugCrash => tracing::warn!("renderer worker: DebugCrash received"), + } +} + fn handle_command(state: &Rc>, cmd: RendererCommand) { let mut st = match state.try_borrow_mut() { Ok(st) => st, @@ -3037,7 +3243,7 @@ pub fn entry() { match st.handshake.accept_command(&envelope) { Ok(Some(cmd)) => { drop(st); - tracing::debug!(command = ?cmd, "renderer worker: command received"); + log_command_summary(&cmd); handle_command(&state_for_msg, cmd); } Ok(None) => { diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index d682b0e..225a919 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -11,16 +11,17 @@ use jacquard_common::types::string::{AtUri, Cid, Did}; use jacquard_common::xrpc::XrpcExt; use polymodel_api::com_atproto::sync::get_blob::GetBlob; use polymodel_api::space_polymodel::library::get_model_load_bundle::GetModelLoadBundleRequest; -use polymodel_ldraw_core::extract_references; use polymodel_renderer_protocol::{ - MAX_MODEL_METADATA_BYTES, MAX_MODEL_RESOURCE_BYTES, MAX_MODEL_RESOURCES, MeshFormat, - ModelLoadBundle, ModelResource, PROTOCOL_VERSION, + CacheKey as ProtocolCacheKey, MAX_MODEL_METADATA_BYTES, MAX_MODEL_RESOURCE_BYTES, + MAX_MODEL_RESOURCES, MeshFormat, ModelLoadBundle, ModelResource, PROTOCOL_VERSION, + RootId as ProtocolRootId, Span as ProtocolSpan, TargetSelection, }; use serde::Deserialize; #[cfg(test)] use serde::Serialize; use sha2::{Digest, Sha256}; -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; use super::error::{AppResult, db, internal, invalid_request, not_found}; use super::state::AppState; @@ -101,38 +102,222 @@ pub(super) async fn app_model_load_bundle( build_model_load_bundle(state, request.uri).await } +/// A resolved LDraw resource in a model-load bundle. DB-backed resources are +/// fetched from the user's PDS via blob CIDs; official-library resources are +/// read from the local filesystem fallback and carry inline bytes. +#[derive(Debug, Clone)] +enum BundleNode { + Db(Box), + Official(OfficialResource), +} + +/// A resource read from the local official LDraw library directory. Unlike +/// `ResolvedResource`, bytes are inline in the bundle (no PDS fetch, no blob +/// CIDs, no route binding). The SHA-256 is computed at read time and verified +/// client-side by `ModelResource::validate()`. +#[derive(Debug, Clone)] +struct OfficialResource { + root: RootId, + path: CanonicalPath, + bytes: Vec, + sha256: Vec, + mime_type: String, +} + +/// Resolve a bundle node: try the DB-backed resolver first, then probe the +/// official LDraw library using the shared permissive candidate policy. +async fn resolve_bundle_node( + resolver: &SqliteResolver, + project: &AtUri, + path: &CanonicalPath, + libdir: Option<&PathBuf>, +) -> AppResult { + tracing::debug!(path = %path, "resolving compound bundle path"); + match resolver.resolve(project, path).await { + Ok(resolved) => { + tracing::debug!(path = %path, source = "projection", "resolved compound bundle path"); + Ok(BundleNode::Db(Box::new(resolved))) + } + Err(crate::ldraw::ResolveError::NotFound) => { + tracing::debug!(path = %path, source = "projection", "compound bundle projection miss"); + let Some(libdir) = libdir else { + return Err(resolve_error(crate::ldraw::ResolveError::NotFound)); + }; + if path.segments().next().is_some_and(|root| { + !root.eq_ignore_ascii_case("parts") && !root.eq_ignore_ascii_case("p") + }) { + tracing::warn!(path = %path, "probing non-standard logical path in official LDraw library"); + } + let official = resolve_official_library(path, libdir).await; + tracing::debug!( + path = %path, + source = "official-library", + found = official.is_some(), + "resolved compound bundle official-library fallback" + ); + official.map(BundleNode::Official).ok_or_else(not_found) + } + Err(error) => Err(resolve_error(error)), + } +} + +/// Read an official LDraw library file from the filesystem using the shared +/// candidate policy. Case-insensitive matching is used for every path segment. +async fn resolve_official_library(path: &CanonicalPath, libdir: &Path) -> Option { + let normalized = polymodel_ldraw_core::NormalizedPath::new(path.as_str()).ok()?; + let candidates = polymodel_ldraw_core::official_library_candidates(&normalized).ok()?; + let (logical_path, file_path) = find_official_candidate(libdir, &candidates).await?; + let root = if logical_path.as_str().starts_with("parts/") { + RootId::OfficialParts + } else { + RootId::OfficialP + }; + let metadata = tokio::fs::metadata(&file_path).await.ok()?; + let file_size = metadata.len(); + if file_size > MAX_MODEL_RESOURCE_BYTES { + return None; + } + let bytes = tokio::fs::read(&file_path).await.ok()?; + if bytes.len() as u64 != file_size { + return None; + } + let sha256 = Sha256::digest(&bytes).to_vec(); + Some(OfficialResource { + root, + path: CanonicalPath::parse(logical_path.as_str()).ok()?, + bytes, + sha256, + mime_type: "application/x-ldraw".to_owned(), + }) +} + +async fn find_official_candidate( + libdir: &Path, + candidates: &[polymodel_ldraw_core::NormalizedPath], +) -> Option<(polymodel_ldraw_core::NormalizedPath, PathBuf)> { + let canonical_root = tokio::fs::canonicalize(libdir).await.ok()?; + for candidate in candidates { + let mut segments = candidate.as_str().split('/'); + let root = segments.next()?; + if !root.eq_ignore_ascii_case("parts") && !root.eq_ignore_ascii_case("p") { + continue; + } + let Some(mut current) = case_insensitive_lookup(libdir, root).await else { + continue; + }; + let mut found = true; + for segment in segments { + let Some(next) = case_insensitive_lookup(¤t, segment).await else { + found = false; + break; + }; + current = next; + } + if !found { + continue; + } + let Ok(canonical) = tokio::fs::canonicalize(¤t).await else { + continue; + }; + if canonical.starts_with(&canonical_root) && canonical.is_file() { + return Some((candidate.clone(), current)); + } + } + None +} + +/// Look up one path segment in a directory case-insensitively. Exact matches +/// win, while the fallback handles official files authored with loose casing. +async fn case_insensitive_lookup(dir: &Path, name: &str) -> Option { + let exact = dir.join(name); + if tokio::fs::metadata(&exact).await.is_ok() { + return Some(exact); + } + let mut entries = tokio::fs::read_dir(dir).await.ok()?; + while let Ok(Some(entry)) = entries.next_entry().await { + if entry + .file_name() + .to_str() + .is_some_and(|n| n.eq_ignore_ascii_case(name)) + { + return Some(entry.path()); + } + } + None +} + async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult { - let row = db(sqlx::query!( + let Some(row) = db(sqlx::query!( "SELECT project_uri, canonical_path FROM ldraw_resources WHERE resource_uri = ?", uri.as_ref(), ) .fetch_optional(&state.pool) .await)? - .ok_or_else(not_found)?; - let project = AtUri::new_owned(row.project_uri).map_err(|_| not_found())?; - let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; + else { + tracing::warn!(%uri, stage = "root_projection_miss", "compound model-load bundle root was not projected"); + return Err(not_found()); + }; + let project = match AtUri::new_owned(row.project_uri) { + Ok(project) => project, + Err(_) => { + tracing::error!(%uri, stage = "root_project_uri_invalid", "compound model-load bundle root has an invalid project URI"); + return Err(not_found()); + } + }; + let path = match CanonicalPath::parse(&row.canonical_path) { + Ok(path) => path, + Err(_) => { + tracing::error!(%uri, stage = "root_path_invalid", canonical_path = %row.canonical_path, "compound model-load bundle root has an invalid canonical path"); + return Err(not_found()); + } + }; let resolver = SqliteResolver::new(state.pool.clone()); - let mut resources = Vec::new(); - let mut seen_bindings = HashSet::new(); - let mut queue = VecDeque::from([path.clone()]); + let libdir = state.ldraw_library_dir.as_deref(); + tracing::info!( + %uri, + root = %path, + library_configured = libdir.is_some(), + "building compound LDraw model-load bundle" + ); + let mut resources: Vec<(BundleNode, Vec)> = Vec::new(); + let mut target_keys_by_path = HashMap::new(); + let root_node = resolve_bundle_node(&resolver, &project, &path, libdir).await?; + let root_bytes = match &root_node { + BundleNode::Db(resolved) => { + let binding = binding_for(&project, resolved)?; + fetch_verified_bytes(&state, resolved, binding.byte_length).await? + } + BundleNode::Official(official) => official.bytes.clone(), + }; + let root_root = match &root_node { + BundleNode::Db(resolved) => protocol_root(resolved.root), + BundleNode::Official(official) => protocol_root(official.root), + }; + let root_key = ProtocolCacheKey::new( + polymodel_renderer_protocol::NormalizedPath::new(path.as_str()) + .map_err(|_| invalid_request("invalid bundled root path"))?, + root_root, + &root_bytes, + ); + target_keys_by_path.insert(path.clone(), root_key.clone()); + let mut queue = VecDeque::from([(path.clone(), root_node, root_bytes, root_key)]); + let mut source_identities = Vec::new(); + let mut target_selections = Vec::new(); let mut manifest_edges = 0_usize; let mut total_bytes = 0_u64; - while let Some(current_path) = queue.pop_front() { - let current = resolver - .resolve(&project, ¤t_path) - .await - .map_err(resolve_error)?; - let current_binding = binding_for(&project, ¤t)?; - if !seen_bindings.insert(current_binding.clone()) { - continue; - } - if seen_bindings.len() > MAX_MODEL_RESOURCES { + while let Some((current_path, node, bytes, source_key)) = queue.pop_front() { + if resources.len() >= MAX_MODEL_RESOURCES { return Err(invalid_request( "compound resource descriptor bound exceeded", )); } - let projected_length = current_binding.byte_length; + let current_root = match &node { + BundleNode::Db(resolved) => protocol_root(resolved.root), + BundleNode::Official(official) => protocol_root(official.root), + }; + let projected_length = u64::try_from(bytes.len()) + .map_err(|_| invalid_request("compound resource byte length overflow"))?; let remaining_budget = MAX_MODEL_RESOURCE_BYTES .checked_sub(total_bytes) .ok_or_else(|| invalid_request("compound byte bound overflow"))?; @@ -142,93 +327,273 @@ async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult(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, - }) + let inspection = polymodel_ldraw_core::inspect_compound_source( + &bytes, + &polymodel_renderer_protocol::NormalizedPath::new(current_path.as_str()) + .map_err(|_| invalid_request("invalid bundled source path"))?, + current_root, + ) + .map_err(|_| not_found())?; + let source_keys = inspection.source_keys; + let has_virtual_files = inspection.has_virtual_files; + if has_virtual_files { + for (_, identity) in &source_keys { + if !source_identities.contains(identity) { + source_identities.push(identity.clone()); + } + } + } + let references = inspection.references; + let source = match &node { + BundleNode::Db(_) => "projection", + BundleNode::Official(_) => "official-library", + }; + let reachable_sections = reachable_mpd_sections(¤t_path, &references); + let eligible_references = references + .iter() + .filter(|reference| { + !reference.target_virtual_file + && reachable_sections.contains(&reference.file_ordinal) }) - .collect::>>()?; - let mut children = Vec::with_capacity(manifest.len()); - let mut referenced_paths = HashSet::new(); - for reference in references + .count(); + let virtual_target_references = references .iter() - .filter(|reference| reference_is_in_current_file(¤t.path, reference)) - { + .filter(|reference| reference.target_virtual_file) + .count(); + let other_section_references = references + .iter() + .filter(|reference| { + !reference.target_virtual_file + && !reachable_sections.contains(&reference.file_ordinal) + }) + .count(); + let reference_names = references + .iter() + .filter(|reference| { + !reference.target_virtual_file + && reachable_sections.contains(&reference.file_ordinal) + }) + .take(12) + .map(|reference| reference.target.as_str()) + .collect::>(); + tracing::info!( + path = %current_path, + source, + bytes = bytes.len(), + extracted_references = references.len(), + eligible_references, + virtual_target_references, + other_section_references, + reference_names = ?reference_names, + "resolved compound bundle resource" + ); + + // Manifest rows only exist for DB-backed project resources. + let manifest = if let BundleNode::Db(resolved) = &node { + let rows = db( + sqlx::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", + resolved.resource.uri.as_ref(), + ) + .fetch_all(&state.pool) + .await, + )?; + 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::>>()? + } else { + Vec::new() + }; + + let mut children = Vec::with_capacity(manifest.len()); + for reference in references.iter().filter(|reference| { + !reference.target_virtual_file && reachable_sections.contains(&reference.file_ordinal) + }) { 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 = core_reference_path(¤t.path, reference).ok_or_else(not_found)?; - let child = resolver - .resolve(&project, &child_path) - .await - .map_err(resolve_error)?; - validate_manifest_child(¤t, &child, &manifest)?; - push_first_seen(&mut children, &mut referenced_paths, child.path); + let child_path = core_reference_path(¤t_path, reference).ok_or_else(not_found)?; + let source_key = if has_virtual_files { + source_keys + .iter() + .find(|(name, _)| name == &reference.file_name.to_ascii_lowercase()) + .map(|(_, key)| key.clone()) + .ok_or_else(not_found)? + } else { + source_key.clone() + }; + let child_key = if let Some(child_key) = target_keys_by_path.get(&child_path) { + child_key.clone() + } else { + // Resolve, read, and key a new child before queueing it. Duplicate + // references take the cache-key branch above and do no I/O. + let child = if let BundleNode::Db(resolved) = &node { + match resolver.resolve(&project, &child_path).await { + Ok(child) => { + validate_manifest_child(resolved, &child, &manifest)?; + let child_bytes = fetch_verified_bytes( + &state, + &child, + u64::try_from(child.byte_length) + .map_err(|_| invalid_request("negative child length"))?, + ) + .await?; + (BundleNode::Db(Box::new(child)), child_bytes) + } + Err(crate::ldraw::ResolveError::NotFound) if libdir.is_some() => { + let official = resolve_official_library( + &child_path, + libdir.expect("checked above"), + ) + .await + .ok_or_else(not_found)?; + (BundleNode::Official(official.clone()), official.bytes) + } + Err(error) => return Err(resolve_error(error)), + } + } else { + let official = + resolve_official_library(&child_path, libdir.ok_or_else(not_found)?) + .await + .ok_or_else(not_found)?; + (BundleNode::Official(official.clone()), official.bytes) + }; + let requested_child_path = child_path.clone(); + let child_path = match &child.0 { + BundleNode::Db(resolved) => resolved.path.clone(), + BundleNode::Official(official) => official.path.clone(), + }; + let child_root = match &child.0 { + BundleNode::Db(resolved) => protocol_root(resolved.root), + BundleNode::Official(official) => protocol_root(official.root), + }; + let child_key = ProtocolCacheKey::new( + polymodel_renderer_protocol::NormalizedPath::new(child_path.as_str()) + .map_err(|_| invalid_request("invalid child path"))?, + child_root, + &child.1, + ); + target_keys_by_path.insert(requested_child_path, child_key.clone()); + target_keys_by_path.insert(child_path.clone(), child_key.clone()); + children.push((child_path, child.0, child.1, child_key.clone())); + child_key + }; + target_selections.push(TargetSelection { + source: source_key, + span: ProtocolSpan { + start: reference.span.start, + end: reference.span.end, + line: reference.span.line, + column: reference.span.column, + }, + target: child_key, + }); } - // Manifest-only companions: entries present in the manifest but not - // reachable through a type-1 reference in the source bytes (e.g. an - // empty companion FILE section). These are discovered via the manifest - // query, not via reference extraction. + // Manifest-only companions are enqueued with their bytes so the BFS + // never performs a second resolver/fetch pass. for entry in &manifest { - if !referenced_paths.contains(&entry.path) { + if !target_keys_by_path.contains_key(&entry.path) { 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(&project, &entry.path) - .await - .map_err(resolve_error)?; - push_first_seen(&mut children, &mut referenced_paths, child.path); + let Some(resolved) = (match &node { + BundleNode::Db(_) => match resolver.resolve(&project, &entry.path).await { + Ok(child) => Some(BundleNode::Db(Box::new(child))), + Err(crate::ldraw::ResolveError::NotFound) if libdir.is_some() => { + resolve_official_library(&entry.path, libdir.unwrap()) + .await + .map(BundleNode::Official) + } + Err(error) => return Err(resolve_error(error)), + }, + BundleNode::Official(_) => None, + }) else { + continue; + }; + let child_bytes = match &resolved { + BundleNode::Db(child) => { + fetch_verified_bytes( + &state, + child, + u64::try_from(child.byte_length) + .map_err(|_| invalid_request("negative child length"))?, + ) + .await? + } + BundleNode::Official(child) => child.bytes.clone(), + }; + let resolved_path = match &resolved { + BundleNode::Db(child) => child.path.clone(), + BundleNode::Official(child) => child.path.clone(), + }; + let child_root = match &resolved { + BundleNode::Db(child) => protocol_root(child.root), + BundleNode::Official(child) => protocol_root(child.root), + }; + let child_key = ProtocolCacheKey::new( + polymodel_renderer_protocol::NormalizedPath::new(resolved_path.as_str()) + .map_err(|_| invalid_request("invalid companion path"))?, + child_root, + &child_bytes, + ); + target_keys_by_path.insert(entry.path.clone(), child_key.clone()); + target_keys_by_path.insert(resolved_path.clone(), child_key.clone()); + children.push((resolved_path, resolved, child_bytes, child_key)); } } - resources.push((current, bytes)); - for child_path in children { - queue.push_back(child_path); + resources.push((node, bytes)); + for (child_path, child_node, child_bytes, child_key) in children { + queue.push_back((child_path, child_node, child_bytes, child_key)); } } let mut bundled = Vec::with_capacity(resources.len()); - let mut seen_keys = HashSet::new(); let mut metadata_bytes = path.as_str().len(); - for (resource, bytes) in resources { - let binding = binding_for(&project, &resource)?; - let key = resource.path.to_string(); - if !seen_keys.insert(key.clone()) { - continue; - } - let bundled_resource = ModelResource { - key: key.clone(), - path: key, - root: resource.root.as_str().to_owned(), - byte_length: binding.byte_length, - sha256: binding.sha256, - mime_type: resource.mime_type, - target_cids: binding.target_cids, - bytes, + for (node, bytes) in &resources { + let bundled_resource = match node { + BundleNode::Db(resolved) => { + let binding = binding_for(&project, resolved)?; + ModelResource { + key: resolved.path.to_string(), + path: resolved.path.to_string(), + root: resolved.root.as_str().to_owned(), + byte_length: binding.byte_length, + sha256: binding.sha256, + mime_type: resolved.mime_type.clone(), + target_cids: binding.target_cids, + bytes: bytes.clone(), + } + } + BundleNode::Official(official) => { + let byte_length = u64::try_from(official.bytes.len()) + .map_err(|_| invalid_request("official resource byte length overflow"))?; + ModelResource { + key: official.path.to_string(), + path: official.path.to_string(), + root: official.root.as_str().to_owned(), + byte_length, + sha256: official.sha256.clone(), + mime_type: official.mime_type.clone(), + target_cids: Vec::new(), + bytes: bytes.clone(), + } + } }; bundled_resource .validate() @@ -259,6 +624,8 @@ async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult Result { }) } +fn protocol_root(root: RootId) -> ProtocolRootId { + match root { + RootId::Mpd => ProtocolRootId::CurrentMpd, + RootId::Manifest => ProtocolRootId::UploadedManifest, + RootId::Models | RootId::Parts | RootId::P | RootId::External => { + ProtocolRootId::UploadedLdraw + } + RootId::OfficialParts | RootId::OfficialP => ProtocolRootId::OfficialLibrary, + } +} + fn parse_root(value: &str) -> Option { Some(match value { "mpd" => RootId::Mpd, @@ -419,28 +797,52 @@ fn validate_manifest_child( Ok(()) } -fn reference_is_in_current_file( +fn reachable_mpd_sections( 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 -} + references: &[polymodel_ldraw_core::MpdReference], +) -> HashSet { + if !references.iter().any(|reference| reference.virtual_file) { + return references + .iter() + .map(|reference| reference.file_ordinal) + .collect(); + } + if let Some(section) = current.as_str().strip_prefix("mpd/") { + return references + .iter() + .filter(|reference| reference.file_name.eq_ignore_ascii_case(section)) + .map(|reference| reference.file_ordinal) + .collect(); + } -fn push_first_seen( - children: &mut Vec, - seen: &mut HashSet, - path: CanonicalPath, -) { - if seen.insert(path.clone()) { - children.push(path); + let mut sections_by_name = HashMap::new(); + for reference in references { + sections_by_name + .entry(reference.file_name.to_ascii_lowercase()) + .or_insert(reference.file_ordinal); + if let Some((_, suffix)) = reference.file_name.rsplit_once(" - ") { + sections_by_name + .entry(suffix.to_ascii_lowercase()) + .or_insert(reference.file_ordinal); + } + } + + let mut reachable = HashSet::from([0_usize]); + let mut worklist = VecDeque::from([0_usize]); + while let Some(file_ordinal) = worklist.pop_front() { + for reference in references.iter().filter(|reference| { + reference.file_ordinal == file_ordinal && reference.target_virtual_file + }) { + let target = reference.target.to_ascii_lowercase(); + let Some(&target_ordinal) = sections_by_name.get(&target) else { + continue; + }; + if reachable.insert(target_ordinal) { + worklist.push_back(target_ordinal); + } + } } + reachable } fn core_reference_path( @@ -728,6 +1130,7 @@ mod tests { }; let relative = polymodel_ldraw_core::MpdReference { file_name: "main.ldr".into(), + file_ordinal: 0, target: "child.dat".into(), span, virtual_file: false, @@ -765,6 +1168,7 @@ mod tests { } let mpd = polymodel_ldraw_core::MpdReference { file_name: "main.ldr".into(), + file_ordinal: 0, target: "Child.DAT".into(), span, virtual_file: true, @@ -776,6 +1180,41 @@ mod tests { ); } + #[test] + fn mismatched_uploaded_mpd_name_keeps_first_declared_root_section() { + let bytes = b"0 FILE 10030 - main.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 3001.dat\n0 FILE child.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 3002.dat\n0 NOFILE\n"; + let references = polymodel_ldraw_core::extract_references(bytes).unwrap(); + let uploaded = CanonicalPath::parse("models/omr-10030-1.mpd").unwrap(); + let reachable = reachable_mpd_sections(&uploaded, &references); + let eligible = references + .iter() + .filter(|reference| { + !reference.target_virtual_file && reachable.contains(&reference.file_ordinal) + }) + .map(|reference| reference.target.as_str()) + .collect::>(); + assert_eq!(eligible, ["3001.dat"]); + } + + #[test] + fn real_mismatched_mpd_root_exposes_ordinary_references() { + let references = polymodel_ldraw_core::extract_references(include_bytes!( + "../../crates/polymodel-ldraw-testkit/corpus/omr-10030-1.mpd" + )) + .unwrap(); + let uploaded = CanonicalPath::parse("models/omr-10030-1.mpd").unwrap(); + let reachable = reachable_mpd_sections(&uploaded, &references); + let root_references = references.iter().filter(|reference| { + !reference.target_virtual_file && reachable.contains(&reference.file_ordinal) + }); + assert!( + root_references + .clone() + .any(|reference| reference.target == "2625.dat") + ); + assert!(root_references.count() > 10); + } + #[test] fn reference_joins_fail_closed_and_mpd_sections_are_isolated() { let current = CanonicalPath::parse("mpd/sub/root.ldr").unwrap(); @@ -787,6 +1226,7 @@ mod tests { }; let malformed = polymodel_ldraw_core::MpdReference { file_name: "sub/root.ldr".into(), + file_ordinal: 0, target: "../escape.dat".into(), span, virtual_file: false, @@ -795,6 +1235,7 @@ mod tests { assert!(core_reference_path(¤t, &malformed).is_none()); let first = polymodel_ldraw_core::MpdReference { file_name: "sub/root.ldr".into(), + file_ordinal: 0, target: "child.dat".into(), span, virtual_file: true, @@ -802,18 +1243,25 @@ mod tests { }; let second = polymodel_ldraw_core::MpdReference { file_name: "child.dat".into(), + file_ordinal: 1, target: "root.ldr".into(), span, virtual_file: true, target_virtual_file: true, }; - assert!(reference_is_in_current_file(¤t, &first)); - assert!(!reference_is_in_current_file(¤t, &second)); + let references = [first.clone(), second.clone()]; + let reachable = reachable_mpd_sections(¤t, &references); + assert!(reachable.contains(&first.file_ordinal)); + assert!(!reachable.contains(&second.file_ordinal)); 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); + if seen.insert(child.clone()) { + children.push(child.clone()); + } + if seen.insert(child.clone()) { + children.push(child); + } assert_eq!(children.len(), 1); } @@ -922,4 +1370,242 @@ mod tests { "parts/s/child.dat" )); } + + // --- Official LDraw library filesystem fallback tests --- + + use super::super::test_support::{self as test_support, seed_compound_resource}; + use polymodel_renderer_protocol::ModelLoadBundle; + use sha2::Digest; + use std::collections::HashMap; + + const DID_A: &str = test_support::DID_A; + + /// Build minimal valid LDraw bytes for a part that references a primitive. + fn ldraw_part_bytes() -> Vec { + // A part referencing p/4-4disc.dat via type-1 line. + b"0 FILE foo.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 4-4disc.dat\n".to_vec() + } + + /// Build minimal valid LDraw bytes for a primitive (no references). + fn ldraw_primitive_bytes() -> Vec { + b"0 Unofficial primitive\n3 16 0 0 0 1 0 0 0 1 0\n".to_vec() + } + + #[tokio::test] + async fn official_library_fallback_resolves_parts_and_primitives() { + let project = format!("at://{DID_A}/space.polymodel.library.thing/mpd"); + let root_uri = format!("at://{DID_A}/space.polymodel.library.part/root"); + + // Root MPD: references parts/foo.dat twice. + let root_bytes = b"0 FILE main.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/foo.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/foo.dat\n".to_vec(); + let (root_cid, _) = test_support::test_blob(&root_bytes); + + // Serve root_bytes from the loopback PDS. + let (pds, _server) = test_support::loopback_pds_with_blobs(HashMap::from([( + root_cid.clone(), + root_bytes.clone(), + )])) + .await; + + // Create tempdir fake official library. + let tmp = tempfile::tempdir().unwrap(); + let libdir = tmp.path(); + tokio::fs::create_dir_all(libdir.join("parts")) + .await + .unwrap(); + tokio::fs::create_dir_all(libdir.join("p")).await.unwrap(); + let part_bytes = ldraw_part_bytes(); + let prim_bytes = ldraw_primitive_bytes(); + tokio::fs::write(libdir.join("parts").join("foo.dat"), &part_bytes) + .await + .unwrap(); + tokio::fs::write(libdir.join("p").join("4-4disc.dat"), &prim_bytes) + .await + .unwrap(); + + // Set up DB with root resource. + let pool = test_support::pool().await; + seed_compound_resource( + &pool, + &project, + &root_uri, + "models", + "models/main.ldr", + &root_bytes, + &root_cid, + ) + .await; + + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap"); + let state = AppState::new_with_resolver_and_library( + pool, + bootstrap, + test_support::loopback_resolver(&pds), + Some(libdir.to_owned()), + ); + let app = crate::appview::router().with_state(state); + + let request = axum::http::Request::builder() + .method("POST") + .uri("/app/model-load-bundle") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({"uri": root_uri}).to_string(), + )) + .unwrap(); + let response = tower::ServiceExt::oneshot(app, request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = http_body_util::BodyExt::collect(response.into_body()) + .await + .unwrap() + .to_bytes(); + let bundle = ModelLoadBundle::decode(&body).unwrap(); + assert!(bundle.validate().is_ok()); + let keys: Vec<&str> = bundle.resources.iter().map(|r| r.key.as_str()).collect(); + assert!(keys.contains(&"models/main.ldr"), "missing root: {keys:?}"); + assert!(keys.contains(&"parts/foo.dat"), "missing part: {keys:?}"); + assert!( + keys.contains(&"p/4-4disc.dat"), + "missing primitive: {keys:?}" + ); + assert_eq!( + bundle.resources.len(), + 3, + "duplicate references must not duplicate resources" + ); + assert_eq!( + bundle.target_selections.len(), + 3, + "duplicate references must retain both edges" + ); + + // Digests match. + let part_resource = bundle + .resources + .iter() + .find(|r| r.key == "parts/foo.dat") + .unwrap(); + assert_eq!(&part_resource.sha256[..], &Sha256::digest(&part_bytes)[..]); + assert_eq!(part_resource.bytes, part_bytes); + assert_eq!(part_resource.root, "official-parts"); + assert_eq!(part_resource.mime_type, "application/x-ldraw"); + assert!(part_resource.target_cids.is_empty()); + + let prim_resource = bundle + .resources + .iter() + .find(|r| r.key == "p/4-4disc.dat") + .unwrap(); + assert_eq!(&prim_resource.sha256[..], &Sha256::digest(&prim_bytes)[..]); + assert_eq!(prim_resource.bytes, prim_bytes); + assert_eq!(prim_resource.root, "official-p"); + assert!(prim_resource.target_cids.is_empty()); + } + + #[tokio::test] + async fn official_library_missing_part_returns_404() { + let project = format!("at://{DID_A}/space.polymodel.library.thing/mpd"); + let root_uri = format!("at://{DID_A}/space.polymodel.library.part/root"); + let root_bytes = + b"0 FILE main.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/nonexistent.dat\n".to_vec(); + let (root_cid, _) = test_support::test_blob(&root_bytes); + let (pds, _server) = test_support::loopback_pds_with_blobs(HashMap::from([( + root_cid.clone(), + root_bytes.clone(), + )])) + .await; + + // Empty official library (no files). + let tmp = tempfile::tempdir().unwrap(); + let libdir = tmp.path(); + tokio::fs::create_dir_all(libdir.join("parts")) + .await + .unwrap(); + + let pool = test_support::pool().await; + seed_compound_resource( + &pool, + &project, + &root_uri, + "models", + "models/main.ldr", + &root_bytes, + &root_cid, + ) + .await; + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap"); + let state = AppState::new_with_resolver_and_library( + pool, + bootstrap, + test_support::loopback_resolver(&pds), + Some(libdir.to_owned()), + ); + let app = crate::appview::router().with_state(state); + + let request = axum::http::Request::builder() + .method("POST") + .uri("/app/model-load-bundle") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({"uri": root_uri}).to_string(), + )) + .unwrap(); + let response = tower::ServiceExt::oneshot(app, request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); + } + + #[test] + fn canonical_path_rejects_traversal_attempts() { + // CanonicalPath parsing already enforces `..` and `%` rejection. + assert!(CanonicalPath::parse("parts/../etc/passwd").is_err()); + assert!(CanonicalPath::parse("parts/%2e%2e/escape.dat").is_err()); + assert!(CanonicalPath::parse("p/../../escape.dat").is_err()); + assert!(CanonicalPath::parse("parts/foo.dat").is_ok()); + } + + #[tokio::test] + async fn no_library_dir_behavior_unchanged() { + // With ldraw_library_dir = None, the official fallback does not activate. + // A reference to parts/foo.dat with no DB row and no library dir → 404. + let project = format!("at://{DID_A}/space.polymodel.library.thing/mpd"); + let root_uri = format!("at://{DID_A}/space.polymodel.library.part/root"); + let root_bytes = b"0 FILE main.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 parts/foo.dat\n".to_vec(); + let (root_cid, _) = test_support::test_blob(&root_bytes); + let (pds, _server) = test_support::loopback_pds_with_blobs(HashMap::from([( + root_cid.clone(), + root_bytes.clone(), + )])) + .await; + + let pool = test_support::pool().await; + seed_compound_resource( + &pool, + &project, + &root_uri, + "models", + "models/main.ldr", + &root_bytes, + &root_cid, + ) + .await; + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap"); + // No library dir: None + let state = + AppState::new_with_resolver(pool, bootstrap, test_support::loopback_resolver(&pds)); + let app = crate::appview::router().with_state(state); + + let request = axum::http::Request::builder() + .method("POST") + .uri("/app/model-load-bundle") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({"uri": root_uri}).to_string(), + )) + .unwrap(); + let response = tower::ServiceExt::oneshot(app, request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); + } } diff --git a/src/indexing/config.rs b/src/indexing/config.rs index 5650cd1..11451e3 100644 --- a/src/indexing/config.rs +++ b/src/indexing/config.rs @@ -28,9 +28,10 @@ pub struct ServerConfig { /// Optional DID/PDS resolver base used by deterministic local fixtures. /// Production deployments leave this unset and use the public resolver. pub resolver_base_url: Option, - /// Optional immutable official LDraw library root. The directory must - /// contain the standard `parts/` and `p/` subdirectories; it is never - /// exposed to browser code and is only read by server-side compound routes. + /// Optional immutable official LDraw library root from `LDRAWDIR`. The + /// directory must contain the standard `parts/` and `p/` subdirectories. It + /// is never exposed to browser code and is only read by server-side compound + /// routes. pub ldraw_library_dir: Option, /// Whether to start the Hydrant firehose/indexing pipeline. Defaults to /// `on`; local browser/demo runs can set `POLYMODEL_INDEXING=off` to keep @@ -95,7 +96,7 @@ impl ServerConfig { .ok() .map(|s| s.trim_end_matches('/').to_owned()) .filter(|s| !s.is_empty()); - let ldraw_library_dir = env::var_os("POLYMODEL_LDRAW_LIBRARY_DIR") + let ldraw_library_dir = env::var_os("LDRAWDIR") .map(PathBuf::from) .filter(|path| path.is_dir()); let indexing = !matches!( diff --git a/src/main.rs b/src/main.rs index 5ba9a55..a62ccce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,6 +78,21 @@ fn main() { dioxus::serve(|| async { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); // Hydrant selects AWS-LC while other deps enable ring; choose before websocket TLS setup. let cfg = indexing::config::ServerConfig::load(); + tracing::info!( + library_dir = cfg + .ldraw_library_dir + .as_deref() + .map_or("".to_owned(), |path| path.display().to_string()), + library_parts = cfg + .ldraw_library_dir + .as_deref() + .is_some_and(|path| path.join("parts").is_dir()), + library_primitives = cfg + .ldraw_library_dir + .as_deref() + .is_some_and(|path| path.join("p").is_dir()), + "official LDraw library configuration" + ); let db = indexing::db::init_db(&cfg) .await .expect("failed to initialize sqlite projection database"); diff --git a/src/publish/draft.rs b/src/publish/draft.rs index f54fcef..0164f6b 100644 --- a/src/publish/draft.rs +++ b/src/publish/draft.rs @@ -253,6 +253,8 @@ pub fn plan_draft_ldraw_bundle( format: MeshFormat::CompoundLdraw, primary: primary_path.as_str().to_owned(), resources, + source_identities: Vec::new(), + target_selections: Vec::new(), }; match bundle.validate() { Ok(()) => DraftBundlePlan::Complete(bundle), diff --git a/src/viewer.rs b/src/viewer.rs index e9ee0d7..931b523 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -620,27 +620,65 @@ fn start_compound_plan_load( "/xrpc/space.polymodel.library.getModelLoadBundle?uri={}", urlencoding::encode(&logical.key) ); + tracing::info!( + key = %logical.key, + restart_epoch = expected_restart_epoch, + "viewer: requesting compound model-load bundle" + ); let result = client .app_bytes::>(http::Method::GET, &path, None) .await .and_then(|bytes| { + tracing::info!( + key = %logical.key, + response_bytes = bytes.len(), + "viewer: compound model-load bundle response received" + ); ModelLoadBundle::decode(&bytes) .map_err(|error| crate::client::RawError::Decode(format!("{error:?}"))) }); let current = *restart_epoch.read() == expected_restart_epoch && expected_intent.read().as_ref() == Some(&logical); if !current { + tracing::warn!( + key = %logical.key, + restart_epoch = expected_restart_epoch, + "viewer: discarded stale compound model-load bundle" + ); return; } match result { - Ok(bundle) => pending.set(Some(PendingCompoundLoad { - logical, - bundle, - acquisition_epoch: expected_restart_epoch, - })), - Err(error) => status.set(ViewerStatus::MeshError(format!( - "model bundle acquisition failed: {error}" - ))), + Ok(bundle) => { + let keys = bundle + .resources + .iter() + .take(12) + .map(|resource| resource.key.as_str()) + .collect::>(); + tracing::info!( + key = %logical.key, + primary = %bundle.primary, + resources = bundle.resources.len(), + resource_keys = ?keys, + keys_truncated = bundle.resources.len() > 12, + "viewer: decoded compound model-load bundle" + ); + pending.set(Some(PendingCompoundLoad { + logical, + bundle, + acquisition_epoch: expected_restart_epoch, + })); + } + Err(error) => { + tracing::error!( + key = %logical.key, + error = %error, + "viewer: compound model-load bundle acquisition or decode failed" + ); + status.set(ViewerStatus::MeshError(format!( + "model bundle acquisition failed: {error}" + ))); + } } }); } @@ -2678,6 +2716,8 @@ mod tests { target_cids: vec![], bytes: vec![], }], + source_identities: Vec::new(), + target_selections: Vec::new(), }; let mut reducer = LoadReducer::new(RendererNamespace::Interactive); diff --git a/tools/in-dev-shell b/tools/in-dev-shell index da830c0..67f9172 100755 --- a/tools/in-dev-shell +++ b/tools/in-dev-shell @@ -2,20 +2,33 @@ set -euo pipefail if (($# == 0)); then - echo "usage: tools/in-dev-shell _private-recipe [args ...]" >&2 + echo "usage: tools/in-dev-shell [--print-dev-env | _private-recipe [args ...]]" >&2 exit 2 fi -private_recipe=$1 -shift +print_dev_env=0 +if [[ $1 == --print-dev-env ]]; then + print_dev_env=1 + shift +elif [[ $1 != _* ]]; then + echo "error: expected --print-dev-env or a private Just recipe" >&2 + exit 2 +fi -if ! just_bin=$(command -v just); then - echo "error: just is required to dispatch Polymodel recipes" >&2 - exit 127 +private_recipe=${1:-} +if ((print_dev_env == 0)); then + shift fi -if [[ "${POLYMODEL_NIX_SHELL:-}" == 1 && -n "${LDRAWDIR:-}" && -d "${LDRAWDIR}" ]]; then - exec "$just_bin" "$private_recipe" "$@" +if ((print_dev_env == 0)); then + if ! just_bin=$(command -v just); then + echo "error: just is required to dispatch Polymodel recipes" >&2 + exit 127 + fi + + if [[ "${POLYMODEL_NIX_SHELL:-}" == 1 && -n "${LDRAWDIR:-}" && -d "${LDRAWDIR}" ]]; then + exec "$just_bin" "$private_recipe" "$@" + fi fi if ! nix_bin=$(command -v nix); then @@ -46,5 +59,9 @@ if [[ -e "$root/.jj" && ! -e "$root/.git" ]]; then fi fi +if ((print_dev_env)); then + exec "$nix_bin" print-dev-env "$flake_ref" +fi + printf '==> entering Polymodel dev shell for: %s\n' "${private_recipe#_}" >&2 exec "$nix_bin" develop "$flake_ref" --command "$just_bin" "$private_recipe" "$@" -- 2.51.2