scripts/generate_profile.py
Rama main · réplica de lectura del repositorio original.
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
from profile_model import (
PRODUCTS,
ProfileError,
compare_trees,
load_profile_sources,
parse_json_document,
render_profile,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SOURCES = PROJECT_ROOT / "sources"
DEFAULT_OUTPUT = PROJECT_ROOT / "generated" / "reversed-profile"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate the Ekko Rules standard product from canonical sources."
)
parser.add_argument(
"--sources",
type=Path,
default=DEFAULT_SOURCES,
help=f"Canonical source directory (default: {DEFAULT_SOURCES})",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help=f"Generated product directory (default: {DEFAULT_OUTPUT})",
)
parser.add_argument(
"--check",
action="store_true",
help="Compare a clean render with the current output without modifying it.",
)
return parser.parse_args()
def require_owned_output(path: Path) -> None:
require_directory(path)
manifest_path = path / "manifest.json"
try:
manifest = parse_json_document(
manifest_path.read_text(encoding="utf-8"), context=str(manifest_path)
)
except (OSError, UnicodeError, ProfileError) as exc:
raise ProfileError(
f"Refusing to replace an unrecognized existing directory: {path}"
) from exc
if not (
isinstance(manifest, dict)
and manifest.get("schema_version") == 1
and manifest.get("profile") == "reversed-profile"
and manifest.get("hash_algorithm") == "sha256"
and manifest.get("self") == "manifest.json"
and manifest.get("self_hash_included") is False
and isinstance(manifest.get("files"), dict)
):
raise ProfileError(
f"Refusing to replace an unrecognized existing directory: {path}"
)
hashes = manifest["files"]
if not hashes or not all(
isinstance(relative, str)
and isinstance(expected_hash, str)
and len(expected_hash) == 64
for relative, expected_hash in hashes.items()
):
raise ProfileError(f"Refusing to replace an invalid output directory: {path}")
required_markers = {
"config/ekko-rules.ini",
"Mihomo/reversed-template.yaml",
"analysis.json",
}
if not required_markers <= set(hashes):
raise ProfileError(f"Refusing to replace an unrecognized output directory: {path}")
def replace_directory(staging: Path, output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
backup = output.parent / f".{output.name}.backup"
if backup.exists():
raise ProfileError(
f"Refusing to replace output while a recovery directory exists: {backup}"
)
moved_old_output = False
try:
if output.exists():
require_owned_output(output)
os.replace(output, backup)
moved_old_output = True
os.replace(staging, output)
except BaseException:
if moved_old_output and not output.exists() and backup.exists():
os.replace(backup, output)
raise
else:
if backup.exists():
shutil.rmtree(backup)
def require_directory(path: Path) -> None:
if not path.is_dir():
raise ProfileError(f"Expected a directory: {path}")
def main() -> int:
args = parse_args()
sources_path = args.sources.resolve()
output_path = args.output.resolve()
if (
output_path == sources_path
or output_path.is_relative_to(sources_path)
or sources_path.is_relative_to(output_path)
):
raise ProfileError(
"Generated output and canonical sources must not contain each other"
)
sources = load_profile_sources(sources_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
temporary_root = Path(
tempfile.mkdtemp(prefix=f".{output_path.name}.stage-", dir=output_path.parent)
)
staging = temporary_root / output_path.name
try:
render_profile(sources, staging)
if args.check:
diff = compare_trees(staging, output_path)
result = {
"status": "passed" if diff.clean else "out-of-date",
"mode": "check",
"output": str(output_path),
**diff.as_dict(),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if diff.clean else 1
diff = compare_trees(staging, output_path)
replace_directory(staging, output_path)
print(
json.dumps(
{
"status": "generated",
"output": str(output_path),
# Two products are written, so both are counted here.
"products": {
product: {
"segments": len(sources.segments_for(product)),
"rule_files": len(sources.rule_segments_for(product)),
"proxy_groups": len(sources.proxy_groups_for(product)),
}
for product in PRODUCTS
},
"previous_output_was_current": diff.clean,
"previous_difference": diff.as_dict(),
},
ensure_ascii=False,
indent=2,
)
)
return 0
finally:
if temporary_root.exists():
shutil.rmtree(temporary_root)
if __name__ == "__main__":
try:
raise SystemExit(main())
except ProfileError as exc:
print(f"generation failed: {exc}", file=sys.stderr)
raise SystemExit(2) from exc