diff --git a/backend/tests/test_public_demo_zip.py b/backend/tests/test_public_demo_zip.py index 5f1178d..a176699 100644 --- a/backend/tests/test_public_demo_zip.py +++ b/backend/tests/test_public_demo_zip.py @@ -40,10 +40,49 @@ def test_generator_is_reproducible_and_contains_searchable_source(self): self.assertIn("src/catalog.py", paths) self.assertIn("src/api.py", paths) self.assertIn("find_title", archive.read("src/catalog.py").decode("utf-8")) + self.assertTrue(all( + member.date_time == (1980, 1, 1, 0, 0, 0) + and member.create_system == 3 + and member.external_attr >> 16 == 0o100644 + for member in archive.infolist() + )) self.assertTrue( all(not p.startswith("/") and ".." not in Path(p).parts for p in paths) ) + def test_generator_does_not_overwrite_existing_output(self): + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / "existing.zip" + sentinel = b"EXISTING_DOCUMENT_DO_NOT_TOUCH" + destination.write_bytes(sentinel) + result = subprocess.run( + [sys.executable, str(GENERATOR), "--output", str(destination)], + cwd=ROOT, capture_output=True, text=True, timeout=20 + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("already exists", result.stderr.lower()) + self.assertEqual(destination.read_bytes(), sentinel) + + + @unittest.skipIf(sys.platform == "win32", "Windows symlink setup needs special privileges") + def test_generator_does_not_follow_existing_symlink(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "important.txt" + sentinel = b"DO_NOT_REPLACE_SYMLINK_TARGET" + target.write_bytes(sentinel) + link = root / "fictional-catalog.zip" + link.symlink_to(target) + result = subprocess.run( + [sys.executable, str(GENERATOR), "--output", str(link)], + cwd=ROOT, capture_output=True, text=True, timeout=20 + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("already exists", result.stderr.lower()) + self.assertTrue(link.is_symlink()) + self.assertEqual(target.read_bytes(), sentinel) + + class PublishedDemoHttpTests(unittest.IsolatedAsyncioTestCase): async def test_published_zip_imports_searches_and_deletes_without_ollama(self): diff --git a/docs/first-run.md b/docs/first-run.md index df268e9..74c9e98 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -79,6 +79,8 @@ Import **fictional-catalog.zip** through the UI, then search for **find_title**. The search result should show **src/catalog.py**, line **2**. These are real source lines from a fictional fixture, **not** an AI model answer. +The generator refuses to overwrite an existing output path, including a symlink. If you run the command again, choose a different filename or manually remove only your disposable sample ZIP first. + CI checks this exact public ZIP generator and its real FastAPI ZIP-import/search/ deletion journey, without starting Ollama: diff --git a/examples/create_demo_zip.py b/examples/create_demo_zip.py index d2a8b59..9bf8060 100644 --- a/examples/create_demo_zip.py +++ b/examples/create_demo_zip.py @@ -13,13 +13,25 @@ def create_demo_zip(destination: Path) -> Path: destination = Path(destination) destination.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for path, contents in DEMO_FILES.items(): - archive.writestr(path, contents) + # Exclusive creation refuses existing files and symlinks; a first-run demo + # should never replace another file at a caller-selected output path. + with destination.open("xb") as output: + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path, contents in DEMO_FILES.items(): + # An explicit DOS timestamp and file mode keep ZIP members + # reproducible across seconds and independent of host OS. + member = zipfile.ZipInfo(path, date_time=(1980, 1, 1, 0, 0, 0)) + member.compress_type = zipfile.ZIP_DEFLATED + member.create_system = 3 + member.external_attr = 0o100644 << 16 + archive.writestr(member, contents) return destination if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--output", required=True, type=Path) args = parser.parse_args() - print(create_demo_zip(args.output)) + try: + print(create_demo_zip(args.output)) + except FileExistsError: + parser.error(f"Output already exists: {args.output}. Choose another --output path.")