Bound assembler inputs and verify bootstrap

ober

ea2637422bf3de682bc08afd11eb55afdb58afc3

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b695be9..e323620 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,18 +13,13 @@ jobs:
   verify:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install build tools
-        run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates ripgrep
+        run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates openssh-client ripgrep
 
       - name: Install jerbuild
-        run: |
-          set -eux
-          curl -fsSL "https://github.com/jerboa-lang/jerboa/releases/download/${JERBOA_VERSION}/jerbuild-linux-x86_64" -o /usr/local/bin/jerbuild
-          chmod +x /usr/local/bin/jerbuild
-        env:
-          JERBOA_VERSION: v0.2.3
+        run: support/install-verified-jerbuild.sh /usr/local/bin/jerbuild
 
       - name: Verify
         run: make verify
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 4d15e85..edb0afa 100644
--- a/.github/workflows/security-baseline.yml
+++ b/.github/workflows/security-baseline.yml
@@ -13,7 +13,7 @@ jobs:
   baseline:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install scanner tools
         run: sudo apt-get update && sudo apt-get install -y ripgrep
diff --git a/Makefile b/Makefile
index 06a5999..b353c25 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ SBOM_DIR ?= dist/sbom
 REPRO_DIR ?= dist/reproducibility
 TARGET_EVIDENCE_DIR ?= dist/target-evidence
 
-.PHONY: all build binary run test check test-binary import-check object-fuzz-evidence clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean
+.PHONY: all build binary run test check test-binary test-input-limit import-check object-fuzz-evidence clean-generated security audit verify sbom reproducibility-report target-evidence release-evidence clean
 
 all: binary
 
@@ -57,7 +57,7 @@ audit: security
 target-evidence: scripts/target-evidence.sh
 	@REPO_ROOT=. TARGET_EVIDENCE_DIR="$(TARGET_EVIDENCE_DIR)" sh scripts/target-evidence.sh
 
-verify: audit test object-fuzz-evidence test-binary target-evidence
+verify: audit test object-fuzz-evidence test-binary test-input-limit target-evidence
 
 sbom:
 	REPO_ROOT="$(CURDIR)" SBOM_DIR="$(SBOM_DIR)" JERBUILD="$(JERBUILD)" JERBOA_VERSION="$(JERBOA_VERSION)" sh scripts/sbom.sh
@@ -98,6 +98,9 @@ test-binary: binary
 	./$(BINARY) raw --arch aarch64 --hex '20 00 80 d2 c0 03 5f d6'
 	./$(BINARY) raw --arch riscv64 --hex '13 01 01 ff 67 80 00 00'
 
+test-input-limit: binary tests/test-input-limit.sh
+	@REPO_ROOT="$(CURDIR)" sh tests/test-input-limit.sh
+
 clean:
 	rm -f $(BINARY) $(BINARY).wp.so $(BINARY)-main.c petite_boot.h scheme_boot.h program_boot.h .jasm-sample.o .jasm-test-sample.o .jasm-raw.bin
 	find . \( -name '*.so' -o -name '*.wpo' -o -name '*.o' \) -print0 | xargs -0 rm -f
diff --git a/lib/jasm/object/archive.ss b/lib/jasm/object/archive.ss
index f47769d..944d6da 100644
--- a/lib/jasm/object/archive.ss
+++ b/lib/jasm/object/archive.ss
@@ -55,17 +55,42 @@
 
     (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes))
 
