diff --git a/README.md b/README.md index bbaf7fa..4d90b65 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ my-project/.contextzip/upload/ The files can then be selected together and dragged into a ChatGPT conversation or project. Directory separators become `__`. Extensionless files such as `Dockerfile` receive a `.txt` suffix for web upload compatibility. Only the output filename changes; the file bytes do not. +Long or colliding output names receive a stable hash suffix and stay within a 240-byte filename limit. ## Core principles @@ -112,6 +113,8 @@ python3 scripts/pack.py \ Use `--help` for all options. +Custom output and manifest paths are resolved and validated before any files are deleted or written. The output must be a strict subdirectory of `/.contextzip`; the manifest must also remain inside `.contextzip` but outside the output directory. Overlapping paths, paths outside `.contextzip`, and a symlinked `.contextzip` directory are rejected. + ## Selection behavior When the project is a Git repository, ContextZIP uses Git to collect tracked files and unignored untracked files. This respects `.gitignore` without implementing a second ignore parser. diff --git a/SKILL.md b/SKILL.md index 2a1809f..3b931c6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,6 +14,8 @@ Create a lightweight upload pack from the current local project. - Prefer path names, extensions, Git status, `.gitignore`, file sizes, and explicit user scope. - Copy selected files byte-for-byte into a flat output directory. - Keep `manifest.json` outside the upload directory so it does not add context unless the user chooses to upload it. +- Keep output and manifest paths inside `/.contextzip`; keep the manifest outside the upload directory. +- Use the default paths unless the user explicitly requests safe custom paths inside `.contextzip`. - Never upload files automatically. - Never bypass the sensitive-path blocklist unless the user explicitly edits the script themselves. @@ -66,7 +68,7 @@ python3 /scripts/pack.py \ └── manifest.json ``` -The flattened filename encodes the original relative path with `__`. Extensionless files receive a `.txt` suffix for web upload compatibility. The manifest records the exact source-to-output mapping and SHA-256 hashes. +The flattened filename encodes the original relative path with `__`. Extensionless files receive a `.txt` suffix for web upload compatibility. Long or colliding names receive a stable hash while remaining within 240 bytes. The manifest records the exact source-to-output mapping and SHA-256 hashes. ## Important interpretation diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..ce10d6e --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "ContextZIP" + short_description: "Prepare safe, byte-preserving project upload packs" + default_prompt: "Use $contextzip to prepare the current project as a safe drag-and-drop upload pack." diff --git a/scripts/pack.py b/scripts/pack.py index c80cff0..cf58493 100755 --- a/scripts/pack.py +++ b/scripts/pack.py @@ -357,41 +357,53 @@ def select_candidates( return selected, skipped +def truncate_utf8(value: str, max_bytes: int) -> str: + """Truncate text to a UTF-8 byte budget without splitting a character.""" + return value.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore") + + +def fit_flat_name(candidate: str, marker: str = "") -> str: + """Fit a filename and optional pre-extension marker within the byte limit.""" + suffix = Path(candidate).suffix + stem = candidate[: -len(suffix)] if suffix else candidate + reserve = f"{marker}{suffix}" + + if len(reserve.encode("utf-8")) >= MAX_OUTPUT_FILENAME_BYTES: + stem = candidate + suffix = "" + reserve = marker + + stem_budget = MAX_OUTPUT_FILENAME_BYTES - len(reserve.encode("utf-8")) + fitted = f"{truncate_utf8(stem, stem_budget)}{marker}{suffix}" + if len(fitted.encode("utf-8")) > MAX_OUTPUT_FILENAME_BYTES: + raise PackError("Could not create a safe output filename") + return fitted + + def safe_flat_name(relative: Path, used_names: set[str]) -> str: """Encode a relative path as one visible, collision-safe filename.""" parts = [] for part in relative.parts: visible = f"dot_{part[1:]}" if part.startswith(".") else part parts.append(visible) - candidate = "__".join(parts) + base_candidate = "__".join(parts) if not relative.suffix: - candidate = f"{candidate}.txt" + base_candidate = f"{base_candidate}.txt" digest = hashlib.sha256(relative.as_posix().encode("utf-8")).hexdigest()[:10] - encoded = candidate.encode("utf-8") - if len(encoded) > MAX_OUTPUT_FILENAME_BYTES: - suffix = Path(candidate).suffix - reserve = len((f"--{digest}{suffix}").encode("utf-8")) - budget = max(1, MAX_OUTPUT_FILENAME_BYTES - reserve) - prefix_bytes = candidate[: -len(suffix) if suffix else None].encode("utf-8")[:budget] - prefix = prefix_bytes.decode("utf-8", errors="ignore") - candidate = f"{prefix}--{digest}{suffix}" - - if candidate in used_names: - suffix = Path(candidate).suffix - stem = candidate[: -len(suffix)] if suffix else candidate - candidate = f"{stem}--{digest}{suffix}" - - counter = 2 - unique_candidate = candidate - while unique_candidate in used_names: - suffix = Path(candidate).suffix - stem = candidate[: -len(suffix)] if suffix else candidate - unique_candidate = f"{stem}-{counter}{suffix}" + if len(base_candidate.encode("utf-8")) <= MAX_OUTPUT_FILENAME_BYTES: + candidate = base_candidate + else: + candidate = fit_flat_name(base_candidate, f"--{digest}") + + counter = 1 + while candidate in used_names: + marker = f"--{digest}" if counter == 1 else f"--{digest}-{counter}" + candidate = fit_flat_name(base_candidate, marker) counter += 1 - used_names.add(unique_candidate) - return unique_candidate + used_names.add(candidate) + return candidate def sha256_file(path: Path) -> str: @@ -402,15 +414,46 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def ensure_safe_output(root: Path, output: Path) -> None: +def ensure_safe_paths(root: Path, output: Path, manifest_path: Path) -> None: root_resolved = root.resolve() + contextzip_path = root_resolved / ".contextzip" + if contextzip_path.is_symlink(): + raise PackError("/.contextzip must not be a symlink") + + contextzip_resolved = contextzip_path.resolve() output_resolved = output.resolve() + manifest_resolved = manifest_path.resolve() + try: - relative = output_resolved.relative_to(root_resolved) + output_relative = output_resolved.relative_to(contextzip_resolved) except ValueError as exc: - raise PackError("Output directory must be inside the project root") from exc - if not relative.parts or relative.parts[0] != ".contextzip": - raise PackError("Output directory must be inside /.contextzip") + raise PackError("Output directory must be inside /.contextzip") from exc + if not output_relative.parts: + raise PackError("Output directory must be a strict subdirectory of /.contextzip") + + try: + manifest_relative = manifest_resolved.relative_to(contextzip_resolved) + except ValueError as exc: + raise PackError("Manifest path must be inside /.contextzip") from exc + if not manifest_relative.parts: + raise PackError("Manifest path must name a file inside /.contextzip") + + try: + manifest_resolved.relative_to(output_resolved) + except ValueError: + pass + else: + raise PackError("Manifest path must be outside the upload directory") + + try: + output_resolved.relative_to(manifest_resolved) + except ValueError: + pass + else: + raise PackError("Manifest path must not contain the upload directory") + + if manifest_resolved.is_dir(): + raise PackError("Manifest path must not be a directory") def prepare_output(output: Path) -> None: @@ -428,7 +471,10 @@ def create_pack( mode: str, verify: bool, ) -> dict[str, object]: - ensure_safe_output(root, output) + root = root.resolve() + output = output.resolve() + manifest_path = manifest_path.resolve() + ensure_safe_paths(root, output, manifest_path) prepare_output(output) manifest_path.parent.mkdir(parents=True, exist_ok=True) @@ -511,13 +557,19 @@ def build_parser() -> argparse.ArgumentParser: "--output", type=Path, default=None, - help=f"Output directory relative to root. Defaults to {DEFAULT_OUTPUT_DIR}.", + help=( + "Output directory inside /.contextzip. " + f"Defaults to {DEFAULT_OUTPUT_DIR}." + ), ) parser.add_argument( "--manifest", type=Path, default=None, - help=f"Manifest path relative to root. Defaults to {DEFAULT_MANIFEST}.", + help=( + "Manifest path inside /.contextzip and outside the output directory. " + f"Defaults to {DEFAULT_MANIFEST}." + ), ) parser.add_argument("--include", action="append", default=[], help="Additional include glob. Repeatable.") parser.add_argument("--exclude", action="append", default=[], help="Exclude glob. Repeatable.") @@ -575,6 +627,7 @@ def main(argv: Sequence[str] | None = None) -> int: max_file_size_bytes = int(args.max_file_size_mb * 1024 * 1024) try: + ensure_safe_paths(root, output, manifest) selected, skipped = select_candidates( root=root, mode=args.mode, diff --git a/tests/test_pack.py b/tests/test_pack.py index 1525c5e..df523af 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -1,6 +1,8 @@ from __future__ import annotations +import contextlib import importlib.util +import io import json import shutil import subprocess @@ -40,6 +42,13 @@ def select_all(self): max_file_size_bytes=50 * 1024 * 1024, ) + def run_main(self, args: list[str]) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + return_code = pack.main(args) + return return_code, stdout.getvalue(), stderr.getvalue() + def test_pack_preserves_bytes_and_keeps_manifest_outside_upload(self) -> None: original = b"def answer():\n return 42\n" self.write_bytes("src/main.py", original) @@ -86,6 +95,28 @@ def test_sensitive_generated_and_unsupported_files_are_excluded(self) -> None: self.assertNotIn("node_modules/pkg/index.js", {item.source for item in skipped}) self.assertIn(("assets/archive.bin", "unsupported file type"), skipped_reasons) + def test_size_limit_and_user_exclude_are_reported(self) -> None: + self.write_bytes("src/keep.py", b"ok\n") + self.write_bytes("src/large.py", b"12345\n") + self.write_bytes("src/excluded.py", b"skip\n") + + selected, skipped = pack.select_candidates( + root=self.root, + mode="all", + includes=[], + excludes=["src/excluded.py"], + max_file_size_bytes=5, + ) + + self.assertEqual([path.as_posix() for path in selected], ["src/keep.py"]) + self.assertEqual( + {(item.source, item.reason) for item in skipped}, + { + ("src/excluded.py", "user exclude pattern"), + ("src/large.py", "file exceeds size limit"), + }, + ) + def test_flattened_name_collision_gets_stable_hash(self) -> None: self.write_bytes("a/b.py", b"nested\n") self.write_bytes("a__b.py", b"flat\n") @@ -109,6 +140,26 @@ def test_flattened_name_collision_gets_stable_hash(self) -> None: self.assertIn("a__b.py", names) self.assertTrue(any(name.startswith("a__b--") for name in names)) + def test_flattened_name_limit_survives_multiple_collisions(self) -> None: + for stem in ("x" * 231, "한" * 77): + with self.subTest(stem=stem[:3]): + paths = [ + Path("a") / "b" / f"{stem}.py", + Path("a__b") / f"{stem}.py", + Path("a") / f"b__{stem}.py", + Path(f"a__b__{stem}.py"), + ] + used_names: set[str] = set() + names = [pack.safe_flat_name(path, used_names) for path in paths] + + self.assertEqual(len(names), len(set(names))) + self.assertTrue( + all( + len(name.encode("utf-8")) <= pack.MAX_OUTPUT_FILENAME_BYTES + for name in names + ) + ) + def test_explicit_include_adds_unknown_extension_but_not_sensitive_file(self) -> None: self.write_bytes("logs/failure.log", b"trace\n") self.write_bytes("secrets/client_secret_dev.json", b"{}\n") @@ -153,6 +204,21 @@ def test_current_mode_selects_changes_untracked_and_root_context(self) -> None: self.assertEqual(selected_names, {"README.md", "notes/new.md", "src/changed.py"}) + @unittest.skipUnless(shutil.which("git"), "git is required for ignore test") + def test_git_repository_respects_gitignore(self) -> None: + subprocess.run(["git", "init"], cwd=self.root, check=True, stdout=subprocess.DEVNULL) + self.write_bytes(".gitignore", b"ignored.py\n") + self.write_bytes("visible.py", b"visible\n") + self.write_bytes("ignored.py", b"ignored\n") + + selected, skipped = self.select_all() + + self.assertEqual( + {path.as_posix() for path in selected}, + {".gitignore", "visible.py"}, + ) + self.assertNotIn("ignored.py", {item.source for item in skipped}) + def test_symlink_is_skipped_even_when_target_is_inside_project(self) -> None: target = self.write_bytes("src/target.py", b"target\n") link = self.root / "src/link.py" @@ -190,7 +256,7 @@ def test_cli_refuses_to_silently_truncate_max_files(self) -> None: self.write_bytes("one.py", b"1\n") self.write_bytes("two.py", b"2\n") - return_code = pack.main( + return_code, _, stderr = self.run_main( [ "--root", str(self.root), @@ -200,8 +266,228 @@ def test_cli_refuses_to_silently_truncate_max_files(self) -> None: ] ) self.assertEqual(return_code, 2) + self.assertIn("exceeding --max-files 1", stderr) self.assertFalse((self.root / ".contextzip/upload").exists()) + def test_cli_rejects_manifest_outside_project_without_writing(self) -> None: + project = self.root / "project" + project.mkdir() + (project / "README.md").write_bytes(b"# Demo\n") + outside_manifest = self.root / "outside.json" + + return_code, _, stderr = self.run_main( + [ + "--root", + str(project), + "--manifest", + "../outside.json", + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("Manifest path must be inside", stderr) + self.assertFalse(outside_manifest.exists()) + self.assertFalse((project / ".contextzip").exists()) + + def test_cli_rejects_output_outside_contextzip_without_writing(self) -> None: + project = self.root / "project" + project.mkdir() + (project / "README.md").write_bytes(b"# Demo\n") + outside_output = self.root / "outside-upload" + + return_code, _, stderr = self.run_main( + [ + "--root", + str(project), + "--output", + "../outside-upload", + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("Output directory must be inside", stderr) + self.assertFalse(outside_output.exists()) + self.assertFalse((project / ".contextzip").exists()) + + def test_cli_rejects_absolute_manifest_outside_project(self) -> None: + project = self.root / "project" + project.mkdir() + (project / "README.md").write_bytes(b"# Demo\n") + outside_manifest = self.root / "absolute-outside.json" + + return_code, _, stderr = self.run_main( + [ + "--root", + str(project), + "--manifest", + str(outside_manifest), + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("Manifest path must be inside", stderr) + self.assertFalse(outside_manifest.exists()) + self.assertFalse((project / ".contextzip").exists()) + + def test_dry_run_rejects_unsafe_manifest_path(self) -> None: + project = self.root / "project" + project.mkdir() + (project / "README.md").write_bytes(b"# Demo\n") + + return_code, _, stderr = self.run_main( + [ + "--root", + str(project), + "--manifest", + "../outside.json", + "--dry-run", + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("Manifest path must be inside", stderr) + self.assertFalse((project / ".contextzip").exists()) + + def test_cli_rejects_manifest_that_would_overwrite_source(self) -> None: + original = b"# Keep me\n" + source = self.write_bytes("README.md", original) + + return_code, _, stderr = self.run_main( + [ + "--root", + str(self.root), + "--manifest", + "README.md", + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("Manifest path must be inside", stderr) + self.assertEqual(source.read_bytes(), original) + self.assertFalse((self.root / ".contextzip").exists()) + + def test_cli_rejects_manifest_inside_upload_directory(self) -> None: + self.write_bytes("README.md", b"# Demo\n") + + return_code, _, stderr = self.run_main( + [ + "--root", + str(self.root), + "--manifest", + ".contextzip/upload/manifest.json", + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("outside the upload directory", stderr) + self.assertFalse((self.root / ".contextzip").exists()) + + def test_cli_rejects_manifest_path_that_contains_upload(self) -> None: + self.write_bytes("README.md", b"# Demo\n") + + return_code, _, stderr = self.run_main( + [ + "--root", + str(self.root), + "--output", + ".contextzip/manifest.json/upload", + "--manifest", + ".contextzip/manifest.json", + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("must not contain the upload directory", stderr) + self.assertFalse((self.root / ".contextzip").exists()) + + def test_cli_rejects_contextzip_root_as_output(self) -> None: + self.write_bytes("README.md", b"# Demo\n") + + return_code, _, stderr = self.run_main( + [ + "--root", + str(self.root), + "--output", + ".contextzip", + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("strict subdirectory", stderr) + self.assertFalse((self.root / ".contextzip").exists()) + + def test_cli_accepts_custom_manifest_inside_contextzip(self) -> None: + self.write_bytes("README.md", b"# Demo\n") + output = self.root / ".contextzip/custom-upload" + manifest = self.root / ".contextzip/manifests/custom.json" + + return_code, _, stderr = self.run_main( + [ + "--root", + str(self.root), + "--output", + ".contextzip/custom-upload", + "--manifest", + ".contextzip/manifests/custom.json", + ] + ) + + self.assertEqual(return_code, 0, stderr) + self.assertTrue(manifest.is_file()) + self.assertTrue((output / "README.md").is_file()) + + def test_dry_run_preserves_existing_output(self) -> None: + self.write_bytes("README.md", b"# Demo\n") + existing = self.write_bytes(".contextzip/upload/keep.txt", b"keep\n") + + return_code, stdout, stderr = self.run_main( + [ + "--root", + str(self.root), + "--dry-run", + ] + ) + + self.assertEqual(return_code, 0, stderr) + self.assertIn("Dry-run complete", stdout) + self.assertEqual(existing.read_bytes(), b"keep\n") + + def test_cli_with_no_eligible_files_does_not_create_output(self) -> None: + self.write_bytes("assets/archive.bin", b"unsupported\n") + + return_code, _, stderr = self.run_main( + [ + "--root", + str(self.root), + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn("No eligible files were selected", stderr) + self.assertFalse((self.root / ".contextzip").exists()) + + def test_cli_rejects_contextzip_symlink_without_touching_target(self) -> None: + self.write_bytes("README.md", b"# Demo\n") + target = self.root / "context-target" + target.mkdir() + marker = target / "keep.txt" + marker.write_bytes(b"keep\n") + try: + (self.root / ".contextzip").symlink_to(target.name) + except (OSError, NotImplementedError): + self.skipTest("symlinks are not available") + + return_code, _, stderr = self.run_main( + [ + "--root", + str(self.root), + ] + ) + + self.assertEqual(return_code, 2) + self.assertIn(".contextzip must not be a symlink", stderr) + self.assertEqual(marker.read_bytes(), b"keep\n") + if __name__ == "__main__": unittest.main()