| 1 | """Stage an untrusted preview ZIP without letting it replace packaging code.""" |
| 2 | |
| 3 | import shutil |
| 4 | import stat |
| 5 | import sys |
| 6 | import zipfile |
| 7 | from pathlib import Path |
| 8 | |
| 9 | ROOTS = ("server/dist", "desktop/dist-electron") |
| 10 | REQUIRED_FILES = { |
| 11 | "server/dist/bin.mjs", |
| 12 | "server/dist/client/index.html", |
| 13 | "desktop/dist-electron/main.cjs", |
| 14 | } |
| 15 | # The current bundle is about 32 MiB compressed. Bound extraction on the |
| 16 | # trusted runner even when the PR replaces the uploader entirely. |
| 17 | MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 |
| 18 | MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 |
| 19 | MAX_ENTRIES = 50_000 |
| 20 | |
| 21 | |
| 22 | def stage_bundle(archive: Path, destination: Path): |
| 23 | if archive.stat().st_size > MAX_ARCHIVE_BYTES: |
| 24 | raise ValueError("Preview archive is too large") |
| 25 | with zipfile.ZipFile(archive) as bundle: |
| 26 | entries = bundle.infolist() |
| 27 | if len(entries) > MAX_ENTRIES: |
| 28 | raise ValueError("Preview archive has too many entries") |
| 29 | if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES: |
| 30 | raise ValueError("Expanded preview bundle is too large") |
| 31 | seen = set() |
| 32 | files = set() |
| 33 | for entry in entries: |
| 34 | name = entry.filename.removesuffix("/") |
| 35 | parts = name.split("/") |
| 36 | # Reject ambiguous paths before normalization, including names |
| 37 | # that would alias on the macOS signing runner. |
| 38 | if ( |
| 39 | entry.orig_filename != entry.filename |
| 40 | or any(part in ("", ".", "..") for part in parts) |
| 41 | or any(char in name for char in "\\:") |
| 42 | or not name.isascii() |
| 43 | or any(ord(char) < 32 or ord(char) == 127 for char in name) |
| 44 | ): |
| 45 | raise ValueError(f"Unsafe preview path: {entry.filename!r}") |
| 46 | allowed = any(name.startswith(root + "/") for root in ROOTS) |
| 47 | if entry.is_dir(): |
| 48 | allowed |= any(root == name or root.startswith(name + "/") for root in ROOTS) |
| 49 | if not allowed: |
| 50 | raise ValueError(f"Unexpected preview path: {name!r}") |
| 51 | kind = stat.S_IFMT(entry.external_attr >> 16) |
| 52 | if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG): |
| 53 | raise ValueError(f"Non-regular preview entry: {name!r}") |
| 54 | if name.casefold() in seen: |
| 55 | raise ValueError(f"Duplicate preview path: {name!r}") |
| 56 | seen.add(name.casefold()) |
| 57 | if not entry.is_dir(): |
| 58 | files.add(name) |
| 59 | if not REQUIRED_FILES <= files: |
| 60 | raise ValueError("Preview bundle is missing required entry points") |
| 61 | # Validate all names before writing anything. This is a fresh directory |
| 62 | # outside the checkout; neither pre-existing links nor trusted files |
| 63 | # can be followed or overwritten. ZIP permissions are never restored. |
| 64 | destination.mkdir(parents=True, exist_ok=False) |
| 65 | for entry in entries: |
| 66 | target = destination / entry.filename |
| 67 | if entry.is_dir(): |
| 68 | target.mkdir(parents=True, exist_ok=True) |
| 69 | else: |
| 70 | target.parent.mkdir(parents=True, exist_ok=True) |
| 71 | with bundle.open(entry) as source, target.open("xb") as output: |
| 72 | shutil.copyfileobj(source, output) |
| 73 | |
| 74 | |
| 75 | if __name__ == "__main__": |
| 76 | stage_bundle(Path(sys.argv[1]), Path(sys.argv[2])) |