diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index 76688fe96..e29492361 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.22", + "version": "0.1.38", "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..21786a65b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py +++ b/sdk/typescript/_bundled_plugin/scripts/generate_in_scope_files.py @@ -174,7 +174,7 @@ def generate_diff_in_scope_files( is_binary_sample, preview_for, ) - from workbench_target import git_blob_bytes + from workbench_target import existing_ancestor_is_within_target, git_blob_bytes rows: list[bytes] = [] try: @@ -206,6 +206,19 @@ def generate_diff_in_scope_files( for path, status in eligible: relative = path.relative_to(repository) + if mode != "revisions": + try: + within_target = existing_ancestor_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] @@ -215,12 +228,13 @@ def generate_diff_in_scope_files( ) if is_binary_sample(contents): continue - elif ( - path.is_symlink() - or not path.is_file() - or preview_for(path, DEFAULT_PREVIEW_BYTES)[1] - ): - continue + else: + if ( + path.is_symlink() + or not path.is_file() + or preview_for(path, DEFAULT_PREVIEW_BYTES)[1] + ): + continue relative_path = relative.as_posix() if "\n" in relative_path or "\r" in relative_path: raise InventoryError( diff --git a/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py b/sdk/typescript/_bundled_plugin/scripts/generate_rank_input.py index 8c4dd3722..45ba4e737 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 ( + existing_ancestor_is_within_target, + git_blob_bytes, + git_directory_snapshot_paths, +) EXCLUDED_DIRS = { ".cache", @@ -708,6 +712,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 = existing_ancestor_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 +738,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..95c38d54a 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,41 @@ 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 existing_ancestor_is_within_target(path: Path, target: Path) -> bool: + """Resolve the nearest existing path without losing missing Git entries.""" + candidate = path + 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 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 +529,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 241666585..61b9ee469 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.22" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.38" 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..a4cde407a 100644 --- a/sdk/typescript/tests-ts/diff-rank-input.test.ts +++ b/sdk/typescript/tests-ts/diff-rank-input.test.ts @@ -52,9 +52,10 @@ test("diff previews stay inside the selected repository", () => { 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, "src", "deleted.py"), "removed = True\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", "."); @@ -71,7 +72,7 @@ test("diff previews stay inside the selected repository", () => { 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")); + rmSync(join(repository, "removed"), { recursive: true }); git(repository, "add", "."); const updatedLink = git(repository, "hash-object", "src/entry.py"); git( @@ -82,35 +83,31 @@ test("diff previews stay inside the selected repository", () => { ); git(repository, "commit", "-qm", "selected changes"); const head = git(repository, "rev-parse", "HEAD"); - - 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 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 result = spawnSync( - python!, - [ - "-B", - join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), - "make-diff-rank-input", - "--repo", - repository, - "--base", - base, - "--head", - head, - "--mode", - "local-patch", - "--out", - output, - ], - { encoding: "utf8" }, - ); + const args = [ + "-B", + join(PLUGIN_ROOT, "scripts", "generate_rank_input.py"), + "make-diff-rank-input", + "--repo", + repository, + "--base", + base, + "--head", + head, + "--mode", + "local-patch", + "--out", + output, + ]; + const result = spawnSync(python!, args, { encoding: "utf8" }); expect(result.status, result.stderr).toBe(0); const rows = readFileSync(output, "utf8") @@ -118,7 +115,7 @@ test("diff previews stay inside the selected repository", () => { .split("\n") .map((row) => JSON.parse(row) as { path: string; preview: string }); expect(rows.map((row) => row.path)).toEqual([ - "src/deleted.py", + "removed/deleted.py", "src/entry.py", "src/handler.py", "src/nested/linked.py", @@ -127,7 +124,19 @@ test("diff previews stay inside the selected repository", () => { "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" }); + expect(escaped.status).not.toBe(0); + expect(escaped.stderr).toContain( + "Changed Git working-tree paths must stay inside the selected target.", ); }); diff --git a/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts b/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts index 67dbddeab..54e448d4d 100644 --- a/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts +++ b/sdk/typescript/tests-ts/workbench-canonical-paths.test.ts @@ -129,6 +129,254 @@ 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"); + await mkdir(repository); + await mkdir(outside); + await mkdir(join(repository, "selected")); + await writeFile( + join(repository, "selected", "public.py"), + "public = True\n", + ); + await writeFile(join(outside, "private.py"), "private = True\n"); + await symlink( + outside, + join(repository, "linked"), + process.platform === "win32" ? "junction" : "dir", + ); + await mkdir(join(repository, "cyclic")); + await mkdir(join(repository, "nested-checkout")); + await mkdir(join(repository, "internal-source")); + await writeFile( + join(repository, "internal-source", "public.py"), + "public = True\n", + ); + await symlink( + outside, + join(repository, "nested-checkout", "linked"), + process.platform === "win32" ? "junction" : "dir", + ); + await symlink( + join(repository, "internal-source"), + join(repository, "nested-checkout", "safe-linked"), + process.platform === "win32" ? "junction" : "dir", + ); + await mkdir(join(repository, "selected", "nested-checkout")); + await symlink( + join(repository, "internal-source"), + join(repository, "selected", "nested-checkout", "sibling-linked"), + 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(