diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index bfeb6cd23..744dda415 100644 --- a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json +++ b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.60", + "version": "0.1.63", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py index 42bb48dc6..e3906ed56 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -167,7 +167,11 @@ def generate_diff_in_scope_files( ) -> int: """Reuse the existing diff selection without generating previews or duplicate worklists.""" sys.path.insert(0, str(Path(__file__).resolve().parent)) - from generate_rank_input import git_changed_paths, path_is_excluded + from generate_rank_input import ( + changed_path_parent_is_within_target, + git_changed_paths, + path_is_excluded, + ) from rank_preview import ( DEFAULT_PREVIEW_BYTES, TEXT_CODE_EXTENSIONS, @@ -206,6 +210,17 @@ def generate_diff_in_scope_files( for path, status in eligible: relative = path.relative_to(repository) + if mode != "revisions": + try: + within_target = changed_path_parent_is_within_target(path, repository) + except (OSError, RuntimeError) as error: + raise InventoryError( + "could not inspect a changed Git working-tree path" + ) from error + if not within_target: + raise InventoryError( + "changed Git working-tree paths must stay inside the selected target" + ) if status != "D": if mode == "revisions": contents = revision_blobs[relative] diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 8c4dd3722..e4db849cb 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py @@ -43,7 +43,11 @@ preview_for, preview_for_bytes, ) -from workbench_target import git_blob_bytes, git_directory_snapshot_paths +from workbench_target import ( + directory_is_within_target, + git_blob_bytes, + git_directory_snapshot_paths, +) EXCLUDED_DIRS = { ".cache", @@ -293,6 +297,22 @@ def path_is_excluded(path: Path) -> bool: return path.name.endswith((".min.js", ".map")) +def changed_path_parent_is_within_target(path: Path, target: Path) -> bool: + """Resolve the nearest existing parent without dereferencing the changed leaf.""" + target = target.resolve(strict=True) + candidate = path.parent + while True: + try: + candidate.lstat() + except (FileNotFoundError, NotADirectoryError): + parent = candidate.parent + if parent == candidate: + return False + candidate = parent + continue + return directory_is_within_target(candidate.resolve(strict=True), target) + + def windows_stream_component(path: Path) -> str | None: """Return the first NTFS alternate-data-stream component.""" @@ -708,6 +728,17 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: rows: list[JsonRow] = [] for path, status in changed: rel = path.relative_to(repo) + if args.mode != "revisions": + try: + within_target = changed_path_parent_is_within_target(path, repo) + except (OSError, RuntimeError) as error: + raise SystemExit( + "Could not inspect a changed Git working-tree path." + ) from error + if not within_target: + raise SystemExit( + "Changed Git working-tree paths must stay inside the selected target." + ) if status == "D": preview = "" @@ -723,16 +754,11 @@ def make_diff_rank_input(args: argparse.Namespace) -> None: elif path.is_symlink(): preview = "" elif path.is_file(): - try: - path.resolve(strict=True).relative_to(repo) - except (OSError, ValueError): - preview = "" - else: - preview, is_binary = preview_for(path, args.preview_bytes) - if is_binary: - continue + preview, is_binary = preview_for(path, args.preview_bytes) + if is_binary: + continue else: - preview = "" + continue rows.append({"path": rel.as_posix(), "area": args.area, "preview": preview}) rows.sort(key=lambda row: str(row["path"])) diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 3e26d5acd..429346754 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -258,7 +258,10 @@ def worktree_content_digest_for_context( update_digest_field( digest, b"untracked-content", - directory_content_digest(path.resolve()).encode(), + directory_content_digest( + path.resolve(), + _selected_target=((work_tree or repository) / pathspec).resolve(), + ).encode(), ) elif stat.S_ISREG(metadata.st_mode): content_digest = hashlib.sha256() @@ -369,7 +372,21 @@ def clean_worktree_content_digest() -> str: return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" -def git_directory_snapshot_paths(target: Path) -> list[Path] | None: +def git_directory_snapshot_paths( + target: Path, *, skip_unsafe_paths: bool = False, _selected_target: Path | None = None +) -> list[Path] | None: + try: + resolved_target = target.resolve() + canonical_target = ( + _selected_target if _selected_target is not None else resolved_target + ) + within_target = directory_is_within_target(resolved_target, canonical_target) + except (OSError, RuntimeError): + within_target = False + if not within_target: + if skip_unsafe_paths: + return [] + raise SystemExit("Git working-tree paths must stay inside the selected target.") repository_root = git_output(target, "rev-parse", "--show-toplevel") if repository_root is None: return None @@ -387,6 +404,7 @@ def git_directory_snapshot_paths(target: Path) -> list[Path] | None: if listed is None: raise SystemExit("Could not inspect files in the selected Git working tree.") paths: list[Path] = [] + resolved_parents: dict[Path, Path] = {target: resolved_target} for raw_path in (raw_path for raw_path in listed.split(b"\0") if raw_path): path = repository / os.fsdecode(raw_path) try: @@ -394,15 +412,35 @@ def git_directory_snapshot_paths(target: Path) -> list[Path] | None: except FileNotFoundError: # The index can retain a path that was staged and then deleted. continue + parent = path.parent + try: + if parent not in resolved_parents: + resolved_parents[parent] = parent.resolve() + directory = path.resolve() if stat.S_ISDIR(metadata.st_mode) else None + within_target = directory_is_within_target( + resolved_parents[parent], canonical_target + ) + if directory is not None: + within_target = ( + within_target or directory.samefile(canonical_target) + ) and directory_is_within_target(directory, canonical_target) + except (OSError, RuntimeError): + within_target = False + if not within_target: + if skip_unsafe_paths: + continue + raise SystemExit("Git working-tree paths must stay inside the selected target.") paths.append(path) if not stat.S_ISDIR(metadata.st_mode): continue nested_repository_root = git_output(path, "rev-parse", "--show-toplevel") if ( nested_repository_root is not None - and Path(nested_repository_root).resolve() == path.resolve() + and Path(nested_repository_root).resolve() == directory ): - nested_paths = git_directory_snapshot_paths(path) + nested_paths = git_directory_snapshot_paths( + path, skip_unsafe_paths=skip_unsafe_paths, _selected_target=canonical_target + ) if nested_paths is not None: paths.extend(nested_paths) continue @@ -414,14 +452,26 @@ def git_directory_snapshot_paths(target: Path) -> list[Path] | None: return sorted(set(paths)) -def directory_content_digest(target: Path, *, excluded: tuple[Path, ...] = ()) -> str: +def directory_is_within_target(directory: Path, target: Path) -> bool: + """Compare resolved directories, including equivalent filesystem spellings.""" + if directory.is_relative_to(target): + return True + try: + return any(candidate.samefile(target) for candidate in (directory, *directory.parents)) + except OSError: + return False + + +def directory_content_digest( + target: Path, *, excluded: tuple[Path, ...] = (), _selected_target: Path | None = None +) -> str: excluded_relative = [] for path in excluded: try: excluded_relative.append(path.relative_to(target)) except ValueError: continue - paths = git_directory_snapshot_paths(target) + paths = git_directory_snapshot_paths(target, _selected_target=_selected_target) if paths is None: paths = sorted(target.rglob("*")) digest = hashlib.sha256() @@ -464,7 +514,7 @@ def directory_content_digest(target: Path, *, excluded: tuple[Path, ...] = ()) - def directory_snapshot_regular_file_count(target: Path) -> int: - paths = git_directory_snapshot_paths(target) + paths = git_directory_snapshot_paths(target, skip_unsafe_paths=True) if paths is None: paths = sorted(target.rglob("*")) count = 0 diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 95861c52e..c8b8e12af 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.60" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.63" as const; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/diff-rank-input.test.ts b/sdk/typescript/tests-ts/diff-rank-input.test.ts index 19e120300..319d2ce0e 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -1,5 +1,6 @@ import { execFileSync, spawnSync } from "node:child_process"; import { + cpSync, mkdirSync, mkdtempSync, readFileSync, @@ -11,10 +12,35 @@ import { import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, expect, test } from "bun:test"; +import { + BUNDLED_PLUGIN_VERSION, + bootstrapPlugin, + resolveCodexCommand, +} from "../src/index.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; const temporaryRoots: string[] = []; +function supportsFileSymlinks(): boolean { + const root = mkdtempSync(join(tmpdir(), "codex-security-symlink-probe-")); + try { + symlinkSync("missing.py", join(root, "broken.py"), "file"); + return true; + } catch (error) { + if ( + process.platform === "win32" && + (error as NodeJS.ErrnoException).code === "EPERM" + ) { + return false; + } + throw error; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const fileSymlinksAvailable = supportsFileSymlinks(); + function pythonExecutable(): string | null { return ( process.env["PYTHON"] ?? @@ -44,59 +70,206 @@ function git(repository: string, ...args: string[]): string { ).trim(); } -test("diff previews stay inside the selected repository", () => { - const root = realpathSync( - mkdtempSync(join(tmpdir(), "codex-security-diff-rank-")), +async function upgradeBundledPlugin( + root: string, + previousVersion: string, +): Promise { + const previous = join(root, "previous-plugin"); + cpSync(PLUGIN_ROOT, previous, { recursive: true }); + const previousManifestPath = join(previous, ".codex-plugin", "plugin.json"); + const previousManifest = JSON.parse( + readFileSync(previousManifestPath, "utf8"), + ) as { version: string }; + previousManifest.version = previousVersion; + writeFileSync(previousManifestPath, JSON.stringify(previousManifest)); + writeFileSync( + join(previous, "scripts", "generate_rank_input.py"), + "# synthetic previous plugin\n", ); - temporaryRoots.push(root); - const repository = join(root, "repository"); - const nested = join(repository, "src", "nested"); - mkdirSync(nested, { recursive: true }); - git(repository, "init", "-q"); - writeFileSync(join(repository, "src", "handler.py"), "value = 1\n"); - writeFileSync(join(repository, "src", "deleted.py"), "removed = True\n"); - writeFileSync(join(repository, "src", "entry.py"), "handler.py"); - writeFileSync(join(nested, "linked.py"), "value = 1\n"); - git(repository, "add", "."); - const originalLink = git(repository, "hash-object", "src/entry.py"); - git( - repository, - "update-index", - "--cacheinfo", - `120000,${originalLink},src/entry.py`, + writeFileSync( + join(previous, ".mcp.json"), + JSON.stringify({ mcpServers: { "codex-security": { env_vars: [] } } }), ); - git(repository, "commit", "-qm", "base"); - const base = git(repository, "rev-parse", "HEAD"); - writeFileSync(join(repository, "src", "handler.py"), "value = 2\n"); - writeFileSync(join(repository, "src", "entry.py"), "nested/linked.py"); - writeFileSync(join(nested, "linked.py"), "value = 2\n"); - rmSync(join(repository, "src", "deleted.py")); - git(repository, "add", "."); - const updatedLink = git(repository, "hash-object", "src/entry.py"); - git( - repository, - "update-index", - "--cacheinfo", - `120000,${updatedLink},src/entry.py`, + const home = join(root, "codex-home"); + mkdirSync(home, { mode: 0o700 }); + writeFileSync( + join(home, "config.toml"), + 'cli_auth_credentials_store = "file"\n\n[features]\nplugins = true\n', ); - git(repository, "commit", "-qm", "selected changes"); - const head = git(repository, "rev-parse", "HEAD"); + const command = resolveCodexCommand(); + const environment = { + ...process.env, + CODEX_HOME: home, + OPENAI_API_KEY: undefined, + CODEX_API_KEY: undefined, + }; + const login = spawnSync(command.command, ["login", "--with-api-key"], { + env: environment, + input: "synthetic-key\n", + encoding: "utf8", + windowsHide: true, + }); + expect(login.status, login.stderr).toBe(0); + const options = { codexCommand: command, environment }; - const externalFixture = join(root, "synthetic-fixture"); - mkdirSync(externalFixture); - writeFileSync(join(externalFixture, "linked.py"), "synthetic = True\n"); - rmSync(nested, { recursive: true }); - symlinkSync(externalFixture, nested, "junction"); + const predecessor = await bootstrapPlugin(home, previous, options); + const upgraded = await bootstrapPlugin(home, PLUGIN_ROOT, options); + expect(predecessor.version).toBe(previousVersion); + expect(upgraded.version).toBe(BUNDLED_PLUGIN_VERSION); + expect(upgraded.installedRoot).not.toBe(predecessor.installedRoot); + const installedMcp = JSON.parse( + readFileSync(join(upgraded.installedRoot, ".mcp.json"), "utf8"), + ) as { mcpServers: Record }; + expect(installedMcp.mcpServers["codex-security"]?.env_vars).toContain( + "CODEX_SAFETY_IDENTIFIER", + ); + expect( + readFileSync( + join(upgraded.installedRoot, "scripts", "generate_rank_input.py"), + "utf8", + ), + ).toBe( + readFileSync( + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "utf8", + ), + ); + return upgraded.installedRoot; +} - const python = pythonExecutable(); - expect(python).not.toBeNull(); - const output = join(root, "rank-input.jsonl"); - const result = spawnSync( - python!, - [ +for (const kind of ["staged", "untracked"] as const) { + test.skipIf(!fileSymlinksAvailable)( + `local diff accepts ${kind} broken in-repository symlinks`, + () => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-diff-broken-link-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + mkdirSync(repository); + git(repository, "init", "-q"); + writeFileSync(join(repository, "base.py"), "value = 1\n"); + git(repository, "add", "base.py"); + git(repository, "commit", "-qm", "base"); + const base = git(repository, "rev-parse", "HEAD"); + symlinkSync("missing.py", join(repository, "broken.py"), "file"); + if (kind === "staged") { + git(repository, "add", "broken.py"); + } + + const python = pythonExecutable(); + expect(python).not.toBeNull(); + const inventoryOutput = join(root, "in-scope-files.txt"); + const inventory = spawnSync( + python!, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + inventoryOutput, + "--diff-base", + base, + "--diff-mode", + "local-patch", + ], + { encoding: "utf8" }, + ); + expect(inventory.status, inventory.stderr).toBe(0); + expect(readFileSync(inventoryOutput, "utf8")).toBe(""); + + const rankingOutput = join(root, "rank-input.jsonl"); + const ranking = spawnSync( + python!, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + repository, + "--base", + base, + "--mode", + "local-patch", + "--out", + rankingOutput, + ], + { encoding: "utf8" }, + ); + expect(ranking.status, ranking.stderr).toBe(0); + expect(JSON.parse(readFileSync(rankingOutput, "utf8"))).toEqual({ + path: "broken.py", + area: "diff", + preview: "", + }); + }, + ); +} + +test.each(["0.1.60", "0.1.62"])( + "diff inventory and previews stay in target after upgrading %s", + async (previousVersion) => { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-diff-rank-")), + ); + temporaryRoots.push(root); + const installedPluginRoot = await upgradeBundledPlugin( + root, + previousVersion, + ); + const repository = join(root, "repository"); + const nested = join(repository, "src", "nested"); + mkdirSync(nested, { recursive: true }); + mkdirSync(join(repository, "removed")); + git(repository, "init", "-q"); + writeFileSync(join(repository, "src", "handler.py"), "value = 1\n"); + writeFileSync( + join(repository, "removed", "deleted.py"), + "removed = True\n", + ); + writeFileSync(join(repository, "src", "entry.py"), "handler.py"); + writeFileSync(join(nested, "linked.py"), "value = 1\n"); + git(repository, "add", "."); + const originalLink = git(repository, "hash-object", "src/entry.py"); + git( + repository, + "update-index", + "--cacheinfo", + `120000,${originalLink},src/entry.py`, + ); + git(repository, "commit", "-qm", "base"); + const base = git(repository, "rev-parse", "HEAD"); + + writeFileSync(join(repository, "src", "handler.py"), "value = 2\n"); + writeFileSync(join(repository, "src", "entry.py"), "nested/linked.py"); + writeFileSync(join(nested, "linked.py"), "value = 2\n"); + rmSync(join(repository, "removed"), { recursive: true }); + git(repository, "add", "."); + const updatedLink = git(repository, "hash-object", "src/entry.py"); + git( + repository, + "update-index", + "--cacheinfo", + `120000,${updatedLink},src/entry.py`, + ); + git(repository, "commit", "-qm", "selected changes"); + const head = git(repository, "rev-parse", "HEAD"); + const vanished = join(repository, "vanished"); + mkdirSync(vanished); + writeFileSync(join(vanished, "added.py"), "vanished = True\n"); + git(repository, "add", "vanished/added.py"); + rmSync(vanished, { recursive: true }); + + const python = pythonExecutable(); + expect(python).not.toBeNull(); + const output = join(root, "rank-input.jsonl"); + const args = [ "-B", - join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + join(installedPluginRoot, "scripts", "generate_rank_input.py"), "make-diff-rank-input", "--repo", repository, @@ -108,28 +281,68 @@ test("diff previews stay inside the selected repository", () => { "local-patch", "--out", output, - ], - { encoding: "utf8" }, - ); + ]; + const inventoryOutput = join(root, "in-scope-files.txt"); + const inventoryArgs = [ + "-B", + join(installedPluginRoot, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + inventoryOutput, + "--diff-base", + base, + "--diff-head", + head, + "--diff-mode", + "local-patch", + ]; + const result = spawnSync(python!, args, { encoding: "utf8" }); + const safeInventory = spawnSync(python!, inventoryArgs, { + encoding: "utf8", + }); - expect(result.status, result.stderr).toBe(0); - const rows = readFileSync(output, "utf8") - .trim() - .split("\n") - .map((row) => JSON.parse(row) as { path: string; preview: string }); - expect(rows.map((row) => row.path)).toEqual([ - "src/deleted.py", - "src/entry.py", - "src/handler.py", - "src/nested/linked.py", - ]); - expect(rows.find((row) => row.path === "src/handler.py")?.preview).toBe( - "value = 2", - ); - expect(rows.find((row) => row.path === "src/nested/linked.py")?.preview).toBe( - "", - ); -}); + expect(result.status, result.stderr).toBe(0); + expect(safeInventory.status, safeInventory.stderr).toBe(0); + expect(readFileSync(inventoryOutput, "utf8")).toContain( + "removed/deleted.py\n", + ); + const rows = readFileSync(output, "utf8") + .trim() + .split("\n") + .map((row) => JSON.parse(row) as { path: string; preview: string }); + expect(rows.map((row) => row.path)).toEqual([ + "removed/deleted.py", + "src/entry.py", + "src/handler.py", + "src/nested/linked.py", + ]); + expect(rows.find((row) => row.path === "src/handler.py")?.preview).toBe( + "value = 2", + ); + expect( + rows.find((row) => row.path === "src/nested/linked.py")?.preview, + ).toBe("value = 2"); + + const externalFixture = join(root, "synthetic-fixture"); + mkdirSync(externalFixture); + writeFileSync(join(externalFixture, "linked.py"), "synthetic = True\n"); + rmSync(nested, { recursive: true }); + symlinkSync(externalFixture, nested, "junction"); + + const escaped = spawnSync(python!, args, { encoding: "utf8" }); + const inventory = spawnSync(python!, inventoryArgs, { encoding: "utf8" }); + expect([escaped.status, inventory.status]).toEqual([1, 2]); + expect(escaped.stderr).toContain( + "Changed Git working-tree paths must stay inside the selected target.", + ); + expect(inventory.stderr).toContain( + "changed Git working-tree paths must stay inside the selected target", + ); + }, +); test("preserves Unicode Git paths and legacy-encoded commit metadata", () => { const root = realpathSync( diff --git a/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts b/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts index 67dbddeab..b3c013f2c 100644 --- a/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts +++ b/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts @@ -129,6 +129,249 @@ function runPythonProbe( } describe("bundled workbench canonical paths", () => { + test("does not follow repository parent symlinks during inventory or snapshots", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const outside = join(root, "outside"); + for (const directory of [ + outside, + join(repository, "cyclic"), + join(repository, "nested-checkout"), + join(repository, "internal-source"), + join(repository, "selected", "nested-checkout"), + ]) { + await mkdir(directory, { recursive: true }); + } + for (const directory of ["selected", "internal-source"]) { + await writeFile( + join(repository, directory, "public.py"), + "public = True\n", + ); + } + await writeFile(join(outside, "private.py"), "private = True\n"); + for (const [target, link] of [ + [outside, "linked"], + [outside, "nested-checkout/linked"], + [join(repository, "internal-source"), "nested-checkout/safe-linked"], + [ + join(repository, "internal-source"), + "selected/nested-checkout/sibling-linked", + ], + ] as const) { + await symlink( + target, + join(repository, link), + process.platform === "win32" ? "junction" : "dir", + ); + } + + const result = runPythonProbe( + [ + "import contextlib, io, json, sys, unicodedata", + "from pathlib import Path", + "from types import SimpleNamespace", + "sys.path.insert(0, sys.argv[1])", + "import generate_in_scope_files as inventory", + "import generate_rank_input as ranking", + "import rank_preview as previews", + "import workbench_target as workbench", + "repository = Path(sys.argv[2])", + "output = Path(sys.argv[3])", + "candidate = repository / 'linked' / 'private.py'", + "outside = repository.parent / 'outside'", + "safe_fallback_digest = workbench.directory_content_digest(repository / 'internal-source', _selected_target=repository).startswith('codex-security-snapshot/v1:sha256:')", + "try:", + " workbench.directory_content_digest(outside, _selected_target=repository)", + "except SystemExit:", + " outside_digest_rejected = True", + "else:", + " outside_digest_rejected = False", + "outside_progress_skipped = workbench.git_directory_snapshot_paths(outside, skip_unsafe_paths=True, _selected_target=repository) == []", + "reads = []", + "def preview(path, *_):", + " contents = path.read_text()", + " if not path.resolve().is_relative_to(repository):", + " reads.append(contents)", + " return '', False", + "previews.preview_for = preview", + "ranking.preview_for = preview", + "inventories = {}", + "for status in ('M', 'D'):", + " ranking.git_changed_paths = lambda *_: [(candidate, status)]", + " try:", + " inventory.generate_diff_in_scope_files(repository, 'base', 'head', 'local-patch', output)", + " inventories[status] = output.read_text()", + " except inventory.InventoryError as error:", + " inventories[status] = str(error)", + "inventory.committed_changed_paths = lambda *_: [(candidate, 'M')]", + "workbench.git_blob_bytes = lambda _, names: [b'private = True\\n' for _ in names]", + "inventory.generate_diff_in_scope_files(repository, 'base', 'head', 'revisions', output)", + "inventories['revisions'] = output.read_text()", + "original_resolve = Path.resolve", + "def cyclic_resolve(path, *args, **kwargs):", + " if path.name == 'cyclic':", + " raise RuntimeError('symlink loop')", + " return original_resolve(path, *args, **kwargs)", + "Path.resolve = cyclic_resolve", + "ranking.git_changed_paths = lambda *_: [(repository / 'cyclic' / 'private.py', 'M')]", + "try:", + " inventory.generate_diff_in_scope_files(repository, 'base', 'head', 'local-patch', output)", + " inventories['cyclic'] = output.read_text()", + "except inventory.InventoryError as error:", + " inventories['cyclic'] = str(error)", + "Path.resolve = original_resolve", + "ranking.git_changed_paths = lambda *_: [(repository / 'missing-parent' / 'added.py', 'A')]", + "inventory.generate_diff_in_scope_files(repository, 'base', 'head', 'local-patch', output)", + "inventories['missingParent'] = output.read_text()", + "ranking.git_changed_paths = lambda *_: [(repository / 'linked' / 'vanished.py', 'A')]", + "try:", + " inventory.generate_diff_in_scope_files(repository, 'base', 'head', 'local-patch', output)", + " inventories['vanishedExternalParent'] = output.read_text()", + "except inventory.InventoryError as error:", + " inventories['vanishedExternalParent'] = str(error)", + "blocked_parent = repository / 'not-a-directory'", + "blocked_parent.write_text('not a directory\\n')", + "ranking.git_changed_paths = lambda *_: [(blocked_parent / 'nested' / 'added.py', 'A')]", + "inventory.generate_diff_in_scope_files(repository, 'base', 'head', 'local-patch', output)", + "inventories['notDirectoryParent'] = output.read_text()", + "workbench.git_output = lambda *_: str(repository)", + "workbench.git_worktree_context = lambda _: (repository, '.')", + "workbench.git_bytes = lambda *_: b'linked/private.py\\0'", + "try:", + " workbench.git_directory_snapshot_paths(repository)", + "except SystemExit:", + " snapshot_rejected = True", + "else:", + " snapshot_rejected = False", + "unsafe_progress_count = workbench.directory_snapshot_regular_file_count(repository)", + "nested_repository = repository / 'nested-checkout'", + "workbench.git_worktree_context = lambda target: (target, '.')", + "workbench.git_output = lambda target, *_: str(target)", + "workbench.git_bytes = lambda target, *_: b'nested-checkout\\0' if target == repository else b'linked/private.py\\0'", + "nested_unsafe_progress_count = workbench.directory_snapshot_regular_file_count(repository)", + "workbench.git_bytes = lambda target, *_: b'nested-checkout\\0' if target == repository else b'safe-linked/public.py\\0'", + "nested_safe_paths = [path.relative_to(repository).as_posix() for path in workbench.git_directory_snapshot_paths(repository)]", + "def nested_digest_bytes(target, *args, **kwargs):", + " if args[0] == 'diff':", + " return b''", + " if '--cached' in args:", + " return b'safe-linked/public.py\\0'", + " return b'nested-checkout\\0'", + "workbench.git_bytes = nested_digest_bytes", + "nested_safe_digest = workbench.worktree_content_digest_for_context(repository, '.').startswith('codex-security-snapshot/v1:sha256:')", + "selected_nested = repository / 'selected' / 'nested-checkout'", + "workbench.git_output = lambda target, *_: str(target) if target == selected_nested else str(repository)", + "workbench.git_worktree_context = lambda target: (target, '.') if target == selected_nested else (repository, 'selected')", + "def selected_nested_bytes(target, *args, **kwargs):", + " if args[0] == 'diff':", + " return b''", + " if target == repository:", + " return b'selected/nested-checkout\\0'", + " return b'sibling-linked/public.py\\0'", + "workbench.git_bytes = selected_nested_bytes", + "try:", + " workbench.worktree_content_digest_for_context(repository, 'selected')", + "except SystemExit:", + " selected_nested_rejected = True", + "else:", + " selected_nested_rejected = False", + "if sys.platform == 'win32':", + " (selected_nested / 'sibling-linked').rmdir()", + "else:", + " (selected_nested / 'sibling-linked').unlink()", + "selected_nested.rmdir()", + "workbench.git_output = lambda *_: str(repository)", + "workbench.git_worktree_context = lambda _: (repository, '.')", + "workbench.git_bytes = lambda *_: b'nested-checkout\\0'", + "def cyclic_directory_resolve(path, *args, **kwargs):", + " if path == nested_repository:", + " raise RuntimeError('junction loop')", + " return original_resolve(path, *args, **kwargs)", + "Path.resolve = cyclic_directory_resolve", + "cyclic_progress_count = workbench.directory_snapshot_regular_file_count(repository)", + "Path.resolve = original_resolve", + "workbench.git_worktree_context = lambda _: (repository, '.')", + "workbench.git_output = lambda *_: str(repository)", + "workbench.git_bytes = lambda *_: b'linked/missing.py\\0'", + "missing_skipped = workbench.git_directory_snapshot_paths(repository) == []", + "selected = repository / 'selected'", + "workbench.git_worktree_context = lambda _: (repository, 'selected')", + "workbench.git_bytes = lambda *_: b'selected\\0'", + "selected_paths = [path.relative_to(selected).as_posix() for path in workbench.git_directory_snapshot_paths(selected)]", + "unicode_directory = repository / unicodedata.normalize('NFD', 'café')", + "unicode_directory.mkdir()", + "(unicode_directory / 'public.py').write_text('public = True\\n')", + "unicode_alias = repository / unicodedata.normalize('NFC', 'café')", + "unicode_accepted = True", + "if unicode_alias.exists():", + " workbench.git_worktree_context = lambda _: (repository, unicode_directory.name)", + " workbench.git_bytes = lambda *_: (unicode_alias.name + '\\0' + unicode_alias.name + '/public.py\\0').encode()", + " unicode_accepted = len(workbench.git_directory_snapshot_paths(unicode_directory)) == 2", + "case_alias_accepted = True", + "case_alias_changes_accepted = True", + "if sys.platform == 'darwin' and (repository / 'SELECTED').exists():", + " (selected / 'actual').mkdir()", + " (selected / 'actual' / 'public.py').write_text('public = True\\n')", + " (selected / 'internal').symlink_to(repository / 'SELECTED' / 'actual', target_is_directory=True)", + " workbench.git_worktree_context = lambda _: (repository, 'selected')", + " workbench.git_bytes = lambda *_: b'selected/internal/public.py\\0'", + " case_alias_accepted = len(workbench.git_directory_snapshot_paths(selected)) == 1", + " internal = selected / 'internal' / 'public.py'", + " ranking.git_changed_paths = lambda *_: [(internal, 'M')]", + " inventory.generate_diff_in_scope_files(selected, 'base', 'head', 'local-patch', output)", + " case_alias_changes_accepted = output.read_text() == 'internal/public.py\\n'", + " args = SimpleNamespace(repo=selected, base='base', head='head', mode='local-patch', area='.', preview_bytes=1024, out=output)", + " with contextlib.redirect_stdout(io.StringIO()):", + " ranking.make_diff_rank_input(args)", + " case_alias_changes_accepted = case_alias_changes_accepted and len(output.read_text().splitlines()) == 1", + "workbench.git_worktree_context = lambda _: (repository, '.')", + "workbench.git_bytes = lambda *_: b'linked\\0'", + "try:", + " workbench.git_directory_snapshot_paths(repository)", + "except SystemExit:", + " direct_link_rejected = True", + "else:", + " direct_link_rejected = False", + "print(json.dumps({'inventories': inventories, 'externalReads': reads, 'safeFallbackDigest': safe_fallback_digest, 'outsideDigestRejected': outside_digest_rejected, 'outsideProgressSkipped': outside_progress_skipped, 'snapshotRejected': snapshot_rejected, 'unsafeProgressCount': unsafe_progress_count, 'nestedUnsafeProgressCount': nested_unsafe_progress_count, 'nestedSafePaths': nested_safe_paths, 'nestedSafeDigest': nested_safe_digest, 'selectedNestedRejected': selected_nested_rejected, 'cyclicProgressCount': cyclic_progress_count, 'missingSkipped': missing_skipped, 'selectedPaths': selected_paths, 'unicodeAccepted': unicode_accepted, 'caseAliasAccepted': case_alias_accepted, 'caseAliasChangesAccepted': case_alias_changes_accepted, 'directLinkRejected': direct_link_rejected}))", + ].join("\n"), + repository, + join(root, "inventory.txt"), + ); + + expect(result).toEqual({ + inventories: { + M: "changed Git working-tree paths must stay inside the selected target", + D: "changed Git working-tree paths must stay inside the selected target", + revisions: "linked/private.py\n", + cyclic: "could not inspect a changed Git working-tree path", + missingParent: "", + vanishedExternalParent: + "changed Git working-tree paths must stay inside the selected target", + notDirectoryParent: "", + }, + externalReads: [], + safeFallbackDigest: true, + outsideDigestRejected: true, + outsideProgressSkipped: true, + snapshotRejected: true, + unsafeProgressCount: 0, + nestedUnsafeProgressCount: 0, + nestedSafePaths: [ + "nested-checkout", + "nested-checkout/safe-linked/public.py", + ], + nestedSafeDigest: true, + selectedNestedRejected: true, + cyclicProgressCount: 0, + missingSkipped: true, + selectedPaths: [".", "public.py"], + unicodeAccepted: true, + caseAliasAccepted: true, + caseAliasChangesAccepted: true, + directLinkRejected: process.platform === "win32", + }); + }); + test("reads Unicode commit subjects regardless of locale or Git log encoding", async () => { const repository = await temporaryDirectory(); expect(