Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,8 @@ broke and why, without grepping):
- a sandboxed functional test of the timezone autodetect dhcpcd hook
([`scripts/test-timezone.sh`](scripts/test-timezone.sh)) — it turns a string off the
network into a filesystem path, so every class of hostile answer is asserted rejected,
run twice: under the host shell and under the target's own BusyBox `ash` via `qemu-arm`
run three times: under the host shell, under the target's own `bash --posix` via `qemu-arm`
(what `/bin/sh` is on the box, as on stock), and under the target's BusyBox `ash`

Alongside it, [`scripts/check-abi.sh`](scripts/check-abi.sh) runs the full
SONAME/loader checklist from [`docs/abi-contract.md`](docs/abi-contract.md). The overlap on
Expand Down
6 changes: 6 additions & 0 deletions configs/fragments/de10nano-image.fragment
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,12 @@ BR2_PACKAGE_WPA_SUPPLICANT_WPA3=y
BR2_PACKAGE_WPA_SUPPLICANT_CLI=y
BR2_PACKAGE_WPA_SUPPLICANT_PASSPHRASE=y
BR2_PACKAGE_BASH=y
# WARNING: BR2_SYSTEM_BIN_SH_BASH depends on BR2_PACKAGE_BUSYBOX_SHOW_OTHERS
# (§5.15 above); without it kconfig drops this line silently and /bin/sh falls
# back to BusyBox ash. Stock parity: Buildroot's skeleton finalize hook makes
# /bin/sh -> bash AND sets root's login shell to /bin/bash from this one symbol
# (issue #144; docs §5.19).
BR2_SYSTEM_BIN_SH_BASH=y
BR2_PACKAGE_DIALOG=y
BR2_PACKAGE_WIRELESS_TOOLS=y
BR2_PACKAGE_WIRELESS_TOOLS_IWCONFIG=y
Expand Down
2 changes: 1 addition & 1 deletion configs/fragments/golden.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# scripts/check-config-fragments.sh --update-golden
# and say in the commit message what changed and why. Columns:
# <BUILDROOT_VERSION> <stack> <sha256>
2026.05.2 de10nano 3cb8e3d9b32d701c4e3eefa1b9993da9b76a73a29a3b29f57924c10b807138a1
2026.05.2 de10nano 251ae321011945e690a32f2fbb704b619681fd26d06acf578f7691e76286f992
2026.05.2 de10nano-kernel 5b372abe43c46c4eb5f3c88d51e48ff2eb6fa713b3c02f0f337c39d0f111dc69
2026.05.2 de25nano 2adb6b4029bee4467053403fdcf290042ccd2b554cc126de9a2f7a155b433b28
2026.05.2 rt e34a522a56eb088ac84b1e6db7350a22f12293d0d84086dbf1f405f68a58b7e5
23 changes: 23 additions & 0 deletions docs/buildroot-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,29 @@ overlay's `70-persistent-net.rules` pre-up depends on it),
`BR2_PACKAGE_IPROUTE2=y` (stock's `/usr/sbin/ip` — wifi.sh's link up/down
fallback).

