Skip to content

Commit a06119a

Browse files
committed
fix: bypass sass.bat on Windows, call dart.exe directly (#14267, #6651)
Instead of invoking sass.bat through safeWindowsExec (which creates a temp .bat file via cmd /c), call dart.exe + sass.snapshot directly. This eliminates three classes of Windows .bat file issues: - Deno quoting bug with spaced paths (#13997) - cmd.exe OEM code page misreading UTF-8 accented paths (#14267) - Enterprise group policy blocking .bat execution (#6651) Follows the pattern established by verapdf.ts which bypasses its .bat wrapper for the same reasons.
1 parent bd8d5b6 commit a06119a

3 files changed

Lines changed: 141 additions & 91 deletions

File tree

news/changelog-1.10.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
All changes included in 1.10:
22

3+
## Regression fixes
4+
5+
- ([#14267](https://github.com/quarto-dev/quarto-cli/issues/14267)): Fix Windows paths with accented characters (e.g., `C:\Users\Sébastien\`) breaking dart-sass compilation.
6+
- ([#6651](https://github.com/quarto-dev/quarto-cli/issues/6651)): Fix dart-sass compilation failing in enterprise environments where `.bat` files are blocked by group policy.
7+
38
## Formats
49

510
### `typst`

src/core/dart-sass.ts

Lines changed: 58 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@ import { join } from "../deno_ral/path.ts";
77

88
import { architectureToolsPath } from "./resources.ts";
99
import { execProcess } from "./process.ts";
10-
import { ProcessResult } from "./process-types.ts";
1110
import { TempContext } from "./temp.ts";
1211
import { lines } from "./text.ts";
1312
import { debug, info } from "../deno_ral/log.ts";
1413
import { existsSync } from "../deno_ral/fs.ts";
1514
import { warnOnce } from "./log.ts";
1615
import { isWindows } from "../deno_ral/platform.ts";
17-
import { requireQuoting, safeWindowsExec } from "./windows.ts";
1816

1917
export function dartSassInstallDir() {
2018
return architectureToolsPath("dart-sass");
@@ -60,80 +58,75 @@ export async function dartCompile(
6058
*/
6159
export interface DartCommandOptions {
6260
/**
63-
* Override the sass executable path.
64-
* Primarily used for testing with spaced paths.
61+
* Override the dart-sass install directory.
62+
* Used for testing with non-standard paths (spaces, accented characters).
6563
*/
66-
sassPath?: string;
64+
installDir?: string;
6765
}
6866

69-
export async function dartCommand(
70-
args: string[],
71-
options?: DartCommandOptions,
72-
) {
73-
const resolvePath = () => {
74-
const dartOverrideCmd = Deno.env.get("QUARTO_DART_SASS");
75-
if (dartOverrideCmd) {
76-
if (!existsSync(dartOverrideCmd)) {
77-
warnOnce(
78-
`Specified QUARTO_DART_SASS does not exist, using built in dart sass.`,
79-
);
80-
} else {
81-
return dartOverrideCmd;
82-
}
83-
}
84-
85-
const command = isWindows ? "sass.bat" : "sass";
86-
return architectureToolsPath(join("dart-sass", command));
87-
};
88-
const sass = options?.sassPath ?? resolvePath();
89-
90-
// Process result helper (shared by Windows and non-Windows paths)
91-
const processResult = (result: ProcessResult): string | undefined => {
92-
if (result.success) {
93-
if (result.stderr) {
94-
info(result.stderr);
95-
}
96-
return result.stdout;
67+
/**
68+
* Resolve the dart-sass command and its base arguments.
69+
*
70+
* On Windows, calls dart.exe + sass.snapshot directly to avoid .bat file
71+
* issues: Deno quoting bugs (#13997), cmd.exe encoding (#14267), and
72+
* enterprise .bat blocking (#6651).
73+
*/
74+
function resolveSassCommand(options?: DartCommandOptions): {
75+
cmd: string;
76+
baseArgs: string[];
77+
} {
78+
const dartOverrideCmd = Deno.env.get("QUARTO_DART_SASS");
79+
if (dartOverrideCmd) {
80+
if (!existsSync(dartOverrideCmd)) {
81+
warnOnce(
82+
`Specified QUARTO_DART_SASS does not exist, using built in dart sass.`,
83+
);
9784
} else {
98-
debug(`[DART path] : ${sass}`);
99-
debug(`[DART args] : ${args.join(" ")}`);
100-
debug(`[DART stdout] : ${result.stdout}`);
101-
debug(`[DART stderr] : ${result.stderr}`);
102-
103-
const errLines = lines(result.stderr || "");
104-
// truncate the last 2 lines (they include a pointer to the temp file containing
105-
// all of the concatenated sass, which is more or less incomprehensible for users.
106-
const errMsg = errLines.slice(0, errLines.length - 2).join("\n");
107-
throw new Error("Theme file compilation failed:\n\n" + errMsg);
85+
return { cmd: dartOverrideCmd, baseArgs: [] };
10886
}
109-
};
87+
}
88+
89+
const installDir = options?.installDir ??
90+
architectureToolsPath("dart-sass");
11091

111-
// On Windows, use safeWindowsExec to handle paths with spaces
112-
// (e.g., when Quarto is installed in C:\Program Files\)
113-
// See https://github.com/quarto-dev/quarto-cli/issues/13997
11492
if (isWindows) {
115-
const quoted = requireQuoting([sass, ...args]);
116-
const result = await safeWindowsExec(
117-
quoted.args[0],
118-
quoted.args.slice(1),
119-
(cmd: string[]) => {
120-
return execProcess({
121-
cmd: cmd[0],
122-
args: cmd.slice(1),
123-
stdout: "piped",
124-
stderr: "piped",
125-
});
126-
},
127-
);
128-
return processResult(result);
93+
return {
94+
cmd: join(installDir, "src", "dart.exe"),
95+
baseArgs: [join(installDir, "src", "sass.snapshot")],
96+
};
12997
}
13098

131-
// Non-Windows: direct execution
99+
return { cmd: join(installDir, "sass"), baseArgs: [] };
100+
}
101+
102+
export async function dartCommand(
103+
args: string[],
104+
options?: DartCommandOptions,
105+
) {
106+
const { cmd, baseArgs } = resolveSassCommand(options);
107+
132108
const result = await execProcess({
133-
cmd: sass,
134-
args,
109+
cmd,
110+
args: [...baseArgs, ...args],
135111
stdout: "piped",
136112
stderr: "piped",
137113
});
138-
return processResult(result);
114+
115+
if (result.success) {
116+
if (result.stderr) {
117+
info(result.stderr);
118+
}
119+
return result.stdout;
120+
} else {
121+
debug(`[DART cmd] : ${cmd}`);
122+
debug(`[DART args] : ${[...baseArgs, ...args].join(" ")}`);
123+
debug(`[DART stdout] : ${result.stdout}`);
124+
debug(`[DART stderr] : ${result.stderr}`);
125+
126+
const errLines = lines(result.stderr || "");
127+
// truncate the last 2 lines (they include a pointer to the temp file containing
128+
// all of the concatenated sass, which is more or less incomprehensible for users.
129+
const errMsg = errLines.slice(0, errLines.length - 2).join("\n");
130+
throw new Error("Theme file compilation failed:\n\n" + errMsg);
131+
}
139132
}

tests/unit/dart-sass.test.ts

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
* dart-sass.test.ts
33
*
44
* Tests for dart-sass functionality.
5-
* Validates fix for https://github.com/quarto-dev/quarto-cli/issues/13997
5+
* Validates fixes for:
6+
* https://github.com/quarto-dev/quarto-cli/issues/13997 (spaced paths)
7+
* https://github.com/quarto-dev/quarto-cli/issues/14267 (accented paths)
8+
* https://github.com/quarto-dev/quarto-cli/issues/6651 (enterprise .bat blocking)
69
*
710
* Copyright (C) 2020-2025 Posit Software, PBC
811
*/
@@ -13,46 +16,53 @@ import { isWindows } from "../../src/deno_ral/platform.ts";
1316
import { join } from "../../src/deno_ral/path.ts";
1417
import { dartCommand, dartSassInstallDir } from "../../src/core/dart-sass.ts";
1518

19+
/**
20+
* Helper: create a junction to the real dart-sass install dir at `targetDir`.
21+
* Returns cleanup function to remove the junction.
22+
*/
23+
async function createDartSassJunction(targetDir: string) {
24+
const sassInstallDir = dartSassInstallDir();
25+
const result = await new Deno.Command("cmd", {
26+
args: ["/c", "mklink", "/J", targetDir, sassInstallDir],
27+
}).output();
28+
29+
if (!result.success) {
30+
const stderr = new TextDecoder().decode(result.stderr);
31+
throw new Error(`Failed to create junction: ${stderr}`);
32+
}
33+
34+
return async () => {
35+
await new Deno.Command("cmd", {
36+
args: ["/c", "rmdir", targetDir],
37+
}).output();
38+
};
39+
}
40+
1641
// Test that dartCommand handles spaced paths on Windows (issue #13997)
17-
// The bug only triggers when BOTH the executable path AND arguments contain spaces.
42+
// dart.exe is called directly, bypassing sass.bat and its quoting issues.
1843
unitTest(
1944
"dartCommand - handles spaced paths on Windows (issue #13997)",
2045
async () => {
21-
// Create directories with spaces for both sass and file arguments
2246
const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" });
2347
const spacedSassDir = join(tempBase, "Program Files", "dart-sass");
2448
const spacedProjectDir = join(tempBase, "My Project");
25-
const sassInstallDir = dartSassInstallDir();
49+
50+
let removeJunction: (() => Promise<void>) | undefined;
2651

2752
try {
28-
// Create directories
2953
Deno.mkdirSync(join(tempBase, "Program Files"), { recursive: true });
3054
Deno.mkdirSync(spacedProjectDir, { recursive: true });
3155

32-
// Create junction (Windows directory symlink) to actual dart-sass
33-
const junctionResult = await new Deno.Command("cmd", {
34-
args: ["/c", "mklink", "/J", spacedSassDir, sassInstallDir],
35-
}).output();
56+
removeJunction = await createDartSassJunction(spacedSassDir);
3657

37-
if (!junctionResult.success) {
38-
const stderr = new TextDecoder().decode(junctionResult.stderr);
39-
throw new Error(`Failed to create junction: ${stderr}`);
40-
}
41-
42-
// Create test SCSS file in spaced path (args with spaces)
4358
const inputScss = join(spacedProjectDir, "test style.scss");
4459
const outputCss = join(spacedProjectDir, "test style.css");
4560
Deno.writeTextFileSync(inputScss, "body { color: red; }");
4661

47-
const spacedSassPath = join(spacedSassDir, "sass.bat");
48-
49-
// This is the exact bug scenario: spaced exe path + spaced args
50-
// Without the fix, this fails with "C:\...\Program" not recognized
5162
const result = await dartCommand([inputScss, outputCss], {
52-
sassPath: spacedSassPath,
63+
installDir: spacedSassDir,
5364
});
5465

55-
// Verify compilation succeeded (no stdout expected for file-to-file compilation)
5666
assert(
5767
result === undefined || result === "",
5868
"Sass compile should succeed (no stdout for file-to-file compilation)",
@@ -62,14 +72,56 @@ unitTest(
6272
"Output CSS file should be created",
6373
);
6474
} finally {
65-
// Cleanup: remove junction first (rmdir for junctions), then temp directory
6675
try {
67-
await new Deno.Command("cmd", {
68-
args: ["/c", "rmdir", spacedSassDir],
69-
}).output();
76+
if (removeJunction) await removeJunction();
77+
await Deno.remove(tempBase, { recursive: true });
78+
} catch (e) {
79+
console.debug("Test cleanup failed:", e);
80+
}
81+
}
82+
},
83+
{ ignore: !isWindows },
84+
);
85+
86+
// Test that dartCommand handles accented characters in paths (issue #14267)
87+
// Accented chars in user paths (e.g., C:\Users\Sébastien\) broke when
88+
// dart-sass was invoked through a .bat wrapper with UTF-8/OEM mismatch.
89+
unitTest(
90+
"dartCommand - handles accented characters in paths (issue #14267)",
91+
async () => {
92+
const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" });
93+
const accentedSassDir = join(tempBase, "Sébastien", "dart-sass");
94+
const accentedProjectDir = join(tempBase, "Sébastien", "project");
95+
96+
let removeJunction: (() => Promise<void>) | undefined;
97+
98+
try {
99+
Deno.mkdirSync(join(tempBase, "Sébastien"), { recursive: true });
100+
Deno.mkdirSync(accentedProjectDir, { recursive: true });
101+
102+
removeJunction = await createDartSassJunction(accentedSassDir);
103+
104+
const inputScss = join(accentedProjectDir, "style.scss");
105+
const outputCss = join(accentedProjectDir, "style.css");
106+
Deno.writeTextFileSync(inputScss, "body { color: blue; }");
107+
108+
const result = await dartCommand([inputScss, outputCss], {
109+
installDir: accentedSassDir,
110+
});
111+
112+
assert(
113+
result === undefined || result === "",
114+
"Sass compile should succeed with accented path",
115+
);
116+
assert(
117+
Deno.statSync(outputCss).isFile,
118+
"Output CSS file should be created at accented path",
119+
);
120+
} finally {
121+
try {
122+
if (removeJunction) await removeJunction();
70123
await Deno.remove(tempBase, { recursive: true });
71124
} catch (e) {
72-
// Best effort cleanup - log for debugging if it fails
73125
console.debug("Test cleanup failed:", e);
74126
}
75127
}

0 commit comments

Comments
 (0)