use super::paths::{CanonicalPath, LookupEntry, PathDiagnostic, RootId, resolve_precedence}; use super::verification::{VerificationRecord, VerificationState}; use async_trait::async_trait; use jacquard_common::types::string::AtUri; use sqlx::SqlitePool; #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct SourceIdentity { pub resource: AtUri, pub content_hash: Vec, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct TargetIdentity { pub blob_cids: Vec, } /// The sole cache and route binding key shared across PM-76 and PM-80. /// /// Public LDraw resources are anonymous. Identity is content-bound to the /// project, selected source, canonical path/root, and ordered target CIDs; it /// never varies by browser principal. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct CacheKey { pub project: AtUri, pub source: SourceIdentity, pub root: RootId, pub path: CanonicalPath, pub target: TargetIdentity, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ResourceIdentity { pub uri: AtUri, pub content_hash: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ByteSource { pub resource_uri: AtUri, pub blob_cids: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ResolvedResource { pub cache_key: CacheKey, pub resource: ResourceIdentity, pub root: RootId, pub path: CanonicalPath, pub target: TargetIdentity, pub mime_type: String, pub byte_length: i64, pub sha256: Vec, pub source: ByteSource, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum DiagnosticCode { InvalidManifest, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum CollisionDiagnostic { FoldedPath, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum IntegrityDiagnostic { Length, Digest, BlobCid, MissingResourceUri, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum AvailabilityDiagnostic { SourceUnavailable, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum ResolveError { NotFound, Unverified, Rejected { diagnostic: DiagnosticCode }, SourceChanged, InvalidPath { diagnostic: PathDiagnostic }, Collision { diagnostic: CollisionDiagnostic }, Integrity { diagnostic: IntegrityDiagnostic }, Unavailable { diagnostic: AvailabilityDiagnostic }, } #[async_trait] pub trait LdrawResourceResolver: Send + Sync { async fn resolve( &self, project: &AtUri, path: &CanonicalPath, ) -> Result; } #[derive(Clone)] pub struct SqliteResolver { pool: SqlitePool, } impl SqliteResolver { pub fn new(pool: SqlitePool) -> Self { Self { pool } } /// Resolve the canonical candidate without requiring verification. This is /// the only lookup path used by the lazy verifier and the verified resolver. pub async fn candidate( &self, project: &AtUri, path: &CanonicalPath, ) -> Result { let rows = sqlx::query!( r#"SELECT r.resource_uri, r.owner_did, r.sha256, r.ordered_blob_cids, m.root, m.canonical_path, r.mime_type, r.byte_length FROM ldraw_resource_memberships m JOIN ldraw_resources r ON r.resource_uri = m.resource_uri WHERE m.project_uri = ?"#, project.as_ref(), ).fetch_all(&self.pool).await.map_err(|_| ResolveError::Unavailable { diagnostic: AvailabilityDiagnostic::SourceUnavailable })?; let mut entries = Vec::with_capacity(rows.len()); let mut by_uri = std::collections::HashMap::new(); for row in rows { let Some(root) = root_id(&row.root) else { continue; }; let candidate_path = CanonicalPath::parse(&row.canonical_path) .map_err(|diagnostic| ResolveError::InvalidPath { diagnostic })?; let Some(uri) = row.resource_uri.clone() else { return Err(ResolveError::Integrity { diagnostic: IntegrityDiagnostic::MissingResourceUri, }); }; entries.push(LookupEntry { root, path: candidate_path, target: uri.clone(), }); by_uri.insert(uri, row); } let mut mpd = Vec::new(); let mut manifest = Vec::new(); let mut uploaded = Vec::new(); let mut official = Vec::new(); for entry in entries { match entry.root { RootId::Mpd => mpd.push(entry), RootId::Manifest => manifest.push(entry), RootId::Models | RootId::Parts | RootId::P | RootId::External => { uploaded.push(entry) } RootId::OfficialParts | RootId::OfficialP => official.push(entry), } } let chosen = resolve_precedence(path, &mpd, &manifest, &uploaded, &official) .ok_or(ResolveError::NotFound)?; let matching = match chosen.root { RootId::Mpd => &mpd, RootId::Manifest => &manifest, RootId::Models | RootId::Parts | RootId::P | RootId::External => &uploaded, RootId::OfficialParts | RootId::OfficialP => &official, }; let matching_count = matching .iter() .filter(|entry| { entry.root == chosen.root && (entry.path == *path || (entry.root.case_insensitive() && entry.path.as_str().eq_ignore_ascii_case(path.as_str()))) }) .count(); if matching_count > 1 { return Err(ResolveError::Collision { diagnostic: CollisionDiagnostic::FoldedPath, }); } let row = by_uri .get(&chosen.target) .ok_or(ResolveError::SourceChanged)?; let resource = AtUri::new_owned(&chosen.target).map_err(|_| ResolveError::SourceChanged)?; let blobs = parse_blob_cids(&row.ordered_blob_cids); if blobs.is_empty() { return Err(ResolveError::Integrity { diagnostic: IntegrityDiagnostic::BlobCid, }); } if row.sha256.len() != 32 || row.byte_length < 0 { return Err(ResolveError::Integrity { diagnostic: if row.byte_length < 0 { IntegrityDiagnostic::Length } else { IntegrityDiagnostic::Digest }, }); } let target = TargetIdentity { blob_cids: blobs.clone(), }; let content_hash = source_hash(&blobs, &row.sha256, row.byte_length); let cache_key = CacheKey { project: project.clone(), source: SourceIdentity { resource: resource.clone(), content_hash: content_hash.clone(), }, root: chosen.root, path: chosen.path.clone(), target: target.clone(), }; Ok(ResolvedResource { cache_key, resource: ResourceIdentity { uri: resource.clone(), content_hash, }, root: chosen.root, path: chosen.path, target, mime_type: row.mime_type.clone(), byte_length: row.byte_length, sha256: row.sha256.clone(), source: ByteSource { resource_uri: resource, blob_cids: blobs, }, }) } } #[async_trait] impl LdrawResourceResolver for SqliteResolver { async fn resolve( &self, project: &AtUri, path: &CanonicalPath, ) -> Result { let resource = self.candidate(project, path).await?; let verification = sqlx::query!( "SELECT source_identity, state, diagnostic FROM ldraw_verification WHERE resource_uri = ?", resource.resource.uri.as_ref() ) .fetch_optional(&self.pool) .await .map_err(|_| ResolveError::Unavailable { diagnostic: AvailabilityDiagnostic::SourceUnavailable, })? .ok_or(ResolveError::Unverified)?; let state = VerificationState::parse(&verification.state).ok_or(ResolveError::Rejected { diagnostic: DiagnosticCode::InvalidManifest, })?; let expected_source = serialize_source_identity(&resource.cache_key.source); let record = VerificationRecord { state, source_identity: verification.source_identity, diagnostic: verification.diagnostic, }; if record.source_identity != expected_source { let reset = super::verification::source_changed(&record, expected_source); return Err(match reset.state { VerificationState::Unverified => ResolveError::SourceChanged, _ => ResolveError::Rejected { diagnostic: DiagnosticCode::InvalidManifest, }, }); } match record.state { VerificationState::Verified => Ok(resource), VerificationState::Rejected => Err(ResolveError::Rejected { diagnostic: DiagnosticCode::InvalidManifest, }), VerificationState::Unverified => Err(ResolveError::Unverified), } } } /// Canonical serialization shared by projection, route bindings, and resolver. pub fn serialize_source_identity(source: &SourceIdentity) -> String { format!( "{}|{}", source.resource.as_ref(), hex_bytes(&source.content_hash) ) } /// Build the persisted identity for a resource row from its complete content metadata. pub fn source_identity_for( resource: &AtUri, blob_cids: &[String], sha256: &[u8], byte_length: i64, ) -> String { serialize_source_identity(&SourceIdentity { resource: resource.clone(), content_hash: source_hash(blob_cids, sha256, byte_length), }) } fn hex_bytes(bytes: &[u8]) -> String { bytes.iter().map(|byte| format!("{byte:02x}")).collect() } fn parse_blob_cids(value: &str) -> Vec { serde_json::from_str(value).unwrap_or_default() } fn source_hash(cids: &[String], sha256: &[u8], size: i64) -> Vec { let mut value = serde_json::to_vec(cids).unwrap_or_default(); value.extend_from_slice(sha256); value.extend_from_slice(&size.to_be_bytes()); value } fn root_id(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, }) } #[cfg(test)] mod tests { use super::*; #[test] fn cache_identity_changes_for_content_dimensions_not_principal() { let project = AtUri::new_owned("at://did:plc:a/space.polymodel.library.thing/p").unwrap(); let path = CanonicalPath::parse("parts/3001.dat").unwrap(); let base = CacheKey { project: project.clone(), source: SourceIdentity { resource: project.clone(), content_hash: vec![1], }, root: RootId::Parts, path: path.clone(), target: TargetIdentity { blob_cids: vec!["bafk".into()], }, }; let mut changed = base.clone(); changed.path = CanonicalPath::parse("parts/3002.dat").unwrap(); assert_ne!(base, changed); changed = base.clone(); changed.target.blob_cids.push("bafk2".into()); assert_ne!(base, changed); } }