#!/usr/bin/env bash
# LinuxCraft Hardware Probe v2
# Tylko odczyt: bez sudo, plikow tymczasowych i zmian konfiguracji.
# Raport nie zawiera nazwy hosta, uzytkownikow, adresow IP/MAC, UUID ani numerow seryjnych.
set -Eeuo pipefail

if ! command -v python3 >/dev/null 2>&1; then
  printf 'LinuxCraft Probe wymaga python3.\n' >&2
  exit 1
fi

python3 - <<'PY'
import json
import os
import pathlib
import platform
import re
import shutil
import subprocess


COMMAND_ENV = dict(os.environ)
COMMAND_ENV.update({"LC_ALL": "C", "LANG": "C"})
PCI_DISPLAY_CLASSES = {0x0300, 0x0302, 0x0380}


def run(*args):
    """Run a bounded, read-only command and return stdout."""
    try:
        return subprocess.run(
            args,
            check=False,
            capture_output=True,
            text=True,
            timeout=8,
            env=COMMAND_ENV,
        ).stdout.strip()
    except (OSError, subprocess.SubprocessError):
        return ""


def read_text(path):
    try:
        return pathlib.Path(path).read_text(errors="replace").strip()
    except OSError:
        return ""


def prefixed_hex(path, width=4):
    value = read_text(path).lower()
    if value.startswith("0x"):
        value = value[2:]
    if not value or not re.fullmatch(r"[0-9a-f]+", value):
        return None
    return "0x" + value.zfill(width)


def os_release():
    data = {}
    try:
        for line in pathlib.Path("/etc/os-release").read_text(errors="replace").splitlines():
            if "=" in line:
                key, value = line.split("=", 1)
                data[key.lower()] = value.strip().strip('"')
    except OSError:
        pass
    return {
        "id": data.get("id"),
        "version_id": data.get("version_id"),
        "pretty_name": data.get("pretty_name"),
    }


def cpu_info():
    raw = run("lscpu")
    vendor = model = architecture = None
    for line in raw.splitlines():
        key, _, value = line.partition(":")
        normalized = key.strip().lower()
        if normalized == "vendor id":
            vendor = value.strip()
        elif normalized == "model name":
            model = value.strip()
        elif normalized == "architecture":
            architecture = value.strip()
    return {
        "vendor": vendor,
        "model": model,
        "architecture": architecture or platform.machine(),
        "logical_cpus": len(list(pathlib.Path("/sys/devices/system/cpu").glob("cpu[0-9]*"))),
    }


def module_candidates(device_path, current_driver):
    modules = []
    alias = read_text(device_path / "modalias")
    if alias:
        output = run("modprobe", "--resolve-alias", alias) or run("modprobe", "-R", alias)
        for line in output.splitlines():
            module = line.strip().split()[0] if line.strip() else ""
            if re.fullmatch(r"[A-Za-z0-9_.+-]+", module):
                modules.append(module)

    driver_module = device_path / "driver" / "module"
    try:
        if driver_module.exists():
            modules.append(driver_module.resolve().name)
    except OSError:
        pass
    if current_driver:
        modules.append(current_driver)
    return sorted(set(modules))


def render_nodes_for(device_path):
    names = {candidate.name for candidate in device_path.glob("drm/renderD*")}
    try:
        device_real = device_path.resolve()
    except OSError:
        device_real = device_path
    for candidate in pathlib.Path("/sys/class/drm").glob("renderD*"):
        try:
            if (candidate / "device").resolve() == device_real:
                names.add(candidate.name)
        except OSError:
            continue
    return [
        {
            "name": name,
            "path": f"/dev/dri/{name}",
            "present": pathlib.Path("/dev/dri", name).exists(),
        }
        for name in sorted(names)
    ]


def gpu_info():
    devices = []
    for device_path in sorted(pathlib.Path("/sys/bus/pci/devices").glob("*")):
        raw_class = read_text(device_path / "class")
        try:
            class_code = (int(raw_class, 16) >> 8) & 0xFFFF
        except ValueError:
            continue
        if class_code not in PCI_DISPLAY_CLASSES:
            continue

        driver = None
        try:
            driver_path = device_path / "driver"
            if driver_path.exists():
                driver = driver_path.resolve().name
        except OSError:
            pass

        boot_vga_raw = read_text(device_path / "boot_vga")
        boot_vga = None if boot_vga_raw not in {"0", "1"} else boot_vga_raw == "1"
        subsystem_vendor = prefixed_hex(device_path / "subsystem_vendor")
        subsystem_device = prefixed_hex(device_path / "subsystem_device")
        devices.append(
            {
                "pci_address": device_path.name,
                "pci_class": f"{class_code:04x}",
                "vendor_id": prefixed_hex(device_path / "vendor"),
                "device_id": prefixed_hex(device_path / "device"),
                "subsystem_vendor_id": subsystem_vendor,
                "subsystem_device_id": subsystem_device,
                "subsystem_id": (
                    f"{subsystem_vendor}:{subsystem_device}"
                    if subsystem_vendor and subsystem_device
                    else None
                ),
                "boot_vga": boot_vga,
                "driver": driver,
                "modules": module_candidates(device_path, driver),
                "render_nodes": render_nodes_for(device_path),
            }
        )
    return devices


