io: remove the .sctmp temporary file when a save fails

write_file writes `<path>.sctmp` and renames it over the workbook. When the
write or the rename failed (a full disk, or the target locked by another
app such as Excel or a sync client), the temporary file was left beside the
user's workbook. Remove it on either failure and report the same error.

Adds io::tests::failed_save_leaves_no_temporary_file, which fails before
this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SteamSmith
2026-10-11 19:59:42 -04:00
co-authored by Claude Opus 5.5
parent 3926d21aa9
commit 3424229ee3
+26 -2
View File
@@ -339,8 +339,12 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
// A write or rename that fails (full disk, the workbook locked by another app) leaves
// no temporary file beside the user's workbook.
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})
}
#[cfg(target_arch = "wasm32")]
@@ -434,6 +438,26 @@ mod tests {
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
/// A save that can't be put in place (here a folder already has the workbook's name, as
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn failed_save_leaves_no_temporary_file() {
let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let blocked = dir.join("Book1.xlsx");
std::fs::create_dir(&blocked).unwrap();
assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err());
assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed");
// An ordinary save still works and leaves only the workbook.
let ok = dir.join("Book2.xlsx");
write_file(ok.to_str().unwrap(), b"workbook").unwrap();
assert_eq!(std::fs::read(&ok).unwrap(), b"workbook");
assert!(!dir.join("Book2.xlsx.sctmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {