diff --git a/apps/skit/src/assets.rs b/apps/skit/src/assets.rs index 3ddc7f7d..81f7b12e 100644 --- a/apps/skit/src/assets.rs +++ b/apps/skit/src/assets.rs @@ -202,11 +202,10 @@ pub async fn list_assets_handler( } async fn list_assets( - _app_state: &AppState, + app_state: &AppState, perms: &RolePermissions, ) -> Result, AssetsError> { - // Audio assets are in samples/audio/, not samples/pipelines/ - let base_path = PathBuf::from("samples/audio"); + let base_path = app_state.asset_root.join("samples/audio"); let system_path = base_path.join("system"); let user_path = base_path.join("user"); @@ -336,8 +335,9 @@ async fn process_upload( filename: String, extension: String, field: axum::extract::multipart::Field<'_>, + asset_root: &std::path::Path, ) -> Result { - let base_path = PathBuf::from("samples/audio"); + let base_path = asset_root.join("samples/audio"); let user_dir = base_path.join("user"); fs::create_dir_all(&user_dir) @@ -392,7 +392,7 @@ pub async fn upload_asset_handler( Err(e) => return e.into_response(), }; - match process_upload(filename, extension, field).await { + match process_upload(filename, extension, field, &app_state.asset_root).await { Ok(asset) => Json(asset).into_response(), Err(e) => { error!("Failed to process upload: {}", e); @@ -453,7 +453,7 @@ pub async fn delete_asset_handler( return AssetsError::Forbidden.into_response(); } - let base_path = PathBuf::from("samples/audio"); + let base_path = app_state.asset_root.join("samples/audio"); let user_dir = base_path.join("user"); let file_path = user_dir.join(&id); @@ -649,7 +649,7 @@ pub async fn list_image_assets_handler( ) -> impl IntoResponse { let perms = get_permissions(&headers, &app_state); - match list_image_assets(&perms).await { + match list_image_assets(&app_state.asset_root, &perms).await { Ok(assets) => { info!("Listed {} image assets", assets.len()); Json(assets).into_response() @@ -661,8 +661,11 @@ pub async fn list_image_assets_handler( } } -async fn list_image_assets(perms: &RolePermissions) -> Result, AssetsError> { - let base_path = PathBuf::from("samples/images"); +async fn list_image_assets( + asset_root: &std::path::Path, + perms: &RolePermissions, +) -> Result, AssetsError> { + let base_path = asset_root.join("samples/images"); let system_path = base_path.join("system"); let user_path = base_path.join("user"); @@ -685,8 +688,9 @@ async fn process_image_upload( extension: String, field: axum::extract::multipart::Field<'_>, max_image_dimension: u32, + asset_root: &std::path::Path, ) -> Result { - let base_path = PathBuf::from("samples/images"); + let base_path = asset_root.join("samples/images"); let user_dir = base_path.join("user"); fs::create_dir_all(&user_dir) @@ -865,7 +869,7 @@ pub async fn upload_image_asset_handler( }; let max_dim = app_state.config.compositor.max_image_dimension; - match process_image_upload(filename, extension, field, max_dim).await { + match process_image_upload(filename, extension, field, max_dim, &app_state.asset_root).await { Ok(asset) => Json(asset).into_response(), Err(e) => { error!("Failed to process image upload: {}", e); @@ -886,7 +890,7 @@ pub async fn delete_image_asset_handler( return AssetsError::Forbidden.into_response(); } - let base_path = PathBuf::from("samples/images"); + let base_path = app_state.asset_root.join("samples/images"); let user_dir = base_path.join("user"); let file_path = user_dir.join(&id); @@ -937,7 +941,7 @@ async fn serve_image_asset_handler( .into_response(); } - let file_path = PathBuf::from("samples/images").join(&scope).join(&id); + let file_path = app_state.asset_root.join("samples/images").join(&scope).join(&id); let asset_path_str = format!("samples/images/{scope}/{id}"); // Reject files without an allowed image extension @@ -1142,7 +1146,7 @@ pub async fn list_font_assets_handler( ) -> impl IntoResponse { let perms = get_permissions(&headers, &app_state); - match list_font_assets(&perms).await { + match list_font_assets(&app_state.asset_root, &perms).await { Ok(assets) => { info!("Listed {} font assets", assets.len()); Json(assets).into_response() @@ -1154,8 +1158,11 @@ pub async fn list_font_assets_handler( } } -async fn list_font_assets(perms: &RolePermissions) -> Result, AssetsError> { - let base_path = PathBuf::from("samples/fonts"); +async fn list_font_assets( + asset_root: &std::path::Path, + perms: &RolePermissions, +) -> Result, AssetsError> { + let base_path = asset_root.join("samples/fonts"); let system_path = base_path.join("system"); let user_path = base_path.join("user"); @@ -1177,8 +1184,9 @@ async fn process_font_upload( filename: String, extension: String, field: axum::extract::multipart::Field<'_>, + asset_root: &std::path::Path, ) -> Result { - let base_path = PathBuf::from("samples/fonts"); + let base_path = asset_root.join("samples/fonts"); let user_dir = base_path.join("user"); fs::create_dir_all(&user_dir) @@ -1270,7 +1278,7 @@ pub async fn upload_font_asset_handler( Err(e) => return e.into_response(), }; - match process_font_upload(filename, extension, field).await { + match process_font_upload(filename, extension, field, &app_state.asset_root).await { Ok(asset) => Json(asset).into_response(), Err(e) => { error!("Failed to process font upload: {}", e); @@ -1291,7 +1299,7 @@ pub async fn delete_font_asset_handler( return AssetsError::Forbidden.into_response(); } - let base_path = PathBuf::from("samples/fonts"); + let base_path = app_state.asset_root.join("samples/fonts"); let user_dir = base_path.join("user"); let file_path = user_dir.join(&id); @@ -1322,7 +1330,10 @@ pub async fn delete_font_asset_handler( // Invalidate the font cache entry so re-uploads with the same name get a fresh parse. let asset_path = format!("samples/fonts/user/{id}"); - streamkit_nodes::video::compositor::overlay::invalidate_font_cache_entry(&asset_path); + streamkit_nodes::video::compositor::overlay::invalidate_font_cache_entry( + &asset_path, + &app_state.asset_root, + ); info!("Deleted font asset: {}", id); StatusCode::NO_CONTENT.into_response() @@ -1350,7 +1361,7 @@ async fn serve_font_asset_handler( .into_response(); } - let file_path = PathBuf::from("samples/fonts").join(&scope).join(&id); + let file_path = app_state.asset_root.join("samples/fonts").join(&scope).join(&id); let asset_path_str = format!("samples/fonts/{scope}/{id}"); let extension = file_path.extension().and_then(|s| s.to_str()).unwrap_or("").to_lowercase(); @@ -1458,9 +1469,7 @@ impl std::error::Error for AssetsError {} #[cfg(test)] // `unwrap` / `expect` are idiomatic in tests where the panic IS the assertion. -// `result_large_err` fires on the closure passed to `figment::Jail::expect_with`, -// whose `Err` variant size is fixed by the upstream API. -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::result_large_err)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; use crate::config::{AuthMode, Config}; @@ -1469,43 +1478,18 @@ mod tests { use tempfile::TempDir; use tower::ServiceExt; - /// Runs `body` inside a `figment::Jail`, which provides a tempdir CWD - /// and serialises all CWD mutations against `figment::Jail`'s internal - /// lock — the same lock that `apps/skit/src/config.rs` tests acquire. - /// - /// The asset handlers hardcode `samples/{audio,images,fonts}` relative - /// to the process CWD, so tests must run with CWD pointing at a - /// disposable directory. Using `Jail` (rather than a module-private - /// `Mutex<()>`) prevents the cross-module CWD race observed when these - /// tests ran concurrently with `config::tests::load_*_returns_*` — both - /// now share `figment::Jail::LOCK`. See follow-up issue #478 for the - /// production-side refactor that would let us drop CWD mutation entirely. - fn run_in_jail(body: F) - where - F: FnOnce() -> Fut, - Fut: std::future::Future, - { - figment::Jail::expect_with(|_jail| { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| e.to_string())?; - rt.block_on(body()); - Ok(()) - }); - } - - fn make_state() -> Arc { + fn make_state(root: &std::path::Path) -> Arc { let mut config = Config::default(); config.auth.mode = AuthMode::Disabled; + config.asset_root = Some(root.to_owned()); crate::server::create_app_state(config, None) } - /// AppState wired so the default role is `viewer` (cannot upload/delete). - fn make_viewer_state() -> Arc { + fn make_viewer_state(root: &std::path::Path) -> Arc { let mut config = Config::default(); config.auth.mode = AuthMode::Disabled; config.permissions.default_role = "viewer".to_string(); + config.asset_root = Some(root.to_owned()); crate::server::create_app_state(config, None) } @@ -2057,221 +2041,207 @@ mod tests { assert!(msg.contains("x.opus")); } - #[test] - fn list_audio_assets_returns_empty_when_no_dirs() { - run_in_jail(|| async { - let state = make_state(); - let app = assets_router().with_state(state); - let resp = app - .oneshot( - Request::builder().uri("/api/v1/assets/audio").body(Body::empty()).unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_string(resp.into_body()).await; - assert_eq!(body, "[]"); - }); + #[tokio::test] + async fn list_audio_assets_returns_empty_when_no_dirs() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = assets_router().with_state(state); + let resp = app + .oneshot(Request::builder().uri("/api/v1/assets/audio").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp.into_body()).await; + assert_eq!(body, "[]"); } - #[test] - fn list_audio_assets_returns_sorted_user_and_system() { - run_in_jail(|| async { - // Populate both system and user dirs in the temp CWD. - tokio::fs::create_dir_all("samples/audio/system").await.unwrap(); - tokio::fs::create_dir_all("samples/audio/user").await.unwrap(); - tokio::fs::write("samples/audio/system/zulu.opus", b"x").await.unwrap(); - tokio::fs::write("samples/audio/user/alpha.flac", b"x").await.unwrap(); - - let state = make_state(); - let app = assets_router().with_state(state); - let resp = app - .oneshot( - Request::builder().uri("/api/v1/assets/audio").body(Body::empty()).unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body: Vec = - serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); - // Sorted by display name: "alpha", "zulu". - assert_eq!(body.len(), 2); - assert_eq!(body[0].name, "alpha"); - assert_eq!(body[1].name, "zulu"); - assert!(!body[0].is_system); - assert!(body[1].is_system); - }); + #[tokio::test] + async fn list_audio_assets_returns_sorted_user_and_system() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/audio/system")).await.unwrap(); + tokio::fs::create_dir_all(root.join("samples/audio/user")).await.unwrap(); + tokio::fs::write(root.join("samples/audio/system/zulu.opus"), b"x").await.unwrap(); + tokio::fs::write(root.join("samples/audio/user/alpha.flac"), b"x").await.unwrap(); + + let state = make_state(root); + let app = assets_router().with_state(state); + let resp = app + .oneshot(Request::builder().uri("/api/v1/assets/audio").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: Vec = + serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); + assert_eq!(body.len(), 2); + assert_eq!(body[0].name, "alpha"); + assert_eq!(body[1].name, "zulu"); + assert!(!body[0].is_system); + assert!(body[1].is_system); } - #[test] - fn upload_audio_happy_path_writes_file_and_sidecar() { - run_in_jail(|| async { - let state = make_state(); - let app = assets_router().with_state(state); - - let boundary = "boundXYZ"; - let body = build_multipart_body(boundary, "file", "fresh.opus", b"some audio bytes"); - let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - let asset: AudioAsset = - serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); - assert_eq!(asset.id, "fresh.opus"); - assert!(tokio::fs::try_exists("samples/audio/user/fresh.opus").await.unwrap()); - assert!(tokio::fs::try_exists("samples/audio/user/fresh.opus.license").await.unwrap()); - }); + #[tokio::test] + async fn upload_audio_happy_path_writes_file_and_sidecar() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = assets_router().with_state(state); + + let boundary = "boundXYZ"; + let body = build_multipart_body(boundary, "file", "fresh.opus", b"some audio bytes"); + let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let asset: AudioAsset = + serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); + assert_eq!(asset.id, "fresh.opus"); + assert!(tokio::fs::try_exists(root.join("samples/audio/user/fresh.opus")).await.unwrap()); + assert!(tokio::fs::try_exists(root.join("samples/audio/user/fresh.opus.license")) + .await + .unwrap()); } - #[test] - fn upload_audio_returns_409_on_duplicate() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/audio/user").await.unwrap(); - tokio::fs::write("samples/audio/user/dup.mp3", b"existing").await.unwrap(); - - let state = make_state(); - let app = assets_router().with_state(state); - let boundary = "b1"; - let body = build_multipart_body(boundary, "file", "dup.mp3", b"newer"); - let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::CONFLICT); - // The pre-existing file must not be overwritten by the rejected upload. - let on_disk = tokio::fs::read("samples/audio/user/dup.mp3").await.unwrap(); - assert_eq!(on_disk, b"existing"); - }); - } + #[tokio::test] + async fn upload_audio_returns_409_on_duplicate() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/audio/user")).await.unwrap(); + tokio::fs::write(root.join("samples/audio/user/dup.mp3"), b"existing").await.unwrap(); - #[test] - fn upload_audio_rejects_disallowed_extension() { - run_in_jail(|| async { - let state = make_state(); - let app = assets_router().with_state(state); - let boundary = "b1"; - let body = build_multipart_body(boundary, "file", "evil.exe", b"x"); - let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - // No file should be created when the extension is rejected. - assert!(!std::path::Path::new("samples/audio/user/evil.exe").exists()); - }); + let state = make_state(root); + let app = assets_router().with_state(state); + let boundary = "b1"; + let body = build_multipart_body(boundary, "file", "dup.mp3", b"newer"); + let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + let on_disk = tokio::fs::read(root.join("samples/audio/user/dup.mp3")).await.unwrap(); + assert_eq!(on_disk, b"existing"); } - #[test] - fn upload_audio_forbidden_when_role_lacks_permission() { - run_in_jail(|| async { - let state = make_viewer_state(); - let app = assets_router().with_state(state); - let boundary = "b1"; - let body = build_multipart_body(boundary, "file", "x.opus", b"x"); - let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); - // Permission denial must short-circuit before any write to disk. - assert!(!std::path::Path::new("samples/audio/user/x.opus").exists()); - }); + #[tokio::test] + async fn upload_audio_rejects_disallowed_extension() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = assets_router().with_state(state); + let boundary = "b1"; + let body = build_multipart_body(boundary, "file", "evil.exe", b"x"); + let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert!(!root.join("samples/audio/user/evil.exe").exists()); } - #[test] - fn delete_audio_404_when_missing() { - run_in_jail(|| async { - // Ensure the user dir exists so canonicalize() inside doesn't matter - // (handler returns NotFound before validate_file_in_user_directory). - tokio::fs::create_dir_all("samples/audio/user").await.unwrap(); - let state = make_state(); - let app = assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/audio/ghost.opus") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - }); + #[tokio::test] + async fn upload_audio_forbidden_when_role_lacks_permission() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_viewer_state(root); + let app = assets_router().with_state(state); + let boundary = "b1"; + let body = build_multipart_body(boundary, "file", "x.opus", b"x"); + let req = multipart_request(Method::POST, "/api/v1/assets/audio", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!(!root.join("samples/audio/user/x.opus").exists()); } - #[test] - fn delete_audio_removes_file_returns_204() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/audio/user").await.unwrap(); - tokio::fs::write("samples/audio/user/bye.opus", b"x").await.unwrap(); - tokio::fs::write("samples/audio/user/bye.opus.license", b"x").await.unwrap(); - - let state = make_state(); - let app = assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/audio/bye.opus") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NO_CONTENT); - assert!(!std::path::Path::new("samples/audio/user/bye.opus").exists()); - assert!(!std::path::Path::new("samples/audio/user/bye.opus.license").exists()); - }); + #[tokio::test] + async fn delete_audio_404_when_missing() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/audio/user")).await.unwrap(); + let state = make_state(root); + let app = assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/audio/ghost.opus") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } - #[test] - fn delete_audio_forbidden_for_viewer() { - run_in_jail(|| async { - let state = make_viewer_state(); - let app = assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/audio/anything.opus") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); - }); + #[tokio::test] + async fn delete_audio_removes_file_returns_204() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/audio/user")).await.unwrap(); + tokio::fs::write(root.join("samples/audio/user/bye.opus"), b"x").await.unwrap(); + tokio::fs::write(root.join("samples/audio/user/bye.opus.license"), b"x").await.unwrap(); + + let state = make_state(root); + let app = assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/audio/bye.opus") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + assert!(!root.join("samples/audio/user/bye.opus").exists()); + assert!(!root.join("samples/audio/user/bye.opus.license").exists()); } - #[test] - fn list_image_assets_returns_empty_when_no_dirs() { - run_in_jail(|| async { - let state = make_state(); - let app = image_assets_router().with_state(state); - let resp = app - .oneshot( - Request::builder().uri("/api/v1/assets/images").body(Body::empty()).unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_string(resp.into_body()).await; - assert_eq!(body, "[]"); - }); + #[tokio::test] + async fn delete_audio_forbidden_for_viewer() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_viewer_state(root); + let app = assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/audio/anything.opus") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); } - #[test] - fn list_image_assets_returns_sorted_with_dimensions() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/images/user").await.unwrap(); - tokio::fs::write("samples/images/user/banner.png", tiny_png()).await.unwrap(); - tokio::fs::write("samples/images/user/aero.svg", tiny_svg()).await.unwrap(); + #[tokio::test] + async fn list_image_assets_returns_empty_when_no_dirs() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let resp = app + .oneshot(Request::builder().uri("/api/v1/assets/images").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp.into_body()).await; + assert_eq!(body, "[]"); + } - let state = make_state(); - let app = image_assets_router().with_state(state); - let resp = app - .oneshot( - Request::builder().uri("/api/v1/assets/images").body(Body::empty()).unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body: Vec = - serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); - assert_eq!(body.len(), 2); - // "aero" then "banner" by display name. - assert_eq!(body[0].name, "aero"); - assert_eq!(body[0].width, 10); - assert_eq!(body[1].name, "banner"); - assert_eq!(body[1].width, 1); - }); + #[tokio::test] + async fn list_image_assets_returns_sorted_with_dimensions() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/images/user")).await.unwrap(); + tokio::fs::write(root.join("samples/images/user/banner.png"), tiny_png()).await.unwrap(); + tokio::fs::write(root.join("samples/images/user/aero.svg"), tiny_svg()).await.unwrap(); + + let state = make_state(root); + let app = image_assets_router().with_state(state); + let resp = app + .oneshot(Request::builder().uri("/api/v1/assets/images").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: Vec = + serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); + assert_eq!(body.len(), 2); + assert_eq!(body[0].name, "aero"); + assert_eq!(body[0].width, 10); + assert_eq!(body[1].name, "banner"); + assert_eq!(body[1].width, 1); } - #[test] - fn upload_image_happy_path_for_each_raster_format() { + #[tokio::test] + async fn upload_image_happy_path_for_each_raster_format() { let cases: [(&str, Vec); 4] = [ ("a.png", tiny_png()), ("a.jpg", tiny_jpeg()), @@ -2279,422 +2249,422 @@ mod tests { ("a.webp", tiny_webp()), ]; for (filename, bytes) in cases { - run_in_jail(|| async { - let state = make_state(); - let app = image_assets_router().with_state(state); - let boundary = "imgBoundary"; - let body = build_multipart_body(boundary, "file", filename, &bytes); - let req = multipart_request(Method::POST, "/api/v1/assets/images", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK, "{filename}"); - let asset: ImageAsset = - serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); - assert_eq!(asset.id, filename); - assert!(asset.width >= 1); - }); - } - } - - #[test] - fn upload_image_rejects_corrupt_png_and_deletes_partial() { - run_in_jail(|| async { - let state = make_state(); + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); let app = image_assets_router().with_state(state); - let boundary = "imgB"; - let body = build_multipart_body(boundary, "file", "bad.png", b"not an image"); + let boundary = "imgBoundary"; + let body = build_multipart_body(boundary, "file", filename, &bytes); let req = multipart_request(Method::POST, "/api/v1/assets/images", boundary, body); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - assert!(!std::path::Path::new("samples/images/user/bad.png").exists()); - }); + assert_eq!(resp.status(), StatusCode::OK, "{filename}"); + let asset: ImageAsset = + serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); + assert_eq!(asset.id, filename); + assert!(asset.width >= 1); + } } - #[test] - fn upload_image_rejects_invalid_svg() { - run_in_jail(|| async { - let state = make_state(); - let app = image_assets_router().with_state(state); - let boundary = "imgB"; - let body = build_multipart_body(boundary, "file", "bad.svg", b"not svg"); - let req = multipart_request(Method::POST, "/api/v1/assets/images", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - assert!(!std::path::Path::new("samples/images/user/bad.svg").exists()); - }); + #[tokio::test] + async fn upload_image_rejects_corrupt_png_and_deletes_partial() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let boundary = "imgB"; + let body = build_multipart_body(boundary, "file", "bad.png", b"not an image"); + let req = multipart_request(Method::POST, "/api/v1/assets/images", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert!(!root.join("samples/images/user/bad.png").exists()); } - #[test] - fn upload_image_forbidden_for_viewer() { - run_in_jail(|| async { - let state = make_viewer_state(); - let app = image_assets_router().with_state(state); - let boundary = "b"; - let body = build_multipart_body(boundary, "file", "x.png", &tiny_png()); - let req = multipart_request(Method::POST, "/api/v1/assets/images", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); - }); + #[tokio::test] + async fn upload_image_rejects_invalid_svg() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let boundary = "imgB"; + let body = build_multipart_body(boundary, "file", "bad.svg", b"not svg"); + let req = multipart_request(Method::POST, "/api/v1/assets/images", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert!(!root.join("samples/images/user/bad.svg").exists()); } - #[test] - fn delete_image_404_when_missing() { - run_in_jail(|| async { - let state = make_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/images/ghost.png") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - }); + #[tokio::test] + async fn upload_image_forbidden_for_viewer() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_viewer_state(root); + let app = image_assets_router().with_state(state); + let boundary = "b"; + let body = build_multipart_body(boundary, "file", "x.png", &tiny_png()); + let req = multipart_request(Method::POST, "/api/v1/assets/images", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); } - #[test] - fn delete_image_returns_204_and_removes_file() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/images/user").await.unwrap(); - tokio::fs::write("samples/images/user/bye.png", tiny_png()).await.unwrap(); - let state = make_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/images/bye.png") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NO_CONTENT); - assert!(!std::path::Path::new("samples/images/user/bye.png").exists()); - }); + #[tokio::test] + async fn delete_image_404_when_missing() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/images/ghost.png") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } - #[test] - fn delete_image_forbidden_for_viewer() { - run_in_jail(|| async { - let state = make_viewer_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/images/anything.png") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); - }); + #[tokio::test] + async fn delete_image_returns_204_and_removes_file() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/images/user")).await.unwrap(); + tokio::fs::write(root.join("samples/images/user/bye.png"), tiny_png()).await.unwrap(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/images/bye.png") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + assert!(!root.join("samples/images/user/bye.png").exists()); } - #[test] - fn serve_image_returns_correct_content_types() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/images/user").await.unwrap(); - let cases: [(&str, Vec, &str); 4] = [ - ("pic.png", tiny_png(), "image/png"), - ("pic.jpg", tiny_jpeg(), "image/jpeg"), - ("pic.gif", tiny_gif(), "image/gif"), - ("pic.webp", tiny_webp(), "image/webp"), - ]; - for (name, bytes, expected) in cases { - tokio::fs::write(format!("samples/images/user/{name}"), &bytes).await.unwrap(); - let state = make_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .uri(format!("/api/v1/assets/images/file/user/{name}")) - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK, "{name}"); - let ct = - resp.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap().to_string(); - assert_eq!(ct, expected, "{name}"); - } - }); + #[tokio::test] + async fn delete_image_forbidden_for_viewer() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_viewer_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/images/anything.png") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); } - #[test] - fn serve_image_svg_emits_security_headers() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/images/user").await.unwrap(); - tokio::fs::write("samples/images/user/safe.svg", tiny_svg()).await.unwrap(); - let state = make_state(); + #[tokio::test] + async fn serve_image_returns_correct_content_types() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/images/user")).await.unwrap(); + let cases: [(&str, Vec, &str); 4] = [ + ("pic.png", tiny_png(), "image/png"), + ("pic.jpg", tiny_jpeg(), "image/jpeg"), + ("pic.gif", tiny_gif(), "image/gif"), + ("pic.webp", tiny_webp(), "image/webp"), + ]; + for (name, bytes, expected) in cases { + tokio::fs::write(root.join(format!("samples/images/user/{name}")), &bytes) + .await + .unwrap(); + let state = make_state(root); let app = image_assets_router().with_state(state); let req = Request::builder() - .uri("/api/v1/assets/images/file/user/safe.svg") + .uri(format!("/api/v1/assets/images/file/user/{name}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let h = resp.headers(); - assert_eq!(h.get(header::CONTENT_TYPE).unwrap(), "image/svg+xml"); - assert_eq!(h.get(header::X_CONTENT_TYPE_OPTIONS).unwrap(), "nosniff"); - assert!(h.get(header::CONTENT_SECURITY_POLICY).is_some()); - assert!(h.get(header::CONTENT_ENCODING).is_none()); - }); + assert_eq!(resp.status(), StatusCode::OK, "{name}"); + let ct = + resp.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap().to_string(); + assert_eq!(ct, expected, "{name}"); + } } - #[test] - fn serve_image_svgz_emits_gzip_content_encoding() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/images/user").await.unwrap(); - // Pretend an svgz exists; content not actually parsed here, only - // headers are asserted. - tokio::fs::write("samples/images/user/safe.svgz", &[0u8; 32]).await.unwrap(); - let state = make_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .uri("/api/v1/assets/images/file/user/safe.svgz") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(resp.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); - }); + #[tokio::test] + async fn serve_image_svg_emits_security_headers() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/images/user")).await.unwrap(); + tokio::fs::write(root.join("samples/images/user/safe.svg"), tiny_svg()).await.unwrap(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/images/file/user/safe.svg") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let h = resp.headers(); + assert_eq!(h.get(header::CONTENT_TYPE).unwrap(), "image/svg+xml"); + assert_eq!(h.get(header::X_CONTENT_TYPE_OPTIONS).unwrap(), "nosniff"); + assert!(h.get(header::CONTENT_SECURITY_POLICY).is_some()); + assert!(h.get(header::CONTENT_ENCODING).is_none()); } - #[test] - fn serve_image_404_for_missing_file() { - run_in_jail(|| async { - let state = make_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .uri("/api/v1/assets/images/file/user/missing.png") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - }); + #[tokio::test] + async fn serve_image_svgz_emits_gzip_content_encoding() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/images/user")).await.unwrap(); + tokio::fs::write(root.join("samples/images/user/safe.svgz"), &[0u8; 32]).await.unwrap(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/images/file/user/safe.svgz") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers().get(header::CONTENT_ENCODING).unwrap(), "gzip"); } - #[test] - fn serve_image_rejects_invalid_scope() { - run_in_jail(|| async { - let state = make_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .uri("/api/v1/assets/images/file/admin/foo.png") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - }); + #[tokio::test] + async fn serve_image_404_for_missing_file() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/images/file/user/missing.png") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } - #[test] - fn serve_image_rejects_disallowed_format() { - run_in_jail(|| async { - let state = make_state(); - let app = image_assets_router().with_state(state); - let req = Request::builder() - .uri("/api/v1/assets/images/file/user/foo.exe") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - }); + #[tokio::test] + async fn serve_image_rejects_invalid_scope() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/images/file/admin/foo.png") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } - #[test] - fn list_font_assets_returns_empty_when_no_dirs() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let resp = app - .oneshot( - Request::builder().uri("/api/v1/assets/fonts").body(Body::empty()).unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_string(resp.into_body()).await; - assert_eq!(body, "[]"); - }); + #[tokio::test] + async fn serve_image_rejects_disallowed_format() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = image_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/images/file/user/foo.exe") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } - #[test] - fn upload_font_happy_path_ttf() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let boundary = "fb"; - let body = build_multipart_body(boundary, "file", "myface.ttf", &fake_ttf()); - let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let asset: FontAsset = - serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); - assert_eq!(asset.id, "myface.ttf"); - assert_eq!(asset.format, "ttf"); - assert!(tokio::fs::try_exists("samples/fonts/user/myface.ttf").await.unwrap()); - assert!(tokio::fs::try_exists("samples/fonts/user/myface.ttf.license").await.unwrap()); - }); + #[tokio::test] + async fn list_font_assets_returns_empty_when_no_dirs() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let resp = app + .oneshot(Request::builder().uri("/api/v1/assets/fonts").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_string(resp.into_body()).await; + assert_eq!(body, "[]"); } - #[test] - fn upload_font_happy_path_otf() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let boundary = "fb"; - let body = build_multipart_body(boundary, "file", "myface.otf", &fake_otf()); - let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - }); + #[tokio::test] + async fn upload_font_happy_path_ttf() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let boundary = "fb"; + let body = build_multipart_body(boundary, "file", "myface.ttf", &fake_ttf()); + let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let asset: FontAsset = serde_json::from_slice(&body_bytes(resp.into_body()).await).unwrap(); + assert_eq!(asset.id, "myface.ttf"); + assert_eq!(asset.format, "ttf"); + assert!(tokio::fs::try_exists(root.join("samples/fonts/user/myface.ttf")).await.unwrap()); + assert!(tokio::fs::try_exists(root.join("samples/fonts/user/myface.ttf.license")) + .await + .unwrap()); } - #[test] - fn upload_font_rejects_invalid_magic_bytes_and_cleans_up() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let boundary = "fb"; - let body = - build_multipart_body(boundary, "file", "fake.ttf", b"NOTAFONTHEADERAAAAAAAAAA"); - let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - assert!(!std::path::Path::new("samples/fonts/user/fake.ttf").exists()); - assert!(!std::path::Path::new("samples/fonts/user/fake.ttf.license").exists()); - }); + #[tokio::test] + async fn upload_font_happy_path_otf() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let boundary = "fb"; + let body = build_multipart_body(boundary, "file", "myface.otf", &fake_otf()); + let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); } - #[test] - fn upload_font_rejects_too_small_file() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let boundary = "fb"; - let body = build_multipart_body(boundary, "file", "tiny.ttf", b"ab"); - let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - }); + #[tokio::test] + async fn upload_font_rejects_invalid_magic_bytes_and_cleans_up() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let boundary = "fb"; + let body = build_multipart_body(boundary, "file", "fake.ttf", b"NOTAFONTHEADERAAAAAAAAAA"); + let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert!(!root.join("samples/fonts/user/fake.ttf").exists()); + assert!(!root.join("samples/fonts/user/fake.ttf.license").exists()); } - #[test] - fn upload_font_forbidden_for_viewer() { - run_in_jail(|| async { - let state = make_viewer_state(); - let app = font_assets_router().with_state(state); - let boundary = "fb"; - let body = build_multipart_body(boundary, "file", "x.ttf", &fake_ttf()); - let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); - }); + #[tokio::test] + async fn upload_font_rejects_too_small_file() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let boundary = "fb"; + let body = build_multipart_body(boundary, "file", "tiny.ttf", b"ab"); + let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } - #[test] - fn delete_font_returns_204_and_removes_files() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/fonts/user").await.unwrap(); - tokio::fs::write("samples/fonts/user/bye.ttf", fake_ttf()).await.unwrap(); - tokio::fs::write("samples/fonts/user/bye.ttf.license", b"x").await.unwrap(); - let state = make_state(); - let app = font_assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/fonts/bye.ttf") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NO_CONTENT); - assert!(!std::path::Path::new("samples/fonts/user/bye.ttf").exists()); - assert!(!std::path::Path::new("samples/fonts/user/bye.ttf.license").exists()); - }); + #[tokio::test] + async fn upload_font_forbidden_for_viewer() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_viewer_state(root); + let app = font_assets_router().with_state(state); + let boundary = "fb"; + let body = build_multipart_body(boundary, "file", "x.ttf", &fake_ttf()); + let req = multipart_request(Method::POST, "/api/v1/assets/fonts", boundary, body); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); } - #[test] - fn delete_font_404_when_missing() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/fonts/ghost.ttf") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - }); + #[tokio::test] + async fn delete_font_returns_204_and_removes_files() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/fonts/user")).await.unwrap(); + tokio::fs::write(root.join("samples/fonts/user/bye.ttf"), fake_ttf()).await.unwrap(); + tokio::fs::write(root.join("samples/fonts/user/bye.ttf.license"), b"x").await.unwrap(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/fonts/bye.ttf") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + assert!(!root.join("samples/fonts/user/bye.ttf").exists()); + assert!(!root.join("samples/fonts/user/bye.ttf.license").exists()); } - #[test] - fn delete_font_forbidden_for_viewer() { - run_in_jail(|| async { - let state = make_viewer_state(); - let app = font_assets_router().with_state(state); - let req = Request::builder() - .method(Method::DELETE) - .uri("/api/v1/assets/fonts/anything.ttf") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); - }); + #[tokio::test] + async fn delete_font_404_when_missing() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/fonts/ghost.ttf") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } - #[test] - fn serve_font_returns_correct_content_types() { - run_in_jail(|| async { - tokio::fs::create_dir_all("samples/fonts/user").await.unwrap(); - let cases: [(&str, Vec, &str); 2] = - [("face.ttf", fake_ttf(), "font/ttf"), ("face.otf", fake_otf(), "font/otf")]; - for (name, bytes, expected) in cases { - tokio::fs::write(format!("samples/fonts/user/{name}"), &bytes).await.unwrap(); - let state = make_state(); - let app = font_assets_router().with_state(state); - let req = Request::builder() - .uri(format!("/api/v1/assets/fonts/file/user/{name}")) - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK, "{name}"); - let ct = resp.headers().get(header::CONTENT_TYPE).unwrap(); - assert_eq!(ct, expected, "{name}"); - } - }); + #[tokio::test] + async fn delete_font_forbidden_for_viewer() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_viewer_state(root); + let app = font_assets_router().with_state(state); + let req = Request::builder() + .method(Method::DELETE) + .uri("/api/v1/assets/fonts/anything.ttf") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); } - #[test] - fn serve_font_404_for_missing_file() { - run_in_jail(|| async { - let state = make_state(); + #[tokio::test] + async fn serve_font_returns_correct_content_types() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join("samples/fonts/user")).await.unwrap(); + let cases: [(&str, Vec, &str); 2] = + [("face.ttf", fake_ttf(), "font/ttf"), ("face.otf", fake_otf(), "font/otf")]; + for (name, bytes, expected) in cases { + tokio::fs::write(root.join(format!("samples/fonts/user/{name}")), &bytes) + .await + .unwrap(); + let state = make_state(root); let app = font_assets_router().with_state(state); let req = Request::builder() - .uri("/api/v1/assets/fonts/file/user/missing.ttf") + .uri(format!("/api/v1/assets/fonts/file/user/{name}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - }); + assert_eq!(resp.status(), StatusCode::OK, "{name}"); + let ct = resp.headers().get(header::CONTENT_TYPE).unwrap(); + assert_eq!(ct, expected, "{name}"); + } } - #[test] - fn serve_font_rejects_invalid_scope() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let req = Request::builder() - .uri("/api/v1/assets/fonts/file/system_admin/x.ttf") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - }); + #[tokio::test] + async fn serve_font_404_for_missing_file() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/fonts/file/user/missing.ttf") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } - #[test] - fn serve_font_rejects_disallowed_format() { - run_in_jail(|| async { - let state = make_state(); - let app = font_assets_router().with_state(state); - let req = Request::builder() - .uri("/api/v1/assets/fonts/file/user/x.exe") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - }); + #[tokio::test] + async fn serve_font_rejects_invalid_scope() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/fonts/file/system_admin/x.ttf") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn serve_font_rejects_disallowed_format() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let state = make_state(root); + let app = font_assets_router().with_state(state); + let req = Request::builder() + .uri("/api/v1/assets/fonts/file/user/x.exe") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } } diff --git a/apps/skit/src/config.rs b/apps/skit/src/config.rs index 31dec4d9..c4944b2a 100644 --- a/apps/skit/src/config.rs +++ b/apps/skit/src/config.rs @@ -990,6 +990,14 @@ pub struct Config { #[serde(default)] pub mcp: McpConfig, + + /// Root directory for sample assets (`samples/audio`, `samples/images`, + /// `samples/fonts`, and plugin asset directories). When `None` (the + /// default), the working directory at server startup is used. + /// A relative path is resolved against the startup working directory. + /// The value is snapshotted once at startup and not re-read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_root: Option, } #[derive(Debug)] diff --git a/apps/skit/src/plugin_assets.rs b/apps/skit/src/plugin_assets.rs index 6e17d912..83758e81 100644 --- a/apps/skit/src/plugin_assets.rs +++ b/apps/skit/src/plugin_assets.rs @@ -387,7 +387,10 @@ async fn list_handler( let mut all_assets = Vec::new(); - match scan_directory(&asset_type.system_dir, true, &perms, &asset_type).await { + let system_dir = app_state.asset_root.join(&asset_type.system_dir); + let user_dir = app_state.asset_root.join(&asset_type.user_dir); + + match scan_directory(&system_dir, true, &perms, &asset_type).await { Ok(assets) => all_assets.extend(assets), Err(e) => { error!("Failed to scan system dir for {}: {}", type_id, e); @@ -395,7 +398,7 @@ async fn list_handler( }, } - match scan_directory(&asset_type.user_dir, false, &perms, &asset_type).await { + match scan_directory(&user_dir, false, &perms, &asset_type).await { Ok(assets) => all_assets.extend(assets), Err(e) => { error!("Failed to scan user dir for {}: {}", type_id, e); @@ -449,19 +452,20 @@ async fn upload_handler( }; // Ensure user directory exists. - if let Err(e) = fs::create_dir_all(&asset_type.user_dir).await { + let user_dir = app_state.asset_root.join(&asset_type.user_dir); + if let Err(e) = fs::create_dir_all(&user_dir).await { return PluginAssetError::IoError(format!("Failed to create directory: {e}")) .into_response(); } - let file_path = asset_type.user_dir.join(&filename); + let file_path = user_dir.join(&filename); // Defense-in-depth: verify the user directory (which now exists after // create_dir_all) canonicalizes to where we expect. We cannot // canonicalize `file_path` itself because the file doesn't exist yet. // Instead we canonicalize the parent and check the filename is safe. { - let canonical_dir = match asset_type.user_dir.canonicalize() { + let canonical_dir = match user_dir.canonicalize() { Ok(d) => d, Err(e) => { return PluginAssetError::IoError(format!("Failed to resolve directory: {e}")) @@ -530,12 +534,13 @@ async fn delete_handler( .into_response(); } - let file_path = asset_type.user_dir.join(&id); + let user_dir = app_state.asset_root.join(&asset_type.user_dir); + let file_path = user_dir.join(&id); // Verify the file is inside the user directory (path traversal protection). // Also returns NotFound if the file doesn't exist (avoids TOCTOU with a // separate exists() check). - if let Err(e) = validate_file_in_directory(&file_path, &asset_type.user_dir) { + if let Err(e) = validate_file_in_directory(&file_path, &user_dir) { return e.into_response(); } @@ -574,7 +579,11 @@ async fn serve_handler( return (StatusCode::NOT_FOUND, format!("Unknown asset type: {type_id}")).into_response(); }; - let dir = if scope == "system" { &asset_type.system_dir } else { &asset_type.user_dir }; + let dir = if scope == "system" { + app_state.asset_root.join(&asset_type.system_dir) + } else { + app_state.asset_root.join(&asset_type.user_dir) + }; let file_path = dir.join(&id); let base = asset_type.system_dir.parent().unwrap_or(&asset_type.system_dir); @@ -595,7 +604,7 @@ async fn serve_handler( // Canonical path validation — also returns NotFound if the file doesn't // exist, avoiding a TOCTOU race with a separate exists() check. - if let Err(e) = validate_file_in_directory(&file_path, dir) { + if let Err(e) = validate_file_in_directory(&file_path, &dir) { return e.into_response(); } @@ -661,11 +670,12 @@ async fn update_handler( Err(e) => return e.into_response(), }; - let file_path = asset_type.user_dir.join(&id); + let user_dir = app_state.asset_root.join(&asset_type.user_dir); + let file_path = user_dir.join(&id); // Canonical path validation — also returns NotFound if the file doesn't // exist, avoiding a TOCTOU race with a separate exists() check. - if let Err(e) = validate_file_in_directory(&file_path, &asset_type.user_dir) { + if let Err(e) = validate_file_in_directory(&file_path, &user_dir) { return e.into_response(); } @@ -1523,6 +1533,13 @@ mod handler_tests { crate::server::create_app_state(config, None) } + fn make_state_with_asset_root(root: std::path::PathBuf) -> Arc { + let mut config = Config::default(); + config.auth.mode = AuthMode::Disabled; + config.asset_root = Some(root); + crate::server::create_app_state(config, None) + } + fn make_viewer_state() -> Arc { let mut config = Config::default(); config.auth.mode = AuthMode::Disabled; @@ -2711,4 +2728,56 @@ mod handler_tests { std::fs::write(tmp.path().join("plugin.yml"), "not a manifest").unwrap(); assert!(read_local_plugin_manifest(&library).is_none()); } + + /// Exercises the `asset_root.join(relative_system_dir)` path that + /// `register()` produces. Previous tests used absolute `system_dir` + /// values which short-circuit `Path::join`. + #[tokio::test] + async fn upload_with_relative_system_dir_uses_asset_root() { + let tmp = TempDir::new().unwrap(); + let state = make_state_with_asset_root(tmp.path().to_path_buf()); + + // Use register() which produces *relative* system_dir/user_dir + // (e.g. "samples/models/system"). + state + .plugin_asset_registry + .register( + "test-plugin", + "plugin::native::test-plugin", + &[PluginAssetSpec { + type_id: "models".to_string(), + label: "Models".to_string(), + extensions: vec!["bin".to_string()], + max_size_bytes: 4096, + content_type: AssetContentType::Binary, + system_dir: None, + icon_hint: None, + node_param: None, + }], + ) + .await; + + // Create the dirs under the asset_root that handlers will resolve. + let user_dir = tmp.path().join("samples/models/user"); + std::fs::create_dir_all(&user_dir).unwrap(); + + let boundary = "----boundary"; + let body = build_multipart_body(boundary, "file", Some("test.bin"), b"model-data"); + let app = plugin_assets_router().with_state(state); + let resp = app + .oneshot(multipart_request( + Method::POST, + "/api/v1/assets/plugin/models", + boundary, + body, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + // Verify the file landed under asset_root, not under CWD. + let uploaded = user_dir.join("test.bin"); + assert!(uploaded.exists(), "file should exist under asset_root"); + assert_eq!(std::fs::read(&uploaded).unwrap(), b"model-data"); + } } diff --git a/apps/skit/src/server/mod.rs b/apps/skit/src/server/mod.rs index 1c0cdef2..78e23d50 100644 --- a/apps/skit/src/server/mod.rs +++ b/apps/skit/src/server/mod.rs @@ -1735,6 +1735,31 @@ pub fn create_app_state( config.permissions.role_header = Some(BUILTIN_AUTH_ROLE_HEADER.to_string()); } + let asset_root_explicit = config.asset_root.is_some(); + let asset_root = config.asset_root.clone().unwrap_or_else(|| { + std::env::current_dir().expect( + "failed to determine current working directory for asset_root; \ + set [server].asset_root explicitly in skit.toml", + ) + }); + if asset_root_explicit && !asset_root.exists() { + tracing::error!( + asset_root = %asset_root.display(), + "configured asset_root does not exist — \ + fix the path in skit.toml or create the directory" + ); + std::process::exit(1); + } else if !asset_root_explicit && !asset_root.exists() { + std::fs::create_dir_all(&asset_root).unwrap_or_else(|e| { + tracing::error!( + asset_root = %asset_root.display(), + "default asset_root does not exist and could not be created: {e}" + ); + std::process::exit(1); + }); + } + tracing::info!(asset_root = %asset_root.display(), "Asset root resolved"); + Arc::new(AppState { engine, session_manager: Arc::new(tokio::sync::Mutex::new(SessionManager::default())), @@ -1745,6 +1770,7 @@ pub fn create_app_state( auth, shutdown_tracker: crate::state::ShutdownTracker::default(), plugin_asset_registry, + asset_root, #[cfg(feature = "moq")] moq_gateway, mse_gateway, diff --git a/apps/skit/src/server/oneshot.rs b/apps/skit/src/server/oneshot.rs index 5935a51f..eb3f657d 100644 --- a/apps/skit/src/server/oneshot.rs +++ b/apps/skit/src/server/oneshot.rs @@ -633,6 +633,7 @@ pub(super) async fn process_oneshot_pipeline_handler( io_channel_capacity: cfg .io_channel_capacity .unwrap_or(streamkit_engine::constants::DEFAULT_ONESHOT_IO_CAPACITY), + asset_root: app_state.asset_root.clone(), } }; diff --git a/apps/skit/src/server/preview.rs b/apps/skit/src/server/preview.rs index de078344..f68bc8a2 100644 --- a/apps/skit/src/server/preview.rs +++ b/apps/skit/src/server/preview.rs @@ -1471,6 +1471,7 @@ mod inject_teardown_tests { Some("preview-inject-test".to_string()), tx, Some("test-role".to_string()), + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), ) .await .expect("Session::create on a fresh engine should succeed"); @@ -2022,10 +2023,16 @@ mod handler_tests { async fn install_session(state: &Arc, name: &str) -> Session { let event_tx = state.event_tx.clone(); - let session = - Session::create(&state.engine, &state.config, Some(name.to_string()), event_tx, None) - .await - .expect("Session::create succeeds"); + let session = Session::create( + &state.engine, + &state.config, + Some(name.to_string()), + event_tx, + None, + state.asset_root.clone(), + ) + .await + .expect("Session::create succeeds"); state.session_manager.lock().await.add_session(session.clone()).expect("insert session"); session } diff --git a/apps/skit/src/server/sessions.rs b/apps/skit/src/server/sessions.rs index 31beb63a..f3a6485c 100644 --- a/apps/skit/src/server/sessions.rs +++ b/apps/skit/src/server/sessions.rs @@ -369,6 +369,7 @@ pub async fn create_dynamic_session( name, app_state.event_tx.clone(), Some(role_name), + app_state.asset_root.clone(), ) .await .map_err(|e| CreateSessionError::Internal(format!("Failed to create session: {e}")))?; @@ -755,6 +756,7 @@ mod sessions_batch_tests { Some("batch-helpers-test".to_string()), tx, Some("test-role".to_string()), + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), ) .await .expect("Session::create on a fresh engine should succeed"); diff --git a/apps/skit/src/session.rs b/apps/skit/src/session.rs index a01f9483..67f6f107 100644 --- a/apps/skit/src/session.rs +++ b/apps/skit/src/session.rs @@ -279,6 +279,7 @@ impl Session { name: Option, event_tx: broadcast::Sender, created_by: Option, + asset_root: std::path::PathBuf, ) -> Result { let session_id = Uuid::new_v4().to_string(); let name = @@ -303,6 +304,7 @@ impl Session { session_id: Some(session_id.clone()), node_input_capacity, pin_distributor_capacity, + asset_root, }; // Start the long-running dynamic engine actor for this session. diff --git a/apps/skit/src/state.rs b/apps/skit/src/state.rs index cffa6601..a4536f1b 100644 --- a/apps/skit/src/state.rs +++ b/apps/skit/src/state.rs @@ -118,6 +118,9 @@ pub struct AppState { pub auth: Arc, pub shutdown_tracker: ShutdownTracker, pub plugin_asset_registry: PluginAssetRegistry, + /// Root directory for sample assets. All `samples/` paths are resolved + /// relative to this directory instead of the process working directory. + pub asset_root: std::path::PathBuf, #[cfg(feature = "moq")] pub moq_gateway: Option>, pub mse_gateway: Arc, diff --git a/apps/skit/src/websocket_handlers.rs b/apps/skit/src/websocket_handlers.rs index 3616e48a..855253cf 100644 --- a/apps/skit/src/websocket_handlers.rs +++ b/apps/skit/src/websocket_handlers.rs @@ -191,6 +191,7 @@ async fn handle_create_session( name.clone(), app_state.event_tx.clone(), Some(role_name.to_string()), + app_state.asset_root.clone(), ) .await { @@ -1229,6 +1230,7 @@ mod dispatcher_tests { Some(format!("ws-dispatch-test-{}", uuid::Uuid::new_v4())), state.event_tx.clone(), Some(role.to_string()), + state.asset_root.clone(), ) .await .expect("Session::create succeeded"); @@ -1248,9 +1250,16 @@ mod dispatcher_tests { let (tx, _rx) = broadcast::channel::(1); let engine = Engine::without_plugins(); let config = Config::default(); - Session::create(&engine, &config, Some("owned".into()), tx, role.map(str::to_owned)) - .await - .expect("create test session") + Session::create( + &engine, + &config, + Some("owned".into()), + tx, + role.map(str::to_owned), + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ) + .await + .expect("create test session") } #[tokio::test] diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 3b491ef9..fbe8da5d 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -285,6 +285,10 @@ pub struct NodeContext { /// Only provided in dynamic pipelines. `None` in oneshot/static /// pipelines where the graph is fixed at build time. pub engine_control_tx: Option>, + /// Root directory for resolving relative asset paths (`samples/images/`, + /// `samples/fonts/`, etc.). Defaults to the process working directory + /// at server startup. + pub asset_root: std::path::PathBuf, } impl NodeContext { diff --git a/crates/engine/src/dynamic_actor.rs b/crates/engine/src/dynamic_actor.rs index 351f5251..a9cd16b1 100644 --- a/crates/engine/src/dynamic_actor.rs +++ b/crates/engine/src/dynamic_actor.rs @@ -135,6 +135,7 @@ pub struct DynamicEngine { pub(super) node_input_capacity: usize, /// Buffer capacity for pin distributor channels pub(super) pin_distributor_capacity: usize, + pub(super) asset_root: std::path::PathBuf, /// Tracks the current state of each node in the pipeline. /// Wrapped in `Arc` so that query handlers can cheaply clone the snapshot /// instead of deep-copying the entire map. @@ -667,6 +668,7 @@ impl DynamicEngine { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: Some(channels.view_data.clone()), engine_control_tx: Some(self.engine_control_tx.clone()), + asset_root: self.asset_root.clone(), }; let task_handle = tokio::spawn(node.run(context).instrument(tracing::info_span!( diff --git a/crates/engine/src/dynamic_config.rs b/crates/engine/src/dynamic_config.rs index 7eaad098..b0c24e6d 100644 --- a/crates/engine/src/dynamic_config.rs +++ b/crates/engine/src/dynamic_config.rs @@ -17,8 +17,18 @@ pub struct DynamicEngineConfig { pub node_input_capacity: Option, /// Overrides `DEFAULT_PIN_DISTRIBUTOR_CAPACITY` when `Some`. pub pin_distributor_capacity: Option, + /// Root directory for resolving relative asset paths in nodes. + pub asset_root: std::path::PathBuf, } +impl DynamicEngineConfig { + pub fn new(asset_root: std::path::PathBuf) -> Self { + Self { asset_root, ..Self::default() } + } +} + +/// Note: `Default` calls `std::env::current_dir()` to populate `asset_root`. +/// Prefer [`DynamicEngineConfig::new`] when the asset root is known. impl Default for DynamicEngineConfig { fn default() -> Self { Self { @@ -26,6 +36,7 @@ impl Default for DynamicEngineConfig { session_id: None, node_input_capacity: None, pin_distributor_capacity: None, + asset_root: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), } } } diff --git a/crates/engine/src/graph_builder.rs b/crates/engine/src/graph_builder.rs index 418406c5..bbfd31ff 100644 --- a/crates/engine/src/graph_builder.rs +++ b/crates/engine/src/graph_builder.rs @@ -54,6 +54,7 @@ pub async fn wire_and_spawn_graph( cancellation_token: Option, audio_pool: Option>, video_pool: Option>, + asset_root: std::path::PathBuf, ) -> Result, StreamKitError> { tracing::info!( "Graph builder starting with {} nodes and {} connections", @@ -341,6 +342,7 @@ pub async fn wire_and_spawn_graph( pipeline_mode: streamkit_core::PipelineMode::Oneshot, view_data_tx: None, engine_control_tx: None, + asset_root: asset_root.clone(), }; tracing::debug!("Starting task for node '{}'", name); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8d408c8e..6c4305bd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -174,6 +174,7 @@ impl Engine { video_pool: self.video_pool.clone(), node_input_capacity, pin_distributor_capacity, + asset_root: config.asset_root, node_states: Arc::new(HashMap::new()), state_subscribers: Vec::new(), node_stats: Arc::new(HashMap::new()), diff --git a/crates/engine/src/oneshot.rs b/crates/engine/src/oneshot.rs index aa379bd8..3d2c4bb1 100644 --- a/crates/engine/src/oneshot.rs +++ b/crates/engine/src/oneshot.rs @@ -96,14 +96,25 @@ pub struct OneshotEngineConfig { pub packet_batch_size: usize, pub media_channel_capacity: usize, pub io_channel_capacity: usize, + /// Root directory for resolving relative asset paths in nodes. + pub asset_root: std::path::PathBuf, } +impl OneshotEngineConfig { + pub fn new(asset_root: std::path::PathBuf) -> Self { + Self { asset_root, ..Self::default() } + } +} + +/// Note: `Default` calls `std::env::current_dir()` to populate `asset_root`. +/// Prefer [`OneshotEngineConfig::new`] when the asset root is known. impl Default for OneshotEngineConfig { fn default() -> Self { Self { packet_batch_size: DEFAULT_BATCH_SIZE, media_channel_capacity: DEFAULT_ONESHOT_MEDIA_CAPACITY, io_channel_capacity: DEFAULT_ONESHOT_IO_CAPACITY, + asset_root: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), } } } @@ -421,6 +432,7 @@ impl Engine { Some(cancellation_token.clone()), Some(audio_pool), Some(video_pool), + config.asset_root, ) .await?; tracing::info!("Pipeline graph successfully spawned"); diff --git a/crates/engine/src/tests/graph_builder.rs b/crates/engine/src/tests/graph_builder.rs index c6b4c7fc..4d8affdf 100644 --- a/crates/engine/src/tests/graph_builder.rs +++ b/crates/engine/src/tests/graph_builder.rs @@ -137,6 +137,7 @@ async fn wire( None, None, None, + super::test_asset_root(), ) .await } diff --git a/crates/engine/src/tests/mod.rs b/crates/engine/src/tests/mod.rs index e7d16410..b3ab272d 100644 --- a/crates/engine/src/tests/mod.rs +++ b/crates/engine/src/tests/mod.rs @@ -31,6 +31,10 @@ mod pipeline_activation; #[cfg(feature = "dynamic")] mod upstream_hints; +pub fn test_asset_root() -> std::path::PathBuf { + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) +} + /// Construct a minimal [`DynamicEngine`] for direct-construction tests, /// avoiding field-list drift across callers. #[cfg(feature = "dynamic")] @@ -93,5 +97,6 @@ pub fn create_test_engine() -> crate::dynamic_actor::DynamicEngine { pending_tunes: Vec::new(), next_creation_id: 0, active_creations: std::collections::HashMap::new(), + asset_root: test_asset_root(), } } diff --git a/crates/engine/src/tests/oneshot.rs b/crates/engine/src/tests/oneshot.rs index c90b416c..6b1f8447 100644 --- a/crates/engine/src/tests/oneshot.rs +++ b/crates/engine/src/tests/oneshot.rs @@ -132,6 +132,7 @@ async fn linear_pipeline_runs_to_completion() { None, None, None, + super::test_asset_root(), ) .await else { @@ -199,6 +200,7 @@ async fn cancellation_token_stops_pipeline() { Some(token.clone()), None, None, + super::test_asset_root(), ) .await else { @@ -234,6 +236,7 @@ async fn failing_node_propagates_error() { None, None, None, + super::test_asset_root(), ) .await else { diff --git a/crates/engine/src/tests/oneshot_linear.rs b/crates/engine/src/tests/oneshot_linear.rs index 02472c53..6a67ca0e 100644 --- a/crates/engine/src/tests/oneshot_linear.rs +++ b/crates/engine/src/tests/oneshot_linear.rs @@ -74,6 +74,7 @@ async fn test_oneshot_rejects_fanout() { None, None, None, + super::test_asset_root(), ) .await else { diff --git a/crates/engine/tests/backpressure.rs b/crates/engine/tests/backpressure.rs index 0c66c181..c13cacc1 100644 --- a/crates/engine/tests/backpressure.rs +++ b/crates/engine/tests/backpressure.rs @@ -46,8 +46,7 @@ async fn test_backpressure_no_deadlock() { .parent() .and_then(|parent| parent.parent()) .expect("streamkit-engine should live under workspace_root/crates/engine"); - let sample_file = repo_root.join("samples/audio/system/speech_10m.opus"); - let sample_file = sample_file.to_string_lossy(); + let sample_file = "samples/audio/system/speech_10m.opus"; let engine = Engine::without_plugins(); let config = DynamicEngineConfig { @@ -55,6 +54,7 @@ async fn test_backpressure_no_deadlock() { session_id: Some("test-backpressure".to_string()), node_input_capacity: None, pin_distributor_capacity: None, + asset_root: repo_root.to_path_buf(), }; let handle = engine.start_dynamic_actor(config); diff --git a/crates/nodes/src/audio/filters/resampler.rs b/crates/nodes/src/audio/filters/resampler.rs index 16710ec1..3b1bda45 100644 --- a/crates/nodes/src/audio/filters/resampler.rs +++ b/crates/nodes/src/audio/filters/resampler.rs @@ -726,6 +726,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; let config = AudioResamplerConfig { @@ -800,6 +801,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; let config = AudioResamplerConfig { diff --git a/crates/nodes/src/core/file_read.rs b/crates/nodes/src/core/file_read.rs index b6805699..1a9ddea0 100644 --- a/crates/nodes/src/core/file_read.rs +++ b/crates/nodes/src/core/file_read.rs @@ -64,8 +64,19 @@ impl ProcessorNode for FileReadNode { let node_name = context.output_sender.node_name().to_string(); state_helpers::emit_initializing(&context.state_tx, &node_name); - let mut file = tokio::fs::File::open(&self.config.path).await.map_err(|e| { - StreamKitError::Runtime(format!("Failed to open file '{}': {}", self.config.path, e)) + let config_path = std::path::Path::new(&self.config.path); + if config_path.components().any(|c| c == std::path::Component::ParentDir) { + return Err(StreamKitError::Runtime(format!( + "file_reader path must not contain '..': {}", + self.config.path + ))); + } + let resolved_path = context.asset_root.join(config_path); + let mut file = tokio::fs::File::open(&resolved_path).await.map_err(|e| { + StreamKitError::Runtime(format!( + "Failed to open file '{}': {e}", + resolved_path.display() + )) })?; tracing::info!( @@ -228,6 +239,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; // Create and run node diff --git a/crates/nodes/src/core/file_write.rs b/crates/nodes/src/core/file_write.rs index 687d3f03..f031e946 100644 --- a/crates/nodes/src/core/file_write.rs +++ b/crates/nodes/src/core/file_write.rs @@ -61,8 +61,19 @@ impl ProcessorNode for FileWriteNode { let node_name = context.output_sender.node_name().to_string(); state_helpers::emit_initializing(&context.state_tx, &node_name); - let mut file = tokio::fs::File::create(&self.config.path).await.map_err(|e| { - StreamKitError::Runtime(format!("Failed to create file '{}': {}", self.config.path, e)) + let config_path = std::path::Path::new(&self.config.path); + if config_path.components().any(|c| c == std::path::Component::ParentDir) { + return Err(StreamKitError::Runtime(format!( + "file_writer path must not contain '..': {}", + self.config.path + ))); + } + let resolved_path = context.asset_root.join(config_path); + let mut file = tokio::fs::File::create(&resolved_path).await.map_err(|e| { + StreamKitError::Runtime(format!( + "Failed to create file '{}': {e}", + resolved_path.display() + )) })?; tracing::info!( @@ -196,6 +207,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; // Create and run node @@ -281,6 +293,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; // Create and run node with small chunk size for testing diff --git a/crates/nodes/src/core/object_store_write.rs b/crates/nodes/src/core/object_store_write.rs index e2720597..c2c300b6 100644 --- a/crates/nodes/src/core/object_store_write.rs +++ b/crates/nodes/src/core/object_store_write.rs @@ -751,6 +751,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; // No credentials provided — should fail during init diff --git a/crates/nodes/src/core/pacer.rs b/crates/nodes/src/core/pacer.rs index 509ee38d..10b6519d 100644 --- a/crates/nodes/src/core/pacer.rs +++ b/crates/nodes/src/core/pacer.rs @@ -445,6 +445,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; let node = Box::new(PacerNode { speed: 100.0, buffer_size: 16, initial_burst_packets: 0 }); diff --git a/crates/nodes/src/test_utils.rs b/crates/nodes/src/test_utils.rs index eb2e0e0d..c5118642 100644 --- a/crates/nodes/src/test_utils.rs +++ b/crates/nodes/src/test_utils.rs @@ -3,11 +3,16 @@ // SPDX-License-Identifier: MPL-2.0 use std::collections::HashMap; +use std::path::PathBuf; use streamkit_core::node::{NodeContext, OutputRouting, OutputSender, RoutedPacketMessage}; use streamkit_core::state::NodeStateUpdate; use streamkit_core::types::{Packet, PixelFormat, VideoFrame, VideoLayout}; use tokio::sync::mpsc; +pub fn test_asset_root() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + #[allow(clippy::implicit_hasher)] pub fn create_test_context( inputs: HashMap>, @@ -39,11 +44,23 @@ pub fn create_test_context( pipeline_mode: streamkit_core::node::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: test_asset_root(), }; (context, mock_sender, state_rx) } +#[allow(clippy::implicit_hasher)] +pub fn create_test_context_with_asset_root( + inputs: HashMap>, + batch_size: usize, + asset_root: PathBuf, +) -> (NodeContext, MockOutputSender, mpsc::Receiver) { + let (mut ctx, sender, rx) = create_test_context(inputs, batch_size); + ctx.asset_root = asset_root; + (ctx, sender, rx) +} + #[allow(clippy::implicit_hasher)] pub fn create_test_context_with_pin_mgmt( inputs: HashMap>, @@ -79,6 +96,7 @@ pub fn create_test_context_with_pin_mgmt( pipeline_mode: streamkit_core::node::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: test_asset_root(), }; (context, mock_sender, state_rx, pin_mgmt_tx) diff --git a/crates/nodes/src/transport/http.rs b/crates/nodes/src/transport/http.rs index 532fa981..327bc902 100644 --- a/crates/nodes/src/transport/http.rs +++ b/crates/nodes/src/transport/http.rs @@ -360,6 +360,7 @@ mod tests { pipeline_mode: streamkit_core::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; // Create and run node with small chunk size for testing diff --git a/crates/nodes/src/video/colorbars.rs b/crates/nodes/src/video/colorbars.rs index 5e235f82..31c69420 100644 --- a/crates/nodes/src/video/colorbars.rs +++ b/crates/nodes/src/video/colorbars.rs @@ -138,6 +138,7 @@ impl ProcessorNode for ColorBarsNode { async fn run(self: Box, mut context: NodeContext) -> Result<(), StreamKitError> { let node_name = context.output_sender.node_name().to_string(); + let asset_root = context.asset_root.clone(); state_helpers::emit_initializing(&context.state_tx, &node_name); let width = self.config.width; @@ -159,12 +160,12 @@ impl ProcessorNode for ColorBarsNode { let draw_time_font = if self.config.draw_time { let font_bytes = self.config.draw_time_font_path.as_ref().map_or_else( || { - crate::video::fonts::load_default_mono_font().unwrap_or_else(|e| { + crate::video::fonts::load_default_mono_font(&asset_root).unwrap_or_else(|e| { tracing::warn!("draw_time: {e}, text overlay will be unavailable"); Vec::new() }) }, - |path| match std::fs::read(path) { + |path| match std::fs::read(asset_root.join(path)) { Ok(bytes) => { tracing::info!("draw_time: loaded custom font from {path}"); bytes @@ -174,10 +175,12 @@ impl ProcessorNode for ColorBarsNode { "draw_time: failed to read custom font '{path}': {e}, \ falling back to default system monospace font" ); - crate::video::fonts::load_default_mono_font().unwrap_or_else(|e2| { - tracing::warn!("draw_time: {e2}, text overlay will be unavailable"); - Vec::new() - }) + crate::video::fonts::load_default_mono_font(&asset_root).unwrap_or_else( + |e2| { + tracing::warn!("draw_time: {e2}, text overlay will be unavailable"); + Vec::new() + }, + ) }, }, ); diff --git a/crates/nodes/src/video/compositor/mod.rs b/crates/nodes/src/video/compositor/mod.rs index 776d3b51..f90c67d7 100644 --- a/crates/nodes/src/video/compositor/mod.rs +++ b/crates/nodes/src/video/compositor/mod.rs @@ -429,6 +429,7 @@ impl ProcessorNode for CompositorNode { #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] async fn run(mut self: Box, mut context: NodeContext) -> Result<(), StreamKitError> { let node_name = context.output_sender.node_name().to_string(); + let asset_root = context.asset_root.clone(); state_helpers::emit_initializing(&context.state_tx, &node_name); tracing::info!( @@ -448,7 +449,7 @@ impl ProcessorNode for CompositorNode { tracing::warn!("Failed to decode image overlay '{}': {}", img_cfg.id, e); continue; } - let bytes = match tokio::fs::read(&img_cfg.asset_path).await { + let bytes = match tokio::fs::read(asset_root.join(&img_cfg.asset_path)).await { Ok(b) => b, Err(e) => { tracing::warn!("Failed to read image asset '{}': {}", img_cfg.asset_path, e); @@ -483,6 +484,7 @@ impl ProcessorNode for CompositorNode { txt_cfg, self.limits.max_canvas_dimension, self.limits.max_text_length, + &asset_root, ))); } @@ -805,6 +807,7 @@ impl ProcessorNode for CompositorNode { &mut text_overlays, params.clone(), &mut stats_tracker, + &asset_root, ); Self::send_resize_hints(&old_config, &self.config, &slots); layer_configs_dirty = true; @@ -1190,6 +1193,7 @@ impl CompositorNode { new_config: &CompositorConfig, old_overlays: &Arc<[Arc]>, limits: &GlobalCompositorConfig, + asset_root: &std::path::Path, ) -> Vec> { let mut cache: HashMap<&str, Arc> = HashMap::new(); for decoded in old_overlays.iter() { @@ -1261,7 +1265,7 @@ impl CompositorNode { tracing::warn!("Image overlay decode failed: {e}"); continue; } - let bytes = match std::fs::read(&img_cfg.asset_path) { + let bytes = match std::fs::read(asset_root.join(&img_cfg.asset_path)) { Ok(b) => b, Err(e) => { tracing::warn!("Failed to read image asset '{}': {}", img_cfg.asset_path, e); @@ -1291,6 +1295,7 @@ impl CompositorNode { text_overlays: &mut Arc<[Arc]>, mut params: serde_json::Value, stats_tracker: &mut NodeStatsTracker, + asset_root: &std::path::Path, ) { // Strip transient sync metadata (`_sender`, `_rev`) before // deserializing. The metadata has already been read by the @@ -1320,6 +1325,7 @@ impl CompositorNode { &new_config, image_overlays, limits, + asset_root, ); *image_overlays = Arc::from(new_image_overlays); @@ -1342,6 +1348,7 @@ impl CompositorNode { txt_cfg, limits.max_canvas_dimension, limits.max_text_length, + asset_root, )) }) .collect(); diff --git a/crates/nodes/src/video/compositor/overlay.rs b/crates/nodes/src/video/compositor/overlay.rs index 9e629d42..a6ba7256 100644 --- a/crates/nodes/src/video/compositor/overlay.rs +++ b/crates/nodes/src/video/compositor/overlay.rs @@ -341,7 +341,7 @@ use crate::video::fonts; /// All fonts (system and user) are loaded from disk as assets under /// `samples/fonts/`. #[derive(Clone, Hash, Eq, PartialEq)] -struct FontKey(String); +struct FontKey(std::path::PathBuf); /// Maximum number of distinct fonts kept in [`FONT_CACHE`]. /// @@ -369,9 +369,9 @@ static FONT_CACHE: LazyLock>>> = /// Called when a font asset is deleted via the REST API so that a /// subsequent re-upload with the same filename triggers a fresh parse /// instead of serving stale cached data. -pub fn invalidate_font_cache_entry(path: &str) { +pub fn invalidate_font_cache_entry(path: &str, asset_root: &std::path::Path) { if let Ok(mut cache) = FONT_CACHE.lock() { - cache.remove(&FontKey(path.to_owned())); + cache.remove(&FontKey(asset_root.join(path))); } } @@ -407,20 +407,25 @@ fn validate_font_asset_path(path: &str) -> Result<(), String> { /// /// Returning a boxed closure lets the caller skip file I/O entirely on a cache /// hit. -fn resolve_font_source(config: &TextOverlayConfig) -> (FontKey, FontBytesLoader<'_>) { +fn resolve_font_source<'a>( + config: &'a TextOverlayConfig, + asset_root: &'a std::path::Path, +) -> (FontKey, FontBytesLoader<'a>) { if let Some(ref name) = config.font_name { // Check if it's a font asset path (samples/fonts/...). if name.starts_with("samples/fonts/") { if let Err(e) = validate_font_asset_path(name) { tracing::warn!("{e}, falling back to default system font"); - let key = FontKey(fonts::DEFAULT_FONT_PATH.to_owned()); - let loader = || fonts::load_default_font(); + let key = FontKey(asset_root.join(fonts::DEFAULT_FONT_PATH)); + let loader = move || fonts::load_default_font(asset_root); return (key, Box::new(loader)); } - let key = FontKey(name.clone()); - let path = name.clone(); + let full_path = asset_root.join(name); + let key = FontKey(full_path.clone()); let loader = move || { - std::fs::read(&path).map_err(|e| format!("Failed to read font asset '{path}': {e}")) + let display = full_path.display().to_string(); + std::fs::read(&full_path) + .map_err(|e| format!("Failed to read font asset '{display}': {e}")) }; return (key, Box::new(loader)); } @@ -430,14 +435,14 @@ fn resolve_font_source(config: &TextOverlayConfig) -> (FontKey, FontBytesLoader< "Unknown font name '{name}', falling back to default system font (DejaVu Sans). \ Use a font asset path like 'samples/fonts/system/Inter.ttf'." ); - let key = FontKey(fonts::DEFAULT_FONT_PATH.to_owned()); - let loader = || fonts::load_default_font(); + let key = FontKey(asset_root.join(fonts::DEFAULT_FONT_PATH)); + let loader = move || fonts::load_default_font(asset_root); return (key, Box::new(loader)); } // Default: DejaVu Sans system font asset. - let key = FontKey(fonts::DEFAULT_FONT_PATH.to_owned()); - let loader = || fonts::load_default_font(); + let key = FontKey(asset_root.join(fonts::DEFAULT_FONT_PATH)); + let loader = move || fonts::load_default_font(asset_root); (key, Box::new(loader)) } @@ -448,8 +453,11 @@ fn resolve_font_source(config: &TextOverlayConfig) -> (FontKey, FontBytesLoader< /// Parsed fonts are cached in [`FONT_CACHE`] keyed by the resolved source /// identity, so repeated calls for the same font are an `Arc::clone` rather /// than a fresh parse. -fn load_font(config: &TextOverlayConfig) -> Result, String> { - let (key, load_bytes) = resolve_font_source(config); +fn load_font( + config: &TextOverlayConfig, + asset_root: &std::path::Path, +) -> Result, String> { + let (key, load_bytes) = resolve_font_source(config, asset_root); // Fast path: cache hit. Lock scope limited to the lookup. if let Ok(cache) = FONT_CACHE.lock() { @@ -500,9 +508,10 @@ pub fn rasterize_text_overlay( config: &TextOverlayConfig, max_dimension: u32, max_text_length: usize, + asset_root: &std::path::Path, ) -> DecodedOverlay { // Attempt to load the font; fall back to rectangle placeholders on error. - let font = match load_font(config) { + let font = match load_font(config, asset_root) { Ok(f) => Some(f), Err(e) => { tracing::warn!("Font loading failed, using placeholder rectangles: {e}"); diff --git a/crates/nodes/src/video/compositor/tests.rs b/crates/nodes/src/video/compositor/tests.rs index 3a9b35fe..5f19c1ee 100644 --- a/crates/nodes/src/video/compositor/tests.rs +++ b/crates/nodes/src/video/compositor/tests.rs @@ -286,7 +286,7 @@ fn test_rasterize_text_overlay_produces_pixels() { font_name: None, word_wrap: false, }; - let overlay = rasterize_text_overlay(&cfg, 7680, 10_000); + let overlay = rasterize_text_overlay(&cfg, 7680, 10_000, std::path::Path::new(".")); // Bitmap is sized to the measured text extent, not the config rect. assert!(overlay.width > 0, "rasterized width must be positive"); assert!(overlay.height > 0, "rasterized height must be positive"); @@ -2224,10 +2224,12 @@ fn test_text_overlay_cache_reuses_arc_on_unchanged_config() { CompositorConfig { text_overlays: vec![txt_cfg.clone()], ..Default::default() }; // Initial rasterize. + let asset_root = std::path::Path::new("."); let initial = Arc::new(rasterize_text_overlay( &txt_cfg, limits.max_canvas_dimension, limits.max_text_length, + asset_root, )); let mut text_overlays: Arc<[Arc]> = Arc::from(vec![initial]); let mut image_overlays: Arc<[Arc]> = Arc::from(vec![]); @@ -2251,6 +2253,7 @@ fn test_text_overlay_cache_reuses_arc_on_unchanged_config() { &mut text_overlays, params, &mut stats, + asset_root, ); assert_eq!( Arc::as_ptr(&text_overlays[0]), @@ -2275,6 +2278,7 @@ fn test_text_overlay_cache_reuses_arc_on_unchanged_config() { &mut text_overlays, params, &mut stats, + asset_root, ); assert_ne!( Arc::as_ptr(&text_overlays[0]), @@ -2348,15 +2352,18 @@ fn test_text_overlay_cache_handles_length_changes() { text_overlays: vec![txt_a.clone(), txt_b.clone()], ..Default::default() }; + let asset_root = std::path::Path::new("."); let initial_a = Arc::new(rasterize_text_overlay( &txt_a, limits.max_canvas_dimension, limits.max_text_length, + asset_root, )); let initial_b = Arc::new(rasterize_text_overlay( &txt_b, limits.max_canvas_dimension, limits.max_text_length, + asset_root, )); let mut text_overlays: Arc<[Arc]> = Arc::from(vec![initial_a, initial_b]); let mut image_overlays: Arc<[Arc]> = Arc::from(vec![]); @@ -2377,6 +2384,7 @@ fn test_text_overlay_cache_handles_length_changes() { &mut text_overlays, params, &mut stats, + asset_root, ); assert_eq!(text_overlays.len(), 1, "Should have 1 overlay after shrink"); assert_eq!( @@ -2406,6 +2414,7 @@ fn test_text_overlay_cache_handles_length_changes() { &mut text_overlays, params, &mut stats, + asset_root, ); assert_eq!(text_overlays.len(), 3, "Should have 3 overlays after grow"); assert_eq!( @@ -2497,6 +2506,7 @@ async fn test_compositor_output_format_runtime_change() { pipeline_mode: streamkit_core::node::PipelineMode::Dynamic, view_data_tx: None, engine_control_tx: None, + asset_root: crate::test_utils::test_asset_root(), }; // Start with no output_format (RGBA8). @@ -2833,6 +2843,7 @@ fn test_rebuild_svg_rerasterizes_on_resize() { &new_config, &old_overlays, &config::GlobalCompositorConfig::default(), + std::path::Path::new("."), ); assert_eq!(rebuilt.len(), 1); @@ -2860,6 +2871,7 @@ fn test_rebuild_svg_reuses_bitmap_when_unchanged() { &config_with_overlay, &old_overlays, &config::GlobalCompositorConfig::default(), + std::path::Path::new("."), ); assert_eq!(rebuilt.len(), 1); @@ -2902,6 +2914,7 @@ fn test_update_params_ignores_transient_sync_metadata() { &mut text_overlays, params, &mut stats, + std::path::Path::new("."), ); // Config should have been updated despite the extra metadata fields. @@ -2935,6 +2948,7 @@ fn test_update_params_rejects_truly_unknown_fields() { &mut text_overlays, params, &mut stats, + std::path::Path::new("."), ); // Config should NOT have been updated — deserialization should fail. diff --git a/crates/nodes/src/video/fonts.rs b/crates/nodes/src/video/fonts.rs index ac043cc1..226e7411 100644 --- a/crates/nodes/src/video/fonts.rs +++ b/crates/nodes/src/video/fonts.rs @@ -8,6 +8,8 @@ //! being embedded in the binary via `include_bytes!`. The default fonts //! (DejaVu family) ship as system assets in `samples/fonts/system/`. +use std::path::Path; + /// Default proportional font asset path (DejaVu Sans) — used when no font is /// specified in compositor text overlays. pub const DEFAULT_FONT_PATH: &str = "samples/fonts/system/DejaVuSans.ttf"; @@ -19,15 +21,17 @@ pub const DEFAULT_MONO_FONT_PATH: &str = "samples/fonts/system/DejaVuSansMono.tt /// Load the default proportional font bytes from disk. /// /// Returns an error if the file cannot be read. -pub fn load_default_font() -> Result, String> { - std::fs::read(DEFAULT_FONT_PATH) - .map_err(|e| format!("Failed to read default font '{DEFAULT_FONT_PATH}': {e}")) +pub fn load_default_font(asset_root: &Path) -> Result, String> { + let full_path = asset_root.join(DEFAULT_FONT_PATH); + std::fs::read(&full_path) + .map_err(|e| format!("Failed to read default font '{}': {e}", full_path.display())) } /// Load the default monospace font bytes from disk. /// /// Returns an error if the file cannot be read. -pub fn load_default_mono_font() -> Result, String> { - std::fs::read(DEFAULT_MONO_FONT_PATH) - .map_err(|e| format!("Failed to read default mono font '{DEFAULT_MONO_FONT_PATH}': {e}")) +pub fn load_default_mono_font(asset_root: &Path) -> Result, String> { + let full_path = asset_root.join(DEFAULT_MONO_FONT_PATH); + std::fs::read(&full_path) + .map_err(|e| format!("Failed to read default mono font '{}': {e}", full_path.display())) } diff --git a/crates/plugin-native/tests/panicking_plugin.rs b/crates/plugin-native/tests/panicking_plugin.rs index cb93d84a..1dd2b35e 100644 --- a/crates/plugin-native/tests/panicking_plugin.rs +++ b/crates/plugin-native/tests/panicking_plugin.rs @@ -72,6 +72,7 @@ fn test_node_context_with_output_observer( pipeline_mode: PipelineMode::Oneshot, view_data_tx: None, engine_control_tx: None, + asset_root: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), }; (ctx, state_rx, control_tx, routed_rx) } diff --git a/crates/plugin-native/tests/source_plugin.rs b/crates/plugin-native/tests/source_plugin.rs index 05c5c16d..1c65ce69 100644 --- a/crates/plugin-native/tests/source_plugin.rs +++ b/crates/plugin-native/tests/source_plugin.rs @@ -70,6 +70,7 @@ fn source_node_context() -> ( pipeline_mode: PipelineMode::Oneshot, view_data_tx: None, engine_control_tx: None, + asset_root: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), }; (ctx, state_rx, control_tx, routed_rx) }