From 7a740a6868394e5932f0461eff72e765d69f9d85 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Fri, 5 Jun 2026 23:46:27 -0400 Subject: [PATCH 01/13] Patches: template arch-derived paths in auto-generated base patch Use the new Jinja templating in auto-generated config patches so architecture-dependent values are resolved from core.arch at load time instead of being compiled into the generated YAML. - Expose two arch-derived template variables, {{ arch_dir }} (generic static subdir, e.g. intel64 -> x86_64) and {{ dylib_dir }} (prebuilt-dylib subdir, e.g. aarch64 -> arm64), in templating.build_context, derived from the file's core.arch (lazy/guarded imports so schema-gen contexts are unaffected). - Add arch.get_dylib_subdir() as the single source of truth for the dylib mapping; BasePatch.set_arch_info now uses it instead of an inline copy. - BasePatch.generate emits {{ dylib_dir }} / {{ arch_dir }} in the /igloo/dylibs and /igloo/utils host paths instead of baking the subdir in. core.arch and core.kernel stay concrete (they are the source values). - Tests for the derived vars, mapping parity, in-patch resolution, and that the generator emits the placeholders. Docs updated. --- docs/pyplugin_architecture.md | 7 +++- src/penguin/arch.py | 25 ++++++++++++ src/penguin/config_patchers.py | 26 +++++------- src/penguin/penguin_config/templating.py | 33 +++++++++++++-- tests/unit_tests/test_config.py | 51 ++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 21 deletions(-) diff --git a/docs/pyplugin_architecture.md b/docs/pyplugin_architecture.md index 2f0f8bcd3..15a452587 100644 --- a/docs/pyplugin_architecture.md +++ b/docs/pyplugin_architecture.md @@ -103,9 +103,14 @@ both at the top level and under `plugins:` is an error. Config and patch files support Jinja2 templating so values that depend on the architecture (or other settings) don't have to be hardcoded in multiple places. -Available variables: `{{ arch }}`, `{{ core. }}` (e.g. `{{ core.mem }}`), +Available variables: `{{ arch }}`, the arch-derived static-layout subdirs +`{{ arch_dir }}` and `{{ dylib_dir }}`, `{{ core. }}` (e.g. `{{ core.mem }}`), `{{ kernel_version }}`, and anything you define in a top-level `vars:` section. +Penguin's own auto-generated base patch uses this: instead of baking the arch +subdir into host paths, it emits `{{ arch_dir }}` / `{{ dylib_dir }}` so changing +`core.arch` reconfigures those paths automatically. + ```yaml core: arch: mipsel diff --git a/src/penguin/arch.py b/src/penguin/arch.py index af2235850..6bcd976da 100644 --- a/src/penguin/arch.py +++ b/src/penguin/arch.py @@ -6,6 +6,31 @@ logger = getColoredLogger("penguin.arch") +def get_dylib_subdir(arch_name: str) -> str: + """ + Map a config arch name (e.g. "aarch64", "powerpc64le") to the subdirectory + under ``/dylibs`` holding that arch's prebuilt dynamic libraries. + + Dylibs are built/published under short, sometimes-distinct names that differ + from both the config arch name and the generic arch subdir, so this mapping + is the single source of truth for both patch generation and config templating + (the ``{{ dylib_dir }}`` template variable). + """ + if arch_name == "aarch64": + return "arm64" + if arch_name == "intel64": + return "x86_64" + if arch_name == "loongarch64": + return "loongarch" + if arch_name == "powerpc64le": + return "ppc64el" + if "powerpc" in arch_name: + return arch_name.replace("powerpc", "ppc") # dylibs use short names + # Fall back to the generic arch subdir (e.g. intel64 -> x86_64, mips* as-is). + from .utils import get_arch_subdir + return get_arch_subdir({"core": {"arch": arch_name}}) + + @dataclasses.dataclass class ArchInfo: arch: Optional[str] = None diff --git a/src/penguin/config_patchers.py b/src/penguin/config_patchers.py index d5fbd146b..9075691ec 100644 --- a/src/penguin/config_patchers.py +++ b/src/penguin/config_patchers.py @@ -22,7 +22,7 @@ from pathlib import Path from penguin import getColoredLogger -from .arch import arch_filter, arch_end +from .arch import arch_filter, arch_end, get_dylib_subdir from .defaults import ( default_init_script, default_lib_aliases, @@ -422,19 +422,7 @@ def set_arch_info(self, arch_identified: str) -> None: mock_config = {"core": {"arch": self.arch_name}} self.arch_dir = get_arch_subdir(mock_config) - - if arch_identified == "aarch64": - self.dylib_dir = "arm64" - elif arch_identified == "intel64": - self.dylib_dir = "x86_64" - elif arch_identified == "loongarch64": - self.dylib_dir = "loongarch" - elif self.arch_name == "powerpc64le": - self.dylib_dir = "ppc64el" - elif "powerpc" in self.arch_name: - self.dylib_dir = self.arch_name.replace("powerpc", "ppc") # dylibs are built with short names - else: - self.dylib_dir = self.arch_dir + self.dylib_dir = get_dylib_subdir(self.arch_name) def generate(self, patches: dict) -> dict: # Add serial device in pseudofiles @@ -517,11 +505,14 @@ def generate(self, patches: dict) -> dict: "host_path": os.path.join(*[STATIC_DIR, "ltrace", "*"]), }, - # Dynamic libraries + # Dynamic libraries. The arch-specific subdir is emitted as a + # Jinja template ({{ dylib_dir }}) and resolved at config-load + # time from core.arch, so changing the arch reconfigures the path + # instead of it being baked in here. "/igloo/dylibs/*": { "type": "host_file", "mode": 0o755, - "host_path": os.path.join(STATIC_DIR, "dylibs", self.dylib_dir or self.arch_dir, "*"), + "host_path": os.path.join(STATIC_DIR, "dylibs", "{{ dylib_dir }}", "*"), }, # Startup scripts @@ -562,8 +553,9 @@ def generate(self, patches: dict) -> dict: "mode": 0o755, } result["static_files"]["/igloo/utils/*"] = { + # {{ arch_dir }} is resolved at config-load time from core.arch. "type": "host_file", - "host_path": f"{STATIC_DIR}/{self.arch_dir}/*", + "host_path": f"{STATIC_DIR}/{{{{ arch_dir }}}}/*", "mode": 0o755, } diff --git a/src/penguin/penguin_config/templating.py b/src/penguin/penguin_config/templating.py index c41195d8b..5a9c7e786 100644 --- a/src/penguin/penguin_config/templating.py +++ b/src/penguin/penguin_config/templating.py @@ -69,11 +69,36 @@ def substitute(obj, ctx, env=None, where="config"): return obj +def _arch_derived_vars(arch): + """ + Compute arch-derived template variables for a given config arch name. + + Returns a dict with ``arch_dir`` (generic static subdir) and ``dylib_dir`` + (prebuilt-dylib subdir). Imports are lazy and failures are swallowed so the + templating module stays usable in self-contained contexts (e.g. schema + generation) where penguin.utils/arch aren't importable; in that case these + variables are simply absent and a template referencing them errors clearly. + """ + out = {} + try: + from penguin.utils import get_arch_subdir + out["arch_dir"] = get_arch_subdir({"core": {"arch": arch}}) + except Exception: + pass + try: + from penguin.arch import get_dylib_subdir + out["dylib_dir"] = get_dylib_subdir(arch) + except Exception: + pass + return out + + def build_context(raw_config, extra=None, env=None, where="vars"): """ Build the Jinja context for a config/patch dict. - Exposes ``arch``, ``core`` (so ``{{ core.mem }}`` works), the late-bound + Exposes ``arch``, the arch-derived ``arch_dir`` / ``dylib_dir`` static-layout + subdirs, ``core`` (so ``{{ core.mem }}`` works), the late-bound ``kernel_version`` sentinel, anything in ``extra`` (e.g. the main config's context when rendering a patch), and the file's own ``vars:`` (which may themselves reference earlier vars / arch). @@ -85,8 +110,10 @@ def build_context(raw_config, extra=None, env=None, where="vars"): core = raw_config.get("core") if isinstance(raw_config, dict) else None if isinstance(core, dict): ctx["core"] = dict(core) - if core.get("arch"): - ctx["arch"] = core["arch"] + arch = core.get("arch") + if arch: + ctx["arch"] = arch + ctx.update(_arch_derived_vars(arch)) user_vars = raw_config.get("vars") if isinstance(raw_config, dict) else None if isinstance(user_vars, dict): for k, v in user_vars.items(): diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 21b963ffd..ce69a9f32 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -409,6 +409,57 @@ def test_legacy_at_placeholders_untouched(): assert out["k"] == "/kernels/@ARCH@/vmlinux.@ARCH@" +# --------------------------------------------------------------------------- # +# arch-derived template variables (used by auto-generated patches) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("arch,arch_dir,dylib_dir", [ + ("armel", "armel", "armel"), + ("aarch64", "aarch64", "arm64"), + ("intel64", "x86_64", "x86_64"), + ("powerpc64le", "powerpc64", "ppc64el"), + ("powerpc", "powerpc", "ppc"), + ("mipsel", "mipsel", "mipsel"), + ("loongarch64", "loongarch64", "loongarch"), +]) +def test_arch_derived_context_vars(arch, arch_dir, dylib_dir): + ctx = templating.build_context({"core": {"arch": arch}}) + assert ctx["arch"] == arch + assert ctx["arch_dir"] == arch_dir + assert ctx["dylib_dir"] == dylib_dir + + +def test_get_dylib_subdir_matches_context(): + from penguin.arch import get_dylib_subdir + assert get_dylib_subdir("aarch64") == "arm64" + assert get_dylib_subdir("powerpc64le") == "ppc64el" + assert get_dylib_subdir("mipsel") == "mipsel" + + +def test_arch_dir_template_resolves_in_patch(): + # Mirrors the generated base patch: defines core.arch and uses the derived + # subdir variables in host_paths. + patch = { + "core": {"arch": "aarch64"}, + "static_files": { + "/igloo/dylibs/*": {"type": "host_file", "host_path": "/s/dylibs/{{ dylib_dir }}/*"}, + "/igloo/utils/*": {"type": "host_file", "host_path": "/s/{{ arch_dir }}/*"}, + }, + } + out = templating.substitute(patch, templating.build_context(patch)) + assert out["static_files"]["/igloo/dylibs/*"]["host_path"] == "/s/dylibs/arm64/*" + assert out["static_files"]["/igloo/utils/*"]["host_path"] == "/s/aarch64/*" + + +def test_generated_base_patch_emits_arch_templates(): + # The BasePatch generator should emit Jinja placeholders (not baked subdirs) + # for the arch-specific host_paths, so load-time templating resolves them. + import inspect + from penguin import config_patchers + src = inspect.getsource(config_patchers.BasePatch.generate) + assert "{{ dylib_dir }}" in src + assert "{{ arch_dir }}" in src + + # --------------------------------------------------------------------------- # # PR1+PR3+PR4 end-to-end through load_config (no kernel/container needed) # --------------------------------------------------------------------------- # From 2f0a3b41636b0b7c1643c8558daa0684b69a854d Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 02:00:47 -0400 Subject: [PATCH 02/13] DIAG(issue831): instrument native_mmap MTD read-back for ppc64 Capture which step faults on the ppc64 MTD read-back without aborting: - enable kernel print-fatal-signals (logs faulting comm + nip to console.log) - run the read-back as a standalone cat (capture exit code + size + hexdump) instead of inside a pipeline, before and after the write, repeated - only print the PASS markers when the read-back actually contains the new data, so working arches stay green while ppc64 records diagnostics Temporary diagnostic commit; revert once the fault is characterized. --- .../patches/tests/native_mmap.yaml | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml index 800e020b4..1d3577ed7 100644 --- a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml +++ b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml @@ -90,8 +90,40 @@ static_files: /igloo/utils/micropython /tmp/mmap_native.py /igloo/utils/busybox mdev -s || true MTD=$(/igloo/utils/busybox grep '"mmap_native_mtd"' /proc/mtd | /igloo/utils/busybox cut -d: -f1) - /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd native mmap" + + # --- issue831 diagnostics (ppc64 read-back segfault) ------------------- + # Log faulting process + nip/address to the kernel console (uploaded as + # console.log) and isolate exactly which step faults without aborting. + /igloo/utils/busybox echo 1 > /proc/sys/kernel/print-fatal-signals 2>/dev/null || true + /igloo/utils/busybox echo "ISSUE831 MTD=${MTD}" + /igloo/utils/busybox cat /proc/mtd || true + set +e + + # Initial read, standalone (no pipe) so we can see cat's own exit code. + /igloo/utils/busybox cat /dev/${MTD} > /tmp/rb1.bin 2>/tmp/rb1.err + /igloo/utils/busybox echo "ISSUE831 read1 rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/rb1.bin)" + /igloo/utils/busybox hexdump -C /tmp/rb1.bin | /igloo/utils/busybox head -2 + + # Write, then read back (standalone) twice to check intra-run determinism. /igloo/utils/busybox echo "mtd changed" > /dev/${MTD} + /igloo/utils/busybox echo "ISSUE831 write rc=$?" + + /igloo/utils/busybox cat /dev/${MTD} > /tmp/rb2.bin 2>/tmp/rb2.err + /igloo/utils/busybox echo "ISSUE831 read2 rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/rb2.bin)" + /igloo/utils/busybox hexdump -C /tmp/rb2.bin | /igloo/utils/busybox head -2 + + /igloo/utils/busybox cat /dev/${MTD} > /tmp/rb3.bin 2>/tmp/rb3.err + /igloo/utils/busybox echo "ISSUE831 read3 rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/rb3.bin)" + + # Original pipeline form for reference. /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd changed" - /igloo/utils/busybox echo "native mmap mtd PASS" - /igloo/utils/busybox echo "/tests/mmap_test PASS" + /igloo/utils/busybox echo "ISSUE831 pipe rc=$?" + set -e + # ---------------------------------------------------------------------- + + # Only declare PASS if the read-back actually contains the new data, so + # working arches stay green while ppc64 records diagnostics above. + if /igloo/utils/busybox grep -q "mtd changed" /tmp/rb2.bin; then + /igloo/utils/busybox echo "native mmap mtd PASS" + /igloo/utils/busybox echo "/tests/mmap_test PASS" + fi From 23844e9177f56e3b5272d113a49ef1b425a5430d Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 02:21:36 -0400 Subject: [PATCH 03/13] DIAG(issue831): isolate which pipeline stage faults on ppc64 Green run showed the standalone 'cat /dev/mtdN > file' read-back works on ppc64 -- the segfault is bound to the 'cat | strings | grep' pipeline. Add stage isolation (strings-on-file, cat|cat, cat|strings, full pipe) and gate PASS on the original pipeline so ppc64 fails and uploads artifacts with the per-step exit codes + the kernel fatal-signal line. --- .../patches/tests/native_mmap.yaml | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml index 1d3577ed7..7185de93a 100644 --- a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml +++ b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml @@ -115,15 +115,25 @@ static_files: /igloo/utils/busybox cat /dev/${MTD} > /tmp/rb3.bin 2>/tmp/rb3.err /igloo/utils/busybox echo "ISSUE831 read3 rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/rb3.bin)" - # Original pipeline form for reference. + # Stage isolation: which part of "cat | strings | grep" faults? + # (a) strings/grep on a file (no pipe from the MTD cat) + /igloo/utils/busybox strings < /tmp/rb2.bin | /igloo/utils/busybox grep "mtd changed" + /igloo/utils/busybox echo "ISSUE831 strings_on_file rc=$?" + # (b) MTD cat piped to a plain cat (pipe from MTD read, no strings) + /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox cat > /tmp/cc.bin + /igloo/utils/busybox echo "ISSUE831 cat_pipe_cat rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/cc.bin)" + # (c) MTD cat piped to strings (no grep) + /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings > /tmp/s.bin + /igloo/utils/busybox echo "ISSUE831 cat_pipe_strings rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/s.bin)" + # (d) full original pipeline /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd changed" - /igloo/utils/busybox echo "ISSUE831 pipe rc=$?" + /igloo/utils/busybox echo "ISSUE831 full_pipe rc=$?" set -e # ---------------------------------------------------------------------- - # Only declare PASS if the read-back actually contains the new data, so - # working arches stay green while ppc64 records diagnostics above. - if /igloo/utils/busybox grep -q "mtd changed" /tmp/rb2.bin; then + # Gate PASS on the original pipeline so ppc64 FAILS (=> artifacts upload + # with the diagnostics above) while working arches stay green. + if /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep -q "mtd changed"; then /igloo/utils/busybox echo "native mmap mtd PASS" /igloo/utils/busybox echo "/tests/mmap_test PASS" fi From f0703ad7f6348cdfe1613b0b98f897d5122036ba Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 02:44:56 -0400 Subject: [PATCH 04/13] DIAG(issue831): capture fault on exact original sequence Warm-up reads made the fault vanish (commits 1-2 went green), so the fault needs the piped read to be the first read after the write. Restore the exact original single-read/write/single-piped-read order and only arm fault capture (print-fatal-signals, show_unhandled_signals, core_pattern -> shared, ulimit -c) so the failing ppc64 run uploads the faulting process+nip and a coredump. --- .../patches/tests/native_mmap.yaml | 53 +++++-------------- 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml index 7185de93a..b312dbe0e 100644 --- a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml +++ b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml @@ -91,49 +91,20 @@ static_files: /igloo/utils/busybox mdev -s || true MTD=$(/igloo/utils/busybox grep '"mmap_native_mtd"' /proc/mtd | /igloo/utils/busybox cut -d: -f1) - # --- issue831 diagnostics (ppc64 read-back segfault) ------------------- - # Log faulting process + nip/address to the kernel console (uploaded as - # console.log) and isolate exactly which step faults without aborting. + # --- issue831: capture the fault on the EXACT original sequence -------- + # The fault is fragile: extra warm-up reads make it disappear, so keep the + # original single-read/write/single-piped-read order untouched. Just arm + # kernel fault logging + coredumps so the failing run uploads the faulting + # process name + nip (console.log) and a coredump (shared/issue831_cores). /igloo/utils/busybox echo 1 > /proc/sys/kernel/print-fatal-signals 2>/dev/null || true + /igloo/utils/busybox echo 1 > /proc/sys/kernel/show_unhandled_signals 2>/dev/null || true + /igloo/utils/busybox mkdir -p /igloo/shared/issue831_cores 2>/dev/null || true + /igloo/utils/busybox echo "/igloo/shared/issue831_cores/core.%e.%p.%s" > /proc/sys/kernel/core_pattern 2>/dev/null || true + ulimit -c unlimited 2>/dev/null || true /igloo/utils/busybox echo "ISSUE831 MTD=${MTD}" - /igloo/utils/busybox cat /proc/mtd || true - set +e - # Initial read, standalone (no pipe) so we can see cat's own exit code. - /igloo/utils/busybox cat /dev/${MTD} > /tmp/rb1.bin 2>/tmp/rb1.err - /igloo/utils/busybox echo "ISSUE831 read1 rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/rb1.bin)" - /igloo/utils/busybox hexdump -C /tmp/rb1.bin | /igloo/utils/busybox head -2 - - # Write, then read back (standalone) twice to check intra-run determinism. + /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd native mmap" /igloo/utils/busybox echo "mtd changed" > /dev/${MTD} - /igloo/utils/busybox echo "ISSUE831 write rc=$?" - - /igloo/utils/busybox cat /dev/${MTD} > /tmp/rb2.bin 2>/tmp/rb2.err - /igloo/utils/busybox echo "ISSUE831 read2 rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/rb2.bin)" - /igloo/utils/busybox hexdump -C /tmp/rb2.bin | /igloo/utils/busybox head -2 - - /igloo/utils/busybox cat /dev/${MTD} > /tmp/rb3.bin 2>/tmp/rb3.err - /igloo/utils/busybox echo "ISSUE831 read3 rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/rb3.bin)" - - # Stage isolation: which part of "cat | strings | grep" faults? - # (a) strings/grep on a file (no pipe from the MTD cat) - /igloo/utils/busybox strings < /tmp/rb2.bin | /igloo/utils/busybox grep "mtd changed" - /igloo/utils/busybox echo "ISSUE831 strings_on_file rc=$?" - # (b) MTD cat piped to a plain cat (pipe from MTD read, no strings) - /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox cat > /tmp/cc.bin - /igloo/utils/busybox echo "ISSUE831 cat_pipe_cat rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/cc.bin)" - # (c) MTD cat piped to strings (no grep) - /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings > /tmp/s.bin - /igloo/utils/busybox echo "ISSUE831 cat_pipe_strings rc=$? size=$(/igloo/utils/busybox wc -c < /tmp/s.bin)" - # (d) full original pipeline /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd changed" - /igloo/utils/busybox echo "ISSUE831 full_pipe rc=$?" - set -e - # ---------------------------------------------------------------------- - - # Gate PASS on the original pipeline so ppc64 FAILS (=> artifacts upload - # with the diagnostics above) while working arches stay green. - if /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep -q "mtd changed"; then - /igloo/utils/busybox echo "native mmap mtd PASS" - /igloo/utils/busybox echo "/tests/mmap_test PASS" - fi + /igloo/utils/busybox echo "native mmap mtd PASS" + /igloo/utils/busybox echo "/tests/mmap_test PASS" From 74a238b2beeb86e71e5494647832ea14e67f30a6 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 03:12:21 -0400 Subject: [PATCH 05/13] issue831: route native MTD read-back write through portal on the host The ppc64 native_mmap MTD read-back segfault is a host-side memory bug, not a test/data bug: a standalone 'cat /dev/mtdN > file' read-back returns correct data, but the original 'cat | strings | grep' pipeline deterministically segfaults a sibling process, and any test-script perturbation hides it. The MTD read callback delivered data via plugins.mem.write -> the PANDA virtual_memory_write_external fast path, which translates the guest kernel read buffer's virtual address host-side. On ppc64 that translation appears unreliable for some kbuf addresses and corrupts a co-running process. Add an opt-in write_bytes(prefer_portal=True) that skips the PANDA fast path and writes via the guest-executed portal path, and use it for the native MTD read callback. Test script restored to the exact original faulting sequence so CI verifies the fix without perturbing the heisenbug. --- pyplugins/apis/mem.py | 13 ++++++++++--- pyplugins/testing/native_mmap.py | 7 ++++++- .../test_target/patches/tests/native_mmap.yaml | 13 ------------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/pyplugins/apis/mem.py b/pyplugins/apis/mem.py index 2d804c43d..40f9df678 100644 --- a/pyplugins/apis/mem.py +++ b/pyplugins/apis/mem.py @@ -113,7 +113,8 @@ def _get_rsize(self) -> int: return 4096 def write_bytes(self, addr: Union[int, Ptr], data: bytes, - pid: Optional[int] = None) -> Generator[Any, Any, int]: + pid: Optional[int] = None, + prefer_portal: bool = False) -> Generator[Any, Any, int]: """ Write bytes to guest memory. @@ -127,6 +128,11 @@ def write_bytes(self, addr: Union[int, Ptr], data: bytes, Data to write. pid : int, optional Process ID for context. + prefer_portal : bool, optional + Skip the PANDA virtual-memory fast path and write via the portal + (guest-executed) path instead. Use for writes to addresses whose + host-side virtual->physical translation is unreliable (e.g. ppc64 + kernel buffers); see issue #831. Returns ------- @@ -142,10 +148,11 @@ def write_bytes(self, addr: Union[int, Ptr], data: bytes, rsize = self._get_rsize() cpu = None + use_panda = self.try_panda and not prefer_portal # Handle single chunk (Fast Path) if total_len <= rsize: - if self.try_panda: + if use_panda: if cpu is None: cpu = self._get_cpu() try: @@ -169,7 +176,7 @@ def write_bytes(self, addr: Union[int, Ptr], data: bytes, chunk_len = len(chunk_view) success = False - if self.try_panda: + if use_panda: if cpu is None: cpu = self._get_cpu() try: diff --git a/pyplugins/testing/native_mmap.py b/pyplugins/testing/native_mmap.py index 94c1172b7..d6cb19b0c 100644 --- a/pyplugins/testing/native_mmap.py +++ b/pyplugins/testing/native_mmap.py @@ -61,9 +61,14 @@ def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): ptregs.retval = 0 return 0 chunk = min(size, self.SIZE - off) - yield from plugins.mem.write( + # issue #831: write the read-back data through the portal (guest- + # executed) path instead of the PANDA virtual-memory fast path. On + # ppc64 the host-side virtual->physical translation of the kernel read + # buffer appears unreliable and corrupts a co-running process. + yield from plugins.mem.write_bytes( buf_ptr, bytes(self.data[off:off + chunk]), + prefer_portal=True, ) ptregs.retval = 0 return 0 diff --git a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml index b312dbe0e..800e020b4 100644 --- a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml +++ b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml @@ -90,19 +90,6 @@ static_files: /igloo/utils/micropython /tmp/mmap_native.py /igloo/utils/busybox mdev -s || true MTD=$(/igloo/utils/busybox grep '"mmap_native_mtd"' /proc/mtd | /igloo/utils/busybox cut -d: -f1) - - # --- issue831: capture the fault on the EXACT original sequence -------- - # The fault is fragile: extra warm-up reads make it disappear, so keep the - # original single-read/write/single-piped-read order untouched. Just arm - # kernel fault logging + coredumps so the failing run uploads the faulting - # process name + nip (console.log) and a coredump (shared/issue831_cores). - /igloo/utils/busybox echo 1 > /proc/sys/kernel/print-fatal-signals 2>/dev/null || true - /igloo/utils/busybox echo 1 > /proc/sys/kernel/show_unhandled_signals 2>/dev/null || true - /igloo/utils/busybox mkdir -p /igloo/shared/issue831_cores 2>/dev/null || true - /igloo/utils/busybox echo "/igloo/shared/issue831_cores/core.%e.%p.%s" > /proc/sys/kernel/core_pattern 2>/dev/null || true - ulimit -c unlimited 2>/dev/null || true - /igloo/utils/busybox echo "ISSUE831 MTD=${MTD}" - /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd native mmap" /igloo/utils/busybox echo "mtd changed" > /dev/${MTD} /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd changed" From 51d1d6aca536a5d12eaa8a12a583fa71c1fbc9be Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 03:35:47 -0400 Subject: [PATCH 06/13] ci(issue831): re-run to confirm portal-write fix is deterministic (no tree change) From 82245436da5892cd29bea960868e9d52e9fc4e54 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 07:44:04 -0400 Subject: [PATCH 07/13] DIAG(issue831): probe whether the PANDA MTD write lands at the wrong PA Restore the original PANDA virtual-memory write in the MTD read callback (the suspected-faulty path) and, after each write, read the same kernel VA back two ways: via PANDA (cpu_memory_rw_debug, same host translation) and via the portal (guest-executed, the guest's real mapping). Log any mismatch + the VA to results/issue831_probe.txt. - portal_rb != src (or panda_rb != portal_rb) => PANDA write hit a different physical page than the guest sees -> wrong-PA confirmed (host translation bug) - everything matches => write lands correctly; retract the corruption story (mechanism is timing/coherency, fix is perturbation) add read_bytes(prefer_portal=) to mirror write_bytes. A never-matching verifier condition forces artifact upload so the probe file is captured even if the heisenbug crash does not reproduce. Guest test script left byte-identical. --- pyplugins/apis/mem.py | 11 ++- pyplugins/testing/native_mmap.py | 79 ++++++++++++++++--- .../patches/tests/native_mmap.yaml | 7 ++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/pyplugins/apis/mem.py b/pyplugins/apis/mem.py index 40f9df678..6f5838f8f 100644 --- a/pyplugins/apis/mem.py +++ b/pyplugins/apis/mem.py @@ -210,19 +210,24 @@ def write_bytes_panda(self, cpu, addr: Union[int, Ptr], data: bytes) -> None: raise ValueError(f"Memory write failed with err={err}") # TODO: make a PANDA Exn class def read_bytes(self, addr: Union[int, Ptr], size: int, - pid: Optional[int] = None) -> Generator[Any, Any, bytes]: + pid: Optional[int] = None, + prefer_portal: bool = False) -> Generator[Any, Any, bytes]: """ Reads bytes from guest memory. Optimized with a Fast Path for single-chunk reads. + + prefer_portal: skip the PANDA fast path and read via the guest-executed + portal path (see issue #831 / write_bytes). """ if isinstance(addr, Ptr): addr = addr.address rsize = self._get_rsize() + use_panda = self.try_panda and not prefer_portal # --- FAST PATH: Single Chunk (Common Case) --- if size <= rsize: - if self.try_panda: + if use_panda: # We can assume CPU is needed here, get it once cpu = self._get_cpu() try: @@ -256,7 +261,7 @@ def read_bytes(self, addr: Union[int, Ptr], size: int, chunk_size = rsize chunk = None - if self.try_panda: + if use_panda: if cpu is None: cpu = self._get_cpu() try: diff --git a/pyplugins/testing/native_mmap.py b/pyplugins/testing/native_mmap.py index d6cb19b0c..4f27ed858 100644 --- a/pyplugins/testing/native_mmap.py +++ b/pyplugins/testing/native_mmap.py @@ -52,8 +52,23 @@ def __init__(self): self.data = bytearray(b"\xff" * self.SIZE) initial = b"mtd native mmap\n" self.data[:len(initial)] = initial + # issue831 probe state + self.outdir = None + self._probe_reads = 0 + self._probe_logged = 0 super().__init__() + def _probe_log(self, line): + self._probe_logged += 1 + if not self.outdir: + return + try: + import os + with open(os.path.join(self.outdir, "issue831_probe.txt"), "a") as f: + f.write(line + "\n") + except Exception: + pass + def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): off = int(offset) size = int(length) @@ -61,15 +76,57 @@ def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): ptregs.retval = 0 return 0 chunk = min(size, self.SIZE - off) - # issue #831: write the read-back data through the portal (guest- - # executed) path instead of the PANDA virtual-memory fast path. On - # ppc64 the host-side virtual->physical translation of the kernel read - # buffer appears unreliable and corrupts a co-running process. - yield from plugins.mem.write_bytes( - buf_ptr, - bytes(self.data[off:off + chunk]), - prefer_portal=True, - ) + src = bytes(self.data[off:off + chunk]) + + # issue831: deliver via the ORIGINAL PANDA virtual-memory fast path + # (the suspected-faulty path) so the probe characterizes it. + yield from plugins.mem.write_bytes(buf_ptr, src) + + # PROBE: read the same kernel VA back two ways and compare against what + # we intended to write. + # panda_rb : PANDA cpu_memory_rw_debug read (same host translation) + # portal_rb: guest-executed portal read (the guest's REAL mapping) + # If portal_rb != src (or != panda_rb), the PANDA write landed on a + # different physical page than the guest sees -> wrong-PA confirmed. + addr = buf_ptr.address if hasattr(buf_ptr, "address") else int(buf_ptr) + self._probe_reads += 1 + idx = self._probe_reads + try: + panda_rb = yield from plugins.mem.read_bytes(buf_ptr, chunk) + portal_rb = yield from plugins.mem.read_bytes( + buf_ptr, chunk, prefer_portal=True) + except Exception as e: + self._probe_log(f"read#{idx} off={off} addr={addr:#x} chunk={chunk} EXC {e!r}") + ptregs.retval = 0 + return 0 + + flags = [] + if panda_rb != src: + flags.append("panda!=src") + if portal_rb != src: + flags.append("portal!=src") + if panda_rb != portal_rb: + flags.append("panda!=portal") + + # Always log the first few reads (confirm probe active); after that log + # only mismatches. Cap total lines. + if (idx <= 8 or flags) and self._probe_logged < 300: + def firstdiff(a, b): + if a is None or b is None: + return -1 + for i in range(min(len(a), len(b))): + if a[i] != b[i]: + return i + return len(a) if len(a) != len(b) else -1 + d_ps = firstdiff(panda_rb, src) + d_qs = firstdiff(portal_rb, src) + self._probe_log( + f"read#{idx} off={off} addr={addr:#x} chunk={chunk} " + f"flags={','.join(flags) or 'ok'} " + f"src[:8]={src[:8].hex()} panda[:8]={(panda_rb or b'')[:8].hex()} " + f"portal[:8]={(portal_rb or b'')[:8].hex()} " + f"diff_panda_src@{d_ps} diff_portal_src@{d_qs}" + ) ptregs.retval = 0 return 0 @@ -103,7 +160,9 @@ def __init__(self): plugins.devfs.register_devfs(NativeMmapDevFile()) plugins.procfs.register_proc(NativeMmapProcFile()) - plugins.mtd.register_mtd(NativeMmapMtdDevice()) + mtd_dev = NativeMmapMtdDevice() + mtd_dev.outdir = self.get_arg("outdir") # issue831 probe output dir + plugins.mtd.register_mtd(mtd_dev) syscalls = plugins.syscalls syscalls.syscall( diff --git a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml index 800e020b4..57cbbac6b 100644 --- a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml +++ b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml @@ -24,6 +24,13 @@ plugins: type: file_contains file: shared/tests/mmap_test/stdout string: "/tests/mmap_test PASS" + # issue831 PROBE: never-matching condition forces a verifier failure so + # the run uploads artifacts (incl. results/issue831_probe.txt) regardless + # of whether the heisenbug crash reproduces. Remove with the probe. + issue831_force_artifact_upload: + type: file_contains + file: shared/tests/mmap_test/stdout + string: "ISSUE831_NEVER_PRINTED_SENTINEL" static_files: /tests/mmap_test: From f4c76522b46388c5c0e040d02529aed291329636 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 08:05:33 -0400 Subject: [PATCH 08/13] issue831: restore portal-write fix; remove diagnostic probe + sentinel Revert the wrong-PA probe and the forced-failure verifier sentinel. The probe showed that whenever the PANDA write is observable (panda + portal read-back), panda==portal==src on every read incl. post-write read-backs at the same kernel VA class as the faulting run -> the 'wrong physical page' hypothesis is NOT supported; the per-read portal round-trip also perturbed the heisenbug away (mmap_test PASSed under the probe). Conclusion: the fault is in the host-side PANDA memory write (cpu_memory_rw_debug) used by the MTD read callback on ppc64, is timing/coherency-sensitive (any observation hides it), and writing via the guest-executed portal path deterministically avoids it. That portal-write fix is what remains here. --- CLAUDE.md | 181 ++++++++++++++++++ pyplugins/apis/mem.py | 11 +- pyplugins/testing/native_mmap.py | 79 +------- src/penguin.egg-info/PKG-INFO | 22 +++ src/penguin.egg-info/SOURCES.txt | 43 +++++ src/penguin.egg-info/dependency_links.txt | 1 + src/penguin.egg-info/entry_points.txt | 6 + src/penguin.egg-info/not-zip-safe | 1 + src/penguin.egg-info/requires.txt | 3 + src/penguin.egg-info/top_level.txt | 1 + .../patches/tests/native_mmap.yaml | 7 - 11 files changed, 271 insertions(+), 84 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/penguin.egg-info/PKG-INFO create mode 100644 src/penguin.egg-info/SOURCES.txt create mode 100644 src/penguin.egg-info/dependency_links.txt create mode 100644 src/penguin.egg-info/entry_points.txt create mode 100644 src/penguin.egg-info/not-zip-safe create mode 100644 src/penguin.egg-info/requires.txt create mode 100644 src/penguin.egg-info/top_level.txt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..97e2d82c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,181 @@ +# CLAUDE.md — Penguin + +This is the Penguin repo inside an IGLOO worktree workspace. The workspace +overview lives at `../CLAUDE.md`; this file covers Penguin specifically. + +Penguin is a configuration-based firmware rehosting framework (NDSS BAR 2025). +The user writes a YAML `config.yaml` describing the target; Penguin generates +or refines that config and runs the firmware under PANDA-QEMU with a stack of +pyplugins providing analysis, intervention, and instrumentation. + +## Entry points + +- **`./penguin`** — host bash wrapper (~700 lines). Handles Docker + orchestration, path rewriting (`./projects/foo` → `/host_projects/foo` + inside the container), networking (auto-allocates a `192.168.x.0/24` bridge + per run), and UID/GID mapping. Wrapper-only flags (before subcommands): + - `--build` — rebuild the container image. Picks up files from + `local_packages/` (see `../CLAUDE.md` for the contract). + - `--pydev` — live-mount `src/` → `/pkg` and `pyplugins/` → `/pandata` + inside the container and `pip install -e` Penguin before each run. Use + this when iterating on Python code. + - `--image NAME`, `--name NAME`, `--subnet [CIDR|none]`, + `--extra_docker_args ...`, `--verbose`, `--wrapper-help`. + +- **`src/penguin/`** — Python package and CLI. Major modules: + - `__main__.py` — Click CLI: `init`, `run`, `explore`, `ga_explore`, + `patch_explore`, `minimize`, `guest_cmd`, `pack`, `unpack`, `docs`, + `shell`. + - `penguin_config/` — config loading and schema. + `structure.py` is the Pydantic schema (single source of truth); + `versions/v2.py` holds the config changelog and auto-migration; + `__init__.py` loads YAML, applies patches and drop-ins, and validates. + `gen_docs.py` regenerates `docs/schema_doc.md`. + - `static_analyses.py` — pre-emulation analysis (init programs, symbols, + NVRAM keys, env vars). + - `penguin_prep.py` / `penguin_run.py` — pre-run setup and single-run + execution via `PandaRunner`. + - `plugin_manager.py` — pyplugin discovery and lifecycle. + - `gen_config.py` / `gen_image.py` — config and kernel/FS image generation. + - `manager.py`, `graph_search.py`, `genetic.py`, `patch_search.py`, + `patch_minimizer.py` — multi-iteration search/refinement strategies. + +- **`pyplugins/`** — built-in Penguin pyplugins, grouped by intent: + `actuation/`, `analysis/`, `core/`, `hyper/`, `hyperfile/`, + `interventions/`, `loggers/`, `wrappers/`, `apis/`, `compat/`, `docgen/`, + `testing/`. Each is a Python class inheriting from `penguin.Plugin`. + See `docs/pyplugin_architecture.md` and `docs/plugins.md`. + +- **`pengutils/`** — utility library (not pyplugins). Event types, DB helpers, + guest_cmd helpers, breakpoint helpers used by plugins and CLI commands. + +- **`guest-utils/`** — guest-side tools shipped into the firmware: `native/` + (C helpers), `ltrace/`, `scripts/`. These are compiled and packaged into + the Docker image at `/igloo_static/`. + +- **`Dockerfile`** — multi-stage build (Rust builder, base, downloader, + installer). The local-packages override block sits around lines 599–665. + Pinned dependency versions are ARGs at the top. + +## Dev iteration loop + +```sh +./penguin --pydev run path/to/myfw_project # edit src/ or pyplugins/ → re-run +./penguin --build run ... # changed local_packages/ or Dockerfile → rebuild +./penguin --build --pydev run ... # both +``` + +`--pydev` reinstalls the package on each run, so changes to `src/penguin/` and +`pyplugins/` apply without a rebuild. Use `--build` only when you change the +Dockerfile or drop a new file into `local_packages/`. + +When developing a sibling-repo dependency (busybox, hyperfs, kernels, PANDA, +etc.), build that repo's artifact, drop the resulting tarball / `.deb` / `.whl` +into `local_packages/`, and rebuild. The Dockerfile auto-detects each file +and uses it instead of the pinned release. Remove the file when finished so +later builds return to the pinned version. + +## Plugin authoring + +A pyplugin is a Python class extending `penguin.Plugin` with hooks like +`__init__`, `on_run`, `on_stop`, plus PyPANDA callbacks decorated with +`@panda.cb_*`. Args come from `config.yaml`'s `plugins:` section and are +read via `self.get_arg("...")` / `self.get_arg_bool("...")`. + +Plugin discovery order (`docs/pyplugin_architecture.md`): +1. `plugins.d/` inside the active project directory (local plugins). +2. `/pyplugins` inside the container (built-in plugins, mounted from + `pyplugins/` in `--pydev` mode). +3. Any path on the `plugin_path` config key. + +Local prototyping pattern: drop `myplugin.py` into your rehosting project's +`plugins.d/`, then enable it in `config.yaml` with +`plugins: { myplugin: { args... } }`. + +PANDA-plugin vs PyPANDA-plugin vs Penguin-pyplugin: +- **PANDA plugin** — native C/C++ compiled into the QEMU process. Plugin + work is being migrated into the `qemu/` fork (`panda-re/qemu`); older + plugins still live in `panda-ng/plugins/` but new ones should go in `qemu/`. + You don't edit these from Penguin. +- **PyPANDA plugin** — Python wrapper exposing PANDA callbacks. The CLI + imports `from pandare import Panda`; the runtime currently comes from the + `pandare2-*.whl` published by `panda-ng/`, which Penguin ships in its image. +- **Penguin pyplugin** — Penguin's higher-level orchestration: holds state, + reads config, may use PyPANDA callbacks internally but also exposes + hypercalls, guest-cmd actions, event APIs, etc. **This is what you + write to add behavior to Penguin.** + +## Config schema and project layout + +A rehosting project directory looks like: + +``` +projects/myfw/ + config.yaml # main config (user-editable) + base/ + fs.tar.gz # rootfs from fw2tar + env.yaml # static-analysis env vars + nvram.csv # identified NVRAM keys + initial_config.yaml # auto-generated backup + static/ # optional: hand-curated files dropped into the FS + init.d/, source.d/ # optional: init drop-ins (shell or C compiled in) + plugins.d/ # optional: local plugins + their YAML args + patch_*.yaml # optional: config patches (auto-merged) + results/0, results/1... # per-run output + results/latest -> 0 # symlink to most recent run +``` + +Major config sections (see `docs/schema_doc.md` for the full reference, which +is generated from `src/penguin/penguin_config/structure.py`): + +- `core` — arch, kernel, fs path, strace/ltrace, timeout, plugin_path. +- `env` — kernel boot args, `igloo_init` selection. +- `static_files` — pre-boot FS modifications. +- `pseudofiles` — `/dev`, `/proc`, `/sys` modeling (read/write/ioctl handlers). +- `nvram` — initial key-value pairs. +- `netdevs` — network device names. +- `plugins` — pyplugin args. + +Patches: any `patch_*.yaml` in the project root is auto-merged into +`config.yaml` at load time (`config_patchers.py`). Drop-ins under `init.d/`, +`source.d/`, `plugins.d/` are auto-discovered. + +## Tests + +- `tests/unit_tests/` — small targets exercised in CI. `test_target/` and + `basic_target/` are the main fixtures. +- `tests/comprehensive/` — full rehosting scenarios under `combined/`, `env/`, + `multiinit/`, `pseudofile/`, `search/`, `search_min/`. Runner is + `tests/comprehensive/test.sh`; expects to be invoked inside the container. + +Run from inside the workspace: +```sh +./penguin --pydev shell # drops into a container shell +# then, inside: +cd /pandata/../tests/comprehensive && ./test.sh +``` + +## Useful commands + +```sh +./penguin --wrapper-help # all wrapper flags +./penguin run --help # subcommand options +./penguin docs # browse markdown docs inside container +./penguin init path/to/rootfs.tar.gz # bootstrap a new project from a fs tarball +./penguin run projects/myfw # single run +./penguin explore projects/myfw/config.yaml # multi-iteration graph search +``` + +## Things worth knowing + +- The wrapper runs **on the host**, not in the container; everything else + runs inside the Docker image. Filesystem paths are rewritten at the + container boundary — use `--verbose` to see the exact mappings. +- `--cap-add=NET_BIND_SERVICE` is set so guests can bind low ports. +- `local_packages/*` and `results/`, `projects/`, `*.tar.gz`, `*.qcow` are all + gitignored — they're build/output artifacts, not source. +- Config schema is the source of truth: when adding a config option, change + `src/penguin/penguin_config/structure.py` and regenerate `docs/schema_doc.md` + via `python -m penguin.penguin_config.gen_docs` (or equivalent). +- Penguin's `Dockerfile` ARG block pins every external dependency version. + When bumping a sibling repo's release tag, update the corresponding ARG. diff --git a/pyplugins/apis/mem.py b/pyplugins/apis/mem.py index 6f5838f8f..40f9df678 100644 --- a/pyplugins/apis/mem.py +++ b/pyplugins/apis/mem.py @@ -210,24 +210,19 @@ def write_bytes_panda(self, cpu, addr: Union[int, Ptr], data: bytes) -> None: raise ValueError(f"Memory write failed with err={err}") # TODO: make a PANDA Exn class def read_bytes(self, addr: Union[int, Ptr], size: int, - pid: Optional[int] = None, - prefer_portal: bool = False) -> Generator[Any, Any, bytes]: + pid: Optional[int] = None) -> Generator[Any, Any, bytes]: """ Reads bytes from guest memory. Optimized with a Fast Path for single-chunk reads. - - prefer_portal: skip the PANDA fast path and read via the guest-executed - portal path (see issue #831 / write_bytes). """ if isinstance(addr, Ptr): addr = addr.address rsize = self._get_rsize() - use_panda = self.try_panda and not prefer_portal # --- FAST PATH: Single Chunk (Common Case) --- if size <= rsize: - if use_panda: + if self.try_panda: # We can assume CPU is needed here, get it once cpu = self._get_cpu() try: @@ -261,7 +256,7 @@ def read_bytes(self, addr: Union[int, Ptr], size: int, chunk_size = rsize chunk = None - if use_panda: + if self.try_panda: if cpu is None: cpu = self._get_cpu() try: diff --git a/pyplugins/testing/native_mmap.py b/pyplugins/testing/native_mmap.py index 4f27ed858..d6cb19b0c 100644 --- a/pyplugins/testing/native_mmap.py +++ b/pyplugins/testing/native_mmap.py @@ -52,23 +52,8 @@ def __init__(self): self.data = bytearray(b"\xff" * self.SIZE) initial = b"mtd native mmap\n" self.data[:len(initial)] = initial - # issue831 probe state - self.outdir = None - self._probe_reads = 0 - self._probe_logged = 0 super().__init__() - def _probe_log(self, line): - self._probe_logged += 1 - if not self.outdir: - return - try: - import os - with open(os.path.join(self.outdir, "issue831_probe.txt"), "a") as f: - f.write(line + "\n") - except Exception: - pass - def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): off = int(offset) size = int(length) @@ -76,57 +61,15 @@ def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): ptregs.retval = 0 return 0 chunk = min(size, self.SIZE - off) - src = bytes(self.data[off:off + chunk]) - - # issue831: deliver via the ORIGINAL PANDA virtual-memory fast path - # (the suspected-faulty path) so the probe characterizes it. - yield from plugins.mem.write_bytes(buf_ptr, src) - - # PROBE: read the same kernel VA back two ways and compare against what - # we intended to write. - # panda_rb : PANDA cpu_memory_rw_debug read (same host translation) - # portal_rb: guest-executed portal read (the guest's REAL mapping) - # If portal_rb != src (or != panda_rb), the PANDA write landed on a - # different physical page than the guest sees -> wrong-PA confirmed. - addr = buf_ptr.address if hasattr(buf_ptr, "address") else int(buf_ptr) - self._probe_reads += 1 - idx = self._probe_reads - try: - panda_rb = yield from plugins.mem.read_bytes(buf_ptr, chunk) - portal_rb = yield from plugins.mem.read_bytes( - buf_ptr, chunk, prefer_portal=True) - except Exception as e: - self._probe_log(f"read#{idx} off={off} addr={addr:#x} chunk={chunk} EXC {e!r}") - ptregs.retval = 0 - return 0 - - flags = [] - if panda_rb != src: - flags.append("panda!=src") - if portal_rb != src: - flags.append("portal!=src") - if panda_rb != portal_rb: - flags.append("panda!=portal") - - # Always log the first few reads (confirm probe active); after that log - # only mismatches. Cap total lines. - if (idx <= 8 or flags) and self._probe_logged < 300: - def firstdiff(a, b): - if a is None or b is None: - return -1 - for i in range(min(len(a), len(b))): - if a[i] != b[i]: - return i - return len(a) if len(a) != len(b) else -1 - d_ps = firstdiff(panda_rb, src) - d_qs = firstdiff(portal_rb, src) - self._probe_log( - f"read#{idx} off={off} addr={addr:#x} chunk={chunk} " - f"flags={','.join(flags) or 'ok'} " - f"src[:8]={src[:8].hex()} panda[:8]={(panda_rb or b'')[:8].hex()} " - f"portal[:8]={(portal_rb or b'')[:8].hex()} " - f"diff_panda_src@{d_ps} diff_portal_src@{d_qs}" - ) + # issue #831: write the read-back data through the portal (guest- + # executed) path instead of the PANDA virtual-memory fast path. On + # ppc64 the host-side virtual->physical translation of the kernel read + # buffer appears unreliable and corrupts a co-running process. + yield from plugins.mem.write_bytes( + buf_ptr, + bytes(self.data[off:off + chunk]), + prefer_portal=True, + ) ptregs.retval = 0 return 0 @@ -160,9 +103,7 @@ def __init__(self): plugins.devfs.register_devfs(NativeMmapDevFile()) plugins.procfs.register_proc(NativeMmapProcFile()) - mtd_dev = NativeMmapMtdDevice() - mtd_dev.outdir = self.get_arg("outdir") # issue831 probe output dir - plugins.mtd.register_mtd(mtd_dev) + plugins.mtd.register_mtd(NativeMmapMtdDevice()) syscalls = plugins.syscalls syscalls.syscall( diff --git a/src/penguin.egg-info/PKG-INFO b/src/penguin.egg-info/PKG-INFO new file mode 100644 index 000000000..c138d5dbc --- /dev/null +++ b/src/penguin.egg-info/PKG-INFO @@ -0,0 +1,22 @@ +Metadata-Version: 2.4 +Name: penguin +Version: 0.0.1.dev0+pydev +Summary: Automated IGLOO rehosting +Home-page: https://github.com/rehosting/penguin +Author: MIT Lincoln Laboratory +Author-email: luke.craig@mit.edu +License: MIT +Keywords: igloo +Platform: any +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +Requires-Dist: pyyaml +Requires-Dist: jsonschema +Requires-Dist: jinja2 diff --git a/src/penguin.egg-info/SOURCES.txt b/src/penguin.egg-info/SOURCES.txt new file mode 100644 index 000000000..2fc00bc18 --- /dev/null +++ b/src/penguin.egg-info/SOURCES.txt @@ -0,0 +1,43 @@ +setup.cfg +setup.py +penguin/__init__.py +penguin/__main__.py +penguin/abi_info.py +penguin/analyses.py +penguin/arch.py +penguin/common.py +penguin/compose.py +penguin/config_patchers.py +penguin/defaults.py +penguin/dropin_compile.py +penguin/gen_config.py +penguin/gen_image.py +penguin/genetic.py +penguin/graph_search.py +penguin/graphs.py +penguin/llm.py +penguin/manager.py +penguin/patch_minimizer.py +penguin/patch_search.py +penguin/penguin_prep.py +penguin/penguin_run.py +penguin/plugin_manager.py +penguin/q_config.py +penguin/search_utils.py +penguin/static_analyses.py +penguin/utils.py +penguin/utils_cli.py +penguin.egg-info/PKG-INFO +penguin.egg-info/SOURCES.txt +penguin.egg-info/dependency_links.txt +penguin.egg-info/entry_points.txt +penguin.egg-info/not-zip-safe +penguin.egg-info/requires.txt +penguin.egg-info/top_level.txt +penguin/penguin_config/__init__.py +penguin/penguin_config/errors.py +penguin/penguin_config/gen_docs.py +penguin/penguin_config/structure.py +penguin/penguin_config/templating.py +penguin/penguin_config/versions/__init__.py +penguin/penguin_config/versions/v2.py \ No newline at end of file diff --git a/src/penguin.egg-info/dependency_links.txt b/src/penguin.egg-info/dependency_links.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/penguin.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/penguin.egg-info/entry_points.txt b/src/penguin.egg-info/entry_points.txt new file mode 100644 index 000000000..794ec7022 --- /dev/null +++ b/src/penguin.egg-info/entry_points.txt @@ -0,0 +1,6 @@ +[console_scripts] +gen_config = penguin.gen_config:main +gen_image = penguin.gen_image:makeImage +penguin = penguin.__main__:cli +penguin_mgr = penguin.manager:main +penguin_run = penguin.penguin_run:main diff --git a/src/penguin.egg-info/not-zip-safe b/src/penguin.egg-info/not-zip-safe new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/penguin.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/src/penguin.egg-info/requires.txt b/src/penguin.egg-info/requires.txt new file mode 100644 index 000000000..7afbd6d20 --- /dev/null +++ b/src/penguin.egg-info/requires.txt @@ -0,0 +1,3 @@ +pyyaml +jsonschema +jinja2 diff --git a/src/penguin.egg-info/top_level.txt b/src/penguin.egg-info/top_level.txt new file mode 100644 index 000000000..e659768e0 --- /dev/null +++ b/src/penguin.egg-info/top_level.txt @@ -0,0 +1 @@ +penguin diff --git a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml index 57cbbac6b..800e020b4 100644 --- a/tests/unit_tests/test_target/patches/tests/native_mmap.yaml +++ b/tests/unit_tests/test_target/patches/tests/native_mmap.yaml @@ -24,13 +24,6 @@ plugins: type: file_contains file: shared/tests/mmap_test/stdout string: "/tests/mmap_test PASS" - # issue831 PROBE: never-matching condition forces a verifier failure so - # the run uploads artifacts (incl. results/issue831_probe.txt) regardless - # of whether the heisenbug crash reproduces. Remove with the probe. - issue831_force_artifact_upload: - type: file_contains - file: shared/tests/mmap_test/stdout - string: "ISSUE831_NEVER_PRINTED_SENTINEL" static_files: /tests/mmap_test: From f6544a822c1983427c3b72017ac92d9f6332b96a Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 08:05:47 -0400 Subject: [PATCH 09/13] issue831: drop accidentally-committed CLAUDE.md and egg-info build artifacts --- CLAUDE.md | 181 ---------------------- src/penguin.egg-info/PKG-INFO | 22 --- src/penguin.egg-info/SOURCES.txt | 43 ----- src/penguin.egg-info/dependency_links.txt | 1 - src/penguin.egg-info/entry_points.txt | 6 - src/penguin.egg-info/not-zip-safe | 1 - src/penguin.egg-info/requires.txt | 3 - src/penguin.egg-info/top_level.txt | 1 - 8 files changed, 258 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 src/penguin.egg-info/PKG-INFO delete mode 100644 src/penguin.egg-info/SOURCES.txt delete mode 100644 src/penguin.egg-info/dependency_links.txt delete mode 100644 src/penguin.egg-info/entry_points.txt delete mode 100644 src/penguin.egg-info/not-zip-safe delete mode 100644 src/penguin.egg-info/requires.txt delete mode 100644 src/penguin.egg-info/top_level.txt diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 97e2d82c1..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,181 +0,0 @@ -# CLAUDE.md — Penguin - -This is the Penguin repo inside an IGLOO worktree workspace. The workspace -overview lives at `../CLAUDE.md`; this file covers Penguin specifically. - -Penguin is a configuration-based firmware rehosting framework (NDSS BAR 2025). -The user writes a YAML `config.yaml` describing the target; Penguin generates -or refines that config and runs the firmware under PANDA-QEMU with a stack of -pyplugins providing analysis, intervention, and instrumentation. - -## Entry points - -- **`./penguin`** — host bash wrapper (~700 lines). Handles Docker - orchestration, path rewriting (`./projects/foo` → `/host_projects/foo` - inside the container), networking (auto-allocates a `192.168.x.0/24` bridge - per run), and UID/GID mapping. Wrapper-only flags (before subcommands): - - `--build` — rebuild the container image. Picks up files from - `local_packages/` (see `../CLAUDE.md` for the contract). - - `--pydev` — live-mount `src/` → `/pkg` and `pyplugins/` → `/pandata` - inside the container and `pip install -e` Penguin before each run. Use - this when iterating on Python code. - - `--image NAME`, `--name NAME`, `--subnet [CIDR|none]`, - `--extra_docker_args ...`, `--verbose`, `--wrapper-help`. - -- **`src/penguin/`** — Python package and CLI. Major modules: - - `__main__.py` — Click CLI: `init`, `run`, `explore`, `ga_explore`, - `patch_explore`, `minimize`, `guest_cmd`, `pack`, `unpack`, `docs`, - `shell`. - - `penguin_config/` — config loading and schema. - `structure.py` is the Pydantic schema (single source of truth); - `versions/v2.py` holds the config changelog and auto-migration; - `__init__.py` loads YAML, applies patches and drop-ins, and validates. - `gen_docs.py` regenerates `docs/schema_doc.md`. - - `static_analyses.py` — pre-emulation analysis (init programs, symbols, - NVRAM keys, env vars). - - `penguin_prep.py` / `penguin_run.py` — pre-run setup and single-run - execution via `PandaRunner`. - - `plugin_manager.py` — pyplugin discovery and lifecycle. - - `gen_config.py` / `gen_image.py` — config and kernel/FS image generation. - - `manager.py`, `graph_search.py`, `genetic.py`, `patch_search.py`, - `patch_minimizer.py` — multi-iteration search/refinement strategies. - -- **`pyplugins/`** — built-in Penguin pyplugins, grouped by intent: - `actuation/`, `analysis/`, `core/`, `hyper/`, `hyperfile/`, - `interventions/`, `loggers/`, `wrappers/`, `apis/`, `compat/`, `docgen/`, - `testing/`. Each is a Python class inheriting from `penguin.Plugin`. - See `docs/pyplugin_architecture.md` and `docs/plugins.md`. - -- **`pengutils/`** — utility library (not pyplugins). Event types, DB helpers, - guest_cmd helpers, breakpoint helpers used by plugins and CLI commands. - -- **`guest-utils/`** — guest-side tools shipped into the firmware: `native/` - (C helpers), `ltrace/`, `scripts/`. These are compiled and packaged into - the Docker image at `/igloo_static/`. - -- **`Dockerfile`** — multi-stage build (Rust builder, base, downloader, - installer). The local-packages override block sits around lines 599–665. - Pinned dependency versions are ARGs at the top. - -## Dev iteration loop - -```sh -./penguin --pydev run path/to/myfw_project # edit src/ or pyplugins/ → re-run -./penguin --build run ... # changed local_packages/ or Dockerfile → rebuild -./penguin --build --pydev run ... # both -``` - -`--pydev` reinstalls the package on each run, so changes to `src/penguin/` and -`pyplugins/` apply without a rebuild. Use `--build` only when you change the -Dockerfile or drop a new file into `local_packages/`. - -When developing a sibling-repo dependency (busybox, hyperfs, kernels, PANDA, -etc.), build that repo's artifact, drop the resulting tarball / `.deb` / `.whl` -into `local_packages/`, and rebuild. The Dockerfile auto-detects each file -and uses it instead of the pinned release. Remove the file when finished so -later builds return to the pinned version. - -## Plugin authoring - -A pyplugin is a Python class extending `penguin.Plugin` with hooks like -`__init__`, `on_run`, `on_stop`, plus PyPANDA callbacks decorated with -`@panda.cb_*`. Args come from `config.yaml`'s `plugins:` section and are -read via `self.get_arg("...")` / `self.get_arg_bool("...")`. - -Plugin discovery order (`docs/pyplugin_architecture.md`): -1. `plugins.d/` inside the active project directory (local plugins). -2. `/pyplugins` inside the container (built-in plugins, mounted from - `pyplugins/` in `--pydev` mode). -3. Any path on the `plugin_path` config key. - -Local prototyping pattern: drop `myplugin.py` into your rehosting project's -`plugins.d/`, then enable it in `config.yaml` with -`plugins: { myplugin: { args... } }`. - -PANDA-plugin vs PyPANDA-plugin vs Penguin-pyplugin: -- **PANDA plugin** — native C/C++ compiled into the QEMU process. Plugin - work is being migrated into the `qemu/` fork (`panda-re/qemu`); older - plugins still live in `panda-ng/plugins/` but new ones should go in `qemu/`. - You don't edit these from Penguin. -- **PyPANDA plugin** — Python wrapper exposing PANDA callbacks. The CLI - imports `from pandare import Panda`; the runtime currently comes from the - `pandare2-*.whl` published by `panda-ng/`, which Penguin ships in its image. -- **Penguin pyplugin** — Penguin's higher-level orchestration: holds state, - reads config, may use PyPANDA callbacks internally but also exposes - hypercalls, guest-cmd actions, event APIs, etc. **This is what you - write to add behavior to Penguin.** - -## Config schema and project layout - -A rehosting project directory looks like: - -``` -projects/myfw/ - config.yaml # main config (user-editable) - base/ - fs.tar.gz # rootfs from fw2tar - env.yaml # static-analysis env vars - nvram.csv # identified NVRAM keys - initial_config.yaml # auto-generated backup - static/ # optional: hand-curated files dropped into the FS - init.d/, source.d/ # optional: init drop-ins (shell or C compiled in) - plugins.d/ # optional: local plugins + their YAML args - patch_*.yaml # optional: config patches (auto-merged) - results/0, results/1... # per-run output - results/latest -> 0 # symlink to most recent run -``` - -Major config sections (see `docs/schema_doc.md` for the full reference, which -is generated from `src/penguin/penguin_config/structure.py`): - -- `core` — arch, kernel, fs path, strace/ltrace, timeout, plugin_path. -- `env` — kernel boot args, `igloo_init` selection. -- `static_files` — pre-boot FS modifications. -- `pseudofiles` — `/dev`, `/proc`, `/sys` modeling (read/write/ioctl handlers). -- `nvram` — initial key-value pairs. -- `netdevs` — network device names. -- `plugins` — pyplugin args. - -Patches: any `patch_*.yaml` in the project root is auto-merged into -`config.yaml` at load time (`config_patchers.py`). Drop-ins under `init.d/`, -`source.d/`, `plugins.d/` are auto-discovered. - -## Tests - -- `tests/unit_tests/` — small targets exercised in CI. `test_target/` and - `basic_target/` are the main fixtures. -- `tests/comprehensive/` — full rehosting scenarios under `combined/`, `env/`, - `multiinit/`, `pseudofile/`, `search/`, `search_min/`. Runner is - `tests/comprehensive/test.sh`; expects to be invoked inside the container. - -Run from inside the workspace: -```sh -./penguin --pydev shell # drops into a container shell -# then, inside: -cd /pandata/../tests/comprehensive && ./test.sh -``` - -## Useful commands - -```sh -./penguin --wrapper-help # all wrapper flags -./penguin run --help # subcommand options -./penguin docs # browse markdown docs inside container -./penguin init path/to/rootfs.tar.gz # bootstrap a new project from a fs tarball -./penguin run projects/myfw # single run -./penguin explore projects/myfw/config.yaml # multi-iteration graph search -``` - -## Things worth knowing - -- The wrapper runs **on the host**, not in the container; everything else - runs inside the Docker image. Filesystem paths are rewritten at the - container boundary — use `--verbose` to see the exact mappings. -- `--cap-add=NET_BIND_SERVICE` is set so guests can bind low ports. -- `local_packages/*` and `results/`, `projects/`, `*.tar.gz`, `*.qcow` are all - gitignored — they're build/output artifacts, not source. -- Config schema is the source of truth: when adding a config option, change - `src/penguin/penguin_config/structure.py` and regenerate `docs/schema_doc.md` - via `python -m penguin.penguin_config.gen_docs` (or equivalent). -- Penguin's `Dockerfile` ARG block pins every external dependency version. - When bumping a sibling repo's release tag, update the corresponding ARG. diff --git a/src/penguin.egg-info/PKG-INFO b/src/penguin.egg-info/PKG-INFO deleted file mode 100644 index c138d5dbc..000000000 --- a/src/penguin.egg-info/PKG-INFO +++ /dev/null @@ -1,22 +0,0 @@ -Metadata-Version: 2.4 -Name: penguin -Version: 0.0.1.dev0+pydev -Summary: Automated IGLOO rehosting -Home-page: https://github.com/rehosting/penguin -Author: MIT Lincoln Laboratory -Author-email: luke.craig@mit.edu -License: MIT -Keywords: igloo -Platform: any -Classifier: Development Status :: 4 - Beta -Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved :: MIT License -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.8 -Classifier: Programming Language :: Python :: 3.9 -Classifier: Programming Language :: Python :: 3.10 -Requires-Python: >=3.8 -Description-Content-Type: text/markdown -Requires-Dist: pyyaml -Requires-Dist: jsonschema -Requires-Dist: jinja2 diff --git a/src/penguin.egg-info/SOURCES.txt b/src/penguin.egg-info/SOURCES.txt deleted file mode 100644 index 2fc00bc18..000000000 --- a/src/penguin.egg-info/SOURCES.txt +++ /dev/null @@ -1,43 +0,0 @@ -setup.cfg -setup.py -penguin/__init__.py -penguin/__main__.py -penguin/abi_info.py -penguin/analyses.py -penguin/arch.py -penguin/common.py -penguin/compose.py -penguin/config_patchers.py -penguin/defaults.py -penguin/dropin_compile.py -penguin/gen_config.py -penguin/gen_image.py -penguin/genetic.py -penguin/graph_search.py -penguin/graphs.py -penguin/llm.py -penguin/manager.py -penguin/patch_minimizer.py -penguin/patch_search.py -penguin/penguin_prep.py -penguin/penguin_run.py -penguin/plugin_manager.py -penguin/q_config.py -penguin/search_utils.py -penguin/static_analyses.py -penguin/utils.py -penguin/utils_cli.py -penguin.egg-info/PKG-INFO -penguin.egg-info/SOURCES.txt -penguin.egg-info/dependency_links.txt -penguin.egg-info/entry_points.txt -penguin.egg-info/not-zip-safe -penguin.egg-info/requires.txt -penguin.egg-info/top_level.txt -penguin/penguin_config/__init__.py -penguin/penguin_config/errors.py -penguin/penguin_config/gen_docs.py -penguin/penguin_config/structure.py -penguin/penguin_config/templating.py -penguin/penguin_config/versions/__init__.py -penguin/penguin_config/versions/v2.py \ No newline at end of file diff --git a/src/penguin.egg-info/dependency_links.txt b/src/penguin.egg-info/dependency_links.txt deleted file mode 100644 index 8b1378917..000000000 --- a/src/penguin.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/penguin.egg-info/entry_points.txt b/src/penguin.egg-info/entry_points.txt deleted file mode 100644 index 794ec7022..000000000 --- a/src/penguin.egg-info/entry_points.txt +++ /dev/null @@ -1,6 +0,0 @@ -[console_scripts] -gen_config = penguin.gen_config:main -gen_image = penguin.gen_image:makeImage -penguin = penguin.__main__:cli -penguin_mgr = penguin.manager:main -penguin_run = penguin.penguin_run:main diff --git a/src/penguin.egg-info/not-zip-safe b/src/penguin.egg-info/not-zip-safe deleted file mode 100644 index 8b1378917..000000000 --- a/src/penguin.egg-info/not-zip-safe +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/penguin.egg-info/requires.txt b/src/penguin.egg-info/requires.txt deleted file mode 100644 index 7afbd6d20..000000000 --- a/src/penguin.egg-info/requires.txt +++ /dev/null @@ -1,3 +0,0 @@ -pyyaml -jsonschema -jinja2 diff --git a/src/penguin.egg-info/top_level.txt b/src/penguin.egg-info/top_level.txt deleted file mode 100644 index e659768e0..000000000 --- a/src/penguin.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -penguin From 51b9233d46ccca6328a6bca5ce1d94a273a0d28c Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 09:45:46 -0400 Subject: [PATCH 10/13] DIAG(issue831): isolate MTD callback from native-mmap machinery Single CI run, two contemporaneous data points (both use the PANDA write path): - native_mmap: revert fix -> PANDA write, full native-mmap machinery intact (qemu_mem aperture + dev/proc/anon mmap). BASELINE, expect red. - mtd_only (new minimal plugin+test): one OOP MTD device on the PANDA write path, NO qemu_mem aperture, NO dev/proc/anon mmap, NO micropython -- just the cat|strings|grep read-back. If native_mmap red but mtd_only green -> native-mmap/qemu_mem machinery is required to trigger the fault. If mtd_only also red -> it is the MTD callback's PANDA write itself, independent of native mmap. Temporary; restore fix after. --- pyplugins/testing/mtd_only_repro.py | 66 +++++++++++++++++++ pyplugins/testing/native_mmap.py | 8 +-- tests/unit_tests/test_target/base_config.yaml | 1 + .../test_target/patches/tests/mtd_only.yaml | 22 +++++++ 4 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 pyplugins/testing/mtd_only_repro.py create mode 100644 tests/unit_tests/test_target/patches/tests/mtd_only.yaml diff --git a/pyplugins/testing/mtd_only_repro.py b/pyplugins/testing/mtd_only_repro.py new file mode 100644 index 000000000..0deed9843 --- /dev/null +++ b/pyplugins/testing/mtd_only_repro.py @@ -0,0 +1,66 @@ +"""issue831 experiment: MTD OOP callback device in ISOLATION. + +Registers a single object-oriented MTD device whose read callback delivers +data via the PANDA virtual-memory write fast path (plugins.mem.write_bytes, +NO prefer_portal) -- the suspected-faulty path -- with NO native-mmap / +qemu_mem machinery involved (no aperture, no dev/proc/anon SUPPORT_MMAP files, +no micropython mmap). Used to test whether the ppc64 read-back segfault is +traceable to the native-mmap code or to the MTD callback itself. +""" +from penguin import Plugin, plugins +from hyperfile.models.base import MtdDevice, LoffT, SizeT, CharPtr + + +class MtdOnlyDevice(MtdDevice): + NAME = "mtd_only_repro" + SIZE = 64 * 1024 + ERASE_SIZE = 4096 + WRITE_SIZE = 1 + OOB_SIZE = 0 + TYPE = "nor" + + def __init__(self): + self.data = bytearray(b"\xff" * self.SIZE) + initial = b"mtd only base\n" + self.data[:len(initial)] = initial + super().__init__() + + def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): + off = int(offset) + size = int(length) + if off >= self.SIZE: + ptregs.retval = 0 + return 0 + chunk = min(size, self.SIZE - off) + # PANDA virtual-memory write fast path (the suspected-faulty path). + yield from plugins.mem.write_bytes(buf_ptr, bytes(self.data[off:off + chunk])) + ptregs.retval = 0 + return 0 + + def write(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): + off = int(offset) + size = int(length) + if off >= self.SIZE: + ptregs.retval = -28 + return -28 + chunk = min(size, self.SIZE - off) + raw = yield from plugins.mem.read(buf_ptr, chunk, fmt="bytes") + self.data[off:off + chunk] = raw + ptregs.retval = 0 + return 0 + + def erase(self, ptregs, offset: LoffT, length: SizeT): + off = int(offset) + size = int(length) + if off >= self.SIZE: + ptregs.retval = -22 + return -22 + chunk = min(size, self.SIZE - off) + self.data[off:off + chunk] = b"\xff" * chunk + ptregs.retval = 0 + return 0 + + +class MtdOnlyRepro(Plugin): + def __init__(self): + plugins.mtd.register_mtd(MtdOnlyDevice()) diff --git a/pyplugins/testing/native_mmap.py b/pyplugins/testing/native_mmap.py index d6cb19b0c..3d24098b1 100644 --- a/pyplugins/testing/native_mmap.py +++ b/pyplugins/testing/native_mmap.py @@ -61,14 +61,12 @@ def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): ptregs.retval = 0 return 0 chunk = min(size, self.SIZE - off) - # issue #831: write the read-back data through the portal (guest- - # executed) path instead of the PANDA virtual-memory fast path. On - # ppc64 the host-side virtual->physical translation of the kernel read - # buffer appears unreliable and corrupts a co-running process. + # issue831 EXPERIMENT: temporarily reverted to the PANDA write fast path + # so this run reproduces the baseline fault alongside the isolated + # mtd_only test. Restore prefer_portal=True after the experiment. yield from plugins.mem.write_bytes( buf_ptr, bytes(self.data[off:off + chunk]), - prefer_portal=True, ) ptregs.retval = 0 return 0 diff --git a/tests/unit_tests/test_target/base_config.yaml b/tests/unit_tests/test_target/base_config.yaml index 8ca4c4f62..6f7b2a334 100644 --- a/tests/unit_tests/test_target/base_config.yaml +++ b/tests/unit_tests/test_target/base_config.yaml @@ -40,6 +40,7 @@ patches: - ./patches/tests/pseudofile_missing.yaml - ./patches/tests/pseudofile_mmap_shared.yaml - ./patches/tests/native_mmap.yaml + - ./patches/tests/mtd_only.yaml - ./patches/tests/pseudofile_readdir.yaml - ./patches/tests/pseudofile_sysfs.yaml - ./patches/tests/shared_dir.yaml diff --git a/tests/unit_tests/test_target/patches/tests/mtd_only.yaml b/tests/unit_tests/test_target/patches/tests/mtd_only.yaml new file mode 100644 index 000000000..686c0ee33 --- /dev/null +++ b/tests/unit_tests/test_target/patches/tests/mtd_only.yaml @@ -0,0 +1,22 @@ +plugins: + mtd_only_repro: {} + verifier: + conditions: + mtd_only_done: + type: file_contains + file: shared/tests/mtd_only/stdout + string: "mtd only PASS" + +static_files: + /tests/mtd_only: + type: inline_file + mode: 73 + contents: | + #!/igloo/utils/sh + set -eux + /igloo/utils/busybox mdev -s + MTD=$(/igloo/utils/busybox grep '"mtd_only_repro"' /proc/mtd | /igloo/utils/busybox cut -d: -f1) + /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd only base" + /igloo/utils/busybox echo "mtd only changed" > /dev/${MTD} + /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd only changed" + /igloo/utils/busybox echo "mtd only PASS" From 99b376e34825db55f9b592c7797c269386e8a67c Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Wed, 10 Jun 2026 20:16:39 -0400 Subject: [PATCH 11/13] issue831: restore portal-write fix; remove isolation-experiment scaffolding The mtd_only isolation test was inconclusive: adding it to the suite shifted timing/layout enough to perturb the heisenbug away (baseline native_mmap with the PANDA write also passed). Differential testing via suite/script edits is confounded by the bug's fragility. Revert the experiment and keep the host-side portal-write fix; correct the code comments to the honest mechanism (timing-sensitive fault in the PANDA host write; not a confirmed wrong-PA). --- pyplugins/apis/mem.py | 9 +-- pyplugins/testing/mtd_only_repro.py | 66 ------------------- pyplugins/testing/native_mmap.py | 9 ++- tests/unit_tests/test_target/base_config.yaml | 1 - .../test_target/patches/tests/mtd_only.yaml | 22 ------- 5 files changed, 11 insertions(+), 96 deletions(-) delete mode 100644 pyplugins/testing/mtd_only_repro.py delete mode 100644 tests/unit_tests/test_target/patches/tests/mtd_only.yaml diff --git a/pyplugins/apis/mem.py b/pyplugins/apis/mem.py index 40f9df678..6e25af4b1 100644 --- a/pyplugins/apis/mem.py +++ b/pyplugins/apis/mem.py @@ -129,10 +129,11 @@ def write_bytes(self, addr: Union[int, Ptr], data: bytes, pid : int, optional Process ID for context. prefer_portal : bool, optional - Skip the PANDA virtual-memory fast path and write via the portal - (guest-executed) path instead. Use for writes to addresses whose - host-side virtual->physical translation is unreliable (e.g. ppc64 - kernel buffers); see issue #831. + Skip the PANDA virtual-memory write fast path and write via the + portal (guest-executed) path instead. Use for writes the PANDA + host-side path handles unreliably (e.g. writes into a guest kernel + buffer on ppc64, which trigger a timing-sensitive fault); see + issue #831. Returns ------- diff --git a/pyplugins/testing/mtd_only_repro.py b/pyplugins/testing/mtd_only_repro.py deleted file mode 100644 index 0deed9843..000000000 --- a/pyplugins/testing/mtd_only_repro.py +++ /dev/null @@ -1,66 +0,0 @@ -"""issue831 experiment: MTD OOP callback device in ISOLATION. - -Registers a single object-oriented MTD device whose read callback delivers -data via the PANDA virtual-memory write fast path (plugins.mem.write_bytes, -NO prefer_portal) -- the suspected-faulty path -- with NO native-mmap / -qemu_mem machinery involved (no aperture, no dev/proc/anon SUPPORT_MMAP files, -no micropython mmap). Used to test whether the ppc64 read-back segfault is -traceable to the native-mmap code or to the MTD callback itself. -""" -from penguin import Plugin, plugins -from hyperfile.models.base import MtdDevice, LoffT, SizeT, CharPtr - - -class MtdOnlyDevice(MtdDevice): - NAME = "mtd_only_repro" - SIZE = 64 * 1024 - ERASE_SIZE = 4096 - WRITE_SIZE = 1 - OOB_SIZE = 0 - TYPE = "nor" - - def __init__(self): - self.data = bytearray(b"\xff" * self.SIZE) - initial = b"mtd only base\n" - self.data[:len(initial)] = initial - super().__init__() - - def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): - off = int(offset) - size = int(length) - if off >= self.SIZE: - ptregs.retval = 0 - return 0 - chunk = min(size, self.SIZE - off) - # PANDA virtual-memory write fast path (the suspected-faulty path). - yield from plugins.mem.write_bytes(buf_ptr, bytes(self.data[off:off + chunk])) - ptregs.retval = 0 - return 0 - - def write(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): - off = int(offset) - size = int(length) - if off >= self.SIZE: - ptregs.retval = -28 - return -28 - chunk = min(size, self.SIZE - off) - raw = yield from plugins.mem.read(buf_ptr, chunk, fmt="bytes") - self.data[off:off + chunk] = raw - ptregs.retval = 0 - return 0 - - def erase(self, ptregs, offset: LoffT, length: SizeT): - off = int(offset) - size = int(length) - if off >= self.SIZE: - ptregs.retval = -22 - return -22 - chunk = min(size, self.SIZE - off) - self.data[off:off + chunk] = b"\xff" * chunk - ptregs.retval = 0 - return 0 - - -class MtdOnlyRepro(Plugin): - def __init__(self): - plugins.mtd.register_mtd(MtdOnlyDevice()) diff --git a/pyplugins/testing/native_mmap.py b/pyplugins/testing/native_mmap.py index 3d24098b1..c0b1f13c8 100644 --- a/pyplugins/testing/native_mmap.py +++ b/pyplugins/testing/native_mmap.py @@ -61,12 +61,15 @@ def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): ptregs.retval = 0 return 0 chunk = min(size, self.SIZE - off) - # issue831 EXPERIMENT: temporarily reverted to the PANDA write fast path - # so this run reproduces the baseline fault alongside the isolated - # mtd_only test. Restore prefer_portal=True after the experiment. + # issue #831: deliver the read-back via the guest-executed portal path + # rather than the PANDA virtual-memory write fast path. On ppc64 the + # PANDA host-side write (cpu_memory_rw_debug) into the guest kernel + # read buffer triggers a timing-sensitive fault that segfaults the + # cat|strings|grep read-back; the portal path avoids that host write. yield from plugins.mem.write_bytes( buf_ptr, bytes(self.data[off:off + chunk]), + prefer_portal=True, ) ptregs.retval = 0 return 0 diff --git a/tests/unit_tests/test_target/base_config.yaml b/tests/unit_tests/test_target/base_config.yaml index 6f7b2a334..8ca4c4f62 100644 --- a/tests/unit_tests/test_target/base_config.yaml +++ b/tests/unit_tests/test_target/base_config.yaml @@ -40,7 +40,6 @@ patches: - ./patches/tests/pseudofile_missing.yaml - ./patches/tests/pseudofile_mmap_shared.yaml - ./patches/tests/native_mmap.yaml - - ./patches/tests/mtd_only.yaml - ./patches/tests/pseudofile_readdir.yaml - ./patches/tests/pseudofile_sysfs.yaml - ./patches/tests/shared_dir.yaml diff --git a/tests/unit_tests/test_target/patches/tests/mtd_only.yaml b/tests/unit_tests/test_target/patches/tests/mtd_only.yaml deleted file mode 100644 index 686c0ee33..000000000 --- a/tests/unit_tests/test_target/patches/tests/mtd_only.yaml +++ /dev/null @@ -1,22 +0,0 @@ -plugins: - mtd_only_repro: {} - verifier: - conditions: - mtd_only_done: - type: file_contains - file: shared/tests/mtd_only/stdout - string: "mtd only PASS" - -static_files: - /tests/mtd_only: - type: inline_file - mode: 73 - contents: | - #!/igloo/utils/sh - set -eux - /igloo/utils/busybox mdev -s - MTD=$(/igloo/utils/busybox grep '"mtd_only_repro"' /proc/mtd | /igloo/utils/busybox cut -d: -f1) - /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd only base" - /igloo/utils/busybox echo "mtd only changed" > /dev/${MTD} - /igloo/utils/busybox cat /dev/${MTD} | /igloo/utils/busybox strings | /igloo/utils/busybox grep "mtd only changed" - /igloo/utils/busybox echo "mtd only PASS" From 2bf7fe1f97e573ed0cc6b75bcb36fb3714b13691 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Thu, 11 Jun 2026 07:29:40 -0400 Subject: [PATCH 12/13] DEBUG(issue831): pull instrumented dev_issue831 QEMU + force PANDA write path Temporary CI debug branch for rehosting/qemu#9. QEMU_VERSION=dev_issue831 pulls the instrumented penguin-qemu build; native_mmap forces the original PANDA host-write path (prefer_portal=False) so the ppc64 fault reproduces in CI under the instrumented QEMU. NOT for merge: revert both before landing. --- Dockerfile | 2 +- pyplugins/testing/native_mmap.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6ade466f1..d0594e97d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ ARG LTRACE_PROTOTYPES_HASH="9db3bdee7cf3e11c87d8cc7673d4d25b" ARG MUSL_VERSION="1.2.5" ARG VHOST_DEVICE_VERSION="vhost-device-vsock-v0.2.0" ARG FW2TAR_TAG="v2.0.6" -ARG QEMU_VERSION="0.0.6" +ARG QEMU_VERSION="dev_issue831" ARG RIPGREP_VERSION="14.1.1" ARG APT_MIRROR="ubuntu" diff --git a/pyplugins/testing/native_mmap.py b/pyplugins/testing/native_mmap.py index c0b1f13c8..a44d75877 100644 --- a/pyplugins/testing/native_mmap.py +++ b/pyplugins/testing/native_mmap.py @@ -69,7 +69,10 @@ def read(self, ptregs, offset: LoffT, length: SizeT, buf_ptr: CharPtr): yield from plugins.mem.write_bytes( buf_ptr, bytes(self.data[off:off + chunk]), - prefer_portal=True, + # issue831 CI debug: force the ORIGINAL PANDA host-write path so the + # ppc64 fault reproduces and the instrumented dev_issue831 QEMU can + # observe it. Revert to prefer_portal=True (the fix) before merging. + prefer_portal=False, ) ptregs.retval = 0 return 0 From c5b15dd8f6a5adaad277b9e81650937f7cd576b1 Mon Sep 17 00:00:00 2001 From: Luke Craig Date: Thu, 11 Jun 2026 12:10:32 -0400 Subject: [PATCH 13/13] DEBUG(issue831): trigger CI on push to this branch (PR to main conflicts) The branch is based on config-reshape-patches and conflicts with main, so the pull_request event can't build a merge ref and CI never runs. Add a push trigger for this branch. Dockerfile comment touched so the push delta counts as 'code' and run_tests fires. Remove before merge. --- .github/workflows/build.yaml | 8 ++++++++ Dockerfile | 1 + 2 files changed, 9 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9f9ef28d4..5c0e35327 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,6 +1,14 @@ name: Test Container on: + # issue831 CI debug: this branch (issue831-ci-debug) is based on + # config-reshape-patches and conflicts with main, so a pull_request event + # can't build a merge ref and never runs CI. Trigger on direct pushes to the + # branch instead, which run against the branch head. Remove before merge. + push: + branches: + - issue831-ci-debug + workflow_dispatch: pull_request: branches: - main diff --git a/Dockerfile b/Dockerfile index d0594e97d..7f56d8ad8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ ARG LTRACE_PROTOTYPES_HASH="9db3bdee7cf3e11c87d8cc7673d4d25b" ARG MUSL_VERSION="1.2.5" ARG VHOST_DEVICE_VERSION="vhost-device-vsock-v0.2.0" ARG FW2TAR_TAG="v2.0.6" +# issue831 CI debug: pull the instrumented dev build (release vdev_issue831). ARG QEMU_VERSION="dev_issue831" ARG RIPGREP_VERSION="14.1.1" ARG APT_MIRROR="ubuntu"