Skip to content

Commit 3384dcc

Browse files
fix(windows): preserve platform-safe canonical scan paths (#85)
* fix(windows): normalize path casing in canonical directory checks & auto-create draft manifest on CLI completion * fix(windows): normalize path casing in canonical directory checks * fix: preserve platform-aware canonical scan paths --------- Co-authored-by: hoquanganh09 <quanganhho99@gmail.com>
1 parent 150d6f6 commit 3384dcc

4 files changed

Lines changed: 204 additions & 5 deletions

File tree

sdk/typescript/_bundled_plugin/scripts/deep_scan_workbench.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ def deep_scan_path(
239239
resolved.relative_to(scan_dir)
240240
except (OSError, RuntimeError, ValueError) as exc:
241241
raise SystemExit(f"{label} must be an existing path inside the scan directory.") from exc
242-
if resolved.as_posix().lower() != supplied.absolute().as_posix().lower():
242+
if os.path.normcase(resolved) != os.path.normcase(supplied.absolute()):
243243
raise SystemExit(f"{label} must be a canonical non-symlink path.")
244244
if kind == "file" and not resolved.is_file():
245245
raise SystemExit(f"{label} must be a regular file.")

sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def _require_scan_directory(scan_dir: Path) -> Path:
316316
resolved = scan_dir.resolve(strict=True)
317317
except OSError as exc:
318318
raise ContractError("scan directory: expected an existing non-symlink directory") from exc
319-
if resolved.as_posix().lower() != scan_dir.as_posix().lower():
319+
if os.path.normcase(resolved) != os.path.normcase(scan_dir):
320320
raise ContractError("scan directory: expected a canonical non-symlink directory")
321321
return resolved
322322

@@ -327,7 +327,10 @@ def _validate_scan_local_output_path(scan_dir: Path, path: Path, relative_path:
327327
resolved_parent.relative_to(scan_dir)
328328
except (OSError, RuntimeError, ValueError) as exc:
329329
raise ContractError(f"{relative_path}: expected a path inside the scan directory") from exc
330-
if resolved_parent.as_posix().lower() != path.parent.as_posix().lower() or path.is_symlink():
330+
if (
331+
os.path.normcase(resolved_parent) != os.path.normcase(path.parent)
332+
or path.is_symlink()
333+
):
331334
raise ContractError(
332335
f"{relative_path}: expected a non-symlink path inside the scan directory"
333336
)

sdk/typescript/_bundled_plugin/scripts/workbench_db.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3436,7 +3436,7 @@ def artifact_path(scan_dir: Path, file_name: str, *, required: bool) -> Path | N
34363436
raise SystemExit(
34373437
f"{file_name}: expected a regular file inside the scan directory."
34383438
) from exc
3439-
if resolved.as_posix().lower() != candidate.as_posix().lower() or not candidate.is_file():
3439+
if os.path.normcase(resolved) != os.path.normcase(candidate) or not candidate.is_file():
34403440
raise SystemExit(f"{file_name}: expected a regular non-symlink file.")
34413441
return resolved
34423442

@@ -3450,7 +3450,9 @@ def require_canonical_scan_directory(scan_dir: Path) -> Path:
34503450
raise SystemExit(
34513451
"Scan directory must be an existing canonical non-symlink directory."
34523452
) from exc
3453-
if not stat.S_ISDIR(metadata.st_mode) or resolved.as_posix().lower() != scan_dir.as_posix().lower():
3453+
if not stat.S_ISDIR(metadata.st_mode) or os.path.normcase(resolved) != os.path.normcase(
3454+
scan_dir
3455+
):
34543456
raise SystemExit("Scan directory must be an existing canonical non-symlink directory.")
34553457
return scan_dir
34563458

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
mkdir,
3+
mkdtemp,
4+
realpath,
5+
rm,
6+
symlink,
7+
writeFile,
8+
} from "node:fs/promises";
9+
import { tmpdir } from "node:os";
10+
import { join } from "node:path";
11+
import { afterEach, describe, expect, test } from "bun:test";
12+
import { PLUGIN_ROOT } from "./plugin-root.js";
13+
14+
const temporaryDirectories: string[] = [];
15+
const testCaseSensitive = process.platform === "linux" ? test : test.skip;
16+
const testWindows = process.platform === "win32" ? test : test.skip;
17+
18+
const simulatedPathProbe = [
19+
"import json, ntpath, os, posixpath, sys",
20+
"from pathlib import PurePosixPath, PureWindowsPath",
21+
"from types import SimpleNamespace",
22+
"sys.path.insert(0, sys.argv[1])",
23+
"import deep_scan_workbench as deep_scan",
24+
"mode = sys.argv[2]",
25+
"if mode == 'windows':",
26+
" path_type, path_module = PureWindowsPath, ntpath",
27+
" root, supplied, resolved = 'D:/Scan', 'd:/sCaN/pRoMpT', 'D:/Scan/Prompt'",
28+
"else:",
29+
" path_type, path_module = PurePosixPath, posixpath",
30+
" root, supplied, resolved = '/scan', '/scan/prompt', '/scan/Prompt'",
31+
"class SimulatedPath(path_type):",
32+
" def expanduser(self):",
33+
" return self",
34+
" def absolute(self):",
35+
" return self",
36+
" def resolve(self, strict=False):",
37+
" return type(self)(resolved)",
38+
" def is_file(self):",
39+
" return True",
40+
"deep_scan.Path = SimulatedPath",
41+
"deep_scan.os = SimpleNamespace(path=path_module)",
42+
"deep_scan.require_canonical_scan_directory = lambda path: path",
43+
"try:",
44+
" result = deep_scan.deep_scan_path({'scan_dir': root}, supplied, 'Worker prompt path', kind='file')",
45+
"except SystemExit:",
46+
" accepted = False",
47+
" result = None",
48+
"else:",
49+
" accepted = True",
50+
"print(json.dumps({'accepted': accepted, 'nativePathEquality': path_type(supplied) == path_type(resolved), 'resolvedPath': result}))",
51+
].join("\n");
52+
53+
const realFilesystemProbe = [
54+
"import json, sys",
55+
"from pathlib import Path",
56+
"sys.path.insert(0, sys.argv[1])",
57+
"import deep_scan_workbench as deep_scan",
58+
"import finalize_scan_contract as finalizer",
59+
"import workbench_db as workbench",
60+
"mode = sys.argv[2]",
61+
"scan_dir = Path(sys.argv[3])",
62+
"if mode == 'windows':",
63+
" alias_scan_dir = Path(str(scan_dir).swapcase())",
64+
" alias_directory = alias_scan_dir / 'pRoMpTs'",
65+
" artifact_name = 'pRoMpTs/PrOmPt.TxT'",
66+
" candidate_name = 'PrOmPt.TxT'",
67+
"else:",
68+
" alias_scan_dir = Path(sys.argv[4])",
69+
" alias_directory = scan_dir / 'prompts'",
70+
" artifact_name = 'prompts/prompt.txt'",
71+
" candidate_name = 'prompt.txt'",
72+
"deep_scan.require_canonical_scan_directory = workbench.require_canonical_scan_directory",
73+
"def accepted(action):",
74+
" try:",
75+
" action()",
76+
" except (SystemExit, finalizer.ContractError):",
77+
" return False",
78+
" return True",
79+
"checks = {",
80+
" 'deepScanPath': accepted(lambda: deep_scan.deep_scan_path({'scan_dir': str(scan_dir)}, str(alias_directory / candidate_name), 'Worker prompt path', kind='file')),",
81+
" 'finalizerScanDirectory': accepted(lambda: finalizer._require_scan_directory(alias_scan_dir)),",
82+
" 'finalizerOutputParent': accepted(lambda: finalizer._validate_scan_local_output_path(scan_dir, alias_directory / 'output.json', f'{alias_directory.name}/output.json')),",
83+
" 'workbenchArtifact': accepted(lambda: workbench.artifact_path(scan_dir, artifact_name, required=True)),",
84+
" 'workbenchScanDirectory': accepted(lambda: workbench.require_canonical_scan_directory(alias_scan_dir)),",
85+
"}",
86+
"print(json.dumps(checks))",
87+
].join("\n");
88+
89+
afterEach(async () => {
90+
await Promise.all(
91+
temporaryDirectories
92+
.splice(0)
93+
.map((directory) => rm(directory, { recursive: true, force: true })),
94+
);
95+
});
96+
97+
async function temporaryDirectory(): Promise<string> {
98+
const directory = await realpath(
99+
await mkdtemp(join(tmpdir(), "codex-security-canonical-paths-")),
100+
);
101+
temporaryDirectories.push(directory);
102+
return directory;
103+
}
104+
105+
function runPythonProbe(
106+
program: string,
107+
...args: string[]
108+
): Record<string, unknown> {
109+
const python = Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py");
110+
expect(python).not.toBeNull();
111+
if (python === null) {
112+
throw new Error(
113+
"A Python interpreter is required for workbench path tests.",
114+
);
115+
}
116+
117+
const result = Bun.spawnSync(
118+
[python, "-I", "-B", "-c", program, join(PLUGIN_ROOT, "scripts"), ...args],
119+
{ stdout: "pipe", stderr: "pipe" },
120+
);
121+
expect(new TextDecoder().decode(result.stderr)).toBe("");
122+
expect(result.exitCode).toBe(0);
123+
return JSON.parse(new TextDecoder().decode(result.stdout)) as Record<
124+
string,
125+
unknown
126+
>;
127+
}
128+
129+
describe("bundled workbench canonical paths", () => {
130+
test("preserves native Windows case-insensitive path comparison", () => {
131+
expect(runPythonProbe(simulatedPathProbe, "windows")).toMatchObject({
132+
accepted: true,
133+
nativePathEquality: true,
134+
});
135+
});
136+
137+
test("rejects case-differing POSIX symlink resolution", () => {
138+
expect(runPythonProbe(simulatedPathProbe, "posix")).toMatchObject({
139+
accepted: false,
140+
nativePathEquality: false,
141+
});
142+
});
143+
144+
testCaseSensitive(
145+
"rejects case-differing symlinks at every workbench and finalizer boundary",
146+
async () => {
147+
const root = await temporaryDirectory();
148+
const parent = join(root, "Scans");
149+
const aliasParent = join(root, "scans");
150+
const scanDirectory = join(parent, "Scan");
151+
const promptDirectory = join(scanDirectory, "Prompts");
152+
await mkdir(promptDirectory, { recursive: true });
153+
await writeFile(join(promptDirectory, "prompt.txt"), "worker prompt\n");
154+
await symlink(parent, aliasParent, "dir");
155+
await symlink(promptDirectory, join(scanDirectory, "prompts"), "dir");
156+
157+
expect(
158+
runPythonProbe(
159+
realFilesystemProbe,
160+
"posix",
161+
scanDirectory,
162+
join(aliasParent, "Scan"),
163+
),
164+
).toEqual({
165+
deepScanPath: false,
166+
finalizerScanDirectory: false,
167+
finalizerOutputParent: false,
168+
workbenchArtifact: false,
169+
workbenchScanDirectory: false,
170+
});
171+
},
172+
);
173+
174+
testWindows(
175+
"accepts mixed-case Windows paths at every workbench and finalizer boundary",
176+
async () => {
177+
const root = await temporaryDirectory();
178+
const scanDirectory = join(root, "ScanRoot");
179+
const promptDirectory = join(scanDirectory, "Prompts");
180+
await mkdir(promptDirectory, { recursive: true });
181+
await writeFile(join(promptDirectory, "prompt.txt"), "worker prompt\n");
182+
183+
expect(
184+
runPythonProbe(realFilesystemProbe, "windows", scanDirectory),
185+
).toEqual({
186+
deepScanPath: true,
187+
finalizerScanDirectory: true,
188+
finalizerOutputParent: true,
189+
workbenchArtifact: true,
190+
workbenchScanDirectory: true,
191+
});
192+
},
193+
);
194+
});

0 commit comments

Comments
 (0)