vpnhide/scripts/codegen-hooks.py
Danila Gornushko 1c3e0a88d1
Close audit findings in clean-device, release, changelog, update-json, codegen (#209)
* fix(scripts): close audit findings in clean-device, release, changelog, update-json

Five tooling fixes from the codebase audit.

- clean-device.sh wiped only three /data/adb dirs and the legacy uids file, so a
  "clean" test left the canonical config, the root-owned APatch superkey under
  /data/adb/vpnhide, and the KPM/ports state behind. Mirror the full
  FULL_RESET_FILES / FULL_RESET_DIRS list from FullReset.kt.
- release.py ran the irreversible changelog rotation (save_json + delete the
  fragment files) BEFORE patching the version-bearing source files, so a drifted
  module.prop / Cargo.toml / gradle format failed mid-release with the changelog
  already mutated and the duplicate-version guard then refusing a retry. Dry-run-
  validate every version-patch regex before the changelog step.
- changelog.py printed the new fragment path relative to cwd, raising ValueError
  when cwd is not an ancestor of changelog.d (e.g. run from scripts/), a spurious
  traceback after the fragment was written fine. Make it relative to REPO_ROOT.
- update-json.sh computed versionCode with bare $(( )), so a zero-padded version
  component (1.08.0) was read as octal and aborted on digit 8/9. Force base-10.
- codegen-hooks.py now uses write_if_changed and reports only what changed, like
  codegen-interfaces.py, instead of rewriting every file and bumping mtimes.

* refactor(scripts): extract scripts/codegen_lib.py shared by both generators

codegen-interfaces.py and codegen-hooks.py each re-derived the same scaffolding:
REPO_ROOT, the "AUTO-GENERATED … Regenerate with …" banner, the verbatim
11-segment path to the LSPosed app's generated Kotlin package, and the
write-if-changed + change-reporting tail of main(). That duplication had already
drifted. Move it into scripts/codegen_lib.py (REPO_ROOT, generated_header,
lsposed_generated_kt, write_if_changed, emit_outputs), imported by both — the
shared-lib pattern scripts/ already uses for changelog_lib.py.

Generated output is byte-identical (verified: re-running both codegen scripts
leaves no diff in any generated file). The escaping helpers stay per-script —
they legitimately differ (C/Rust/Kotlin) and consolidating them risks output
drift for no gain.

* style: ruff format codegen_lib + generators
2026-06-30 11:31:45 +03:00

298 lines
11 KiB
Python
Executable file

#!/usr/bin/env -S uv run
#
# /// script
# requires-python = ">=3.12"
# ///
"""Render the hook-id registry + status error codes from data/hooks.toml.
The registry is the global id space shared by the control/stats protocol
(docs/protocol.md §5): bit N of a config mask == hook id N == the id used in a
stats line. This script emits the matching id enums, per-backend "own" masks,
and status error codes for every language that touches the protocol:
- kmod/KPM C -> kmod/generated/hook_ids.h
- protocol Rust-> crates/protocol/src/generated/hook_ids.rs
- zygisk Rust-> zygisk/src/generated/hook_ids.rs
- lsposed Rust-> lsposed/native/src/generated/hook_ids.rs
- app Kotlin-> .../generated/HookIds.kt
Re-run after editing data/hooks.toml and commit the regenerated files. CI's
lint job re-runs the codegen and fails on drift, so the numbering can never
diverge between backends.
Run via `uv run scripts/codegen-hooks.py` (or `./scripts/codegen-hooks.py` — the
uv shebang provisions a pinned interpreter). No external deps: tomllib is stdlib
and the emitters are plain string building.
"""
from __future__ import annotations
import sys
import tomllib
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from codegen_lib import ( # type: ignore[import-not-found]
REPO_ROOT,
emit_outputs,
generated_header,
lsposed_generated_kt,
)
TOML_PATH = REPO_ROOT / "data" / "hooks.toml"
# Targets are only the protocol participants (§1.4): the kernel backends (C),
# the Zygisk backend (Rust), and the app + system_server hook (Kotlin). NOT
# lsposed/native — that Rust crate is the uniffi diagnostic-probe library; it
# consumes iface_lists (VPN-name matching) but never the protocol/registry.
OUT_KMOD = REPO_ROOT / "kmod" / "generated" / "hook_ids.h"
OUT_PROTOCOL_RS = REPO_ROOT / "crates" / "protocol" / "src" / "generated" / "hook_ids.rs"
OUT_ZYGISK = REPO_ROOT / "zygisk" / "src" / "generated" / "hook_ids.rs"
OUT_LSP_KT = lsposed_generated_kt("HookIds.kt")
GENERATED_HEADER_LINE = generated_header("data/hooks.toml", "uv run scripts/codegen-hooks.py")
KNOWN_BACKENDS = ("kernel", "zygisk", "lsposed")
# ---------------------------------------------------------------------------
# load + validate
# ---------------------------------------------------------------------------
class Hook:
def __init__(self, raw: dict[str, Any]) -> None:
self.id: int = raw["id"]
self.name: str = raw["name"]
self.backend: str = raw["backend"]
self.note: str = raw.get("note", "")
class Err:
def __init__(self, raw: dict[str, Any]) -> None:
self.id: int = raw["id"]
self.name: str = raw["name"]
self.note: str = raw.get("note", "")
class Backend:
def __init__(self, raw: dict[str, Any]) -> None:
self.id: int = raw["id"]
self.name: str = raw["name"]
self.note: str = raw.get("note", "")
def load() -> tuple[list[Hook], list[Err], list[Backend]]:
with TOML_PATH.open("rb") as fh:
data = tomllib.load(fh)
hooks = [Hook(h) for h in data.get("hook", [])]
errs = [Err(e) for e in data.get("error", [])]
backends = [Backend(b) for b in data.get("backend", [])]
# ids must be dense, append-only, and 0-based — anything else is a bug
# that would silently shift the global id space.
for label, items in (("hook", hooks), ("error", errs), ("backend", backends)):
ids = [it.id for it in items]
if ids != list(range(len(ids))):
sys.exit(f"error: {label} ids must be 0..N-1 with no gaps, got {ids}")
names = [it.name for it in items]
if len(set(names)) != len(names):
sys.exit(f"error: duplicate {label} name in {names}")
for h in hooks:
if h.backend not in KNOWN_BACKENDS:
sys.exit(f"error: hook {h.name!r} has unknown backend {h.backend!r}")
return hooks, errs, backends
def backend_mask(hooks: list[Hook], backend: str) -> int:
m = 0
for h in hooks:
if h.backend == backend:
m |= 1 << h.id
return m
# name-casing helpers ------------------------------------------------------
def upper(name: str) -> str:
return name.upper()
def pascal(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_"))
def kt_string(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
# ---------------------------------------------------------------------------
# emitters
# ---------------------------------------------------------------------------
def emit_kmod(hooks: list[Hook], errs: list[Err], backends: list[Backend]) -> str:
L: list[str] = [f"/* {GENERATED_HEADER_LINE} */"]
L.append("#ifndef VPNHIDE_GENERATED_HOOK_IDS_H")
L.append("#define VPNHIDE_GENERATED_HOOK_IDS_H")
L.append("")
L.append("/* Global hook id space (data/hooks.toml). bit N == hook id N. */")
width = max(len(f"VPNHIDE_HOOK_{upper(h.name)}") for h in hooks)
for h in hooks:
macro = f"VPNHIDE_HOOK_{upper(h.name)}"
L.append(f"#define {macro:<{width}} {h.id}")
L.append(f"#define {'VPNHIDE_HOOK_COUNT':<{width}} {len(hooks)}")
L.append("")
L.append("/* Hooks owned by each backend: apply `mask & own`, ignore foreign bits. */")
for b in KNOWN_BACKENDS:
L.append(f"#define VPNHIDE_{upper(b)}_HOOK_MASK 0x{backend_mask(hooks, b):x}u")
L.append("")
L.append("/* status error codes (protocol §5.1). */")
ewidth = max(len(f"VPNHIDE_ERR_{upper(e.name)}") for e in errs)
for e in errs:
L.append(f"#define {f'VPNHIDE_ERR_{upper(e.name)}':<{ewidth}} {e.id}")
L.append("")
L.append("/* backend ids (protocol §4.3 `status backend <id>`). */")
bwidth = max(len(f"VPNHIDE_BACKEND_{upper(b.name)}") for b in backends)
for b in backends:
L.append(f"#define {f'VPNHIDE_BACKEND_{upper(b.name)}':<{bwidth}} {b.id}")
L.append("")
L.append("/* Hook name for an id (labeling / debug). Inline so the header stays")
L.append(" self-contained and an unused table never warns. */")
L.append("static inline const char *vpnhide_hook_name(int id)")
L.append("{")
L.append("\tswitch (id) {")
for h in hooks:
L.append(f'\tcase {h.id}: return "{h.name}";')
L.append('\tdefault: return "?";')
L.append("\t}")
L.append("}")
L.append("")
L.append("#endif /* VPNHIDE_GENERATED_HOOK_IDS_H */")
L.append("")
return "\n".join(L)
def emit_rust(hooks: list[Hook], errs: list[Err], backends: list[Backend]) -> str:
L: list[str] = [f"// {GENERATED_HEADER_LINE}", "", "#![allow(dead_code)]", ""]
L.append("/// Global hook id space (data/hooks.toml). bit N == hook id N.")
L.append("#[repr(u32)]")
L.append("#[derive(Copy, Clone, Eq, PartialEq, Debug)]")
L.append("pub enum Hook {")
for h in hooks:
if h.note:
L.append(f" /// {h.note}")
L.append(f" {pascal(h.name)} = {h.id},")
L.append("}")
L.append("")
L.append(f"pub const HOOK_COUNT: u32 = {len(hooks)};")
L.append("")
L.append("/// Hooks owned by each backend: apply `mask & own`.")
for b in KNOWN_BACKENDS:
L.append(f"pub const {upper(b)}_HOOK_MASK: u32 = 0x{backend_mask(hooks, b):x};")
L.append("")
L.append("/// status error codes (protocol §5.1).")
L.append("#[repr(u32)]")
L.append("#[derive(Copy, Clone, Eq, PartialEq, Debug)]")
L.append("pub enum StatusError {")
for e in errs:
if e.note:
L.append(f" /// {e.note}")
L.append(f" {pascal(e.name)} = {e.id},")
L.append("}")
L.append("")
L.append("/// backend ids (protocol §4.3 `status backend <id>`).")
L.append("#[repr(u32)]")
L.append("#[derive(Copy, Clone, Eq, PartialEq, Debug)]")
L.append("pub enum Backend {")
for b in backends:
if b.note:
L.append(f" /// {b.note}")
L.append(f" {pascal(b.name)} = {b.id},")
L.append("}")
L.append("")
# One name per line so the output is rustfmt-clean (CI runs `cargo fmt
# --check`); rustfmt wraps an over-width array into exactly this shape.
L.append(f"pub const HOOK_NAMES: [&str; {len(hooks)}] = [")
for h in hooks:
L.append(f' "{h.name}",')
L.append("];")
L.append("")
return "\n".join(L)
def emit_kotlin(hooks: list[Hook], errs: list[Err], backends: list[Backend]) -> str:
# Shaped to match `ktlint --format` (lsposed's quality gate): multiline
# class signatures with trailing comma, a blank line between commented enum
# entries, no trailing `;`. Keep this in sync if the ktlint style changes.
L: list[str] = [f"// {GENERATED_HEADER_LINE}", ""]
L.append("package dev.okhsunrog.vpnhide.generated")
L.append("")
L.append("/** Global hook id space (data/hooks.toml). bit N == hook id N. */")
L.append("internal object HookIds {")
L.append(" enum class Hook(")
L.append(" val id: Int,")
L.append(" val hookName: String,")
L.append(" val note: String,")
L.append(" ) {")
for i, h in enumerate(hooks):
if i:
L.append("")
if h.note:
L.append(f" // {h.note}")
L.append(f' {upper(h.name)}({h.id}, "{h.name}", "{kt_string(h.note)}"),')
L.append(" }")
L.append("")
L.append(" // Hooks owned by each backend: apply `mask and own`.")
for b in KNOWN_BACKENDS:
L.append(f" const val {upper(b)}_HOOK_MASK = 0x{backend_mask(hooks, b):x}")
L.append("")
L.append(" /** status error codes (protocol §5.1). */")
L.append(" enum class StatusError(")
L.append(" val code: Int,")
L.append(" ) {")
for i, e in enumerate(errs):
if i:
L.append("")
if e.note:
L.append(f" // {e.note}")
L.append(f" {upper(e.name)}({e.id}),")
L.append(" }")
L.append("")
L.append(" /** backend ids (protocol §4.3 `status backend <id>`). */")
L.append(" enum class Backend(")
L.append(" val id: Int,")
L.append(" ) {")
for i, b in enumerate(backends):
if i:
L.append("")
if b.note:
L.append(f" // {b.note}")
L.append(f" {upper(b.name)}({b.id}),")
L.append(" }")
L.append("}")
L.append("")
return "\n".join(L)
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> int:
hooks, errs, backends = load()
return emit_outputs(
{
OUT_KMOD: emit_kmod(hooks, errs, backends),
OUT_PROTOCOL_RS: emit_rust(hooks, errs, backends),
OUT_ZYGISK: emit_rust(hooks, errs, backends),
OUT_LSP_KT: emit_kotlin(hooks, errs, backends),
}
)
if __name__ == "__main__":
sys.exit(main())