`BR2_SYSTEM_BIN_SH_BASH=y` — **`/bin/sh` is bash, and so is root's login
shell** (issue #144). Stock has `/bin/sh -> bash` and
`root:x:0:0:root:/root:/bin/bash`; until this symbol was added we shipped
Buildroot's defaults, BusyBox ash as `/bin/sh` and root on `/bin/sh`, with
nothing recording the difference. That matters in two places: a user script
in `/media/fat/Scripts` with a `#!/bin/sh` shebang and bash syntax runs on
stock and may not on ash (`docs/stock-reconciliation.md`'s `usr/bin/timidity`
row is the one case that was checked by hand — its `function` keyword happens
to be in ash's bash-compat set), and an interactive root session over ssh or
the console gets ash's line editing and no bash history. One symbol does both
halves: Buildroot's `SKELETON_INIT_COMMON_SET_BIN_SH` finalize hook runs
`ln -sf bash /bin/sh` **and** `sed '/^root:/s,[^/]*$,bash,' /etc/passwd`
(`package/skeleton-init-common/skeleton-init-common.mk`), so no post-build
edit is needed and the result is byte-for-byte stock's layout. The symbol
depends on `BR2_PACKAGE_BUSYBOX_SHOW_OTHERS` (§5.15, already on) — without
it kconfig silently drops the choice back to BusyBox, which is why the
fragment carries a WARNING next to it. BusyBox's own `ash` applet stays
built and listed in `/etc/shells`, exactly as on stock; only what `sh`
resolves to changes. Scripts invoked as `sh` get bash in POSIX mode
(`argv[0]` is `sh`), same as stock. `scripts/ci-tests.sh` asserts both
halves against `rootfs.tar` (P3.4 section) and runs the dhcpcd timezone hook
under the target's bash in POSIX mode as well as under ash.

### 5.20 On-device text editors, ifupdown, BusyBox fragment, dhcpcd, ntp, cifs

Editors (stock parity): stock ships `usr/bin/joe`, `usr/bin/nano` AND
Expand Down
16 changes: 10 additions & 6 deletions docs/decisions/0025-first-boot-timezone-autodetect.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ to add traffic silently, hence the disclosure above, the FAQ entry, and the opt-

`scripts/test-timezone.sh` — a sandboxed functional test (no build, no board, no network:
paths rewritten into a temp dir, `curl` stubbed, and the hook sourced exactly as
`dhcpcd-run-hooks` sources it — under `$TZ_TEST_SH`, so the second CI leg genuinely parses
and runs it as the target's BusyBox `ash`). 16 cases / 60 assertions covering the
`dhcpcd-run-hooks` sources it — under `$TZ_TEST_SH`, so the target CI legs genuinely parse
and run it as the target's own shells). 16 cases / 60 assertions covering the
happy path, the never-overwrite rule (including that an *empty* timezone file counts as
unset, so a half-written card self-heals, and that a timezone set *while the lookup runs*
is not clobbered), the once-and-only-once contract, the opt-out stamp, nine classes of
Expand All @@ -170,10 +170,14 @@ which dhcpcd reasons fire it and which do not, `if_up` handled as data rather th
`exit`ing dhcpcd's hook run, that it leaks nothing into the shell that sourced it, and that
the sourcing shell returns while the lookup is still in flight.

`scripts/ci-tests.sh`'s Timezone section runs it **twice** — once under the host shell,
once under the target's own BusyBox `ash` via `qemu-arm`, since this is a boot-path script
and dash-accepts-it is not the same claim as ash-accepts-it — and additionally asserts the
script ships executable and that `curl`, its one runtime dependency, is still in the image.
`scripts/ci-tests.sh`'s Timezone section runs it **three times** — once under the host
shell, once under the target's own `bash --posix` via `qemu-arm`, once under the target's
BusyBox `ash` — since this is a boot-path script and dash-accepts-it is not the same claim
as the-box's-shell-accepts-it — and additionally asserts the script ships executable and
that `curl`, its one runtime dependency, is still in the image. (Addendum 2026-09-03: this
ADR originally said *twice*, host + ash, because `/bin/sh` was BusyBox ash at the time.
Issue #144 made `/bin/sh` bash as on stock, so the shell that actually sources this hook
on the box is bash in POSIX mode; the ash leg is kept as the stricter interpreter.)

**Not yet verified on hardware**: the on-device first-boot path (real card, real DHCP)
has not been exercised. That belongs to the next hardware validation pass.
1 change: 1 addition & 0 deletions docs/init-parity.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/stock-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ marked CLOSED here.
| `usr/sbin/vmode` | **B — CLOSED** | Vendored byte-identical. Writes `fb_cmd0/fb_cmd1` to `/dev/MiSTer_cmd` — a FIFO **Main creates** (`input.cpp:4051`), commands handled at `input.cpp:6236` — and polls `/sys/module/MiSTer_fb/parameters/res_count`, which exists because `CONFIG_FB_MISTER=y` and `MiSTer_fb.c:38` declares `module_param(res_count, uint, 0444)`. bash + busybox `usleep` both present. |
| `usr/bin/vhd_mount` | **B — CLOSED** | Vendored byte-identical. Mechanism verified end-to-end on *our* stack: busybox `losetup` attaches, the cmdline's `loop.max_part=8` (`docs/boot-chain.md:155`) makes `/dev/loop1p1` appear, busybox `mount` mounts it on `/media/rootfs` — which must pre-exist on a read-only `/`, so the overlay now ships `media/rootfs/` (`.gitkeep` idiom, same as `/media/fat`). Stock's image has the same dir (`work/imgroot/media/rootfs`). |
| `usr/bin/m3u_play` | **B — CLOSED** | Vendored byte-identical. mp3 branch works (`mpg123` shipped); the vgm/vgz branches reference `vgmplay`, deliberately not shipped (below) — on a vgm playlist the script prints `vgmplay: not found`, exactly the graceful-degradation stock had for absent optional players. |
| `usr/bin/timidity` | **B — CLOSED (recon error corrected)** | The first reconciliation pass classified this "audio player (mixed)" as if it were TiMidity++ needing a new package. **It is a 22-line `/bin/sh` wrapper** (read from `addon.tar`): fluidsynth (ALSA-seq, `/media/fat/linux/soundfonts/SC-55.sf2`) + `aplaymidi --port 128 $MC_EXT_SELECTED`. Both callees shipped; `$MC_EXT_SELECTED` is exported by mc on every ext.ini action (`mc-4.8.33 src/filemanager/ext.c:205`), so it also works with mc 4.8.33's stock `[midi]` handler (whose `sound.sh` execs `timidity` — i.e. this wrapper). Its `function ctrl_c()` bashism is legal in our `/bin/sh`: busybox 1.38 ash with `CONFIG_ASH_BASH_COMPAT=y` compiles the `function` keyword in (`shell/ash.c:223,9054`; `.config:1148`). Vendored byte-identical. |
| `usr/bin/timidity` | **B — CLOSED (recon error corrected)** | The first reconciliation pass classified this "audio player (mixed)" as if it were TiMidity++ needing a new package. **It is a 22-line `/bin/sh` wrapper** (read from `addon.tar`): fluidsynth (ALSA-seq, `/media/fat/linux/soundfonts/SC-55.sf2`) + `aplaymidi --port 128 $MC_EXT_SELECTED`. Both callees shipped; `$MC_EXT_SELECTED` is exported by mc on every ext.ini action (`mc-4.8.33 src/filemanager/ext.c:205`), so it also works with mc 4.8.33's stock `[midi]` handler (whose `sound.sh` execs `timidity` — i.e. this wrapper). Its `function ctrl_c()` bashism is legal in our `/bin/sh`: since issue #144 `/bin/sh` is bash, as on stock (`BR2_SYSTEM_BIN_SH_BASH=y`, `docs/buildroot-config.md` §5.19), and bash accepts `function` in POSIX mode — this file was written for bash all along, and the earlier note that busybox 1.38 ash's `CONFIG_ASH_BASH_COMPAT=y` happened to tolerate it described the accommodation, not the design. Vendored byte-identical. |
| `usr/bin/vgmplay`, `usr/bin/VGMPlay.ini` | **C — DECLINED, documented** | Real gap, deliberate decline. VGMPlay (vgmrips/vgmplay, GPL) has no Buildroot package; writing one here could not be build-verified (T3 runs under a no-build constraint), and an untested C package is a worse outcome than an honest absence — it risks breaking `make all` for a niche feature (VGM chiptune playback inside mc). Cost of absence: mc's vgm handler and `m3u_play`'s vgm branch print `not found`; nothing else references it. Revisit as its own small task if VGM playback is ever asked for: pin a release tarball, `Makefile`-type package, install `VGMPlay.ini` beside the binary (it looks for its ini next to `argv[0]`). |
| `usr/bin/memtool` | **A — CLOSED** | Not a sourceless blob after all: strings on the stock ELF are pengutronix memtool's exact usage text, and `addon.tar`'s `usr/bin/md`/`mw` are symlinks → `memtool` (argv[0] dispatch — `memtool.c:475` in the pinned 2018.03.0 tarball switches on `basename(argv[0])`). `BR2_PACKAGE_MEMTOOL=y` (Buildroot's own package, same upstream, 2018.03.0 — upstream's last release, confirmed against pengutronix's release directory). The package installs only the `memtool` binary, so stock's `md`/`mw` symlinks are reproduced in the overlay. |
| `usr/bin/fpga` | **D — INFEASIBLE, precisely bounded** | Stripped ARM ELF; **no public source found**: GitHub code search for its distinctive strings finds only `Main_MiSTer/fpga_io.cpp` (which shares the literal `"FPGA: Unaligned data, realign to 32bit boundary."`, `fpga_io.cpp:352`) and u-boot's `drivers/fpga/socfpga.c`; no MiSTer-devel repo builds a standalone `fpga` binary. It is a dev-era peek/poke + RBF loader (`Usage(1): %s { address } [ data ]`, `Usage(2): %s { rbf_file }`, mmaps `/dev/mem`). Shipping the blob violates rule G6; recreating it from `fpga_io.cpp` would be new, untestable systems code. **Intent is covered**: core loading = `echo load_core <file> > /dev/MiSTer_cmd` (Main, `input.cpp:6238-6242` — and mc's Enter-on-`.rbf` now does exactly that); peek/poke = `memtool md`/`mw` (above) or busybox `devmem`. Same documentation standard as `docs/firmware-parity.md`'s no-upstream-source firmware. |
Expand Down
66 changes: 54 additions & 12 deletions scripts/ci-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,29 @@ else
fail "iwgetid present (symlink to iwconfig)" "not in rootfs.tar"
fi

# /bin/sh and root's login shell are bash, as on stock (issue #144,
# docs/buildroot-config.md §5.19). One symbol (BR2_SYSTEM_BIN_SH_BASH) drives
# both through Buildroot's skeleton finalize hook, and kconfig drops that
# symbol SILENTLY if BR2_PACKAGE_BUSYBOX_SHOW_OTHERS ever goes away -- the
# build stays green and /bin/sh quietly becomes BusyBox ash again. Usr-merged
# rootfs: the tar records /bin/sh only as ./usr/bin/sh.
sh_line=$(tar tvf "$ROOTFS_TAR" -- "./usr/bin/sh" 2>/dev/null | head -1)
case "$sh_line" in
*'-> bash')
pass "/bin/sh -> bash (stock parity, BR2_SYSTEM_BIN_SH_BASH)" ;;
'')
fail "/bin/sh -> bash (stock parity, BR2_SYSTEM_BIN_SH_BASH)" "usr/bin/sh not in rootfs.tar" ;;
*)
fail "/bin/sh -> bash (stock parity, BR2_SYSTEM_BIN_SH_BASH)" "is: $sh_line" ;;
esac
root_line=$(tar xOf "$ROOTFS_TAR" "./etc/passwd" 2>/dev/null | grep -m1 '^root:')
case "$root_line" in
root:*:/bin/bash)
pass "root's login shell is /bin/bash (stock parity)" ;;
*)
fail "root's login shell is /bin/bash (stock parity)" "passwd: ${root_line:-no root line}" ;;
esac

