Bound native media preview capabilities and source range reads

This commit is contained in:
karl committed 2026-10-13 14:22:19 +02:00
1 parent 4b0b5ebfd9
commit 370db7e791
13 files changed
+834 -46

No files matched your search

-7
View File
@@ -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",
+2 -2
View File
@@ -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]
+26 -32
View File
@@ -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<SemanticState>,
watching: Arc<watching::WatchService>,
index_options: searchi_core::IndexOptions,
previews: Arc<media_preview::PreviewRegistry>,
}
#[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<MediaPreview, String> {
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(())
})
+701
View File
@@ -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<Self, String> {
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::<BY_HANDLE_FILE_INFORMATION>::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<File>,
identity: Identity,
revision: i64,
kind: &'static str,
mime: &'static str,
}
#[derive(Default)]
pub struct PreviewRegistry {
grants: Mutex<VecDeque<Arc<Grant>>>,
requests: AtomicUsize,
}
pub struct Admission(Arc<PreviewRegistry>);
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<Self>) -> Option<Admission> {
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<String, String> {
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<Vec<u8>>) -> Response<Vec<u8>> {
match self.read_response(request) {
Ok(response) => response,
Err(status) => error_response(status),
}
}
fn read_response(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>, 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(&current).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<i64, String> {
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<u64, ()> {
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<Vec<u8>> {
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<PreviewRegistry>,
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<Vec<u8>> {
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"
);
}
}
+2 -2
View File
@@ -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": {
+3 -1
View File
@@ -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<T>(
}
export function prepareMediaURL(path: string) {
if (isTauri()) return convertFileSrc(path);
if (isTauri())
return convertFileSrc(nativePreviewToken(path), "searchi-media");
return capture.mediaURL(path);
}
+2 -1
View File
@@ -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();
+9
View File
@@ -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;
}
+7
View File
@@ -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);
+20
View File
@@ -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/);
}
});
+13
View File
@@ -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");
+45
View File
@@ -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.
+4 -1
View File
@@ -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.