Bound native media preview capabilities and source range reads
This commit is contained in:
1 parent
4b0b5ebfd9
commit
370db7e791
13 files changed
+834
-46
No files matched your search
Generated
-7
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -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(¤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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user