# =============================================================================
section "T2 — WiFi hotplug (70-persistent-net.rules, docs/wifi-parity.md §9)"
# =============================================================================
Expand Down Expand Up @@ -1747,22 +1770,41 @@ else
"one or more cases failed -- see output above"
fi

# ...and again under the shell that will ACTUALLY run it on the box. The host's
# /bin/sh (dash, on the CI runner) is a good POSIX proxy for BusyBox ash, but it
# is not the same interpreter, and this is a boot-path script: a construct dash
# accepts and ash does not would fail on hardware and nowhere else.
# ...and again under the target's own interpreters. The host's /bin/sh (dash,
# on the CI runner) is a good POSIX proxy, but it is not what runs on the box,
# and this is a boot-path script: a construct dash accepts and the real shell
# does not would fail on hardware and nowhere else. On the box /bin/sh is bash
# (issue #144, stock parity), invoked through the `sh` symlink so it runs in
# POSIX mode -- `bash --posix` is the same thing spelled explicitly. BusyBox
# ash is still shipped and still a valid /etc/shells entry, so keep that run
# too: it is the stricter interpreter, and the hook must not depend on bash.
if [ -z "$QEMU_ARM" ]; then
skip "test-timezone.sh under the target's own bash --posix (/bin/sh)" "qemu-arm not found on PATH"
skip "test-timezone.sh under the target's own BusyBox ash" "qemu-arm not found on PATH"
elif [ ! -x "$TARGET/bin/busybox" ]; then
skip "test-timezone.sh under the target's own BusyBox ash" "$TARGET/bin/busybox not present"
else
printf -- '--- test-timezone.sh: same cases, target BusyBox ash under qemu-arm ---\n'
if TZ_TEST_SH="$QEMU_ARM -L $TARGET $TARGET/bin/busybox sh" \
"$ROOT/scripts/test-timezone.sh"; then
pass "test-timezone.sh under the target's own BusyBox ash"
if [ ! -x "$TARGET/usr/bin/bash" ]; then
skip "test-timezone.sh under the target's own bash --posix (/bin/sh)" "$TARGET/usr/bin/bash not present"
else
fail "test-timezone.sh under the target's own BusyBox ash" \
"passes on the host shell but not on BusyBox ash -- see output above"
printf -- '--- test-timezone.sh: same cases, target bash --posix (what /bin/sh is on the box) under qemu-arm ---\n'
if TZ_TEST_SH="$QEMU_ARM -L $TARGET $TARGET/usr/bin/bash --posix" \
"$ROOT/scripts/test-timezone.sh"; then
pass "test-timezone.sh under the target's own bash --posix (/bin/sh)"
else
fail "test-timezone.sh under the target's own bash --posix (/bin/sh)" \
"passes on the host shell but not on the target's bash -- see output above"
fi
fi
if [ ! -x "$TARGET/bin/busybox" ]; then
skip "test-timezone.sh under the target's own BusyBox ash" "$TARGET/bin/busybox not present"
else
printf -- '--- test-timezone.sh: same cases, target BusyBox ash under qemu-arm ---\n'
if TZ_TEST_SH="$QEMU_ARM -L $TARGET $TARGET/bin/busybox sh" \
"$ROOT/scripts/test-timezone.sh"; then
pass "test-timezone.sh under the target's own BusyBox ash"
else
fail "test-timezone.sh under the target's own BusyBox ash" \
"passes on the host shell but not on BusyBox ash -- see output above"
fi
fi
fi

