From 52b01063b66674522fe002ce20ec6a18e554a671 Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 3 Aug 2026 19:27:07 -0400 Subject: [PATCH] PM-80: restore anonymous stateless compound resources --- ...c2ece40e973d6d9607e3e8e9747d117b0f806.json | 12 - ...5c0b31f774619cfc7c087ebdc97dc4d875af6.json | 74 -- ...397c9fb45c4fd9b8420004b36cc5aa2648c01.json | 50 -- ...78b8d3fb4e0d6c3e391458997bc8bb472b25b.json | 98 +++ ...c61492a5433ad04a26e0f7d6b4ac843dd0dcc.json | 110 +++ ...af8a5a53e4471253857005ec009686a7bc0a9.json | 12 - ...f0877483138892b86129738cae8e63ce35fe4.json | 26 + crates/polymodel-renderer-protocol/src/lib.rs | 9 +- .../polymodel-renderer-worker/src/worker.rs | 8 +- migrations/009_ldraw_resources.sql | 33 +- src/appview/download_tests.rs | 56 +- src/appview/ldraw.rs | 693 ++++++++---------- src/appview/mod.rs | 11 +- src/appview/state.rs | 2 +- src/ldraw/resolve.rs | 39 +- src/ldraw/verification.rs | 121 +-- src/viewer.rs | 5 +- 17 files changed, 667 insertions(+), 692 deletions(-) delete mode 100644 .sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json delete mode 100644 .sqlx/query-5621466585209ebe477f2ae01e75c0b31f774619cfc7c087ebdc97dc4d875af6.json delete mode 100644 .sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json create mode 100644 .sqlx/query-d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b.json create mode 100644 .sqlx/query-d87395649e34a00258562514babc61492a5433ad04a26e0f7d6b4ac843dd0dcc.json delete mode 100644 .sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json create mode 100644 .sqlx/query-f5e10559f7894deb1a1c0269dd3f0877483138892b86129738cae8e63ce35fe4.json diff --git a/.sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json b/.sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json deleted file mode 100644 index 9774e76..0000000 --- a/.sqlx/query-0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE ldraw_verification SET state = 'rejected', diagnostic = 'integrity-mismatch-v1', attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND attempt_token = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "0dd7d6c305d747bd45f7c04c5f6c2ece40e973d6d9607e3e8e9747d117b0f806" -} diff --git a/.sqlx/query-5621466585209ebe477f2ae01e75c0b31f774619cfc7c087ebdc97dc4d875af6.json b/.sqlx/query-5621466585209ebe477f2ae01e75c0b31f774619cfc7c087ebdc97dc4d875af6.json deleted file mode 100644 index db99a87..0000000 --- a/.sqlx/query-5621466585209ebe477f2ae01e75c0b31f774619cfc7c087ebdc97dc4d875af6.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT uri AS \"uri!\", cid AS \"cid!\", name AS \"name!\", format, file_json AS \"file_json!\"\n FROM parts WHERE uri = ?", - "describe": { - "columns": [ - { - "name": "uri!", - "ordinal": 0, - "type_info": "Text", - "origin": { - "Table": { - "table": "parts", - "name": "uri" - } - } - }, - { - "name": "cid!", - "ordinal": 1, - "type_info": "Text", - "origin": { - "Table": { - "table": "parts", - "name": "cid" - } - } - }, - { - "name": "name!", - "ordinal": 2, - "type_info": "Text", - "origin": { - "Table": { - "table": "parts", - "name": "name" - } - } - }, - { - "name": "format", - "ordinal": 3, - "type_info": "Text", - "origin": { - "Table": { - "table": "parts", - "name": "format" - } - } - }, - { - "name": "file_json!", - "ordinal": 4, - "type_info": "Text", - "origin": { - "Table": { - "table": "parts", - "name": "file_json" - } - } - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - false - ] - }, - "hash": "5621466585209ebe477f2ae01e75c0b31f774619cfc7c087ebdc97dc4d875af6" -} diff --git a/.sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json b/.sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json deleted file mode 100644 index 191379a..0000000 --- a/.sqlx/query-993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT state, attempt_token, lease_expires_at FROM ldraw_verification WHERE resource_uri = ?", - "describe": { - "columns": [ - { - "name": "state", - "ordinal": 0, - "type_info": "Text", - "origin": { - "Table": { - "table": "ldraw_verification", - "name": "state" - } - } - }, - { - "name": "attempt_token", - "ordinal": 1, - "type_info": "Text", - "origin": { - "Table": { - "table": "ldraw_verification", - "name": "attempt_token" - } - } - }, - { - "name": "lease_expires_at", - "ordinal": 2, - "type_info": "Integer", - "origin": { - "Table": { - "table": "ldraw_verification", - "name": "lease_expires_at" - } - } - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - true - ] - }, - "hash": "993ddd19315c0c0d93925def404397c9fb45c4fd9b8420004b36cc5aa2648c01" -} diff --git a/.sqlx/query-d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b.json b/.sqlx/query-d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b.json new file mode 100644 index 0000000..b4c7fce --- /dev/null +++ b/.sqlx/query-d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal ASC", + "describe": { + "columns": [ + { + "name": "root", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "root" + } + } + }, + { + "name": "canonical_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "canonical_path" + } + } + }, + { + "name": "target_resource_uri", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "target_resource_uri" + } + } + }, + { + "name": "target_cid", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "target_cid" + } + } + }, + { + "name": "byte_length", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "byte_length" + } + } + }, + { + "name": "sha256", + "ordinal": 5, + "type_info": "Blob", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "sha256" + } + } + }, + { + "name": "mime_type", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_manifest_files", + "name": "mime_type" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "d18780ca6ae14a81fe3b043151478b8d3fb4e0d6c3e391458997bc8bb472b25b" +} diff --git a/.sqlx/query-d87395649e34a00258562514babc61492a5433ad04a26e0f7d6b4ac843dd0dcc.json b/.sqlx/query-d87395649e34a00258562514babc61492a5433ad04a26e0f7d6b4ac843dd0dcc.json new file mode 100644 index 0000000..1c6c9f1 --- /dev/null +++ b/.sqlx/query-d87395649e34a00258562514babc61492a5433ad04a26e0f7d6b4ac843dd0dcc.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT resource_uri, owner_did, sha256, ordered_blob_cids, root, canonical_path, mime_type, byte_length\n FROM ldraw_resources\n WHERE project_uri = ?", + "describe": { + "columns": [ + { + "name": "resource_uri", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "resource_uri" + } + } + }, + { + "name": "owner_did", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "owner_did" + } + } + }, + { + "name": "sha256", + "ordinal": 2, + "type_info": "Blob", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "sha256" + } + } + }, + { + "name": "ordered_blob_cids", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "ordered_blob_cids" + } + } + }, + { + "name": "root", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "root" + } + } + }, + { + "name": "canonical_path", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "canonical_path" + } + } + }, + { + "name": "mime_type", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "mime_type" + } + } + }, + { + "name": "byte_length", + "ordinal": 7, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "byte_length" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "d87395649e34a00258562514babc61492a5433ad04a26e0f7d6b4ac843dd0dcc" +} diff --git a/.sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json b/.sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json deleted file mode 100644 index 75d6978..0000000 --- a/.sqlx/query-dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE ldraw_verification SET state = 'verifying', attempt_token = ?, lease_expires_at = ?, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND (state = 'unverified' OR (state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?)))", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "dc55a59c3ee37cb600702aee282af8a5a53e4471253857005ec009686a7bc0a9" -} diff --git a/.sqlx/query-f5e10559f7894deb1a1c0269dd3f0877483138892b86129738cae8e63ce35fe4.json b/.sqlx/query-f5e10559f7894deb1a1c0269dd3f0877483138892b86129738cae8e63ce35fe4.json new file mode 100644 index 0000000..c1157ec --- /dev/null +++ b/.sqlx/query-f5e10559f7894deb1a1c0269dd3f0877483138892b86129738cae8e63ce35fe4.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT state FROM ldraw_verification WHERE resource_uri = ?", + "describe": { + "columns": [ + { + "name": "state", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "state" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "f5e10559f7894deb1a1c0269dd3f0877483138892b86129738cae8e63ce35fe4" +} diff --git a/crates/polymodel-renderer-protocol/src/lib.rs b/crates/polymodel-renderer-protocol/src/lib.rs index 3d7a0e8..836b543 100644 --- a/crates/polymodel-renderer-protocol/src/lib.rs +++ b/crates/polymodel-renderer-protocol/src/lib.rs @@ -185,7 +185,10 @@ impl HandshakeState { (Self::Negotiating, CommandPayload::Accept(negotiated)) if negotiated.version == PROTOCOL_VERSION && negotiated.session_id != 0 - && negotiated.capabilities.windows(2).all(|pair| pair[0] != pair[1]) => + && negotiated + .capabilities + .windows(2) + .all(|pair| pair[0] != pair[1]) => { *self = Self::Negotiated(negotiated.clone()); Ok(None) @@ -591,7 +594,9 @@ pub enum CompoundSceneError { /// Typed boundary between G's verified route fetch and PM-79's LDraw parser/scene adapter. /// G owns acquisition and integrity only; PM-79 owns bytes-to-scene conversion and exact LDU /// conversion. The source is returned intact so the mesh/worker boundary can consume it. -pub fn handoff_compound_scene(source: CompoundSceneSource) -> Result { +pub fn handoff_compound_scene( + source: CompoundSceneSource, +) -> Result { if source.descriptors.is_empty() || source.primary.is_empty() { return Err(CompoundSceneError::Empty); } diff --git a/crates/polymodel-renderer-worker/src/worker.rs b/crates/polymodel-renderer-worker/src/worker.rs index 20b1bec..6436bec 100644 --- a/crates/polymodel-renderer-worker/src/worker.rs +++ b/crates/polymodel-renderer-worker/src/worker.rs @@ -1691,10 +1691,10 @@ fn handle_command(state: &Rc>, cmd: RendererCommand) { return; }; let capability_allowed = match &cmd { - RendererCommand::StartCompoundLoad { .. } => - capabilities.contains(&Capability::CompoundLdraw), - RendererCommand::RenderPreviewImage { .. } => - capabilities.contains(&Capability::Preview), + RendererCommand::StartCompoundLoad { .. } => { + capabilities.contains(&Capability::CompoundLdraw) + } + RendererCommand::RenderPreviewImage { .. } => capabilities.contains(&Capability::Preview), RendererCommand::Cancel { .. } => capabilities.contains(&Capability::Cancellation), _ => true, }; diff --git a/migrations/009_ldraw_resources.sql b/migrations/009_ldraw_resources.sql index 7b4c4cd..0da373c 100644 --- a/migrations/009_ldraw_resources.sql +++ b/migrations/009_ldraw_resources.sql @@ -1,6 +1,7 @@ --- PM-76: durable LDraw resource projection, verification, and opaque routes. --- All dependent cleanup is performed by application transactions; SQLite foreign-key --- enforcement is intentionally not required by this schema. +-- PM-76: durable LDraw resource projection and verification metadata. +-- Public compound routes are stateless; this schema intentionally contains no +-- route rows, bearer capabilities, expiry, replay, revocation, or CAS state. +-- SQLite foreign-key enforcement is intentionally not required by this schema. CREATE TABLE ldraw_resources ( resource_uri TEXT PRIMARY KEY, @@ -19,7 +20,6 @@ CREATE TABLE ldraw_resources ( notices_json TEXT NOT NULL, snapshot_id TEXT, legacy INTEGER NOT NULL DEFAULT 0 CHECK (legacy IN (0, 1)), - revoked_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE (owner_did, project_uri, root, canonical_path), @@ -47,34 +47,13 @@ CREATE INDEX idx_ldraw_manifest_target ON ldraw_manifest_files(target_resource_u CREATE TABLE ldraw_verification ( resource_uri TEXT PRIMARY KEY, source_identity TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('unverified', 'verifying', 'verified', 'rejected')), - attempt_token TEXT UNIQUE, - lease_expires_at INTEGER, - expired_attempts INTEGER NOT NULL DEFAULT 0 CHECK (expired_attempts >= 0 AND expired_attempts <= 3), + state TEXT NOT NULL CHECK (state IN ('unverified', 'verified', 'rejected')), diagnostic TEXT, - revoked_at INTEGER, updated_at INTEGER NOT NULL ); -CREATE INDEX idx_ldraw_verification_state ON ldraw_verification(state, lease_expires_at); +CREATE INDEX idx_ldraw_verification_state ON ldraw_verification(state); CREATE INDEX idx_ldraw_verification_source ON ldraw_verification(source_identity); -CREATE TABLE ldraw_routes ( - token_digest BLOB PRIMARY KEY CHECK (length(token_digest) = 32), - principal_did TEXT, - project_uri TEXT NOT NULL, - resource_uri TEXT NOT NULL, - source_identity TEXT NOT NULL, - root TEXT NOT NULL, - canonical_path TEXT NOT NULL, - target_identity TEXT NOT NULL, - expires_at INTEGER NOT NULL, - revoked_at INTEGER, - created_at INTEGER NOT NULL, - UNIQUE (principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity) -); -CREATE INDEX idx_ldraw_routes_auth ON ldraw_routes(principal_did, project_uri, expires_at, revoked_at); -CREATE INDEX idx_ldraw_routes_resource ON ldraw_routes(resource_uri, source_identity); - CREATE TABLE ldraw_official_snapshots ( snapshot_id TEXT PRIMARY KEY, digest BLOB NOT NULL CHECK (length(digest) = 32), diff --git a/src/appview/download_tests.rs b/src/appview/download_tests.rs index 46ca90d..c53a719 100644 --- a/src/appview/download_tests.rs +++ b/src/appview/download_tests.rs @@ -153,10 +153,27 @@ async fn get_part_file_later_blob_failure_truncates_committed_stream() { } #[tokio::test] -async fn removed_ldraw_routes_and_public_download_surface_are_rejected() { +async fn anonymous_compound_surface_is_present_without_legacy_routes_or_route_rows() { let state = state().await; let pool = state.pool.clone(); let app = crate::appview::router().with_state(state); + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/app/ldraw/compound-resources") + .header("content-type", "application/json") + .body(Body::from( + r#"{"project":"at://did:plc:x/space.polymodel.library.thing/main","path":"models/main.ldr"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + for (method, path, body) in [ (Method::GET, "/app/parts/did:plc:x/abc/download", Body::empty()), (Method::GET, "/app/ldraw/did:plc:x/abc", Body::empty()), @@ -164,16 +181,28 @@ async fn removed_ldraw_routes_and_public_download_surface_are_rejected() { (Method::GET, "/xrpc/space.polymodel.library.getLdrawResource?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fresource%2Fabc", Body::empty()), (Method::POST, "/xrpc/space.polymodel.library.mintLdrawResource", Body::from("{}")), ] { - let resp = app.clone().oneshot( - Request::builder().method(method.clone()).uri(path).body(body).unwrap(), - ).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND, "removed route must stay absent: {method} {path}"); + let response = app + .clone() + .oneshot( + Request::builder() + .method(method.clone()) + .uri(path) + .body(body) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "legacy route must stay absent: {method} {path}" + ); } + for table in [ "ldraw_resources", "ldraw_manifest_files", "ldraw_verification", - "ldraw_routes", "ldraw_official_snapshots", "ldraw_legacy_resources", ] { @@ -184,6 +213,19 @@ async fn removed_ldraw_routes_and_public_download_surface_are_rejected() { .fetch_optional(&pool) .await .unwrap(); - assert!(exists.is_none(), "cleanup migration must remove {table}"); + assert!( + exists.is_some(), + "PM-76 projection table must remain: {table}" + ); } + let routes: Option = sqlx::query_scalar( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ldraw_routes'", + ) + .fetch_optional(&pool) + .await + .unwrap(); + assert!( + routes.is_none(), + "public compound routes must remain stateless" + ); } diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs index dcd47a3..90d1723 100644 --- a/src/appview/ldraw.rs +++ b/src/appview/ldraw.rs @@ -1,123 +1,173 @@ -use axum::Json; use axum::body::Body; -use axum::extract::{Query, State}; +use axum::extract::{Path as AxumPath, State}; use axum::http::{StatusCode, header}; use axum::response::Response; -use chrono::Utc; +use base64::Engine as _; use futures::TryStreamExt; -use futures::stream::{self}; -use jacquard::client::Agent; +use futures::stream; use jacquard::identity::resolver::IdentityResolver; -use jacquard_axum::oauth::ExtractOAuthSession; -use jacquard_common::types::string::AtUri; +use jacquard_common::types::string::{AtUri, Cid, Did}; use jacquard_common::xrpc::XrpcExt; -use rand::RngCore; +use polymodel_api::com_atproto::sync::get_blob::GetBlob; +use polymodel_renderer_protocol::{ + CompoundLoadPlan, MAX_COMPOUND_DESCRIPTORS, MAX_COMPOUND_METADATA_BYTES, + MAX_COMPOUND_TOTAL_BYTES, PROTOCOL_VERSION, WorkerFetchDescriptor, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::{HashSet, VecDeque}; -use super::error::{AppResult, db, internal, invalid_request, not_found, unauthorized}; +use super::error::{AppResult, db, internal, invalid_request, not_found}; use super::state::AppState; -use super::writes::{ExtractSession, authenticated_did}; -use crate::ldraw::resolve::{SqliteResolver, serialize_source_identity}; -use crate::ldraw::verification::{VerificationRecord, VerificationState, claim, complete}; -use crate::ldraw::{CanonicalPath, LdrawResourceResolver}; -use polymodel_renderer_protocol::{ - CompoundLoadPlan, WorkerFetchDescriptor, MAX_COMPOUND_DESCRIPTORS, - MAX_COMPOUND_METADATA_BYTES, MAX_COMPOUND_TOTAL_BYTES, PROTOCOL_VERSION, -}; +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_COMPOUND_DESCRIPTORS * 8; #[derive(Debug, Deserialize)] -pub(super) struct MintRequest { +pub(super) struct CompoundRequest { pub project: AtUri, pub path: String, } -#[derive(Debug, Serialize)] -pub(super) struct MintResponse { - pub token: String, - #[serde(rename = "expiresAt")] - pub expires_at: String, +#[derive(Debug, Clone, PartialEq, Eq)] +struct RouteBinding { + version: u8, + project: AtUri, + publisher: Did, + source: AtUri, + root: RootId, + path: CanonicalPath, + target_cids: Vec, + byte_length: u64, + sha256: Vec, } -#[derive(Debug, Deserialize)] -pub(super) struct FetchQuery { - pub token: String, +#[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)] -pub(super) struct CompoundRequest { - pub project: AtUri, - pub path: String, -} - -#[derive(Debug, Serialize)] -pub(super) struct CompoundResponse { - pub plan: CompoundLoadPlan, +struct RoutePayloadOwned { + v: u8, + project: AtUri, + publisher: Did, + source: AtUri, + root: String, + path: String, + cids: Vec, + len: u64, + sha256: Vec, } pub(super) async fn compound_resources( State(state): State, - ExtractOAuthSession(session): ExtractSession, - Json(request): Json, -) -> AppResult> { - let agent = Agent::from(session); - let principal = authenticated_did(&agent).await?; + axum::Json(request): axum::Json, +) -> AppResult> { let path = CanonicalPath::parse(&request.path) .map_err(|diagnostic| invalid_request(format!("invalid LDraw path: {diagnostic:?}")))?; let resolver = SqliteResolver::new(state.pool.clone()); - let primary = ensure_verified(&state, &resolver, Some(&principal), &request.project, &path) - .await - .map_err(resolve_error)?; - let mut resources = vec![primary]; - let mut seen = std::collections::HashSet::new(); - seen.insert(path.as_str().to_owned()); - let mut index = 0; - while index < resources.len() { - if resources.len() > MAX_COMPOUND_DESCRIPTORS { - return Err(invalid_request("compound resource descriptor bound exceeded")); + let mut resources = Vec::new(); + let mut seen_paths = HashSet::new(); + let mut queue = VecDeque::from([path.clone()]); + let mut manifest_edges = 0_usize; + + while let Some(current_path) = queue.pop_front() { + if !seen_paths.insert(current_path.clone()) { + continue; } - let current = &resources[index]; - let rows = sqlx::query!( - "SELECT canonical_path, ordinal FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal ASC", - current.resource.uri.as_ref(), - ) - .fetch_all(&state.pool) - .await - .map_err(|error| internal(format!("LDraw manifest enumeration failed: {error}")))?; + if seen_paths.len() > MAX_COMPOUND_DESCRIPTORS { + return Err(invalid_request( + "compound resource descriptor bound exceeded", + )); + } + let current = resolver + .resolve(&request.project, ¤t_path) + .await + .map_err(resolve_error)?; + let current_key = (current.root, current.path.clone()); + if resources.iter().any(|resource: &ResolvedResource| { + (resource.root, resource.path.clone()) == current_key + }) { + continue; + } + resources.push(current.clone()); + + let rows = db( + sqlx::query!( + "SELECT root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type FROM ldraw_manifest_files WHERE resource_uri = ? ORDER BY ordinal ASC", + current.resource.uri.as_ref(), + ) + .fetch_all(&state.pool) + .await, + )?; for row in rows { - let child_path = CanonicalPath::parse(&row.canonical_path) - .map_err(|_| not_found())?; - if seen.insert(child_path.as_str().to_owned()) { - let child = ensure_verified( - &state, - &resolver, - Some(&principal), - &request.project, - &child_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_path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; + let child_root = parse_root(&row.root).ok_or_else(not_found)?; + if !contains_in_scope( + current.root, + current.path.as_str(), + child_root, + child_path.as_str(), + ) { + return Err(not_found()); + } + let child = resolver + .resolve(&request.project, &child_path) .await .map_err(resolve_error)?; - resources.push(child); + if child.root != child_root + || child.path != child_path + || child.resource.uri.as_ref() != row.target_resource_uri + || child.target.blob_cids.first().map(String::as_str) + != Some(row.target_cid.as_str()) + || child.byte_length != row.byte_length + || child.sha256 != row.sha256 + || child.mime_type != row.mime_type + { + return Err(not_found()); } + queue.push_back(child_path); } - index += 1; } + let mut descriptors = Vec::with_capacity(resources.len()); + let mut seen_keys = HashSet::new(); let mut total_bytes = 0_u64; let mut metadata_bytes = path.as_str().len(); - let mut route_tokens = Vec::with_capacity(resources.len()); for resource in resources { - let token = issue_one_route(&state, &principal, &request.project, &resource).await?; + let binding = binding_for(&request.project, &resource)?; + let route = encode_binding(&binding)?; + let key = resource.path.to_string(); + if !seen_keys.insert(key.clone()) { + continue; + } let descriptor = WorkerFetchDescriptor { - key: resource.path.to_string(), - route: format!("/xrpc/space.polymodel.library.getLdrawResource?token={token}"), - path: resource.path.to_string(), + key: key.clone(), + route, + path: key, root: resource.root.as_str().to_owned(), - byte_length: u64::try_from(resource.byte_length) - .map_err(|_| invalid_request("negative LDraw resource length"))?, - sha256: resource.sha256, + byte_length: binding.byte_length, + sha256: binding.sha256, mime_type: resource.mime_type, - target_cids: resource.target.blob_cids, + target_cids: binding.target_cids, }; descriptor .validate() @@ -125,10 +175,24 @@ pub(super) async fn compound_resources( total_bytes = total_bytes .checked_add(descriptor.byte_length) .ok_or_else(|| invalid_request("compound byte bound overflow"))?; + let descriptor_metadata = descriptor + .key + .len() + .checked_add(descriptor.route.len()) + .and_then(|size| size.checked_add(descriptor.path.len())) + .and_then(|size| size.checked_add(descriptor.root.len())) + .and_then(|size| size.checked_add(descriptor.mime_type.len())) + .and_then(|size| { + descriptor + .target_cids + .iter() + .try_fold(size, |total, cid| total.checked_add(cid.len())) + }) + .ok_or_else(|| invalid_request("compound metadata bound overflow"))?; metadata_bytes = metadata_bytes - .checked_add(descriptor.key.len() + descriptor.route.len() + descriptor.root.len()) + .checked_add(descriptor_metadata) .ok_or_else(|| invalid_request("compound metadata bound overflow"))?; - route_tokens.push(descriptor); + descriptors.push(descriptor); } if total_bytes > MAX_COMPOUND_TOTAL_BYTES || metadata_bytes > MAX_COMPOUND_METADATA_BYTES { return Err(invalid_request("compound plan bounds exceeded")); @@ -136,140 +200,28 @@ pub(super) async fn compound_resources( let plan = CompoundLoadPlan { version: PROTOCOL_VERSION, primary: path.to_string(), - descriptors: route_tokens, + descriptors, }; plan.validate() .map_err(|error| invalid_request(format!("invalid compound plan: {error:?}")))?; plan.encoded_size() .map_err(|error| invalid_request(format!("compound plan encoding failed: {error:?}")))?; - Ok(Json(plan)) -} - -async fn issue_one_route( - state: &AppState, - principal: &jacquard_common::types::string::Did, - project: &AtUri, - resource: &crate::ldraw::resolve::ResolvedResource, -) -> AppResult { - let mut token_bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut token_bytes); - let digest = Sha256::digest(token_bytes); - let digest_bytes: &[u8] = digest.as_ref(); - let now = Utc::now().timestamp_nanos_opt().unwrap_or_default(); - let expires_at = now + 300_000_000_000_i64; - db(sqlx::query!( - "INSERT INTO ldraw_routes (token_digest, principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity, expires_at, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", - digest_bytes, - principal.as_ref(), - project.as_ref(), - resource.resource.uri.as_ref(), - serialize_source_identity(&resource.cache_key.source), - resource.root.as_str(), - resource.path.as_str(), - serde_json::to_string(&resource.target.blob_cids).map_err(|error| internal(format!("target identity serialization failed: {error}")))?, - expires_at, - now, - ).execute(&state.pool).await)?; - Ok(encode_token(&token_bytes)) -} - -pub(super) async fn mint( - State(state): State, - ExtractOAuthSession(session): ExtractSession, - Json(request): Json, -) -> AppResult> { - let agent = Agent::from(session); - let principal = authenticated_did(&agent).await?; - let path = CanonicalPath::parse(&request.path) - .map_err(|diagnostic| invalid_request(format!("invalid LDraw path: {diagnostic:?}")))?; - let resolver = SqliteResolver::new(state.pool.clone()); - let resource = ensure_verified(&state, &resolver, Some(&principal), &request.project, &path) - .await - .map_err(resolve_error)?; - - let mut token_bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut token_bytes); - let digest = Sha256::digest(token_bytes); - let digest_bytes: &[u8] = digest.as_ref(); - let now = Utc::now().timestamp_nanos_opt().unwrap_or_default(); - let expires_at = now + 300_000_000_000_i64; - db(sqlx::query!( - "INSERT INTO ldraw_routes (token_digest, principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity, expires_at, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", - digest_bytes, - principal.as_ref(), - request.project.as_ref(), - resource.resource.uri.as_ref(), - serialize_source_identity(&resource.cache_key.source), - resource.root.as_str(), - resource.path.as_str(), - serde_json::to_string(&resource.target.blob_cids) - .map_err(|e| internal(format!("LDraw target identity serialization failed: {e}")))?, - expires_at, - now, - ).execute(&state.pool).await)?; - - Ok(Json(MintResponse { - token: encode_token(&token_bytes), - expires_at: chrono::DateTime::from_timestamp_nanos(expires_at).to_rfc3339(), - })) + Ok(axum::Json(plan)) } -pub(super) async fn fetch( +pub(super) async fn fetch_resource( State(state): State, - ExtractOAuthSession(session): ExtractSession, - Query(query): Query, + AxumPath(binding): AxumPath, ) -> AppResult { - let agent = Agent::from(session); - let principal = authenticated_did(&agent).await.map_err(|_| not_found())?; - let token = - decode_token(&query.token).ok_or_else(|| invalid_request("invalid LDraw route token"))?; - let digest = Sha256::digest(token); - let digest_bytes: &[u8] = digest.as_ref(); - let now = Utc::now().timestamp_nanos_opt().unwrap_or_default(); - let row = sqlx::query!( - "SELECT principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity FROM ldraw_routes WHERE token_digest = ? AND revoked_at IS NULL AND expires_at > ? AND principal_did = ?", - digest_bytes, - now, - principal.as_ref(), - ).fetch_optional(&state.pool).await.map_err(|_| not_found())?.ok_or_else(not_found)?; - let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; - let project = AtUri::new_owned(&row.project_uri).map_err(|_| not_found())?; - let row_principal = row - .principal_did - .as_deref() - .and_then(|value| jacquard_common::types::string::Did::new_owned(value).ok()) - .ok_or_else(not_found)?; - if row_principal != principal { - return Err(not_found()); - } + let binding = decode_binding(&binding).map_err(|_| not_found())?; let resolver = SqliteResolver::new(state.pool.clone()); let resource = resolver - .resolve(Some(&principal), &project, &path) + .resolve(&binding.project, &binding.path) .await - .map_err(resolve_error)?; - if resource.resource.uri.as_ref() != row.resource_uri - || serialize_source_identity(&resource.cache_key.source) != row.source_identity - || resource.root.as_str() != row.root - || serde_json::to_string(&resource.target.blob_cids).unwrap_or_default() - != row.target_identity - { + .map_err(|_| not_found())?; + if binding_for(&binding.project, &resource).map_err(|_| not_found())? != binding { return Err(not_found()); } - let mut tx = state.pool.begin().await.map_err(|_| not_found())?; - let consumed = sqlx::query!( - "UPDATE ldraw_routes SET revoked_at = ? WHERE token_digest = ? AND principal_did = ? AND revoked_at IS NULL AND expires_at > ?", - now, - digest_bytes, - principal.as_ref(), - now, - ) - .execute(&mut *tx) - .await - .map_err(|_| not_found())?; - if consumed.rows_affected() != 1 { - return Err(not_found()); - } - tx.commit().await.map_err(|_| not_found())?; let bytes = fetch_verified_bytes(&state, &resource).await?; Response::builder() .status(StatusCode::OK) @@ -281,159 +233,137 @@ pub(super) async fn fetch( .map_err(|error| internal(format!("LDraw response build failed: {error}"))) } -async fn ensure_verified( - state: &AppState, - resolver: &SqliteResolver, - principal: Option<&jacquard_common::types::string::Did>, - project: &AtUri, - path: &CanonicalPath, -) -> Result { - let resource = resolver.candidate(principal, project, path).await?; - let source = serialize_source_identity(&resource.cache_key.source); - let row = sqlx::query!( - "SELECT state, attempt_token, lease_expires_at, expired_attempts, revoked_at FROM ldraw_verification WHERE resource_uri = ?", - resource.resource.uri.as_ref(), - ).fetch_optional(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?.ok_or(crate::ldraw::ResolveError::Unverified)?; - if row.state == "verified" { - return Ok(resource); - } - if row.state == "rejected" { - return Err(crate::ldraw::ResolveError::Rejected { - diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, - }); +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()); } - let now = now_nanos(); - let mut token = [0_u8; 32]; - rand::rng().fill_bytes(&mut token); - let attempt = encode_token(&token); - let record = VerificationRecord { - state: match row.state.as_str() { - "unverified" => VerificationState::Unverified, - "verifying" => VerificationState::Verifying, - _ => return Err(crate::ldraw::ResolveError::SourceChanged), - }, - source_identity: source.clone(), - attempt_token: row.attempt_token.clone(), - lease_expires_at: row.lease_expires_at, - expired_attempts: row.expired_attempts.try_into().unwrap_or(u32::MAX), - diagnostic: None, - revoked_at: row.revoked_at, + 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(), + }) +} + +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 lease = now + crate::ldraw::verification::LEASE_NANOS; - let claim = claim(&record, now, attempt.clone()); - match &claim { - crate::ldraw::verification::Transition::AlreadyVerifying => { - return Err(crate::ldraw::ResolveError::Verifying); - } - crate::ldraw::verification::Transition::Rejected(_reason) - if record.revoked_at.is_some() => - { - return Err(crate::ldraw::ResolveError::Rejected { - diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, - }); - } - crate::ldraw::verification::Transition::Rejected(reason) => { - let rejected = sqlx::query!( - "UPDATE ldraw_verification SET state = 'rejected', diagnostic = ?, attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", - reason, now, resource.resource.uri.as_ref(), source, record.attempt_token, now, - ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; - return if rejected.rows_affected() == 1 { - Err(crate::ldraw::ResolveError::Rejected { - diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, - }) - } else { - Err(crate::ldraw::ResolveError::SourceChanged) - }; - } - crate::ldraw::verification::Transition::Claim { .. } => {} - crate::ldraw::verification::Transition::Verified => { - return Ok(resource); - } - crate::ldraw::verification::Transition::Reset => { - return Err(crate::ldraw::ResolveError::SourceChanged); - } + 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 claimed = sqlx::query!( - "UPDATE ldraw_verification SET state = 'verifying', attempt_token = ?, lease_expires_at = ?, expired_attempts = expired_attempts + CASE WHEN state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?) THEN 1 ELSE 0 END, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND expired_attempts < ? AND (state = 'unverified' OR (state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?)))", - attempt, lease, now, now, resource.resource.uri.as_ref(), source, crate::ldraw::verification::MAX_EXPIRED_ATTEMPTS as i64, now, - ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; - if claimed.rows_affected() != 1 { - return Err(crate::ldraw::ResolveError::Verifying); + 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(()); } - let active_record = VerificationRecord { - state: VerificationState::Verifying, - source_identity: source.clone(), - attempt_token: Some(attempt.clone()), - lease_expires_at: Some(lease), - expired_attempts: record.expired_attempts - + u32::from(record.state == VerificationState::Verifying), - diagnostic: None, - revoked_at: record.revoked_at, - }; - let verification = fetch_verified_bytes(state, &resource).await; - match verification { - Ok(_) => { - complete(&active_record, &attempt, &source, now_nanos()) - .map_err(|_| crate::ldraw::ResolveError::SourceChanged)?; - let completed = sqlx::query!( - "UPDATE ldraw_verification SET state = 'verified', attempt_token = NULL, lease_expires_at = NULL, diagnostic = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND lease_expires_at > ?", - now_nanos(), resource.resource.uri.as_ref(), source, attempt, now_nanos(), - ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; - if completed.rows_affected() == 1 { - Ok(resource) - } else { - Err(crate::ldraw::ResolveError::SourceChanged) - } - } - Err(_) => { - let rejection_now = now_nanos(); - let rejected = sqlx::query!( - "UPDATE ldraw_verification SET state = 'rejected', diagnostic = 'integrity-mismatch-v1', attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND lease_expires_at > ?", - rejection_now, resource.resource.uri.as_ref(), source, attempt, rejection_now, - ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; - if rejected.rows_affected() == 1 { - Err(crate::ldraw::ResolveError::Rejected { - diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, - }) - } else { - Err(crate::ldraw::ResolveError::SourceChanged) - } - } + 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 parse_root(value: &str) -> Option { + Some(match value { + "mpd" => RootId::Mpd, + "manifest" => RootId::Manifest, + "models" => RootId::Models, + "parts" => RootId::Parts, + "p" => RootId::P, + "official-parts" => RootId::OfficialParts, + "official-p" => RootId::OfficialP, + _ => return None, + }) +} + +fn contains_in_scope(parent_root: RootId, parent: &str, child_root: RootId, child: &str) -> bool { + if parent_root != child_root { + return false; } + child == parent + || child + .strip_prefix(parent) + .is_some_and(|suffix| suffix.starts_with('/')) + || parent + .rsplit_once('/') + .is_some_and(|(prefix, _)| child == prefix || child.starts_with(&format!("{prefix}/"))) } -async fn fetch_verified_bytes( - state: &AppState, - resource: &crate::ldraw::resolve::ResolvedResource, -) -> AppResult> { - let did = - super::actor::resolve_actor(&state, &resource.source.resource_uri.authority().convert()) - .await?; +async fn fetch_verified_bytes(state: &AppState, resource: &ResolvedResource) -> 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(|e| internal(format!("DID document resolution failed: {e}")))?; + .map_err(|error| internal(format!("DID document resolution failed: {error}")))?; let did_doc = did_doc_response .parse() - .map_err(|e| internal(format!("DID document parse failed: {e}")))?; + .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::new(); for value in &resource.source.blob_cids { - let cid = jacquard_common::types::string::Cid::new_owned(value.as_bytes()) - .map_err(|e| internal(format!("invalid LDraw blob CID: {e}")))?; + 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(&polymodel_api::com_atproto::sync::get_blob::GetBlob { + .download(&GetBlob { did: did.clone(), cid, }) .await - .map_err(|e| internal(format!("blob fetch failed: {e}")))?; + .map_err(|error| internal(format!("blob fetch failed: {error}")))?; if !response.status().is_success() { return Err(internal(format!( "blob fetch failed with HTTP {}", @@ -441,70 +371,99 @@ async fn fetch_verified_bytes( ))); } let (_, body) = response.into_parts(); - let chunked = body + let chunk = body .into_inner() .try_fold(Vec::new(), |mut bytes, chunk| async move { bytes.extend_from_slice(&chunk); Ok::<_, _>(bytes) }) .await - .map_err(|e| internal(format!("blob stream failed: {e}")))?; - bytes.extend_from_slice(&chunked); + .map_err(|error| internal(format!("blob stream failed: {error}")))?; + 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 i64 != resource.byte_length { - return Err(internal("LDraw byte length verification failed")); + return Err(invalid_request("LDraw byte length verification failed")); } - if sha2::Sha256::digest(&bytes).as_slice() != resource.sha256.as_slice() { - return Err(internal("LDraw SHA-256 verification failed")); + if Sha256::digest(&bytes).as_slice() != resource.sha256.as_slice() { + return Err(invalid_request("LDraw SHA-256 verification failed")); } Ok(bytes) } -fn now_nanos() -> i64 { - Utc::now().timestamp_nanos_opt().unwrap_or_default() -} - -fn resolve_error(error: crate::ldraw::ResolveError) -> super::error::AppError { - match error { - crate::ldraw::ResolveError::Unauthorized => { - unauthorized("LDraw resource is not authorized") - } - crate::ldraw::ResolveError::NotFound - | crate::ldraw::ResolveError::Revoked - | crate::ldraw::ResolveError::Rejected { .. } - | crate::ldraw::ResolveError::Unverified - | crate::ldraw::ResolveError::Verifying - | crate::ldraw::ResolveError::SourceChanged - | crate::ldraw::ResolveError::InvalidPath { .. } - | crate::ldraw::ResolveError::Collision { .. } - | crate::ldraw::ResolveError::Integrity { .. } => not_found(), - crate::ldraw::ResolveError::Unavailable { .. } => internal("LDraw resolver unavailable"), +fn verify_chunk_cid(value: &str, bytes: &[u8]) -> Result<(), String> { + let cid = cid::Cid::try_from(value).map_err(|error| format!("invalid CID: {error}"))?; + if cid.hash().code() != 0x12 { + return Err(format!("unsupported CID hash code {}", cid.hash().code())); + } + if cid.hash().digest() != Sha256::digest(bytes).as_slice() { + return Err("CID digest mismatch".into()); } + Ok(()) } -fn encode_token(token: &[u8; 32]) -> String { - token.iter().map(|byte| format!("{byte:02x}")).collect() +fn hex_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() } -fn decode_token(value: &str) -> Option> { - if value.len() != 64 || !value.is_ascii() { - return None; +fn resolve_error(error: crate::ldraw::ResolveError) -> super::error::AppError { + match error { + crate::ldraw::ResolveError::Unavailable { .. } => internal("LDraw resolver unavailable"), + _ => not_found(), } - (0..value.len()) - .step_by(2) - .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok()) - .collect() } #[cfg(test)] mod tests { - use super::{decode_token, encode_token}; + 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 route_tokens_are_fixed_width_hex() { - let token = [7_u8; 32]; - let encoded = encode_token(&token); - assert_eq!(decode_token(&encoded), Some(token.to_vec())); - assert!(decode_token("short").is_none()); + 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" + )); } } diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 932871a..623fa36 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -24,12 +24,11 @@ mod content_type; mod downloads; mod drafts; mod graph; -mod library; mod ldraw; +mod library; mod proxy; mod writes; - #[cfg(test)] mod tests; @@ -130,12 +129,8 @@ pub fn router() -> Router { axum::routing::post(ldraw::compound_resources), ) .route( - "/app/ldraw/resources", - axum::routing::post(ldraw::mint), - ) - .route( - "/xrpc/space.polymodel.library.getLdrawResource", - axum::routing::get(ldraw::fetch), + "/app/ldraw/resources/{binding}", + axum::routing::get(ldraw::fetch_resource), ) // PM-43 app-internal draft store + image upload. Not federated lexicons, // so these use a plain `/app/*` namespace (cookie-authenticated, diff --git a/src/appview/state.rs b/src/appview/state.rs index 64f3015..26d6b2b 100644 --- a/src/appview/state.rs +++ b/src/appview/state.rs @@ -13,6 +13,7 @@ //! the orphan rule); it is cheaply `Clone` (pool/resolver/oauth are //! `Arc`-backed). +use crate::oauth::{OAuthBootstrap, SqliteAuthStore}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; use jacquard::client::BasicClient; @@ -22,7 +23,6 @@ use jacquard_axum::oauth::{OAuthWebConfig, OAuthWebState}; use sqlx::SqlitePool; use std::sync::Arc; use tokio::sync::Mutex; -use crate::oauth::{OAuthBootstrap, SqliteAuthStore}; /// State shared by all XRPC read handlers and the OAuth routes. #[derive(Clone)] diff --git a/src/ldraw/resolve.rs b/src/ldraw/resolve.rs index c698e2e..a0d8ce7 100644 --- a/src/ldraw/resolve.rs +++ b/src/ldraw/resolve.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use jacquard_common::types::string::{AtUri, Did}; +use jacquard_common::types::string::AtUri; use sqlx::SqlitePool; use std::collections::HashSet; @@ -17,9 +17,12 @@ pub struct TargetIdentity { } /// 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 principal: Option, pub project: AtUri, pub source: SourceIdentity, pub root: RootId, @@ -55,15 +58,11 @@ pub struct ResolvedResource { #[derive(Clone, Debug, Eq, PartialEq)] pub enum DiagnosticCode { InvalidManifest, - ExpiredAttempts, - Revoked, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum CollisionDiagnostic { - DuplicatePath, FoldedPath, - OutOfRoot, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -81,11 +80,8 @@ pub enum AvailabilityDiagnostic { #[derive(Clone, Debug, Eq, PartialEq)] pub enum ResolveError { NotFound, - Unauthorized, Unverified, - Verifying, Rejected { diagnostic: DiagnosticCode }, - Revoked, SourceChanged, InvalidPath { diagnostic: PathDiagnostic }, Collision { diagnostic: CollisionDiagnostic }, @@ -97,7 +93,6 @@ pub enum ResolveError { pub trait LdrawResourceResolver: Send + Sync { async fn resolve( &self, - principal: Option<&Did>, project: &AtUri, path: &CanonicalPath, ) -> Result; @@ -117,16 +112,14 @@ impl SqliteResolver { /// the only lookup path used by the lazy verifier and the verified resolver. pub async fn candidate( &self, - principal: Option<&Did>, project: &AtUri, path: &CanonicalPath, ) -> Result { let rows = sqlx::query!( r#"SELECT resource_uri, owner_did, sha256, ordered_blob_cids, root, canonical_path, mime_type, byte_length FROM ldraw_resources - WHERE project_uri = ? AND revoked_at IS NULL - AND (owner_did = ? OR owner_did = 'public')"#, - project.as_ref(), principal.map(Did::as_ref), + WHERE 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(); @@ -188,7 +181,6 @@ impl SqliteResolver { }; let content_hash = source_hash(&blobs, &row.sha256, row.byte_length); let cache_key = CacheKey { - principal: principal.cloned(), project: project.clone(), source: SourceIdentity { resource: resource.clone(), @@ -222,13 +214,12 @@ impl SqliteResolver { impl LdrawResourceResolver for SqliteResolver { async fn resolve( &self, - principal: Option<&Did>, project: &AtUri, path: &CanonicalPath, ) -> Result { - let resource = self.candidate(principal, project, path).await?; + let resource = self.candidate(project, path).await?; let verification = sqlx::query!( - "SELECT state, revoked_at FROM ldraw_verification WHERE resource_uri = ?", + "SELECT state FROM ldraw_verification WHERE resource_uri = ?", resource.resource.uri.as_ref() ) .fetch_optional(&self.pool) @@ -237,12 +228,8 @@ impl LdrawResourceResolver for SqliteResolver { diagnostic: AvailabilityDiagnostic::SourceUnavailable, })? .ok_or(ResolveError::Unverified)?; - if verification.revoked_at.is_some() { - return Err(ResolveError::Revoked); - } match verification.state.as_str() { "verified" => Ok(resource), - "verifying" => Err(ResolveError::Verifying), "rejected" => Err(ResolveError::Rejected { diagnostic: DiagnosticCode::InvalidManifest, }), @@ -293,12 +280,10 @@ mod tests { use super::*; #[test] - fn every_cache_dimension_changes_identity() { + 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 did = Did::new_owned("did:plc:a").unwrap(); let base = CacheKey { - principal: Some(did.clone()), project: project.clone(), source: SourceIdentity { resource: project.clone(), @@ -311,10 +296,10 @@ mod tests { }, }; let mut changed = base.clone(); - changed.principal = None; + changed.path = CanonicalPath::parse("parts/3002.dat").unwrap(); assert_ne!(base, changed); changed = base.clone(); - changed.path = CanonicalPath::parse("parts/3002.dat").unwrap(); + changed.target.blob_cids.push("bafk2".into()); assert_ne!(base, changed); } } diff --git a/src/ldraw/verification.rs b/src/ldraw/verification.rs index ff92b67..1826a77 100644 --- a/src/ldraw/verification.rs +++ b/src/ldraw/verification.rs @@ -1,12 +1,8 @@ use std::fmt; -pub const LEASE_NANOS: i64 = 60_000_000_000; -pub const MAX_EXPIRED_ATTEMPTS: u32 = 3; - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum VerificationState { Unverified, - Verifying, Verified, Rejected, } @@ -15,98 +11,44 @@ pub enum VerificationState { pub struct VerificationRecord { pub state: VerificationState, pub source_identity: String, - pub attempt_token: Option, - pub lease_expires_at: Option, - pub expired_attempts: u32, pub diagnostic: Option, - pub revoked_at: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum Transition { - Claim { - token: String, - lease_expires_at: i64, - }, - AlreadyVerifying, Verified, Rejected(String), Reset, } -#[derive(Debug, thiserror::Error, Eq, PartialEq)] -pub enum VerificationError { - #[error("verification attempt is not active")] - InvalidAttempt, - #[error("verification source changed")] - SourceChanged, - #[error("verification lease expired")] - Expired, - #[error("verification is revoked")] - Revoked, -} - -pub fn claim(record: &VerificationRecord, now: i64, token: impl Into) -> Transition { - if record.revoked_at.is_some() { - return Transition::Rejected("revoked".into()); - } - if record.state == VerificationState::Verifying { - if record.lease_expires_at.is_some_and(|expiry| expiry > now) { - return Transition::AlreadyVerifying; - } - if record.expired_attempts + 1 >= MAX_EXPIRED_ATTEMPTS { - return Transition::Rejected("verification-expired-attempt-limit-v1".into()); - } - } - Transition::Claim { - token: token.into(), - lease_expires_at: now + LEASE_NANOS, +pub fn source_changed( + old: &VerificationRecord, + source_identity: impl Into, +) -> VerificationRecord { + VerificationRecord { + state: VerificationState::Unverified, + source_identity: source_identity.into(), + diagnostic: None, } } pub fn complete( record: &VerificationRecord, - token: &str, source_identity: &str, - now: i64, -) -> Result { - if record.revoked_at.is_some() { - return Err(VerificationError::Revoked); - } - if record.state != VerificationState::Verifying - || record.attempt_token.as_deref() != Some(token) - { - return Err(VerificationError::InvalidAttempt); - } +) -> Result { if record.source_identity != source_identity { - return Err(VerificationError::SourceChanged); + return Err("verification source changed"); } - if record.lease_expires_at.is_none_or(|expiry| expiry <= now) { - return Err(VerificationError::Expired); + if record.state != VerificationState::Unverified { + return Err("verification is not pending"); } Ok(Transition::Verified) } -pub fn source_changed( - old: &VerificationRecord, - source_identity: impl Into, -) -> VerificationRecord { - VerificationRecord { - state: VerificationState::Unverified, - source_identity: source_identity.into(), - attempt_token: None, - lease_expires_at: None, - expired_attempts: 0, - diagnostic: None, - revoked_at: old.revoked_at, - } -} - impl fmt::Display for VerificationState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Unverified => "unverified", - Self::Verifying => "verifying", Self::Verified => "verified", Self::Rejected => "rejected", }) @@ -121,47 +63,28 @@ mod tests { VerificationRecord { state: VerificationState::Unverified, source_identity: "source-a".into(), - attempt_token: None, - lease_expires_at: None, - expired_attempts: 0, diagnostic: None, - revoked_at: None, } } #[test] - fn claims_coalesce_and_completion_is_cas_guarded() { - let mut current = record(); - let Transition::Claim { - token, - lease_expires_at, - } = claim(¤t, 10, "one") - else { - panic!() - }; - current.state = VerificationState::Verifying; - current.attempt_token = Some(token.clone()); - current.lease_expires_at = Some(lease_expires_at); - assert_eq!(claim(¤t, 11, "two"), Transition::AlreadyVerifying); - assert_eq!( - complete(¤t, "wrong", "source-a", 11), - Err(VerificationError::InvalidAttempt) - ); + fn completion_requires_the_same_source_without_attempt_state() { + let current = record(); assert_eq!( - complete(¤t, &token, "source-a", 11), - Ok(Transition::Verified) + complete(¤t, "wrong"), + Err("verification source changed") ); + assert_eq!(complete(¤t, "source-a"), Ok(Transition::Verified)); } #[test] - fn source_change_resets_attempt_counter_and_lease() { + fn source_change_resets_state_and_diagnostic() { let mut current = record(); - current.state = VerificationState::Verified; - current.expired_attempts = 2; - current.attempt_token = Some("old".into()); + current.state = VerificationState::Rejected; + current.diagnostic = Some("old".into()); let reset = source_changed(¤t, "source-b"); assert_eq!(reset.state, VerificationState::Unverified); - assert_eq!(reset.expired_attempts, 0); - assert!(reset.attempt_token.is_none()); + assert_eq!(reset.source_identity, "source-b"); + assert!(reset.diagnostic.is_none()); } } diff --git a/src/viewer.rs b/src/viewer.rs index 4af70d4..0f1f94b 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1321,7 +1321,8 @@ impl WorkerBridgeState { Ok(Some(evt)) => evt, Ok(None) => { if let Some(protocol) = negotiated { - *session_id_msg.borrow_mut() = Some(protocol.session_id); + let session_id = protocol.session_id; + *session_id_msg.borrow_mut() = Some(session_id); let accept = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, session_id: 0, @@ -1344,7 +1345,7 @@ impl WorkerBridgeState { for command in queued { let envelope = CommandEnvelope { version: polymodel_renderer_protocol::PROTOCOL_VERSION, - session_id: protocol.session_id, + session_id, payload: CommandPayload::Command(command), }; if let Ok(bytes) = serialize_command_envelope(&envelope) { -- 2.51.2