Merge #2260: a save to a missing folder writes the file and says so

The folders are made by a new writer only user-named targets use, so autosave
and a dangling symlink's target keep the strict write_atomic. Save, Save As,
Export, Plot, WBLOCK, layout templates, render and the CLI report the topmost
new folder at the command line and in createdFolder.
This commit is contained in:
Tanish Manku
2026-11-02 16:48:16 -07:00
11 changed files with 187 additions and 17 deletions
+47
View File
@@ -42,6 +42,27 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, permissions, |file| file.write_all(bytes))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
/// `None` when the file's folder exists, or when `path` itself exists (a dangling symlink's target
/// folder is not ours to make).
pub fn missing_folder(path: &Path) -> Option<PathBuf> {
if std::fs::symlink_metadata(path).is_ok() {
return None;
}
path.ancestors().skip(1).filter(|a| !a.as_os_str().is_empty()).take_while(|a| std::fs::symlink_metadata(a).is_err()).last().map(Path::to_path_buf)
}
/// [`write_atomic`] to a path the user named, first creating the folders missing above it: a
/// mistyped folder costs less than a drawing that could not be written. The caller reports
/// [`missing_folder`], asked before this call, so the new folder is never a surprise.
pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(folder) = missing_folder(path) {
let dir = path.parent().unwrap_or(&folder);
std::fs::create_dir_all(dir).map_err(|e| io::Error::new(e.kind(), format!("the folder {} could not be created: {e}", dir.display())))?;
}
write_atomic(path, bytes)
}
/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer),
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
/// at `path` is replaced by the new file, never written through.
@@ -159,6 +180,30 @@ fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Resul
mod tests {
use super::*;
#[test]
fn creates_missing_parent_directories() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mkdir-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
let dest = dir.join("new_folder").join("deeper").join("out.bin");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(missing_folder(&dest), Some(dir.join("new_folder")));
assert!(write_atomic(&dest, b"created").is_err(), "the plain writer never makes folders");
write_atomic_making_folders(&dest, b"created").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"created");
assert_eq!(missing_folder(&dest), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_folder_that_cannot_be_made_is_named_in_the_error() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomk-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let blocker = dir.join("a_file");
std::fs::write(&blocker, b"x").unwrap();
let e = write_atomic_making_folders(&blocker.join("sub").join("out.bin"), b"new").unwrap_err();
assert!(e.to_string().contains("could not be created"), "{e}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn replaces_contents_atomically() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
@@ -259,6 +304,8 @@ mod tests {
let link = dir.join("link.dxf");
symlink("missing/real.dxf", &link).unwrap();
assert!(write_atomic(&link, b"new").is_err());
assert!(write_atomic_making_folders(&link, b"new").is_err());
assert!(!dir.join("missing").exists(), "a link's unavailable target folder must not be created");
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
std::fs::remove_dir_all(dir).unwrap();
}
+6 -2
View File
@@ -8,7 +8,7 @@ use cadcraft_geom::{Mat3, Vec2, Vec3};
use serde_json::{Value, json};
use super::super::curves::KW;
use super::super::file::{drops_unknown, not_saved, not_saved_message, report_not_saved};
use super::super::file::{drops_unknown, new_folder, not_saved, not_saved_message, report_new_folder, report_not_saved};
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::modify::erase_kept;
use super::super::{CommandSpec, bad, point_param, str_param, targets};
@@ -43,11 +43,13 @@ enum Source<'a> {
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let folder = new_folder(&path);
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "wblock", &path, Source::Block(name, base))?,
None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?,
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_new_folder(s, &mut r, folder);
report_not_saved(s, &mut r, &lost);
Ok(r)
}
@@ -58,6 +60,7 @@ fn run_dash_wblock(s: &mut Session, p: &Value) -> Result<Value> {
}
let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let folder = new_folder(&path);
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?,
None => {
@@ -72,6 +75,7 @@ fn run_dash_wblock(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_new_folder(s, &mut r, folder);
report_not_saved(s, &mut r, &lost);
Ok(r)
}
@@ -101,7 +105,7 @@ fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usi
let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?;
// Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces.
#[cfg(not(target_arch = "wasm32"))]
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let lost = if drops_unknown(path) { not_saved(&d) } else { BTreeMap::new() };
Ok((d.model.len(), bytes.len(), lost))
}
+3 -1
View File
@@ -82,7 +82,7 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
@@ -119,6 +119,7 @@ fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
}
let file = format!("export.{format}");
let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?;
let folder = path.and_then(super::file::new_folder);
let mut r = match path {
Some(path) => {
write_file("export", path, &bytes)?;
@@ -126,6 +127,7 @@ fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
}
None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }),
};
super::file::report_new_folder(s, &mut r, folder);
super::file::report_loss(s, &mut r, &file);
Ok(r)
}
+25 -1
View File
@@ -446,7 +446,7 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
}
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
@@ -851,6 +851,28 @@ fn report_sat_text_dropped(s: &mut Session, r: &mut Value) {
}
}
/// The folder a write to `path` has to create, asked before writing so it can be reported after.
/// The web has no folders.
pub(super) fn new_folder(path: &str) -> Option<String> {
#[cfg(not(target_arch = "wasm32"))]
return crate::atomic_write::missing_folder(std::path::Path::new(path)).map(|f| f.display().to_string());
#[cfg(target_arch = "wasm32")]
{
let _ = path;
None
}
}
/// Say on the command line that the file went into a folder that didn't exist, so a mistyped path
/// is noticed, and name the folder in the result's `createdFolder`.
pub(super) fn report_new_folder(s: &mut Session, r: &mut Value, folder: Option<String>) {
let Some(folder) = folder else { return };
s.echo(format!("Created the folder {folder}, which did not exist. Check the path if you did not mean to."));
if let Some(o) = r.as_object_mut() {
o.insert("createdFolder".into(), json!(folder));
}
}
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
let Some(msg) = not_saved_message(lost) else { return };
@@ -867,7 +889,9 @@ fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> R
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
let version = asked.unwrap_or_else(|| drawing_version(s, &path));
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
let folder = new_folder(&path);
let mut r = run(s, p)?;
report_new_folder(s, &mut r, folder);
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
report_not_saved(s, &mut r, &lost);
} else if version >= Version::R2013 && r.get("path").and_then(Value::as_str).is_some_and(is_dxf_file) {
+5 -2
View File
@@ -840,8 +840,11 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let path = _path;
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
let folder = super::file::new_folder(path);
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages });
super::file::report_new_folder(s, &mut r, folder);
Ok(r)
}
#[cfg(target_arch = "wasm32")]
{
+3 -1
View File
@@ -104,8 +104,10 @@ pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
let t = template_of(d, &name)?;
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
let folder = super::super::file::new_folder(&path);
store(&path, &bytes)?;
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
super::super::file::report_new_folder(s, &mut r, folder);
let lost = not_saved(&t);
if drops_unknown(&path)
&& let Some(msg) = not_saved_message(&lost)
@@ -124,7 +126,7 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{