From c13a48caf6b0bfefcf9052b5e69d7132f5380e12 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 09:17:09 -0700 Subject: [PATCH 1/2] acpmux file_put: failing test, Windows device names pass the name check On a Windows peer `uploads\\\CON` opens the console and `nul.txt` the null device, and a trailing dot or space is dropped, yet is_file_name accepts all of them. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15d3d-4bad-7860-b096-c54282b4bec4&host=cosmic-gray --- .../crates/acpmux/src/hub/file_uploads.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmux-tui/crates/acpmux/src/hub/file_uploads.rs b/cmux-tui/crates/acpmux/src/hub/file_uploads.rs index 67b6d0739aed..0767aecbc522 100644 --- a/cmux-tui/crates/acpmux/src/hub/file_uploads.rs +++ b/cmux-tui/crates/acpmux/src/hub/file_uploads.rs @@ -167,6 +167,34 @@ mod tests { } } + #[test] + fn a_windows_device_name_is_no_file_name_on_windows() { + // Windows opens these as devices in any folder (`dir\CON` is the console), and drops a + // trailing dot or space, so the write would not land in the upload's own file. + for bad in [ + "CON", + "con.txt", + "NUL", + "nul.tar.gz", + "AUX", + "PRN", + "COM1", + "lpt9.log", + "COM\u{b9}", + "CONIN$", + "a.", + "a ", + ] { + assert!(!is_file_name_for(bad, true), "{bad:?}"); + assert!(is_file_name_for(bad, false), "{bad:?}"); + } + for good in + ["CONSOLE.txt", "COM10", "COM0", "nullable.rs", "aux_data.csv", "LPT", "report.pdf"] + { + assert!(is_file_name_for(good, true), "{good:?}"); + } + } + #[test] fn one_byte_past_the_cap_is_refused_before_it_is_sent() { use base64::Engine; From 59b868bd462a6d518fe24e7f611c43182c6c409d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 09:17:35 -0700 Subject: [PATCH 2/2] acpmux file_put: refuse Windows device names on a Windows daemon A name whose stem is CON, PRN, AUX, NUL, COM1-9, LPT1-9 (or their superscript forms), CONIN$ or CONOUT$, with any extension and in any case, or one that ends in a dot or a space, is no file name on Windows. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15d3d-4bad-7860-b096-c54282b4bec4&host=cosmic-gray --- .../crates/acpmux/src/hub/file_uploads.rs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/hub/file_uploads.rs b/cmux-tui/crates/acpmux/src/hub/file_uploads.rs index 0767aecbc522..75038668ef59 100644 --- a/cmux-tui/crates/acpmux/src/hub/file_uploads.rs +++ b/cmux-tui/crates/acpmux/src/hub/file_uploads.rs @@ -33,14 +33,19 @@ fn session_dir(id: &str) -> Option { } /// A name a folder holds as one plain file (the app's `AgentPaneUpload.isFileName`): 1 to 255 -/// UTF-8 bytes, not a dot file (nor `.` or `..`), no `/` (nor `\` or `:` on Windows, where a -/// drive prefix such as `C:x` replaces the folder it is joined to), one plain path component, and -/// no control or bidirectional formatting character. +/// UTF-8 bytes, not a dot file (nor `.` or `..`), no `/`, one plain path component, and no control +/// or bidirectional formatting character. On Windows also no `\` or `:` (a drive prefix such as +/// `C:x` replaces the folder it is joined to) and no device name (`windows_device_name`). pub fn is_file_name(name: &str) -> bool { + is_file_name_for(name, cfg!(windows)) +} + +/// `is_file_name` with Windows' rules when `windows`. +fn is_file_name_for(name: &str, windows: bool) -> bool { if !(1..=255).contains(&name.len()) || name.starts_with('.') || name.contains('/') - || (cfg!(windows) && (name.contains('\\') || name.contains(':'))) + || (windows && (name.contains('\\') || name.contains(':') || windows_device_name(name))) { return false; } @@ -60,6 +65,27 @@ pub fn is_file_name(name: &str) -> bool { }) } +/// A name Windows does not keep as its own file in a folder: a device (`CON`, `PRN`, `AUX`, `NUL`, +/// `COM1`-`COM9`, `LPT1`-`LPT9` and their superscript-digit forms, `CONIN$`, `CONOUT$`) with any +/// extension, in any case, and a name that ends in a dot or a space, which Windows drops. +/// https://learn.microsoft.com/windows/win32/fileio/naming-a-file#naming-conventions +fn windows_device_name(name: &str) -> bool { + if name.ends_with(['.', ' ']) { + return true; + } + let stem = name.split('.').next().unwrap_or(name).trim_end_matches(' ').to_uppercase(); + if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") { + return true; + } + let mut chars = stem.chars(); + let head: String = chars.by_ref().take(3).collect(); + matches!(head.as_str(), "COM" | "LPT") + && matches!( + (chars.next(), chars.next()), + (Some('1'..='9' | '\u{b9}' | '\u{b2}' | '\u{b3}'), None) + ) +} + fn too_large() -> RpcError { RpcError::invalid_params(format!("a file is at most {} MiB", MAX_BYTES >> 20)) .with_data(json!({"reason": "filePut.tooLarge", "maxBytes": MAX_BYTES}))