"""Shared tests for the OOXML `office/` tree bundled by docx, pptx, and xlsx. Those three skills each ship a byte-identical copy of `scripts/office/` -- `helpers/`, `validators/`, `validate.py`, `soffice.py`, and the vendored ISO/ECMA schemas. Rather than write the same tests three times, each suite instantiates `office_test_case()` against its own copy, so a change that lands in one copy and not the others fails on the copy that was missed as well as on `identical_tree_problems()` in `tests/_meta`. The interesting surface is the safety logic: `safe_extract` guards zip-slip and symlink entries, and `opc_target` refuses relationship targets that escape the package. Both are reachable with nothing but the standard library. """ from __future__ import annotations import hashlib import importlib import stat import sys import tempfile import unittest import zipfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] SKILLS_DIR = REPO_ROOT / "skills" #: The skills that ship the shared tree. Kept here so adding a fourth is one edit. OFFICE_SKILLS = ("docx", "pptx", "xlsx") #: Other files several skills ship byte-identical copies of. A copy that drifts #: is a skill quietly behaving differently from its siblings, so `tests/_meta` #: pins them together. Each entry is (path relative to the skill, skills). SHARED_FILES: tuple[tuple[str, tuple[str, ...]], ...] = ( ( "scripts/generate_schematic.py", ( "scientific-schematics", "latex-posters", "literature-review", "scientific-slides", ), ), ( "scripts/generate_schematic_ai.py", ( "scientific-schematics", "latex-posters", "literature-review", "scientific-slides", ), ), ) def shared_file_problems(skills_dir: Path = SKILLS_DIR) -> list[str]: """Every skill in a `SHARED_FILES` group ships the same bytes.""" problems = [] for relative, skills in SHARED_FILES: digests = {} for name in skills: path = skills_dir / name / relative if not path.is_file(): problems.append(f"{name}: {relative} is missing") continue digests[name] = hashlib.sha256(path.read_bytes()).hexdigest() if len(set(digests.values())) > 1: reference = skills[0] problems.extend( f"{name}: {relative} has drifted from {reference}'s copy" for name, digest in digests.items() if digest != digests.get(reference) ) return problems def _tree_digest(office_dir: Path) -> str: """A digest over every path and byte under an `office/` tree.""" digest = hashlib.sha256() for path in sorted(office_dir.rglob("*")): if not path.is_file(): continue digest.update(str(path.relative_to(office_dir)).encode("utf-8")) digest.update(path.read_bytes()) return digest.hexdigest() def identical_tree_problems(skills_dir: Path = SKILLS_DIR) -> list[str]: """The shared `office/` trees have not drifted apart.""" digests = {} for name in OFFICE_SKILLS: office = skills_dir / name / "scripts" / "office" if not office.is_dir(): return [f"{name}: scripts/office/ is missing"] digests[name] = _tree_digest(office) reference = digests[OFFICE_SKILLS[0]] return [ f"{name}: scripts/office/ has drifted from {OFFICE_SKILLS[0]}'s copy " f"({digests[name][:12]} vs {reference[:12]})" for name in OFFICE_SKILLS[1:] if digests[name] != reference ] def office_test_case(skill_root: Path) -> type[unittest.TestCase]: """Build the shared `office/` TestCase for one of docx / pptx / xlsx.""" office_dir = skill_root / "scripts" / "office" def load_helpers(): # `office/` puts `helpers` and `validators` at the top level -- see the # `from helpers import ...` in validate.py -- so office/ itself goes on # the path. Safe because this repo runs one skill per process. if str(office_dir) not in sys.path: sys.path.insert(0, str(office_dir)) return importlib.import_module("helpers") class OfficeSharedTreeTests(unittest.TestCase): maxDiff = None def setUp(self) -> None: self.helpers = load_helpers() # -- safe_extract ------------------------------------------------ def test_safe_extract_unpacks_an_ordinary_package(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) archive = root / "package.docx" with zipfile.ZipFile(archive, "w") as handle: handle.writestr("[Content_Types].xml", "") handle.writestr("word/document.xml", "") destination = root / "unpacked" destination.mkdir() with zipfile.ZipFile(archive) as handle: self.helpers.safe_extract(handle, destination) self.assertTrue((destination / "[Content_Types].xml").is_file()) self.assertEqual( (destination / "word" / "document.xml").read_text(), "" ) def test_safe_extract_refuses_a_traversal_entry(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) archive = root / "evil.docx" with zipfile.ZipFile(archive, "w") as handle: handle.writestr("../escaped.xml", "") destination = root / "unpacked" destination.mkdir() with zipfile.ZipFile(archive) as handle: with self.assertRaisesRegex(ValueError, "unsafe archive entry"): self.helpers.safe_extract(handle, destination) self.assertFalse((root / "escaped.xml").exists()) def test_safe_extract_refuses_a_symlink_entry(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) archive = root / "link.docx" info = zipfile.ZipInfo("word/document.xml") info.external_attr = (stat.S_IFLNK | 0o777) << 16 with zipfile.ZipFile(archive, "w") as handle: handle.writestr(info, "/etc/passwd") destination = root / "unpacked" destination.mkdir() with zipfile.ZipFile(archive) as handle: with self.assertRaisesRegex(ValueError, "symlink archive entry"): self.helpers.safe_extract(handle, destination) # -- opc_target -------------------------------------------------- def test_opc_target_resolves_relative_and_absolute_part_names(self) -> None: resolve = self.helpers.opc_target self.assertEqual( resolve("media/image1.png", "word/document.xml"), "word/media/image1.png", ) self.assertEqual( resolve("/word/styles.xml", "word/document.xml"), "word/styles.xml" ) self.assertEqual( resolve("../customXml/item1.xml", "word/document.xml"), "customXml/item1.xml", ) # Percent-encoding is decoded before resolution. self.assertEqual( resolve("media/my%20image.png", "word/document.xml"), "word/media/my image.png", ) def test_opc_target_ignores_external_and_scheme_targets(self) -> None: resolve = self.helpers.opc_target self.assertIsNone(resolve("", "word/document.xml")) self.assertIsNone( resolve("https://example.invalid/x", "word/document.xml", "External") ) self.assertIsNone(resolve("mailto:someone@example.invalid", "word/document.xml")) def test_opc_target_refuses_targets_that_escape_the_package(self) -> None: resolve = self.helpers.opc_target with self.assertRaisesRegex(ValueError, "escapes the package"): resolve("../../outside.xml", "word/document.xml") with self.assertRaisesRegex(ValueError, "not a POSIX part name"): resolve("word\\styles.xml", "word/document.xml") # A bare "/" is absolute and strips to nothing at all. with self.assertRaisesRegex(ValueError, "resolves to nothing"): resolve("/", "word/document.xml") def test_a_dot_target_resolves_to_its_own_directory(self) -> None: # normpath collapses it rather than treating it as an error. self.assertEqual( self.helpers.opc_target(".", "word/document.xml"), "word" ) # -- text rendering ---------------------------------------------- def test_rendered_text_honours_xml_space_preserve(self) -> None: render = self.helpers.rendered_text self.assertEqual(render(" spaced ", True), " spaced ") self.assertEqual(render(" spaced ", False), "spaced") self.assertEqual(render("\t\nmixed\r\n", False), "mixed") def test_part_text_survives_undecodable_bytes(self) -> None: # surrogateescape, not a crash: a corrupt part must still be # reportable rather than taking the whole validation run down. self.assertEqual(self.helpers.part_text(b"ok"), "ok") self.assertEqual(len(self.helpers.part_text(b"\xff\xfe")), 2) # -- rezip ------------------------------------------------------- def test_rezip_stores_content_types_first_and_uncompressed(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "unpacked" (source / "word").mkdir(parents=True) (source / "[Content_Types].xml").write_text("") (source / "word" / "document.xml").write_text("") output = root / "rebuilt.docx" self.helpers.rezip(source, output) with zipfile.ZipFile(output) as handle: entries = handle.namelist() # OOXML readers require [Content_Types].xml first and stored. self.assertEqual(entries[0], "[Content_Types].xml") self.assertEqual( handle.getinfo("[Content_Types].xml").compress_type, zipfile.ZIP_STORED, ) self.assertIn("word/document.xml", entries) def test_rezip_leaves_no_temporary_file_behind(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "unpacked" source.mkdir() (source / "part.xml").write_text("") output = root / "rebuilt.docx" self.helpers.rezip(source, output) self.assertEqual( sorted(path.name for path in root.iterdir()), ["rebuilt.docx", "unpacked"], ) # -- packaging --------------------------------------------------- def test_ooxml_family_covers_this_skill(self) -> None: families = set(self.helpers.OOXML_FAMILY.values()) self.assertEqual(families, {"docx", "pptx", "xlsx"}) self.assertIn(f".{skill_root.name}", self.helpers.OOXML_FAMILY) def test_every_validator_schema_path_exists(self) -> None: schemas = office_dir / "schemas" self.assertTrue(schemas.is_dir(), "vendored schemas are missing") referenced = set() for module in sorted((office_dir / "validators").glob("*.py")): for line in module.read_text(encoding="utf-8").splitlines(): for token in line.split('"'): if token.endswith(".xsd"): referenced.add(token.lstrip("/")) missing = sorted( name for name in referenced if not any(schemas.rglob(Path(name).name)) ) self.assertEqual(missing, [], "validators name schemas that are not shipped") OfficeSharedTreeTests.__qualname__ = f"OfficeSharedTreeTests[{skill_root.name}]" return OfficeSharedTreeTests