faces: fix PathExcluder false-positive + cover face_client/crop in tests

PathExcluder was iterating every component of the absolute path,
including the system prefix. Two of the existing memories tests had
been failing on master because tempdir() lives under /tmp on Linux
and a pattern like "tmp" then matched the system /tmp component
rather than anything the user actually asked to exclude. Phase 3's
file-watch hook will use the same code to skip @eaDir / .thumbnails
under each library's BASE_PATH, so the bug would hide every photo
on a host whose BASE_PATH passes through a directory named the same
as a user pattern.

Fix: store base in PathExcluder and strip it before scanning
components. A path that lives outside base falls through to the
no-match branch (defensive — nothing legit hits that today).

Also extracted the face_client error classification into a pure
classify_error_response(status, body) so the marker-row contract
with Apollo (422 → Permanent / 'failed', 5xx → Transient / defer)
is unit-testable without spinning up an HTTP server.

New tests:
  memories::tests::test_path_excluder_*           — 2 previously
    failing tests now pass.
  ai::face_client::tests::classify_*              — 4 cases:
    422 decode_failed → Permanent, 503 cuda_oom → Transient
    (handles both string and {code:..} detail shapes), 5xx →
    Transient + other 4xx → Permanent, unparseable HTML body still
    classifies on status.
  faces::tests::crop_*                            — 3 cases:
    invalid bbox rejected, valid bbox round-trips through JPEG
    decode, corner crop with 10% padding clamps inside source.

cargo test --lib: 165 passed / 0 failed (was 156 / 2 failed).
cargo fmt and clippy on new code clean. The remaining
sort_by clippy warnings in pre-existing files (memories.rs,
files.rs, exif.rs) are unrelated and present on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-04-29 18:09:44 +00:00
parent 860169032b
commit f77e44b34d
3 changed files with 200 additions and 71 deletions

View File

@@ -23,7 +23,8 @@ use crate::utils::earliest_fs_time;
// Helper that encapsulates path-exclusion semantics
#[derive(Debug)]
struct PathExcluder {
pub struct PathExcluder {
base: PathBuf,
excluded_dirs: Vec<PathBuf>,
excluded_patterns: Vec<String>,
}
@@ -34,9 +35,12 @@ impl PathExcluder {
/// Rules:
/// - Entries starting with '/' are interpreted as "absolute under base"
/// (e.g. "/photos/private" -> base/photos/private).
/// - Entries without '/' are treated as substring patterns that match
/// anywhere in the full path string (still scoped under base).
fn new(base: &Path, raw_excluded: &[String]) -> Self {
/// - Entries without '/' are treated as path-component patterns that
/// match a directory or file name *under* `base`. The base prefix is
/// stripped before matching so a system-level component (e.g. the
/// `tmp` in `/tmp/...` when running tests) doesn't masquerade as a
/// user-defined exclude.
pub fn new(base: &Path, raw_excluded: &[String]) -> Self {
let mut excluded_dirs = Vec::new();
let mut excluded_patterns = Vec::new();
@@ -53,18 +57,19 @@ impl PathExcluder {
}
debug!(
"PathExcluder created. dirs={:?}, patterns={:?}",
excluded_dirs, excluded_patterns
"PathExcluder created. base={:?}, dirs={:?}, patterns={:?}",
base, excluded_dirs, excluded_patterns
);
Self {
base: base.to_path_buf(),
excluded_dirs,
excluded_patterns,
}
}
/// Returns true if `path` should be excluded.
fn is_excluded(&self, path: &Path) -> bool {
pub fn is_excluded(&self, path: &Path) -> bool {
// Directory-based exclusions
for excluded in &self.excluded_dirs {
if path.starts_with(excluded) {
@@ -76,19 +81,24 @@ impl PathExcluder {
}
}
// Pattern-based exclusions: match whole path components (dir or file name),
// not substrings.
if !self.excluded_patterns.is_empty() {
for component in path.components() {
if let Some(comp_str) = component.as_os_str().to_str()
&& self.excluded_patterns.iter().any(|pat| pat == comp_str)
{
trace!(
"PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})",
path, comp_str, self.excluded_patterns
);
return true;
}
if self.excluded_patterns.is_empty() {
return false;
}
// Strip the base prefix before scanning components. Without this,
// every path component above `base` (e.g. `tmp` in `/tmp/test123`
// under tempdir, or the user's `home` in `/home/user/Pictures`)
// would match user-defined patterns and produce false positives.
let scan_root = path.strip_prefix(&self.base).unwrap_or(path);
for component in scan_root.components() {
if let Some(comp_str) = component.as_os_str().to_str()
&& self.excluded_patterns.iter().any(|pat| pat == comp_str)
{
trace!(
"PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})",
path, comp_str, self.excluded_patterns
);
return true;
}
}