fix(filesystem): harden legacy filesystem path normalization and strip Windows drive slashes - #287
Conversation
…normalize_file_path Resolves AtomicBot-ai#271
|
Thanks for picking this one up, @s-chudmunge — #271 has been sitting since 4 Sep and a leading slash before a drive letter is exactly the kind of thing that's obvious once someone writes it down and invisible until then. The byte-level check is also the right shape: no regex, no allocation on the common path, and it correctly refuses to fire on a two-character string. Verified on your branch merged onto current
I can't build the Tauri crate on this machine (no GTK dev libraries), so the There's one structural problem, and unfortunately it's the load-bearing one. 1. The fix isn't on the path the bug takes
// WARNING: These APIs will be deprecated soon due to removing FS API access from frontend.The call in #271 is
So this can't close #271 as written. That's not a reason to drop the work — hardening the frontend fs API is worth having on its own — but the PR should stop claiming the issue, and the real fix needs to land where the path actually flows. Could you ask the reporter on #271 for their 2. The strip isn't gated to WindowsThe comment says "on Windows or environments where…", but the code runs everywhere. I compiled the function as it stands and ran it:
3.
|
…rive slashes - Gate Windows drive letter leading slash stripping to Windows (cfg(windows)) - Use strip_prefix instead of global replace for file URI prefixes - Support file:/// URI prefix - Add resolve_path and POSIX preservation unit tests
|
Thanks for the fast turnaround, @s-chudmunge — you took the right fork in the road. Reframing this as "harden the legacy fs path helpers" rather than "closes #271" is exactly what I hoped for, and the title and description now describe what the diff actually does. Points 2, 3 and 4 from last round are all addressed, and 5 has a start. The Verified on your branch merged onto current
Still can't build the Tauri crate here — Two things I'd like before merge, then some smaller ones. 1.
|
| input | main today |
this PR |
|---|---|---|
file:///home/user/file.md |
//home/user/file.md |
home/user/file.md |
file:/// |
// |
`` (empty) |
file:///home/user/file.md is the standard URI for an absolute POSIX path. Stripping all three slashes turns it into a relative path — the leading / is the path, not part of the scheme. main's //home/... was accidental but at least still absolute; this is a regression in the one direction that matters.
Your new test asserts the new behaviour as correct:
assert_eq!(
normalize_file_path("file:///home/user/file.md"),
"home/user/file.md"
);Honest scoping: this is not reachable through resolve_path today. That branch does trim_start_matches('/') and joins onto the data folder, so //home/…, /home/… and home/… all land in the same place. I checked all three forms through the merged resolve_path and they're byte-identical. But normalize_file_path is pub in jan_utils, and your new doc comment now advertises file:/// handling — so the next caller gets the wrong answer with a docstring telling them it's right.
The fix is to strip two slashes (the empty authority), not three, and let the drive-letter check you already wrote handle the rest:
let stripped = if let Some(rest) = path.strip_prefix("file://") {
rest
} else if path.starts_with("file:/") || path.starts_with("file:\\") {
&path["file:".len()..]
} else {
path
};I patched that in and measured it, first as-is and then with the cfg(windows) block forced on to simulate a Windows build:
posix build windows build
file:///home/user/file.md -> /home/user/file.md /home/user/file.md
file:///C:/Users/x/a.md -> /C:/Users/x/a.md C:/Users/x/a.md
file:/C:/Users/x/a.md -> /C:/Users/x/a.md C:/Users/x/a.md
file:\C:\Users\x\a.md -> \C:\Users\x\a.md C:\Users\x\a.md
/C:\Users\User\Documents\f.md -> /C:\Users\...\f.md C:\Users\User\Documents\f.md
\D:\data\models -> \D:\data\models D:\data\models
/home/user/profile:/draft.md -> /home/user/profile:/draft.md (both)
All four assertions in your Windows test still pass, POSIX absoluteness is preserved, and resolve_path's output is unchanged for every input I tried — the trim in that branch absorbs the difference. It also makes file:/home/user/file.md correct (/home/user/file.md), which main gets wrong too.
file:///home/user/file.md in the POSIX test should then expect /home/user/file.md.
2. Nothing in CI runs either of your new tests
This one isn't your fault, but the cfg(windows) gate changed who can see the fix work, so it's worth knowing.
There's no PR workflow in this repo — release.yml is it, and its Rust step is filtered:
cargo test --manifest-path src-tauri/Cargo.toml --lib --features test-tauri core::agent::core::agent:: only. The jan-utils suite and core::filesystem::tests run under make test (Makefile:390–398) and nowhere else. So:
test_normalize_file_path_strips_redundant_leading_slash_on_windows_drivesnow needs a Windows machine runningmake testby hand. Before the gate, it at least ran for anyone on Linux or macOS.test_resolve_pathnever runs automatically on any platform.
Gating the behaviour to Windows is right — I asked for it. But the byte check itself is pure string logic with nothing platform-specific about it, so it doesn't have to be gated to be tested. Lifting it into a small always-compiled helper gets you coverage everywhere:
/// Strips one leading separator from `\C:\…` / `/C:/…`. Always compiled so it
/// can be tested off-Windows; only *applied* on Windows.
fn strip_windows_drive_slash(s: &str) -> Option<&str> {
let b = s.as_bytes();
(b.len() >= 3
&& (b[0] == b'/' || b[0] == b'\\')
&& b[1].is_ascii_alphabetic()
&& b[2] == b':')
.then(|| &s[1..])
}Then normalize_file_path keeps its #[cfg(windows)] block and just calls it, and the test of the byte logic loses its gate. Your call — but as it stands the actual fix has no automated coverage on any runner we have.
3. test_resolve_path doesn't test what this PR changed
Both cases are good to have, but neither exercises the new line:
file://models/model.ggufgoes through theifbranch, which this PR doesn't touch.https://example.com/model.binhits the URL early-return before normalization can matter.
The change is in the else branch. A case that would actually fail without it:
// A non-file: path is passed through untouched, including embedded "file:/"
let weird = resolve_path(app.handle().clone(), "/tmp/a/file:/b.md");
assert_eq!(weird, std::path::PathBuf::from("/tmp/a/file:/b.md"));(That one needs a #[cfg(unix)] gate, and note resolve_path runs canonicalize().unwrap_or(path) on the way out, so it only holds for a path that doesn't exist. A #[cfg(windows)] twin asserting /C:\… → C:\… would cover the other half.)
4. Resolves #271 is still in the branch
The description dropped it — good — but commit ac30484 still carries it:
fix(filesystem): strip leading slash before Windows drive letters in normalize_file_path
Resolves #271
GitHub honours closing keywords in commit messages as well as PR bodies. A squash merge uses the PR title and body, so it'd be fine — but a merge commit would auto-close #271, which we established this doesn't fix. Worth amending so it can't depend on which button gets pressed.
Nits
cargo fmt— two new diffs.path.rs:310collapses the\D:\data\modelsassertion onto one line (same one as last round), andfilesystem/tests.rs:105wants theassert_eq!on thehttps://case split across lines.mainhas 3 pre-existing diffs inpath.rs— please leave those./c:notesisn't in the POSIX test. It's the case from my last comment, it works correctly now, and it's the one a reader will wonder about. One line.- Forward slash in frontof path with 'read' #271 is still waiting. No follow-up on the issue yet asking the reporter for their
mcp_config.jsonand a log line. Not a blocker for this PR, but it's the thread that leads to the actual bug. - Still no CI on the branch — check runs come back
total_count: 0.
Fix 1, decide on 2, and I'll take this. The byte check is correct and the strip_prefix swap was the right instinct — the remaining problem is only about which slash belongs to the scheme 🪟
Generated by Claude Code
Summary
Hardens legacy filesystem path normalization in
normalize_file_pathandresolve_path.Changes
#[cfg(windows)]), preventing unintentional modification of legal POSIX paths like/c:notes..replace("file:/", "")withstrip_prefixforfile:///,file:/, andfile:\to avoid corrupting POSIX paths that contain colons.file:///URIs.jan-utils.resolve_pathinsrc-tauri/src/core/filesystem/tests.rs.