60-packaging/deterministic_zip.py

#!/usr/bin/env python3
"""Deterministische ZIP-Erzeugung für Repository-Roundtrips."""
from __future__ import annotations
import argparse, fnmatch, os, stat, zipfile
from io import BytesIO
from pathlib import Path, PurePosixPath
from typing import Mapping

DEFAULT_EXCLUDES = (
    ".git", ".git/**", "**/.git", "**/.git/**",
    ".github", ".github/**", "**/.github", "**/.github/**",
    "*.env", "**/*.env", "!*.env.example", "!**/*.env.example",
    ".local-tools", ".local-tools/**", "**/.local-tools", "**/.local-tools/**",
    "node_modules", "node_modules/**", "**/node_modules", "**/node_modules/**",
    "logs", "logs/**", "**/logs", "**/logs/**",
    "archive", "archive/**", "**/archive", "**/archive/**",
    "out", "out/**", "**/out", "**/out/**",
    "inbox", "inbox/**", "**/inbox", "**/inbox/**",
    "tmp", "tmp/**", "**/tmp", "**/tmp/**",
    "temp", "temp/**", "**/temp", "**/temp/**",
    "runtimes", "runtimes/**", "**/runtimes", "**/runtimes/**",
    "test-results", "test-results/**", "**/test-results", "**/test-results/**",
    "playwright-report", "playwright-report/**", "**/playwright-report", "**/playwright-report/**",
    "coverage", "coverage/**", "**/coverage", "**/coverage/**",
    "dist", "dist/**", "**/dist", "**/dist/**",
    "build", "build/**", "**/build", "**/build/**",
    "**/__pycache__", "**/__pycache__/**", "**/*.pyc",
    "**/.DS_Store", "**/*~", "**/*.tmp",
)
FIXED_TIMESTAMP = (1980, 1, 1, 0, 0, 0)

def build_deterministic_zip(entries: Mapping[str, bytes], mode: int = 0o644) -> bytes:
    """Erzeugt ein deterministisches ZIP aus sicheren relativen Dateipfaden."""
    normalized: dict[str, bytes] = {}
    for name, content in entries.items():
        if "\\" in name:
            raise ValueError(f"ZIP-Pfad muss POSIX-Syntax verwenden: {name}")
        path = PurePosixPath(name)
        if not name or path.is_absolute() or ".." in path.parts or name.endswith("/"):
            raise ValueError(f"Unsicherer ZIP-Pfad: {name}")
        canonical = path.as_posix()
        if canonical in normalized:
            raise ValueError(f"Doppelter ZIP-Pfad: {canonical}")
        normalized[canonical] = bytes(content)

    stream = BytesIO()
    with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
        for name in sorted(normalized, key=lambda value: value.encode("utf-8")):
            info = zipfile.ZipInfo(name, FIXED_TIMESTAMP)
            info.external_attr = (mode & 0xFFFF) << 16
            info.compress_type = zipfile.ZIP_DEFLATED
            archive.writestr(info, normalized[name], compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
    return stream.getvalue()

def detect_repository_root(source: Path) -> Path:
    source = source.resolve()
    if not source.is_dir():
        raise ValueError(f"Quellpfad ist kein Verzeichnis: {source}")
    children = [p for p in source.iterdir() if p.name not in {".DS_Store"}]
    dirs = [p for p in children if p.is_dir()]
    files = [p for p in children if p.is_file()]
    if len(dirs) == 1 and not files:
        candidate = dirs[0]
        if (candidate / "manifest.json").exists() or (candidate / "README.md").exists():
            return candidate
    return source

def _match(path: str, pattern: str) -> bool:
    return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch('/'+path, pattern)

def is_excluded(relative: str, patterns: tuple[str, ...]) -> bool:
    excluded = False
    for pattern in patterns:
        negated = pattern.startswith("!")
        p = pattern[1:] if negated else pattern
        if _match(relative, p):
            excluded = not negated
    return excluded

def collect_files(root: Path, patterns: tuple[str, ...]) -> list[Path]:
    result=[]
    for path in root.rglob('*'):
        if not path.is_file() or path.is_symlink():
            continue
        rel=path.relative_to(root).as_posix()
        if not is_excluded(rel, patterns):
            result.append(path)
    return sorted(result, key=lambda p: p.relative_to(root).as_posix().encode('utf-8'))

def create_repository_zip(source: Path, output: Path, excludes: tuple[str, ...]=DEFAULT_EXCLUDES) -> list[str]:
    root=detect_repository_root(source)
    output=output.resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    files=collect_files(root, excludes)
    written=[]
    with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
        for path in files:
            rel=PurePosixPath(root.name) / PurePosixPath(path.relative_to(root).as_posix())
            info=zipfile.ZipInfo(rel.as_posix(), FIXED_TIMESTAMP)
            mode=stat.S_IMODE(path.stat().st_mode)
            info.external_attr=(mode & 0xFFFF) << 16
            info.compress_type=zipfile.ZIP_DEFLATED
            archive.writestr(info, path.read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
            written.append(rel.as_posix())
    return written

def main() -> int:
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('source', type=Path)
    parser.add_argument('output', type=Path)
    parser.add_argument('--exclude', action='append', default=[])
    args=parser.parse_args()
    patterns=tuple(DEFAULT_EXCLUDES)+tuple(args.exclude)
    written=create_repository_zip(args.source,args.output,patterns)
    print(f"ZIP erzeugt: {args.output} ({len(written)} Dateien)")
    return 0
if __name__ == '__main__':
    raise SystemExit(main())