use crate::io_timeout::{min_read_rate_mb_s, timeout_for_size, STAT_TIMEOUT};
use crate::library::{bounded_op, root_cause_is_not_found, LibraryContext, STATE_DIR};
use anyhow::{bail, Context, Result};
use rustix::fs::{openat, renameat, statat, unlinkat, AtFlags, FileType, Mode, OFlags};
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
const TEMP_ATTEMPTS: usize = 3;
pub fn open_media(ctx: &LibraryContext, path: &Path) -> Result<File> {
ctx.ensure_root_identity()?;
let candidate = crate::library_guard::rooted(ctx, path);
let resolved = crate::library_guard::resolve_in_root(ctx, &candidate)?;
if resolved == ctx.paths.root {
bail!(
"the library root {} itself is not a media file",
resolved.display()
);
}
let name = resolved.file_name().ok_or_else(|| {
anyhow::anyhow!(
"{} does not name a file inside library {}",
path.display(),
ctx.paths.root.display()
)
})?;
let parent = anchored_directory(ctx, resolved.parent().unwrap_or(Path::new("/")))?;
open_media_file(&parent, name, &resolved)
}
pub fn replace_sidecar(ctx: &LibraryContext, target: &Path, bytes: &[u8]) -> Result<()> {
ctx.ensure_root_identity()?;
let candidate = crate::library_guard::rooted(ctx, target);
let Some(name) = candidate.file_name().map(OsStr::to_owned) else {
bail!(
"sidecar target {} does not name a file inside library {}",
target.display(),
ctx.paths.root.display()
);
};
let parent_given = candidate.parent().unwrap_or(Path::new(""));
let resolved_parent = crate::library_guard::resolve_in_root(ctx, parent_given)?;
refuse_state_dir(&resolved_parent, &name, &ctx.paths.root)?;
let display = resolved_parent.join(&name);
let parent = anchored_directory(ctx, &resolved_parent)?;
match name_kind(&parent, &name, &display)? {
Some(FileType::Symlink) => bail!(
"{} is a symbolic link; delete the link or choose another target, videre never writes through one",
display.display()
),
Some(FileType::Directory) => bail!(
"{} is a directory, so it cannot be a sidecar",
display.display()
),
Some(kind) if !kind.is_file() => bail!(
"{} is not a regular file, so it cannot be a sidecar",
display.display()
),
_ => {}
}
let temp = write_temporary(&parent, bytes, &display)?;
if let Err(e) = ctx.ensure_root_identity() {
discard_temporary(&parent, &temp, &display);
return Err(e);
}
if let Err(e) = publish(&parent, &temp, &name, &display) {
discard_temporary(&parent, &temp, &display);
return Err(e);
}
Ok(())
}
fn anchored_directory(ctx: &LibraryContext, dir: &Path) -> Result<File> {
let rel = dir.strip_prefix(&ctx.paths.root).with_context(|| {
format!(
"{} is not inside library {}",
dir.display(),
ctx.paths.root.display()
)
})?;
let mut handle = dupe(ctx.root_handle(), &ctx.paths.root)?;
let mut walked = ctx.paths.root.clone();
for component in rel.components() {
match component {
Component::Normal(name) => {
walked.push(name);
handle = open_child_directory(&handle, name, &walked)?;
}
Component::CurDir => {}
_ => bail!(
"{} must be a resolved path inside the library",
dir.display()
),
}
}
Ok(handle)
}
fn open_child_directory(parent: &File, name: &OsStr, display: &Path) -> Result<File> {
let dup = dupe(parent, display)?;
let name = name.to_os_string();
let for_classifying = name.clone();
let oflags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
match bounded_op(display, "open", STAT_TIMEOUT, move || {
openat(&dup, &name, oflags, Mode::empty())
.map(File::from)
.map_err(std::io::Error::from)
}) {
Ok(file) => Ok(file),
Err(e) => Err(refuse_if_link(e, parent, &for_classifying, display)),
}
}
fn open_media_file(parent: &File, name: &OsStr, display: &Path) -> Result<File> {
let dup = dupe(parent, display)?;
let name = name.to_os_string();
let for_classifying = name.clone();
let oflags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK;
let file = match bounded_op(display, "open", STAT_TIMEOUT, move || {
openat(&dup, &name, oflags, Mode::empty())
.map(File::from)
.map_err(std::io::Error::from)
}) {
Ok(file) => file,
Err(e) => {
return Err(refuse_if_link(
e,
parent,
&for_classifying.as_os_str(),
display,
))
}
};
let meta = {
let dup = dupe(&file, display)?;
bounded_op(display, "stat", STAT_TIMEOUT, move || dup.metadata())?
};
anyhow::ensure!(
meta.is_file(),
"{} is not a regular file",
display.display()
);
Ok(file)
}
fn name_kind(parent: &File, name: &OsStr, display: &Path) -> Result<Option<FileType>> {
let dup = dupe(parent, display)?;
let name = name.to_os_string();
match bounded_op(display, "read", STAT_TIMEOUT, move || {
statat(&dup, &name, AtFlags::SYMLINK_NOFOLLOW)
.map(|stat| FileType::from_raw_mode(stat.st_mode))
.map_err(std::io::Error::from)
}) {
Ok(kind) => Ok(Some(kind)),
Err(e) if root_cause_is_not_found(&e) => Ok(None),
Err(e) => Err(e),
}
}
fn refuse_state_dir(resolved_parent: &Path, name: &OsStr, root: &Path) -> Result<()> {
let under_state = resolved_parent
.strip_prefix(root)
.ok()
.and_then(|rel| rel.components().next())
.is_some_and(|first| first.as_os_str() == OsStr::new(STATE_DIR));
let shadows_state = resolved_parent == root && name == OsStr::new(STATE_DIR);
if under_state || shadows_state {
bail!(
"{} is reserved for videre's state directory, which is not a sidecar location",
resolved_parent.join(name).display()
);
}
Ok(())
}
fn write_temporary(parent: &File, bytes: &[u8], display: &Path) -> Result<OsString> {
for _ in 0..TEMP_ATTEMPTS {
let name = OsString::from(format!(
".videre-sidecar-{}-{}.tmp",
std::process::id(),
TMP_SEQ.fetch_add(1, Ordering::Relaxed)
));
let dup = dupe(parent, display)?;
let open_name = name.clone();
let bytes = bytes.to_vec();
let result = bounded_op(display, "write", STAT_TIMEOUT, move || {
let oflags =
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let mode = Mode::from_bits(0o644).expect("a plain permission triplet is always valid");
let mut file = File::from(openat(&dup, &open_name, oflags, mode)?);
std::io::Write::write_all(&mut file, &bytes)?;
file.sync_all()?;
Ok(())
});
match result {
Ok(()) => return Ok(name),
Err(e) if already_exists(&e) => continue,
Err(e) => {
discard_temporary(parent, &name, display);
return Err(e);
}
}
}
bail!(
"could not find an unused temporary name next to {}",
display.display()
)
}
fn publish(parent: &File, temp: &OsStr, name: &OsStr, display: &Path) -> Result<()> {
let rename = dupe(parent, display)?;
let temp = temp.to_os_string();
let name = name.to_os_string();
bounded_op(display, "publish", STAT_TIMEOUT, move || {
renameat(&rename, &temp, &rename, &name)?;
Ok(())
})?;
let sync = dupe(parent, display)?;
bounded_op(display, "sync", STAT_TIMEOUT, move || sync.sync_all())?;
Ok(())
}
fn discard_temporary(parent: &File, temp: &OsStr, display: &Path) {
let _ = (|| -> Result<()> {
let dup = dupe(parent, display)?;
let temp = temp.to_os_string();
bounded_op(display, "remove", STAT_TIMEOUT, move || {
unlinkat(&dup, &temp, AtFlags::empty())?;
Ok(())
})?;
Ok(())
})();
}
pub struct StagedCopy {
path: PathBuf,
parent: File,
name: OsString,
#[allow(dead_code)]
held: File,
}
impl StagedCopy {
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for StagedCopy {
fn drop(&mut self) {
discard_temporary(&self.parent, &self.name, &self.path);
}
}
pub fn staged_copy(ctx: &LibraryContext, source: &File, extension: &OsStr) -> Result<StagedCopy> {
ctx.ensure_root_identity()?;
let scratch = &ctx.paths.state;
let dir = anchored_directory(ctx, scratch)?;
let mut name = OsString::from(format!(
".videre-stage-{}-{}",
std::process::id(),
TMP_SEQ.fetch_add(1, Ordering::Relaxed)
));
if !extension.is_empty() {
name.push(".");
name.push(extension);
}
let path = scratch.join(&name);
let budget = {
let probe = dupe(source, scratch)?;
bounded_op(scratch, "stat", STAT_TIMEOUT, move || probe.metadata())
.map(|meta| timeout_for_size(meta.len(), min_read_rate_mb_s()))
.unwrap_or(STAT_TIMEOUT)
};
let open_name = name.clone();
let dup_dir = dupe(&dir, scratch)?;
let mut read = dupe(source, scratch)?;
let held = bounded_op(&path, "stage", budget, move || {
let oflags =
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let mode = Mode::from_bits(0o600).expect("a plain permission triplet is always valid");
let mut held = File::from(openat(&dup_dir, &open_name, oflags, mode)?);
std::io::Seek::seek(&mut read, std::io::SeekFrom::Start(0))?;
std::io::copy(&mut read, &mut held)?;
held.sync_all()?;
Ok(held)
})?;
Ok(StagedCopy {
path,
parent: dir,
name,
held,
})
}
fn dupe(file: &File, display: &Path) -> Result<File> {
file.try_clone()
.with_context(|| format!("dupe a handle on {}", display.display()))
}
fn refuse_if_link(
original: anyhow::Error,
parent: &File,
name: &OsStr,
display: &Path,
) -> anyhow::Error {
if matches!(
name_kind(parent, name, display),
Ok(Some(FileType::Symlink))
) {
anyhow::anyhow!(
"{} is a symbolic link, so the library changed after it was resolved; refusing to follow it",
display.display()
)
} else {
original
}
}
fn already_exists(e: &anyhow::Error) -> bool {
e.root_cause()
.downcast_ref::<std::io::Error>()
.is_some_and(|io| rustix::io::Errno::from_io_error(io) == Some(rustix::io::Errno::EXIST))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::LibraryContext;
use crate::library_test_support::write_past_test_capture;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::path::Path;
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)
}
fn read_all(mut file: File) -> Vec<u8> {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).unwrap();
bytes
}
#[test]
fn changed_descendant_link_cannot_redirect_a_sidecar() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("photos");
let outside = temp.path().join("outside");
std::fs::create_dir_all(root.join("inside")).unwrap();
std::fs::create_dir(&outside).unwrap();
let link = root.join("link");
std::os::unix::fs::symlink(root.join("inside"), &link).unwrap();
let ctx = crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
crate::library_guard::validate_paths(&ctx, &[link.clone()]).unwrap();
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&outside, &link).unwrap();
assert!(replace_sidecar(&ctx, &link.join("image.xmp"), b"private").is_err());
assert!(!outside.join("image.xmp").exists());
assert!(std::fs::read_dir(&outside).unwrap().next().is_none());
}
#[test]
fn the_walk_refuses_a_component_that_is_now_a_symlink() {
let (temp, ctx) = library();
let outside = temp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::create_dir(ctx.paths.root.join("real")).unwrap();
for target in [ctx.paths.root.join("real"), outside.clone()] {
let link = ctx.paths.root.join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let err = anchored_directory(&ctx, &link).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("symbolic link"), "{msg}");
let deeper = anchored_directory(&ctx, &link.join("deeper"));
assert!(deeper.is_err());
assert!(std::fs::read_dir(&outside).unwrap().next().is_none());
std::fs::remove_file(&link).unwrap();
}
let held = anchored_directory(&ctx, &ctx.paths.root.join("real")).unwrap();
assert!(held.metadata().unwrap().is_dir());
}
#[test]
fn a_final_sidecar_symlink_cannot_overwrite_its_target() {
let (temp, ctx) = library();
let outside = temp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("image.xmp"), b"keep").unwrap();
std::os::unix::fs::symlink(outside.join("image.xmp"), ctx.paths.root.join("image.xmp"))
.unwrap();
let err = replace_sidecar(&ctx, &ctx.paths.root.join("image.xmp"), b"private").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("symbolic link"), "{msg}");
assert_eq!(std::fs::read(outside.join("image.xmp")).unwrap(), b"keep");
let meta = std::fs::symlink_metadata(ctx.paths.root.join("image.xmp")).unwrap();
assert!(meta.file_type().is_symlink());
}
#[test]
fn a_replaced_root_fails_the_operation() {
let (temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
std::fs::write(ctx.paths.root.join("Trips/f.jpg"), b"jpeg").unwrap();
let media = ctx.paths.root.join("Trips/f.jpg");
assert_eq!(read_all(open_media(&ctx, &media).unwrap()), b"jpeg");
std::fs::rename(&ctx.paths.root, temp.path().join("old")).unwrap();
std::fs::create_dir(&ctx.paths.root).unwrap();
let err = open_media(&ctx, &media).unwrap_err();
assert!(format!("{err:#}").contains("no longer names"), "{err:#}");
let err = replace_sidecar(&ctx, &ctx.paths.root.join("f.xmp"), b"x").unwrap_err();
assert!(format!("{err:#}").contains("no longer names"), "{err:#}");
assert!(std::fs::read_dir(&ctx.paths.root).unwrap().next().is_none());
}
#[test]
fn an_alias_of_the_root_reaches_the_same_files() {
let (temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
std::fs::write(ctx.paths.root.join("Trips/f.jpg"), b"jpeg").unwrap();
let alias = temp.path().join("alias");
std::os::unix::fs::symlink(&ctx.paths.root, &alias).unwrap();
crate::library_guard::validate_paths(&ctx, &[alias.clone()]).unwrap();
assert_eq!(
read_all(open_media(&ctx, &alias.join("Trips/f.jpg")).unwrap()),
b"jpeg"
);
replace_sidecar(&ctx, &alias.join("Trips/f.xmp"), b"marks").unwrap();
assert_eq!(
std::fs::read(ctx.paths.root.join("Trips/f.xmp")).unwrap(),
b"marks"
);
}
#[test]
fn an_unchanged_in_root_symlink_still_reaches_its_target() {
let (_temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
std::fs::write(ctx.paths.root.join("Trips/IMG_0001.jpg"), b"jpeg").unwrap();
std::os::unix::fs::symlink(
ctx.paths.root.join("Trips"),
ctx.paths.root.join("trips-alias"),
)
.unwrap();
crate::library_guard::validate_paths(&ctx, &[ctx.paths.root.join("trips-alias")]).unwrap();
let media = ctx.paths.root.join("trips-alias/IMG_0001.jpg");
assert_eq!(read_all(open_media(&ctx, &media).unwrap()), b"jpeg");
let sidecar = ctx.paths.root.join("trips-alias/IMG_0001.xmp");
replace_sidecar(&ctx, &sidecar, b"marks").unwrap();
assert_eq!(
std::fs::read(ctx.paths.root.join("Trips/IMG_0001.xmp")).unwrap(),
b"marks"
);
let meta = std::fs::symlink_metadata(ctx.paths.root.join("trips-alias")).unwrap();
assert!(meta.file_type().is_symlink());
}
#[test]
fn unicode_and_spaces_round_trip() {
let (_temp, ctx) = library();
let dir = ctx.paths.root.join("Fotoğraflar 2024/İstanbul Günü");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("IMG 0001.jpeg"), b"jpeg").unwrap();
let media = dir.join("IMG 0001.jpeg");
assert_eq!(read_all(open_media(&ctx, &media).unwrap()), b"jpeg");
replace_sidecar(&ctx, &dir.join("IMG 0001.xmp"), b"marks").unwrap();
assert_eq!(std::fs::read(dir.join("IMG 0001.xmp")).unwrap(), b"marks");
}
#[test]
fn a_non_regular_sidecar_target_is_refused_not_replaced() {
let (_temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
let err = replace_sidecar(&ctx, &ctx.paths.root.join("Trips"), b"x").unwrap_err();
assert!(format!("{err:#}").contains("directory"), "{err:#}");
let fifo = ctx.paths.root.join("pipe.xmp");
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.map(|status| status.success());
match made {
Ok(true) => {
let err = replace_sidecar(&ctx, &fifo, b"x").unwrap_err();
assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
let meta = std::fs::symlink_metadata(&fifo).unwrap();
assert!(meta.file_type().is_fifo());
}
_ => write_past_test_capture(
"SKIP: mkfifo unavailable, so the FIFO sidecar refusal is unproven here\n",
),
}
}
#[test]
fn an_unwritable_directory_refuses_the_sidecar() {
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 555 does not block creating a sidecar\n",
);
return;
}
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
let err = replace_sidecar(&ctx, &locked.join("image.xmp"), b"private").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));
assert!(std::fs::read_dir(&locked).unwrap().next().is_none());
}
#[test]
fn a_failed_write_discards_its_temporary() {
let (_temp, ctx) = library();
let dir = ctx.paths.root.join("full");
std::fs::create_dir(&dir).unwrap();
let limit: libc::rlim_t = 16 << 20;
let bytes = vec![b'x'; (limit as usize) * 2];
unsafe {
let previous = libc::signal(libc::SIGXFSZ, libc::SIG_IGN);
assert_ne!(previous, libc::SIG_ERR);
let mut saved = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
assert_eq!(libc::getrlimit(libc::RLIMIT_FSIZE, &mut saved), 0);
let limited = libc::rlimit {
rlim_cur: limit,
rlim_max: saved.rlim_max,
};
assert_eq!(libc::setrlimit(libc::RLIMIT_FSIZE, &limited), 0);
let err = replace_sidecar(&ctx, &dir.join("image.xmp"), &bytes).unwrap_err();
assert_eq!(libc::setrlimit(libc::RLIMIT_FSIZE, &saved), 0);
libc::signal(libc::SIGXFSZ, previous);
let msg = format!("{err:#}");
assert!(msg.contains("write"), "{msg}");
assert!(msg.contains("image.xmp"), "{msg}");
}
assert!(
std::fs::read_dir(&dir).unwrap().next().is_none(),
"a failed sidecar write must discard its temporary out of the media tree"
);
}
#[test]
fn open_media_returns_only_a_regular_in_library_file() {
let (temp, ctx) = library();
std::fs::create_dir(ctx.paths.root.join("Trips")).unwrap();
std::fs::write(ctx.paths.root.join("Trips/f.jpg"), b"jpeg").unwrap();
let media = ctx.paths.root.join("Trips/f.jpg");
assert_eq!(read_all(open_media(&ctx, &media).unwrap()), b"jpeg");
let err = open_media(&ctx, &ctx.paths.root.join("Trips")).unwrap_err();
assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
std::os::unix::fs::symlink(&media, ctx.paths.root.join("alias.jpg")).unwrap();
assert_eq!(
read_all(open_media(&ctx, &ctx.paths.root.join("alias.jpg")).unwrap()),
b"jpeg"
);
std::fs::write(temp.path().join("outside.jpg"), b"secret").unwrap();
std::os::unix::fs::symlink(
temp.path().join("outside.jpg"),
ctx.paths.root.join("evil.jpg"),
)
.unwrap();
let err = open_media(&ctx, &ctx.paths.root.join("evil.jpg")).unwrap_err();
assert!(format!("{err:#}").contains("outside library"), "{err:#}");
assert!(open_media(&ctx, &ctx.paths.root).is_err());
let fifo = ctx.paths.root.join("hang.jpg");
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.map(|status| status.success());
match made {
Ok(true) => {
let err = open_media(&ctx, &fifo).unwrap_err();
assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
}
_ => write_past_test_capture(
"SKIP: mkfifo unavailable, so the FIFO refusal is unproven here\n",
),
}
}
#[test]
fn replace_sidecar_replaces_an_existing_sidecar_and_leaves_no_temporaries() {
let (_temp, ctx) = library();
let dir = ctx.paths.root.join("out");
std::fs::create_dir(&dir).unwrap();
let target = dir.join("image.xmp");
replace_sidecar(&ctx, &target, b"one").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"one");
replace_sidecar(&ctx, &target, b"two").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"two");
let names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec!["image.xmp".to_string()],
"the publication must leave only the sidecar behind"
);
}
#[test]
fn state_directory_targets_are_refused_for_sidecars() {
let (_temp, ctx) = library();
let err = replace_sidecar(&ctx, &ctx.paths.state.join("image.xmp"), b"x").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("state directory"), "{msg}");
let err = replace_sidecar(&ctx, &ctx.paths.root.join(".videre"), b"x").unwrap_err();
assert!(format!("{err:#}").contains("state directory"), "{err:#}");
}
#[test]
fn a_missing_parent_directory_is_refused_without_creating_anything() {
let (_temp, ctx) = library();
let err = replace_sidecar(&ctx, &ctx.paths.root.join("gone/image.xmp"), b"x").unwrap_err();
assert!(format!("{err:#}").contains("gone"), "{err:#}");
assert!(!ctx.paths.root.join("gone").exists());
}
#[test]
fn relative_paths_resolve_against_the_library_root() {
let (_temp, ctx) = library();
std::fs::write(ctx.paths.root.join("f.jpg"), b"jpeg").unwrap();
assert_eq!(
read_all(open_media(&ctx, Path::new("f.jpg")).unwrap()),
b"jpeg"
);
replace_sidecar(&ctx, Path::new("f.xmp"), b"marks").unwrap();
assert_eq!(
std::fs::read(ctx.paths.root.join("f.xmp")).unwrap(),
b"marks"
);
}
#[test]
fn a_staged_copy_preserves_bytes_and_extension_and_cleans_up() {
let (_temp, ctx) = library();
std::fs::write(ctx.paths.root.join("scan.dng"), b"raw-ish").unwrap();
let confined = open_media(&ctx, &ctx.paths.root.join("scan.dng")).unwrap();
std::fs::create_dir(&ctx.paths.state).unwrap();
let staged = staged_copy(&ctx, &confined, OsStr::new("dng")).unwrap();
assert_eq!(staged.path.extension(), Some(OsStr::new("dng")));
assert_eq!(std::fs::read(&staged.path).unwrap(), b"raw-ish");
let path = staged.path.clone();
drop(staged);
assert!(!path.exists(), "dropping the staged copy must remove it");
let staged = staged_copy(&ctx, &confined, OsStr::new("")).unwrap();
assert_eq!(staged.path.extension(), None);
let path = staged.path.clone();
drop(staged);
assert!(!path.exists());
}
}