use crate::cache::Sha256Hash; use crate::cache::{CacheKey, NormalizedPath, RootId}; use crate::scanner::{LineType, ScannedLine, TokenKind}; use crate::types::{ DEFAULT_ROOT_NAME, Diagnostic, DiagnosticCode, LdrawLimits, ParseError, Severity, Span, }; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::{BTreeSet, HashSet}; pub(crate) fn duplicate_file_name_spans(files: &[VirtualFile<'_>]) -> Vec { let mut names = BTreeSet::new(); files .iter() .filter_map(|file| { let normalized = crate::cache::NormalizedPath::new(file.name.as_ref()).ok()?; (!names.insert(normalized)).then_some(file.source_span) }) .collect() } #[derive(Clone, Debug, PartialEq)] pub struct VirtualFile<'src> { pub id: u32, pub name: Cow<'src, str>, pub lines: Vec>, pub source_span: Span, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct OwnedVirtualFile { pub id: u32, pub name: String, pub lines: Vec>, pub source_span: Span, } impl<'src> From> for OwnedVirtualFile { fn from(file: VirtualFile<'src>) -> Self { Self { id: file.id, name: file.name.into_owned(), lines: file .lines .into_iter() .map(|line| line.raw.to_vec()) .collect(), source_span: file.source_span, } } } pub type MpdDocument<'src> = Vec>; /// A type-1 dependency observed in one MPD virtual file. /// /// The core owns scanning and MPD partitioning; callers decide how the /// filename is joined to their own resolver roots. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 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, /// True when the source contained explicit `0 FILE` sections. pub virtual_file: bool, /// True only when `target` names one of those declared MPD sections. pub target_virtual_file: bool, } /// Root-qualified namespace selected for a type-1 dependency before a host /// resolver applies project/official precedence. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ReferenceRoot { Mpd, Manifest, Models, Parts, P, External, OfficialParts, OfficialP, } /// Canonical path and namespace for one type-1 dependency. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ResolvedReference { pub path: crate::NormalizedPath, pub root: ReferenceRoot, } /// Apply the shared LDraw type-1 path policy without reading bytes or choosing /// a project candidate. Server discovery and worker materialization planning /// both use this function; official fallback remains the resolver's job. pub fn resolve_reference_path( _current: &crate::NormalizedPath, reference: &MpdReference, ) -> Result { let normalized_target = reference.target.trim().replace('\\', "/"); let target = normalized_target.as_str(); if target.is_empty() { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, severity: Severity::Error, message: "type-1 include has no target filename".into(), span: None, })); } if reference.target_virtual_file { return Ok(ResolvedReference { path: crate::NormalizedPath::new(&format!("mpd/{target}"))?, root: ReferenceRoot::Mpd, }); } let first = target .split('/') .next() .ok_or(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, severity: Severity::Error, message: "type-1 include has no target filename".into(), span: None, }))?; let explicit = match first.to_ascii_lowercase().as_str() { "mpd" => Some(ReferenceRoot::Mpd), "manifest" => Some(ReferenceRoot::Manifest), "models" => Some(ReferenceRoot::Models), "parts" => Some(ReferenceRoot::Parts), "p" => Some(ReferenceRoot::P), "external" => Some(ReferenceRoot::External), "official-parts" => Some(ReferenceRoot::OfficialParts), "official-p" => Some(ReferenceRoot::OfficialP), _ => None, }; if let Some(root) = explicit { return Ok(ResolvedReference { path: crate::NormalizedPath::new(target)?, root, }); } if first.eq_ignore_ascii_case("s") { let suffix = target .strip_prefix(first) .and_then(|suffix| suffix.strip_prefix('/')) .filter(|suffix| !suffix.is_empty()) .ok_or(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, severity: Severity::Error, message: "s/ alias has no part filename".into(), span: None, }))?; return Ok(ResolvedReference { path: crate::NormalizedPath::new(&format!("parts/{suffix}"))?, root: ReferenceRoot::Parts, }); } if first.eq_ignore_ascii_case("8") || first.eq_ignore_ascii_case("48") { let suffix = target .strip_prefix(first) .and_then(|suffix| suffix.strip_prefix('/')) .filter(|suffix| !suffix.is_empty()) .ok_or(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, severity: Severity::Error, message: "8/ or 48/ alias has no primitive filename".into(), span: None, }))?; return Ok(ResolvedReference { path: crate::NormalizedPath::new(&format!("p/{first}/{suffix}"))?, root: ReferenceRoot::P, }); } if normalized_target.split('/').any(|segment| segment == "..") { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::PathOutOfRoot, severity: Severity::Error, message: "type-1 include target contains a parent-directory escape".into(), span: None, })); } let path = crate::NormalizedPath::new(&format!("p/{target}"))?; Ok(ResolvedReference { path, root: ReferenceRoot::P, }) } /// 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, Eq, PartialEq)] pub struct TexmapReference { /// The MPD section (or standalone source) that references these textures. pub source: NormalizedPath, /// The resolver namespace containing the referencing source. pub root: RootId, pub pngfile: String, pub glossmap: Option, /// A `!TEXMAP FALLBACK` command was observed in this source section. pub fallback_available: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum ResourceKind { Include, TexmapAlbedo, TexmapGlossmap, LdConfig, } /// One host-resolvable resource admitted by a [`ResourceClosure`]. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ResourceDependency { pub kind: ResourceKind, pub path: NormalizedPath, /// Source model/MPD section that introduced the dependency. pub source: NormalizedPath, pub root: RootId, /// Missing required dependencies invalidate a render/bundle; optional /// resources may use the fallback described by `fallback_available`. pub required: bool, pub fallback_available: bool, } /// A fetched resource admitted by a [`ResourceClosure`]. /// /// The bytes and digest live on the closure itself so renderers do not need to /// infer authority from a resolver-specific materialization list. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct ResolvedResource { pub path: NormalizedPath, pub root: RootId, pub sha256: Vec, pub byte_length: u64, pub bytes: Vec, pub mime_type: &'static str, pub required: bool, pub fallback_available: bool, } /// Typed, resolver-independent description of every resource needed by an /// LDraw render. Native preview and server bundling resolve these same entries /// through their respective filesystem/database adapters. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct ResourceClosure { pub include_references: Vec, pub dependencies: Vec, pub resolved: Vec, /// Official libraries may provide standard colour definitions. The flag is /// intentionally optional at resolution time because uploaded sources can /// render with their inline colours and built-in fallback palette. pub needs_ldconfig: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] pub enum ResourceClosureError { #[error("resolved resource byte length mismatch")] ByteLengthMismatch, #[error("resolved resource SHA-256 mismatch")] Sha256Mismatch, #[error("required resource is missing from the closure")] MissingRequired, #[error("duplicate resolved resource identity")] DuplicateIdentity, } impl ResolvedResource { pub fn validate(&self) -> Result<(), ResourceClosureError> { if self.byte_length != self.bytes.len() as u64 { return Err(ResourceClosureError::ByteLengthMismatch); } if self.sha256.as_slice() != Sha256Hash::digest(&self.bytes).as_bytes() { return Err(ResourceClosureError::Sha256Mismatch); } Ok(()) } } impl ResourceClosure { pub fn validate(&self) -> Result<(), ResourceClosureError> { let mut identities = std::collections::HashSet::with_capacity(self.resolved.len()); for resource in &self.resolved { if !identities.insert((resource.root, resource.path.clone())) { tracing::error!( root = ?resource.root, path = %resource.path, total_resolved = self.resolved.len(), all_identities = ?self.resolved.iter().map(|r| (r.root, r.path.as_str().to_string())).collect::>(), "DUPLICATE RESOURCE IDENTITY DETECTED" ); return Err(ResourceClosureError::DuplicateIdentity); } resource.validate()?; } for dependency in self .dependencies .iter() .filter(|dependency| dependency.required) { let present = self.resolved.iter().any(|resource| { resource.root == dependency.root && resource.path == dependency.path }); if !present { return Err(ResourceClosureError::MissingRequired); } } Ok(()) } pub fn resolved_texture(&self, path: &NormalizedPath) -> Option<&ResolvedResource> { let authored = path.as_str(); let stripped = authored .split_once('/') .filter(|(prefix, _)| prefix.eq_ignore_ascii_case("textures")) .map(|(_, rest)| rest) .unwrap_or(authored); let prefixed = format!("textures/{stripped}"); [authored, prefixed.as_str(), stripped] .into_iter() .find_map(|candidate| { self.resolved.iter().find(|resource| { resource.path.as_str() == candidate && resource.mime_type.starts_with("image/") }) }) } pub fn from_inspection( inspection: &mut CompoundSourceInspection, current_path: &NormalizedPath, current_root: RootId, ) -> Self { let references = std::mem::take(&mut inspection.references); let texmap_references = std::mem::take(&mut inspection.texmap_references); let mut dependencies = Vec::new(); for texture in &texmap_references { let source = texture.source.clone(); let texture_path = |name: &str| { let normalized = name.replace('\\', "/"); let logical = if normalized .split('/') .next() .is_some_and(|segment| segment.eq_ignore_ascii_case("textures")) { normalized } else { format!("textures/{normalized}") }; NormalizedPath::new(&logical).ok() }; if let Some(path) = texture_path(&texture.pngfile) { dependencies.push(ResourceDependency { kind: ResourceKind::TexmapAlbedo, path, source: source.clone(), root: texture.root, required: !texture.fallback_available, fallback_available: texture.fallback_available, }); } if let Some(glossmap) = texture.glossmap.as_deref().and_then(texture_path) { dependencies.push(ResourceDependency { kind: ResourceKind::TexmapGlossmap, path: glossmap, source, root: texture.root, required: false, fallback_available: true, }); } } let _ = current_path; let _ = current_root; Self { include_references: references, dependencies, resolved: Vec::new(), needs_ldconfig: true, } } pub fn texture_dependencies(&self) -> impl Iterator { self.dependencies.iter().filter(|dependency| { matches!( dependency.kind, ResourceKind::TexmapAlbedo | ResourceKind::TexmapGlossmap ) }) } } #[derive(Clone, Debug)] pub struct CompoundSourceInspection { pub references: Vec, pub texmap_references: Vec, pub source_keys: Vec<(String, CacheKey)>, pub has_virtual_files: bool, } impl CompoundSourceInspection { pub fn texmap_references(&self) -> &[TexmapReference] { &self.texmap_references } } fn extract_texmap_references_from_files( files: &[VirtualFile<'_>], current_path: &NormalizedPath, current_root: RootId, ) -> Vec { let mut references = Vec::new(); for file in files { let source = NormalizedPath::new(file.name.as_ref()).unwrap_or_else(|_| current_path.clone()); let file_reference_start = references.len(); let mut fallback_available = false; for line in &file.lines { if line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|token| token.kind) == Some(TokenKind::Texmap) && line.tokens.get(2).map(|token| token.kind) == Some(TokenKind::Fallback) { fallback_available = true; } if line.line_type != Some(LineType::Zero) || line.tokens.get(1).map(|token| token.kind) != Some(TokenKind::Texmap) || !matches!( line.tokens.get(2).map(|token| token.kind), Some(TokenKind::Start | TokenKind::Next) ) { continue; } let Some(mode) = line.tokens.get(3).map(|token| token.text) else { continue; }; let parameter_count = if mode.eq_ignore_ascii_case("planar") { 9 } else if mode.eq_ignore_ascii_case("cylindrical") { 10 } else if mode.eq_ignore_ascii_case("spherical") { 11 } else { continue; }; let reference_index = 4 + parameter_count; let Some(pngfile) = line.tokens.get(reference_index).map(|token| token.text) else { continue; }; if pngfile.is_empty() { continue; } let glossmap = (line.tokens.get(reference_index + 1).map(|token| token.text) == Some("GLOSSMAP")) .then(|| line.tokens.get(reference_index + 2).map(|token| token.text)) .flatten() .filter(|value| !value.is_empty()) .map(str::to_owned); let reference = TexmapReference { source: source.clone(), root: current_root, pngfile: pngfile.to_owned(), glossmap, fallback_available, }; if !references.contains(&reference) { references.push(reference); } } for reference in &mut references[file_reference_start..] { reference.fallback_available = fallback_available; } } references } /// 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)?, texmap_references: extract_texmap_references_from_files(&files, current_path, current_root), 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 /// resolve filenames, fetch bytes, or build scene geometry. Every type-1 line /// must satisfy the same transform/token shape used by the semantic parser so /// malformed source cannot silently produce a partial compound plan. pub fn extract_references(bytes: &[u8]) -> Result, ParseError> { let limits = LdrawLimits::default(); let lines = crate::scanner::scan_lines(bytes, &limits)?; 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. fn extract_references_from_files( files: &[VirtualFile<'_>], has_virtual_files: bool, ) -> Result, ParseError> { 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_ordinal, file) in files.iter().enumerate() { if !names.insert(file.name.to_string()) { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::DuplicateFileName, severity: Severity::Error, message: "MPD contains a duplicate FILE name".into(), span: Some(file.source_span), })); } for line in &file.lines { if line.line_type != Some(LineType::One) { continue; } if line.tokens.len() < 15 || (2..14).any(|index| { line.tokens .get(index) .and_then(|token| token.number) .is_none() }) { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, severity: Severity::Error, message: "malformed type-1 include".into(), span: Some(line.span), })); } let target = line.tokens[14..] .iter() .map(|token| token.text) .collect::>() .join(" "); if target.is_empty() { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, severity: Severity::Error, message: "type-1 include has no target filename".into(), span: Some(line.span), })); } let target_virtual_file = has_virtual_files && virtual_file_names.iter().any(|name| { name.eq_ignore_ascii_case(&target) || name .rsplit_once(" - ") .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case(&target)) }); references.push(MpdReference { file_name: file.name.to_string(), file_ordinal, target, span: line.span, virtual_file: has_virtual_files, target_virtual_file, }); } } Ok(references) } pub fn split_mpd<'src>( lines: &[ScannedLine<'src>], limits: &LdrawLimits, ) -> Result, ParseError> { let has_file = lines.iter().any(|line| { line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::File) && line.tokens.len() >= 3 }); if !has_file { return Ok(vec![VirtualFile { id: 0, name: Cow::Borrowed(DEFAULT_ROOT_NAME), lines: lines.to_vec(), source_span: lines.first().map(|l| l.span).unwrap_or(Span { start: 0, end: 0, line: 1, column: 1, }), }]); } let mut files = Vec::new(); let mut active: Option = None; let mut discarded = true; for line in lines { if line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::File) && line.tokens.len() >= 3 { let count = u64::try_from(files.len()).map_err(|_| ParseError::Overflow("file count"))?; if count >= limits.files { return Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::FilesLimit, severity: Severity::Error, message: "virtual file limit exceeded".into(), span: Some(line.span), })); } let name = Cow::Owned( line.tokens[2..] .iter() .map(|t| t.text) .collect::>() .join(" "), ); files.push(VirtualFile { id: u32::try_from(files.len()) .map_err(|_| ParseError::Overflow("virtual file id"))?, name, lines: Vec::new(), source_span: line.span, }); active = Some(files.len() - 1); discarded = false; continue; } if line.line_type == Some(LineType::Zero) && line.tokens.get(1).map(|t| t.kind) == Some(TokenKind::NoFile) { active = None; discarded = true; continue; } if let Some(index) = active { files[index].lines.push(line.clone()); } else if !discarded && !files.is_empty() { /* explicit MPD discard state */ } } if files.is_empty() { return Ok(vec![VirtualFile { id: 0, name: Cow::Borrowed(DEFAULT_ROOT_NAME), lines: lines.to_vec(), source_span: lines.first().map(|l| l.span).unwrap_or(Span { start: 0, end: 0, line: 1, column: 1, }), }]); } Ok(files) } #[cfg(test)] mod tests { use super::*; use crate::NormalizedPath; #[test] fn extracts_ordered_references_and_marks_mpd_sources() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 FILE child.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 root.ldr\n0 NOFILE\n"; let references = extract_references(bytes).expect("valid MPD references"); assert_eq!( references, vec![ MpdReference { file_name: "root.ldr".into(), file_ordinal: 0, target: "child.dat".into(), virtual_file: true, target_virtual_file: true, span: Span { start: 16, end: 54, line: 2, column: 1 }, }, MpdReference { file_name: "child.dat".into(), file_ordinal: 1, target: "root.ldr".into(), virtual_file: true, target_virtual_file: true, span: Span { start: 72, end: 109, line: 4, column: 1 }, }, ] ); } #[test] fn extracts_single_file_references_without_virtual_namespace() { let bytes = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n"; let references = extract_references(bytes).expect("valid reference"); assert_eq!(references.len(), 1); assert_eq!(references[0].file_name, DEFAULT_ROOT_NAME); assert_eq!(references[0].target, "child.dat"); assert!(!references[0].virtual_file); } #[test] fn aliases_normalize_slashes_before_root_selection() { let current = NormalizedPath::new("models/root.ldr").unwrap(); for (target, expected_path, expected_root) in [ ("s/box4.dat", "parts/box4.dat", ReferenceRoot::Parts), ("s\\\\box4.dat", "parts/box4.dat", ReferenceRoot::Parts), ("8/1-4cyli.dat", "p/8/1-4cyli.dat", ReferenceRoot::P), ("48\\\\1-4cyli.dat", "p/48/1-4cyli.dat", ReferenceRoot::P), ] { let reference = MpdReference { file_name: "root.ldr".into(), file_ordinal: 0, target: target.into(), span: Span { start: 0, end: 1, line: 1, column: 1, }, virtual_file: false, target_virtual_file: false, }; let resolved = resolve_reference_path(¤t, &reference).unwrap(); assert_eq!(resolved.path.as_str(), expected_path); assert_eq!(resolved.root, expected_root); } } #[test] fn resource_closure_tracks_texmap_assets_and_fallback_policy() { let bytes = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png GLOSSMAP gloss.png\n3 16 0 0 0 1 0 0 0 1 0\n0 !TEXMAP END\n"; let source = NormalizedPath::new("models/root.ldr").unwrap(); let mut inspection = inspect_compound_source(bytes, &source, RootId::UploadedLdraw) .expect("valid TEXMAP source"); let closure = ResourceClosure::from_inspection(&mut inspection, &source, RootId::UploadedLdraw); assert_eq!(closure.dependencies.len(), 2); assert_eq!(closure.dependencies[0].kind, ResourceKind::TexmapAlbedo); assert_eq!( closure.dependencies[0].path.as_str(), "textures/sticker.png" ); assert_eq!(closure.dependencies[0].source.as_str(), "model.ldr"); assert_eq!(closure.dependencies[0].root, RootId::UploadedLdraw); assert!(closure.dependencies[0].required); assert!(!closure.dependencies[0].fallback_available); assert_eq!(closure.dependencies[1].kind, ResourceKind::TexmapGlossmap); assert_eq!(closure.dependencies[1].path.as_str(), "textures/gloss.png"); assert!(!closure.dependencies[1].required); assert!(closure.dependencies[1].fallback_available); assert!(closure.needs_ldconfig); } #[test] fn duplicate_resource_identity_is_rejected_directly() { let bytes = b"same resource".to_vec(); let resource = ResolvedResource { path: NormalizedPath::new("models/root.ldr").unwrap(), root: RootId::UploadedLdraw, sha256: Sha256Hash::digest(&bytes).as_bytes().to_vec(), byte_length: bytes.len() as u64, bytes: bytes.clone(), mime_type: "text/plain", required: false, fallback_available: false, }; let duplicate = ResourceClosure { include_references: Vec::new(), dependencies: Vec::new(), resolved: vec![resource.clone(), resource], needs_ldconfig: false, }; assert_eq!( duplicate.validate(), Err(ResourceClosureError::DuplicateIdentity) ); } #[test] fn malformed_type_one_and_missing_target_fail_closed() { let malformed = b"1 16 0 0 0 1 0 0 0 1 0 0 0 child.dat\n"; assert!(matches!( extract_references(malformed), Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, .. })) )); let missing_target = b"1 16 0 0 0 1 0 0 0 1 0 0 0 0\n"; assert!(matches!( extract_references(missing_target), Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidType1, .. })) )); } #[test] fn duplicate_mpd_file_names_fail_closed() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 other.dat\n0 NOFILE\n"; assert!(matches!( extract_references(bytes), Err(ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::DuplicateFileName, .. })) )); } }