From 6c0886a68ab67e6e6ca62bf6929cc043c751d198 Mon Sep 17 00:00:00 2001 From: Adithya Shankaran <72570811+adikshan11@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:14:47 +0530 Subject: [PATCH 1/5] =?UTF-8?q?Automation=20roots:=20compare=20names=20exa?= =?UTF-8?q?ctly=20and=20refuse=20COM=C2=B9=20to=20LPT=C2=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows a folder can be set to tell case apart, and then IN beside the root in passed as inside it, because names were compared with to_lowercase. Both paths have the spelling the file system gives them by then, so another spelling of the root still matches with an exact compare. A missing name keeps the spelling it was given, but it can only match a root that exists, and resolve has already found that one in its real spelling. is_device_name also missed COM¹, COM², COM³ and LPT¹, LPT², LPT³, which Windows reserves too. It also cut the name only at a period, so COM1: and NUL:x were not seen as device names. --- crates/engine/src/file_access.rs | 80 +++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 303e4c783..27cc2bf67 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -9,7 +9,7 @@ //! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so //! is a link that leads nowhere). //! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. +//! `/work`), exactly: on Windows what exists is resolved to its real case first. //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Roots are in force on a thread inside [`confine`], and in the whole process after @@ -237,30 +237,19 @@ fn resolve(path: &str) -> Result { } } -/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on -/// Windows, whose file systems ignore it)? +/// Is `path` (resolved) `root` or inside it, component by component? Exactly, on Windows too: what +/// exists has its real case from [`resolve`], and a folder that doesn't exist yet may be a different +/// one from the root where a parent folder is case sensitive. fn inside(path: &Path, root: &Path) -> bool { let mut path = path.components(); - root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) + root.components().all(|r| path.next() == Some(r)) } -fn same_component(a: Component, b: Component) -> bool { - if a == b { - return true; - } - if !cfg!(windows) { - return false; - } - match (a.as_os_str().to_str(), b.as_os_str().to_str()) { - (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), - _ => false, - } -} - -/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +/// A Windows device name (`CON`, `NUL`, `COM1`, `COM¹`… also with an extension or a colon), which names no file. 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")); + 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") } @@ -401,16 +390,61 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// A name that differs in case is another name until the file system says otherwise. + #[test] + fn names_are_compared_exactly() { + let root = Path::new("/work/In"); + assert!(inside(Path::new("/work/In/new/a.deckcraft"), root)); + assert!(!inside(Path::new("/work/IN/a.deckcraft"), root)); + assert!(!inside(Path::new("/WORK/In/a.deckcraft"), root)); + assert!(!inside(Path::new("/work/in\u{212A}/a.deckcraft"), Path::new("/work/ink")), "the Kelvin sign folds to k"); + if cfg!(windows) { + let root = Path::new(r"C:\work\In"); + assert!(inside(Path::new(r"c:\work\In\a.deckcraft"), root), "a drive letter is one drive in either case"); + assert!(!inside(Path::new(r"C:\work\IN\a.deckcraft"), root)); + } + } + + /// In a case-sensitive folder, `IN` beside the root `in` is another folder, there or not yet. #[cfg(windows)] #[test] - fn windows_ignores_case_and_refuses_devices() { + fn a_case_sensitive_folder_keeps_a_differently_cased_name_out() { + let base = std::env::temp_dir().join(format!("dc-file-access-case-sensitive-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let set = std::process::Command::new("fsutil").args(["file", "setCaseSensitiveInfo"]).arg(&base).arg("enable").output(); + if !set.as_ref().is_ok_and(|o| o.status.success()) { + eprintln!("skipped: a case-sensitive folder can't be made here: {set:?}"); + let _ = std::fs::remove_dir_all(base); + return; + } + std::fs::create_dir(base.join("in")).unwrap(); + let root = [base.join("in")]; + let roots = AutomationRoots::new(&root, &root).unwrap().unwrap(); + assert_eq!(roots.check(&s(base.join("in/new.deckcraft")), Access::Write), Ok(())); + let e = roots.check(&s(base.join("IN/new.deckcraft")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + std::fs::create_dir(base.join("IN")).unwrap(); + std::fs::write(base.join("IN/secret.deckcraft"), "x").unwrap(); + let e = roots.check(&s(base.join("IN/secret.deckcraft")), Access::Read).unwrap_err(); + assert!(e.contains("outside the read root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_finds_the_real_case_and_refuses_devices() { let (base, roots) = folders("case"); let upper = s(base.join("IN/a.deckcraft")); assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); assert_eq!(roots.check(&s(base.join("In/Sub/New.deckcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("OUT/New.deckcraft")), Access::Write), Ok(())); + assert_eq!(roots.check(&s(base.join("OUT")), Access::Write), Ok(())); let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); assert!(e.contains("reserved device names"), "{e}"); assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); + let e = roots.check(&s(base.join("out/COM¹.txt")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); let _ = std::fs::remove_dir_all(base); } @@ -438,10 +472,10 @@ mod tests { #[test] fn device_names() { - for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL "] { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL ", "COM¹", "Lpt²", "lpt³.txt", "COM1:", "nul:x", "COM¹:"] { assert!(is_device_name(d), "{d}"); } - for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x"] { + for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x", "COM⁴", "com¹0", "lpt¹x", "console:x"] { assert!(!is_device_name(f), "{f}"); } } From 9e6758e2761043b50c7c92f01ab1fd6a8caab615 Mon Sep 17 00:00:00 2001 From: Adithya Shankaran <72570811+adikshan11@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:14:47 +0530 Subject: [PATCH 2/5] Credit my name in the About window --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 87eac2bad..31486f1de 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -73,3 +73,7 @@ sync_github_name = true display_name = "Arshiya" real_name = "Arshiya Rahgozar" sync_github_name = false + +[people.adikshan11] +real_name = "Adithya Shankaran" +sync_github_name = true From fb67888e25d090acc3409bd4af5f2775c021db96 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:29:34 +0000 Subject: [PATCH 3/5] Apply review fixes - docs/mcp.md --- docs/mcp.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 7da2ffb3b..9785365fe 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -56,8 +56,8 @@ folders: its nearest existing folder's links are followed and the rest must be plain names, so `..` below a folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would create its target). The result is compared with the root by whole folder names: `/work2/x` is not - inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like - drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. + inside `/work`. On Windows names are compared exactly after what exists is resolved to its real case (so `OUT\\x` reaches the root `out` on an ordinary volume, but not in a case-sensitive folder), UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`, `COM¹`..., also followed by an extension or a colon) are refused. - A refused path is an ordinary tool error the agent can read and correct, for example `automation path rejected: outside the write root /work/project/out: /etc/x.pptx` or `automation filesystem access is not granted: write authority is absent: /work/project/out/x.pptx`. From 23d0f67d17fa46e40c96e88dabb390ec58524078 Mon Sep 17 00:00:00 2001 From: Adithya Shankaran <72570811+adikshan11@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:45:24 +0000 Subject: [PATCH 4/5] Say what the touched tests check Doc comments only, on the tests this change touches and the ones the docstring scan listed. --- crates/engine/src/file_access.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 27cc2bf67..dc6b1813d 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -354,6 +354,7 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// A root and a path reached through links are compared after the links are followed. #[test] fn links_are_followed_before_comparing() { let (base, roots) = folders("links"); @@ -407,6 +408,7 @@ mod tests { /// In a case-sensitive folder, `IN` beside the root `in` is another folder, there or not yet. #[cfg(windows)] + /// In a case-sensitive Windows folder, a sibling named in another case stays outside the root. #[test] fn a_case_sensitive_folder_keeps_a_differently_cased_name_out() { let base = std::env::temp_dir().join(format!("dc-file-access-case-sensitive-{}", std::process::id())); @@ -432,6 +434,7 @@ mod tests { } #[cfg(windows)] + /// On Windows a path finds its real case on disk, and device names are refused. #[test] fn windows_finds_the_real_case_and_refuses_devices() { let (base, roots) = folders("case"); @@ -449,6 +452,7 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// Scopes nest, and a scope applies only on the thread that set it. #[test] fn scopes_nest_and_stay_on_their_thread() { let (base, roots) = folders("scopes"); @@ -470,6 +474,7 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// What is a device name, with superscript digits and a name before a colon, and what isn't. #[test] fn device_names() { for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL ", "COM¹", "Lpt²", "lpt³.txt", "COM1:", "nul:x", "COM¹:"] { From 2d1e6ff34893bf234c5f5b11c82073f06e22970d Mon Sep 17 00:00:00 2001 From: Adithya Shankaran <72570811+adikshan11@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:10:41 +0000 Subject: [PATCH 5/5] Drop the repeated test comment and show one backslash in the docs The case-sensitive Windows test already says what it checks. In the MCP docs the OUT example is a code span, so one backslash is what a reader sees. --- crates/engine/src/file_access.rs | 1 - docs/mcp.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index dc6b1813d..4916d9914 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -408,7 +408,6 @@ mod tests { /// In a case-sensitive folder, `IN` beside the root `in` is another folder, there or not yet. #[cfg(windows)] - /// In a case-sensitive Windows folder, a sibling named in another case stays outside the root. #[test] fn a_case_sensitive_folder_keeps_a_differently_cased_name_out() { let base = std::env::temp_dir().join(format!("dc-file-access-case-sensitive-{}", std::process::id())); diff --git a/docs/mcp.md b/docs/mcp.md index 9785365fe..b69e17932 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -56,7 +56,7 @@ folders: its nearest existing folder's links are followed and the rest must be plain names, so `..` below a folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would create its target). The result is compared with the root by whole folder names: `/work2/x` is not - inside `/work`. On Windows names are compared exactly after what exists is resolved to its real case (so `OUT\\x` reaches the root `out` on an ordinary volume, but not in a case-sensitive folder), UNC paths (`\\server\share\...`) are resolved like + inside `/work`. On Windows names are compared exactly after what exists is resolved to its real case (so `OUT\x` reaches the root `out` on an ordinary volume, but not in a case-sensitive folder), UNC paths (`\\server\share\...`) are resolved like drive-letter ones, and device names (`CON`, `NUL`, `COM1`, `COM¹`..., also followed by an extension or a colon) are refused. - A refused path is an ordinary tool error the agent can read and correct, for example `automation path rejected: outside the write root /work/project/out: /etc/x.pptx` or