use axum::body::Body; use axum::extract::{Path as AxumPath, State}; use axum::http::{StatusCode, header}; use axum::response::Response; use base64::Engine as _; use futures::TryStreamExt; use futures::stream; use jacquard::identity::resolver::IdentityResolver; use jacquard_axum::ExtractXrpc; 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_renderer_protocol::{ 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::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use super::error::{AppResult, db, internal, invalid_request, not_found, verify_chunk_cid}; use super::state::AppState; use crate::ldraw::resolve::{ResolvedResource, SqliteResolver}; use crate::ldraw::{CanonicalPath, LdrawResourceResolver, RootId}; const ROUTE_PREFIX: &str = "/app/ldraw/resources/"; const ROUTE_VERSION: u8 = 1; const MAX_MANIFEST_EDGES: usize = MAX_MODEL_RESOURCES * 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 ModelBundleRequest { pub uri: AtUri, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(super) struct RouteBinding { version: u8, project: AtUri, publisher: Did, source: AtUri, root: RootId, path: CanonicalPath, target_cids: Vec, byte_length: u64, sha256: Vec, } #[cfg(test)] #[derive(Debug, Serialize)] struct RoutePayload<'a> { v: u8, project: &'a AtUri, publisher: &'a Did, source: &'a AtUri, root: &'a str, path: &'a str, cids: &'a [String], len: u64, sha256: &'a [u8], } #[derive(Debug, Deserialize)] struct RoutePayloadOwned { v: u8, project: AtUri, publisher: Did, source: AtUri, root: String, path: String, cids: Vec, len: u64, sha256: Vec, } pub(super) async fn get_model_load_bundle( State(state): State, ExtractXrpc(request): ExtractXrpc, ) -> AppResult { build_model_load_bundle(state, request.uri).await } pub(super) async fn app_model_load_bundle( State(state): State, axum::Json(request): axum::Json, ) -> AppResult { build_model_load_bundle(state, request.uri).await } fn child_root_local(node: &BundleNode) -> RootId { match node { BundleNode::Db(resolved) => resolved.root, BundleNode::Official(official) => official.root, } } fn cached_target_key( cache: &HashMap<(RootId, CanonicalPath), ProtocolCacheKey>, preferred_root: RootId, path: &CanonicalPath, ) -> Option { cache .get(&(preferred_root, path.clone())) .or_else(|| { path.as_str() .split('/') .next() .and_then(parse_root) .and_then(|root| cache.get(&(root, path.clone()))) }) .cloned() } async fn load_bundle_root(state: &AppState, uri: &AtUri) -> AppResult<(AtUri, CanonicalPath)> { 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)? else { tracing::warn!(%uri, stage = "root_projection_miss", "compound model-load bundle root was not projected"); return Err(not_found()); }; let project = AtUri::new_owned(row.project_uri).map_err(|_| { tracing::error!(%uri, stage = "root_project_uri_invalid", "compound model-load bundle root has an invalid project URI"); not_found() })?; let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| { tracing::error!(%uri, stage = "root_path_invalid", canonical_path = %row.canonical_path, "compound model-load bundle root has an invalid canonical path"); not_found() })?; Ok((project, path)) } /// 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("p/") { RootId::OfficialP } else { RootId::OfficialParts }; read_official_resource( root, logical_path.as_str(), &file_path, "application/x-ldraw", ) .await } /// Resolve a TEXMAP asset or the shared colour table from the official library. /// Bare TEXMAP filenames are rooted at `textures/`; explicit `textures/` paths /// remain canonical. No path outside the configured library is accepted. async fn resolve_official_asset(reference: &str, libdir: &Path) -> Option { let normalized = polymodel_ldraw_core::NormalizedPath::new(reference).ok()?; let canonical_root = tokio::fs::canonicalize(libdir).await.ok()?; if normalized.as_str().eq_ignore_ascii_case("ldconfig.ldr") { let file_path = case_insensitive_lookup(libdir, "LDConfig.ldr").await?; if !tokio::fs::canonicalize(&file_path) .await .ok()? .starts_with(&canonical_root) { return None; } return read_official_resource( RootId::OfficialParts, "LDConfig.ldr", &file_path, "application/x-ldraw", ) .await; } let relative = normalized .as_str() .strip_prefix("textures/") .unwrap_or(normalized.as_str()); if relative.is_empty() || relative.eq_ignore_ascii_case(".") { return None; } let textures_dir = case_insensitive_lookup(libdir, "textures").await?; let mut current = textures_dir; for segment in relative.split('/') { current = case_insensitive_lookup(¤t, segment).await?; } let logical_path = format!("textures/{relative}"); if !tokio::fs::canonicalize(¤t) .await .ok()? .starts_with(&canonical_root) { return None; } let mime_type = match logical_path .rsplit_once('.') .map(|(_, extension)| extension.to_ascii_lowercase()) .as_deref() { Some("png") => "image/png", Some("jpg" | "jpeg") => "image/jpeg", Some("webp") => "image/webp", _ => "application/octet-stream", }; read_official_resource(RootId::OfficialParts, &logical_path, ¤t, mime_type).await } async fn read_official_resource( root: RootId, logical_path: &str, file_path: &Path, mime_type: &str, ) -> Option { let metadata = tokio::fs::metadata(file_path).await.ok()?; let file_size = metadata.len(); if file_size > MAX_MODEL_RESOURCE_BYTES || !metadata.is_file() { return None; } let bytes = tokio::fs::read(file_path).await.ok()?; if bytes.len() as u64 != file_size { return None; } Some(OfficialResource { root, path: CanonicalPath::parse(logical_path).ok()?, bytes: bytes.clone(), sha256: Sha256::digest(&bytes).to_vec(), mime_type: mime_type.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 resolve_child_resource( state: &AppState, resolver: &SqliteResolver, project: &AtUri, parent: &BundleNode, child_path: &CanonicalPath, manifest: &[ManifestEntry], libdir: Option<&PathBuf>, ) -> AppResult<(BundleNode, Vec)> { let child = if let BundleNode::Db(resolved) = parent { 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) => { 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) } 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) }; Ok(child) } async fn build_model_load_bundle(state: AppState, uri: AtUri) -> AppResult { let (project, path) = load_bundle_root(&state, &uri).await?; let resolver = SqliteResolver::new(state.pool.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<(RootId, CanonicalPath), ProtocolCacheKey> = HashMap::new(); let mut resolved_identities: HashSet<(ProtocolRootId, polymodel_ldraw_core::NormalizedPath)> = HashSet::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) => resolved.root, BundleNode::Official(official) => official.root, }; let root_protocol_root = protocol_root(root_root); let root_key = ProtocolCacheKey::new( polymodel_renderer_protocol::NormalizedPath::new(path.as_str()) .map_err(|_| invalid_request("invalid bundled root path"))?, root_protocol_root, &root_bytes, ); target_keys_by_path.insert((root_root, 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 resolved_closure = polymodel_ldraw_core::ResourceClosure { include_references: Vec::new(), dependencies: Vec::new(), resolved: Vec::new(), needs_ldconfig: false, }; let mut manifest_edges = 0_usize; let mut total_bytes = 0_u64; 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 current_root = match &node { BundleNode::Db(resolved) => resolved.root, BundleNode::Official(official) => official.root, }; let current_protocol_root = protocol_root(current_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"))?; if projected_length > remaining_budget { return Err(invalid_request("compound plan bounds exceeded")); } total_bytes = total_bytes .checked_add(projected_length) .ok_or_else(|| invalid_request("compound byte bound overflow"))?; 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_protocol_root, ) .map_err(|_| not_found())?; let mut inspection = inspection; let source_keys = std::mem::take(&mut 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 resource_closure = polymodel_ldraw_core::ResourceClosure::from_inspection( &mut inspection, &polymodel_renderer_protocol::NormalizedPath::new(current_path.as_str()) .map_err(|_| invalid_request("invalid bundled source path"))?, current_protocol_root, ); let mut auxiliary_dependencies = resource_closure .texture_dependencies() .cloned() .collect::>(); let needs_ldconfig = resource_closure.needs_ldconfig; if needs_ldconfig { auxiliary_dependencies.push(polymodel_ldraw_core::ResourceDependency { kind: polymodel_ldraw_core::ResourceKind::LdConfig, path: polymodel_ldraw_core::NormalizedPath::new("LDConfig.ldr") .map_err(|_| invalid_request("invalid LDConfig path"))?, source: polymodel_ldraw_core::NormalizedPath::new(current_path.as_str()) .map_err(|_| invalid_request("invalid bundled source path"))?, root: polymodel_ldraw_core::RootId::OfficialLibrary, required: false, fallback_available: true, }); } resolved_closure .dependencies .extend(resource_closure.dependencies.iter().cloned()); resolved_closure.needs_ldconfig |= needs_ldconfig; let references = resource_closure.include_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) }) .count(); let virtual_target_references = references .iter() .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 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) = cached_target_key(&target_keys_by_path, current_root, &child_path) { child_key } 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 = resolve_child_resource( &state, &resolver, &project, &node, &child_path, &manifest, libdir, ) .await?; 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((current_root, requested_child_path), child_key.clone()); target_keys_by_path.insert( (child_root_local(&child.0), 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 are enqueued with their bytes so the BFS // never performs a second resolver/fetch pass. for entry in &manifest { if cached_target_key(&target_keys_by_path, entry.root, &entry.path).is_none() { 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 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.root, entry.path.clone()), child_key.clone()); target_keys_by_path.insert( (child_root_local(&resolved), resolved_path.clone()), child_key.clone(), ); children.push((resolved_path, resolved, child_bytes, child_key)); } } for dependency in auxiliary_dependencies { let Some(directory) = libdir else { break; }; let Some(official) = resolve_official_asset(dependency.path.as_str(), directory).await else { if dependency.required { return Err(not_found()); } continue; }; let path = official.path.clone(); if target_keys_by_path.contains_key(&(official.root, path.clone())) { continue; } let resource_root = official.root; let key = ProtocolCacheKey::new( polymodel_renderer_protocol::NormalizedPath::new(path.as_str()) .map_err(|_| invalid_request("invalid official auxiliary path"))?, protocol_root(resource_root), &official.bytes, ); let aux_path = polymodel_renderer_protocol::NormalizedPath::new(path.as_str()) .map_err(|_| invalid_request("invalid official resource path"))?; let aux_protocol_root = protocol_root(resource_root); if !resolved_identities.insert((aux_protocol_root, aux_path.clone())) { continue; } target_keys_by_path.insert((resource_root, path.clone()), key.clone()); resolved_closure .resolved .push(polymodel_ldraw_core::ResolvedResource { path: aux_path, root: aux_protocol_root, sha256: official.sha256.clone(), byte_length: u64::try_from(official.bytes.len()) .map_err(|_| invalid_request("official resource byte length overflow"))?, bytes: official.bytes.clone(), mime_type: match official.mime_type.as_str() { "image/png" => "image/png", "image/jpeg" => "image/jpeg", "image/webp" => "image/webp", _ => "application/octet-stream", }, required: dependency.required, fallback_available: dependency.fallback_available, }); resources.push((BundleNode::Official(official.clone()), official.bytes)); } let node_root = child_root_local(&node); let node_path = match &node { BundleNode::Db(resolved) => resolved.path.clone(), BundleNode::Official(official) => official.path.clone(), }; let node_mime = match &node { BundleNode::Db(resolved) if resolved.mime_type == "image/png" => "image/png", BundleNode::Db(resolved) if resolved.mime_type == "image/jpeg" => "image/jpeg", BundleNode::Db(resolved) if resolved.mime_type == "image/webp" => "image/webp", BundleNode::Db(_) => "application/octet-stream", BundleNode::Official(official) if official.mime_type == "image/png" => "image/png", BundleNode::Official(official) if official.mime_type == "image/jpeg" => "image/jpeg", BundleNode::Official(official) if official.mime_type == "image/webp" => "image/webp", BundleNode::Official(_) => "application/octet-stream", }; let node_sha256 = match &node { BundleNode::Db(resolved) => binding_for(&project, resolved)?.sha256, BundleNode::Official(official) => official.sha256.clone(), }; let node_path_normalized = polymodel_renderer_protocol::NormalizedPath::new(node_path.as_str()) .map_err(|_| invalid_request("invalid resolved resource path"))?; let node_protocol_root = protocol_root(node_root); if !resolved_identities.insert((node_protocol_root, node_path_normalized.clone())) { for (child_path, child_node, child_bytes, child_key) in children { queue.push_back((child_path, child_node, child_bytes, child_key)); } continue; } resolved_closure .resolved .push(polymodel_ldraw_core::ResolvedResource { path: node_path_normalized, root: node_protocol_root, sha256: node_sha256, byte_length: u64::try_from(bytes.len()) .map_err(|_| invalid_request("resolved resource byte length overflow"))?, bytes: bytes.clone(), mime_type: node_mime, required: false, fallback_available: true, }); 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)); } } resolved_closure .validate() .map_err(|error| invalid_request(format!("invalid resolved resource closure: {error}")))?; encode_bundle_response( &project, &path, &resources, source_identities, target_selections, ) .await } async fn encode_bundle_response( project: &AtUri, path: &CanonicalPath, resources: &[(BundleNode, Vec)], source_identities: Vec, target_selections: Vec, ) -> AppResult { let mut bundled = Vec::with_capacity(resources.len()); let mut metadata_bytes = path.as_str().len(); 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.as_slice().try_into().map_err(|_| { invalid_request("stored resource digest is not SHA-256") })?, mime_type: resolved.mime_type.clone(), target_cids: binding.target_cids.into_boxed_slice(), bytes: bytes.clone().into_boxed_slice(), } } BundleNode::Official(official) => ModelResource { key: official.path.to_string(), path: official.path.to_string(), root: official.root.as_str().to_owned(), byte_length: u64::try_from(official.bytes.len()) .map_err(|_| invalid_request("official resource byte length overflow"))?, sha256: official .sha256 .clone() .try_into() .map_err(|_| invalid_request("official resource digest is not SHA-256"))?, mime_type: official.mime_type.clone(), target_cids: Box::default(), bytes: bytes.clone().into_boxed_slice(), }, }; bundled_resource .validate() .map_err(|error| invalid_request(format!("invalid model resource: {error:?}")))?; let resource_metadata = bundled_resource .key .len() .checked_add(bundled_resource.path.len()) .and_then(|size| size.checked_add(bundled_resource.root.len())) .and_then(|size| size.checked_add(bundled_resource.mime_type.len())) .and_then(|size| { bundled_resource .target_cids .iter() .try_fold(size, |total, cid| total.checked_add(cid.len())) }) .ok_or_else(|| invalid_request("model bundle metadata bound overflow"))?; metadata_bytes = metadata_bytes .checked_add(resource_metadata) .ok_or_else(|| invalid_request("model bundle metadata bound overflow"))?; bundled.push(bundled_resource); } if metadata_bytes > MAX_MODEL_METADATA_BYTES { return Err(invalid_request("model bundle metadata bound exceeded")); } let bundle = ModelLoadBundle { version: PROTOCOL_VERSION, format: MeshFormat::CompoundLdraw, primary: path.to_string(), resources: bundled.into_boxed_slice(), source_identities, target_selections, }; let encoded = bundle .encode() .map_err(|error| invalid_request(format!("model bundle encoding failed: {error:?}")))?; Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, "application/vnd.polymodel.load-bundle", ) .header(header::CONTENT_LENGTH, encoded.len()) .body(Body::from(encoded)) .map_err(|error| internal(format!("model bundle response build failed: {error}"))) } pub(super) async fn fetch_resource( State(state): State, AxumPath(binding): AxumPath, ) -> AppResult { let binding = decode_binding(&binding).map_err(|_| not_found())?; let resolver = SqliteResolver::new(state.pool.clone()); let resource = resolver .resolve(&binding.project, &binding.path) .await .map_err(|_| not_found())?; if binding_for(&binding.project, &resource).map_err(|_| not_found())? != binding { return Err(not_found()); } let bytes = fetch_verified_bytes(&state, &resource, binding.byte_length).await?; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, resource.mime_type.as_str()) .header(header::CONTENT_LENGTH, bytes.len()) .body(Body::from_stream(stream::once(async move { Ok::<_, std::convert::Infallible>(bytes::Bytes::from(bytes)) }))) .map_err(|error| internal(format!("LDraw response build failed: {error}"))) } pub(super) fn binding_for(project: &AtUri, resource: &ResolvedResource) -> AppResult { let publisher = resource.source.resource_uri.authority().convert(); let publisher = match publisher { jacquard_common::types::string::AtIdentifier::Did(did) => did, jacquard_common::types::string::AtIdentifier::Handle(_) => { return Err(internal("resolved LDraw source has a handle authority")); } }; let byte_length = u64::try_from(resource.byte_length) .map_err(|_| invalid_request("negative LDraw resource length"))?; if resource.sha256.len() != 32 || resource.target.blob_cids.is_empty() { return Err(not_found()); } Ok(RouteBinding { version: ROUTE_VERSION, project: project.clone(), publisher, source: resource.source.resource_uri.clone(), root: resource.root, path: resource.path.clone(), target_cids: resource.target.blob_cids.clone(), byte_length, sha256: resource.sha256.clone(), }) } #[cfg(test)] pub(super) fn encode_binding(binding: &RouteBinding) -> AppResult { let payload = RoutePayload { v: binding.version, project: &binding.project, publisher: &binding.publisher, source: &binding.source, root: binding.root.as_str(), path: binding.path.as_str(), cids: &binding.target_cids, len: binding.byte_length, sha256: &binding.sha256, }; let bytes = serde_json::to_vec(&payload) .map_err(|error| internal(format!("route binding serialization failed: {error}")))?; let digest = Sha256::digest(&bytes); let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); Ok(format!("{ROUTE_PREFIX}{encoded}.{}", hex_bytes(&digest))) } fn decode_binding(value: &str) -> Result { let value = value.strip_prefix(ROUTE_PREFIX).unwrap_or(value); let (payload, digest) = value.rsplit_once('.').ok_or(())?; let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(payload) .map_err(|_| ())?; if digest != hex_bytes(&Sha256::digest(&bytes)) { return Err(()); } let payload: RoutePayloadOwned = serde_json::from_slice(&bytes).map_err(|_| ())?; let root = parse_root(&payload.root).ok_or(())?; let path = CanonicalPath::parse(&payload.path).map_err(|_| ())?; if payload.v != ROUTE_VERSION || payload.sha256.len() != 32 || payload.cids.is_empty() { return Err(()); } Ok(RouteBinding { version: payload.v, project: payload.project, publisher: payload.publisher, source: payload.source, root, path, target_cids: payload.cids, byte_length: payload.len, sha256: payload.sha256, }) } 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, "manifest" => RootId::Manifest, "models" => RootId::Models, "parts" => RootId::Parts, "p" => RootId::P, "external" => RootId::External, "official-parts" => RootId::OfficialParts, "official-p" => RootId::OfficialP, _ => return None, }) } 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 reachable_mpd_sections( current: &CanonicalPath, 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(); } 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( current: &CanonicalPath, reference: &polymodel_ldraw_core::MpdReference, ) -> Option { let current = polymodel_ldraw_core::NormalizedPath::new(current.as_str()).ok()?; let resolved = polymodel_ldraw_core::resolve_reference_path(¤t, reference).ok()?; CanonicalPath::parse(resolved.path.as_str()).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 { 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 root == RootId::External { return if segments.len() == 3 { Ok(None) } else { 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(()), } } async fn fetch_verified_bytes( state: &AppState, resource: &ResolvedResource, max_bytes: u64, ) -> AppResult> { let did = match resource.source.resource_uri.authority().convert() { jacquard_common::types::string::AtIdentifier::Did(did) => did, jacquard_common::types::string::AtIdentifier::Handle(handle) => { super::actor::resolve_actor(state, &handle.convert().into()).await? } }; let did_doc_response = state .resolver .resolve_did_doc(&did) .await .map_err(|error| internal(format!("DID document resolution failed: {error}")))?; let did_doc = did_doc_response .parse() .map_err(|error| internal(format!("DID document parse failed: {error}")))?; let endpoint = did_doc .pds_endpoint() .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? .to_owned(); let expected_length = u64::try_from(resource.byte_length) .map_err(|_| invalid_request("negative LDraw resource length"))?; if expected_length != max_bytes || expected_length > MAX_MODEL_RESOURCE_BYTES { return Err(invalid_request("LDraw byte admission failed")); } let capacity = usize::try_from(expected_length) .map_err(|_| invalid_request("LDraw byte length is too large"))?; let mut bytes = Vec::with_capacity(capacity); let mut admitted = 0_u64; for value in &resource.source.blob_cids { let cid = Cid::new_owned(value.as_bytes()) .map_err(|error| internal(format!("invalid LDraw blob CID: {error}")))?; let response = state .resolver .xrpc(endpoint.borrow()) .download(&GetBlob { did: did.clone(), cid, }) .await .map_err(|error| internal(format!("blob fetch failed: {error}")))?; if !response.status().is_success() { return Err(internal(format!( "blob fetch failed with HTTP {}", response.status() ))); } let (_, body) = response.into_parts(); let mut body_stream = body.into_inner(); let mut chunk = Vec::new(); while let Some(part) = body_stream .try_next() .await .map_err(|error| internal(format!("blob stream failed: {error}")))? { let part_length = u64::try_from(part.len()) .map_err(|_| invalid_request("LDraw chunk length overflow"))?; admitted = admitted .checked_add(part_length) .ok_or_else(|| invalid_request("LDraw byte length overflow"))?; if admitted > max_bytes { return Err(invalid_request("LDraw byte admission exceeded")); } chunk.extend_from_slice(&part); } verify_chunk_cid(value, &chunk) .map_err(|error| invalid_request(format!("chunk integrity check failed: {error}")))?; bytes.extend_from_slice(&chunk); } if admitted != expected_length || bytes.len() as u64 != expected_length { return Err(invalid_request("LDraw byte length verification failed")); } let digest = Sha256::digest(&bytes); if &digest[..] != resource.sha256.as_slice() { return Err(invalid_request("LDraw SHA-256 verification failed")); } Ok(bytes) } pub(super) async fn fetch_staged_bytes( state: &AppState, did: &Did, blob_cids: &[String], expected_length: u64, expected_sha256: &[u8], max_bytes: u64, ) -> AppResult> { if expected_length == 0 || expected_length > max_bytes || expected_length > MAX_MODEL_RESOURCE_BYTES || expected_sha256.len() != 32 { return Err(invalid_request("staged preview byte admission failed")); } let did_doc_response = state .resolver .resolve_did_doc(did) .await .map_err(|error| internal(format!("DID document resolution failed: {error}")))?; let did_doc = did_doc_response .parse() .map_err(|error| internal(format!("DID document parse failed: {error}")))?; let endpoint = did_doc .pds_endpoint() .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? .to_owned(); let mut bytes = Vec::with_capacity( usize::try_from(expected_length) .map_err(|_| invalid_request("staged preview byte length is too large"))?, ); for value in blob_cids { let cid = Cid::new_owned(value.as_bytes()) .map_err(|error| internal(format!("invalid staged blob CID: {error}")))?; let response = state .resolver .xrpc(endpoint.borrow()) .download(&GetBlob { did: did.clone(), cid, }) .await .map_err(|error| internal(format!("blob fetch failed: {error}")))?; if !response.status().is_success() { return Err(internal(format!( "blob fetch failed with HTTP {}", response.status() ))); } let mut stream = response.into_parts().1.into_inner(); let mut chunk = Vec::new(); while let Some(part) = stream .try_next() .await .map_err(|error| internal(format!("blob stream failed: {error}")))? { if bytes .len() .saturating_add(chunk.len()) .saturating_add(part.len()) > expected_length as usize { return Err(invalid_request("staged preview byte admission exceeded")); } chunk.extend_from_slice(&part); } verify_chunk_cid(value, &chunk) .map_err(|error| invalid_request(format!("chunk integrity check failed: {error}")))?; bytes.extend_from_slice(&chunk); } if bytes.len() as u64 != expected_length || &Sha256::digest(&bytes)[..] != expected_sha256 { return Err(invalid_request( "staged preview integrity verification failed", )); } Ok(bytes) } fn hex_bytes(bytes: &[u8]) -> String { bytes.iter().map(|byte| format!("{byte:02x}")).collect() } fn resolve_error(error: crate::ldraw::ResolveError) -> super::error::AppError { match error { crate::ldraw::ResolveError::Unavailable { .. } => internal("LDraw resolver unavailable"), _ => not_found(), } } #[cfg(test)] mod tests { use super::*; #[test] fn route_binding_is_deterministic_and_rejects_substitution() { let project = AtUri::new_owned("at://did:plc:project/space.polymodel.library.thing/main").unwrap(); let publisher = Did::new_owned("did:plc:publisher").unwrap(); let source = AtUri::new_owned("at://did:plc:publisher/space.polymodel.library.part/a").unwrap(); let binding = RouteBinding { version: ROUTE_VERSION, project, publisher, source, root: RootId::Models, path: CanonicalPath::parse("models/main.ldr").unwrap(), target_cids: vec!["bafkreigh2akiscaildc3".into()], byte_length: 4, sha256: vec![0; 32], }; let first = encode_binding(&binding).unwrap(); assert_eq!(first, encode_binding(&binding).unwrap()); assert_eq!(decode_binding(&first).unwrap(), binding); let mut changed = first.into_bytes(); let index = changed.len() - 1; changed[index] = if changed[index] == b'0' { b'1' } else { b'0' }; 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 span = polymodel_ldraw_core::Span { start: 0, end: 0, line: 0, column: 0, }; let relative = polymodel_ldraw_core::MpdReference { file_name: "main.ldr".into(), file_ordinal: 0, target: "child.dat".into(), span, virtual_file: false, target_virtual_file: false, }; assert_eq!( core_reference_path(¤t, &relative).unwrap().as_str(), "p/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!( core_reference_path(¤t, &explicit).unwrap().as_str(), target ); } for (prefix, root) in [("s", "parts"), ("S", "parts"), ("8", "p"), ("48", "p")] { let explicit = polymodel_ldraw_core::MpdReference { target: format!("{prefix}/3001.dat"), ..relative.clone() }; // The s/ alias maps subparts to the parts/ root without preserving // the s/ segment in the canonical path (subparts are stored as // parts/{name} in the official library). The 8/ and 48/ aliases // preserve their segment under the p/ root. let resolved = core_reference_path(¤t, &explicit).unwrap(); let expected = match prefix { "s" | "S" => format!("{root}/3001.dat"), _ => format!("{root}/{prefix}/3001.dat"), }; assert_eq!(resolved.as_str(), expected); } let mpd = polymodel_ldraw_core::MpdReference { file_name: "main.ldr".into(), file_ordinal: 0, target: "Child.DAT".into(), span, virtual_file: true, target_virtual_file: true, }; assert_eq!( core_reference_path(¤t, &mpd).unwrap().as_str(), "mpd/child.dat" ); } #[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(); let span = polymodel_ldraw_core::Span { start: 0, end: 0, line: 0, column: 0, }; let malformed = polymodel_ldraw_core::MpdReference { file_name: "sub/root.ldr".into(), file_ordinal: 0, target: "../escape.dat".into(), span, virtual_file: false, target_virtual_file: false, }; 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, target_virtual_file: false, }; 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, }; 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(); if seen.insert(child.clone()) { children.push(child.clone()); } if seen.insert(child.clone()) { children.push(child); } assert_eq!(children.len(), 1); } #[test] fn scope_requires_same_root_and_containment() { assert!(contains_in_scope( RootId::Models, "models/main.ldr", RootId::Models, "models/main.ldr" )); assert!(contains_in_scope( RootId::Models, "models/main.ldr", RootId::Parts, "parts/3001.dat" )); assert!(!contains_in_scope( RootId::Models, "models/main.ldr", 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::External, "external/xdid_3aplc/xpart", RootId::External, "external/xdid_3aplc/xother" )); assert!(!contains_in_scope( RootId::External, "external/xdid_3aplc/xpart", RootId::External, "External/xdid_3aplc/xother" )); assert!(!contains_in_scope( RootId::External, "external/xdid_3aplc/xpart", RootId::External, "external/xdid_3aplc/nested/xother" )); 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" )); } // --- 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.as_ref(), part_bytes.as_slice()); 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.as_ref(), prim_bytes.as_slice()); 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); } }