//! 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::*; #[test] fn concurrent_admission_stays_bounded_and_unwind_releases_capacity() { let registry = Arc::new(PreviewRegistry::default()); let active = Arc::new(AtomicUsize::new(0)); let barrier = Arc::new(std::sync::Barrier::new(8)); let workers: Vec<_> = (0..8) .map(|_| { let registry = registry.clone(); let active = active.clone(); let barrier = barrier.clone(); std::thread::spawn(move || { barrier.wait(); for _ in 0..500 { if let Some(admission) = registry.admit() { let count = active.fetch_add(1, Ordering::SeqCst) + 1; assert!(count <= MAX_REQUESTS); std::thread::yield_now(); active.fetch_sub(1, Ordering::SeqCst); drop(admission); } } }) }) .collect(); for worker in workers { worker.join().unwrap(); } assert_eq!(active.load(Ordering::SeqCst), 0); assert_eq!(registry.requests.load(Ordering::SeqCst), 0); let first = registry.admit().unwrap(); let interrupted = registry.clone(); assert!(std::panic::catch_unwind(move || { let _second = interrupted.admit().unwrap(); panic!("injected preview worker panic"); }) .is_err()); let second = registry .admit() .expect("Unwinding must release the second slot"); assert!(registry.admit().is_none()); drop(first); drop(second); assert_eq!(registry.requests.load(Ordering::SeqCst), 0); } 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" ); } }