. "$BST_LIB" # BST-TIMEOUT: 1800 # every case below is an interpreter start: bmaptool is a # # python3 console script, and this arm's python3 is one # # large static image whose start-up has been minutes on a # # loaded guest (99-python3.test declares the same bound for # # the same reason). ~25 starts under the runner's 60s # # default would report the port as broken when what ran out # # was the clock. # # bmaptool 3.8.0+git (poky meta/recipes-support) -- the Yocto block-map tool. # `create` reads a sparse file and writes an XML block map; `copy` writes a file # from an image plus that map. Both are file-to-file, so the whole package is # reachable on a machine with no block device. # # WHAT THIS SUITE ASSERTS, AND WHAT IT DELIBERATELY DOES NOT. # # - Nothing here is a golden bmap. A bmap embeds the BlockSize the filesystem # reported and the hole layout the filesystem chose, so a committed byte-for- # byte expectation would encode tmpfs's answers and redden the day the guest's # page size or the runner's TMPDIR changes -- a red that says "bmaptool is # broken" and means "the filesystem moved". Every expectation is instead # DERIVED at run time from the image and the map, which is a stronger claim # than equality with a recorded file: it holds for any block size. # # - The checker is not bmaptool. data/bmaptool-probe.py re-derives the map from # the image with nothing but hashlib and os, so a bmap verified here is # verified by something other than the library that wrote it. Its HOLES case # is the one a self-consistent bmap can still fail: every block the map omits # must read as zero in the image. A bmap that silently drops real data passes # its own checksums and fails that. # # - The reference reconstruction (R-SPARSE / R-DENSE) is the ticket's "copy # reconstructs the image, compared byte for byte" claim, answered WITHOUT # bmaptool copy. It is here because it stays answerable whatever happens to # the copy cases below, and because it separates "the map is wrong" from "the # writer could not run". # # THE COPY CASES ARE BOUNDED, AND THAT IS NOT CAUTION. BmapCopy.copy() hands the # image read to `_thread.start_new_thread` and then blocks on a Queue that only # that thread feeds. pthread_create does not produce a running thread on this arm # (linux-in-the-browser#85, asserted by 99-python3.test). MEASURED 2026-08-30: # start_new_thread REFUSES with RuntimeError, so copy dies rc=1 and nothing parks # -- no bound has ever fired here. The bound stays for the other half of #85's # behaviour, which this arm could return to: a thread that starts and never runs # its body raises nothing, and the process then parks forever, costing the whole # suite its budget and taking the guest's other suites with it. bm_bounded runs # every copy under a hand-rolled wall clock. The bound is hand-rolled rather than timeout(1) for the # reason 16-zsh.test records: GNU timeout waits for its child in the sigsuspend # idiom linux-in-the-browser#161 breaks, so it parks alongside what it was meant # to bound. # # T-THREAD is the case that says WHY, and it is measured rather than assumed -- # the probe starts a thread and waits on a bounded Queue.get, so it answers # "ran", "norun" or "refused" without ever parking. If copy is dead here, that # case names the reason and the reference reconstruction still proves the map. # # THE FIRST COPY IS ALWAYS RUN; THE REST ARE GATED ON IT. Once one bounded copy # has been observed to fail -- rc=1 as measured, or parked past its bound -- # running seven more buys nothing and costs seven bounds; they are reported red # with the measured reason instead. The two cases # that are decided BEFORE any thread starts (the bmap's own checksum, and the # --nobmap/--bmap conflict) are run unconditionally, because those paths are # reachable whatever #85 does. # # ERROR NEEDLES ARE MESSAGE BODIES, NEVER "bmaptool: ERROR:". setup_logger() # colours the level name, so the prefix on the wire is # `bmaptool: [91mERROR[0m:` and a needle spanning it can never match. # # THE PACKAGE'S RECORDED VERSION IS 0.0.0 AND THAT IS UPSTREAM, NOT THE PORT. # setup.py reads the version out of CLI.py with `^VERSION = "(\d+.\d+)"$`, which # does not match `VERSION = "3.8.0"`; setup() gets None and setuptools falls back # to 0.0.0. So the dist-info directory and METADATA say 0.0.0 while `--version` # says 3.8.0. Both are asserted, at the values they actually have, so the day # upstream fixes the regex this suite says so instead of quietly agreeing. # # HOW THE EXPECTATIONS WERE OBTAINED. The corpus, the checker and every needle # below were exercised against bmaptool at this SRCREV on an x86-64 control # (2026-08-26) -- the same source the recipe builds. What the wasm32 image does # with them is what the run this suite ships in reports; nothing here is written # down as an on-target measurement. bst_ok bmaptool/suite-canary # first line: absorbs BRINTOS-1149, which drops it require bmaptool bmaptool/present S=bmaptool BT=/usr/bin/bmaptool SITE=/usr/lib/python3.12/site-packages W=${TMPDIR:-/tmp}/bst-bmaptool.$$ mkdir -p "$W" 2>/dev/null # --- helpers ----------------------------------------------------------------- bm_expect() { # bm_expect _id=$1; _want=$2; _needle=$3; shift 3 _out=$("$@" 2>&1); _got=$? if [ "$_got" != "$_want" ]; then bst_notok "$_id" "rc=$_got want=$_want: $(_bst_1line "$_out")" elif [ -n "$_needle" ] && ! printf '%s\n' "$_out" | grep -qF -- "$_needle"; then bst_notok "$_id" "rc=$_got as wanted but no '$_needle': $(_bst_1line "$_out")" else bst_ok "$_id" fi } # bm_bounded -- sets BRC and BOUT; BRC=124 means the bound fired. # The child records its own pid through `sh -c ... exec`, so the kill reaches the # interpreter rather than the subshell that would leave it parked. BSEQ=0 bm_bounded() { _lim=$1; shift BSEQ=$((BSEQ+1)) _bo=$W/bt.$BSEQ.out; _br=$W/bt.$BSEQ.rc; _bp=$W/bt.$BSEQ.pid ( sh -c 'echo $$ > "$1"; shift; exec "$@"' _ "$_bp" "$@" >"$_bo" 2>&1 echo $? >"$_br" ) & _i=0 while [ ! -f "$_br" ] && [ "$_i" -lt "$_lim" ]; do sleep 1; _i=$((_i + 1)); done if [ -f "$_br" ]; then BRC=$(cat "$_br") else kill -9 "$(cat "$_bp" 2>/dev/null)" 2>/dev/null BRC=124 _i=0 while [ ! -f "$_br" ] && [ "$_i" -lt 5 ]; do sleep 1; _i=$((_i + 1)); done fi BOUT=$(cat "$_bo" 2>/dev/null) } # bm_copy [extra args...] -- a bounded copy that must # reproduce exactly. Gated on the first copy having returned. bm_copy() { _id=$1; _lim=$2; _ref=$3; _dst=$4; shift 4 if [ "$COPY_LIVE" != yes ]; then bst_notok "$_id" "not run: $COPY_WHY" return fi bm_bounded "$_lim" "$@" if [ "$BRC" = 124 ]; then bst_notok "$_id" "parked past ${_lim}s: $COPY_WHY" elif [ "$BRC" != 0 ]; then bst_notok "$_id" "rc=$BRC $(_bst_1line "$BOUT")" elif cmp -s "$_ref" "$_dst"; then bst_ok "$_id" else bst_notok "$_id" "copied file differs from $_ref" fi } # --- 1. the port shipped what it claims to ship ------------------------------- check $S/script test -x $BT check $S/script-shebang grep -q '^#!/usr/bin/env python3' $BT check $S/env-present sh -c 'command -v env >/dev/null' for m in BmapCopy BmapCreate BmapHelpers CLI Filemap TransRead __init__ __main__; do check "$S/module-$m" test -f "$SITE/bmaptool/$m.py" done check $S/pycache test -f "$SITE/bmaptool/__pycache__/CLI.cpython-312.pyc" # 0.0.0 is upstream's setup.py regex missing a three-component VERSION; see the # header. Asserted at its real value so a future upstream fix is visible. check $S/dist-info test -d "$SITE/bmaptool-0.0.0.dist-info" check_out $S/dist-info-version "Version: 0.0.0" \ grep -m1 '^Version:' "$SITE/bmaptool-0.0.0.dist-info/METADATA" check_out $S/entry-point "bmaptool = bmaptool.CLI:main" \ cat "$SITE/bmaptool-0.0.0.dist-info/entry_points.txt" # --- 2. the runtime the package declares -------------------------------------- # Every one of these is a Depends: line of the built package, and each has a way # of being present-but-wrong that only shows up when bmaptool runs. check $S/dep-python3 sh -c 'command -v python3 >/dev/null' for t in gzip bzip2 xz zstd tar; do check "$S/dep-$t" sh -c "command -v $t >/dev/null" done # `df -PT` is not decoration: BmapHelpers.get_file_system_type shells out to it # on EVERY `create`, and busybox's df applet has no -T. If the coreutils # alternative ever loses the slot, create dies with "failed to find file system # type" and nothing else on the image notices. FSTYPE=$(df -PT "$W" 2>/dev/null | sed -n '2s/^[^ ]* *\([^ ]*\).*/\1/p') if [ -n "$FSTYPE" ]; then bst_ok $S/df-PT else bst_notok $S/df-PT "df -PT gave no filesystem type for $W -- create cannot run" fi # --- 3. identity and usage ---------------------------------------------------- bm_expect $S/version 0 "bmaptool 3.8.0" $BT --version bm_expect $S/help-commands 0 "{create,copy}" $BT --help bm_expect $S/help-create 0 "--no-checksum" $BT create --help # One `copy --help` invocation, reused: on this arm each one is a fresh start of # a large static interpreter, and the suite's budget is spent in starts. CH=$($BT copy --help 2>&1) case "$CH" in *--nobmap*) bst_ok $S/help-copy ;; *) bst_notok $S/help-copy "copy --help does not mention --nobmap: $(_bst_1line "$CH")" ;; esac CH=$(printf '%s' "$CH" | tr '\n' ' ' | tr -s ' ') for f in bz2 gz xz lzo lz4 zst zip; do case "$CH" in *" $f,"*|*" $f "*) bst_ok "$S/help-format-$f" ;; *) bst_notok "$S/help-format-$f" "copy --help does not advertise $f" ;; esac done # argparse decides these, so they are the two exit statuses that are NOT 1. bm_expect $S/usage-no-command 2 "required" $BT bm_expect $S/usage-bad-command 2 "invalid choice" $BT bogus # --- 4. the corpus ------------------------------------------------------------ # Data and holes land on 64 KiB boundaries, a multiple of any plausible block # size, so the corpus is block-aligned whatever bmaptool reads out of FIGETBSZ. MK=$(python3 "$BST_DATA/bmaptool-probe.py" mkimage "$W" 2>&1) case "$MK" in *M-OK*) bst_ok $S/corpus ;; *) bst_notok $S/corpus "$(_bst_1line "$MK")" ;; esac # --- 5. create ---------------------------------------------------------------- bm_expect $S/create-sparse 0 "" $BT create -o "$W/sparse.bmap" "$W/sparse.img" bm_expect $S/create-nochecksum 0 "" $BT create --no-checksum -o "$W/sparse-nock.bmap" "$W/sparse.img" # A fully-mapped image is what upstream warns about, and the warning is the # documented behaviour rather than an error: rc stays 0. bm_expect $S/create-dense 0 "no holes in" $BT create -o "$W/dense.bmap" "$W/dense.img" # No -o: the bmap goes to stdout. A subshell performs the redirect, so a failed # one costs this case and not the rest of the file. ( $BT create "$W/sparse.img" > "$W/sparse-stdout.bmap" 2>/dev/null ) if [ -s "$W/sparse-stdout.bmap" ]; then bst_ok $S/create-stdout else bst_notok $S/create-stdout "create with no -o wrote nothing to stdout" fi bm_expect $S/create-empty 1 "cannot generate bmap for zero-sized image file" \ $BT create "$W/empty.img" bm_expect $S/create-missing 1 "cannot open image file" \ $BT create "$W/nosuch.img" # HOLES WERE ACTUALLY FOUND. This is the one case that is about the filesystem # rather than about bmaptool: Filemap takes FIEMAP if the ioctl answers and falls # back to SEEK_HOLE/SEEK_DATA, and if neither works every block is reported # mapped -- a bmap that is valid, self-consistent, and worth nothing. The # reported percentage is diagnostic; the assertion is that the corpus's three # quarters of hole are seen as hole. # The digit is required, not optional: both tag names also appear in the file's # own header COMMENT (" blocks instead of # blocks"), and a `[0-9]*` there matches the empty string and prints a blank line # AHEAD of the real value -- which `[ -lt ]` then compares as leading whitespace # in whichever direction the shell happens to allow. SP_MAPPED=$(sed -n 's/.* *\([0-9][0-9]*\).*/\1/p' "$W/sparse.bmap" 2>/dev/null | head -1) SP_TOTAL=$(sed -n 's/.* *\([0-9][0-9]*\).*/\1/p' "$W/sparse.bmap" 2>/dev/null | head -1) if [ -n "$SP_MAPPED" ] && [ -n "$SP_TOTAL" ] && [ "$SP_MAPPED" -lt "$SP_TOTAL" ]; then bst_ok $S/create-holes-found else bst_notok $S/create-holes-found \ "mapped=$SP_MAPPED of $SP_TOTAL blocks -- no hole detected, so neither FIEMAP nor SEEK_HOLE works on ${FSTYPE:-this filesystem}" fi # --- 6. what the maps say, checked by something other than bmaptool ----------- REP=$(python3 "$BST_DATA/bmaptool-probe.py" report "$W" 2>&1) printf '%s\n' "$REP" > "$W/report.txt" case "$REP" in *REPORT-OK*) bst_ok $S/report-ran ;; *) bst_notok $S/report-ran "checker did not finish: $(_bst_1line "$REP")" ;; esac bm_field() { # bm_field -> the checker's value, or "" if absent printf '%s\n' "$REP" | sed -n "s/^V-$1 $2 //p" | head -1 } for tag in SPARSE NOCK STDOUT DENSE; do lc=$(printf '%s' "$tag" | tr 'A-Z' 'a-z') for k in PARSE SIZE BCOUNT MAPPED ORDER SELFSUM RANGESUM HOLES; do kl=$(printf '%s' "$k" | tr 'A-Z' 'a-z') v=$(bm_field "$tag" "$k") case "$v" in ok|none) bst_ok "$S/$lc-$kl" ;; "") bst_notok "$S/$lc-$kl" "the checker reported nothing for V-$tag $k" ;; *) bst_notok "$S/$lc-$kl" "$v" ;; esac done check_eq "$S/$lc-version" "2.0" "$(bm_field "$tag" VERSION)" done # The map is USABLE: image + bmap reproduce the image byte for byte, reconstructed # without bmaptool copy. This is the ticket's round-trip requirement, and it stays # answerable whatever the threading boundary does to copy below. check_eq $S/ref-reconstruct-sparse ok "$(printf '%s\n' "$REP" | sed -n 's/^R-SPARSE //p')" check_eq $S/ref-reconstruct-dense ok "$(printf '%s\n' "$REP" | sed -n 's/^R-DENSE //p')" # --- 7. the threading boundary, measured before it is blamed ------------------ THREAD=$(printf '%s\n' "$REP" | sed -n 's/^T-THREAD //p' | head -1) case "$THREAD" in ran) bst_ok $S/thread-runs COPY_LIVE=yes COPY_WHY="threads run here" ;; norun|refused:*) bst_notok $S/thread-runs \ "started thread did not run its body ($THREAD) -- linux-in-the-browser#85; BmapCopy.copy() blocks on a Queue only that thread feeds" COPY_LIVE=probe COPY_WHY="linux-in-the-browser#85: a started thread never runs, and BmapCopy.copy() waits on a Queue only it would fill" ;; *) bst_notok $S/thread-runs "the checker reported no T-THREAD line" COPY_LIVE=probe COPY_WHY="the thread probe produced no answer" ;; esac # --- 8. copy ------------------------------------------------------------------ # The first copy is run whatever the probe said -- an inference about #85 is not # a measurement of bmaptool, and this is the case that turns one into the other. bm_bounded 90 $BT copy --bmap "$W/sparse.bmap" "$W/sparse.img" "$W/copy-bmap.img" if [ "$BRC" = 124 ]; then bst_notok $S/copy-bmap "parked past 90s: $COPY_WHY" COPY_LIVE=no COPY_WHY="measured: the first bounded copy parked past 90s -- $COPY_WHY" elif [ "$BRC" != 0 ]; then bst_notok $S/copy-bmap "rc=$BRC $(_bst_1line "$BOUT")" COPY_LIVE=no COPY_WHY="measured: the first copy failed rc=$BRC" elif cmp -s "$W/sparse.img" "$W/copy-bmap.img"; then bst_ok $S/copy-bmap COPY_LIVE=yes COPY_WHY="threads run here" else bst_notok $S/copy-bmap "copied file differs from the image" COPY_LIVE=yes COPY_WHY="copy returns here" fi bm_copy $S/copy-nobmap 90 "$W/sparse.img" "$W/copy-nobmap.img" \ $BT copy --nobmap "$W/sparse.img" "$W/copy-nobmap.img" # Auto-discovery: with no --bmap, copy looks for .bmap. cp "$W/sparse.bmap" "$W/auto.bmap" 2>/dev/null cp "$W/sparse.img" "$W/auto.img" 2>/dev/null bm_copy $S/copy-autodiscover 90 "$W/sparse.img" "$W/copy-auto.img" \ $BT copy "$W/auto.img" "$W/copy-auto.img" bm_copy $S/copy-dense 90 "$W/dense.img" "$W/copy-dense.img" \ $BT copy --bmap "$W/dense.bmap" "$W/dense.img" "$W/copy-dense.img" # --- 9. compressed inputs ------------------------------------------------------ # The decompressor is an external program bmaptool spawns through a shell, one # per format; the bmap always describes the UNCOMPRESSED image. gzip -c "$W/sparse.img" > "$W/s.img.gz" 2>/dev/null bzip2 -c "$W/sparse.img" > "$W/s.img.bz2" 2>/dev/null xz -c "$W/sparse.img" > "$W/s.img.xz" 2>/dev/null zstd -q -c "$W/sparse.img" > "$W/s.img.zst" 2>/dev/null for f in gz bz2 xz zst; do if [ -s "$W/s.img.$f" ]; then bst_ok "$S/compress-$f-made" bm_copy "$S/copy-$f" 120 "$W/sparse.img" "$W/copy-$f.img" \ $BT copy --bmap "$W/sparse.bmap" "$W/s.img.$f" "$W/copy-$f.img" else bst_notok "$S/compress-$f-made" "could not compress the corpus to .$f" bst_notok "$S/copy-$f" "no .$f fixture to copy from" fi done # --- 10. the paths decided before any thread starts --------------------------- # These stay reachable whatever #85 does, so they are never gated: the bmap's own # checksum is verified in BmapCopy's constructor, and the argument conflict never # reaches BmapCopy at all. # The tamper is inside a COMMENT: any byte change breaks the file's own digest, # and a comment changes nothing else, so this reaches the checksum check rather # than the image-size/blocks-count consistency check that fires ahead of it. sed 's/Image size in bytes/Image size in byte./' \ "$W/sparse.bmap" > "$W/tampered.bmap" 2>/dev/null bm_bounded 90 $BT copy --bmap "$W/tampered.bmap" "$W/sparse.img" "$W/copy-bad.img" if [ "$BRC" = 124 ]; then bst_notok $S/copy-tampered-bmap "parked past 90s -- this path is decided before any thread starts" elif [ "$BRC" = 1 ] && printf '%s\n' "$BOUT" | grep -qF "checksum mismatch for bmap file"; then bst_ok $S/copy-tampered-bmap else bst_notok $S/copy-tampered-bmap "rc=$BRC want=1 with 'checksum mismatch for bmap file': $(_bst_1line "$BOUT")" fi bm_expect $S/copy-arg-conflict 1 "cannot be used together" \ $BT copy --nobmap --bmap "$W/sparse.bmap" "$W/sparse.img" "$W/never.img" # A bmap that is intact against an image that is not: the range checksums are # recomputed by the reader thread, so unlike the case above this one needs copy # to be alive. cp "$W/sparse.img" "$W/corrupt.img" 2>/dev/null printf 'XXXXXXXX' | dd of="$W/corrupt.img" bs=1 seek=100 conv=notrunc 2>/dev/null if [ "$COPY_LIVE" != yes ]; then bst_notok $S/copy-range-mismatch "not run: $COPY_WHY" else bm_bounded 90 $BT copy --bmap "$W/sparse.bmap" "$W/corrupt.img" "$W/copy-corrupt.img" if [ "$BRC" = 1 ] && printf '%s\n' "$BOUT" | grep -qF "checksum mismatch for blocks range"; then bst_ok $S/copy-range-mismatch else bst_notok $S/copy-range-mismatch "rc=$BRC want=1 with 'checksum mismatch for blocks range': $(_bst_1line "$BOUT")" fi fi rm -rf "$W"