diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index 3a78fff18..53c543e80 100644 --- a/crates/engine/src/project_manager.rs +++ b/crates/engine/src/project_manager.rs @@ -137,7 +137,10 @@ pub fn run(s: &mut Session, p: &Value) -> Result { for id in ids { let Some(m) = proj.item(id).and_then(|i| i.as_media()).cloned() else { continue }; let MediaRef::File { path } = &m.media else { continue }; - let size = s.services.file_size(path).unwrap_or(0); + // Project files carry stored paths; use only the media pool's resolved, policy-approved + // pathname for filesystem I/O. Keep the stored path in the project itself. + let source_path = s.media.follow(path).map_err(|why| EngineError::Other(format!("{path} is not opened: {why}")))?; + let size = s.services.file_size(&source_path).unwrap_or(0); original_bytes += size; let file = Path::new(path); let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| format!("media-{}", id.0)); @@ -158,20 +161,23 @@ pub fn run(s: &mut Session, p: &Value) -> Result { }; taken.push(out.clone()); let proxy = match (&m.proxy, include_proxies) { - (Some(MediaRef::File { path: pp }), true) if s.services.file_size(pp).is_ok() => { - let pf = Path::new(pp); - let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); - taken.push(o.clone()); - let b = s.services.file_size(pp).unwrap_or(0); - original_bytes += b; - result_bytes += b; - Some((pp.clone(), o)) - } + (Some(MediaRef::File { path: pp }), true) => match s.media.follow(pp) { + Ok(resolved_proxy) if s.services.file_size(&resolved_proxy).is_ok() => { + let pf = Path::new(&resolved_proxy); + let ps = pf.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let pe = pf.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let o = unique_path(&dest_dir.join("Proxies"), &ps, "", &pe, &taken).to_string_lossy().into_owned(); + taken.push(o.clone()); + let b = s.services.file_size(&resolved_proxy).unwrap_or(0); + original_bytes += b; + result_bytes += b; + Some((resolved_proxy, o)) + } + _ => None, + }, _ => None, }; - work.push(Work { item: id, src_path: path.clone(), out, range, proxy }); + work.push(Work { item: id, src_path: source_path, out, range, proxy }); } let previews = s.path.as_deref().map(crate::previews::dir_for_project).filter(|d| include_previews && d.is_dir()); if let Some(d) = &previews { diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index c3b6b3879..7a75826e9 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -489,9 +489,14 @@ pub fn index_folder(root: &Path, limit: usize) -> BTreeMap> if name.starts_with('.') { continue; } - if p.is_dir() { + let Ok(file_type) = e.file_type() else { continue }; + // A relink search must stay within the folder explicitly chosen by the user. In + // particular, following a directory symlink can make a small loop defeat the file + // budget and walk an unrelated tree. Symlinked files are still candidates. + let is_file = if file_type.is_symlink() { std::fs::metadata(&p).is_ok_and(|m| m.is_file()) } else { file_type.is_file() }; + if file_type.is_dir() { queue.push_back(p); - } else { + } else if is_file { out.entry(name.to_ascii_lowercase()).or_default().push(p); n += 1; if n >= limit { @@ -564,7 +569,11 @@ pub fn make_offline(s: &mut Session, p: &Value) -> Result { let mut deleted = Vec::new(); if delete { for (_, path) in &files { - if std::fs::remove_file(path).is_ok() { + // Stored paths in an opened document are not authority to delete arbitrary files. + // Delete only the resolved path that the media pool permits opening. + if let Ok(path) = s.media.follow(path) + && std::fs::remove_file(&path).is_ok() + { deleted.push(path.clone()); } } @@ -583,14 +592,14 @@ fn status_json(s: &Session, id: ItemId) -> Option { let status = match &path { None => "generated", Some(_) if m.offline => "offline", - Some(p) if s.services.file_size(p).is_err() => "missing", + Some(p) if s.media.follow(p).ok().is_none_or(|file| s.services.file_size(&file).is_err()) => "missing", Some(_) => match s.media.offline_status(id) { Some(o) if o.reason == filmcraft_render::offline::OfflineReason::Unreadable => "unreadable", _ => "online", }, }; let proxy = match &m.proxy { - Some(MediaRef::File { path }) => json!({"path": path, "online": s.services.file_size(path).is_ok()}), + Some(MediaRef::File { path }) => json!({"path": path, "online": s.media.follow(path).is_ok_and(|file| s.services.file_size(&file).is_ok())}), _ => Value::Null, }; Some(json!({ diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index 33208c544..180629a2a 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -9,7 +9,7 @@ use serde_json::json; use crate::Session; use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir}; -use crate::relink::{apply_remap, derive_remap}; +use crate::relink::{apply_remap, derive_remap, index_folder}; const W: u32 = 160; const H: u32 = 90; @@ -163,6 +163,29 @@ fn make_offline_shows_the_slate_until_relinked() { let _ = std::fs::remove_dir_all(&root); } +#[cfg(unix)] +#[test] +fn relink_search_does_not_follow_directory_symlinks() { + use std::os::unix::fs::symlink; + + let root = tmp_dir("relink-no-symlink-walk"); + let inside = root.join("inside"); + let outside = root.join("outside"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(inside.join("kept.mov"), b"kept").unwrap(); + std::fs::write(outside.join("outside.mov"), b"outside").unwrap(); + symlink(&outside, inside.join("loop")).unwrap(); + symlink(outside.join("outside.mov"), inside.join("linked.mov")).unwrap(); + + let index = index_folder(&inside, 20_000); + assert!(index.contains_key("kept.mov")); + assert!(!index.contains_key("outside.mov")); + // a symlinked file is still media the user can relink to + assert!(index.contains_key("linked.mov")); + let _ = std::fs::remove_dir_all(&root); +} + // Regression test for issue #110: after `file.import` re-imports a file at a path the project // already listed as offline, `s.offline.missing` must drop that item so the slate and the // "Media missing" badge clear without reopening the project. Before the fix (the patch in