mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user