Expand Down
35 changes: 33 additions & 2 deletions scripts/test-initramfs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,22 @@ ensure_qemu_kernel() {
log "configuring: multi_v7_defconfig + $KERNEL_FRAGMENT"
make -C "$KERNEL_SRC" O="$KBUILD" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" \
multi_v7_defconfig
"$KERNEL_SRC/scripts/kconfig/merge_config.sh" -O "$KBUILD" \
"$KBUILD/.config" "$KERNEL_FRAGMENT" >&2
# merge_config.sh finishes with a BARE `make ... alldefconfig` in the
# CURRENT directory -- it has no -C. Run from this repo's root (the
# normal way to invoke this script) that `make` hits the wrapper
# Makefile, which forwards `alldefconfig` to Buildroot, which fails
# ("Can't read seed configuration"), and merge_config.sh exits before
# writing the merged .config. Every fragment symbol the base defconfig
# already had looks fine; every one it lacked (CONFIG_EXFAT_FS,
# CONFIG_FAT_DEFAULT_UTF8) is silently missing, and the three exFAT
# cases fail with "mount: No such device" for a reason that looks
# nothing like this. So: cd into the kernel tree, give its make the
# ARCH it needs, and refuse to continue if the merge fails. The
# fragment-survival check after olddefconfig below is the backstop.
(cd "$KERNEL_SRC" && ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" \
scripts/kconfig/merge_config.sh -O "$KBUILD" \
"$KBUILD/.config" "$KERNEL_FRAGMENT" >&2) \
|| die "merge_config.sh failed for $KERNEL_FRAGMENT"
elif [ ! -d "$KERNEL_SRC" ]; then
die "$KBUILD exists but its source tree $KERNEL_SRC does not." \
"Remove $KBUILD (or set TEST_INITRAMFS_KBUILD to a fresh path) and re-run."
Expand All @@ -217,6 +231,23 @@ ensure_qemu_kernel() {
make -C "$KERNEL_SRC" O="$KBUILD" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" \
olddefconfig >&2

# Every `CONFIG_X=y` the fragment asks for must be in the resolved config,
# on a fresh bootstrap AND on a cached $KBUILD (a cache made by a run whose
# merge silently failed -- see above -- keeps its broken .config forever,
# because the merge only happens on bootstrap). Fail with the fix spelled
# out rather than let the exFAT cases fail on a symptom.
missing=""
while IFS= read -r sym; do
grep -qx "$sym" "$KBUILD/.config" || missing="$missing $sym"
done <<-EOF
$(grep -E '^CONFIG_[A-Z0-9_]+=y$' "$KERNEL_FRAGMENT")
EOF
[ -z "$missing" ] || die \
"QEMU test kernel .config lacks fragment symbol(s):$missing" \
"(from $KERNEL_FRAGMENT). If $KBUILD is a cache from before the" \
"merge_config.sh cwd fix, remove it (rm -rf $KBUILD) and re-run to" \
"bootstrap a correct one."

log "building QEMU test kernel zImage (embedding $(basename "$CPIO"))"
make -C "$KERNEL_SRC" O="$KBUILD" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" \
-j"$(nproc)" zImage >&2
Expand Down
Loading
Loading