From 4a11a89e33a2fd5d8c9b643c18b16d7fab743001 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Jul 2026 14:55:08 +0200 Subject: [PATCH 1/6] fix(quarto-sass): accept CRLF-terminated SCSS layer markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Windows checkout the SCSS sources baked in via include_dir! carry CRLF line endings (git autocrlf). parse_layer's boundary detection uses a $-anchored regex against the raw content; in multiline mode $ matches before the \n but leaves the trailing \r on the marker line, so CRLF-terminated markers like /*-- scss:defaults --*/ fail to match. The gate then reports no boundary markers, theme/bootstrap/highlight layer compilation errors out, and the render silently falls back to base CSS — so on Windows no theme variables, custom-scss markers, or highlight classes reach the output. Normalize CRLF to LF once at parse_layer entry, a single line-ending agnostic chokepoint. SCSS-to-CSS compilation keeps no byte-offset map, so normalizing here is correct (contrast the document-content preserve policy, where byte offsets must survive). The parse loop already strips \r via str::lines(); the only failure point was the boundary-detection gate, but normalizing at entry also guards any future line-anchored scan and keeps a clean LF stream flowing into grass. The CRLF path is covered by an in-source unit test that builds \r\n input inline, so it runs on Linux CI rather than only on a Windows checkout. --- crates/quarto-sass/src/layer.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/quarto-sass/src/layer.rs b/crates/quarto-sass/src/layer.rs index 67df9f669..b68bd4b71 100644 --- a/crates/quarto-sass/src/layer.rs +++ b/crates/quarto-sass/src/layer.rs @@ -102,8 +102,17 @@ enum LayerType { /// assert!(layer.rules.contains(".container")); /// ``` pub fn parse_layer(content: &str, hint: Option<&str>) -> Result { + // Layer parsing is line-ending agnostic. On a Windows checkout the SCSS + // sources baked in via include_dir! carry CRLF (git autocrlf), and the + // $-anchored boundary regex matches before the `\n` but leaves the `\r`, + // so CRLF-terminated markers would be rejected. SCSS -> CSS keeps no + // byte-offset map, so normalizing to LF at this single chokepoint is + // correct (contrast the document-content preserve policy). Normalizing + // here also keeps a clean LF stream flowing into grass. + let content = content.replace("\r\n", "\n"); + // Verify that at least one boundary marker exists - if !LAYER_BOUNDARY_TEST.is_match(content) { + if !LAYER_BOUNDARY_TEST.is_match(&content) { return Err(SassError::NoBoundaryMarkers { hint: hint.map(String::from), }); @@ -395,6 +404,28 @@ $second: 2; assert!(layer.rules.contains(".rule2")); } + #[test] + fn test_parse_crlf_terminated_markers() { + // On a Windows checkout, resources/scss/**/*.scss are baked into the + // binary with CRLF line endings (git autocrlf) via include_dir!. The + // $-anchored boundary regex must still recognize CRLF-terminated + // markers, and no residual `\r` may leak into the content handed to + // grass. SCSS->CSS keeps no byte-offset map, so normalizing here is + // correct (unlike the document-content preserve policy). + let lf = "/*-- scss:defaults --*/\n$primary: blue !default;\n/*-- scss:rules --*/\n.container { color: $primary; }\n"; + let crlf = lf.replace('\n', "\r\n"); + + let layer = + parse_layer(&crlf, Some("theme.scss")).expect("CRLF-terminated markers must parse"); + + assert!(layer.defaults.contains("$primary: blue !default;")); + assert!(layer.rules.contains(".container")); + assert!( + !layer.defaults.contains('\r') && !layer.rules.contains('\r'), + "residual CR leaked into layer content fed to grass" + ); + } + #[test] fn test_merge_layers_defaults_reversed() { let framework = SassLayer { From 40b4bb07a640c7fda117ba1f4a6012c6aa6acff0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Jul 2026 15:29:36 +0200 Subject: [PATCH 2/6] docs(design): embedded-resource virtual-path contract Records the design for fixing Windows `@import` resolution in embedded SCSS: the two path domains (real OS paths vs the forward-slash virtual /__quarto_resources__ namespace), the grass PathBuf::join root cause with source anchors, the native-vs-WASM contract comparison, and the chosen fix (a single canonicalization boundary in EmbeddedResources). Also captures the deferred WASM pool-unification and Windows-CI items and the decisions taken with the design's multi-agent + grass-source research. --- ...embedded-resource-virtual-path-contract.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 claude-notes/designs/embedded-resource-virtual-path-contract.md diff --git a/claude-notes/designs/embedded-resource-virtual-path-contract.md b/claude-notes/designs/embedded-resource-virtual-path-contract.md new file mode 100644 index 000000000..2fefdec56 --- /dev/null +++ b/claude-notes/designs/embedded-resource-virtual-path-contract.md @@ -0,0 +1,159 @@ +# Embedded-resource virtual-path contract + +**Status:** Design (2026-07-03). Fix tracked by strand `bd-nxxgg0pp` +(discovered from `bd-3fgnmlco`, blocks the `bd-22rtwdur` smoke-all umbrella). + +Quarto compiles Bootstrap/theme SCSS from resources embedded at build time +via `include_dir!`. The `@import`/`@use` statements *inside* that embedded +SCSS (e.g. Bootstrap's own `@import "vendor/rfs"`) are resolved at compile +time against a **virtual** load path rooted at `RESOURCE_PATH_PREFIX = +"/__quarto_resources__"`. This document defines the contract that virtual +namespace must obey so resolution behaves identically on every OS and on +both compile backends (native grass, WASM dart-sass). + +## Two path domains + +Two distinct kinds of path flow through the SASS layer, both currently +typed as `std::path::PathBuf`/`&Path`: + +- **(a) Real OS paths** — user project documents, custom theme directories + (`ThemeContext` load paths). `Path::join` and native separators are + *correct* here. This domain is not changed by this work. +- **(b) Virtual embedded-resource namespace** — `/__quarto_resources__/…`. + Must be **forward-slash on every platform**. This is a URL-like opaque + string domain, not a filesystem path. + +The bug (below) is that domain (b) is forced through `std::path` machinery +that is correct only for domain (a). + +## Root cause (Windows) — source-anchored + +On a Windows build, every embedded `@import` fails to resolve, theme/ +bootstrap/highlight compilation errors, and the render silently falls back +to base CSS (`compile_theme_css.rs:547`). Chain: + +1. `default_load_paths()` (`quarto-sass/src/resources.rs:305-310`) returns + virtual paths built with `format!` — forward-slash, e.g. + `/__quarto_resources__/bootstrap/scss`. +2. grass resolves `@import "vendor/rfs"` in `Visitor::find_import` + (`grass_compiler 0.13.4`, `crates/compiler/src/evaluate/visitor.rs:858-859`): + `let path_buf = load_path.join(path);`. `PathBuf::join` is target-OS + aware and inserts `\` on Windows → `/__quarto_resources__/bootstrap/scss\vendor/rfs`. + grass's own `options.rs` documents this limitation ("still using + std::path… constrains to the target platform"). +3. grass calls `self.options.fs.is_file(&path_buf)` on that raw `\`-joined + path (via the `try_path!` macro, visitor.rs:819/823). `Fs::canonicalize` + is **not** a pre-lookup hook — `import_like_node` (visitor.rs:891) calls + it only *after* `find_import` already matched, for the `import_cache`/ + `files_seen` key and `fs.read`. So the `Fs` impl must normalize itself. +4. Our `RuntimeFs` (`quarto-system-runtime/src/sass_native.rs:84-119`) + checks embedded first → `EmbeddedResources::is_file` → + `strip_prefix` (`quarto-sass/src/resources.rs:167-184`), which is pure + `&str` surgery that only ever trims `/`. It yields relative key + `\vendor/rfs.scss`. +5. Embedded file keys are forward-slash — `include_dir_macros 0.7.4` + normalizes `\`→`/` at compile time (`src/lib.rs:134-136`). So + `\vendor/rfs.scss` never matches `vendor/rfs.scss` → miss. + +Empirically verified: `PathBuf::from("/__quarto_resources__/bootstrap/scss") +.join("vendor/_rfs.scss").to_string_lossy()` = `…/scss\vendor/_rfs.scss` on +Windows; `str::lines`-style stripping leaves the leading `\`. Real OS load +paths (custom themes, reveal SCSS) are immune — `RuntimeFs`'s `std::fs` +fallback is separator-tolerant on Windows, and reveal SCSS is fully inlined +(no `find_import`). + +## Native vs WASM contract + +| Aspect | Native (grass) | WASM (dart-sass via JS) | +|---|---|---| +| Prefix | `RESOURCE_PATH_PREFIX` | same constant, re-used | +| Separator | forward-slash by construction; **breaks** when grass's `Path::join` injects `\` | pure forward-slash JS strings; `vfs:` URL importer never touches `path` | +| Lookup | `EmbeddedResources::strip_prefix` accepts 3 path shapes, normalizes to a relative key | JS importer builds full keys itself (`loadPath + "/" + url`), flat exact-match VFS | +| Correctness on Windows | **broken** (this bug) | correct (no `\` ever introduced) | + +WASM already matches the dart-sass best-practice importer contract +(custom `vfs:` scheme, `canonicalize`→`load`, pure string) in +`ts-packages/wasm-js-bridge/src/sass.js`. The native side is the one that +diverges from the shared forward-slash contract. + +**Known WASM divergences (deferred — not this bug):** +- WASM `populate_vfs_with_embedded_resources` + (`wasm-quarto-hub-client/src/lib.rs:64-76`) materializes **only the + Bootstrap pool** into the VFS; native exposes all five pools via + `CombinedResources`. A future cross-pool `@import`-by-path would resolve + native but fail WASM. Currently masked (no such import shipped). +- The `sass-utils` load path is a dead no-op on WASM (nothing stored there). + +Tracked separately (see Deferred work). + +## Fix + +Introduce one documented canonicalization boundary and route the embedded +lookup through it: + +```rust +/// Canonicalize any std::path::Path — including one corrupted by +/// PathBuf::join re-inserting a native separator on Windows — into the +/// forward-slash virtual-resource key space that both the embedded index +/// (include_dir, normalized at build time) and the WASM `vfs:` importer +/// already treat as ground truth. +pub fn to_virtual_key(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} +``` + +The body is deliberately *only* the `\`→`/` replacement. An earlier draft +also collapsed empty segments (`split('/').filter(!empty).join("/")`), but +that **drops the leading slash**, which breaks the very next step in +`strip_prefix` — `.strip_prefix(RESOURCE_PATH_PREFIX)` where +`RESOURCE_PATH_PREFIX` begins with `/`. grass's `load_path.join(path)` +never produces `//` (the load paths have no trailing slash), so there is +nothing to collapse; the existing `trim_start_matches('/')` in +`strip_prefix` already handles leading separators after the prefix strip. +Keep `to_virtual_key` a one-line, idempotent, leading-slash-preserving +replacement — it stays a named function for the documented boundary and +reuse in the key collectors, not for body complexity. + +- **Placement — `EmbeddedResources` only.** Call it at the top of + `strip_prefix` (which every `is_file`/`is_dir`/`read`/`read_str` funnels + through) and in `collect_files`/`collect_directories` for key symmetry. + This is the embedded-namespace boundary — the same "normalize once at the + boundary" idiom `include_dir` and `rust-embed` use. +- **`RuntimeFs` stays untouched.** Its `read`/`is_file` fall back to real + `std::fs` for user files; normalizing `\`→`/` there would mangle genuine + Windows paths (`C:\Users\…`). Normalization is strictly embedded-only. + (This corrects a research suggestion to also normalize in `RuntimeFs`.) +- **Function, not a newtype (for now).** grass's `Fs` and `SystemRuntime` + are hard-typed to `std::path`; a `VirtualPath` newtype would ripple + through both for a currently-single namespace. Promote to a newtype + (rust-analyzer `VfsPath` style) only if the virtual surface grows. +- **Document the two domains** at the top of `resources.rs` so future + embedded providers route through `to_virtual_key` instead of growing + their own string-trimming. + +## Test plan (TDD) + +1. **RED (in-source unit test, runs on Linux CI):** feed a backslash-joined + virtual path (as grass produces on Windows) to `EmbeddedResources::is_file` + / `read` and assert it resolves. Build the `\` path inline so the test + exercises the corruption on any host. Confirm it fails before the fix. +2. **GREEN:** add `to_virtual_key`, wire into `strip_prefix` + collectors. +3. **Regression:** full `cargo nextest run -p quarto-sass` — the 30 + `@import "vendor/rfs"` failures (uncovered once the parse_layer CRLF bug + `bd-3fgnmlco` was fixed) must go to zero. + +## Deferred work (own strands) + +- **Unify embedded pools into the WASM VFS** / drop the dead `sass-utils` + load path, so native and WASM resolve the same cross-pool imports. +- **`windows-latest` CI job** running `compile_scss_with_embedded` against + Bootstrap — CI is Unix-only today, so this bug cannot regress-guard on CI. + The in-source backslash test covers the logic; real-Windows E2E coverage + needs the job. + +## Decisions (2026-07-03, with Chris) + +- Abstraction: `to_virtual_key` free function now; newtype deferred. +- Placement: `EmbeddedResources` boundary only; `RuntimeFs` untouched. +- WASM pool divergence: documented + deferred to a strand. +- Windows CI job: deferred to a strand. From 623acbf5ac76ed4b92aa798cf4324dbeb6c1afc7 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Jul 2026 15:33:12 +0200 Subject: [PATCH 3/6] docs(design): reuse quarto_util::to_forward_slashes for the fix The canonicalization boundary is already owned by quarto-util (to_forward_slashes), byte-identical to the proposed helper and already tested. Reuse it instead of adding a new function, and note the two existing private copies to consolidate. Records the quarto-util dependency addition to quarto-sass and the WASM-build verification step. --- ...embedded-resource-virtual-path-contract.md | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/claude-notes/designs/embedded-resource-virtual-path-contract.md b/claude-notes/designs/embedded-resource-virtual-path-contract.md index 2fefdec56..c2cd87d55 100644 --- a/claude-notes/designs/embedded-resource-virtual-path-contract.md +++ b/claude-notes/designs/embedded-resource-virtual-path-contract.md @@ -88,47 +88,49 @@ Tracked separately (see Deferred work). ## Fix -Introduce one documented canonicalization boundary and route the embedded -lookup through it: +**Reuse the existing shared helper — do not introduce a new function.** +`quarto-util` already owns the canonicalization boundary: ```rust -/// Canonicalize any std::path::Path — including one corrupted by -/// PathBuf::join re-inserting a native separator on Windows — into the -/// forward-slash virtual-resource key space that both the embedded index -/// (include_dir, normalized at build time) and the WASM `vfs:` importer -/// already treat as ground truth. -pub fn to_virtual_key(path: &Path) -> String { +// crates/quarto-util/src/path.rs:23 +pub fn to_forward_slashes(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } ``` -The body is deliberately *only* the `\`→`/` replacement. An earlier draft -also collapsed empty segments (`split('/').filter(!empty).join("/")`), but -that **drops the leading slash**, which breaks the very next step in -`strip_prefix` — `.strip_prefix(RESOURCE_PATH_PREFIX)` where -`RESOURCE_PATH_PREFIX` begins with `/`. grass's `load_path.join(path)` -never produces `//` (the load paths have no trailing slash), so there is -nothing to collapse; the existing `trim_start_matches('/')` in -`strip_prefix` already handles leading separators after the prefix strip. -Keep `to_virtual_key` a one-line, idempotent, leading-slash-preserving -replacement — it stays a named function for the documented boundary and -reuse in the key collectors, not for body complexity. - -- **Placement — `EmbeddedResources` only.** Call it at the top of - `strip_prefix` (which every `is_file`/`is_dir`/`read`/`read_str` funnels - through) and in `collect_files`/`collect_directories` for key symmetry. - This is the embedded-namespace boundary — the same "normalize once at the - boundary" idiom `include_dir` and `rust-embed` use. +This is byte-identical to what an earlier draft proposed as a new +`to_virtual_key`, and it is already unit-tested (unix no-op + Windows +conversion). It is leading-slash-preserving and idempotent — exactly what +`strip_prefix` needs, since the very next step, `.strip_prefix(RESOURCE_PATH_PREFIX)`, +depends on the leading `/`. (An earlier draft's extra +`split('/').filter(!empty).join("/")` would have *dropped* that leading +slash and broken the prefix strip; grass's `load_path.join(path)` never +produces `//` anyway, so only the `\`→`/` replacement is needed.) + +There are already two private copies of this logic +(`quarto-core/src/stage/stages/document_profile.rs:132` `to_forward_slash`, +`quarto-core/src/project/discovery.rs:181` `to_forward_slashes`), which is +exactly the proliferation to avoid — consolidate on the `quarto-util` one. + +- **Add `quarto-util` as a dependency of `quarto-sass`** (it is not one + today). `quarto-util::path` is pure `std` and already reasons about + `wasm32` behavior (`is_rooted`), so it is safe in `quarto-sass`'s WASM + build. Verify the WASM build during implementation. +- **Placement — `EmbeddedResources` only.** Call `to_forward_slashes` at + the top of `strip_prefix` (which every `is_file`/`is_dir`/`read`/`read_str` + funnels through) and in `collect_files`/`collect_directories` for key + symmetry. This is the embedded-namespace boundary — the same "normalize + once at the boundary" idiom `include_dir` and `rust-embed` use. - **`RuntimeFs` stays untouched.** Its `read`/`is_file` fall back to real `std::fs` for user files; normalizing `\`→`/` there would mangle genuine Windows paths (`C:\Users\…`). Normalization is strictly embedded-only. (This corrects a research suggestion to also normalize in `RuntimeFs`.) -- **Function, not a newtype (for now).** grass's `Fs` and `SystemRuntime` - are hard-typed to `std::path`; a `VirtualPath` newtype would ripple - through both for a currently-single namespace. Promote to a newtype - (rust-analyzer `VfsPath` style) only if the virtual surface grows. +- **Shared helper, not a newtype (for now).** grass's `Fs` and + `SystemRuntime` are hard-typed to `std::path`; a `VirtualPath` newtype + would ripple through both for a currently-single namespace. Promote to a + newtype (rust-analyzer `VfsPath` style) only if the virtual surface grows. - **Document the two domains** at the top of `resources.rs` so future - embedded providers route through `to_virtual_key` instead of growing + embedded providers route through `to_forward_slashes` instead of growing their own string-trimming. ## Test plan (TDD) @@ -153,7 +155,9 @@ reuse in the key collectors, not for body complexity. ## Decisions (2026-07-03, with Chris) -- Abstraction: `to_virtual_key` free function now; newtype deferred. +- Abstraction: reuse the existing `quarto_util::to_forward_slashes` (add + `quarto-util` dep to `quarto-sass`); no new function; newtype deferred. + Consolidate the two private dupes onto it opportunistically. - Placement: `EmbeddedResources` boundary only; `RuntimeFs` untouched. - WASM pool divergence: documented + deferred to a strand. - Windows CI job: deferred to a strand. From b23d7e62b27c256599c0e507a9d47eea2b396905 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Jul 2026 15:37:02 +0200 Subject: [PATCH 4/6] docs(design): align test-plan step with the reuse decision --- .../designs/embedded-resource-virtual-path-contract.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/claude-notes/designs/embedded-resource-virtual-path-contract.md b/claude-notes/designs/embedded-resource-virtual-path-contract.md index c2cd87d55..ea17dce6b 100644 --- a/claude-notes/designs/embedded-resource-virtual-path-contract.md +++ b/claude-notes/designs/embedded-resource-virtual-path-contract.md @@ -139,7 +139,10 @@ exactly the proliferation to avoid — consolidate on the `quarto-util` one. virtual path (as grass produces on Windows) to `EmbeddedResources::is_file` / `read` and assert it resolves. Build the `\` path inline so the test exercises the corruption on any host. Confirm it fails before the fix. -2. **GREEN:** add `to_virtual_key`, wire into `strip_prefix` + collectors. +2. **GREEN:** add `quarto-util` as a `quarto-sass` dependency and call + `quarto_util::path::to_forward_slashes` at the top of `strip_prefix` and + in `collect_files`/`collect_directories`. Do **not** add a new + normalization function. 3. **Regression:** full `cargo nextest run -p quarto-sass` — the 30 `@import "vendor/rfs"` failures (uncovered once the parse_layer CRLF bug `bd-3fgnmlco` was fixed) must go to zero. From 4ab5cab760ed786eff8e20de6496b32eea94821d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Jul 2026 15:40:49 +0200 Subject: [PATCH 5/6] fix(quarto-sass): resolve embedded SCSS `@import` on Windows grass resolves `@import`/`@use` by PathBuf::join-ing each load path with the import specifier. Our embedded resources use virtual, forward-slash load paths (/__quarto_resources__/...), but on Windows PathBuf::join inserts a native backslash at the join boundary, and EmbeddedResources' lookup keys are forward-slash (include_dir normalizes at build time). The corrupted key (e.g. "\vendor/_rfs.scss") never matched, so every embedded `@import` inside Bootstrap failed, theme compilation errored, and the render fell back to base CSS on Windows-from-source builds. Normalize to forward slashes at the single embedded-lookup boundary (EmbeddedResources::strip_prefix and the key collectors) using the existing quarto_util::to_forward_slashes helper rather than a new function. RuntimeFs is left untouched so its std::fs fallback keeps genuine native paths intact for real on-disk files. Design and root-cause analysis: claude-notes/designs/embedded-resource-virtual-path-contract.md --- Cargo.lock | 1 + crates/quarto-sass/Cargo.toml | 2 ++ crates/quarto-sass/src/resources.rs | 31 ++++++++++++++++++++++++++--- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f74d796a..71a9671c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3916,6 +3916,7 @@ dependencies = [ "quarto-pandoc-types", "quarto-source-map", "quarto-system-runtime", + "quarto-util", "regex", "serde", "serde_json", diff --git a/crates/quarto-sass/Cargo.toml b/crates/quarto-sass/Cargo.toml index ad682f76a..21277407c 100644 --- a/crates/quarto-sass/Cargo.toml +++ b/crates/quarto-sass/Cargo.toml @@ -17,6 +17,8 @@ once_cell.workspace = true include_dir.workspace = true # Runtime abstraction for cross-platform file access (WASM + native) quarto-system-runtime.workspace = true +# Shared path helpers (to_forward_slashes for virtual-resource key normalization) +quarto-util.workspace = true # ConfigValue for theme config extraction quarto-pandoc-types.workspace = true # SourceInfo for source-mapped diagnostics diff --git a/crates/quarto-sass/src/resources.rs b/crates/quarto-sass/src/resources.rs index b41724c15..e52c14d9e 100644 --- a/crates/quarto-sass/src/resources.rs +++ b/crates/quarto-sass/src/resources.rs @@ -166,7 +166,12 @@ impl EmbeddedResources { /// Strip the resource prefix from a path to get the relative path. fn strip_prefix(&self, path: &Path) -> String { - let path_str = path.to_string_lossy(); + // Normalize to forward slashes first. Embedded keys are forward-slash + // (include_dir normalizes at build time), but grass resolves @import + // via PathBuf::join, which injects a native `\` on Windows into our + // virtual load paths. Canonicalize here — the single embedded-lookup + // boundary — so the string surgery below matches the keys on every OS. + let path_str = quarto_util::path::to_forward_slashes(path); // Strip absolute resource prefix if present let without_abs_prefix = path_str @@ -229,7 +234,7 @@ impl quarto_system_runtime::EmbeddedResourceProvider for EmbeddedResources { /// include_dir, so we don't need to track or add prefixes. fn collect_files(dir: &Dir<'static>, files: &mut HashSet) { for file in dir.files() { - files.insert(file.path().to_string_lossy().to_string()); + files.insert(quarto_util::path::to_forward_slashes(file.path())); } for subdir in dir.dirs() { @@ -243,7 +248,7 @@ fn collect_files(dir: &Dir<'static>, files: &mut HashSet) { /// include_dir, so we don't need to track or add prefixes. fn collect_directories(dir: &Dir<'static>, dirs: &mut HashSet) { for subdir in dir.dirs() { - dirs.insert(subdir.path().to_string_lossy().to_string()); + dirs.insert(quarto_util::path::to_forward_slashes(subdir.path())); collect_directories(subdir, dirs); } } @@ -440,6 +445,26 @@ mod tests { ))); } + #[test] + fn test_is_file_tolerates_backslash_separators() { + // On Windows, grass resolves @import by PathBuf::join-ing a virtual + // load path with the import, which inserts a backslash at the join + // boundary (e.g. Bootstrap's _mixins.scss does `@import "vendor/rfs"`). + // The embedded lookup keys are forward-slash (include_dir normalizes + // at build time), so the lookup must tolerate the backslash shape. + // Built inline so it reproduces on Linux CI, where `\` is an ordinary + // filename byte rather than a separator. + let windows_shape = Path::new("/__quarto_resources__/bootstrap/scss\\vendor/_rfs.scss"); + assert!( + BOOTSTRAP_RESOURCES.is_file(windows_shape), + "backslash-separated virtual path must resolve to the embedded file" + ); + assert!( + BOOTSTRAP_RESOURCES.read(windows_shape).is_some(), + "backslash-separated virtual path must be readable" + ); + } + #[test] fn test_full_prefix() { assert_eq!( From 4ca6ca43ea092c9063127c67379f72907f122ebc Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 3 Jul 2026 16:31:02 +0200 Subject: [PATCH 6/6] fix(quarto-core): handle Windows paths in !path metadata adjustment adjust_paths_to_document_dir rebases !path metadata values (css, resources, bibliography, ...) relative to the document directory. Two Windows-only defects lived in that one function: - The rebased value is stringified with to_string_lossy, so on Windows pathdiff's native separators leaked into the value. Since it is used verbatim in HTML hrefs (a css: !path ), the emitted attribute became href="..\..\shared\styles.css" instead of forward slashes. Normalize at the emission site with quarto_util::to_forward_slashes, the same helper the SCSS and JSON-writer separator fixes reuse. - The relative-path guard used Path::is_relative, which on Windows is false only for drive-prefixed paths; a POSIX-absolute value like /usr/share/base.css is not is_absolute there, so it was wrongly rebased into ../../../usr/share/base.css. Guard on is_rooted (has_root) instead, matching the helper's documented purpose. The suite's dir-metadata-paths fixture and the existing directory_metadata_tests (previously red on Windows, green elsewhere) now pass on all platforms. --- crates/quarto-core/src/project/mod.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/quarto-core/src/project/mod.rs b/crates/quarto-core/src/project/mod.rs index 05c106d23..0ee97d4e6 100644 --- a/crates/quarto-core/src/project/mod.rs +++ b/crates/quarto-core/src/project/mod.rs @@ -230,14 +230,20 @@ fn adjust_paths_recursive(value: &mut ConfigValue, metadata_dir: &Path, document match &mut value.value { ConfigValueKind::Path(path_str) => { let path = PathBuf::from(&*path_str); - // Only adjust relative paths (not absolute, not URLs) - if path.is_relative() + // Only adjust relative paths (not absolute, not URLs). Use + // `is_rooted` (has_root), not `Path::is_relative`: on Windows a + // POSIX-absolute path like `/usr/share/base.css` is not + // `is_absolute` (no drive prefix) and would be wrongly rebased. + if !quarto_util::is_rooted(&path) && !path_str.starts_with("http://") && !path_str.starts_with("https://") { let abs_path = metadata_dir.join(&path); if let Some(adjusted) = pathdiff::diff_paths(&abs_path, document_dir) { - *path_str = adjusted.to_string_lossy().into_owned(); + // The adjusted value is used verbatim in HTML hrefs (e.g. a + // `css: !path` ), so it must use forward slashes on + // every platform; pathdiff yields native separators. + *path_str = quarto_util::to_forward_slashes(&adjusted); } } }