Files
ImageApi/src/libraries.rs
Cameron Cordes 439532377d libraries: validate excluded_dirs entries on write
Reject the silent-footgun shapes that PathExcluder would store but
never match. The watcher would still walk past every photo as if the
exclude wasn't there, and the operator would have no signal that
their entry is dead. Caught at PATCH time with a descriptive 422.

Rules:
- Backslash anywhere → "use forward slashes" (catches \photos,
  photos\2024, \\server\share — Windows-typed entries land in the
  component-pattern bucket and never fire).
- Drive-letter prefix (Z:, Z:/...) → "relative to library root" —
  excludes are root-relative, not absolute system paths.
- Multi-segment name with no leading slash (photos/2024) →
  "did you mean /photos/2024?" — the common "I forgot the slash"
  typo, today silently stored as a component pattern that never hits.
- `..` segments in a path entry → "doesn't normalise". base.join()
  doesn't canonicalise, so the resulting prefix never matches.
- Bare "/" → "almost certainly a typo" for the library root.

Trailing slashes on path entries are stripped silently. Eight new
tests cover each rejection plus the trailing-slash normalisation
and the all-or-nothing failure mode of normalize_excluded_dirs_input
(one bad entry aborts the whole patch rather than silently applying
N-1 of N changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:02:29 -04:00

1038 lines
39 KiB
Rust

use actix_web::{HttpResponse, Responder, get, patch, web, web::Data};
use chrono::Utc;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use log::{info, warn};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use crate::data::Claims;
use crate::database::models::{InsertLibrary, LibraryRow};
use crate::database::schema::libraries;
use crate::state::AppState;
/// Id of the primary library row seeded by the multi-library migration.
/// Used as the default `library_id` during the Phase 2 transitional shim,
/// before handlers/callers are library-aware.
pub const PRIMARY_LIBRARY_ID: i32 = 1;
/// Placeholder value written into `libraries.root_path` by the migration.
/// Replaced on startup with the live `BASE_PATH` env var.
pub const ROOT_PATH_PLACEHOLDER: &str = "BASE_PATH_PLACEHOLDER";
/// A media library mount point: its numeric id, logical name, and absolute
/// root on disk. `rel_path` values stored in the DB are relative to this root.
#[derive(Clone, Debug, serde::Serialize)]
pub struct Library {
pub id: i32,
pub name: String,
pub root_path: String,
/// Operator kill switch (mirrors `libraries.enabled`). When `false`
/// the watcher skips this library entirely — before the probe,
/// before ingest, before maintenance. Reads / serving still work
/// (a request whose path resolves to a disabled library's root
/// will succeed if the file is on disk; nothing prevents that
/// today and there's no obvious reason to). Toggle via SQL.
pub enabled: bool,
/// Per-library excluded paths/patterns, parsed from the
/// comma-separated DB column. The walker applies these
/// **in union** with the global `EXCLUDED_DIRS` env var; either
/// list matching a path is enough to exclude. Empty = no
/// library-specific excludes (only the global env var applies).
pub excluded_dirs: Vec<String>,
}
impl Library {
/// Resolve a library-relative path into an absolute `PathBuf` under the
/// library root. Does not validate traversal — use `is_valid_full_path`
/// for untrusted input.
#[allow(dead_code)]
pub fn resolve(&self, rel_path: &str) -> PathBuf {
Path::new(&self.root_path).join(rel_path)
}
/// Inverse of `resolve`: given an absolute path under this library's
/// root, return the root-relative portion. Returns `None` if the path
/// is not under the library.
#[allow(dead_code)]
pub fn strip_root(&self, abs_path: &Path) -> Option<String> {
abs_path
.strip_prefix(&self.root_path)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
}
/// Effective excluded directories for a walk of this library:
/// the union of the global env-var excludes (passed in by the
/// caller as `globals`) and this library's per-row excludes.
/// Order doesn't matter; `PathExcluder` accepts repeats.
pub fn effective_excluded_dirs(&self, globals: &[String]) -> Vec<String> {
if self.excluded_dirs.is_empty() {
return globals.to_vec();
}
let mut combined: Vec<String> =
Vec::with_capacity(globals.len() + self.excluded_dirs.len());
combined.extend_from_slice(globals);
combined.extend(self.excluded_dirs.iter().cloned());
combined
}
}
/// Parse a comma-separated excluded_dirs column into a Vec, dropping
/// empty entries (mirrors `AppState::parse_excluded_dirs` for the env
/// var). NULL → empty Vec. Duplicates are preserved — `PathExcluder`
/// accepts repeats, and the storage-side normaliser is where dedup
/// happens.
pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec<String> {
match raw {
None => Vec::new(),
Some(s) => s
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
}
}
/// Validate a single excluded_dirs entry, normalising trivial cosmetic
/// differences and rejecting forms that `PathExcluder` would silently
/// drop. Returns the entry to store, or an error message describing
/// what's wrong with it.
///
/// Rules:
/// - Backslashes are rejected — PathExcluder strips only a leading `/`;
/// a Windows-typed `\photos` or `photos\2024` lands in the
/// component-pattern bucket and never matches anything. Suggest the
/// forward-slash form.
/// - A Windows drive letter prefix (`Z:` etc.) is rejected — excluded
/// entries are *relative to the library root*, not absolute system
/// paths.
/// - A no-leading-slash entry containing `/` is rejected — the
/// component-pattern path matches a single segment only; the user
/// almost certainly meant the leading-slash form.
/// - A `..` segment in a path entry is rejected — `base.join("../x")`
/// doesn't canonicalise, so the resulting prefix never matches and
/// the exclude silently fails.
/// - Trailing slashes on path entries are stripped silently
/// (`/photos/` → `/photos`) — purely cosmetic.
pub fn validate_excluded_dirs_entry(entry: &str) -> Result<String, String> {
let trimmed = entry.trim();
if trimmed.is_empty() {
return Err("empty entry".to_string());
}
if trimmed.contains('\\') {
return Err(format!(
"'{}': use forward slashes — backslash paths never match on the watcher's component-by-component compare",
trimmed
));
}
// Windows drive letter prefix like `Z:` or `Z:/something`. A
// length-2 ASCII-alpha + colon is the canonical form; we don't
// bother with longer multi-letter Windows drive-equivalents
// (`\\?\Volume{…}`) since the backslash check already catches them.
let bytes = trimmed.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return Err(format!(
"'{}': excluded entries are relative to the library root, not absolute system paths — drop the drive letter",
trimmed
));
}
if let Some(rel) = trimmed.strip_prefix('/') {
// Path form. Reject `..` traversal — `base.join(\"../x\")` doesn't
// canonicalise, so `path.starts_with(...)` never matches.
if rel
.split('/')
.any(|seg| seg == "..")
{
return Err(format!(
"'{}': '..' segments don't normalise — the prefix-match never fires",
trimmed
));
}
// Strip a trailing slash if any (`/photos/` → `/photos`). Purely
// cosmetic; PathBuf::starts_with treats both forms identically.
let stripped = if rel.ends_with('/') {
format!("/{}", rel.trim_end_matches('/'))
} else {
trimmed.to_string()
};
// After stripping, an empty rel ("/" alone) excludes the root —
// certainly a typo.
if stripped == "/" {
return Err("'/': excluding the library root is almost certainly a typo".to_string());
}
Ok(stripped)
} else {
// Component-pattern form: must be a single segment. A `/`
// anywhere here is the common "I forgot the leading slash" typo
// — reject so the user fixes it instead of staring at an
// exclude that does nothing.
if trimmed.contains('/') {
return Err(format!(
"'{}': multi-segment names only match with a leading slash — try '/{}'",
trimmed, trimmed
));
}
Ok(trimmed.to_string())
}
}
/// Canonicalise an excluded_dirs string for storage: validate each
/// entry, then parse → trim → dedupe (preserving insertion order) →
/// comma-join with no inner whitespace. Empty / whitespace-only input
/// → `Ok(None)` (writes NULL). Any entry that fails validation aborts
/// the whole patch with a descriptive error so the operator can fix
/// the typo before retrying.
///
/// Used by `PATCH /libraries/{id}` so two users typing the same entries
/// in different orders / casings / whitespace land on the same stored
/// form, and a typo'd duplicate (`@eaDir, @eaDir`) collapses on save.
/// Round-trip stable: writing the output back through this function
/// yields the same string.
pub fn normalize_excluded_dirs_input(raw: &str) -> Result<Option<String>, String> {
let parsed = parse_excluded_dirs_column(Some(raw));
if parsed.is_empty() {
return Ok(None);
}
let mut seen = std::collections::HashSet::new();
let mut deduped: Vec<String> = Vec::with_capacity(parsed.len());
for entry in parsed {
let validated = validate_excluded_dirs_entry(&entry)?;
if seen.insert(validated.clone()) {
deduped.push(validated);
}
}
if deduped.is_empty() {
Ok(None)
} else {
Ok(Some(deduped.join(",")))
}
}
impl From<LibraryRow> for Library {
fn from(row: LibraryRow) -> Self {
Library {
id: row.id,
name: row.name,
root_path: row.root_path,
enabled: row.enabled,
excluded_dirs: parse_excluded_dirs_column(row.excluded_dirs.as_deref()),
}
}
}
/// Load all library rows from the database into `Library` values.
pub fn load_all(conn: &mut SqliteConnection) -> Vec<Library> {
libraries::table
.order(libraries::id.asc())
.load::<LibraryRow>(conn)
.unwrap_or_else(|e| {
warn!("Failed to load libraries table: {:?}", e);
Vec::new()
})
.into_iter()
.map(Library::from)
.collect()
}
/// Ensure at least one library exists and that the seeded placeholder row is
/// patched with the live `BASE_PATH`. Safe to call on every startup; it only
/// writes when the placeholder is still present.
pub fn seed_or_patch_from_env(conn: &mut SqliteConnection, base_path: &str) {
// Check whether the primary row still carries the placeholder from the
// migration. If so, replace it with the live BASE_PATH.
let placeholder_count: i64 = libraries::table
.filter(libraries::root_path.eq(ROOT_PATH_PLACEHOLDER))
.count()
.get_result(conn)
.unwrap_or(0);
if placeholder_count > 0 {
diesel::update(libraries::table.filter(libraries::root_path.eq(ROOT_PATH_PLACEHOLDER)))
.set(libraries::root_path.eq(base_path))
.execute(conn)
.map(|rows| {
info!(
"Patched {} library row(s) with BASE_PATH='{}'",
rows, base_path
);
})
.unwrap_or_else(|e| warn!("Failed to patch library root_path: {:?}", e));
return;
}
// If no rows exist at all (e.g. table created outside the seeded migration),
// insert a primary library pointing at BASE_PATH.
let total: i64 = libraries::table.count().get_result(conn).unwrap_or(0);
if total == 0 {
let now = Utc::now().timestamp();
let result = diesel::insert_into(libraries::table)
.values(InsertLibrary {
name: "main",
root_path: base_path,
created_at: now,
enabled: true,
excluded_dirs: None,
})
.execute(conn);
match result {
Ok(_) => info!(
"Seeded primary library 'main' with BASE_PATH='{}'",
base_path
),
Err(e) => warn!("Failed to seed primary library: {:?}", e),
}
}
}
/// Resolve a library request parameter (accepts numeric id as string or name)
/// against the configured libraries. Returns `Ok(None)` when the param is
/// absent, meaning "span all libraries". Returns `Err` when a value is
/// provided but does not match any library.
pub fn resolve_library_param<'a>(
state: &'a AppState,
param: Option<&str>,
) -> Result<Option<&'a Library>, String> {
let Some(raw) = param.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
if let Ok(id) = raw.parse::<i32>() {
return state
.library_by_id(id)
.map(Some)
.ok_or_else(|| format!("unknown library id: {}", id));
}
state
.library_by_name(raw)
.map(Some)
.ok_or_else(|| format!("unknown library name: {}", raw))
}
/// Health of a library at a point in time. Probed at the top of each
/// file-watcher tick. The `Stale` state is the "be conservative" signal:
/// destructive paths (ingest writes, future move-handoff and orphan GC in
/// branches B/C) skip a stale library, but reads/serving stay unaffected.
///
/// See `CLAUDE.md` → "Library availability and safety" for the policy.
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum LibraryHealth {
Online,
Stale {
reason: String,
/// Unix timestamp (seconds) of the most recent transition into
/// Stale. Held for telemetry / `/libraries` surfacing only —
/// gating logic doesn't read it.
since: i64,
},
}
impl LibraryHealth {
pub fn is_online(&self) -> bool {
matches!(self, LibraryHealth::Online)
}
}
/// Shared snapshot of every configured library's health, keyed by
/// `library_id`. The watcher writes; HTTP handlers read. RwLock because
/// reads vastly outnumber writes (one tick vs. every status request).
pub type LibraryHealthMap = Arc<RwLock<HashMap<i32, LibraryHealth>>>;
/// Construct an initial health map. Libraries start `Online`; the first
/// probe will downgrade any that fail. Starting `Stale` would block ingest
/// for the watcher's first tick on a healthy mount, which is the wrong
/// default for a server that's just been restarted.
pub fn new_health_map(libs: &[Library]) -> LibraryHealthMap {
let mut m = HashMap::with_capacity(libs.len());
for lib in libs {
m.insert(lib.id, LibraryHealth::Online);
}
Arc::new(RwLock::new(m))
}
/// Probe a library's mount point. Cheap: stat + open dir + peek one entry.
///
/// `had_data` is the caller's prior knowledge that this library has been
/// non-empty before — typically `image_exif` row count > 0. When true, an
/// empty directory is suspicious (it's how an unmounted NFS share looks);
/// when false, it's accepted as a fresh mount that simply hasn't been
/// indexed yet.
///
/// Note: stat / read_dir on a hard-mounted, unreachable NFS share can
/// block. The watcher accepts that risk for now — the worst case is that
/// the tick stalls until the mount returns, which is no more destructive
/// than the pre-probe behavior. A future enhancement can wrap this in a
/// thread + timeout if it becomes an operational issue.
pub fn probe_online(lib: &Library, had_data: bool) -> LibraryHealth {
let now = Utc::now().timestamp();
let path = Path::new(&lib.root_path);
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) => {
return LibraryHealth::Stale {
reason: format!("root_path stat failed: {}", e),
since: now,
};
}
};
if !metadata.is_dir() {
return LibraryHealth::Stale {
reason: format!("root_path is not a directory: {}", lib.root_path),
since: now,
};
}
let mut entries = match std::fs::read_dir(path) {
Ok(it) => it,
Err(e) => {
return LibraryHealth::Stale {
reason: format!("read_dir failed: {}", e),
since: now,
};
}
};
// Empty directory only counts as Stale when we have prior evidence
// this library used to have content. A genuinely fresh mount is
// legitimately empty, and degrading it would block first-time ingest.
if had_data && entries.next().is_none() {
return LibraryHealth::Stale {
reason: "library is empty but image_exif has rows for it".to_string(),
since: now,
};
}
LibraryHealth::Online
}
/// Probe `lib`, update `map`, and return the new state. Logs only on a
/// state transition (Online↔Stale) so a long outage doesn't spam at every
/// tick — operators get one warn on the way down and one info on the way
/// up.
pub fn refresh_health(map: &LibraryHealthMap, lib: &Library, had_data: bool) -> LibraryHealth {
let new_state = probe_online(lib, had_data);
let mut guard = map.write().unwrap_or_else(|e| e.into_inner());
let prev = guard.get(&lib.id).cloned();
let transitioned = matches!(
(&prev, &new_state),
(None, LibraryHealth::Stale { .. })
| (Some(LibraryHealth::Online), LibraryHealth::Stale { .. })
| (Some(LibraryHealth::Stale { .. }), LibraryHealth::Online)
);
if transitioned {
match &new_state {
LibraryHealth::Online => info!(
"Library '{}' (id={}) recovered: {} is online",
lib.name, lib.id, lib.root_path
),
LibraryHealth::Stale { reason, .. } => warn!(
"Library '{}' (id={}) is STALE — pausing writes. Reason: {}. Path: {}",
lib.name, lib.id, reason, lib.root_path
),
}
}
guard.insert(lib.id, new_state.clone());
new_state
}
/// Snapshot of one library + its current health, for `/libraries`.
#[derive(serde::Serialize)]
pub struct LibraryStatus {
#[serde(flatten)]
pub library: Library,
pub health: LibraryHealth,
}
#[derive(serde::Serialize)]
pub struct LibrariesResponse {
pub libraries: Vec<LibraryStatus>,
/// Globally-excluded paths/patterns from the `EXCLUDED_DIRS` env var.
/// Applied **in union** with each library's own `excluded_dirs`. Surfaced
/// here so an admin UI can show the operator "you already skip these
/// everywhere" before they add per-library entries that would duplicate
/// the global list. Read-only — globals live in `.env` and aren't
/// mutable via the API today.
pub global_excluded_dirs: Vec<String>,
}
#[get("/libraries")]
pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl Responder {
// Read from the live view so a recent PATCH /libraries/{id} that
// flipped `enabled` or rewrote `excluded_dirs` surfaces immediately
// — the immutable `app_state.libraries` snapshot is stale once the
// first mutation lands.
let live_guard = app_state
.live_libraries
.read()
.unwrap_or_else(|e| e.into_inner());
let health_guard = app_state
.library_health
.read()
.unwrap_or_else(|e| e.into_inner());
let libraries = live_guard
.iter()
.map(|lib| LibraryStatus {
library: lib.clone(),
health: health_guard
.get(&lib.id)
.cloned()
.unwrap_or(LibraryHealth::Online),
})
.collect();
HttpResponse::Ok().json(LibrariesResponse {
libraries,
global_excluded_dirs: app_state.excluded_dirs.clone(),
})
}
/// Body for PATCH /libraries/{id}. Both fields are optional — omitting
/// one leaves it untouched. `excluded_dirs` is the same comma-separated
/// shape as the DB column; an empty string clears (writes NULL).
#[derive(Deserialize, Debug)]
pub struct PatchLibraryBody {
pub enabled: Option<bool>,
pub excluded_dirs: Option<String>,
}
/// Mutate one library row. The watcher reads `app_state.live_libraries`
/// at the top of each tick, so a successful PATCH is picked up within
/// one WATCH_QUICK_INTERVAL_SECONDS without restart — no separate
/// `apply_now` signal. Returns the updated `Library` so the caller can
/// render the new state without a follow-up GET.
///
/// Despite CLAUDE.md noting "Toggle via SQL; there is intentionally no
/// HTTP endpoint for library mutation", we now expose this for Apollo's
/// Settings panel. The single-user trust model hasn't changed; the
/// endpoint just removes the SSH-and-sqlite3 step.
#[patch("/libraries/{id}")]
pub async fn patch_library(
_claims: Claims,
path: web::Path<i32>,
body: web::Json<PatchLibraryBody>,
app_state: Data<AppState>,
) -> impl Responder {
let lib_id = path.into_inner();
let body = body.into_inner();
if body.enabled.is_none() && body.excluded_dirs.is_none() {
return HttpResponse::UnprocessableEntity().body("empty patch body");
}
let mut conn = crate::database::connect();
// Build the SET clause. Diesel's set() takes a tuple of assignments;
// we apply each field independently so an absent field doesn't get
// forced to NULL / its default.
let mut affected = 0usize;
if let Some(enabled) = body.enabled {
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
.set(libraries::enabled.eq(enabled))
.execute(&mut conn)
{
Ok(n) => affected = affected.max(n),
Err(e) => {
warn!("PATCH /libraries/{}: enabled update failed: {:?}", lib_id, e);
return HttpResponse::InternalServerError().body(format!("{}", e));
}
}
}
if let Some(raw) = body.excluded_dirs.as_deref() {
// Canonicalise on write — trim, dedupe, validate, drop empties —
// so the DB stores a round-trip-stable form regardless of how
// messy the user typed it. Empty / whitespace-only → NULL
// (matches a never-set library). Validation failures (Windows
// backslash paths, drive letters, `..` traversal, etc.) bounce
// back as 422 so the operator can fix the typo.
let normalised = match normalize_excluded_dirs_input(raw) {
Ok(v) => v,
Err(msg) => return HttpResponse::UnprocessableEntity().body(msg),
};
let stored: Option<&str> = normalised.as_deref();
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
.set(libraries::excluded_dirs.eq(stored))
.execute(&mut conn)
{
Ok(n) => affected = affected.max(n),
Err(e) => {
warn!(
"PATCH /libraries/{}: excluded_dirs update failed: {:?}",
lib_id, e
);
return HttpResponse::InternalServerError().body(format!("{}", e));
}
}
}
if affected == 0 {
return HttpResponse::NotFound().body(format!("library id {} not found", lib_id));
}
// Refresh the live view from the canonical DB state. Reloading the
// whole table (rather than mutating one entry in place) is cheap
// (handful of rows) and keeps the in-memory and DB views trivially
// consistent.
let fresh = load_all(&mut conn);
let updated = fresh.iter().find(|l| l.id == lib_id).cloned();
{
let mut live = app_state
.live_libraries
.write()
.unwrap_or_else(|e| e.into_inner());
*live = fresh;
}
match updated {
Some(lib) => {
info!(
"PATCH /libraries/{}: enabled={:?} excluded_dirs={:?} → applied",
lib_id, body.enabled, body.excluded_dirs
);
HttpResponse::Ok().json(lib)
}
None => HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::test::in_memory_db_connection;
#[test]
fn seed_patches_placeholder() {
let mut conn = in_memory_db_connection();
// Migration seeds one row with the placeholder.
seed_or_patch_from_env(&mut conn, "/tmp/media");
let libs = load_all(&mut conn);
assert_eq!(libs.len(), 1);
assert_eq!(libs[0].id, 1);
assert_eq!(libs[0].name, "main");
assert_eq!(libs[0].root_path, "/tmp/media");
}
#[test]
fn seed_is_idempotent() {
let mut conn = in_memory_db_connection();
seed_or_patch_from_env(&mut conn, "/tmp/media");
seed_or_patch_from_env(&mut conn, "/tmp/other");
// Second call should not overwrite an already-patched row.
let libs = load_all(&mut conn);
assert_eq!(libs.len(), 1);
assert_eq!(libs[0].root_path, "/tmp/media");
}
#[test]
fn library_strip_root() {
let lib = Library {
id: 1,
name: "main".into(),
root_path: "/tmp/media".into(),
enabled: true,
excluded_dirs: Vec::new(),
};
let rel = lib.strip_root(Path::new("/tmp/media/2024/photo.jpg"));
assert_eq!(rel.as_deref(), Some("2024/photo.jpg"));
let outside = lib.strip_root(Path::new("/etc/passwd"));
assert!(outside.is_none());
}
#[test]
fn library_resolve_joins_under_root() {
let lib = Library {
id: 1,
name: "main".into(),
root_path: "/tmp/media".into(),
enabled: true,
excluded_dirs: Vec::new(),
};
let abs = lib.resolve("2024/photo.jpg");
assert_eq!(abs, PathBuf::from("/tmp/media/2024/photo.jpg"));
}
fn state_with_libraries(libs: Vec<Library>) -> AppState {
let mut state = AppState::test_state();
state.libraries = libs;
state
}
fn sample_libraries() -> Vec<Library> {
vec![
Library {
id: 1,
name: "main".into(),
root_path: "/tmp/main".into(),
enabled: true,
excluded_dirs: Vec::new(),
},
Library {
id: 7,
name: "archive".into(),
root_path: "/tmp/archive".into(),
enabled: true,
excluded_dirs: Vec::new(),
},
]
}
#[actix_rt::test]
async fn resolve_library_param_absent_is_union() {
let state = state_with_libraries(sample_libraries());
assert!(matches!(resolve_library_param(&state, None), Ok(None)));
}
#[actix_rt::test]
async fn resolve_library_param_empty_or_whitespace_is_union() {
let state = state_with_libraries(sample_libraries());
assert!(matches!(resolve_library_param(&state, Some("")), Ok(None)));
assert!(matches!(
resolve_library_param(&state, Some(" ")),
Ok(None)
));
}
#[actix_rt::test]
async fn resolve_library_param_numeric_id_matches() {
let state = state_with_libraries(sample_libraries());
let lib = resolve_library_param(&state, Some("7"))
.expect("valid id")
.expect("some library");
assert_eq!(lib.id, 7);
assert_eq!(lib.name, "archive");
}
#[actix_rt::test]
async fn resolve_library_param_name_matches() {
let state = state_with_libraries(sample_libraries());
let lib = resolve_library_param(&state, Some("main"))
.expect("valid name")
.expect("some library");
assert_eq!(lib.id, 1);
}
#[actix_rt::test]
async fn resolve_library_param_unknown_id_errs() {
let state = state_with_libraries(sample_libraries());
let err = resolve_library_param(&state, Some("999")).unwrap_err();
assert!(err.contains("unknown library id"));
}
#[actix_rt::test]
async fn resolve_library_param_unknown_name_errs() {
let state = state_with_libraries(sample_libraries());
let err = resolve_library_param(&state, Some("missing")).unwrap_err();
assert!(err.contains("unknown library name"));
}
#[test]
fn parse_excluded_dirs_column_handles_null_and_whitespace() {
assert_eq!(parse_excluded_dirs_column(None), Vec::<String>::new());
assert_eq!(parse_excluded_dirs_column(Some("")), Vec::<String>::new());
assert_eq!(
parse_excluded_dirs_column(Some(" /a , /b/sub , @eaDir ,, ")),
vec!["/a".to_string(), "/b/sub".to_string(), "@eaDir".to_string()]
);
}
#[test]
fn effective_excluded_dirs_unions_global_and_per_library() {
let lib_no_extras = Library {
id: 1,
name: "main".into(),
root_path: "/x".into(),
enabled: true,
excluded_dirs: Vec::new(),
};
let globals = vec!["@eaDir".to_string(), ".thumbnails".to_string()];
// Empty per-library excludes → exactly the globals.
assert_eq!(lib_no_extras.effective_excluded_dirs(&globals), globals);
let lib_with_extras = Library {
id: 2,
name: "archive".into(),
root_path: "/y".into(),
enabled: true,
excluded_dirs: vec!["/photos".to_string()],
};
let combined = lib_with_extras.effective_excluded_dirs(&globals);
assert!(combined.contains(&"@eaDir".to_string()));
assert!(combined.contains(&".thumbnails".to_string()));
assert!(combined.contains(&"/photos".to_string()));
assert_eq!(combined.len(), 3);
}
#[test]
fn effective_excluded_dirs_keeps_overlap_between_global_and_per_library() {
// Two sources both excluding `@eaDir` is legal — `PathExcluder`
// accepts repeats, and there's no behavioral reason to dedupe
// here. Documents the design choice so a future refactor that
// tightens this is forced to update both code and tests.
let globals = vec!["@eaDir".to_string()];
let lib = Library {
id: 1,
name: "main".into(),
root_path: "/x".into(),
enabled: true,
excluded_dirs: vec!["@eaDir".to_string(), "/private".to_string()],
};
let combined = lib.effective_excluded_dirs(&globals);
// 2 occurrences of @eaDir + /private = 3 entries total.
assert_eq!(combined, vec!["@eaDir", "@eaDir", "/private"]);
}
#[test]
fn normalize_excluded_dirs_input_handles_empty_and_whitespace() {
assert_eq!(normalize_excluded_dirs_input(""), Ok(None));
assert_eq!(normalize_excluded_dirs_input(" "), Ok(None));
assert_eq!(normalize_excluded_dirs_input(",,,"), Ok(None));
assert_eq!(normalize_excluded_dirs_input(" , , "), Ok(None));
}
#[test]
fn normalize_excluded_dirs_input_trims_per_entry() {
// Inner whitespace stripped on each item, comma-joined without
// spaces. Mirrors how parse_excluded_dirs_column reads it back.
assert_eq!(
normalize_excluded_dirs_input(" @eaDir , /private , .thumbnails "),
Ok(Some("@eaDir,/private,.thumbnails".to_string()))
);
}
#[test]
fn normalize_excluded_dirs_input_dedupes_preserving_first_occurrence() {
// Exact-string duplicates collapse; the first occurrence wins
// (preserves the operator's typed order so they recognise their
// intent on round-trip).
assert_eq!(
normalize_excluded_dirs_input("@eaDir, /private, @eaDir, /private"),
Ok(Some("@eaDir,/private".to_string()))
);
// Whitespace-distinct entries collapse to the same canonical
// form. Case is preserved — `Foo` and `foo` are different keys
// (filesystem case-sensitivity is platform-dependent; we don't
// make that call here).
assert_eq!(
normalize_excluded_dirs_input(" Foo,foo, Foo "),
Ok(Some("Foo,foo".to_string()))
);
}
#[test]
fn normalize_excluded_dirs_input_is_round_trip_stable() {
// Writing the normaliser's output back through it yields the
// same string. PATCH-clearing edits round-trip cleanly through
// parse_excluded_dirs_column too.
let raw = " /a/b ,, /a/b , c ";
let once = normalize_excluded_dirs_input(raw)
.expect("validation passes")
.expect("not empty");
let twice = normalize_excluded_dirs_input(&once)
.expect("validation passes")
.expect("not empty");
assert_eq!(once, twice);
// Parsing the stored form back gives the deduped Vec.
assert_eq!(
parse_excluded_dirs_column(Some(&once)),
vec!["/a/b".to_string(), "c".to_string()]
);
}
#[test]
fn validate_rejects_backslash_paths() {
// Windows-typed entries land in the component-pattern bucket
// and never match — reject so the user gets feedback instead
// of a silent no-op.
assert!(validate_excluded_dirs_entry(r"\photos").is_err());
assert!(validate_excluded_dirs_entry(r"photos\2024").is_err());
assert!(validate_excluded_dirs_entry(r"\\server\share").is_err());
// The error message names the entry and points at the fix.
let err = validate_excluded_dirs_entry(r"\photos").unwrap_err();
assert!(err.contains("forward slashes"), "{}", err);
}
#[test]
fn validate_rejects_windows_drive_letters() {
assert!(validate_excluded_dirs_entry("Z:/photos").is_err());
assert!(validate_excluded_dirs_entry("z:photos").is_err());
// Single-letter alpha + colon is the canonical drive prefix;
// the message should steer toward the relative form.
let err = validate_excluded_dirs_entry("Z:/foo").unwrap_err();
assert!(err.contains("relative to the library root"), "{}", err);
}
#[test]
fn validate_rejects_multi_segment_name_without_leading_slash() {
// The common "I forgot the slash" typo. Today this would store
// a never-matching component pattern; we catch it.
let err = validate_excluded_dirs_entry("photos/2024").unwrap_err();
assert!(err.contains("multi-segment"), "{}", err);
// And the suggestion shows the corrected form.
assert!(err.contains("/photos/2024"), "{}", err);
}
#[test]
fn validate_rejects_parent_dir_traversal_in_path_entries() {
// base.join("../sensitive") doesn't canonicalise, so the
// resulting prefix never starts_with anything the walker sees.
assert!(validate_excluded_dirs_entry("/../secret").is_err());
assert!(validate_excluded_dirs_entry("/photos/../keys").is_err());
// Same string as a non-leading-slash component is fine — it
// just never matches (you'd literally need a directory named
// `..` which is impossible on every filesystem we care about),
// but the validator accepts it because the failure mode isn't
// a silent footgun in that direction.
assert!(validate_excluded_dirs_entry("..").is_ok());
}
#[test]
fn validate_strips_trailing_slash_on_path_entries() {
assert_eq!(
validate_excluded_dirs_entry("/photos/").unwrap(),
"/photos"
);
assert_eq!(
validate_excluded_dirs_entry("/photos//").unwrap(),
"/photos"
);
// Bare "/" is rejected — almost certainly a typo for the
// library root.
assert!(validate_excluded_dirs_entry("/").is_err());
assert!(validate_excluded_dirs_entry("///").is_err());
}
#[test]
fn validate_passes_valid_entries() {
for entry in &[
"/photos",
"/photos/2024",
"/media/raw/private",
"@eaDir",
".thumbnails",
".DS_Store",
"node_modules",
] {
assert!(
validate_excluded_dirs_entry(entry).is_ok(),
"expected {} to pass",
entry
);
}
}
#[test]
fn normalize_aborts_on_invalid_entry() {
// One bad entry kills the whole patch — better to surface the
// problem than to silently apply N-1 of N changes.
let err = normalize_excluded_dirs_input("/photos, photos/2024").unwrap_err();
assert!(err.contains("photos/2024"), "{}", err);
// A valid mix succeeds — the bad-entry test isn't accidentally
// matching the good prefix.
assert_eq!(
normalize_excluded_dirs_input("/photos, @eaDir, /private/"),
Ok(Some("/photos,@eaDir,/private".to_string()))
);
}
fn probe_lib(id: i32, root: String) -> Library {
Library {
id,
name: "main".into(),
root_path: root,
enabled: true,
excluded_dirs: Vec::new(),
}
}
#[test]
fn probe_online_for_existing_non_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("photo.jpg"), b"hello").unwrap();
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
// had_data doesn't matter when the dir has entries.
assert!(probe_online(&lib, true).is_online());
assert!(probe_online(&lib, false).is_online());
}
#[test]
fn probe_stale_when_root_missing() {
let lib = probe_lib(1, "/nonexistent/definitely/not/here".into());
assert!(matches!(
probe_online(&lib, false),
LibraryHealth::Stale { .. }
));
}
#[test]
fn probe_stale_when_root_is_a_file() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
let lib = probe_lib(1, file.to_string_lossy().into());
assert!(matches!(
probe_online(&lib, false),
LibraryHealth::Stale { .. }
));
}
#[test]
fn probe_empty_dir_is_online_when_no_prior_data() {
// Fresh mount: empty directory, no rows in image_exif. Accept it.
let tmp = tempfile::tempdir().unwrap();
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
assert!(probe_online(&lib, false).is_online());
}
#[test]
fn probe_empty_dir_is_stale_when_prior_data_existed() {
// The "share went offline" signal: directory exists but is empty,
// and we know the library used to have content. Treat as Stale.
let tmp = tempfile::tempdir().unwrap();
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
match probe_online(&lib, true) {
LibraryHealth::Stale { reason, .. } => {
assert!(reason.contains("empty"), "unexpected reason: {}", reason)
}
other => panic!("expected Stale, got {:?}", other),
}
}
#[test]
fn refresh_health_logs_only_on_transition() {
// Smoke test: refresh_health updates the map and reports correctly.
// (We can't easily assert on logs without a custom logger; the
// important thing is that the state churns properly.)
let tmp = tempfile::tempdir().unwrap();
let lib = Library {
id: 42,
name: "test".into(),
root_path: tmp.path().to_string_lossy().into(),
enabled: true,
excluded_dirs: Vec::new(),
};
let map = new_health_map(&[lib.clone()]);
// First probe: empty dir, no prior data — Online.
let s1 = refresh_health(&map, &lib, false);
assert!(s1.is_online());
// Probe again with had_data=true on the same empty dir — Stale.
let s2 = refresh_health(&map, &lib, true);
assert!(matches!(s2, LibraryHealth::Stale { .. }));
assert_eq!(
map.read().unwrap().get(&lib.id).cloned(),
Some(s2.clone()),
"map should reflect the latest probe"
);
// Recovery: drop a file and probe again.
std::fs::write(tmp.path().join("photo.jpg"), b"x").unwrap();
let s3 = refresh_health(&map, &lib, true);
assert!(s3.is_online());
}
}