def global_render_nodes():
    return [str(path) for path in sorted(pathlib.Path("/dev/dri").glob("renderD*")) if path.exists()]


def disks():
    # Intentionally excludes mount points, labels, UUIDs and serial numbers.
    try:
        parsed = json.loads(run("lsblk", "-J", "-b", "-o", "TYPE,SIZE,ROTA,TRAN,FSTYPE"))
        return parsed.get("blockdevices", [])
    except (json.JSONDecodeError, TypeError):
        return []


def ram_gib():
    try:
        first = next(
            line
            for line in pathlib.Path("/proc/meminfo").read_text().splitlines()
            if line.startswith("MemTotal:")
        )
        return round(int(first.split()[1]) / 1024 / 1024, 1)
    except (OSError, StopIteration, ValueError, IndexError):
        return None


def secure_boot():
    if not pathlib.Path("/sys/firmware/efi").exists():
        return {"firmware": "legacy-or-no-efi", "state": "not-applicable"}
    if not shutil.which("mokutil"):
        return {"firmware": "uefi", "state": "unknown", "reason": "mokutil-not-installed"}

    normalized = run("mokutil", "--sb-state").lower()
    if "secureboot enabled" in normalized:
        state = "enabled"
    elif "secureboot disabled" in normalized:
        state = "disabled"
    else:
        state = "unknown"
    return {"firmware": "uefi", "state": state}


def session_type():
    value = os.environ.get("XDG_SESSION_TYPE", "").strip().lower()
    if not value:
        session_id = os.environ.get("XDG_SESSION_ID", "").strip()
        if session_id:
            value = run("loginctl", "show-session", session_id, "-p", "Type", "--value").lower()
    if not value and os.environ.get("WAYLAND_DISPLAY"):
        value = "wayland"
    if not value and os.environ.get("DISPLAY"):
        value = "x11"
    if value not in {"wayland", "x11", "tty", "mir"}:
        value = "unknown"
    return value


PACKAGE_PATTERNS = {
    "mesa": re.compile(
        r"^(?:mesa-|lib(?:egl|gl|glx|gbm|dri).*mesa|libglapi-mesa|xserver-xorg-video-(?:amdgpu|ati|intel))"
    ),
    "firmware": re.compile(
        r"^(?:linux-firmware|firmware-(?:amd-graphics|intel-graphics|linux.*|misc-nonfree|nvidia.*))$"
    ),
    "nvidia": re.compile(
        r"^(?:nvidia|libnvidia|linux-(?:modules|objects)-nvidia|xserver-xorg-video-nvidia)"
    ),
    "dkms": re.compile(r"^(?:dkms|.+-dkms)(?::.+)?$"),
    "headers": re.compile(r"^linux-headers"),
}


def installed_packages():
    categories = {name: [] for name in PACKAGE_PATTERNS}
    output = run(
        "dpkg-query",
        "-W",
        "-f=${binary:Package}\\t${Version}\\t${db:Status-Abbrev}\\n",
    )
    for line in output.splitlines():
        fields = line.split("\t")
        if len(fields) != 3:
            continue
        package, version, status = fields
        if not status.startswith("ii"):
            continue
        item = {"name": package, "version": version}
        for category, pattern in PACKAGE_PATTERNS.items():
            if pattern.search(package):
                categories[category].append(item)
    for packages in categories.values():
        packages.sort(key=lambda item: item["name"])
    return categories


virt = run("systemd-detect-virt") or "none"
loaded_modules = []
for line in run("lsmod").splitlines()[1:]:
    fields = line.split()
    if fields:
        loaded_modules.append(fields[0])

report = {
    "schema": "linuxcraft.probe.v2",
    "privacy": "no-hostname-no-user-no-network-identifiers-no-uuid-no-serials",
    "read_only": True,
    "os": os_release(),
    "kernel": platform.release(),
    "virtualization": virt,
    "session_type": session_type(),
    "cpu": cpu_info(),
    "ram_gib": ram_gib(),
    "gpus": gpu_info(),
    "dri": {"render_nodes": global_render_nodes()},
    "disks": disks(),
    "loaded_modules": loaded_modules,
    "secure_boot": secure_boot(),
    "installed_packages": installed_packages(),
}
print(json.dumps(report, ensure_ascii=False, indent=2))
PY
