747 lines
27 KiB
Rust
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(¤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::*;
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|