-    (define (enforce-input-size who path bytes)
-      (when (> (bytevector-length bytes) max-object-bytes)
-        (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes))
-      bytes)
+    (define input-read-chunk-bytes 65536)
+
+    (define (chunks->bytevector chunks total)
+      (let ([out (make-bytevector total 0)])
+        (let loop ([chunks (reverse chunks)] [offset 0])
+          (unless (null? chunks)
+            (let* ([chunk (car chunks)] [size (bytevector-length chunk)])
+              (bytevector-copy! chunk 0 out offset size)
+              (loop (cdr chunks) (+ offset size)))))
+        out))
+
+    (define (read-bounded-bytevector who path port limit)
+      (let ([reported-size (and (port-has-port-position? port)
+                                (file-length port))])
+        (when (and reported-size (> reported-size limit))
+          (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit)))
+      (let ([buffer (make-bytevector input-read-chunk-bytes 0)])
+        (let loop ([chunks '()] [total 0])
+          (let* ([remaining (+ (- limit total) 1)]
+                 [want (min input-read-chunk-bytes remaining)]
+                 [count (get-bytevector-n! port buffer 0 want)])
+            (if (eof-object? count)
+                (chunks->bytevector chunks total)
+                (let ([next-total (+ total count)])
+                  (when (> next-total limit)
+                    (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit))
+                  (let ([chunk (make-bytevector count 0)])
+                    (bytevector-copy! buffer 0 chunk 0 count)
+                    (loop (cons chunk chunks) next-total))))))))
 
     (define (read-file-bytevector path)
       (let ([port (open-file-input-port path)])
         (dynamic-wind
           (lambda () (void))
           (lambda ()
-            (enforce-input-size 'read-archive-file path (get-bytevector-all port)))
+            (read-bounded-bytevector 'read-archive-file path port max-object-bytes))
           (lambda () (close-port port)))))
 
     (define (read-fixed-string bv offset size)
diff --git a/lib/jasm/object/coff.ss b/lib/jasm/object/coff.ss
index b849e48..12dd822 100644
--- a/lib/jasm/object/coff.ss
+++ b/lib/jasm/object/coff.ss
@@ -72,17 +72,42 @@
 
     (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes))
 
-    (define (enforce-input-size who path bytes)
-      (when (> (bytevector-length bytes) max-object-bytes)
-        (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes))
-      bytes)
+    (define input-read-chunk-bytes 65536)
+
+    (define (chunks->bytevector chunks total)
+      (let ([out (make-bytevector total 0)])
+        (let loop ([chunks (reverse chunks)] [offset 0])
+          (unless (null? chunks)
+            (let* ([chunk (car chunks)] [size (bytevector-length chunk)])
+              (bytevector-copy! chunk 0 out offset size)
+              (loop (cdr chunks) (+ offset size)))))
+        out))
+
+    (define (read-bounded-bytevector who path port limit)
+      (let ([reported-size (and (port-has-port-position? port)
+                                (file-length port))])
+        (when (and reported-size (> reported-size limit))
+          (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit)))
+      (let ([buffer (make-bytevector input-read-chunk-bytes 0)])
+        (let loop ([chunks '()] [total 0])
+          (let* ([remaining (+ (- limit total) 1)]
+                 [want (min input-read-chunk-bytes remaining)]
+                 [count (get-bytevector-n! port buffer 0 want)])
+            (if (eof-object? count)
+                (chunks->bytevector chunks total)
+                (let ([next-total (+ total count)])
+                  (when (> next-total limit)
+                    (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit))
+                  (let ([chunk (make-bytevector count 0)])
+                    (bytevector-copy! buffer 0 chunk 0 count)
+                    (loop (cons chunk chunks) next-total))))))))
 
     (define (read-file-bytevector path)
       (let ([port (open-file-input-port path)])
         (dynamic-wind
           (lambda () (void))
           (lambda ()
-            (enforce-input-size 'read-coff-file path (get-bytevector-all port)))
+            (read-bounded-bytevector 'read-coff-file path port max-object-bytes))
           (lambda () (close-port port)))))
 
     (define (read-fixed-string bv offset size)
diff --git a/lib/jasm/object/elf.ss b/lib/jasm/object/elf.ss
index 0dce07c..cc4a257 100644
--- a/lib/jasm/object/elf.ss
+++ b/lib/jasm/object/elf.ss
@@ -141,17 +141,42 @@
 
     (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes))
 
-    (define (enforce-input-size who path bytes)
-      (when (> (bytevector-length bytes) max-object-bytes)
-        (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes))
-      bytes)
+    (define input-read-chunk-bytes 65536)
+
+    (define (chunks->bytevector chunks total)
+      (let ([out (make-bytevector total 0)])
+        (let loop ([chunks (reverse chunks)] [offset 0])
+          (unless (null? chunks)
+            (let* ([chunk (car chunks)] [size (bytevector-length chunk)])
+              (bytevector-copy! chunk 0 out offset size)
+              (loop (cdr chunks) (+ offset size)))))
+        out))
+
+    (define (read-bounded-bytevector who path port limit)
+      (let ([reported-size (and (port-has-port-position? port)
+                                (file-length port))])
+        (when (and reported-size (> reported-size limit))
+          (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit)))
+      (let ([buffer (make-bytevector input-read-chunk-bytes 0)])
+        (let loop ([chunks '()] [total 0])
+          (let* ([remaining (+ (- limit total) 1)]
+                 [want (min input-read-chunk-bytes remaining)]
+                 [count (get-bytevector-n! port buffer 0 want)])
+            (if (eof-object? count)
+                (chunks->bytevector chunks total)
+                (let ([next-total (+ total count)])
+                  (when (> next-total limit)
+                    (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit))
+                  (let ([chunk (make-bytevector count 0)])
+                    (bytevector-copy! buffer 0 chunk 0 count)
+                    (loop (cons chunk chunks) next-total))))))))
 
     (define (read-file-bytevector path)
       (let ([port (open-file-input-port path)])
         (dynamic-wind
           (lambda () (void))
           (lambda ()
-            (enforce-input-size 'read-elf-file path (get-bytevector-all port)))
+            (read-bounded-bytevector 'read-elf-file path port max-object-bytes))
           (lambda () (close-port port)))))
 
     (define (elf-class-name n)
diff --git a/lib/jasm/object/macho.ss b/lib/jasm/object/macho.ss
index 1efd778..0cd6bc3 100644
--- a/lib/jasm/object/macho.ss
+++ b/lib/jasm/object/macho.ss
@@ -114,17 +114,42 @@
 
     (define max-object-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-object-bytes))
 
-    (define (enforce-input-size who path bytes)
-      (when (> (bytevector-length bytes) max-object-bytes)
-        (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-object-bytes))
-      bytes)
+    (define input-read-chunk-bytes 65536)
+
+    (define (chunks->bytevector chunks total)
+      (let ([out (make-bytevector total 0)])
+        (let loop ([chunks (reverse chunks)] [offset 0])
+          (unless (null? chunks)
+            (let* ([chunk (car chunks)] [size (bytevector-length chunk)])
+              (bytevector-copy! chunk 0 out offset size)
+              (loop (cdr chunks) (+ offset size)))))
+        out))
+
+    (define (read-bounded-bytevector who path port limit)
+      (let ([reported-size (and (port-has-port-position? port)
+                                (file-length port))])
+        (when (and reported-size (> reported-size limit))
+          (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit)))
+      (let ([buffer (make-bytevector input-read-chunk-bytes 0)])
+        (let loop ([chunks '()] [total 0])
+          (let* ([remaining (+ (- limit total) 1)]
+                 [want (min input-read-chunk-bytes remaining)]
+                 [count (get-bytevector-n! port buffer 0 want)])
+            (if (eof-object? count)
+                (chunks->bytevector chunks total)
+                (let ([next-total (+ total count)])
+                  (when (> next-total limit)
+                    (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit))
+                  (let ([chunk (make-bytevector count 0)])
+                    (bytevector-copy! buffer 0 chunk 0 count)
+                    (loop (cons chunk chunks) next-total))))))))
 
     (define (read-file-bytevector path)
       (let ([port (open-file-input-port path)])
         (dynamic-wind
           (lambda () (void))
           (lambda ()
-            (enforce-input-size 'read-macho-file path (get-bytevector-all port)))
+            (read-bounded-bytevector 'read-macho-file path port max-object-bytes))
           (lambda () (close-port port)))))
 
     (define (read-fixed-string bv offset size)
diff --git a/main.ss b/main.ss
index 835e66f..1cedf45 100644
--- a/main.ss
+++ b/main.ss
@@ -47,17 +47,54 @@
 
 (def max-input-bytes (configured-positive-int "JASM_MAX_INPUT_BYTES" default-max-input-bytes))
 
-(def (enforce-input-size who path bytes)
-  (when (> (bytevector-length bytes) max-input-bytes)
-    (error who "input exceeds JASM_MAX_INPUT_BYTES" path (bytevector-length bytes) max-input-bytes))
-  bytes)
+(def input-read-chunk-bytes 65536)
+
+(def (chunks->bytevector chunks total)
+  (let ([out (make-bytevector total 0)])
+    (let loop ([chunks (reverse chunks)] [offset 0])
+      (unless (null? chunks)
+        (let* ([chunk (car chunks)]
+               [size (bytevector-length chunk)])
+          (bytevector-copy! chunk 0 out offset size)
+          (loop (cdr chunks) (+ offset size)))))
+    out))
+
+(def (reject-reported-oversize! who path port limit)
+  ;; file-length is a cheap preflight for regular files (including sparse
+  ;; files).  It can fail for FIFOs and other streams, so the streaming limit
+  ;; below remains authoritative.
+  ;; Non-seekable inputs have no reported length; only that optional preflight
+  ;; is skipped.  The authoritative limit remains the bounded streaming read.
+  (let ([reported-size (and (port-has-port-position? port)
+                            (file-length port))])
+    (when (and reported-size (> reported-size limit))
+      (error who "input exceeds JASM_MAX_INPUT_BYTES" path reported-size limit))))
+
+(def (read-bounded-bytevector who path port limit)
+  (reject-reported-oversize! who path port limit)
+  (let ([buffer (make-bytevector input-read-chunk-bytes 0)])
+    (let loop ([chunks '()] [total 0])
+      ;; Once LIMIT bytes have been retained, read only one more byte.  This
+      ;; detects a growing regular file or an oversized FIFO without ever
+      ;; retaining attacker-controlled data beyond the configured bound.
+      (let* ([remaining (+ (- limit total) 1)]
+             [want (min input-read-chunk-bytes remaining)]
+             [count (get-bytevector-n! port buffer 0 want)])
+        (if (eof-object? count)
+            (chunks->bytevector chunks total)
+            (let ([next-total (+ total count)])
+              (when (> next-total limit)
+                (error who "input exceeds JASM_MAX_INPUT_BYTES" path next-total limit))
+              (let ([chunk (make-bytevector count 0)])
+                (bytevector-copy! buffer 0 chunk 0 count)
+                (loop (cons chunk chunks) next-total))))))))
 
 (def (read-file-bytevector path)
   (let ([port (open-file-input-port path)])
     (dynamic-wind
       (lambda () (void))
       (lambda ()
-        (enforce-input-size 'read-file-bytevector path (get-bytevector-all port)))
+        (read-bounded-bytevector 'read-file-bytevector path port max-input-bytes))
       (lambda () (close-port port)))))
 
 (def (bytevector-prefix? bv bytes)
diff --git a/scripts/security-check.sh b/scripts/security-check.sh
index 8e63f20..63813f2 100755
--- a/scripts/security-check.sh
+++ b/scripts/security-check.sh
@@ -138,4 +138,21 @@ if [[ -n "$host_fingerprint_matches" ]]; then
   exit 1
 fi
 
+for bootstrap_file in \
+  support/install-verified-jerbuild.sh \
+  support/jerbuild-bootstrap.lock \
+  support/jerboa-release-signers; do
+  test -f "$bootstrap_file" || { echo "missing authenticated bootstrap input: $bootstrap_file" >&2; exit 1; }
+done
+grep -q 'ssh-keygen -Y verify' support/install-verified-jerbuild.sh
+grep -q '^status=blocked-awaiting-authenticated-upstream-release$' support/jerbuild-bootstrap.lock
+if grep -R -n -E 'jerbuild-linux|releases/download/.*/jerbuild' .github/workflows .build.yml 2>/dev/null; then
+  echo "unsigned Jerbuild bootstrap remains" >&2
+  exit 1
+fi
+if grep -R -n -E 'uses:[[:space:]]+[^[:space:]#]+@(v[0-9]+|main|master|stable|latest)([[:space:]#]|$)' .github/workflows 2>/dev/null; then
+  echo "mutable GitHub Action reference remains" >&2
+  exit 1
+fi
+
 echo "security-check: ok"
diff --git a/support/install-verified-jerbuild.sh b/support/install-verified-jerbuild.sh
new file mode 100755
index 0000000..37e6169
--- /dev/null
+++ b/support/install-verified-jerbuild.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+lock=${JERBOA_JERBUILD_LOCK:-"$root/support/jerbuild-bootstrap.lock"}
+signers="$root/support/jerboa-release-signers"
+dest=${1:-"$root/.deps/bin/jerbuild"}
+
+read_value() {
+  local key=$1
+  sed -n "s/^${key}=//p" "$lock" | tail -n 1
+}
+
+[[ -f "$lock" ]] || { echo "verified bootstrap blocked: missing lock" >&2; exit 1; }
+status=$(read_value status)
+if [[ "$status" != ready ]]; then
+  echo "verified bootstrap blocked: ${status:-lock-status-missing}" >&2
+  exit 1
+fi
+
+base_url=$(read_value base_url)
+asset=$(read_value asset)
+expected=$(read_value sha256)
+manifest=$(read_value manifest)
+signature=$(read_value signature)
+identity=$(read_value signer_identity)
+namespace=$(read_value namespace)
+
+[[ "$base_url" == https://* ]] || { echo "bootstrap base URL must use HTTPS" >&2; exit 1; }
+[[ "$asset" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe bootstrap asset name" >&2; exit 1; }
+[[ "$manifest" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe manifest name" >&2; exit 1; }
+[[ "$signature" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe signature name" >&2; exit 1; }
+[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid pinned SHA-256" >&2; exit 1; }
+grep -Eq '^[[:space:]]*[^#[:space:]][^[:space:]]*[[:space:]]+ssh-(ed25519|rsa)[[:space:]]+' "$signers" || {
+  echo "verified bootstrap blocked: pinned signer entry missing" >&2
+  exit 1
+}
+command -v ssh-keygen >/dev/null || { echo "ssh-keygen is required" >&2; exit 1; }
+
+umask 077
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-bootstrap.XXXXXXXX")
+trap 'rm -rf "$tmp"' EXIT
+curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
+  "$base_url/$manifest" --output "$tmp/$manifest"
+curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
+  "$base_url/$signature" --output "$tmp/$signature"
+ssh-keygen -Y verify -f "$signers" -I "$identity" -n "${namespace:-file}" \
+  -s "$tmp/$signature" < "$tmp/$manifest"
+
+manifest_digest=$(awk -v name="$asset" '$2 == name {print $1}' "$tmp/$manifest")
+[[ "$manifest_digest" == "$expected" ]] || { echo "signed manifest digest mismatch" >&2; exit 1; }
+curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
+  "$base_url/$asset" --output "$tmp/$asset"
+actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')
+[[ "$actual" == "$expected" ]] || { echo "jerbuild digest mismatch" >&2; exit 1; }
+mkdir -p "$(dirname "$dest")"
+install -m 0755 "$tmp/$asset" "$dest"
+printf 'verified jerbuild sha256=%s\n' "$actual"
diff --git a/support/jerboa-release-signers b/support/jerboa-release-signers
new file mode 100644
index 0000000..4386eb3
--- /dev/null
+++ b/support/jerboa-release-signers
@@ -0,0 +1,2 @@
+# OpenSSH allowed-signers entries belong here. No authenticated Jerboa release
+# identity has been published, so CI bootstrap remains deliberately blocked.
diff --git a/support/jerbuild-bootstrap.lock b/support/jerbuild-bootstrap.lock
new file mode 100644
index 0000000..a595f1f
--- /dev/null
+++ b/support/jerbuild-bootstrap.lock
@@ -0,0 +1,11 @@
+# Fail closed until the Jerboa producer publishes a signed manifest and a
+# release identity is provisioned below. A versioned same-origin asset alone
+# is not an authenticated bootstrap.
+status=blocked-awaiting-authenticated-upstream-release
+base_url=
+asset=
+sha256=
+manifest=
+signature=
+signer_identity=jerboa-release
+namespace=file
diff --git a/tests/test-input-limit.sh b/tests/test-input-limit.sh
new file mode 100644
index 0000000..53e8b8b
--- /dev/null
+++ b/tests/test-input-limit.sh
@@ -0,0 +1,79 @@
+#!/bin/sh
+set -eu
+
+repo_root=${REPO_ROOT:-$(pwd)}
+tmp_dir=${TMPDIR:-/tmp}/jasm-input-limit-$$
+writer_pid=
+reader_pid=
+watchdog_pid=
+
+cleanup() {
+  if [ -n "$writer_pid" ]; then
+    kill "$writer_pid" 2>/dev/null || true
+    wait "$writer_pid" 2>/dev/null || true
+  fi
+  if [ -n "$reader_pid" ]; then
+    kill "$reader_pid" 2>/dev/null || true
+    wait "$reader_pid" 2>/dev/null || true
+  fi
+  if [ -n "$watchdog_pid" ]; then
+    kill "$watchdog_pid" 2>/dev/null || true
+    wait "$watchdog_pid" 2>/dev/null || true
+  fi
+  rm -rf "$tmp_dir"
+}
+trap cleanup EXIT INT TERM
+
+mkdir -p "$tmp_dir"
+
+# A sparse file exercises the pre-allocation file-length rejection.  Creating
+# it writes no payload blocks, so this remains cheap even on constrained CI.
+dd if=/dev/zero of="$tmp_dir/sparse.bin" bs=1 count=0 seek=1073741824 2>/dev/null
+if JASM_MAX_INPUT_BYTES=1024 "$repo_root/jasm" raw --arch x86-64 \
+     --file "$tmp_dir/sparse.bin" >"$tmp_dir/sparse.out" 2>"$tmp_dir/sparse.err"; then
+  echo "sparse oversized input was accepted" >&2
+  exit 1
+fi
+grep -q 'input exceeds JASM_MAX_INPUT_BYTES' "$tmp_dir/sparse.err"
+
+# A FIFO has no reliable size.  Keep producing until the reader closes it: a
+# post-read limit check would wait forever, while a bounded reader rejects as
+# soon as it observes limit+1 bytes.  The watchdog makes a regression fail
+# deterministically instead of hanging the test suite.
+mkfifo "$tmp_dir/input.fifo"
+(dd if=/dev/zero bs=65536 2>/dev/null >"$tmp_dir/input.fifo") &
+writer_pid=$!
+JASM_MAX_INPUT_BYTES=1024 "$repo_root/jasm" raw --arch x86-64 \
+  --file "$tmp_dir/input.fifo" >"$tmp_dir/fifo.out" 2>"$tmp_dir/fifo.err" &
+reader_pid=$!
+(
+  sleep 5
+  if kill "$reader_pid" 2>/dev/null; then
+    : >"$tmp_dir/fifo.timeout"
+  fi
+) &
+watchdog_pid=$!
+
+if wait "$reader_pid"; then
+  reader_status=0
+else
+  reader_status=$?
+fi
+reader_pid=
+kill "$watchdog_pid" 2>/dev/null || true
+wait "$watchdog_pid" 2>/dev/null || true
+watchdog_pid=
+
+if [ -f "$tmp_dir/fifo.timeout" ]; then
+  echo "FIFO oversized input did not stop at the configured limit" >&2
+  exit 1
+fi
+if [ "$reader_status" -eq 0 ]; then
+  echo "FIFO oversized input was accepted" >&2
+  exit 1
+fi
+grep -q 'input exceeds JASM_MAX_INPUT_BYTES' "$tmp_dir/fifo.err"
+wait "$writer_pid" 2>/dev/null || true
+writer_pid=
+
+echo "input-limit-tests: ok"