From 766a9417e526a77464af7321d373d71d3f9cc961 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 8 Aug 2026 03:45:15 -0400 Subject: [PATCH] PM-83: remediate format lifecycle review findings Epic: PM-86 Task: PM-83 --- src/appview/download_tests.rs | 114 +++++---- src/appview/drafts.rs | 427 +++++++++++++++++++++++++++++++++- src/appview/test_support.rs | 103 ++++++++ src/appview/writes.rs | 201 +++++++++------- src/publish.rs | 373 ++++++++++++++++++++++++----- src/thing_detail.rs | 10 + src/viewer.rs | 222 ++++++++++++++---- 7 files changed, 1215 insertions(+), 235 deletions(-) diff --git a/src/appview/download_tests.rs b/src/appview/download_tests.rs index 87d6350..5a8ea96 100644 --- a/src/appview/download_tests.rs +++ b/src/appview/download_tests.rs @@ -80,57 +80,69 @@ async fn get_part_file_missing_part_is_not_found() { } #[tokio::test] -async fn get_part_file_streams_projected_manifest_in_offset_order() { - let (pds, server) = loopback_pds(false, None).await; - let pool = pool().await; - let part_uri = format!("at://{DID_A}/space.polymodel.library.part/download"); - let file = json!({ - "mimeType": "model/stl", - "size": 4, - "chunks": [ - {"blob": {"$type": "blob", "ref": {"$link": "bafkreibb44q4gwsyep63iux2f6pquyjmot5zklqgsj2itrvspjb3qf562q"}, "mimeType": "model/stl", "size": 2}, "offset": 2, "size": 2}, - {"blob": {"$type": "blob", "ref": {"$link": "bafkreih3ryqpylsmh4siyygdtplff46bgrzjro4xpofu2widxbifkyqgam"}, "mimeType": "model/stl", "size": 2}, "offset": 0, "size": 2} - ] - }); - sqlx::query("INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") - .bind(DID_A) - .bind("download") - .bind(&part_uri) - .bind("part-cid") - .bind("Bracket.stl") - .bind("stl") - .bind(file.to_string()) - .bind(1_i64) - .bind(1_i64) - .bind(file!()) - .execute(&pool) - .await - .unwrap(); - let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) - .expect("ephemeral OAuth bootstrap for tests"); - let state = AppState::new_with_resolver(pool, bootstrap, loopback_resolver(&pds)); - let app = crate::appview::router().with_state(state); - let resp = app - .oneshot( - Request::builder() - .uri(format!( - "/xrpc/space.polymodel.library.getPartFile?uri={}", - urlencoding::encode(&part_uri) - )) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(resp.headers()["content-type"], "model/stl"); - assert_eq!(resp.headers()["content-length"], "4"); - assert_eq!( - resp.headers()["content-disposition"], - "attachment; filename=\"Bracket.stl\"" - ); - assert_eq!(body_text(resp).await, "abcd"); - server.abort(); +async fn get_part_file_streams_format_matrix_with_exact_download_metadata() { + let cases = [ + ("stl", "model/stl", "Bracket.stl"), + ("obj", "model/obj", "Bracket.obj"), + ("gltf", "model/gltf+json", "Bracket.gltf"), + ("glb", "model/gltf-binary", "Bracket.glb"), + ("3mf", "model/3mf", "Bracket.3mf"), + ("ldraw", "application/x-ldraw", "Bracket.dat"), + ]; + + for (format, mime, filename) in cases { + let (pds, server) = loopback_pds(false, None).await; + let pool = pool().await; + let part_uri = format!("at://{DID_A}/space.polymodel.library.part/download-{format}"); + let file = json!({ + "mimeType": mime, + "size": 4, + "chunks": [ + {"blob": {"$type": "blob", "ref": {"$link": "bafkreibb44q4gwsyep63iux2f6pquyjmot5zklqgsj2itrvspjb3qf562q"}, "mimeType": mime, "size": 2}, "offset": 2, "size": 2}, + {"blob": {"$type": "blob", "ref": {"$link": "bafkreih3ryqpylsmh4siyygdtplff46bgrzjro4xpofu2widxbifkyqgam"}, "mimeType": mime, "size": 2}, "offset": 0, "size": 2} + ] + }); + sqlx::query("INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind(DID_A) + .bind(format!("download-{format}")) + .bind(&part_uri) + .bind("part-cid") + .bind(filename) + .bind(format) + .bind(file.to_string()) + .bind(1_i64) + .bind(1_i64) + .bind(file!()) + .execute(&pool) + .await + .unwrap(); + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap for tests"); + let state = AppState::new_with_resolver(pool, bootstrap, loopback_resolver(&pds)); + let app = crate::appview::router().with_state(state); + let resp = app + .oneshot( + Request::builder() + .uri(format!( + "/xrpc/space.polymodel.library.getPartFile?uri={}", + urlencoding::encode(&part_uri) + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "{format}"); + assert_eq!(resp.headers()["content-type"], mime, "{format}"); + assert_eq!(resp.headers()["content-length"], "4", "{format}"); + assert_eq!( + resp.headers()["content-disposition"], + format!("attachment; filename=\"{filename}\""), + "{format}" + ); + assert_eq!(body_text(resp).await, "abcd", "{format}"); + server.abort(); + } } #[tokio::test] diff --git a/src/appview/drafts.rs b/src/appview/drafts.rs index 34d8f5c..b2c5594 100644 --- a/src/appview/drafts.rs +++ b/src/appview/drafts.rs @@ -343,6 +343,7 @@ mod tests { use axum::response::IntoResponse; use axum_extra::extract::PrivateCookieJar; + use base64::Engine as _; use jacquard_common::types::string::Did; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use tower::ServiceExt; @@ -481,7 +482,7 @@ mod tests { crate::appview::test_support::seed_identity(&state.pool, DID_B, "bob.com").await; crate::appview::test_support::seed_profile(&state.pool, DID_A, "Alice").await; crate::appview::test_support::seed_profile(&state.pool, DID_B, "Bob").await; - let (pds, server) = crate::appview::test_support::loopback_pds(false, None).await; + let (pds, server) = crate::appview::test_support::loopback_pds_for_publish(false).await; state.resolver = crate::appview::test_support::loopback_resolver(&pds); let key_a = crate::appview::test_support::seed_oauth_session_at( &state, @@ -516,6 +517,33 @@ mod tests { .to_owned() }; let app = crate::appview::router().with_state(state.clone()); + let staged = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/xrpc/space.polymodel.library.stageFile") + .header(axum::http::header::COOKIE, cookie(key_a.clone())) + .header("content-type", "model/stl") + .header("x-polymodel-filename", "part.stl") + .body(axum::body::Body::from("solid draft\nendsolid draft\n")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(staged.status(), axum::http::StatusCode::OK); + let staged: polymodel_api::space_polymodel::library::stage_file::StageFileOutput = + serde_json::from_slice( + &axum::body::to_bytes(staged.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + let lifecycle_draft = |name: &str| { + let mut draft = draft_named(name); + draft.models[0].parts[0].upload_id = Some(staged.upload_id.to_string()); + draft + }; let created = app .clone() .oneshot( @@ -525,7 +553,7 @@ mod tests { .header(axum::http::header::COOKIE, cookie(key_a.clone())) .header("content-type", "application/json") .body(axum::body::Body::from( - serde_json::to_vec(&draft_named("Before")).unwrap(), + serde_json::to_vec(&lifecycle_draft("Before")).unwrap(), )) .unwrap(), ) @@ -548,7 +576,7 @@ mod tests { .header(axum::http::header::COOKIE, cookie(key_a.clone())) .header("content-type", "application/json") .body(axum::body::Body::from( - serde_json::to_vec(&draft_named("After")).unwrap(), + serde_json::to_vec(&lifecycle_draft("After")).unwrap(), )) .unwrap(), ) @@ -575,16 +603,407 @@ mod tests { .unwrap(); assert_eq!(reloaded.name.as_deref(), Some("After")); let cross_owner = app + .clone() .oneshot( axum::http::Request::builder() .uri(&path) - .header(axum::http::header::COOKIE, cookie(key_b)) + .header(axum::http::header::COOKIE, cookie(key_b.clone())) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(cross_owner.status(), axum::http::StatusCode::NOT_FOUND); + + let cross_owner_publish = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(format!("{path}/publish")) + .header(axum::http::header::COOKIE, cookie(key_b)) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + cross_owner_publish.status(), + axum::http::StatusCode::NOT_FOUND + ); + + let invalid_created = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/app/drafts") + .header(axum::http::header::COOKIE, cookie(key_a.clone())) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::to_vec(&DraftThingInput { + name: Some("Incomplete".into()), + ..Default::default() + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + let invalid_created: DraftIdResponse = serde_json::from_slice( + &axum::body::to_bytes(invalid_created.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + let invalid_path = format!("/app/drafts/{}", invalid_created.draft_id); + let invalid_publish = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(format!("{invalid_path}/publish")) + .header(axum::http::header::COOKIE, cookie(key_a.clone())) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + invalid_publish.status(), + axum::http::StatusCode::BAD_REQUEST + ); + let invalid_error: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(invalid_publish.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(invalid_error["error"], "InvalidRequest"); + let retained_invalid = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri(&invalid_path) + .header(axum::http::header::COOKIE, cookie(key_a.clone())) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(retained_invalid.status(), axum::http::StatusCode::OK); + + let published = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(format!("{path}/publish")) + .header(axum::http::header::COOKIE, cookie(key_a.clone())) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(published.status(), axum::http::StatusCode::OK); + let published: PublishThingOutput = serde_json::from_slice( + &axum::body::to_bytes(published.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(published.models.len(), 1); + let deleted_after_publish = app + .oneshot( + axum::http::Request::builder() + .uri(&path) + .header(axum::http::header::COOKIE, cookie(key_a)) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + deleted_after_publish.status(), + axum::http::StatusCode::NOT_FOUND + ); + server.abort(); + } + + async fn seed_staged_manifest( + state: &AppState, + upload_id: &str, + cid: &str, + size: i64, + digest: Vec, + ) { + let file = serde_json::json!({ + "mimeType": "application/x-ldraw", + "size": size, + "digest": { "$bytes": base64::engine::general_purpose::STANDARD.encode(&digest) }, + "chunks": [{ + "blob": { + "$type": "blob", + "ref": { "$link": cid }, + "mimeType": "application/x-ldraw", + "size": size + }, + "offset": 0, + "size": size + }] + }); + sqlx::query( + "INSERT INTO upload_staging (owner_did, upload_id, sha256, mime_type, size, filename, status, file_json, chunks_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'uploaded', ?, '[]', 1, 1)", + ) + .bind(DID_A) + .bind(upload_id) + .bind(digest) + .bind("application/x-ldraw") + .bind(size) + .bind("preview.dat") + .bind(file.to_string()) + .execute(&state.pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn staged_preview_route_reports_fetch_integrity_and_bounds_failures() { + use sha2::Digest as _; + + let mut state = crate::appview::test_support::state().await; + crate::appview::test_support::seed_identity(&state.pool, DID_A, "alice.com").await; + crate::appview::test_support::seed_profile(&state.pool, DID_A, "Alice").await; + let (pds, server) = crate::appview::test_support::loopback_pds(false, None).await; + state.resolver = crate::appview::test_support::loopback_resolver(&pds); + let key = crate::appview::test_support::seed_oauth_session_at( + &state, + DID_A, + "staged-preview-failures", + Some(&pds), + ) + .await; + let cookie = jacquard_axum::oauth::set_session_cookie( + PrivateCookieJar::new(state.cookie_key.clone()), + &state.oauth_config, + &key, + ) + .unwrap() + .into_response() + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let good_cid = "bafkreih3ryqpylsmh4siyygdtplff46bgrzjro4xpofu2widxbifkyqgam"; + let failed_cid = "bafkreih7uy2yhx5gobvypuuexbvq22j2cypeqqfk2lc46225e7b3syq7pu"; + seed_staged_manifest( + &state, + "good", + good_cid, + 2, + sha2::Sha256::digest(b"ab").to_vec(), + ) + .await; + seed_staged_manifest( + &state, + "fetch-failure", + failed_cid, + 2, + sha2::Sha256::digest(b"ab").to_vec(), + ) + .await; + seed_staged_manifest(&state, "integrity-failure", good_cid, 2, vec![0; 32]).await; + seed_staged_manifest( + &state, + "bounds-failure", + good_cid, + crate::publish::draft::MAX_PREVIEW_RESOURCE_BYTES as i64 + 1, + vec![0; 32], + ) + .await; + let app = crate::appview::router().with_state(state); + + let success = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/app/staged/good") + .header(axum::http::header::COOKIE, &cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(success.status(), axum::http::StatusCode::OK); + assert_eq!(success.headers()["content-type"], "application/x-ldraw"); + assert_eq!( + axum::body::to_bytes(success.into_body(), 1024) + .await + .unwrap() + .as_ref(), + b"ab" + ); + + for (upload_id, status, error, message) in [ + ( + "fetch-failure", + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "InternalServerError", + "blob fetch failed with HTTP 503 Service Unavailable", + ), + ( + "integrity-failure", + axum::http::StatusCode::BAD_REQUEST, + "InvalidRequest", + "staged preview integrity verification failed", + ), + ( + "bounds-failure", + axum::http::StatusCode::BAD_REQUEST, + "InvalidRequest", + "staged resource exceeds preview bounds", + ), + ] { + let response = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri(format!("/app/staged/{upload_id}")) + .header(axum::http::header::COOKIE, &cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), status, "{upload_id}"); + let body: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(body["error"], error, "{upload_id}: {body}"); + assert_eq!(body["message"], message, "{upload_id}: {body}"); + } + server.abort(); + } + + #[tokio::test] + async fn authenticated_publish_failure_keeps_draft_reloadable() { + let mut state = crate::appview::test_support::state().await; + crate::appview::test_support::seed_identity(&state.pool, DID_A, "alice.com").await; + crate::appview::test_support::seed_profile(&state.pool, DID_A, "Alice").await; + let (pds, server) = crate::appview::test_support::loopback_pds_for_publish(true).await; + state.resolver = crate::appview::test_support::loopback_resolver(&pds); + let key = crate::appview::test_support::seed_oauth_session_at( + &state, + DID_A, + "draft-publish-failure", + Some(&pds), + ) + .await; + let cookie = jacquard_axum::oauth::set_session_cookie( + PrivateCookieJar::new(state.cookie_key.clone()), + &state.oauth_config, + &key, + ) + .unwrap() + .into_response() + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let app = crate::appview::router().with_state(state.clone()); + let staged = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/xrpc/space.polymodel.library.stageFile") + .header(axum::http::header::COOKIE, &cookie) + .header("content-type", "model/stl") + .header("x-polymodel-filename", "failure.stl") + .body(axum::body::Body::from("solid failure\nendsolid failure\n")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(staged.status(), axum::http::StatusCode::OK); + let staged: polymodel_api::space_polymodel::library::stage_file::StageFileOutput = + serde_json::from_slice( + &axum::body::to_bytes(staged.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + let mut draft = draft_named("Keep after PDS failure"); + draft.models[0].parts[0].upload_id = Some(staged.upload_id.to_string()); + let created = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/app/drafts") + .header(axum::http::header::COOKIE, &cookie) + .header("content-type", "application/json") + .body(axum::body::Body::from(serde_json::to_vec(&draft).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let created: DraftIdResponse = serde_json::from_slice( + &axum::body::to_bytes(created.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + let path = format!("/app/drafts/{}", created.draft_id); + let failed = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(format!("{path}/publish")) + .header(axum::http::header::COOKIE, &cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + failed.status(), + axum::http::StatusCode::INTERNAL_SERVER_ERROR + ); + let error: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(failed.into_body(), 1 << 20) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(error["error"], "InternalServerError"); + assert_eq!(error["message"], "authenticated PDS operation failed"); + let retained = app + .oneshot( + axum::http::Request::builder() + .uri(&path) + .header(axum::http::header::COOKIE, cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(retained.status(), axum::http::StatusCode::OK); server.abort(); } diff --git a/src/appview/test_support.rs b/src/appview/test_support.rs index 94d70e4..ca3f472 100644 --- a/src/appview/test_support.rs +++ b/src/appview/test_support.rs @@ -285,6 +285,109 @@ pub(crate) async fn loopback_pds_with_upload( (endpoint, task) } +pub(crate) async fn loopback_pds_for_publish( + fail_create: bool, +) -> (String, tokio::task::JoinHandle<()>) { + use axum::response::IntoResponse; + use axum::routing::any; + use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; + + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let did_doc_endpoint = endpoint.clone(); + let sequence = Arc::new(AtomicU64::new(0)); + let app = axum::Router::new() + .route( + "/xrpc/com.atproto.identity.resolveDid", + any(move || { + let service_endpoint = did_doc_endpoint.clone(); + async move { + axum::Json(json!({ + "didDoc": { + "@context": ["https://www.w3.org/ns/did/v1"], + "id": DID_A, + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": service_endpoint + }] + } + })) + } + }), + ) + .route( + "/xrpc/com.atproto.repo.getRecord", + any(|| async { + ( + StatusCode::BAD_REQUEST, + axum::Json(json!({"error": "RecordNotFound", "message": "record not found"})), + ) + }), + ) + .route( + "/xrpc/com.atproto.repo.uploadBlob", + any(|headers: axum::http::HeaderMap, body: Body| async move { + let mime = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("application/octet-stream"); + let bytes = axum::body::to_bytes(body, 1 << 20).await.unwrap(); + let (cid, _) = test_blob(&bytes); + axum::Json(json!({ + "blob": { + "$type": "blob", + "ref": { "$link": cid }, + "mimeType": mime, + "size": bytes.len() + } + })) + .into_response() + }), + ) + .route( + "/xrpc/com.atproto.repo.createRecord", + any({ + let sequence = sequence.clone(); + move |body: Body| { + let sequence = sequence.clone(); + async move { + if fail_create { + return ( + StatusCode::SERVICE_UNAVAILABLE, + axum::Json(json!({ + "error": "UpstreamFailure", + "message": "simulated create failure" + })), + ) + .into_response(); + } + let body = axum::body::to_bytes(body, 1 << 20).await.unwrap(); + let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let collection = request["collection"].as_str().unwrap(); + let ordinal = sequence.fetch_add(1, Ordering::Relaxed); + let rkey = request["rkey"] + .as_str() + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("record-{ordinal}")); + axum::Json(json!({ + "uri": format!("at://{DID_A}/{collection}/{rkey}"), + "cid": "bafkreih3ryqpylsmh4siyygdtplff46bgrzjro4xpofu2widxbifkyqgam" + })) + .into_response() + } + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (endpoint, task) +} + pub(crate) async fn loopback_pds_with_blobs( blobs: std::collections::HashMap>, ) -> (String, tokio::task::JoinHandle<()>) { diff --git a/src/appview/writes.rs b/src/appview/writes.rs index 8685861..fdfc333 100644 --- a/src/appview/writes.rs +++ b/src/appview/writes.rs @@ -1861,91 +1861,130 @@ mod tests { } #[tokio::test] - async fn stage_file_route_accepts_authenticated_ldraw_upload_and_persists_owner_metadata() { - let mut state = state().await; - let (pds, server) = - crate::appview::test_support::loopback_pds_with_upload("application/x-ldraw").await; - state.resolver = crate::appview::test_support::loopback_resolver(&pds); - seed_identity(&state.pool, DID_A, "alice.com").await; - seed_profile(&state.pool, DID_A, "Alice").await; - let key = seed_oauth_session_at(&state, DID_A, "stage-ldraw-session", Some(&pds)).await; - let cookie_response = jacquard_axum::oauth::set_session_cookie( - PrivateCookieJar::new(state.cookie_key.clone()), - &state.oauth_config, - &key, - ) - .unwrap() - .into_response(); - let cookie = cookie_response - .headers() - .get(axum::http::header::SET_COOKIE) - .unwrap() - .to_str() - .unwrap() - .split(';') - .next() + async fn stage_file_route_accepts_format_matrix_and_persists_owner_metadata() { + let cases: &[(&str, &str, &[u8])] = &[ + ("model/stl", "model.stl", b"solid matrix\nendsolid matrix\n"), + ("model/obj", "model.obj", b"v 0 0 0\n"), + ( + "model/gltf+json", + "model.gltf", + br#"{"asset":{"version":"2.0"}}"#, + ), + ("model/gltf-binary", "model.glb", b"glTF\x02\0\0\0"), + ("model/3mf", "model.3mf", b"PK\x03\x04matrix"), + ( + "application/x-ldraw", + "model.dat", + b"0 FILE model.dat\n1 16 0 0 0 part.dat\n", + ), + ( + "application/octet-stream", + "extensionless", + b"solid bounded-evidence\nendsolid bounded-evidence\n", + ), + ]; + + for (case_index, &(mime, filename, bytes)) in cases.iter().enumerate() { + let mut state = state().await; + let (pds, server) = crate::appview::test_support::loopback_pds_with_upload(mime).await; + state.resolver = crate::appview::test_support::loopback_resolver(&pds); + seed_identity(&state.pool, DID_A, "alice.com").await; + seed_profile(&state.pool, DID_A, "Alice").await; + let key = seed_oauth_session_at( + &state, + DID_A, + &format!("stage-format-{case_index}"), + Some(&pds), + ) + .await; + let cookie_response = jacquard_axum::oauth::set_session_cookie( + PrivateCookieJar::new(state.cookie_key.clone()), + &state.oauth_config, + &key, + ) .unwrap() - .to_owned(); - let app = crate::appview::router().with_state(state.clone()); - let bytes = b"0 FILE model.dat\n1 16 0 0 0 part.dat\n"; - let digest = sha256_bytes(bytes); - let response = app - .oneshot( - axum::http::Request::builder() - .method("POST") - .uri("/xrpc/space.polymodel.library.stageFile") - .header(axum::http::header::COOKIE, cookie) - .header("content-type", "application/x-ldraw") - .header("x-polymodel-filename", "model.dat") - .body(axum::body::Body::from(bytes.as_slice())) - .unwrap(), + .into_response(); + let cookie = cookie_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let app = crate::appview::router().with_state(state.clone()); + let digest = sha256_bytes(bytes); + let response = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/xrpc/space.polymodel.library.stageFile") + .header(axum::http::header::COOKIE, cookie) + .header("content-type", mime) + .header("x-polymodel-filename", filename) + .body(axum::body::Body::from(bytes.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK, "{mime}"); + let body = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .unwrap(); + let output: StageFileOutput = serde_json::from_slice(&body).unwrap(); + assert_eq!( + output.status, + Some(StageFileOutputStatus::Uploaded), + "{mime}" + ); + assert_eq!(output.filename.as_deref(), Some(filename), "{mime}"); + assert_eq!(output.file.mime_type.as_str(), mime, "{mime}"); + assert_eq!(output.file.size, bytes.len() as i64, "{mime}"); + assert_eq!( + output.file.digest.as_deref(), + Some(digest.as_slice()), + "{mime}" + ); + assert_eq!(output.file.chunks.len(), 1, "{mime}"); + assert_eq!(output.file.chunks[0].size, bytes.len() as i64, "{mime}"); + let upload_id = output.upload_id.to_string(); + let row = sqlx::query_as::<_, (String, String, i64, Vec, String)>( + "SELECT owner_did, filename, size, sha256, file_json FROM upload_staging WHERE owner_did = ? AND upload_id = ?", ) + .bind(DID_A) + .bind(&upload_id) + .fetch_one(&state.pool) .await .unwrap(); - assert_eq!(response.status(), axum::http::StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), 1 << 20) - .await - .unwrap(); - let output: StageFileOutput = serde_json::from_slice(&body).unwrap(); - assert_eq!(output.status, Some(StageFileOutputStatus::Uploaded)); - assert_eq!(output.filename.as_deref(), Some("model.dat")); - assert_eq!(output.file.mime_type.as_str(), "application/x-ldraw"); - assert_eq!(output.file.size, bytes.len() as i64); - assert_eq!(output.file.digest.as_deref(), Some(digest.as_slice())); - assert_eq!(output.file.chunks.len(), 1); - assert_eq!(output.file.chunks[0].size, bytes.len() as i64); - let upload_id = output.upload_id.to_string(); - let row = sqlx::query_as::<_, (String, String, i64, Vec, String)>( - "SELECT owner_did, filename, size, sha256, file_json FROM upload_staging WHERE owner_did = ? AND upload_id = ?", - ) - .bind(DID_A) - .bind(&upload_id) - .fetch_one(&state.pool) - .await - .unwrap(); - assert_eq!(row.0, DID_A); - assert_eq!(row.1, "model.dat"); - assert_eq!(row.2, bytes.len() as i64); - assert_eq!(row.3, digest); - let stored: File = serde_json::from_str(&row.4).unwrap(); - assert_eq!(stored.mime_type.as_str(), "application/x-ldraw"); - assert_eq!( - stored.chunks[0].blob.blob().r#ref.as_str(), - output.file.chunks[0].blob.blob().r#ref.as_str() - ); - assert_eq!( - load_upload_file(&state, &did(DID_A), &upload_id) - .await - .unwrap() - .size, - bytes.len() as i64 - ); - assert!( - load_upload_file(&state, &did(DID_B), &upload_id) - .await - .is_err() - ); - server.abort(); + assert_eq!(row.0, DID_A, "{mime}"); + assert_eq!(row.1, filename, "{mime}"); + assert_eq!(row.2, bytes.len() as i64, "{mime}"); + assert_eq!(row.3, digest, "{mime}"); + let stored: File = serde_json::from_str(&row.4).unwrap(); + assert_eq!(stored.mime_type.as_str(), mime, "{mime}"); + assert_eq!( + stored.chunks[0].blob.blob().r#ref.as_str(), + output.file.chunks[0].blob.blob().r#ref.as_str(), + "{mime}" + ); + assert_eq!( + load_upload_file(&state, &did(DID_A), &upload_id) + .await + .unwrap() + .size, + bytes.len() as i64, + "{mime}" + ); + assert!( + load_upload_file(&state, &did(DID_B), &upload_id) + .await + .is_err(), + "{mime}" + ); + server.abort(); + } } #[tokio::test] diff --git a/src/publish.rs b/src/publish.rs index de8e6fa..6699fe6 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -263,14 +263,16 @@ fn PublishFlow(resume: String) -> Element { let resume = resume.clone(); use_hook(move || { if !resume.trim().is_empty() { - let flow = flow.clone(); + let mut flow = flow.clone(); let resume = resume.clone(); spawn(async move { match draft_client::get_draft(&flow.client, resume.trim()).await { - Ok(loaded) => { + Ok(mut loaded) => { // A resumed multi-model draft should land with its // Advanced section expanded. let multi = loaded.models.len() > 1; + normalize_loaded_draft(&mut loaded); + flow.preview_generations.write().clear(); { let mut composition = flow.composition; composition.set(loaded); @@ -445,10 +447,12 @@ impl Flow { } /// Load an existing draft into the editor in place (without a route change). - async fn resume_draft(self, id: String) { + async fn resume_draft(mut self, id: String) { match draft_client::get_draft(&self.client, &id).await { - Ok(loaded) => { + Ok(mut loaded) => { let multi = loaded.models.len() > 1; + normalize_loaded_draft(&mut loaded); + self.preview_generations.write().clear(); { let mut composition = self.composition; composition.set(loaded); @@ -469,10 +473,11 @@ impl Flow { } /// Delete a draft from the resume list; clears the editor if it was active. - async fn discard_draft(self, id: String) { + async fn discard_draft(mut self, id: String) { match draft_client::delete_draft(&self.client, &id).await { Ok(_) => { if self.draft_id.read().as_deref() == Some(id.as_str()) { + self.preview_generations.write().clear(); let mut composition = self.composition; let mut draft_id = self.draft_id; composition.set(DraftThingInput::default()); @@ -2143,6 +2148,23 @@ impl Flow { /// Stage a single file into a specific model (the per-model "Add a file" /// button). Shares the per-file routine; saves once. async fn stage_file_into(self, model_index: usize, file: FileData) { + let model_id = { + let mut composition = self.composition; + let mut guard = composition.write(); + let Some(model) = guard.models.get_mut(model_index) else { + return; + }; + model.model_id.get_or_insert_with(|| { + let mut counter = self.preview_operation_counter; + let next = counter.read().saturating_add(1); + counter.set(next); + format!("pending-model:{next}") + }); + model + .model_id + .clone() + .expect("model identity was installed") + }; let filename = file.name(); let mut upload = self.upload; upload.set(UploadStatus::Uploading(filename.clone())); @@ -2186,18 +2208,22 @@ impl Flow { format: format.clone(), ..Default::default() }; - let model_id = { + let installed = { let mut composition = self.composition; let mut guard = composition.write(); - let Some(model) = guard.models.get_mut(model_index) else { + let Some(model) = guard + .models + .iter_mut() + .find(|model| model.model_id.as_deref() == Some(model_id.as_str())) + else { return; }; install_staged_resource(model, resource); ensure_model_identity(model, &resource_id); model.parts.push(part); - model.model_id.clone() + true }; - if let Some(model_id) = model_id { + if installed { self.clone().recompute_model_preview(model_id).await; } upload.set(UploadStatus::Idle); @@ -2265,6 +2291,7 @@ impl Flow { } }; if !self.preview_is_current(&model_id, &operation_id) { + self.retire_preview_operation(&model_id, &operation_id); return; } let png = if format_registry::format_for_extension(&primary_name) @@ -2291,9 +2318,10 @@ impl Flow { return; }; if !self.preview_is_current(&model_id, &operation_id) { + self.retire_preview_operation(&model_id, &operation_id); return; } - let Some(image) = upload_generated_preview( + let image = match upload_generated_preview( self.client.clone(), primary_name.clone(), png_bytes, @@ -2301,36 +2329,47 @@ impl Flow { self.image_data_uris, ) .await - else { - return; + { + Ok(image) => image, + Err(error) => { + self.clear_model_preview(&model_id, Some(&operation_id)); + let mut upload = self.upload; + upload.set(UploadStatus::Error(format!( + "Preview upload failed for {primary_name}: {error}" + ))); + return; + } }; if !self.preview_is_current(&model_id, &operation_id) { + self.retire_preview_operation(&model_id, &operation_id); return; } let mut composition = self.composition; let mut guard = composition.write(); - let Some(model) = guard - .models - .iter() - .find(|model| model.model_id.as_deref() == Some(model_id.as_str())) - else { - return; - }; - let Some(current_primary) = model.primary_resource_id.as_deref() else { - return; - }; - let current_identity = preview_identity( + commit_generated_preview( + &mut guard, &draft_scope, &model_id, - current_primary, - &model.staged_resources, + &primary_id, + &composition_identity, + &operation_id, + image, ); - if current_primary != primary_id - || current_identity.as_deref() != Some(composition_identity.as_str()) + self.retire_preview_operation(&model_id, &operation_id); + } + + fn retire_preview_operation(&self, model_id: &str, operation_id: &str) -> bool { + let mut preview_generations = self.preview_generations; + let mut generations = preview_generations.write(); + if generations + .get(model_id) + .is_some_and(|current| current == operation_id) { - return; + generations.remove(model_id); + true + } else { + false } - attach_generated_preview(&mut guard, &model_id, &primary_id, &operation_id, image); } fn preview_is_current(&self, model_id: &str, identity: &str) -> bool { @@ -2341,20 +2380,9 @@ impl Flow { } fn clear_model_preview(&mut self, model_id: &str, operation_id: Option<&str>) { - let should_clear = { - let mut generations = self.preview_generations.write(); - match operation_id { - Some(operation_id) - if generations - .get(model_id) - .is_some_and(|current| current == operation_id) => - { - generations.remove(model_id); - true - } - Some(_) => false, - None => generations.remove(model_id).is_some(), - } + let should_clear = match operation_id { + Some(operation_id) => self.retire_preview_operation(model_id, operation_id), + None => self.preview_generations.write().remove(model_id).is_some(), }; if should_clear || operation_id.is_none() { let mut composition = self.composition; @@ -2624,7 +2652,78 @@ fn ensure_model_identity(model: &mut DraftModelInput, primary_resource_id: &str) model.primary_resource_id = Some(primary_resource_id.to_owned()); } if model.model_id.is_none() { - model.model_id = Some(format!("model-{primary_resource_id}")); + model.model_id = Some(stable_model_id(primary_resource_id)); + } +} + +fn stable_model_id(primary_resource_id: &str) -> String { + let digest = sha2::Sha256::digest(primary_resource_id.as_bytes()); + let suffix = digest[..16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("model-{suffix}") +} + +fn normalize_loaded_draft(composition: &mut DraftThingInput) { + let mut used_ids = std::collections::HashSet::new(); + for (index, model) in composition.models.iter_mut().enumerate() { + let primary = model + .primary_resource_id + .as_ref() + .filter(|resource_id| { + model + .staged_resources + .iter() + .any(|resource| resource.resource_id.as_str() == resource_id.as_str()) + }) + .cloned() + .or_else(|| { + model.parts.iter().find_map(|part| { + let resource_id = part.resource_id.as_ref()?; + model + .staged_resources + .iter() + .any(|resource| resource.resource_id == *resource_id) + .then(|| resource_id.clone()) + }) + }) + .or_else(|| { + model + .staged_resources + .first() + .map(|resource| resource.resource_id.clone()) + }); + if let Some(primary) = primary { + model.primary_resource_id = Some(primary.clone()); + let mut model_id = model + .model_id + .clone() + .unwrap_or_else(|| stable_model_id(&primary)); + if used_ids.contains(&model_id) { + model_id = format!("{}-{index}", stable_model_id(&primary)); + } + used_ids.insert(model_id.clone()); + model.model_id = Some(model_id); + } + if let Some(generated) = model.generated_preview.as_mut() + && generated.operation_id.is_none() + { + generated.operation_id = Some(format!( + "legacy:{}", + stable_model_id(&generated.source_resource_id) + )); + } + for part in &mut model.parts { + if let Some(generated) = part.generated_preview.as_mut() + && generated.operation_id.is_none() + { + generated.operation_id = Some(format!( + "legacy:{}", + stable_model_id(&generated.source_resource_id) + )); + } + } } } @@ -2769,6 +2868,41 @@ fn clear_generated_preview( } } +fn commit_generated_preview( + composition: &mut DraftThingInput, + draft_scope: &str, + model_id: &str, + expected_primary: &str, + expected_identity: &str, + operation_id: &str, + image: Image, +) -> bool { + let Some(model) = composition + .models + .iter() + .find(|model| model.model_id.as_deref() == Some(model_id)) + else { + return false; + }; + let Some(current_primary) = model.primary_resource_id.as_deref() else { + return false; + }; + if current_primary != expected_primary + || preview_identity( + draft_scope, + model_id, + current_primary, + &model.staged_resources, + ) + .as_deref() + != Some(expected_identity) + { + return false; + } + attach_generated_preview(composition, model_id, expected_primary, operation_id, image); + true +} + fn attach_generated_preview( composition: &mut DraftThingInput, model_id: &str, @@ -2843,23 +2977,15 @@ async fn upload_generated_preview( png_bytes: Vec, mut upload: Signal, mut image_data_uris: Signal>, -) -> Option { +) -> Result { let alt = format!("Rendered preview of {filename}"); upload.set(UploadStatus::Uploading(format!("preview of {filename}"))); - match draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await { - Ok(image) => { - let cid = image.image.blob().r#ref.as_str().to_string(); - image_data_uris - .write() - .insert(cid.into(), data_uri_from_bytes(&png_bytes, "image/png")); - Some(image) - } - Err(error) => { - tracing::warn!(%error, "preview upload failed for {filename}"); - upload.set(UploadStatus::Idle); - None - } - } + let image = draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await?; + let cid = image.image.blob().r#ref.as_str().to_string(); + image_data_uris + .write() + .insert(cid.into(), data_uri_from_bytes(&png_bytes, "image/png")); + Ok(image) } #[cfg(all(target_family = "wasm", target_os = "unknown"))] @@ -3436,6 +3562,135 @@ mod tests { ); } + #[test] + fn legacy_draft_hydration_preserves_existing_primary_and_reconciles_preview_identity() { + let generated = test_image("legacy generated"); + let mut composition = DraftThingInput { + models: vec![DraftModelInput { + parts: vec![ + DraftPartInput { + resource_id: Some("older-resource".into()), + generated_preview: Some(GeneratedPreview { + source_resource_id: "older-resource".into(), + operation_id: None, + image: generated.clone(), + }), + ..Default::default() + }, + DraftPartInput { + resource_id: Some("newer-resource".into()), + ..Default::default() + }, + ], + staged_resources: vec![ + DraftStagedResource { + resource_id: "older-resource".into(), + upload_id: "older-upload".into(), + source_name: "old.stl".into(), + mime_type: "model/stl".into(), + digest: vec![1; 32], + byte_length: 3, + format: Some("stl".into()), + }, + DraftStagedResource { + resource_id: "newer-resource".into(), + upload_id: "newer-upload".into(), + source_name: "new.obj".into(), + mime_type: "model/obj".into(), + digest: vec![2; 32], + byte_length: 4, + format: Some("obj".into()), + }, + ], + generated_preview: Some(GeneratedPreview { + source_resource_id: "older-resource".into(), + operation_id: None, + image: generated, + }), + ..Default::default() + }], + ..Default::default() + }; + + normalize_loaded_draft(&mut composition); + + let expected_model_id = stable_model_id("older-resource"); + let model = &composition.models[0]; + assert_eq!(model.primary_resource_id.as_deref(), Some("older-resource")); + assert_eq!(model.model_id.as_deref(), Some(expected_model_id.as_str())); + assert!( + model + .generated_preview + .as_ref() + .unwrap() + .operation_id + .is_some() + ); + assert!( + model.parts[0] + .generated_preview + .as_ref() + .unwrap() + .operation_id + .is_some() + ); + ensure_model_identity(&mut composition.models[0], "newer-resource"); + assert_eq!( + composition.models[0].primary_resource_id.as_deref(), + Some("older-resource") + ); + } + + #[test] + fn removed_model_cannot_receive_stale_preview_after_vector_compaction() { + let generated = test_image("stale generated"); + let resource = DraftStagedResource { + resource_id: "resource-a".into(), + upload_id: "upload-a".into(), + source_name: "a.stl".into(), + mime_type: "model/stl".into(), + digest: vec![7; 32], + byte_length: 12, + format: Some("stl".into()), + }; + let mut composition = DraftThingInput { + models: vec![ + DraftModelInput { + model_id: Some("model-a".into()), + primary_resource_id: Some("resource-a".into()), + staged_resources: vec![resource.clone()], + parts: vec![DraftPartInput { + resource_id: Some("resource-a".into()), + ..Default::default() + }], + ..Default::default() + }, + DraftModelInput { + model_id: Some("model-b".into()), + primary_resource_id: Some("resource-b".into()), + ..Default::default() + }, + ], + ..Default::default() + }; + let identity = preview_identity("draft", "model-a", "resource-a", &[resource]).unwrap(); + composition.models.remove(0); + + assert!(!commit_generated_preview( + &mut composition, + "draft", + "model-a", + "resource-a", + &identity, + "model-a:1", + generated, + )); + assert_eq!(composition.models[0].model_id.as_deref(), Some("model-b")); + assert!(composition.models[0].generated_preview.is_none()); + assert!(composition.models[0].previews.is_none()); + assert!(composition.previews.is_none()); + } + #[test] fn generated_preview_cleanup_preserves_user_cover_and_normalizes_empty_lists() { let generated = test_image("generated"); diff --git a/src/thing_detail.rs b/src/thing_detail.rs index cb5d48f..ec6ed1d 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -117,6 +117,7 @@ fn part_viewer_format(part: &PartDetail, url: &str, name: &str) -> Option Option { @@ -1208,6 +1209,15 @@ mod tests { part_viewer_format(&cases[4].0, "parts/fixture.ply", cases[4].1), None ); + let neutral = part_detail_for_viewer( + format_registry::GENERIC_MIME, + Some("scene.ply"), + Some("unknown"), + ); + assert_eq!( + part_viewer_format(&neutral, "parts/fixture", "drawing"), + Some(ViewerSourceFormat::BoundedEvidence) + ); } #[test] diff --git a/src/viewer.rs b/src/viewer.rs index ee9deb5..540f901 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -15,15 +15,15 @@ #[cfg(target_arch = "wasm32")] use crate::client::PolymodelClient; use dioxus::prelude::*; -use polymodel_renderer_protocol::{Capability, NegotiatedProtocol}; -#[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ - CommandEnvelope, CommandPayload, EventPayload, HandshakeState, LoadEvent, LoadIdentity, - LoadOutput, LoadReducer, LoadSource, ModelLoadBundle, ProtocolHello, RendererNamespace, - deserialize_event_envelope, serialize_command_envelope, + Capability, KeyModifiers, LoadErrorCode, LoadEvent, LoadIdentity, LoadOutput, LoadReducer, + LoadSource, MeshFormat, MeshStats, ModelLoadBundle, NegotiatedProtocol, RendererCommand, + RendererEvent, RendererNamespace, }; +#[cfg(target_arch = "wasm32")] use polymodel_renderer_protocol::{ - KeyModifiers, LoadErrorCode, MeshFormat, MeshStats, RendererCommand, RendererEvent, + CommandEnvelope, CommandPayload, EventPayload, HandshakeState, ProtocolHello, + deserialize_event_envelope, serialize_command_envelope, }; #[cfg(target_arch = "wasm32")] use std::cell::RefCell; @@ -54,6 +54,7 @@ pub struct ViewerFallbackImage { pub enum ViewerSourceFormat { Renderer(MeshFormat), Step, + BoundedEvidence, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -528,6 +529,38 @@ pub fn AssetViewer( source.key, ); } + ViewerSourceFormat::BoundedEvidence => { + clear_step_render_source(render_source, active_step_object_url); + match classify_verified_source(&source).await { + Ok(ViewerSourceFormat::Renderer(format)) => { + render_source.set(Some(RenderSource { + key: source.key, + format, + primary_url: source.url, + expected_size: source.expected_size, + expected_digest: source.expected_digest, + })); + } + Ok(ViewerSourceFormat::Step) => { + step_report.set(StepConversionReport { + status: StepConversionStatus::Converting, + details: format!( + "Converting {} in the STEP conversion worker.", + source.name + ), + }); + start_step_conversion( + step_report, + render_source, + active_step_object_url, + source.url, + source.key, + ); + } + Ok(ViewerSourceFormat::BoundedEvidence) => unreachable!(), + Err(error) => status.set(ViewerStatus::MeshError(error)), + } + } } } else { clear_step_render_source(render_source, active_step_object_url); @@ -612,6 +645,70 @@ fn start_compound_plan_load( }); } +fn viewer_format_from_evidence(bytes: &[u8]) -> Result { + use polymodel_renderer_protocol::format_registry::{Evidence, FormatId, recognize_evidence}; + + match recognize_evidence(bytes) { + Evidence::One(FormatId::Step) => Ok(ViewerSourceFormat::Step), + Evidence::One(format) => format + .renderer_format() + .map(ViewerSourceFormat::Renderer) + .ok_or_else(|| "verified file format has no viewer path".to_string()), + Evidence::None => Err("verified file has no recognizable bounded format evidence".into()), + Evidence::Multiple => Err("verified file has ambiguous bounded format evidence".into()), + Evidence::OverBound => { + Err("verified file exceeds the bounded format evidence limit".into()) + } + } +} + +#[cfg(target_arch = "wasm32")] +async fn classify_verified_source(source: &ViewerMeshSource) -> Result { + use polymodel_renderer_protocol::{format_registry::MAX_EVIDENCE_BYTES, verify_part_file}; + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let expected_size = source + .expected_size + .ok_or_else(|| "verified source has no expected size".to_string())?; + if expected_size < 0 || expected_size as usize > MAX_EVIDENCE_BYTES { + return Err("verified file exceeds the bounded format evidence limit".into()); + } + let response = JsFuture::from( + web_sys::window() + .ok_or_else(|| "no window".to_string())? + .fetch_with_str(&source.url), + ) + .await + .map_err(|error| format!("part file fetch failed: {error:?}"))? + .dyn_into::() + .map_err(|_| "part file fetch did not return a response".to_string())?; + if !response.ok() { + return Err(format!( + "part file fetch failed: HTTP {}", + response.status() + )); + } + let buffer = JsFuture::from( + response + .array_buffer() + .map_err(|error| format!("part file body failed: {error:?}"))?, + ) + .await + .map_err(|error| format!("part file body failed: {error:?}"))?; + let bytes = js_sys::Uint8Array::new(&buffer).to_vec(); + verify_part_file(&bytes, expected_size, source.expected_digest.as_deref()) + .map_err(|error| format!("part file verification failed: {error}"))?; + viewer_format_from_evidence(&bytes) +} + +#[cfg(not(target_arch = "wasm32"))] +async fn classify_verified_source( + _source: &ViewerMeshSource, +) -> Result { + Err("bounded source classification is browser-only".into()) +} + fn clear_step_render_source( mut step_render_source: Signal>, mut active_step_object_url: Signal>, @@ -1458,6 +1555,33 @@ fn compound_capability_is_available(negotiated: Option<&NegotiatedProtocol>) -> negotiated.is_some_and(|protocol| protocol.capabilities.contains(&Capability::CompoundLdraw)) } +fn start_compound_load_command( + negotiated: Option<&NegotiatedProtocol>, + reducer: &mut LoadReducer, + identity: LoadIdentity, + bundle: ModelLoadBundle, +) -> Option { + if !compound_capability_is_available(negotiated) { + tracing::error!("renderer worker did not negotiate CompoundLdraw capability"); + return None; + } + let namespace = RendererNamespace::Interactive; + let result = reducer.reduce(LoadEvent::StartLoad { + namespace, + identity, + }); + matches!( + result.outcome, + polymodel_renderer_protocol::TransitionOutcome::Committed + ) + .then_some(RendererCommand::StartLoad { + namespace, + identity, + source: LoadSource::Bundle(bundle), + output: LoadOutput::Interactive, + }) +} + fn context_acquisitions_after_event(current: u64, event: &RendererEvent) -> u64 { if matches!(event, RendererEvent::ContextAcquired) { 1 @@ -1873,25 +1997,18 @@ impl WorkerBridgeState { } fn start_compound_load(&mut self, pending: PendingCompoundLoad) { - if !compound_capability_is_available(self.handshake.borrow().negotiated()) { - tracing::error!("renderer worker did not negotiate CompoundLdraw capability"); - return; - } if self.compound_started_key.as_deref() == Some(pending.logical.key.as_str()) { return; } let identity = self.allocate_identity(); - let namespace = RendererNamespace::Interactive; - let result = self.load_reducer.borrow_mut().reduce(LoadEvent::StartLoad { - namespace, + let Some(command) = start_compound_load_command( + self.handshake.borrow().negotiated(), + &mut self.load_reducer.borrow_mut(), identity, - }); - if !matches!( - result.outcome, - polymodel_renderer_protocol::TransitionOutcome::Committed - ) { + pending.bundle, + ) else { return; - } + }; self.compound_started_key = Some(pending.logical.key.clone()); *self.active_compound_identity.borrow_mut() = Some(identity); self.current_intent = Some(pending.logical.clone()); @@ -1899,12 +2016,7 @@ impl WorkerBridgeState { pending.logical.key.clone(), identity.scene_generation as u64, )); - self.send_command(&RendererCommand::StartLoad { - namespace, - identity, - source: LoadSource::Bundle(pending.bundle), - output: LoadOutput::Interactive, - }); + self.send_command(&command); } fn start_load(&mut self, source: RenderSource) { @@ -2462,11 +2574,26 @@ mod tests { } #[test] - fn context_acquisitions_signal_starts_at_zero() { - // The signal is initialized to 0 and set to 1 on ContextAcquired from the worker. - // This test verifies the initial value semantics. - let acquisitions: u64 = 0; - assert_eq!(acquisitions, 0); + fn verified_bounded_evidence_selects_only_unambiguous_registry_formats() { + assert_eq!( + viewer_format_from_evidence(b"glTF\x02\0\0\0\x0c\0\0\0"), + Ok(ViewerSourceFormat::Renderer(MeshFormat::Gltf)) + ); + assert_eq!( + viewer_format_from_evidence(b"ISO-10303-21;\nDATA;\nEND-ISO-10303-21;"), + Ok(ViewerSourceFormat::Step) + ); + assert_eq!( + viewer_format_from_evidence(b"0 FILE main.dat\n"), + Ok(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) + ); + assert!(viewer_format_from_evidence(b"solid maybe-stl").is_err()); + assert!( + viewer_format_from_evidence( + &[0; polymodel_renderer_protocol::format_registry::MAX_EVIDENCE_BYTES + 1] + ) + .is_err() + ); } #[test] @@ -2507,23 +2634,38 @@ mod tests { #[test] fn compound_load_without_negotiated_capability_stays_non_ready_and_reports_existing_error() { - use polymodel_renderer_protocol::{Capability, NegotiatedProtocol}; + use polymodel_renderer_protocol::{Capability, ModelResource, NegotiatedProtocol}; let negotiated = NegotiatedProtocol { version: polymodel_renderer_protocol::PROTOCOL_VERSION, capabilities: vec![Capability::CurrentFormats, Capability::Preview], session_id: 7, }; - assert!(!compound_capability_is_available(Some(&negotiated))); - assert!(!compound_capability_is_available(None)); + let bundle = ModelLoadBundle { + version: polymodel_renderer_protocol::PROTOCOL_VERSION, + format: MeshFormat::CompoundLdraw, + primary: "main.ldr".into(), + resources: vec![ModelResource { + key: "main.ldr".into(), + path: "main.ldr".into(), + root: "main.ldr".into(), + mime_type: "application/x-ldraw".into(), + byte_length: 0, + sha256: vec![], + target_cids: vec![], + bytes: vec![], + }], + }; + let mut reducer = LoadReducer::new(RendererNamespace::Interactive); - let reducer = LoadReducer::new(RendererNamespace::Interactive); - assert!(matches!( - reducer.state(), - polymodel_renderer_protocol::LoadState::Idle - )); - assert_eq!(reducer.current_identity(), None); - assert!(!compound_capability_is_available(Some(&negotiated))); + let command = start_compound_load_command( + Some(&negotiated), + &mut reducer, + LoadIdentity::new(0, 1), + bundle, + ); + + assert!(command.is_none()); assert!(matches!( reducer.state(), polymodel_renderer_protocol::LoadState::Idle -- knotmirror 0.0.1