diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index b7a0e2f29..50033eff7 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -331,11 +331,21 @@ fn library_tab_name(file: &str, lib_name: &str) -> String { } /// The file name of library `name` with extension `ext`: characters file systems reject become -/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`. +/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`, and +/// one that is a Windows device name (`CON`, `NUL`, `COM1`…, also with an extension) gets a `-` +/// after its first part (`CON-.json`, `CON-.txt.json`). pub fn library_file_name(name: &str, ext: &str) -> String { let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); let base = base.trim_matches(['.', ' ']); - format!("{}.{ext}", if base.is_empty() { "Library" } else { base }) + if base.is_empty() { + format!("Library.{ext}") + } else if crate::file_access::is_device_name(base) { + // The part before the first dot is what makes it a device, so the `-` goes there. + let (stem, rest) = base.split_once('.').map_or((base, String::new()), |(stem, rest)| (stem, format!(".{rest}"))); + format!("{stem}-{rest}.{ext}") + } else { + format!("{base}.{ext}") + } } /// The library files with extensions `exts` in folder `dir`, sorted by name. diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 993006513..56db72a22 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -274,7 +274,7 @@ fn same_component(a: Component, b: Component) -> bool { } /// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. -fn is_device_name(component: &str) -> bool { +pub(crate) fn is_device_name(component: &str) -> bool { let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index c20968db5..7337d6b74 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -637,3 +637,22 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi run(&mut s, "swatch.edit", json!({"name": "Red", "paint": {"color": "#00ff00"}})); assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into())); } + +/// #3602: a library titled with a Windows device name is saved under a name that is not one. +#[test] +fn a_library_file_name_is_never_a_windows_device_name() { + use crate::cmd::swatchlib::library_file_name; + for (title, ext) in + [("CON", "json"), ("nul", "json"), ("Com1", "acb"), ("LPT9", "ase"), ("aux.", "json"), ("CON.txt", "json"), ("nul.tar.gz", "acb")] + { + let file = library_file_name(title, ext); + assert!(!crate::file_access::is_device_name(&file), "{title}: {file}"); + assert!( + file.ends_with(&format!(".{ext}")) + && file.to_ascii_lowercase().starts_with(&title.split('.').next().unwrap_or_default().to_ascii_lowercase()), + "{title}: {file}" + ); + } + assert_eq!(library_file_name("Console", "json"), "Console.json"); + assert_eq!(library_file_name("COM10", "json"), "COM10.json"); +}