Skip to content

Commit 1954462

Browse files
authored
Merge pull request #374 from quarto-dev/bugfix/bd-22rtwdur-windows-smoke-all-suite
Fix Windows-only theme CSS fallback and metadata href separators
2 parents 64e459c + 4ca6ca4 commit 1954462

6 files changed

Lines changed: 238 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Embedded-resource virtual-path contract
2+
3+
**Status:** Design (2026-07-03). Fix tracked by strand `bd-nxxgg0pp`
4+
(discovered from `bd-3fgnmlco`, blocks the `bd-22rtwdur` smoke-all umbrella).
5+
6+
Quarto compiles Bootstrap/theme SCSS from resources embedded at build time
7+
via `include_dir!`. The `@import`/`@use` statements *inside* that embedded
8+
SCSS (e.g. Bootstrap's own `@import "vendor/rfs"`) are resolved at compile
9+
time against a **virtual** load path rooted at `RESOURCE_PATH_PREFIX =
10+
"/__quarto_resources__"`. This document defines the contract that virtual
11+
namespace must obey so resolution behaves identically on every OS and on
12+
both compile backends (native grass, WASM dart-sass).
13+
14+
## Two path domains
15+
16+
Two distinct kinds of path flow through the SASS layer, both currently
17+
typed as `std::path::PathBuf`/`&Path`:
18+
19+
- **(a) Real OS paths** — user project documents, custom theme directories
20+
(`ThemeContext` load paths). `Path::join` and native separators are
21+
*correct* here. This domain is not changed by this work.
22+
- **(b) Virtual embedded-resource namespace**`/__quarto_resources__/…`.
23+
Must be **forward-slash on every platform**. This is a URL-like opaque
24+
string domain, not a filesystem path.
25+
26+
The bug (below) is that domain (b) is forced through `std::path` machinery
27+
that is correct only for domain (a).
28+
29+
## Root cause (Windows) — source-anchored
30+
31+
On a Windows build, every embedded `@import` fails to resolve, theme/
32+
bootstrap/highlight compilation errors, and the render silently falls back
33+
to base CSS (`compile_theme_css.rs:547`). Chain:
34+
35+
1. `default_load_paths()` (`quarto-sass/src/resources.rs:305-310`) returns
36+
virtual paths built with `format!` — forward-slash, e.g.
37+
`/__quarto_resources__/bootstrap/scss`.
38+
2. grass resolves `@import "vendor/rfs"` in `Visitor::find_import`
39+
(`grass_compiler 0.13.4`, `crates/compiler/src/evaluate/visitor.rs:858-859`):
40+
`let path_buf = load_path.join(path);`. `PathBuf::join` is target-OS
41+
aware and inserts `\` on Windows → `/__quarto_resources__/bootstrap/scss\vendor/rfs`.
42+
grass's own `options.rs` documents this limitation ("still using
43+
std::path… constrains to the target platform").
44+
3. grass calls `self.options.fs.is_file(&path_buf)` on that raw `\`-joined
45+
path (via the `try_path!` macro, visitor.rs:819/823). `Fs::canonicalize`
46+
is **not** a pre-lookup hook — `import_like_node` (visitor.rs:891) calls
47+
it only *after* `find_import` already matched, for the `import_cache`/
48+
`files_seen` key and `fs.read`. So the `Fs` impl must normalize itself.
49+
4. Our `RuntimeFs` (`quarto-system-runtime/src/sass_native.rs:84-119`)
50+
checks embedded first → `EmbeddedResources::is_file`
51+
`strip_prefix` (`quarto-sass/src/resources.rs:167-184`), which is pure
52+
`&str` surgery that only ever trims `/`. It yields relative key
53+
`\vendor/rfs.scss`.
54+
5. Embedded file keys are forward-slash — `include_dir_macros 0.7.4`
55+
normalizes `\``/` at compile time (`src/lib.rs:134-136`). So
56+
`\vendor/rfs.scss` never matches `vendor/rfs.scss` → miss.
57+
58+
Empirically verified: `PathBuf::from("/__quarto_resources__/bootstrap/scss")
59+
.join("vendor/_rfs.scss").to_string_lossy()` = `…/scss\vendor/_rfs.scss` on
60+
Windows; `str::lines`-style stripping leaves the leading `\`. Real OS load
61+
paths (custom themes, reveal SCSS) are immune — `RuntimeFs`'s `std::fs`
62+
fallback is separator-tolerant on Windows, and reveal SCSS is fully inlined
63+
(no `find_import`).
64+
65+
## Native vs WASM contract
66+
67+
| Aspect | Native (grass) | WASM (dart-sass via JS) |
68+
|---|---|---|
69+
| Prefix | `RESOURCE_PATH_PREFIX` | same constant, re-used |
70+
| Separator | forward-slash by construction; **breaks** when grass's `Path::join` injects `\` | pure forward-slash JS strings; `vfs:` URL importer never touches `path` |
71+
| 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 |
72+
| Correctness on Windows | **broken** (this bug) | correct (no `\` ever introduced) |
73+
74+
WASM already matches the dart-sass best-practice importer contract
75+
(custom `vfs:` scheme, `canonicalize``load`, pure string) in
76+
`ts-packages/wasm-js-bridge/src/sass.js`. The native side is the one that
77+
diverges from the shared forward-slash contract.
78+
79+
**Known WASM divergences (deferred — not this bug):**
80+
- WASM `populate_vfs_with_embedded_resources`
81+
(`wasm-quarto-hub-client/src/lib.rs:64-76`) materializes **only the
82+
Bootstrap pool** into the VFS; native exposes all five pools via
83+
`CombinedResources`. A future cross-pool `@import`-by-path would resolve
84+
native but fail WASM. Currently masked (no such import shipped).
85+
- The `sass-utils` load path is a dead no-op on WASM (nothing stored there).
86+
87+
Tracked separately (see Deferred work).
88+
89+
## Fix
90+
91+
**Reuse the existing shared helper — do not introduce a new function.**
92+
`quarto-util` already owns the canonicalization boundary:
93+
94+
```rust
95+
// crates/quarto-util/src/path.rs:23
96+
pub fn to_forward_slashes(path: &Path) -> String {
97+
path.to_string_lossy().replace('\\', "/")
98+
}
99+
```
100+
101+
This is byte-identical to what an earlier draft proposed as a new
102+
`to_virtual_key`, and it is already unit-tested (unix no-op + Windows
103+
conversion). It is leading-slash-preserving and idempotent — exactly what
104+
`strip_prefix` needs, since the very next step, `.strip_prefix(RESOURCE_PATH_PREFIX)`,
105+
depends on the leading `/`. (An earlier draft's extra
106+
`split('/').filter(!empty).join("/")` would have *dropped* that leading
107+
slash and broken the prefix strip; grass's `load_path.join(path)` never
108+
produces `//` anyway, so only the `\``/` replacement is needed.)
109+
110+
There are already two private copies of this logic
111+
(`quarto-core/src/stage/stages/document_profile.rs:132` `to_forward_slash`,
112+
`quarto-core/src/project/discovery.rs:181` `to_forward_slashes`), which is
113+
exactly the proliferation to avoid — consolidate on the `quarto-util` one.
114+
115+
- **Add `quarto-util` as a dependency of `quarto-sass`** (it is not one
116+
today). `quarto-util::path` is pure `std` and already reasons about
117+
`wasm32` behavior (`is_rooted`), so it is safe in `quarto-sass`'s WASM
118+
build. Verify the WASM build during implementation.
119+
- **Placement — `EmbeddedResources` only.** Call `to_forward_slashes` at
120+
the top of `strip_prefix` (which every `is_file`/`is_dir`/`read`/`read_str`
121+
funnels through) and in `collect_files`/`collect_directories` for key
122+
symmetry. This is the embedded-namespace boundary — the same "normalize
123+
once at the boundary" idiom `include_dir` and `rust-embed` use.
124+
- **`RuntimeFs` stays untouched.** Its `read`/`is_file` fall back to real
125+
`std::fs` for user files; normalizing `\``/` there would mangle genuine
126+
Windows paths (`C:\Users\…`). Normalization is strictly embedded-only.
127+
(This corrects a research suggestion to also normalize in `RuntimeFs`.)
128+
- **Shared helper, not a newtype (for now).** grass's `Fs` and
129+
`SystemRuntime` are hard-typed to `std::path`; a `VirtualPath` newtype
130+
would ripple through both for a currently-single namespace. Promote to a
131+
newtype (rust-analyzer `VfsPath` style) only if the virtual surface grows.
132+
- **Document the two domains** at the top of `resources.rs` so future
133+
embedded providers route through `to_forward_slashes` instead of growing
134+
their own string-trimming.
135+
136+
## Test plan (TDD)
137+
138+
1. **RED (in-source unit test, runs on Linux CI):** feed a backslash-joined
139+
virtual path (as grass produces on Windows) to `EmbeddedResources::is_file`
140+
/ `read` and assert it resolves. Build the `\` path inline so the test
141+
exercises the corruption on any host. Confirm it fails before the fix.
142+
2. **GREEN:** add `quarto-util` as a `quarto-sass` dependency and call
143+
`quarto_util::path::to_forward_slashes` at the top of `strip_prefix` and
144+
in `collect_files`/`collect_directories`. Do **not** add a new
145+
normalization function.
146+
3. **Regression:** full `cargo nextest run -p quarto-sass` — the 30
147+
`@import "vendor/rfs"` failures (uncovered once the parse_layer CRLF bug
148+
`bd-3fgnmlco` was fixed) must go to zero.
149+
150+
## Deferred work (own strands)
151+
152+
- **Unify embedded pools into the WASM VFS** / drop the dead `sass-utils`
153+
load path, so native and WASM resolve the same cross-pool imports.
154+
- **`windows-latest` CI job** running `compile_scss_with_embedded` against
155+
Bootstrap — CI is Unix-only today, so this bug cannot regress-guard on CI.
156+
The in-source backslash test covers the logic; real-Windows E2E coverage
157+
needs the job.
158+
159+
## Decisions (2026-07-03, with Chris)
160+
161+
- Abstraction: reuse the existing `quarto_util::to_forward_slashes` (add
162+
`quarto-util` dep to `quarto-sass`); no new function; newtype deferred.
163+
Consolidate the two private dupes onto it opportunistically.
164+
- Placement: `EmbeddedResources` boundary only; `RuntimeFs` untouched.
165+
- WASM pool divergence: documented + deferred to a strand.
166+
- Windows CI job: deferred to a strand.

crates/quarto-core/src/project/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,14 +230,20 @@ fn adjust_paths_recursive(value: &mut ConfigValue, metadata_dir: &Path, document
230230
match &mut value.value {
231231
ConfigValueKind::Path(path_str) => {
232232
let path = PathBuf::from(&*path_str);
233-
// Only adjust relative paths (not absolute, not URLs)
234-
if path.is_relative()
233+
// Only adjust relative paths (not absolute, not URLs). Use
234+
// `is_rooted` (has_root), not `Path::is_relative`: on Windows a
235+
// POSIX-absolute path like `/usr/share/base.css` is not
236+
// `is_absolute` (no drive prefix) and would be wrongly rebased.
237+
if !quarto_util::is_rooted(&path)
235238
&& !path_str.starts_with("http://")
236239
&& !path_str.starts_with("https://")
237240
{
238241
let abs_path = metadata_dir.join(&path);
239242
if let Some(adjusted) = pathdiff::diff_paths(&abs_path, document_dir) {
240-
*path_str = adjusted.to_string_lossy().into_owned();
243+
// The adjusted value is used verbatim in HTML hrefs (e.g. a
244+
// `css: !path` <link>), so it must use forward slashes on
245+
// every platform; pathdiff yields native separators.
246+
*path_str = quarto_util::to_forward_slashes(&adjusted);
241247
}
242248
}
243249
}

crates/quarto-sass/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ once_cell.workspace = true
1717
include_dir.workspace = true
1818
# Runtime abstraction for cross-platform file access (WASM + native)
1919
quarto-system-runtime.workspace = true
20+
# Shared path helpers (to_forward_slashes for virtual-resource key normalization)
21+
quarto-util.workspace = true
2022
# ConfigValue for theme config extraction
2123
quarto-pandoc-types.workspace = true
2224
# SourceInfo for source-mapped diagnostics

crates/quarto-sass/src/layer.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,17 @@ enum LayerType {
102102
/// assert!(layer.rules.contains(".container"));
103103
/// ```
104104
pub fn parse_layer(content: &str, hint: Option<&str>) -> Result<SassLayer, SassError> {
105+
// Layer parsing is line-ending agnostic. On a Windows checkout the SCSS
106+
// sources baked in via include_dir! carry CRLF (git autocrlf), and the
107+
// $-anchored boundary regex matches before the `\n` but leaves the `\r`,
108+
// so CRLF-terminated markers would be rejected. SCSS -> CSS keeps no
109+
// byte-offset map, so normalizing to LF at this single chokepoint is
110+
// correct (contrast the document-content preserve policy). Normalizing
111+
// here also keeps a clean LF stream flowing into grass.
112+
let content = content.replace("\r\n", "\n");
113+
105114
// Verify that at least one boundary marker exists
106-
if !LAYER_BOUNDARY_TEST.is_match(content) {
115+
if !LAYER_BOUNDARY_TEST.is_match(&content) {
107116
return Err(SassError::NoBoundaryMarkers {
108117
hint: hint.map(String::from),
109118
});
@@ -395,6 +404,28 @@ $second: 2;
395404
assert!(layer.rules.contains(".rule2"));
396405
}
397406

407+
#[test]
408+
fn test_parse_crlf_terminated_markers() {
409+
// On a Windows checkout, resources/scss/**/*.scss are baked into the
410+
// binary with CRLF line endings (git autocrlf) via include_dir!. The
411+
// $-anchored boundary regex must still recognize CRLF-terminated
412+
// markers, and no residual `\r` may leak into the content handed to
413+
// grass. SCSS->CSS keeps no byte-offset map, so normalizing here is
414+
// correct (unlike the document-content preserve policy).
415+
let lf = "/*-- scss:defaults --*/\n$primary: blue !default;\n/*-- scss:rules --*/\n.container { color: $primary; }\n";
416+
let crlf = lf.replace('\n', "\r\n");
417+
418+
let layer =
419+
parse_layer(&crlf, Some("theme.scss")).expect("CRLF-terminated markers must parse");
420+
421+
assert!(layer.defaults.contains("$primary: blue !default;"));
422+
assert!(layer.rules.contains(".container"));
423+
assert!(
424+
!layer.defaults.contains('\r') && !layer.rules.contains('\r'),
425+
"residual CR leaked into layer content fed to grass"
426+
);
427+
}
428+
398429
#[test]
399430
fn test_merge_layers_defaults_reversed() {
400431
let framework = SassLayer {

crates/quarto-sass/src/resources.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,12 @@ impl EmbeddedResources {
166166

167167
/// Strip the resource prefix from a path to get the relative path.
168168
fn strip_prefix(&self, path: &Path) -> String {
169-
let path_str = path.to_string_lossy();
169+
// Normalize to forward slashes first. Embedded keys are forward-slash
170+
// (include_dir normalizes at build time), but grass resolves @import
171+
// via PathBuf::join, which injects a native `\` on Windows into our
172+
// virtual load paths. Canonicalize here — the single embedded-lookup
173+
// boundary — so the string surgery below matches the keys on every OS.
174+
let path_str = quarto_util::path::to_forward_slashes(path);
170175

171176
// Strip absolute resource prefix if present
172177
let without_abs_prefix = path_str
@@ -229,7 +234,7 @@ impl quarto_system_runtime::EmbeddedResourceProvider for EmbeddedResources {
229234
/// include_dir, so we don't need to track or add prefixes.
230235
fn collect_files(dir: &Dir<'static>, files: &mut HashSet<String>) {
231236
for file in dir.files() {
232-
files.insert(file.path().to_string_lossy().to_string());
237+
files.insert(quarto_util::path::to_forward_slashes(file.path()));
233238
}
234239

235240
for subdir in dir.dirs() {
@@ -243,7 +248,7 @@ fn collect_files(dir: &Dir<'static>, files: &mut HashSet<String>) {
243248
/// include_dir, so we don't need to track or add prefixes.
244249
fn collect_directories(dir: &Dir<'static>, dirs: &mut HashSet<String>) {
245250
for subdir in dir.dirs() {
246-
dirs.insert(subdir.path().to_string_lossy().to_string());
251+
dirs.insert(quarto_util::path::to_forward_slashes(subdir.path()));
247252
collect_directories(subdir, dirs);
248253
}
249254
}
@@ -440,6 +445,26 @@ mod tests {
440445
)));
441446
}
442447

448+
#[test]
449+
fn test_is_file_tolerates_backslash_separators() {
450+
// On Windows, grass resolves @import by PathBuf::join-ing a virtual
451+
// load path with the import, which inserts a backslash at the join
452+
// boundary (e.g. Bootstrap's _mixins.scss does `@import "vendor/rfs"`).
453+
// The embedded lookup keys are forward-slash (include_dir normalizes
454+
// at build time), so the lookup must tolerate the backslash shape.
455+
// Built inline so it reproduces on Linux CI, where `\` is an ordinary
456+
// filename byte rather than a separator.
457+
let windows_shape = Path::new("/__quarto_resources__/bootstrap/scss\\vendor/_rfs.scss");
458+
assert!(
459+
BOOTSTRAP_RESOURCES.is_file(windows_shape),
460+
"backslash-separated virtual path must resolve to the embedded file"
461+
);
462+
assert!(
463+
BOOTSTRAP_RESOURCES.read(windows_shape).is_some(),
464+
"backslash-separated virtual path must be readable"
465+
);
466+
}
467+
443468
#[test]
444469
fn test_full_prefix() {
445470
assert_eq!(

0 commit comments

Comments
 (0)