110 lines
3.2 KiB
Python
110 lines
3.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Audit deterministic mini-tool package and text-file size limits."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
MIB = 1024 * 1024
|
|
ZIP_LIMIT = 10 * MIB
|
|
ZIP_RECOMMENDED = 2 * MIB
|
|
TEXT_FILE_WARNING = 2 * MIB
|
|
TEXT_TOTAL_WARNING = 5 * MIB
|
|
TEXT_SUFFIXES = {".html", ".css", ".js", ".json"}
|
|
|
|
|
|
def format_size(size: int) -> str:
|
|
return f"{size / MIB:.2f} MiB"
|
|
|
|
|
|
def audit_entries(entries: list[tuple[str, int]]) -> list[str]:
|
|
warnings: list[str] = []
|
|
text_total = 0
|
|
for name, size in entries:
|
|
if PurePosixPath(name).suffix.lower() not in TEXT_SUFFIXES:
|
|
continue
|
|
text_total += size
|
|
if size > TEXT_FILE_WARNING:
|
|
warnings.append(
|
|
f"{name}: text file is {format_size(size)}; review parse and memory cost"
|
|
)
|
|
|
|
if text_total > TEXT_TOTAL_WARNING:
|
|
warnings.append(
|
|
f"uncompressed HTML/CSS/JS/JSON total is {format_size(text_total)}; "
|
|
"review for embedded databases or generated content"
|
|
)
|
|
return warnings
|
|
|
|
|
|
def read_directory(path: Path) -> list[tuple[str, int]]:
|
|
return [
|
|
(file.relative_to(path).as_posix(), file.stat().st_size)
|
|
for file in sorted(path.rglob("*"))
|
|
if file.is_file() and ".git" not in file.relative_to(path).parts
|
|
]
|
|
|
|
|
|
def read_zip(path: Path) -> tuple[list[tuple[str, int]], list[str], list[str]]:
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
if path.stat().st_size > ZIP_LIMIT:
|
|
errors.append(
|
|
f"{path.name}: zip is {format_size(path.stat().st_size)}; hard limit is 10 MiB"
|
|
)
|
|
elif path.stat().st_size > ZIP_RECOMMENDED:
|
|
warnings.append(
|
|
f"{path.name}: zip is {format_size(path.stat().st_size)}; recommended target is 2 MiB"
|
|
)
|
|
|
|
with zipfile.ZipFile(path) as archive:
|
|
infos = [info for info in archive.infolist() if not info.is_dir()]
|
|
entries = [(info.filename, info.file_size) for info in infos]
|
|
return entries, errors, warnings
|
|
|
|
|
|
def main() -> int:
|
|
if len(sys.argv) != 2:
|
|
print("Usage: python3 audit_artifact.py <artifact-directory-or-zip>")
|
|
return 2
|
|
|
|
path = Path(sys.argv[1]).resolve()
|
|
if not path.exists():
|
|
print(f"ERROR: path not found: {path}")
|
|
return 2
|
|
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
try:
|
|
if path.is_dir():
|
|
entries = read_directory(path)
|
|
elif zipfile.is_zipfile(path):
|
|
entries, zip_errors, zip_warnings = read_zip(path)
|
|
errors.extend(zip_errors)
|
|
warnings.extend(zip_warnings)
|
|
else:
|
|
print(f"ERROR: expected a directory or zip file: {path}")
|
|
return 2
|
|
except (OSError, zipfile.BadZipFile) as exc:
|
|
print(f"ERROR: cannot inspect {path}: {exc}")
|
|
return 2
|
|
|
|
warnings.extend(audit_entries(entries))
|
|
|
|
for message in errors:
|
|
print(f"ERROR: {message}")
|
|
for message in warnings:
|
|
print(f"WARN: {message}")
|
|
|
|
if errors:
|
|
print(f"FAILED: {len(errors)} error(s), {len(warnings)} warning(s)")
|
|
return 1
|
|
print(f"PASS: {len(entries)} file(s), {len(warnings)} warning(s)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|