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())