fix(engine): use project scratch folders only inside the project folder or when chosen

Project Auto Save and Video Previews folders named in a project file are used
only when they are inside the project's own folder or were chosen in Project
Settings > Scratch Disks on this computer (remembered in the preference
autoSave.scratchFolders). Other folders fall back to the default location,
with a log line and a message when the project opens. Network-share and
device paths are refused for these folders.
This commit is contained in:
aiongg
2026-10-16 16:37:56 +00:00
parent 3984ef90ff
commit 07374d1f2a
5 changed files with 198 additions and 22 deletions
+19 -10
View File
@@ -50,11 +50,23 @@ pub struct AutoSavePrefs {
pub recovery_journal: bool,
/// Longest time (s) unsaved changes may stay out of the journal while edits keep coming.
pub recovery_interval_seconds: u32,
/// Folders chosen in Project Settings ▸ Scratch Disks on this computer (Project Auto Save,
/// Video Previews). A project file may name these, or folders inside its own folder; see
/// [`crate::project_tools::trusted_scratch`].
pub scratch_folders: Vec<String>,
}
impl Default for AutoSavePrefs {
fn default() -> Self {
Self { enabled: true, interval_minutes: 5, max_versions: 20, save_current_project: false, recovery_journal: true, recovery_interval_seconds: 5 }
Self {
enabled: true,
interval_minutes: 5,
max_versions: 20,
save_current_project: false,
recovery_journal: true,
recovery_interval_seconds: 5,
scratch_folders: Vec::new(),
}
}
}
@@ -1000,21 +1012,18 @@ impl Worker {
fn auto_save(&mut self) -> Result<PathBuf, String> {
let Some(s) = &self.latest else { return Err("nothing to save".into()) };
let scratch = s.project.settings.scratch.auto_save.clone().filter(|d| !d.is_empty());
let (dir, name) = match &s.path {
let scratch = crate::project_tools::trusted_scratch(s.project.settings.scratch.auto_save.as_deref(), s.path.as_deref(), &self.prefs.scratch_folders);
let (dir, name) = match (&s.path, scratch) {
// Project Settings ▸ Scratch Disks ▸ Project Auto Save
Some(p) if scratch.is_some() => {
(Some(p), Some(scratch)) => {
let p = Path::new(p);
(
PathBuf::from(scratch.clone().unwrap_or_default()),
p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()),
)
(scratch, p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()))
}
Some(p) => {
(Some(p), None) => {
let p = Path::new(p);
(names::auto_save_dir(p), p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone()))
}
None => (self.data_dir.join(names::AUTO_SAVE_DIR), s.project.name.clone()),
(None, _) => (self.data_dir.join(names::AUTO_SAVE_DIR), s.project.name.clone()),
};
let t0 = Instant::now();
let bytes = filmcraft_format::encode(&s.project, false);
+57
View File
@@ -229,3 +229,60 @@ fn journal_costs_the_ui_thread_only_a_handoff() {
s.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
/// Scratch Disks folders named in a project file are used only when they are inside the project's
/// folder or were chosen on this computer: otherwise auto-saves and previews go to the default
/// location, nothing is written to the named folder, and the user is told.
#[test]
fn project_scratch_folders_outside_the_project_are_not_used() {
let d = temp_dir("scratch-trust");
let outside = d.join("elsewhere");
let show = d.join("show");
std::fs::create_dir_all(&show).unwrap();
let proj = show.join("cut.fcproj").to_string_lossy().to_string();
let mut a = demo();
let named = outside.to_string_lossy().to_string();
a.edit("scratch", |p, _| {
p.settings.scratch.auto_save = Some(named.clone());
p.settings.scratch.video_previews = Some(named.clone());
Ok(())
})
.unwrap();
a.execute("file.saveAs", json!({"path": proj})).unwrap();
let _ = std::fs::remove_dir_all(&outside);
let mut s = Session::default();
start(&mut s, &d.join("data"));
s.execute("file.open", json!({"path": proj})).unwrap();
assert!(
s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message.contains(&named))),
"the user is told the folder is not used"
);
assert_eq!(crate::project_tools::previews_dir(&s), Some(show.join("FilmCraft Previews").join("cut")));
s.execute("sequence.addEdit", json!({"seconds": 1.0})).unwrap();
let r = s.execute("file.autoSaveNow", json!({})).unwrap();
let written = std::path::PathBuf::from(r["path"].as_str().unwrap());
assert!(written.starts_with(show.join("Auto-Save")), "{}", written.display());
assert!(!outside.exists(), "nothing written to the folder the project names");
// A folder inside the project's folder is used as is.
let inner = show.join("Saves").to_string_lossy().to_string();
s.edit("scratch", |p, _| {
p.settings.scratch.auto_save = Some(inner.clone());
Ok(())
})
.unwrap();
s.sync_persistence();
let r = s.execute("file.autoSaveNow", json!({})).unwrap();
assert!(std::path::Path::new(r["path"].as_str().unwrap()).starts_with(&inner));
// Choosing the outside folder in Scratch Disks makes it usable; network shares never are.
s.execute("file.projectSettings.scratchDisks", json!({"autoSave": named})).unwrap();
let r = s.execute("file.autoSaveNow", json!({})).unwrap();
assert!(std::path::Path::new(r["path"].as_str().unwrap()).starts_with(&outside));
assert!(s.execute("file.projectSettings.scratchDisks", json!({"autoSave": r"\\server\share\saves"})).is_err());
assert!(crate::project_tools::trusted_scratch(Some(r"\\server\share"), Some(&proj), &[r"\\server\share".into()]).is_none());
assert!(crate::project_tools::trusted_scratch(Some(&format!("{}/../x", show.display())), Some(&proj), &[]).is_none());
s.shutdown();
let _ = std::fs::remove_dir_all(&d);
}
+7 -3
View File
@@ -4365,7 +4365,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
if let Some(p) = &path
&& !cfg!(target_arch = "wasm32")
{
s.previews.set_dir(Some(crate::project_tools::previews_dir_for(&s.project, p)));
s.previews.set_dir(Some(crate::project_tools::previews_dir_for(&s.project, p, &s.prefs.auto_save.scratch_folders)));
}
s.path = path;
s.revision += 1;
@@ -4373,6 +4373,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
s.saved_revision = if clean { s.revision } else { 0 };
s.loaded_schema = filmcraft_format::SCHEMA_VERSION;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
crate::project_tools::warn_untrusted_scratch(s);
}
fn open_project(s: &mut Session, path: &str) -> Result<Value> {
@@ -4424,8 +4425,11 @@ fn recover(s: &mut Session, id: Option<&str>) -> Result<Value> {
/// Where auto-saves of the current project go.
fn auto_save_dir(s: &Session) -> std::path::PathBuf {
if let Some(d) = s.project.settings.scratch.auto_save.as_deref().filter(|d| !d.is_empty() && s.path.is_some()) {
return std::path::PathBuf::from(d);
if s.path.is_some()
&& let Some(d) =
crate::project_tools::trusted_scratch(s.project.settings.scratch.auto_save.as_deref(), s.path.as_deref(), &s.prefs.auto_save.scratch_folders)
{
return d;
}
match (&s.path, &s.persistence) {
(Some(p), _) => filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)),
+109 -9
View File
@@ -953,33 +953,124 @@ pub fn scratch_paths(s: &Session) -> Value {
let sc = &s.project.settings.scratch;
let project_dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_string_lossy().to_string());
let same = |o: &Option<String>| o.clone().or(project_dir.clone()).unwrap_or_else(|| "(data directory until the project is saved)".into());
let auto_save = match s.path.as_deref() {
Some(p) => trusted_scratch(sc.auto_save.as_deref(), Some(p), &s.prefs.auto_save.scratch_folders)
.unwrap_or_else(|| filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)))
.to_string_lossy()
.to_string(),
None => same(&None),
};
json!({
"captured": {"setting": sc.captured, "path": same(&sc.captured)},
"videoPreviews": {"setting": sc.video_previews, "path": previews_dir(s).map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| same(&sc.video_previews))},
"audioPreviews": {"setting": sc.audio_previews, "path": same(&sc.audio_previews)},
"autoSave": {"setting": sc.auto_save, "path": sc.auto_save.clone().or_else(|| s.path.as_deref().map(|p| filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)).to_string_lossy().to_string())).unwrap_or_else(|| same(&None))},
"autoSave": {"setting": sc.auto_save, "path": auto_save},
})
}
/// Render previews folder of the open project: Scratch Disks ▸ Video Previews / `<project
/// name>`, else `FilmCraft Previews/<project name>` next to the project (None while unsaved).
pub fn previews_dir(s: &Session) -> Option<std::path::PathBuf> {
s.path.as_deref().map(|p| previews_dir_for(&s.project, p))
s.path.as_deref().map(|p| previews_dir_for(&s.project, p, &s.prefs.auto_save.scratch_folders))
}
/// [`previews_dir`] for `project` saved at `path`.
pub fn previews_dir_for(project: &Project, path: &str) -> std::path::PathBuf {
match &project.settings.scratch.video_previews {
Some(d) if !d.is_empty() => {
/// [`previews_dir`] for `project` saved at `path`; `chosen` as in [`trusted_scratch`].
pub fn previews_dir_for(project: &Project, path: &str, chosen: &[String]) -> std::path::PathBuf {
match trusted_scratch(project.settings.scratch.video_previews.as_deref(), Some(path), chosen) {
Some(d) => {
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
std::path::Path::new(d).join(stem)
d.join(stem)
}
_ => crate::previews::dir_for_project(path),
None => crate::previews::dir_for_project(path),
}
}
/// The Scratch Disks folder `dir` from the settings of the project saved at `project_path`, if
/// FilmCraft may write there: a folder chosen in Project Settings ▸ Scratch Disks on this computer
/// (`chosen`, kept in the preferences), or a folder inside the project's own folder. Other folders
/// named in a project file, network shares and device paths give None: the default location.
pub fn trusted_scratch(dir: Option<&str>, project_path: Option<&str>, chosen: &[String]) -> Option<std::path::PathBuf> {
let dir = dir.filter(|d| !d.is_empty() && !network_or_device(d))?;
if chosen.iter().any(|c| c == dir) {
return Some(dir.into());
}
let root = std::path::Path::new(project_path?).parent().filter(|r| !r.as_os_str().is_empty())?;
inside(std::path::Path::new(dir), root).then(|| dir.into())
}
/// Whether `dir` lies inside `root`, also after following the links in the part that exists.
fn inside(dir: &std::path::Path, root: &std::path::Path) -> bool {
use std::path::Component;
if !dir.starts_with(root) || dir.components().any(|c| matches!(c, Component::ParentDir | Component::CurDir)) {
return false;
}
let mut existing = dir;
while existing.symlink_metadata().is_err() {
let Some(up) = existing.parent() else { return false };
existing = up;
}
match (std::fs::canonicalize(existing), std::fs::canonicalize(root)) {
(Ok(real), Ok(real_root)) => real.starts_with(real_root),
_ => false,
}
}
/// Network-share (`\\server\share`, `//server/share`) and device (`\\?\`, `\\.\`, `NUL`,
/// `COM1`…) paths, on every platform.
pub(crate) fn network_or_device(dir: &str) -> bool {
let d = dir.replace('\\', "/");
d.starts_with("//")
|| d.split('/').any(|c| {
let stem = c.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|| stem.strip_prefix("COM").or_else(|| stem.strip_prefix("LPT")).is_some_and(|n| n.len() == 1 && n.bytes().all(|b| b.is_ascii_digit()))
})
}
/// Remember the Scratch Disks folders chosen in this session, so projects naming them may use
/// them later (see [`trusted_scratch`]).
fn remember_scratch_folders(s: &mut Session, dirs: &[String]) {
const KEEP: usize = 64;
let mut prefs = s.prefs.clone();
let list = &mut prefs.auto_save.scratch_folders;
for d in dirs {
list.retain(|x| x != d);
list.push(d.clone());
}
let excess = list.len().saturating_sub(KEEP);
list.drain(..excess);
if prefs != s.prefs
&& let Err(e) = s.set_prefs(prefs)
{
log::warn!("saving preferences: {e}");
}
}
/// Log and tell the user once when the project just opened names Scratch Disks folders that
/// FilmCraft will not write to (see [`trusted_scratch`]).
pub(crate) fn warn_untrusted_scratch(s: &mut Session) {
let sc = &s.project.settings.scratch;
let ignored: Vec<String> = [&sc.auto_save, &sc.video_previews]
.into_iter()
.filter_map(|d| d.as_deref().filter(|d| !d.is_empty()))
.filter(|d| trusted_scratch(Some(d), s.path.as_deref(), &s.prefs.auto_save.scratch_folders).is_none())
.map(str::to_string)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if ignored.is_empty() {
return;
}
let list = ignored.join(", ");
log::warn!("project scratch disks outside the project folder not used: {list}");
s.toast(format!(
"Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves and previews go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it."
));
}
fn project_settings_scratch(s: &mut Session, p: &Value) -> Result<Value> {
let mut sc = s.project.settings.scratch.clone();
let mut chosen = Vec::new();
for (k, slot) in
[("captured", &mut sc.captured), ("videoPreviews", &mut sc.video_previews), ("audioPreviews", &mut sc.audio_previews), ("autoSave", &mut sc.auto_save)]
{
@@ -987,10 +1078,19 @@ fn project_settings_scratch(s: &mut Session, p: &Value) -> Result<Value> {
None => {}
Some(Value::Null) => *slot = None,
Some(Value::String(x)) if x.is_empty() || x.eq_ignore_ascii_case("same as project") => *slot = None,
Some(Value::String(x)) => *slot = Some(x.clone()),
Some(Value::String(x)) if matches!(k, "videoPreviews" | "autoSave") && network_or_device(x) => {
return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` must be a folder on this computer, not a network share or device")));
}
Some(Value::String(x)) => {
if matches!(k, "videoPreviews" | "autoSave") {
chosen.push(x.clone());
}
*slot = Some(x.clone());
}
Some(_) => return Err(bad("file.projectSettings.scratchDisks", format!("`{k}` is a folder path or null"))),
}
}
remember_scratch_folders(s, &chosen);
if sc != s.project.settings.scratch {
s.edit("Scratch Disks", |pr, _| {
pr.settings.scratch = sc;
+6
View File
@@ -131,6 +131,12 @@ deleted. Only files named exactly `<name>-<timestamp>.fcproj` are ever deleted.
ordinary project file: open it with File ▸ Open Project. `file.listAutoSaves` lists them, newest
first.
Project Settings ▸ Scratch Disks can send auto-saves (Project Auto Save) and render previews (Video
Previews) to another folder. A folder named in a project file is used only when it is inside the
project's own folder or was chosen in Scratch Disks on this computer (remembered in
`autoSave.scratchFolders`). Otherwise FilmCraft uses the default location and says so when the
project opens. Network shares and device paths are never used for these two.
Preferences are stored in `<data dir>/preferences.json`. The `prefs.*` commands read and change them.
## Crash recovery