Files
searchi/apps/desktop/src-tauri/src/media_preview.rs
T

747 lines
27 KiB
Rust

//! 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::*;
#[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<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"
);
}
}