From 370db7e791db3d864df4ad246144567bca07b5c6 Mon Sep 17 00:00:00 2001 From: karl Date: Tue, 13 Oct 2026 14:22:19 +0200 Subject: [PATCH] Bound native media preview capabilities and source range reads --- apps/desktop/src-tauri/Cargo.lock | 7 - apps/desktop/src-tauri/Cargo.toml | 4 +- apps/desktop/src-tauri/src/main.rs | 58 +- apps/desktop/src-tauri/src/media_preview.rs | 701 ++++++++++++++++++++ apps/desktop/src-tauri/tauri.conf.json | 4 +- apps/desktop/src/desktop-api.ts | 4 +- apps/desktop/src/main.ts | 3 +- apps/desktop/src/media-access.ts | 9 + apps/desktop/src/media-display.ts | 7 + apps/desktop/tests/media-access.test.mjs | 20 + apps/desktop/tests/media-display.test.mjs | 13 + docs/MEDIA-PREVIEW.md | 45 ++ docs/ROADMAP.md | 5 +- 13 files changed, 834 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src-tauri/src/media_preview.rs create mode 100644 apps/desktop/src/media-access.ts create mode 100644 apps/desktop/tests/media-access.test.mjs create mode 100644 docs/MEDIA-PREVIEW.md diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 459f17f..9b0e7c5 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1858,12 +1858,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - [[package]] name = "httparse" version = "1.10.1" @@ -4396,7 +4390,6 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "http-range", "jni", "libc", "log", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 933e60f..1842b4f 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ tauri-build = { version = "2", features = [] } [dependencies] anyhow = "1" notify = "=8.2.0" -tauri = { version = "2", features = ["protocol-asset"] } +tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" @@ -35,7 +35,7 @@ tauri-plugin-single-instance = "2" [target.'cfg(windows)'.dependencies] window-vibrancy = "=0.8.1" -windows-sys = { version = "0.61", features = ["Win32_UI_Accessibility", "Win32_System_SystemInformation", "Wdk_System_SystemServices", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Accessibility", "Win32_System_SystemInformation", "Wdk_System_SystemServices", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging", "Win32_Storage_FileSystem"] } # Reviewed upstream GLib 0.18-compatible iterator safety backport. [patch.crates-io] diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 9ac1ec1..1ad99b8 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -7,6 +7,7 @@ mod ai_commands; mod appearance; mod data_directory; mod instance; +mod media_preview; mod model_download; mod semantic_commands; mod settings; @@ -44,6 +45,7 @@ struct AppState { semantic: Arc, watching: Arc, index_options: searchi_core::IndexOptions, + previews: Arc, } #[derive(Serialize)] @@ -356,17 +358,20 @@ struct MediaPreview { kind: &'static str, } -// The asset protocol starts with an empty scope. Only a validated indexed -// media file is granted access; the webview receives no general filesystem API. +// Return a bounded opaque capability, never a lifetime asset-scope permission. #[tauri::command] async fn prepare_media_preview( path: String, - app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { let database = state.database.clone(); + let previews = state.previews.clone(); + let admission = previews + .admit() + .ok_or("Media preview is busy; try again.")?; tauri::async_runtime::spawn_blocking(move || { - let index = SearchIndex::open(database).map_err(|error| error.to_string())?; + let _admission = admission; + let index = SearchIndex::open(&database).map_err(|error| error.to_string())?; let canonical = validated_result(&index, &path)?; let kind = match searchi_media::classify(&canonical) { Some(searchi_media::MediaKind::Image) => "image", @@ -374,33 +379,8 @@ async fn prepare_media_preview( Some(searchi_media::MediaKind::Video) => "video", None => return Err("This file is not a supported media preview.".into()), }; - if kind == "image" { - let bytes = std::fs::metadata(&canonical) - .map_err(|error| error.to_string())? - .len(); - if bytes > 64 * 1024 * 1024 { - return Err("Image exceeds the 64 MiB preview limit.".into()); - } - let reader = image::ImageReader::open(&canonical) - .map_err(|error| error.to_string())? - .with_guessed_format() - .map_err(|error| error.to_string())?; - // Resource limits follow decoded format, not a renameable suffix. - let max_pixels = preview_pixel_limit(reader.format()); - let (width, height) = reader - .into_dimensions() - .map_err(|error| format!("Image preview is unavailable: {error}"))?; - if width == 0 || height == 0 || u64::from(width) * u64::from(height) > max_pixels { - return Err("Image exceeds the supported preview dimensions.".into()); - } - } - app.asset_protocol_scope() - .allow_file(&canonical) - .map_err(|error| error.to_string())?; - Ok(MediaPreview { - path: canonical.to_string_lossy().into_owned(), - kind, - }) + let token = previews.grant(database, canonical, kind)?; + Ok(MediaPreview { path: token, kind }) }) .await .map_err(|error| error.to_string())? @@ -554,11 +534,24 @@ fn main() { std::env::var_os("SEARCHI_DATA_DIR"), ) .expect("searchi profile identity could not be resolved"); + let previews = Arc::new(media_preview::PreviewRegistry::default()); + let protocol_previews = previews.clone(); tauri::Builder::default() // Detect duplicates before any model, database, watcher or other plugin starts. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { instance::focus_search(app); })) + .register_asynchronous_uri_scheme_protocol("searchi-media", move |_context, request, responder| { + let Some(admission) = protocol_previews.admit() else { + responder.respond(media_preview::error_response(tauri::http::StatusCode::SERVICE_UNAVAILABLE)); + return; + }; + let registry = protocol_previews.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _admission = admission; + responder.respond(registry.respond(request)); + }); + }) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(|app, _, event| { @@ -566,7 +559,7 @@ fn main() { instance::focus_search(app); } }).build()) - .setup(|app| { + .setup(move |app| { let directory = data_directory::resolve( app.path().app_data_dir()?, std::env::var_os("SEARCHI_DATA_DIR"), @@ -673,6 +666,7 @@ fn main() { semantic, watching, index_options, + previews: previews.clone(), }); Ok(()) }) diff --git a/apps/desktop/src-tauri/src/media_preview.rs b/apps/desktop/src-tauri/src/media_preview.rs new file mode 100644 index 0000000..0fdb3eb --- /dev/null +++ b/apps/desktop/src-tauri/src/media_preview.rs @@ -0,0 +1,701 @@ +//! Bounded, revocable media capabilities. The webview never receives file paths. +//! +//! Every request rechecks indexed metadata/change revision and the opened file's +//! identity. This is not a byte hash: undetected same-stat in-place writes remain +//! possible until a filesystem hint or byte-verifying reconciliation occurs. +use searchi_core::SearchIndex; +use std::{ + collections::VecDeque, + fs::{File, Metadata}, + io::{BufReader, Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::UNIX_EPOCH, +}; +use tauri::http::{header, Method, Request, Response, StatusCode}; + +const MAX_GRANTS: usize = 16; +const MAX_REQUESTS: usize = 2; +const MAX_CHUNK: u64 = 1024 * 1024; +const MAX_IMAGE: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Identity { + len: u64, + modified_ns: i64, + file_id: (u64, u64), +} + +impl Identity { + fn read(file: &File) -> Result { + let metadata = file.metadata().map_err(|_| "Media file is unavailable.")?; + if !metadata.is_file() { + return Err("Preview source is not a file.".into()); + } + let modified_ns = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .and_then(|value| i64::try_from(value.as_nanos()).ok()) + .ok_or("Preview source timestamp is unavailable.")?; + Ok(Self { + len: metadata.len(), + modified_ns, + file_id: file_id(file, &metadata)?, + }) + } +} + +#[cfg(windows)] +fn file_id(file: &File, _: &Metadata) -> Result<(u64, u64), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + let mut info = std::mem::MaybeUninit::::uninit(); + // The live File owns this handle for the duration of the call; the API + // initializes the output only on success, which is checked before reading. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), info.as_mut_ptr()) } == 0 { + return Err("Preview source identity is unavailable.".into()); + } + let info = unsafe { info.assume_init() }; + Ok(( + u64::from(info.dwVolumeSerialNumber), + (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow), + )) +} + +#[cfg(unix)] +fn file_id(_: &File, metadata: &Metadata) -> Result<(u64, u64), String> { + use std::os::unix::fs::MetadataExt; + Ok((metadata.dev(), metadata.ino())) +} + +struct Grant { + token: String, + path: PathBuf, + database: PathBuf, + file: Mutex, + identity: Identity, + revision: i64, + kind: &'static str, + mime: &'static str, +} + +#[derive(Default)] +pub struct PreviewRegistry { + grants: Mutex>>, + requests: AtomicUsize, +} + +pub struct Admission(Arc); +impl Drop for Admission { + fn drop(&mut self) { + self.0.requests.fetch_sub(1, Ordering::AcqRel); + } +} + +impl PreviewRegistry { + /// Admit before scheduling work, so the blocking executor cannot accumulate + /// an unbounded queue of requests or hold an unbounded number of files. + pub fn admit(self: &Arc) -> Option { + self.requests + .try_update(Ordering::AcqRel, Ordering::Acquire, |count| { + (count < MAX_REQUESTS).then_some(count + 1) + }) + .ok()?; + Some(Admission(self.clone())) + } + + pub fn grant( + &self, + database: PathBuf, + path: PathBuf, + kind: &'static str, + ) -> Result { + let file = File::open(&path).map_err(|_| "Media file is unavailable.")?; + let identity = Identity::read(&file)?; + if identity.len == 0 { + return Err("Empty media files have no preview.".into()); + } + let index = SearchIndex::open(&database).map_err(|_| "Search index is unavailable.")?; + let revision = indexed_revision(&index, &path, &identity)?; + let mime = if kind == "image" { + if identity.len > MAX_IMAGE { + return Err("Image exceeds the 64 MiB preview limit.".into()); + } + let reader = image::ImageReader::new(BufReader::new( + file.try_clone().map_err(|_| "Image is unavailable.")?, + )) + .with_guessed_format() + .map_err(|_| "Image format is unavailable.")?; + let format = reader.format(); + let max_pixels = crate::preview_pixel_limit(format); + let (width, height) = reader + .into_dimensions() + .map_err(|_| "Image preview is unavailable.")?; + if width == 0 || height == 0 || u64::from(width) * u64::from(height) > max_pixels { + return Err("Image exceeds the supported preview dimensions.".into()); + } + match format { + Some(image::ImageFormat::Jpeg) => "image/jpeg", + Some(image::ImageFormat::Png) => "image/png", + Some(image::ImageFormat::WebP) => "image/webp", + Some(image::ImageFormat::Bmp) => "image/bmp", + _ => return Err("Unsupported image preview format.".into()), + } + } else { + media_mime(&path, kind)? + }; + if Identity::read(&file)? != identity { + return Err("Media changed while preparing its preview.".into()); + } + let token = uuid::Uuid::new_v4().simple().to_string(); + let grant = Arc::new(Grant { + token: token.clone(), + path, + database, + file: Mutex::new(file), + identity, + revision, + kind, + mime, + }); + let mut grants = self + .grants + .lock() + .map_err(|_| "Media preview is unavailable.")?; + while grants.len() >= MAX_GRANTS { + grants.pop_front(); + } + grants.push_back(grant); + Ok(token) + } + + pub fn respond(&self, request: Request>) -> Response> { + match self.read_response(request) { + Ok(response) => response, + Err(status) => error_response(status), + } + } + + fn read_response(&self, request: Request>) -> Result>, StatusCode> { + if request.method() != Method::GET && request.method() != Method::HEAD { + return Err(StatusCode::METHOD_NOT_ALLOWED); + } + if !request.body().is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + let token = request + .uri() + .path() + .strip_prefix('/') + .ok_or(StatusCode::NOT_FOUND)?; + if request.uri().query().is_some() + || token.len() != 32 + || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(StatusCode::NOT_FOUND); + } + let grant = self + .grants + .lock() + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + .iter() + .find(|grant| grant.token == token) + .cloned() + .ok_or(StatusCode::NOT_FOUND)?; + // Reopen the canonical path to catch deletion, symlink retargeting and + // replacement. Reads still use the retained handle; paths are never sent + // to the webview. Canonical equality prevents a replaced symlink escape. + let canonical = std::fs::canonicalize(&grant.path).map_err(|_| StatusCode::GONE)?; + if canonical != grant.path { + return Err(StatusCode::GONE); + } + let current = File::open(&canonical).map_err(|_| StatusCode::GONE)?; + if Identity::read(¤t).map_err(|_| StatusCode::GONE)? != grant.identity { + return Err(StatusCode::GONE); + } + let index = + SearchIndex::open(&grant.database).map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + if indexed_revision(&index, &grant.path, &grant.identity).map_err(|_| StatusCode::GONE)? + != grant.revision + { + return Err(StatusCode::GONE); + } + let mut ranges = request.headers().get_all(header::RANGE).iter(); + let first_range = ranges.next(); + if ranges.next().is_some() { + return Err(StatusCode::RANGE_NOT_SATISFIABLE); + } + let range = first_range + .map(|value| { + value + .to_str() + .map_err(|_| StatusCode::RANGE_NOT_SATISFIABLE) + }) + .transpose()?; + let (start, end, partial) = if request.method() == Method::HEAD && range.is_none() { + (0, grant.identity.len.saturating_sub(1), false) + } else { + match select_range(range, grant.identity.len, grant.kind == "image") { + Ok(value) => value, + Err(()) => { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header( + header::CONTENT_RANGE, + format!("bytes */{}", grant.identity.len), + ) + .header(header::CONTENT_LENGTH, "0") + .header(header::CACHE_CONTROL, "no-store") + .body(Vec::new()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) + } + } + }; + let length = end + .checked_sub(start) + .and_then(|value| value.checked_add(1)) + .unwrap_or(0); + let mut body = Vec::new(); + let mut file = grant + .file + .lock() + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + if Identity::read(&file).map_err(|_| StatusCode::GONE)? != grant.identity { + return Err(StatusCode::GONE); + } + if request.method() == Method::GET && length != 0 { + file.seek(SeekFrom::Start(start)) + .map_err(|_| StatusCode::GONE)?; + let length = usize::try_from(length).map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?; + body.try_reserve_exact(length) + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + body.resize(length, 0); + file.read_exact(&mut body).map_err(|_| StatusCode::GONE)?; + } + if Identity::read(&file).map_err(|_| StatusCode::GONE)? != grant.identity { + return Err(StatusCode::GONE); + } + if indexed_revision(&index, &grant.path, &grant.identity).map_err(|_| StatusCode::GONE)? + != grant.revision + { + return Err(StatusCode::GONE); + } + let final_path = std::fs::canonicalize(&grant.path).map_err(|_| StatusCode::GONE)?; + if final_path != grant.path + || Identity::read(&File::open(final_path).map_err(|_| StatusCode::GONE)?) + .map_err(|_| StatusCode::GONE)? + != grant.identity + { + return Err(StatusCode::GONE); + } + let mut response = Response::builder() + .status(if partial { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }) + .header(header::CONTENT_TYPE, grant.mime) + .header(header::CONTENT_LENGTH, length.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CACHE_CONTROL, "no-store") + .header("X-Content-Type-Options", "nosniff"); + if partial { + response = response.header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{}", grant.identity.len), + ); + } + response + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) + } +} + +fn indexed_revision(index: &SearchIndex, path: &Path, identity: &Identity) -> Result { + let path = path.to_string_lossy(); + if index + .file_signature(&path) + .map_err(|_| "Search index is unavailable.")? + != Some((identity.len, identity.modified_ns)) + { + return Err("Media changed since indexing; refresh the result first.".into()); + } + index + .media_change_revision(&path, identity.len, identity.modified_ns) + .map_err(|_| "Media changed since indexing; refresh the result first.".into()) +} + +fn media_mime(path: &Path, kind: &str) -> Result<&'static str, String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + match (kind, extension.as_str()) { + ("audio", "mp3") => Ok("audio/mpeg"), + ("audio", "wav") => Ok("audio/wav"), + ("audio", "flac") => Ok("audio/flac"), + ("audio", "ogg" | "oga" | "opus") => Ok("audio/ogg"), + ("audio", "m4a") => Ok("audio/mp4"), + ("audio", "aac") => Ok("audio/aac"), + ("audio", "aiff" | "aif") => Ok("audio/aiff"), + ("video", "mp4" | "m4v") => Ok("video/mp4"), + ("video", "mov") => Ok("video/quicktime"), + ("video", "mpeg" | "mpg") => Ok("video/mpeg"), + ("video", "webm") => Ok("video/webm"), + ("video", "mkv") => Ok("video/x-matroska"), + ("video", "avi") => Ok("video/x-msvideo"), + _ => Err("This media format has no supported inline preview.".into()), + } +} + +/// One byte range only. Large requests are served in bounded 206 chunks. The +/// browser can request subsequent chunks; no-range audio/video receives the +/// initial chunk, never an allocation proportional to the source file size. +fn select_range(range: Option<&str>, len: u64, image: bool) -> Result<(u64, u64, bool), ()> { + if len == 0 { + return Err(()); + } + let Some(range) = range else { + let limit = if image { MAX_IMAGE } else { MAX_CHUNK }; + return Ok((0, len.min(limit) - 1, len > limit)); + }; + let range = range.strip_prefix("bytes=").ok_or(())?; + if range.len() > 64 || range.contains(',') { + return Err(()); + } + let (start, end) = range.split_once('-').ok_or(())?; + let digits = |value: &str| -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(()); + } + value.parse().map_err(|_| ()) + }; + let (start, end) = if start.is_empty() { + let suffix = digits(end)?; + if suffix == 0 { + return Err(()); + } + (len.saturating_sub(suffix), len - 1) + } else { + ( + digits(start)?, + if end.is_empty() { + len - 1 + } else { + digits(end)?.min(len - 1) + }, + ) + }; + if start >= len || end < start { + return Err(()); + } + Ok((start, end.min(start.saturating_add(MAX_CHUNK - 1)), true)) +} + +pub fn error_response(status: StatusCode) -> Response> { + Response::builder() + .status(status) + .header(header::CACHE_CONTROL, "no-store") + .header(header::CONTENT_LENGTH, "0") + .header(header::RETRY_AFTER, "1") + .body(Vec::new()) + .expect("fixed preview response headers") +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Fixture { + _directory: tempfile::TempDir, + database: PathBuf, + path: PathBuf, + registry: Arc, + token: String, + } + impl Fixture { + fn new(len: u64) -> Self { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("media"); + std::fs::create_dir(&root).unwrap(); + let path = root.join("sample.wav"); + let file = File::create(&path).unwrap(); + file.set_len(len).unwrap(); + drop(file); + let path = path.canonicalize().unwrap(); + let database = directory.path().join("index.sqlite"); + SearchIndex::open(&database) + .unwrap() + .index_folder(&root) + .unwrap(); + let registry = Arc::new(PreviewRegistry::default()); + let token = registry + .grant(database.clone(), path.clone(), "audio") + .unwrap(); + Self { + _directory: directory, + database, + path, + registry, + token, + } + } + fn request(&self, method: Method, range: Option<&str>) -> Response> { + let mut request = Request::builder() + .method(method) + .uri(format!("searchi-media://localhost/{}", self.token)); + if let Some(range) = range { + request = request.header(header::RANGE, range); + } + self.registry.respond(request.body(Vec::new()).unwrap()) + } + } + + #[test] + fn large_media_never_allocates_the_full_file_and_head_is_metadata_only() { + let fixture = Fixture::new(8 * MAX_CHUNK); + let response = fixture.request(Method::GET, None); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.body().len(), MAX_CHUNK as usize); + assert_eq!( + response.headers()[header::CONTENT_RANGE], + "bytes 0-1048575/8388608" + ); + let response = fixture.request(Method::HEAD, None); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "8388608"); + assert!(response.body().is_empty()); + } + + #[test] + fn range_forms_have_precise_bounds_and_reject_multi_range_and_overflow() { + assert_eq!(select_range(Some("bytes=2-4"), 10, false), Ok((2, 4, true))); + assert_eq!(select_range(Some("bytes=8-"), 10, false), Ok((8, 9, true))); + assert_eq!(select_range(Some("bytes=-3"), 10, false), Ok((7, 9, true))); + assert_eq!( + select_range(Some("bytes=0-999999999"), 8 * MAX_CHUNK, false), + Ok((0, MAX_CHUNK - 1, true)) + ); + for invalid in [ + "bytes=2-1", + "bytes=10-", + "bytes=-0", + "bytes=0-1,3-4", + "bytes=18446744073709551616-", + "bytes=+1-", + "items=0-1", + ] { + assert!(select_range(Some(invalid), 10, false).is_err(), "{invalid}"); + } + let fixture = Fixture::new(10); + let response = fixture.request(Method::GET, Some("bytes=11-")); + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); + } + + #[test] + fn seek_returns_actual_bytes_and_head_ranges_have_no_body() { + let fixture = Fixture::new(10); + std::fs::write(&fixture.path, b"0123456789").unwrap(); + SearchIndex::open(&fixture.database) + .unwrap() + .index_folder(fixture.path.parent().unwrap()) + .unwrap(); + let token = fixture + .registry + .grant(fixture.database.clone(), fixture.path.clone(), "audio") + .unwrap(); + let fixture = Fixture { token, ..fixture }; + let response = fixture.request(Method::GET, Some("bytes=3-5")); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.body(), b"345"); + let response = fixture.request(Method::HEAD, Some("bytes=-2")); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "2"); + assert!(response.body().is_empty()); + } + + #[test] + fn eviction_expires_old_capability_and_admission_is_bounded() { + let fixture = Fixture::new(10); + for _ in 0..MAX_GRANTS { + fixture + .registry + .grant(fixture.database.clone(), fixture.path.clone(), "audio") + .unwrap(); + } + assert_eq!(fixture.registry.grants.lock().unwrap().len(), MAX_GRANTS); + assert_eq!( + fixture.request(Method::GET, None).status(), + StatusCode::NOT_FOUND + ); + let first = fixture.registry.admit().unwrap(); + let second = fixture.registry.admit().unwrap(); + assert!(fixture.registry.admit().is_none()); + drop(first); + assert!(fixture.registry.admit().is_some()); + drop(second); + } + + #[test] + fn unsupported_method_and_path_traversal_do_not_open_paths() { + let fixture = Fixture::new(10); + assert_eq!( + fixture.request(Method::POST, None).status(), + StatusCode::METHOD_NOT_ALLOWED + ); + for path in [ + "../sample.wav", + "%2e%2e/sample.wav", + "C:/Windows/win.ini", + "00000000000000000000000000000000", + "", + ] { + let request = Request::builder() + .uri(format!("searchi-media://localhost/{path}")) + .body(Vec::new()) + .unwrap(); + assert_eq!( + fixture.registry.respond(request).status(), + StatusCode::NOT_FOUND + ); + } + } + + #[test] + fn deleted_changed_and_removed_index_sources_are_rejected() { + let changed = Fixture::new(10); + File::options() + .write(true) + .open(&changed.path) + .unwrap() + .set_len(11) + .unwrap(); + assert_eq!( + changed.request(Method::GET, None).status(), + StatusCode::GONE + ); + let deleted = Fixture::new(10); + std::fs::remove_file(&deleted.path).unwrap(); + assert_eq!( + deleted.request(Method::GET, None).status(), + StatusCode::GONE + ); + let removed = Fixture::new(10); + SearchIndex::open(&removed.database) + .unwrap() + .index_folder(removed._directory.path().join("empty")) + .unwrap_err(); + // Reconcile the indexed root after a rename; the original grant must + // not survive just because its old retained handle remains readable. + std::fs::rename(&removed.path, removed.path.with_extension("old")).unwrap(); + SearchIndex::open(&removed.database) + .unwrap() + .index_folder(removed.path.parent().unwrap()) + .unwrap(); + assert_eq!( + removed.request(Method::GET, None).status(), + StatusCode::GONE + ); + } + + #[test] + fn unchanged_metadata_does_not_preserve_grant_after_durable_media_hint() { + let fixture = Fixture::new(10); + let identity = Identity::read(&File::open(&fixture.path).unwrap()).unwrap(); + let mut index = SearchIndex::open(&fixture.database).unwrap(); + index + .index_paths_with_options( + fixture.path.parent().unwrap(), + std::slice::from_ref(&fixture.path), + &Default::default(), + ) + .unwrap(); + assert_eq!( + Identity::read(&File::open(&fixture.path).unwrap()).unwrap(), + identity + ); + assert_eq!( + fixture.request(Method::GET, None).status(), + StatusCode::GONE + ); + } + + #[test] + fn replacement_with_same_stats_and_unindexed_live_file_are_rejected() { + let fixture = Fixture::new(10); + let modified = std::fs::metadata(&fixture.path) + .unwrap() + .modified() + .unwrap(); + std::fs::rename(&fixture.path, fixture.path.with_extension("old")).unwrap(); + let replacement = File::create(&fixture.path).unwrap(); + replacement.set_len(10).unwrap(); + replacement + .set_times(std::fs::FileTimes::new().set_modified(modified)) + .unwrap(); + assert_eq!( + fixture.request(Method::GET, None).status(), + StatusCode::GONE + ); + let unindexed = Fixture::new(10); + SearchIndex::open(&unindexed.database) + .unwrap() + .index_folder_with_options( + unindexed.path.parent().unwrap(), + &searchi_core::IndexOptions { + excluded_paths: vec![unindexed.path.clone()], + ..Default::default() + }, + ) + .unwrap(); + assert!(unindexed.path.is_file()); + assert!(!SearchIndex::open(&unindexed.database) + .unwrap() + .contains_path(&unindexed.path.to_string_lossy()) + .unwrap()); + assert_eq!( + unindexed.request(Method::GET, None).status(), + StatusCode::GONE + ); + } + + #[test] + fn image_uses_detected_mime_and_full_bounded_body_even_when_misnamed() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("images"); + std::fs::create_dir(&root).unwrap(); + let path = root.join("actually-png.jpg"); + image::RgbaImage::new(2, 3) + .save_with_format(&path, image::ImageFormat::Png) + .unwrap(); + let database = directory.path().join("index.sqlite"); + SearchIndex::open(&database) + .unwrap() + .index_folder(&root) + .unwrap(); + let registry = PreviewRegistry::default(); + let token = registry + .grant(database, path.canonicalize().unwrap(), "image") + .unwrap(); + let response = registry.respond( + Request::builder() + .uri(format!("searchi-media://localhost/{token}")) + .body(Vec::new()) + .unwrap(), + ); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png"); + assert_eq!(response.body(), &std::fs::read(path).unwrap()); + assert_eq!( + media_mime(Path::new("speech.aac"), "audio").unwrap(), + "audio/aac" + ); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index f7a5d87..16eb039 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -20,8 +20,8 @@ } ], "security": { - "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost https://asset.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost", - "assetProtocol": { "enable": true, "scope": [] } + "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data: searchi-media: http://searchi-media.localhost https://searchi-media.localhost; media-src 'self' searchi-media: http://searchi-media.localhost https://searchi-media.localhost", + "assetProtocol": { "enable": false, "scope": [] } } }, "bundle": { diff --git a/apps/desktop/src/desktop-api.ts b/apps/desktop/src/desktop-api.ts index eb55d3f..25a65c8 100644 --- a/apps/desktop/src/desktop-api.ts +++ b/apps/desktop/src/desktop-api.ts @@ -4,6 +4,7 @@ import { convertFileSrc, } from "@tauri-apps/api/core"; import { CaptureBridge } from "./capture-bridge"; +import { nativePreviewToken } from "./media-access"; const capture = new CaptureBridge(); export const isNativeDesktop = () => isTauri(); @@ -25,6 +26,7 @@ export function invoke( } export function prepareMediaURL(path: string) { - if (isTauri()) return convertFileSrc(path); + if (isTauri()) + return convertFileSrc(nativePreviewToken(path), "searchi-media"); return capture.mediaURL(path); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9db6679..21fb1f1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -61,6 +61,7 @@ import { sourceLabel, seekSeconds, PreviewRevision, + previewSelectionKey, } from "./media-display"; interface SearchResult { @@ -892,7 +893,7 @@ function select(index: number, focus = false, userChoice = true) { ? `Open in your default application. The matched location ${sourceLabel(result.source)} is shown in Searchi; default application opening starts at its own location.` : "Open in your default application"; const selection = previewRevision.select( - JSON.stringify([result.path, result.source?.kind, result.source?.number]), + previewSelectionKey(result.path, result.source, folderRevision), ); if (selection.changed) { mediaLane.clear(); diff --git a/apps/desktop/src/media-access.ts b/apps/desktop/src/media-access.ts new file mode 100644 index 0000000..662da2f --- /dev/null +++ b/apps/desktop/src/media-access.ts @@ -0,0 +1,9 @@ +/** Native previews carry an opaque grant, never a filesystem path or URL. */ +export function nativePreviewToken(value: string): string { + if (!/^[a-f0-9]{32}$/.test(value)) { + throw new Error( + "Media preview access is unavailable. Select the file again.", + ); + } + return value; +} diff --git a/apps/desktop/src/media-display.ts b/apps/desktop/src/media-display.ts index 4dae07f..85e5ca3 100644 --- a/apps/desktop/src/media-display.ts +++ b/apps/desktop/src/media-display.ts @@ -4,6 +4,13 @@ export interface MediaSource { number: number; } export type MediaKind = "image" | "audio" | "video"; +export function previewSelectionKey( + path: string, + source: MediaSource | undefined, + indexRevision: number, +): string { + return JSON.stringify([path, source?.kind, source?.number, indexRevision]); +} export function timestampLabel(milliseconds: number): string { if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Unknown time"; const seconds = Math.floor(milliseconds / 1000); diff --git a/apps/desktop/tests/media-access.test.mjs b/apps/desktop/tests/media-access.test.mjs new file mode 100644 index 0000000..a10f76e --- /dev/null +++ b/apps/desktop/tests/media-access.test.mjs @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { nativePreviewToken } from "../src/media-access.ts"; + +test("native preview accepts only opaque grants, never paths or destinations", () => { + const token = "a123456789abcdef0123456789abcdef"; + assert.equal(nativePreviewToken(token), token); + for (const value of [ + "C:/Users/karl/photo.jpg", + "/home/user/video.mp4", + "https://example.com/media", + "../" + token, + token + "?file=secret", + "%2f" + token, + "", + token.toUpperCase(), + ]) { + assert.throws(() => nativePreviewToken(value), /preview access/); + } +}); diff --git a/apps/desktop/tests/media-display.test.mjs b/apps/desktop/tests/media-display.test.mjs index 2d559a5..260716f 100644 --- a/apps/desktop/tests/media-display.test.mjs +++ b/apps/desktop/tests/media-display.test.mjs @@ -6,10 +6,23 @@ import { sourceLabel, matchLabel, PreviewRevision, + previewSelectionKey, } from "../src/media-display.ts"; import { LatestLane } from "../src/search-state.ts"; const settle = () => new Promise((resolve) => setImmediate(resolve)); +test("indexed content refresh invalidates a decoded preview at the same path", () => { + const revision = new PreviewRevision(); + const source = { kind: "timestamp_ms", number: 1200 }; + const first = revision.select(previewSelectionKey("recording.mp4", source, 1)); + const repeated = revision.select(previewSelectionKey("recording.mp4", source, 1)); + assert.equal(repeated.changed, false); + const refreshed = revision.select(previewSelectionKey("recording.mp4", source, 2)); + assert.equal(refreshed.changed, true); + assert.equal(revision.accepts(first.revision), false); + assert.equal(revision.accepts(refreshed.revision), true); +}); + test("millisecond seeks preserve fractions and readable minute labels", () => { assert.equal(timestampLabel(125450), "2:05"); assert.equal(timestampLabel(3600000), "60:00"); diff --git a/docs/MEDIA-PREVIEW.md b/docs/MEDIA-PREVIEW.md new file mode 100644 index 0000000..faedfd9 --- /dev/null +++ b/docs/MEDIA-PREVIEW.md @@ -0,0 +1,45 @@ +# Bounded native media previews + +Structural tests passed; published Windows 0.1.2 still uses the +earlier asset protocol. Native playback and other OS checks remain open. + +The replacement `searchi-media` protocol uses random opaque capabilities instead +of returning filesystem paths. The registry retains at most sixteen grants; +older addresses expire as new previews are prepared. At most two requests are +admitted before scheduling work. Audio/video responses use bounded 1-MiB byte +ranges instead of allocating an entire recording. Images retain a 64-MiB input +limit and format-specific pixel ceilings. Ordinary opening/revealing uses the +separate index-validated native opener. + +Each grant is bound to an opened file, canonical path, size, timestamp, platform +file identity and durable indexed media-change revision. Requests revalidate +these properties and reject expired, deleted, replaced or invalidated sources. +The frontend accepts only 32 lowercase hexadecimal capability identifiers. +Arbitrary paths, remote URLs, query strings and traversal are not preview grants. +The generic Tauri asset protocol is disabled in the new configuration. +Index-change notifications also advance the preview selection identity, so a +same-path image/recording reloads rather than retaining previously decoded media. + +This does not hash every recording on every request. A same-size/mtime in-place +write may remain undetected until a filesystem hint or byte-verifying rescan +advances the indexed revision. Source reads and filesystem/index changes are +not one atomic transaction. Do not claim complete byte freshness or native +playback compatibility from these structural checks. + +Required validation includes capability eviction, missing/unindexed/replaced +files, durable same-stat invalidation, byte-range edge cases, large recordings, +request admission, MIME behavior, native image/audio/video playback and seeking, +and preview reload after index changes. Record executed checks before closing +the corresponding roadmap item. + +Windows CPU validation: all 45 native tests passed, including nine actual +temporary-file/protocol cases covering 8-MiB no-range recordings with 1-MiB +responses, full HEAD metadata, seek byte equality, grant eviction, admission, +invalid paths, deletion, unindexed files, same-size/time replacement identity, +durable same-stat hint invalidation and actual renamed PNG format/MIME. +All 59 frontend tests passed, including opaque-token rejection and same-path +index-refresh invalidation. Format checking and production frontend build passed. +These checks do not prove WebView playback or macOS/Linux protocol behavior. +The locked native suite, strict all-target Clippy and Rust formatting checks +passed after the final native edits. No model inference or GPU test was needed +for these protocol regressions. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f8bc95c..2bf1d0c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -55,9 +55,12 @@ release. Keep their evidence and unchecked production gates intact. ## 3. Desktop - [x] Image preview pixel ceilings use actual decoded format; a real PNG renamed `.jpg` cannot receive the larger JPEG allowance (Windows CPU regression). -- [ ] Replace lifetime-growing native media asset grants with bounded preview +- [x] Replace lifetime-growing native media asset grants with bounded preview access, including range limits, stale-source handling and selection/reload tests. Do not use permanent forbidden patterns as revocation. +- [ ] Validate native custom-protocol image/audio/video playback and seeking on + release platforms; structural Windows CPU tests alone do not close this gate. + See [preview contract and evidence](MEDIA-PREVIEW.md). - [x] Official single-instance transport with isolated explicit profiles; Windows CPU duplicate launch exits without a second query worker. See [launch evidence](SINGLE-INSTANCE.md); visual focus and other OS checks remain open.