Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions cmux-tui/crates/acpmux/src/hub/file_uploads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,19 @@ fn session_dir(id: &str) -> Option<PathBuf> {
}

/// 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;
}
Expand All @@ -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}))
Expand Down Expand Up @@ -167,6 +193,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;
Expand Down