IQ.Pilot Release Commit @ bec7652
This commit is contained in:
317
artifacts/runtime/apply_boot_branding.py
Normal file
317
artifacts/runtime/apply_boot_branding.py
Normal file
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
||||
|
||||
|
||||
def _paint(text: str, code: str) -> str:
|
||||
return f"\033[{code}m{text}\033[0m" if _COLOR else text
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ASSET_DIR = ROOT / "artifacts" / "runtime" / "boot_branding"
|
||||
BG_ASSET = ASSET_DIR / "bg.jpg"
|
||||
SPLASH_BMP_ASSET = ASSET_DIR / "splash_embedded.bmp"
|
||||
|
||||
BG_TARGET = Path("/usr/comma/bg.jpg")
|
||||
SPLASH_TARGET = Path("/dev/block/bootdevice/by-name/splash")
|
||||
SPLASH_BMP_OFFSET = 16384
|
||||
|
||||
PASSWD_PATH = Path("/etc/passwd")
|
||||
SHADOW_PATH = Path("/etc/shadow")
|
||||
IQ_USER = "iq"
|
||||
IQ_GECOS = "IQ.Pilot"
|
||||
SOURCE_USER = "comma"
|
||||
|
||||
STATE_DIR = Path("/data/iqpilot_boot_branding")
|
||||
BACKUP_DIR = STATE_DIR / "backups"
|
||||
META_PATH = BACKUP_DIR / "metadata.json"
|
||||
BG_BACKUP = BACKUP_DIR / "bg.jpg.orig"
|
||||
SPLASH_BACKUP = BACKUP_DIR / "splash.partition.orig.img"
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def sha256_path(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def run(cmd: list[str], capture_output: bool = False) -> str:
|
||||
res = subprocess.run(cmd, check=True, text=True, capture_output=capture_output)
|
||||
return res.stdout.strip() if capture_output else ""
|
||||
|
||||
|
||||
def get_root_mount_options() -> str:
|
||||
return run(["findmnt", "-no", "OPTIONS", "/"], capture_output=True)
|
||||
|
||||
|
||||
def remount_root(mode: str) -> None:
|
||||
run(["mount", "-o", mode, "/"])
|
||||
|
||||
|
||||
def splash_size() -> int:
|
||||
return int(run(["blockdev", "--getsize64", str(SPLASH_TARGET)], capture_output=True))
|
||||
|
||||
|
||||
def read_splash_bytes(offset: int, size: int) -> bytes:
|
||||
with SPLASH_TARGET.open("rb") as f:
|
||||
f.seek(offset)
|
||||
return f.read(size)
|
||||
|
||||
|
||||
def write_splash_bytes(offset: int, data: bytes) -> None:
|
||||
with SPLASH_TARGET.open("r+b", buffering=0) as f:
|
||||
f.seek(offset)
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
|
||||
def backup_if_missing() -> None:
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not BG_BACKUP.exists():
|
||||
shutil.copy2(BG_TARGET, BG_BACKUP)
|
||||
|
||||
if not SPLASH_BACKUP.exists():
|
||||
with SPLASH_TARGET.open("rb") as src, SPLASH_BACKUP.open("wb") as dst:
|
||||
remaining = splash_size()
|
||||
while remaining > 0:
|
||||
chunk = src.read(min(1024 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
dst.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
if not META_PATH.exists():
|
||||
splash_backup_hash = sha256_path(SPLASH_BACKUP)
|
||||
meta = {
|
||||
"bg_backup_sha256": sha256_path(BG_BACKUP),
|
||||
"splash_backup_sha256": splash_backup_hash,
|
||||
"bg_asset_sha256": sha256_path(BG_ASSET) if BG_ASSET.exists() else None,
|
||||
"splash_asset_sha256": sha256_path(SPLASH_BMP_ASSET) if SPLASH_BMP_ASSET.exists() else None,
|
||||
"splash_bmp_offset": SPLASH_BMP_OFFSET,
|
||||
}
|
||||
META_PATH.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _line_for_user(text: str, user: str) -> str | None:
|
||||
prefix = f"{user}:"
|
||||
for line in text.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
def _expected_passwd_line(source_passwd_line: str) -> str:
|
||||
"""Build the iq passwd line by cloning comma's UID/GID/home/shell."""
|
||||
fields = source_passwd_line.split(":")
|
||||
if len(fields) < 7:
|
||||
raise ValueError(f"malformed passwd line for {SOURCE_USER!r}: {source_passwd_line!r}")
|
||||
_name, passwd_x, uid, gid, _gecos, home, shell = fields[:7]
|
||||
return f"{IQ_USER}:{passwd_x}:{uid}:{gid}:{IQ_GECOS}:{home}:{shell}"
|
||||
|
||||
|
||||
def _expected_shadow_line(days_since_epoch: int) -> str:
|
||||
return f"{IQ_USER}:!:{days_since_epoch}:0:99999:7:::"
|
||||
|
||||
|
||||
def _replace_or_append(text: str, user: str, new_line: str) -> str:
|
||||
prefix = f"{user}:"
|
||||
out: list[str] = []
|
||||
replaced = False
|
||||
for line in text.splitlines():
|
||||
if line.startswith(prefix):
|
||||
if not replaced:
|
||||
out.append(new_line)
|
||||
replaced = True
|
||||
continue
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
out.append(new_line)
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def ensure_iq_user(
|
||||
passwd_path: Path | None = None,
|
||||
shadow_path: Path | None = None,
|
||||
remount: Callable[[str], None] | None = None,
|
||||
root_opts: str | None = None,
|
||||
now: Callable[[], float] | None = None,
|
||||
) -> bool:
|
||||
"""Ensure an `iq` user exists as a UID-1000 alias of `comma`.
|
||||
|
||||
Idempotent: returns False when /etc/passwd and /etc/shadow already have a
|
||||
correctly-shaped `iq` entry, True when either file was modified.
|
||||
"""
|
||||
passwd_path = passwd_path if passwd_path is not None else PASSWD_PATH
|
||||
shadow_path = shadow_path if shadow_path is not None else SHADOW_PATH
|
||||
remount = remount if remount is not None else remount_root
|
||||
now = now if now is not None else time.time
|
||||
|
||||
passwd_text = passwd_path.read_text(encoding="utf-8") if passwd_path.exists() else ""
|
||||
shadow_text = shadow_path.read_text(encoding="utf-8") if shadow_path.exists() else ""
|
||||
|
||||
source_line = _line_for_user(passwd_text, SOURCE_USER)
|
||||
if source_line is None:
|
||||
# Without a comma user to clone from we'd create a broken iq entry; bail.
|
||||
return False
|
||||
|
||||
expected_passwd = _expected_passwd_line(source_line)
|
||||
passwd_line = _line_for_user(passwd_text, IQ_USER)
|
||||
shadow_line = _line_for_user(shadow_text, IQ_USER)
|
||||
|
||||
passwd_ok = passwd_line == expected_passwd
|
||||
shadow_ok = shadow_line is not None and shadow_line.startswith(f"{IQ_USER}:!:")
|
||||
if passwd_ok and shadow_ok:
|
||||
return False
|
||||
|
||||
days = int(now() // 86400)
|
||||
new_passwd = _replace_or_append(passwd_text, IQ_USER, expected_passwd)
|
||||
new_shadow = _replace_or_append(shadow_text, IQ_USER, _expected_shadow_line(days))
|
||||
|
||||
opts = root_opts if root_opts is not None else get_root_mount_options()
|
||||
remounted = False
|
||||
try:
|
||||
remount("remount,rw")
|
||||
remounted = True
|
||||
if not passwd_ok:
|
||||
passwd_path.write_text(new_passwd, encoding="utf-8")
|
||||
if not shadow_ok:
|
||||
shadow_path.write_text(new_shadow, encoding="utf-8")
|
||||
finally:
|
||||
if remounted:
|
||||
remount(f"remount,{opts}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def apply_branding() -> bool:
|
||||
if not BG_ASSET.exists() or not SPLASH_BMP_ASSET.exists():
|
||||
return False
|
||||
|
||||
backup_if_missing()
|
||||
|
||||
bg_asset_bytes = BG_ASSET.read_bytes()
|
||||
splash_asset_bytes = SPLASH_BMP_ASSET.read_bytes()
|
||||
|
||||
current_bg_hash = sha256_path(BG_TARGET)
|
||||
current_splash_hash = sha256_bytes(read_splash_bytes(SPLASH_BMP_OFFSET, len(splash_asset_bytes)))
|
||||
|
||||
bg_asset_hash = sha256_bytes(bg_asset_bytes)
|
||||
splash_asset_hash = sha256_bytes(splash_asset_bytes)
|
||||
|
||||
if current_bg_hash == bg_asset_hash and current_splash_hash == splash_asset_hash:
|
||||
return False
|
||||
|
||||
root_opts = get_root_mount_options()
|
||||
remounted = False
|
||||
try:
|
||||
if current_bg_hash != bg_asset_hash:
|
||||
remount_root("remount,rw")
|
||||
remounted = True
|
||||
BG_TARGET.write_bytes(bg_asset_bytes)
|
||||
|
||||
if current_splash_hash != splash_asset_hash:
|
||||
write_splash_bytes(SPLASH_BMP_OFFSET, splash_asset_bytes)
|
||||
run(["sync"])
|
||||
finally:
|
||||
if remounted:
|
||||
remount_root(f"remount,{root_opts}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def restore_branding() -> bool:
|
||||
if not BG_BACKUP.exists() or not SPLASH_BACKUP.exists():
|
||||
return False
|
||||
|
||||
root_opts = get_root_mount_options()
|
||||
remounted = False
|
||||
try:
|
||||
remount_root("remount,rw")
|
||||
remounted = True
|
||||
shutil.copy2(BG_BACKUP, BG_TARGET)
|
||||
|
||||
with SPLASH_BACKUP.open("rb") as src, SPLASH_TARGET.open("r+b", buffering=0) as dst:
|
||||
while True:
|
||||
chunk = src.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
dst.write(chunk)
|
||||
dst.flush()
|
||||
os.fsync(dst.fileno())
|
||||
run(["sync"])
|
||||
finally:
|
||||
if remounted:
|
||||
remount_root(f"remount,{root_opts}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def status() -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"bg_asset_exists": BG_ASSET.exists(),
|
||||
"splash_asset_exists": SPLASH_BMP_ASSET.exists(),
|
||||
"bg_backup_exists": BG_BACKUP.exists(),
|
||||
"splash_backup_exists": SPLASH_BACKUP.exists(),
|
||||
}
|
||||
if BG_ASSET.exists() and BG_TARGET.exists():
|
||||
result["bg_asset_sha256"] = sha256_path(BG_ASSET)
|
||||
result["bg_current_sha256"] = sha256_path(BG_TARGET)
|
||||
if SPLASH_BMP_ASSET.exists() and SPLASH_TARGET.exists():
|
||||
splash_asset_bytes = SPLASH_BMP_ASSET.read_bytes()
|
||||
result["splash_asset_sha256"] = sha256_bytes(splash_asset_bytes)
|
||||
result["splash_current_sha256"] = sha256_bytes(read_splash_bytes(SPLASH_BMP_OFFSET, len(splash_asset_bytes)))
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if os.geteuid() != 0:
|
||||
raise SystemExit("apply_boot_branding.py must run as root")
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument("--restore", action="store_true")
|
||||
group.add_argument("--status", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.status:
|
||||
print(json.dumps(status(), indent=2))
|
||||
return 0
|
||||
|
||||
changed = restore_branding() if args.restore else apply_branding()
|
||||
|
||||
try:
|
||||
iq_changed = ensure_iq_user()
|
||||
except Exception as e: # noqa: BLE001
|
||||
iq_changed = False
|
||||
print(_paint(f"ensure_iq_user failed: {e}", "1;38;5;203"))
|
||||
|
||||
if changed or iq_changed:
|
||||
print(_paint("changed", "38;5;114"))
|
||||
else:
|
||||
print(_paint("no-change", "2;38;5;246"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user