use crate::io_timeout::STAT_TIMEOUT;
use crate::library::{bounded_op, LibraryContext};
use anyhow::{bail, Result};
use std::collections::VecDeque;
use std::ffi::{OsStr, OsString};
use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};
use std::time::Duration;
const MAX_LINK_EXPANSIONS: usize = 40;
#[cfg(test)]
thread_local! {
static RESOLUTIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn count_resolutions() -> usize {
RESOLUTIONS.with(std::cell::Cell::get)
}
#[cfg(test)]
fn note_resolution() {
RESOLUTIONS.with(|c| c.set(c.get() + 1));
}
pub(crate) fn rooted(ctx: &LibraryContext, raw: &Path) -> PathBuf {
if raw.is_absolute() {
raw.to_path_buf()
} else {
ctx.paths.root.join(raw)
}
}
pub(crate) fn resolve_in_root(ctx: &LibraryContext, candidate: &Path) -> Result<PathBuf> {
let resolved = resolve_allow_missing(candidate)?;
if !resolved.starts_with(&ctx.paths.root) {
bail!(
"path {} resolves to {}, which is outside library {}; paths must name the library itself or a path inside it",
candidate.display(),
resolved.display(),
ctx.paths.root.display()
);
}
Ok(resolved)
}
pub fn validate_paths(ctx: &LibraryContext, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut forms = Vec::with_capacity(paths.len() * 2);
for raw in paths {
let candidate = rooted(ctx, raw);
let resolved = resolve_in_root(ctx, &candidate)?;
forms.push(candidate.clone());
if resolved != candidate {
forms.push(resolved);
}
}
forms.sort();
forms.dedup();
Ok(forms)
}
fn resolve_allow_missing(candidate: &Path) -> Result<PathBuf> {
#[cfg(test)]
{
note_resolution();
}
resolve_budgeted(candidate, STAT_TIMEOUT)
}
fn resolve_budgeted(candidate: &Path, budget: Duration) -> Result<PathBuf> {
let owned = candidate.to_path_buf();
bounded_op(candidate, "resolve", budget, move || walk(&owned))
}
fn steps(path: &Path, from_link: bool) -> Vec<(OsString, bool)> {
path.components()
.filter_map(|c| match c {
Component::Normal(name) => Some((name.to_os_string(), from_link)),
Component::ParentDir => Some((OsString::from(".."), from_link)),
Component::CurDir => None,
Component::RootDir | Component::Prefix(_) => None,
})
.collect()
}
fn walk(path: &Path) -> std::io::Result<PathBuf> {
let mut resolved = PathBuf::from("/");
let mut pending: VecDeque<(OsString, bool)> = steps(path, false).into_iter().collect();
let mut expansions = 0usize;
while let Some((name, from_link)) = pending.pop_front() {
if name.as_os_str() == OsStr::new("..") {
resolved.pop();
continue;
}
let step = resolved.join(&name);
let meta = match std::fs::symlink_metadata(&step) {
Ok(meta) => meta,
Err(e) if e.kind() == ErrorKind::NotFound => {
if from_link {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!(
"dangling symbolic link on the way to {}: {} does not exist",
path.display(),
step.display()
),
));
}
resolved.push(name);
for (rest, _) in pending.drain(..) {
if rest.as_os_str() == OsStr::new("..") {
resolved.pop();
} else {
resolved.push(rest);
}
}
return Ok(resolved);
}
Err(e) => return Err(e),
};
if meta.file_type().is_symlink() {
expansions += 1;
if expansions > MAX_LINK_EXPANSIONS {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!(
"resolving {} crosses more than {MAX_LINK_EXPANSIONS} symbolic links",
path.display()
),
));
}
let target = std::fs::read_link(&step)?;
if target.is_absolute() {
resolved = PathBuf::from("/");
}
for (expanded, _) in steps(&target, true).into_iter().rev() {
pending.push_front((expanded, true));
}
} else {
resolved = step;
}
}
Ok(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library_test_support::write_past_test_capture;
use std::os::unix::fs::PermissionsExt;
fn library() -> (tempfile::TempDir, LibraryContext) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
(temp, ctx)
}
#[test]
fn mixed_paths_reject_the_entire_selection() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir_all(root.join("Trips")).unwrap();
let ctx = crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
assert!(validate_paths(&ctx, &[std::path::PathBuf::from("Trips")]).is_ok());
assert!(validate_paths(&ctx, &[std::path::PathBuf::from("missing")]).is_ok());
assert!(validate_paths(
&ctx,
&[
std::path::PathBuf::from("Trips"),
std::path::PathBuf::from("../outside")
]
)
.is_err());
assert!(!ctx.paths.state.exists());
}
#[test]
fn membership_is_decided_physically_and_component_wise() {
struct Case {
name: &'static str,
setup: fn(&Path, &Path),
filter: fn(&Path, &Path) -> PathBuf,
ok: bool,
}
let cases = vec![
Case {
name: "the library itself, spelled relatively as .",
setup: |_, _| {},
filter: |_, _| PathBuf::from("."),
ok: true,
},
Case {
name: "relative child with spaces and Turkish characters",
setup: |_, root| {
std::fs::create_dir_all(root.join("Trips/FotoÄŸraflar 2024")).unwrap()
},
filter: |_, _| PathBuf::from("Trips/FotoÄŸraflar 2024"),
ok: true,
},
Case {
name: "absolute child",
setup: |_, root| std::fs::create_dir(root.join("Trips")).unwrap(),
filter: |_, root| root.join("Trips"),
ok: true,
},
Case {
name: "the root reached through an alias",
setup: |temp, root| std::os::unix::fs::symlink(root, temp.join("alias")).unwrap(),
filter: |temp, _| temp.join("alias"),
ok: true,
},
Case {
name: "an alias to the root then a child",
setup: |temp, root| std::os::unix::fs::symlink(root, temp.join("alias")).unwrap(),
filter: |temp, _| temp.join("alias").join("Trips"),
ok: true,
},
Case {
name: "sibling sharing the root as a string prefix",
setup: |temp, _| std::fs::create_dir(temp.join("photos-old")).unwrap(),
filter: |temp, _| temp.join("photos-old"),
ok: false,
},
Case {
name: "absolute path outside the library",
setup: |temp, _| std::fs::create_dir(temp.join("elsewhere")).unwrap(),
filter: |temp, _| temp.join("elsewhere"),
ok: false,
},
Case {
name: "relative parent traversal",
setup: |temp, _| std::fs::create_dir(temp.join("outside")).unwrap(),
filter: |_, _| PathBuf::from("../outside"),
ok: false,
},
Case {
name: "absolute parent traversal",
setup: |temp, _| std::fs::create_dir(temp.join("outside")).unwrap(),
filter: |_, root| root.join("../outside"),
ok: false,
},
Case {
name: "the library's own parent",
setup: |_, _| {},
filter: |_, root| root.join(".."),
ok: false,
},
Case {
name: "leaving and re-entering the root by name",
setup: |_, _| {},
filter: |_, _| PathBuf::from("../photos"),
ok: true,
},
Case {
name: "in-root symlink pointing outside",
setup: |temp, root| {
let outside = temp.join("elsewhere");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, root.join("evil")).unwrap()
},
filter: |_, _| PathBuf::from("evil"),
ok: false,
},
Case {
name: "in-root symlink pointing inside",
setup: |_, root| {
std::fs::create_dir(root.join("Trips")).unwrap();
std::os::unix::fs::symlink(root.join("Trips"), root.join("trips-alias"))
.unwrap()
},
filter: |_, _| PathBuf::from("trips-alias"),
ok: true,
},
Case {
name: "symlink then dotdot resolves physically, not lexically",
setup: |temp, root| {
let outside = temp.join("elsewhere");
std::fs::create_dir_all(outside.join("inner")).unwrap();
std::os::unix::fs::symlink(&outside.join("inner"), root.join("jump")).unwrap()
},
filter: |_, _| PathBuf::from("jump/.."),
ok: false,
},
Case {
name: "missing suffix under an existing directory",
setup: |_, root| std::fs::create_dir(root.join("Trips")).unwrap(),
filter: |_, _| PathBuf::from("Trips/gone"),
ok: true,
},
Case {
name: "missing suffix several components deep",
setup: |_, _| {},
filter: |_, _| PathBuf::from("gone/deeper/still"),
ok: true,
},
Case {
name: "missing sibling sharing the root as a string prefix",
setup: |_, _| {},
filter: |temp, _| temp.join("photos-gone"),
ok: false,
},
Case {
name: "dangling symlink with a relative target",
setup: |_, root| {
std::os::unix::fs::symlink(root.join("gone-target"), root.join("dangle"))
.unwrap()
},
filter: |_, _| PathBuf::from("dangle"),
ok: false,
},
Case {
name: "dangling symlink with an absolute outside target",
setup: |temp, root| {
std::os::unix::fs::symlink(temp.join("nonexistent-target"), root.join("dangle"))
.unwrap()
},
filter: |_, _| PathBuf::from("dangle"),
ok: false,
},
Case {
name: "symlink loop",
setup: |_, root| std::os::unix::fs::symlink("loop", root.join("loop")).unwrap(),
filter: |_, _| PathBuf::from("loop"),
ok: false,
},
];
for case in cases {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
std::fs::create_dir(&root).unwrap();
let ctx = LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
let canonical = ctx.paths.root.clone();
(case.setup)(&temp.path(), &canonical);
let filter = (case.filter)(&temp.path(), &canonical);
let got = validate_paths(&ctx, std::slice::from_ref(&filter));
assert_eq!(
got.is_ok(),
case.ok,
"case {:?} (filter {:?}): {:?}",
case.name,
filter,
got.map_err(|e| format!("{e:#}"))
);
}
}
#[test]
fn one_invalid_filter_rejects_the_valid_ones_in_either_order() {
let (temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
let outside = temp.path().join("elsewhere");
std::fs::create_dir(&outside).unwrap();
for order in [
vec![PathBuf::from("Trips"), outside.clone()],
vec![outside.clone(), PathBuf::from("Trips")],
] {
let err = validate_paths(&ctx, &order).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("outside library"), "{msg}");
assert!(msg.contains("elsewhere"), "{msg}");
assert!(msg.contains(&ctx.paths.root.display().to_string()), "{msg}");
}
assert!(!ctx.paths.state.exists());
}
#[test]
fn forms_keep_the_given_spelling_alongside_the_resolved_one() {
let (_temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
std::os::unix::fs::symlink(
ctx.paths.root.join("Trips"),
ctx.paths.root.join("trips-alias"),
)
.unwrap();
let forms = validate_paths(&ctx, &[PathBuf::from("trips-alias")]).unwrap();
assert_eq!(
forms,
vec![
ctx.paths.root.join("Trips"),
ctx.paths.root.join("trips-alias")
]
);
let forms = validate_paths(&ctx, &[PathBuf::from("Trips")]).unwrap();
assert_eq!(forms, vec![ctx.paths.root.join("Trips")]);
let forms =
validate_paths(&ctx, &[PathBuf::from("Trips"), PathBuf::from("Trips")]).unwrap();
assert_eq!(forms, vec![ctx.paths.root.join("Trips")]);
}
#[test]
fn one_resolution_per_supplied_filter_and_none_for_none() {
let (_temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
std::fs::create_dir(ctx.paths.root.join("2024")).unwrap();
let before = count_resolutions();
validate_paths(
&ctx,
&[
PathBuf::from("Trips"),
ctx.paths.root.join("2024"),
PathBuf::from("Trips"),
],
)
.unwrap();
assert_eq!(
count_resolutions() - before,
3,
"three supplied filters, three resolutions, duplicates included"
);
let before = count_resolutions();
assert!(validate_paths(&ctx, &[]).unwrap().is_empty());
assert_eq!(count_resolutions(), before);
}
#[test]
fn an_unsearchable_directory_fails_closed() {
let (temp, ctx) = library();
let locked = ctx.paths.root.join("locked");
std::fs::create_dir(&locked).unwrap();
let probe = temp.path().join("probe");
std::fs::write(&probe, b"x").unwrap();
std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&probe).is_ok() {
write_past_test_capture(
"SKIP: running as root, so chmod 000 does not block searching a directory\n",
);
return;
}
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let err = validate_paths(&ctx, &[PathBuf::from("locked/inside")]).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("locked"), "{msg}");
assert!(msg.contains("denied"), "{msg}");
let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
}
#[test]
fn a_resolution_past_its_budget_is_cut_off_and_names_the_path() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path().join("a");
std::fs::create_dir_all(&dir).unwrap();
resolve_budgeted(&dir, Duration::from_secs(5)).unwrap();
let owned = dir.clone();
let start = std::time::Instant::now();
let err =
crate::library::bounded_op(&dir, "resolve", Duration::from_millis(50), move || {
std::thread::sleep(Duration::from_secs(5));
walk(&owned).map(|_| ())
})
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("did not respond"), "{msg}");
assert!(msg.contains(&dir.display().to_string()), "{msg}");
assert!(start.elapsed() < Duration::from_secs(2));
}
}