Refuse to save to an unknown file extension instead of writing XLSX under it (fixes #878)

This commit is contained in:
Marco Arnulfo
2026-10-13 04:34:03 +02:00
parent fc45b7f6d8
commit 9bb85ab9af
3 changed files with 33 additions and 3 deletions
+1 -2
View File
@@ -107,9 +107,8 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let bytes = crate::io::save_bytes(&d.wb, &path)?;
crate::io::write_file(&path, &bytes)?;
let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx);
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) {
if matches!(crate::io::FileKind::from_path(&path), Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
d.saved = d.wb.clone();
d.path = Some(path.clone());
}
+7 -1
View File
@@ -154,7 +154,13 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
/// Encodes a workbook in the format chosen by `path`'s extension.
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
// An unknown or missing extension is refused rather than written as an XLSX under that name:
// a typo like `.cvs` would otherwise produce a file no other app recognises.
let Some(kind) = FileKind::from_path(path) else {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
};
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
+25
View File
@@ -1519,6 +1519,31 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
std::fs::remove_dir_all(dir).unwrap();
}
/// A typo like `report.cvs` must not quietly write an XLSX under that name and adopt it as the
/// document's file.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn saving_to_an_unknown_extension_is_refused() {
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
for name in ["report.cvs", "report"] {
let path = dir.join(name);
let e = s.execute("file.save", json!({"path": path})).unwrap_err().to_string();
assert!(e.contains(".xlsx"), "{name}: {e}");
assert!(!path.exists(), "{name} was written");
}
let d = s.doc().unwrap();
assert!(d.path.is_none());
assert!(d.is_dirty());
assert!(s.execute("file.saveBytes", json!({"format": "foo"})).is_err());
s.execute("file.save", json!({"path": dir.join("report.csv")})).unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn validation_formulas_are_relative_and_see_the_new_value() {
let mut s = s();