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 a2eeb6ca..05ba3f90 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -4,11 +4,16 @@ from __future__ import annotations import argparse +import os import subprocess import sys import tempfile from pathlib import Path +GIT_TIMEOUT_SECONDS = 10 +INVENTORY_TIMEOUT_SECONDS = 120 +IGNORE_FILE_NAMES = (".gitignore", ".ignore", ".rgignore") + class InventoryError(ValueError): """Raised when the repository, scope, or inventory cannot be used safely.""" @@ -42,6 +47,14 @@ def resolve_scope(repository: Path, value: str) -> str: except ValueError as error: raise InventoryError(f"--scope: path must remain inside --repo: {value}") from error + current = scope + while current != repository: + if current == current.parent: + raise InventoryError("--scope: symbolic links are not supported") + if current.is_symlink(): + raise InventoryError("--scope: symbolic links are not supported") + current = current.parent + if not resolved.is_dir() and not resolved.is_file(): raise InventoryError(f"--scope: expected a file or directory: {value}") @@ -67,29 +80,283 @@ def resolve_output(value: str) -> Path: def generate_in_scope_files(repository: Path, scope: str, output: Path) -> int: - """Atomically write the exact ripgrep inventory sorted as ``LC_ALL=C``.""" - command = ["rg", "--files", "--hidden", "--no-ignore", "--glob", "!.git/**", "--", scope] - with tempfile.TemporaryFile(mode="w+b") as inventory: + """Atomically inventory visible files and ignored files tracked by Git.""" + selected = (repository / scope).resolve(strict=True) + selected_directory = selected if selected.is_dir() else selected.parent + ancestors: list[Path] = [] + current = selected_directory + while True: + ancestors.append(current) + if current == repository: + break + current = current.parent + ancestors.reverse() + + def reject_symbolic_ignore(directory: Path) -> None: + if any((directory / name).is_symlink() for name in IGNORE_FILE_NAMES): + raise InventoryError("symbolic ignore files are not supported") + + for ancestor in ancestors: + reject_symbolic_ignore(ancestor) + if selected.is_dir(): + for directory, children, _ in os.walk(selected, followlinks=False): + children[:] = [name for name in children if name != ".git"] + reject_symbolic_ignore(Path(directory)) + + command = [ + "rg", + "--no-config", + "--files", + "--hidden", + "--no-require-git", + "--no-ignore-parent", + "--no-ignore-global", + "--glob", + "!.git/**", + ] + + def ripgrep_inventory(directory: Path, requested_scope: str) -> set[bytes]: + arguments = command.copy() + for name in IGNORE_FILE_NAMES: + ignore = directory / name + if ignore.is_file() and not ignore.is_symlink(): + arguments.extend(["--ignore-file", str(ignore)]) + arguments.extend(["--", requested_scope]) + with tempfile.TemporaryFile(mode="w+b") as inventory: + try: + result = subprocess.run( + arguments, + cwd=directory, + stdout=inventory, + stderr=subprocess.PIPE, + check=False, + timeout=INVENTORY_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise InventoryError(f"could not run ripgrep: {error}") from error + + if result.returncode not in (0, 1): + detail = result.stderr.decode("utf-8", errors="replace").strip() + message = f"ripgrep exited with status {result.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + + inventory.seek(0) + return set(inventory) + + def normalized(path: bytes) -> bytes: + return path.replace(b"\\", b"/") if os.name == "nt" else path + + rows = ripgrep_inventory(repository, scope) + for ancestor in ancestors[1:]: + if not any((ancestor / name).is_file() for name in IGNORE_FILE_NAMES): + continue + ancestor_scope = selected.relative_to(ancestor).as_posix() or "." + ancestor_prefix = os.fsencode(ancestor.relative_to(repository).as_posix()) + b"/" + visible = { + normalized(ancestor_prefix + row.removesuffix(b"\n").removeprefix(b"./")) + for row in ripgrep_inventory(ancestor, ancestor_scope) + } + rows = { + row + for row in rows + if normalized(row.removesuffix(b"\n").removeprefix(b"./")) in visible + } + + environment = os.environ.copy() + for name in ( + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_DIR", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_INDEX_FILE", + "GIT_ICASE_PATHSPECS", + "GIT_GLOB_PATHSPECS", + "GIT_NAMESPACE", + "GIT_NOGLOB_PATHSPECS", + "GIT_OBJECT_DIRECTORY", + "GIT_WORK_TREE", + ): + environment.pop(name, None) + environment["GIT_LITERAL_PATHSPECS"] = "1" + environment["LC_ALL"] = "C" + git = [ + "git", + "-c", + "core.fsmonitor=false", + "-c", + f"core.excludesFile={os.devnull}", + "--literal-pathspecs", + ] + + def run_git( + arguments: list[str], *, directory: Path = repository, literal: bool = True + ) -> subprocess.CompletedProcess[bytes]: + command = git if literal else git[:-1] + git_environment = environment if literal else environment.copy() + if not literal: + git_environment.pop("GIT_LITERAL_PATHSPECS", None) try: - result = subprocess.run( - command, - cwd=repository, - stdout=inventory, + return subprocess.run( + [*command, *arguments], + cwd=directory, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=git_environment, check=False, + timeout=GIT_TIMEOUT_SECONDS, ) - except OSError as error: - raise InventoryError(f"could not run ripgrep: {error}") from error - - if result.returncode not in (0, 1): - detail = result.stderr.decode("utf-8", errors="replace").strip() - message = f"ripgrep exited with status {result.returncode}" - if detail: - message = f"{message}: {detail}" - raise InventoryError(message) - - inventory.seek(0) - rows = sorted(inventory) + except (OSError, subprocess.TimeoutExpired) as error: + raise InventoryError(f"could not run Git: {error}") from error + + worktree = ( + run_git(["rev-parse", "--show-toplevel"]) + if (repository / ".git").exists() + else None + ) + if worktree is not None and worktree.returncode: + detail = worktree.stderr.decode("utf-8", errors="replace").strip() + message = f"git rev-parse exited with status {worktree.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + + if worktree is not None: + try: + worktree_root = Path(os.fsdecode(worktree.stdout.strip())).resolve(strict=True) + except (OSError, ValueError) as error: + raise InventoryError(f"could not resolve Git worktree root: {error}") from error + if worktree_root != repository: + worktree = None + + if worktree is not None: + prefix = b"./" if scope == "." or scope.startswith("./") else b"" + listed: list[bytes] = [] + for arguments in (["--cached"], ["--others", "--exclude-standard"]): + result = run_git(["ls-files", *arguments, "-z", "--", scope]) + if result.returncode: + detail = result.stderr.decode("utf-8", errors="replace").strip() + message = f"git ls-files exited with status {result.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + listed.append(result.stdout) + + nested_roots: set[Path] = set() + current = selected if selected.is_dir() else selected.parent + while current != repository: + if (current / ".git").exists(): + nested_roots.add(current) + current = current.parent + for collection in listed: + for relative in collection.split(b"\0"): + if not relative: + continue + candidate = repository / os.fsdecode(relative) + if candidate.is_dir() and (candidate / ".git").exists(): + nested_roots.add(candidate.resolve(strict=True)) + + pending_roots = sorted(nested_roots) + inspected_roots: set[Path] = set() + while pending_roots: + nested = pending_roots.pop(0) + if nested in inspected_roots: + continue + inspected_roots.add(nested) + try: + nested_scope = selected.relative_to(nested).as_posix() or "." + except ValueError: + nested_scope = "." + nested_prefix = os.fsencode(nested.relative_to(repository).as_posix()) + b"/" + for index, arguments in enumerate( + (["--cached"], ["--others", "--exclude-standard"]) + ): + result = run_git( + ["ls-files", *arguments, "-z", "--", nested_scope], + directory=nested, + ) + if result.returncode: + detail = result.stderr.decode("utf-8", errors="replace").strip() + raise InventoryError( + f"nested git ls-files exited with status {result.returncode}: {detail}" + ) + listed[index] += b"".join( + nested_prefix + relative + b"\0" + for relative in result.stdout.split(b"\0") + if relative + ) + for relative in result.stdout.split(b"\0"): + if not relative: + continue + candidate = nested / os.fsdecode(relative) + if candidate.is_symlink() or not candidate.is_dir(): + continue + if not (candidate / ".git").exists(): + continue + try: + discovered = candidate.resolve(strict=True) + discovered.relative_to(repository) + except (OSError, ValueError): + continue + if discovered not in inspected_roots: + pending_roots.append(discovered) + + allowed = { + normalized(prefix + relative) + for collection in listed + for relative in collection.split(b"\0") + if relative + } + nested_worktrees = tuple(path for path in allowed if path.endswith(b"/")) + explicitly_ignored = False + if scope not in (".", "./"): + enclosing = max( + (root for root in (repository, *inspected_roots) if selected.is_relative_to(root)), + key=lambda root: len(root.parts), + ) + explicit_relative = selected.relative_to(enclosing).as_posix() + explicit_path = f"./{explicit_relative}" + ignored = run_git( + ["check-ignore", "--quiet", "--no-index", "--", explicit_path], + directory=enclosing, + literal=False, + ) + if ignored.returncode not in (0, 1): + detail = ignored.stderr.decode("utf-8", errors="replace").strip() + message = f"git check-ignore exited with status {ignored.returncode}" + if detail: + message = f"{message}: {detail}" + raise InventoryError(message) + explicitly_ignored = ignored.returncode == 0 + + if not explicitly_ignored: + rows = { + row + for row in rows + if (path := normalized(row.removesuffix(b"\n"))) in allowed + or any(path.startswith(worktree) for worktree in nested_worktrees) + } + recorded = {normalized(row.removesuffix(b"\n")) for row in rows} + + for relative in listed[0].split(b"\0"): + if not relative: + continue + candidate = repository / os.fsdecode(relative) + if candidate.is_symlink() or not candidate.is_file(): + continue + try: + candidate.resolve(strict=True).relative_to(repository) + except (OSError, ValueError): + continue + relative_path = prefix + relative + key = normalized(relative_path) + if key not in recorded: + rows.add(relative_path + b"\n") + recorded.add(key) + + rows = sorted(rows) output.parent.mkdir(parents=True, exist_ok=True) temporary: Path | None = None diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 65a26b24..f45d49f2 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -229,7 +229,8 @@ describe("plugin runtime preparation", () => { join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), "utf8", ); - expect(generator).toContain('"--no-ignore"'); + expect(generator).not.toContain('"--no-ignore"'); + expect(generator).toContain('"--cached"'); return; } diff --git a/sdk/typescript/tests-ts/scan-inventory.test.ts b/sdk/typescript/tests-ts/scan-inventory.test.ts new file mode 100644 index 00000000..ed47c7f9 --- /dev/null +++ b/sdk/typescript/tests-ts/scan-inventory.test.ts @@ -0,0 +1,493 @@ +import { execFileSync } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("security scan file inventory", () => { + test("includes hidden source files without exposing ignored repository files", async () => { + if (Bun.which("rg") === null) { + const generator = await readFile( + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "utf8", + ); + expect(generator).not.toContain('"--no-ignore"'); + expect(generator).toContain('"--cached"'); + expect(generator).toContain('"--no-config"'); + expect(generator).toContain('"--no-ignore-parent"'); + expect(generator).toContain('"--no-require-git"'); + expect(generator).toContain('"--literal-pathspecs"'); + expect(generator).toContain('"core.fsmonitor=false"'); + return; + } + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-scan-inventory-")), + ); + temporaryDirectories.push(root); + + const repository = join(root, "repository"); + const output = join(root, "in-scope-files.txt"); + const globalIgnore = join(root, "global-ignore"); + await mkdir(join(repository, "src"), { recursive: true }); + await mkdir(join(repository, "ignored")); + execFileSync("git", ["init", "-q"], { cwd: repository }); + + await Promise.all([ + writeFile( + join(repository, ".gitignore"), + "ignored/\n.env\ntracked.env\ntracked-link\n", + ), + writeFile(join(repository, ".env"), "SECRET=private\n"), + writeFile(join(repository, ".visible-config"), "visible=true\n"), + writeFile(join(repository, "ignored", "secret.ts"), "private data\n"), + writeFile(join(repository, "src", "handler.ts"), "export {};\n"), + writeFile(join(repository, "src", "info-secret.ts"), "local secret\n"), + writeFile(join(repository, "tracked.env"), "checked in intentionally\n"), + writeFile(join(repository, ".ignore"), "hidden-by-rg.ts\n"), + writeFile(join(repository, "hidden-by-rg.ts"), "tracked source\n"), + writeFile( + join(repository, "info-secret.ts"), + "local Git-excluded data\n", + ), + writeFile(globalIgnore, "*.ts\n"), + ]); + await writeFile( + join(repository, ".git", "info", "exclude"), + "info-secret.ts\nsrc/info-secret.ts\n", + ); + execFileSync( + "git", + ["add", "--force", "--", "tracked.env", "hidden-by-rg.ts"], + { + cwd: repository, + }, + ); + if (process.platform !== "win32") { + const external = join(root, "external.txt"); + await writeFile(external, "private external file\n"); + await symlink(external, join(repository, "tracked-link")); + execFileSync("git", ["add", "--force", "--", "tracked-link"], { + cwd: repository, + }); + } + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { + cwd: repository, + stdio: "pipe", + env: { + ...process.env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.excludesFile", + GIT_CONFIG_VALUE_0: globalIgnore, + GIT_GLOB_PATHSPECS: "1", + GIT_ICASE_PATHSPECS: "1", + }, + }, + ); + + expect( + (await readFile(output, "utf8")) + .trimEnd() + .split("\n") + .map((path) => path.replaceAll("\\", "/")), + ).toEqual([ + "./.gitignore", + "./.ignore", + "./.visible-config", + "./hidden-by-rg.ts", + "./src/handler.ts", + "./tracked.env", + ]); + + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "src", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).trim()).toBe("src/handler.ts"); + }); + + test("respects ignore files in non-Git directory snapshots", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-directory-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "snapshot"); + const output = join(root, "in-scope-files.txt"); + await mkdir(repository); + execFileSync("git", ["init", "-q"], { cwd: root }); + await writeFile(join(root, ".gitignore"), "snapshot/source.ts\n"); + await Promise.all([ + writeFile(join(repository, ".gitignore"), ".env\n"), + writeFile(join(repository, ".env"), "SECRET=private\n"), + writeFile(join(repository, "source.ts"), "export {};\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + const rows = (await readFile(output, "utf8")) + .trimEnd() + .split("\n") + .map((path) => path.replaceAll("\\", "/")); + expect(rows).toEqual(["./.gitignore", "./source.ts"]); + }); + + test.each([false, true])( + "applies intermediate scope ignore files (Git repository: %s)", + async (useGit) => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-ancestor-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const scoped = join(repository, "parent", "nested"); + const output = join(root, "in-scope-files.txt"); + await mkdir(scoped, { recursive: true }); + if (useGit) execFileSync("git", ["init", "-q"], { cwd: repository }); + await Promise.all([ + writeFile(join(repository, "parent", ".ignore"), "nested/secret.py\n"), + writeFile( + join(repository, "parent", ".gitignore"), + "nested/private.py\n", + ), + writeFile(join(scoped, "secret.py"), "secret\n"), + writeFile(join(scoped, "private.py"), "private\n"), + writeFile(join(scoped, "safe.py"), "safe\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "parent/nested", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + expect((await readFile(output, "utf8")).trim()).toBe( + "parent/nested/safe.py", + ); + }, + ); + + test("retains visible files inside nested Git worktrees", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-nested-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const nested = join(repository, "nested"); + const output = join(root, "in-scope-files.txt"); + await mkdir(nested, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: repository }); + execFileSync("git", ["init", "-q"], { cwd: nested }); + await Promise.all([ + writeFile(join(nested, ".gitignore"), ".env\n"), + writeFile(join(nested, ".env"), "SECRET=private\n"), + writeFile(join(nested, "tracked.py"), "print('tracked')\n"), + writeFile(join(nested, "local.py"), "print('local')\n"), + writeFile(join(nested, "chosen.skip"), "explicit nested source\n"), + writeFile(join(nested, ".git", "info", "exclude"), "chosen.skip\n"), + ]); + execFileSync("git", ["add", "--", "tracked.py"], { cwd: nested }); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + const rows = (await readFile(output, "utf8")) + .trimEnd() + .split("\n") + .map((path) => path.replaceAll("\\", "/")); + expect(rows).toContain("./nested/tracked.py"); + expect(rows).toContain("./nested/local.py"); + expect(rows).not.toContain("./nested/.env"); + + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "nested/tracked.py", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).trim()).toBe("nested/tracked.py"); + + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "nested/chosen.skip", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).trim()).toBe("nested/chosen.skip"); + + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track nested source", + ], + { cwd: nested }, + ); + const inner = join(nested, "inner"); + await mkdir(inner); + execFileSync("git", ["init", "-q"], { cwd: inner }); + await writeFile(join(inner, "security.py"), "print('nested security')\n"); + execFileSync("git", ["add", "--", "security.py"], { cwd: inner }); + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track inner security source", + ], + { cwd: inner }, + ); + execFileSync("git", ["add", "--", "inner"], { + cwd: nested, + stdio: "ignore", + }); + execFileSync( + "git", + [ + "-c", + "user.name=Inventory Test", + "-c", + "user.email=inventory@example.test", + "commit", + "-qm", + "Track inner worktree", + ], + { cwd: nested }, + ); + await writeFile(join(repository, ".gitignore"), "nested/\n"); + execFileSync("git", ["add", "--force", "--", "nested"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + ".", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + expect((await readFile(output, "utf8")).split("\n")).toContain( + "./nested/tracked.py", + ); + expect((await readFile(output, "utf8")).split("\n")).toContain( + "./nested/inner/security.py", + ); + }); + + test("retains an explicitly scoped Git-ignored file", async () => { + if (Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-explicit-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const output = join(root, "in-scope-files.txt"); + await mkdir(repository); + execFileSync("git", ["init", "-q"], { cwd: repository }); + await Promise.all([ + writeFile(join(repository, ".gitignore"), "*.skip\n"), + writeFile(join(repository, "selected.skip"), "explicit source\n"), + ]); + + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(python).not.toBeNull(); + if (python === null) throw new Error("A Python interpreter is required."); + execFileSync( + python, + [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--scope", + "selected.skip", + "--out", + output, + ], + { cwd: repository, stdio: "pipe" }, + ); + + expect((await readFile(output, "utf8")).trim()).toBe("selected.skip"); + }); + + test("rejects symbolic scope and ignore-file paths", async () => { + if (process.platform === "win32" || Bun.which("rg") === null) return; + + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-symbolic-inventory-")), + ); + temporaryDirectories.push(root); + const repository = join(root, "repository"); + const output = join(root, "in-scope-files.txt"); + await mkdir(join(repository, "source"), { recursive: true }); + await writeFile(join(repository, "source", "file.ts"), "export {};\n"); + await writeFile(join(repository, ".gitignore"), "ignored.ts\n"); + await symlink("source", join(repository, "alias")); + const unrelated = join(repository, "unrelated"); + await mkdir(unrelated); + await symlink(join(repository, ".gitignore"), join(unrelated, ".ignore")); + const python = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + if (python === null) throw new Error("A Python interpreter is required."); + const command = [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_in_scope_files.py"), + "--repo", + repository, + "--out", + output, + ]; + + execFileSync(python, [...command, "--scope", "source"], { + cwd: repository, + stdio: "pipe", + }); + expect((await readFile(output, "utf8")).trim()).toBe("source/file.ts"); + + expect(() => + execFileSync(python, [...command, "--scope", "alias"], { + cwd: repository, + stdio: "pipe", + }), + ).toThrow("symbolic links are not supported"); + + await symlink(".gitignore", join(repository, ".ignore")); + expect(() => + execFileSync(python, [...command, "--scope", "."], { + cwd: repository, + stdio: "pipe", + }), + ).toThrow("symbolic ignore files are not supported"); + }); +});