Skip to content

Commit 10fa1a1

Browse files
authored
Merge pull request #209 from pylon-code/upstream/2026-08-31-windows-path-quotes
fix(windows): strip quotes from repaired PATH
2 parents f01074c + effbf20 commit 10fa1a1

4 files changed

Lines changed: 137 additions & 15 deletions

File tree

apps/desktop/src/shell/DesktopShellEnvironment.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ describe("DesktopShellEnvironment", () => {
334334
FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
335335
FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
336336
})
337-
: envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" });
337+
: envOutput({ PATH: 'C:\\Custom\\Bin;C:";C:\\Windows\\System32' });
338338
},
339339
});
340340

apps/desktop/src/shell/DesktopShellEnvironment.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,50 @@ const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
151151
return platform === "win32" ? normalized.toLowerCase() : normalized;
152152
};
153153

154+
const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) =>
155+
platform === "win32" ? entry.replaceAll('"', "") : entry;
156+
157+
/**
158+
* Splits a PATH value, honouring Windows quoting. A quoted entry may contain the
159+
* delimiter — `C:\\bin;"C:\\my;dir"` is two directories — so splitting on the raw
160+
* delimiter would tear it apart and leave a relative `dir` entry behind.
161+
*/
162+
const splitPathValue = (value: string, delimiter: string, platform: NodeJS.Platform): string[] => {
163+
if (platform !== "win32") return value.split(delimiter);
164+
// Unbalanced quotes are stray characters, not quoting: honouring them would
165+
// swallow every later entry into one. Fall back to a plain split there.
166+
if ((value.match(/"/g)?.length ?? 0) % 2 !== 0) return value.split(delimiter);
167+
const entries: string[] = [];
168+
let current = "";
169+
let quoted = false;
170+
for (const char of value) {
171+
if (char === '"') {
172+
quoted = !quoted;
173+
current += char;
174+
continue;
175+
}
176+
if (char === delimiter && !quoted) {
177+
entries.push(current);
178+
current = "";
179+
continue;
180+
}
181+
current += char;
182+
}
183+
entries.push(current);
184+
return entries;
185+
};
186+
187+
/**
188+
* Re-quotes an entry containing the delimiter. Stripping its quotes and joining
189+
* would hand consumers a value they split back into the wrong directories.
190+
*/
191+
const quotePathEntryIfNeeded = (entry: string, delimiter: string, platform: NodeJS.Platform) =>
192+
platform === "win32" && entry.includes(delimiter) ? `"${entry}"` : entry;
193+
194+
/** A bare drive letter is drive-relative, so it would add the child's cwd to the search. */
195+
const isUsablePathEntry = (entry: string, platform: NodeJS.Platform) =>
196+
platform !== "win32" || !/^[A-Za-z]:$/.test(entry);
197+
154198
const mergePaths = (
155199
platform: NodeJS.Platform,
156200
values: ReadonlyArray<Option.Option<string>>,
@@ -162,19 +206,23 @@ const mergePaths = (
162206
for (const value of values) {
163207
if (Option.isNone(value)) continue;
164208

165-
for (const entry of value.value.split(delimiter)) {
166-
const trimmed = entry.trim();
167-
if (trimmed.length === 0) continue;
209+
for (const entry of splitPathValue(value.value, delimiter, platform)) {
210+
const sanitized = sanitizePathEntry(entry.trim(), platform);
211+
if (sanitized.length === 0 || !isUsablePathEntry(sanitized, platform)) continue;
168212

169-
const key = pathComparisonKey(trimmed, platform);
213+
const key = pathComparisonKey(sanitized, platform);
170214
if (key.length === 0 || seen.has(key)) continue;
171215

172216
seen.add(key);
173-
entries.push(trimmed);
217+
entries.push(sanitized);
174218
}
175219
}
176220

177-
return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();
221+
return entries.length > 0
222+
? Option.some(
223+
entries.map((entry) => quotePathEntryIfNeeded(entry, delimiter, platform)).join(delimiter),
224+
)
225+
: Option.none();
178226
};
179227

180228
export const resolveDesktopLoginShellCandidates = (

packages/shared/src/shell.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,15 +304,35 @@ describe("readEnvironmentFromWindowsShell", () => {
304304
});
305305

306306
describe("mergePathValues", () => {
307-
it("dedupes case-insensitively on Windows while preserving preferred order", () => {
307+
it("sanitizes and dedupes Windows entries while preserving preferred order", () => {
308308
expect(
309309
mergePathValues(
310310
'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"',
311311
"c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32",
312312
"win32",
313313
),
314314
).toBe(
315-
'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32',
315+
"C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs;C:\\Windows\\System32",
316+
);
317+
});
318+
319+
it("removes stray quotes from Windows entries", () => {
320+
expect(
321+
mergePathValues(
322+
'C:\\Windows\\System32;C:\\cloudflared.exe;C:";C:\\Program Files\\nodejs',
323+
undefined,
324+
"win32",
325+
),
326+
// The bare drive letter left behind is drive-relative: keeping it would add
327+
// the child process's own directory to the executable search.
328+
).toBe("C:\\Windows\\System32;C:\\cloudflared.exe;C:\\Program Files\\nodejs");
329+
});
330+
331+
it("keeps a quoted Windows entry that contains the delimiter", () => {
332+
// The entry stays quoted on the way out, or consumers would split it back
333+
// into "C:\\my" and a relative "dir".
334+
expect(mergePathValues('C:\\bin;"C:\\my;dir";C:\\other', undefined, "win32")).toBe(
335+
'C:\\bin;"C:\\my;dir";C:\\other',
316336
);
317337
});
318338

packages/shared/src/shell.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,58 @@ function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platfor
414414
return platform === "win32" ? normalized.toLowerCase() : normalized;
415415
}
416416

417+
function sanitizePathEntry(entry: string, platform: NodeJS.Platform): string {
418+
return platform === "win32" ? entry.replaceAll('"', "") : entry;
419+
}
420+
421+
/**
422+
* Splits a PATH value, honouring Windows quoting. A quoted entry may itself
423+
* contain the delimiter — `C:\\bin;"C:\\my;dir"` is two directories, not three —
424+
* so splitting on the raw delimiter first would tear it in half and leave a
425+
* relative `dir` entry resolved against the child process's cwd.
426+
*/
427+
function splitPathValue(value: string, delimiter: string, platform: NodeJS.Platform): string[] {
428+
if (platform !== "win32") return value.split(delimiter);
429+
// Unbalanced quotes are stray characters, not quoting: honouring them would
430+
// swallow every later entry into one. Fall back to a plain split there.
431+
if ((value.match(/"/g)?.length ?? 0) % 2 !== 0) return value.split(delimiter);
432+
const entries: string[] = [];
433+
let current = "";
434+
let quoted = false;
435+
for (const char of value) {
436+
if (char === '"') {
437+
quoted = !quoted;
438+
current += char;
439+
continue;
440+
}
441+
if (char === delimiter && !quoted) {
442+
entries.push(current);
443+
current = "";
444+
continue;
445+
}
446+
current += char;
447+
}
448+
entries.push(current);
449+
return entries;
450+
}
451+
452+
/**
453+
* Re-quotes an entry containing the delimiter. Stripping its quotes and joining
454+
* would hand consumers a value they split back into the wrong directories.
455+
*/
456+
function quotePathEntryIfNeeded(
457+
entry: string,
458+
delimiter: string,
459+
platform: NodeJS.Platform,
460+
): string {
461+
return platform === "win32" && entry.includes(delimiter) ? `"${entry}"` : entry;
462+
}
463+
464+
/** A bare drive letter is drive-relative, so it would add the child's cwd to the search. */
465+
function isUsablePathEntry(entry: string, platform: NodeJS.Platform): boolean {
466+
return platform !== "win32" || !/^[A-Za-z]:$/.test(entry);
467+
}
468+
417469
export function mergePathValues(
418470
preferredPath: string | undefined,
419471
inheritedPath: string | undefined,
@@ -426,19 +478,21 @@ export function mergePathValues(
426478
for (const rawValue of [preferredPath, inheritedPath]) {
427479
if (!rawValue) continue;
428480

429-
for (const entry of rawValue.split(delimiter)) {
430-
const trimmed = entry.trim();
431-
if (trimmed.length === 0) continue;
481+
for (const entry of splitPathValue(rawValue, delimiter, platform)) {
482+
const sanitized = sanitizePathEntry(entry.trim(), platform);
483+
if (sanitized.length === 0 || !isUsablePathEntry(sanitized, platform)) continue;
432484

433-
const normalized = normalizePathEntryForComparison(trimmed, platform);
485+
const normalized = normalizePathEntryForComparison(sanitized, platform);
434486
if (normalized.length === 0 || seen.has(normalized)) continue;
435487

436488
seen.add(normalized);
437-
merged.push(trimmed);
489+
merged.push(sanitized);
438490
}
439491
}
440492

441-
return merged.length > 0 ? merged.join(delimiter) : undefined;
493+
return merged.length > 0
494+
? merged.map((entry) => quotePathEntryIfNeeded(entry, delimiter, platform)).join(delimiter)
495+
: undefined;
442496
}
443497

444498
function readEnvPath(env: NodeJS.ProcessEnv): string | undefined {

0 commit comments

Comments
 (0)