From 0b7708ab0ecd23e7c28142b4c4037560d8169c30 Mon Sep 17 00:00:00 2001 From: "Michael C. Ferguson" Date: Thu, 3 Sep 2026 11:10:55 -0500 Subject: [PATCH 1/4] de10nano: /bin/sh and root's login shell are bash, as on stock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock ships /bin/sh -> bash and root:x:0:0:root:/root:/bin/bash. We shipped Buildroot's defaults -- BusyBox ash as /bin/sh, root on /bin/sh -- because no fragment ever set BR2_SYSTEM_BIN_SH, and nothing recorded the difference. Bash was already in the image for wifi.sh. Consequences of the gap: a user script in /media/fat/Scripts with a #!/bin/sh shebang and bash syntax runs on stock and may not here, and an interactive root session gets ash instead of bash (WinSCP's error text in #142 literally says "BASH is recommended"). One symbol, BR2_SYSTEM_BIN_SH_BASH=y, gives stock's exact layout: Buildroot's SKELETON_INIT_COMMON_SET_BIN_SH finalize hook both re-links /bin/sh and rewrites root's passwd shell. No overlay or post-build edit needed. It depends on BUSYBOX_SHOW_OTHERS (already on); the fragment carries a WARNING because kconfig would drop it silently otherwise. - de10nano golden.sha256 line regenerated (the only stack that changes) - docs/buildroot-config.md §5.19 rationale; docs/init-parity.md new row - ci-tests.sh asserts ./usr/bin/sh -> bash and root's passwd shell from rootfs.tar, and runs the dhcpcd timezone hook under the target's own bash --posix (what /bin/sh now is on the box) as well as BusyBox ash Verified: make all -> output/target/bin/sh -> bash, root:/bin/bash, both in rootfs.tar; ci-tests new checks PASS; check-linux-img.sh all assertions passed; check-config-fragments OK. Fixes #144 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vq3yLoYntjiJ1wUaAdruLU --- configs/fragments/de10nano-image.fragment | 5 ++ configs/fragments/golden.sha256 | 2 +- docs/buildroot-config.md | 23 ++++++++ docs/init-parity.md | 1 + scripts/ci-tests.sh | 66 ++++++++++++++++++----- 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/configs/fragments/de10nano-image.fragment b/configs/fragments/de10nano-image.fragment index 13ccda4a..2a62932c 100644 --- a/configs/fragments/de10nano-image.fragment +++ b/configs/fragments/de10nano-image.fragment @@ -246,6 +246,11 @@ BR2_PACKAGE_WPA_SUPPLICANT_WPA3=y BR2_PACKAGE_WPA_SUPPLICANT_CLI=y BR2_PACKAGE_WPA_SUPPLICANT_PASSPHRASE=y BR2_PACKAGE_BASH=y +# WARNING: BIN_SH_BASH needs BUSYBOX_SHOW_OTHERS (§5.15 above) or kconfig drops +# it 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 diff --git a/configs/fragments/golden.sha256 b/configs/fragments/golden.sha256 index 500a85a7..26f61e57 100644 --- a/configs/fragments/golden.sha256 +++ b/configs/fragments/golden.sha256 @@ -4,7 +4,7 @@ # scripts/check-config-fragments.sh --update-golden # and say in the commit message what changed and why. Columns: # -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 diff --git a/docs/buildroot-config.md b/docs/buildroot-config.md index 0dcec471..a115bbdb 100644 --- a/docs/buildroot-config.md +++ b/docs/buildroot-config.md @@ -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 diff --git a/docs/init-parity.md b/docs/init-parity.md index fc702bca..fdf18a93 100644 --- a/docs/init-parity.md +++ b/docs/init-parity.md @@ -151,6 +151,7 @@ regardless, so the worst case is slow, never weak. | `/etc/dhcpcd.conf` | **identical** | Byte-for-byte match. The package's own default differs meaningfully (`#hostname`/`#clientid` instead of stock's enabled `hostname`/`clientid`, `duid` instead of stock's `#duid`, and is missing the `option rapid_commit` block) — all reverted to stock via the overlay. | | `/etc/inittab` | **adapted** (3 documented deviations) | Full stock shape reproduced (`::sysinit:/media/fat/MiSTer &`, `/etc/resync &`, `rcS`, shutdown sequence) with: **(1)** the remount-rw sysinit line kept **commented out**, exactly as stock has it — Buildroot's own skeleton default inittab ships this line **uncommented**, which would remount `/` rw at every sysinit and defeat the whole read-only-root design (ADR 0011); confirmed this project's own finalize hook tries to uncomment it too (see below) and is overridden by the overlay running last. **(2)** the serial console runs `ttyS0::respawn:/sbin/agetty --nohostname -L ttyS0 115200 vt100` — util-linux `agetty`, matching stock (whose inittab also uses `agetty --nohostname`), now that `BR2_PACKAGE_UTIL_LINUX_AGETTY` is enabled (see `docs/util-linux-parity.md`). It still targets `ttyS0` explicitly rather than stock's `console` alias, because this board's actual cmdline is `console=ttyS0,115200` (`docs/boot-chain.md`). agetty treats a numeric positional argument as the baud rate, so `ttyS0 115200` and `115200 ttyS0` are equivalent; the port-first order is kept for continuity with the previous BusyBox `getty` line. (Earlier revisions used BusyBox `getty` because `BR2_PACKAGE_UTIL_LINUX_BINARIES` was not selected — that constraint no longer holds; the overlapping BusyBox `getty` applet is now disabled so `agetty` is the console.) **(3)** `gpm` is invoked at `/usr/sbin/gpm`, not stock's `/sbin/gpm` — originally **a real bug caught during acceptance verification**: gpm is a real package (not a BusyBox applet, which lands under `/sbin` regardless of usr-merge), and when this row was first written (P2.3) the rootfs was **not** usr-merged — `/sbin` was a real directory, not `-> /usr/sbin` the way stock's `work/imgroot/sbin` is — so `/sbin/gpm` genuinely did not exist in the image. An inittab `sysinit` line is an **absolute path**, so unlike `/etc/profile`'s `PATH` it gets no shell search at all; it would have silently failed to spawn on every single boot. Caught by exhaustively grep'ing every absolute path referenced anywhere in the overlay against the actually-built image (see the task report) — first build had this wrong; fixed and rebuilt before acceptance. **Since P2.9 v2** (`7be9ee5`) set `BR2_ROOTFS_MERGED_USR=y`, that premise no longer holds: this build is now usr-merged like stock (verified: `output/target/sbin -> usr/sbin`), so `/sbin/gpm` *would* resolve today. **The deviation is kept anyway, deliberately.** Buildroot builds gpm with `--prefix=/usr`, so the binary's real path is `/usr/sbin/gpm` in *either* layout (gpm's own file list: `gpm,./usr/sbin/gpm`) — stock's binary is at that same physical path, and stock's inittab only reaches it through the `/sbin -> usr/sbin` compat symlink that exists solely because stock is usr-merged. So our path is correct in both layouts and stock's is correct in only one; ours survives an unmerge, stock's would break on one. Given P2.3 already got bitten by exactly that (the unmerged build had no `/sbin/gpm` at all), pointing at the real path rather than a merge-dependent alias is the more durable choice, even though a future unmerge is unlikely. This is an **accepted permanent deviation from stock**, not a cleanup waiting to happen. **Also dropped — later RESTORED, guarded (T3, 2026-07-27):** `loadkeys /etc/kbd.map` and `setfont` were dropped by P2.3 because this BusyBox build has no `loadkeys` applet at all and `CONFIG_SETFONT` is explicitly not set (still true — re-verified in `output/build/busybox-1.38.0/.config`; busybox's `loadkmap` applet is not a substitute, it reads binary bkeymap, not stock's text keymap); keeping either line verbatim would just fail every boot. T3 vendored stock's `etc/kbd.map` (it blanks the F12/Mute/Vol± keycodes Main_MiSTer consumes via evdev) and restored both lines wrapped in `[ -x /usr/bin/... ]` guards: with the parallel T5 task's `BR2_PACKAGE_KBD` (the same package stock's own loadkeys/setfont came from) the lines do stock's exact job, without it they are silent no-ops instead of boot errors. See the inittab's own note 3 and `docs/stock-reconciliation.md` §3c. `gpm -m /dev/input/mice -t imps2` itself **is** kept (just at the corrected path) — `BR2_PACKAGE_GPM=y` was deliberately selected in P2.1 for this, and the binary is present. | | `/etc/profile` | **adapted** (1 documented deviation) | Full stock content reproduced (PATH, `PS1='$(pwd)# '`, `EDITOR=/bin/vi`, `/etc/profile.d/*.sh` sourcing, `LC_ALL=en_US.UTF-8`, and critically the login-time `mount -o remount,rw /` — this is how `/` ever becomes writable at all, matching stock and ADR 0011's own description of the mechanism). **Deviation:** `PATH` gains an explicit `/bin:/sbin:` prefix stock's literal string doesn't have. Stock's rootfs is usr-merged (`work/imgroot`: `/bin -> usr/bin`, `/sbin -> usr/sbin`), so its `PATH="/usr/bin:/usr/sbin"` already covered `/bin`/`/sbin` for free. When this deviation was introduced (P2.3) *this* build was a plain (non-merged) skeleton layout, so omitting `/bin:/sbin` from `PATH` would have silently dropped most BusyBox applets from every interactive shell; changing `BR2_ROOTFS_MERGED_USR` was then out of P2.3's scope ("do NOT disturb ... the package set"), making the overlay `PATH` the correct-altitude fix. **Since P2.9 v2** (`7be9ee5`) set `BR2_ROOTFS_MERGED_USR=y` — the change that fixed the `/lib/security/pam_unix.so` SSH lockout — this build is usr-merged too, so the `/bin:/sbin:` prefix is now redundant. It is kept because it is harmless (the paths resolve to the same directories) and matches Buildroot's own skeleton default profile. The file's own header comment records the same history. | +| `/etc/passwd` (root's shell) + `/bin/sh` | **identical (since issue #144)** | Stock: `root:x:0:0:root:/root:/bin/bash` and `/bin/sh -> bash`. Until 2026-09-03 this build shipped Buildroot's defaults — BusyBox ash as `/bin/sh` and root on `/bin/sh` — because no fragment set `BR2_SYSTEM_BIN_SH`, and no row here recorded it. Found while chasing #142 (WinSCP), which was a separate `/etc/profile` defect but whose error text ("BASH is recommended") pointed at the shell. Fixed with `BR2_SYSTEM_BIN_SH_BASH=y` in `de10nano-image.fragment`: Buildroot's skeleton finalize hook both re-links `/bin/sh` and rewrites root's passwd shell from that one symbol, so the result matches stock with no overlay or post-build edit. `docs/buildroot-config.md` §5.19 has the rationale; `scripts/ci-tests.sh` asserts both halves. | | `/usr/lib/dhcpcd/dhcpcd-hooks/90-timezone` | **added (divergence, [ADR 0025](decisions/0025-first-boot-timezone-autodetect.md))** | Stock has no equivalent, and that is the gap it closes: `/etc/localtime` points at `/media/fat/linux/timezone`, which **does not exist on a fresh card**, so glibc falls back to UTC silently and permanently. The first time dhcpcd brings an interface up with an address, this asks `ip-api.com` for the zone of the box's public IP and copies `/usr/share/zoneinfo/posix/` to that path — the *same* provider, destination and file format as the community `Scripts_MiSTer/timezone.sh` "Automatic" mode, so the two are interchangeable. No new package (`curl` and tzdata were already in the image). **Note this adds no init script**: an earlier revision had an `S48timezone` as well, but on a DHCP box the lease has usually not landed by S48, so it was near-redundant with this hook — and it carried a `/proc/net/route` check whose IPv6 arm silently matched the kernel's own `ip6_null_entry`. A static-IP box configured only in `/etc/network/interfaces` never runs dhcpcd and so never autodetects; accepted deliberately, since setting a static address is already a by-hand act. Properties worth stating because they are the design: the guess is spent **once, and only when it was actually made** (gated on the timezone file *and* on a `timezone.autodetect` stamp written only when a provider answered *with a zone name*, so neither being offline nor a captive portal's HTTP 200 burns it), it **never overwrites** a timezone anyone has already set (re-checked immediately before the write, not only at the gate), and it **delays nothing** — the body is a backgrounded subshell, which also keeps it from leaking a single variable or function into dhcpcd's shell. Sourced, not executed (`dhcpcd-run-hooks`: `. "$hook"`), hence no shebang, no exec bit, and no `exit` — an `exit` here would end dhcpcd's whole hook run and take `20-resolv.conf`/`30-hostname` with it. Zone names arrive off the network, so they are validated against the shipped zoneinfo before being used as a path; `scripts/test-timezone.sh` asserts each rejection, and mutation-checks the two that could otherwise pass vacuously. | | `/usr/lib/dhcpcd/dhcpcd-hooks/` (the set) | **pinned (build-host independence)** | dhcpcd's `configure` chooses which hooks to install by probing the **build host** for `ntpd`/`chronyd`/`systemd-timesyncd`/`ypbind`, and Buildroot's `dhcpcd.mk` passes no `--with-hooks`, so the image inherited whatever daemons the build machine happened to have: a GitHub runner produced `50-ntp.conf`, a developer box with timesyncd produced `50-timesyncd.conf` — with a green build both times (found 2026-09-02 by diffing a CI image against a local one). `external.mk` now appends `--with-hooks=ntp.conf --with-eghooks=yp.conf` to `DHCPCD_CONFIG_OPTS`, which reproduces the canonical CI image exactly (`01-test`, `20-resolv.conf`, `30-hostname`, `50-ntp.conf`, plus our `90-timezone`; `50-yp.conf` stays an example under `/usr/share/dhcpcd/hooks`), and `scripts/check-linux-img.sh` asserts that exact set. `50-ntp.conf` is the right hook: we ship classic `ntpd` (stock parity) and it writes DHCP-offered servers into `/etc/ntp.conf`. Upstream-worthy: Buildroot's `dhcpcd.mk` should pass `--with-hooks` itself. | | `/etc/resync` | **identical** | Byte-for-byte match (53 bytes: `( while [ 1 ]; do sync; sleep 5; done ) &`). Executable bit set. | diff --git a/scripts/ci-tests.sh b/scripts/ci-tests.sh index 761b8e18..6e3dd1c2 100755 --- a/scripts/ci-tests.sh +++ b/scripts/ci-tests.sh @@ -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 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 '^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)" # ============================================================================= @@ -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 From 37ac6be1d4dcbad6e7c1fa6c230751fa98bf365c Mon Sep 17 00:00:00 2001 From: "Michael C. Ferguson" Date: Thu, 3 Sep 2026 11:27:32 -0500 Subject: [PATCH 2/4] PR #145 review: full Kconfig symbol names in comments; first-match root grep Copilot review nits: spell out BR2_SYSTEM_BIN_SH_BASH / BR2_PACKAGE_BUSYBOX_SHOW_OTHERS in the fragment WARNING and the ci-tests comment so they grep; take only the first root: line from passwd so a malformed file fails the check legibly instead of confusing the case. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vq3yLoYntjiJ1wUaAdruLU --- configs/fragments/de10nano-image.fragment | 9 +++++---- scripts/ci-tests.sh | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/configs/fragments/de10nano-image.fragment b/configs/fragments/de10nano-image.fragment index 2a62932c..5f2f5554 100644 --- a/configs/fragments/de10nano-image.fragment +++ b/configs/fragments/de10nano-image.fragment @@ -246,10 +246,11 @@ BR2_PACKAGE_WPA_SUPPLICANT_WPA3=y BR2_PACKAGE_WPA_SUPPLICANT_CLI=y BR2_PACKAGE_WPA_SUPPLICANT_PASSPHRASE=y BR2_PACKAGE_BASH=y -# WARNING: BIN_SH_BASH needs BUSYBOX_SHOW_OTHERS (§5.15 above) or kconfig drops -# it 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). +# 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 diff --git a/scripts/ci-tests.sh b/scripts/ci-tests.sh index 6e3dd1c2..b86bbb2c 100755 --- a/scripts/ci-tests.sh +++ b/scripts/ci-tests.sh @@ -715,9 +715,9 @@ 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 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. +# 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') @@ -727,7 +727,7 @@ case "$sh_line" in *) 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 '^root:') +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)" ;; From e90b85df2414155f45066ebaf3c16cd9de907740 Mon Sep 17 00:00:00 2001 From: "Michael C. Ferguson" Date: Thu, 3 Sep 2026 11:39:15 -0500 Subject: [PATCH 3/4] docs: /bin/sh is bash now -- retire the "target shell is ash" statements Follow-up to BR2_SYSTEM_BIN_SH_BASH (#144): an audit of every #!/bin/sh file, udev RUN+= line, inittab entry, sourced dhcpcd hook and profile.d snippet on the target found nothing that behaves differently under bash in POSIX mode (all parse under `bash --posix -n`; the one bashism in the tree, usr/bin/timidity's `function`, is a stock file written for bash). It did find five statements that still call BusyBox ash the shell that runs things on the box: - docs/stock-reconciliation.md timidity row: the ash BASH_COMPAT note was the accommodation, not the design - README.md, scripts/test-timezone.sh header/comments, ADR 0025 (dated addendum): the timezone hook test now runs three times -- host shell, target bash --posix (what /bin/sh is on the box), target BusyBox ash Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vq3yLoYntjiJ1wUaAdruLU --- README.md | 3 ++- .../0025-first-boot-timezone-autodetect.md | 16 ++++++++++------ docs/stock-reconciliation.md | 2 +- scripts/test-timezone.sh | 15 +++++++++------ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 89c29e6b..945ab3af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/decisions/0025-first-boot-timezone-autodetect.md b/docs/decisions/0025-first-boot-timezone-autodetect.md index f9e364bc..099d793c 100644 --- a/docs/decisions/0025-first-boot-timezone-autodetect.md +++ b/docs/decisions/0025-first-boot-timezone-autodetect.md @@ -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 @@ -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. diff --git a/docs/stock-reconciliation.md b/docs/stock-reconciliation.md index 070aed27..36d34cbe 100644 --- a/docs/stock-reconciliation.md +++ b/docs/stock-reconciliation.md @@ -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 > /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. | diff --git a/scripts/test-timezone.sh b/scripts/test-timezone.sh index 1b385b72..e763c6d5 100755 --- a/scripts/test-timezone.sh +++ b/scripts/test-timezone.sh @@ -32,14 +32,17 @@ # # Usage: scripts/test-timezone.sh # Exit 0 iff every case passed. Wired into scripts/ci-tests.sh's Timezone -# section, which runs it twice: once under the host shell, and once under the -# target's own BusyBox ash via qemu-arm (TZ_TEST_SH, below) -- the shell that -# will actually run this on the box. The host's /bin/sh is usually dash, which -# is a good POSIX proxy but is not the same interpreter. +# section, which runs it three times: under the host shell, under the target's +# own bash --posix via qemu-arm (TZ_TEST_SH, below) -- /bin/sh on the box is +# bash as on stock (issue #144), invoked as `sh` so it runs in POSIX mode -- +# and under the target's BusyBox ash, the stricter interpreter, which stays +# shipped as /bin/ash. The host's /bin/sh is usually dash, which is a good +# POSIX proxy but is not the same interpreter as either. # # Env: # TZ_TEST_SH shell to run the script under (default: sh). May be a command # with arguments, e.g. +# TZ_TEST_SH="qemu-arm -L output/target output/target/usr/bin/bash --posix" # TZ_TEST_SH="qemu-arm -L output/target output/target/bin/busybox sh" # shellcheck disable=SC2030,SC2031 @@ -166,8 +169,8 @@ reset() { rm -f "$TZFILE" "$TZSTAMP" "$TZFILE.tmp"; rmdir "$LOCKDIR" 2>/dev/null # fire [reason] [if_up] -- one dhcpcd address event, sourced the way # dhcpcd-run-hooks sources it, INSIDE ${TEST_SH[@]}. Running it under the -# harness's own shell would make TZ_TEST_SH decorative and the "target BusyBox -# ash" CI leg a no-op that prints PASS. +# harness's own shell would make TZ_TEST_SH decorative and the target-shell CI +# legs (bash --posix, BusyBox ash) no-ops that print PASS. # # Defaults to the event that matters: BOUND with the interface up. Pass the # literal string "unset" as if_up to leave it undefined -- a case dhcpcd never From 78a3b043aecbb59c504cf52c31bb813551a11658 Mon Sep 17 00:00:00 2001 From: "Michael C. Ferguson" Date: Thu, 3 Sep 2026 11:39:15 -0500 Subject: [PATCH 4/4] test-initramfs: run merge_config.sh from the kernel tree; assert the fragment survived The three exFAT cases (exfat, fsck-request, symlink) have been failing locally with "mount: mounting /dev/vdb1 on /mnt/fat failed: No such device": the QEMU test kernel had `# CONFIG_EXFAT_FS is not set` even though scripts/test-initramfs/qemu-test-kernel.config says =y. Cause: the kernel's merge_config.sh ends with a BARE `make ... alldefconfig` in the current directory (no -C). Invoked from this repo's root, that make hits the wrapper Makefile, which forwards `alldefconfig` to Buildroot, which dies with "Can't read seed configuration", and merge_config.sh exits before writing the merged config. Symbols multi_v7_defconfig already had looked merged; the two it lacked (CONFIG_EXFAT_FS, CONFIG_FAT_DEFAULT_UTF8) were silently dropped. Reproduced with a fresh defconfig + merge from the repo root (dropped) and from inside the kernel tree (kept). Fix: cd into $KERNEL_SRC for the merge, pass ARCH/CROSS_COMPILE, and die if it fails. Backstop: after olddefconfig, assert every `CONFIG_X=y` in the fragment is in the resolved .config, with a message that names the stale-cache remedy (rm -rf work/test-initramfs-kbuild) -- a cache built by a failed merge keeps its broken .config forever because the merge only runs on bootstrap. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vq3yLoYntjiJ1wUaAdruLU --- scripts/test-initramfs.sh | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/scripts/test-initramfs.sh b/scripts/test-initramfs.sh index edaf0212..29441a18 100755 --- a/scripts/test-initramfs.sh +++ b/scripts/test-initramfs.sh @@ -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." @@ -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