From 00650a54eecb651e39bcd014adcad21dae995713 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:58:04 +0000 Subject: [PATCH 1/2] eval-under-loop: options for image allocation, direct I/O, mkfs and mount --alloc zero|sparse|fallocate picks how the backing image gets its size. zero (dd from /dev/zero) stays the default so CI cells keep measuring the same thing; sparse (truncate) and fallocate are instant, which is what makes TB-sized images practical. fallocate uses fallocate(2) directly, so a host without it (NFS < 4.2) fails loudly instead of glibc's posix_fallocate silently writing every block. --direct-io attaches with losetup --direct-io=on, so image I/O is not cached twice, and warns if the kernel kept the device buffered. --mkfs-opts / --mount-opts pass extra arguments through (e.g. btrfs -m single, discard=async). The image's apparent vs allocated size is logged, so a sparse image is visible as such. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0135xivQQnx5bWMzWD91J5TR --- GOTCHAS.md | 11 +++-- bin/eval-under-loop | 93 +++++++++++++++++++++++++++++++++++++------ tests/eval-under.bats | 9 +++++ 3 files changed, 97 insertions(+), 16 deletions(-) diff --git a/GOTCHAS.md b/GOTCHAS.md index af18c25..4f1e850 100644 --- a/GOTCHAS.md +++ b/GOTCHAS.md @@ -19,14 +19,17 @@ rediscovered the hard way. ### Loop (`bin/eval-under-loop`) -A sparse backing image is `dd`'d, `mkfs.`'d, and loop-mounted. +A backing image is created under `$TMPDIR`, `mkfs.`'d, and +loop-mounted. | Knob | Value | Why | | --- | --- | --- | -| `mkfs` options | none -- distro defaults | Whatever a user gets from `mkfs.ext4 /dev/sdX`, deliberately. | -| Image size | per target, `loop-size-mb` in `evals/matrix.yaml` | `git annex test` needs room for many small objects; the other three do not. | +| `mkfs` options | none -- distro defaults | Whatever a user gets from `mkfs.ext4 /dev/sdX`, deliberately. `--mkfs-opts` adds some; CI passes none. | +| Image size | per target, `loop-size-mb` in `evals/matrix.yaml` | `git annex test` needs room for many small objects; the other three do not. btrfs refuses anything under 109 MiB. | +| Image allocation | `--alloc zero`: `dd if=/dev/zero`, every block written | Works on any host fs. `sparse` (`truncate`) and `fallocate` are instant, for large images; `fallocate` fails outright where the host lacks it (NFS < 4.2) rather than silently writing every block. | +| Loop I/O | buffered (image cached in the host page cache) | `--direct-io` attaches with `losetup --direct-io=on`; if the kernel keeps the device buffered (host fs without O_DIRECT) the backend warns. | | Mount (vfat, msdos, exfat, ntfs) | `-o uid=,gid=` | These filesystems store no ownership. Without `uid=`, everything belongs to root and an unprivileged wrapped command cannot write. | -| Mount (everything else) | plain `mount`, then `chown ` on the mountpoint | ext4/xfs/btrfs carry real ownership; setting it once on the root is enough. | +| Mount (everything else) | `mount -o defaults`, then `chown ` on the mountpoint | ext4/xfs/btrfs carry real ownership; setting it once on the root is enough. | **The vfat consequence worth knowing.** `fmask`, `dmask` and `umask` are left at kernel defaults, so every file on the mount reads as mode `0755` diff --git a/bin/eval-under-loop b/bin/eval-under-loop index aa796f4..a55c20d 100755 --- a/bin/eval-under-loop +++ b/bin/eval-under-loop @@ -30,6 +30,11 @@ SET_HOME="${EVAL_UNDER_HOME_ON_MOUNT:-0}" FS="${EVAL_UNDER_LOOP_FS:-${DATALAD_TESTS_TEMP_FS:-vfat}}" SIZE_MB="${EVAL_UNDER_LOOP_SIZE_MB:-${DATALAD_TESTS_TEMP_FSSIZE:-100}}" +ALLOC="${EVAL_UNDER_LOOP_ALLOC:-zero}" +DIRECT_IO="${EVAL_UNDER_LOOP_DIRECT_IO:-0}" +MKFS_OPTS="${EVAL_UNDER_LOOP_MKFS_OPTS:-}" +MOUNT_OPTS="${EVAL_UNDER_LOOP_MOUNT_OPTS:-}" + usage() { cat <<'EOF' Usage: eval-under-loop [OPTIONS] -- CMD [ARGS...] @@ -69,6 +74,37 @@ Options (flag / env var / default / purpose): --size MB EVAL_UNDER_LOOP_SIZE_MB 100 Backing image size in megabytes. Bumped from datalad's original 10 -- git-annex's testsuite fills that in a few seconds. + btrfs refuses anything under 109 MiB. + + --alloc MODE EVAL_UNDER_LOOP_ALLOC zero + How the backing image gets its size: + + zero dd from /dev/zero: every block written and allocated + on the host up front. Slow and host-space-hungry at + large sizes (a TB image is a TB of writes), but + works on any host filesystem. + sparse truncate: instant, allocates nothing; the host fills + in blocks as the filesystem inside writes them. The + host running out of space surfaces inside as EIO. + fallocate fallocate(2): instant, reserves every block without + writing it. Fails -- deliberately, with no + write-everything fallback -- where the host does not + support it (e.g. NFS older than 4.2, vfat). + + --direct-io EVAL_UNDER_LOOP_DIRECT_IO (unset) + Attach with `losetup --direct-io=on`, so image I/O bypasses the + host page cache instead of being cached twice (once for the + filesystem inside, once for the image file). The host filesystem + must support O_DIRECT (tmpfs does not); a warning is printed when + the kernel declined. + + --mkfs-opts OPTS EVAL_UNDER_LOOP_MKFS_OPTS (none) + Extra arguments for mkfs., whitespace-split, e.g. "-m single" + or "-K" (skip the initial discard) for btrfs. + + --mount-opts OPTS EVAL_UNDER_LOOP_MOUNT_OPTS (none) + Comma-separated options added to the loop mount's -o, e.g. + "discard=async,compress=zstd" for btrfs. -h, --help Print this help. @@ -97,6 +133,10 @@ while [ $# -gt 0 ]; do --set-home) SET_HOME=1; shift ;; --fs) FS="$2"; shift 2 ;; --size) SIZE_MB="$2"; shift 2 ;; + --alloc) ALLOC="$2"; shift 2 ;; + --direct-io) DIRECT_IO=1; shift ;; + --mkfs-opts) MKFS_OPTS="$2"; shift 2 ;; + --mount-opts) MOUNT_OPTS="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; --) shift; break ;; *) echo "unknown arg: $1" >&2; usage >&2; exit 2 ;; @@ -105,6 +145,15 @@ done [ $# -gt 0 ] || { echo "no command given" >&2; usage >&2; exit 2; } +case "$ALLOC" in + zero|sparse|fallocate) ;; + *) echo "unknown --alloc mode: $ALLOC (expected zero|sparse|fallocate)" >&2 + exit 2 ;; +esac + +# Whitespace-split once here, so an option string cannot glob. +read -ra MKFS_ARGS <<<"$MKFS_OPTS" + if [ "$(id -u)" -ne 0 ]; then command -v sudo >/dev/null || { echo "must run as root (or have sudo available)" >&2; exit 2; } @@ -149,21 +198,39 @@ LOOP="" MNT_CREATED=0 start_loop() { - log "creating ${SIZE_MB}MB backing image $IMAGE" - # Use bs=1M count=SIZE_MB (portable, unambiguous). datalad's original - # used bs=1032192 which is a slightly-off historical vfat-cluster - # rounding; bs=1M is simpler and works for every fs we care about. - dd if=/dev/zero "of=$IMAGE" bs=1M "count=$SIZE_MB" status=none - - log "losetup --find --show $IMAGE" - LOOP=$("${SUDO[@]}" losetup --find --show "$IMAGE") + local losetup_opts=(--find --show) + log "creating ${SIZE_MB}MB backing image $IMAGE (--alloc $ALLOC)" + case "$ALLOC" in + # Use bs=1M count=SIZE_MB (portable, unambiguous). datalad's original + # used bs=1032192 which is a slightly-off historical vfat-cluster + # rounding; bs=1M is simpler and works for every fs we care about. + zero) dd if=/dev/zero "of=$IMAGE" bs=1M "count=$SIZE_MB" status=none ;; + sparse) truncate -s "${SIZE_MB}M" "$IMAGE" ;; + # util-linux fallocate calls fallocate(2) directly; unlike glibc's + # posix_fallocate() it never falls back to writing every block. + fallocate) fallocate -l "${SIZE_MB}M" "$IMAGE" ;; + esac + echo "I: image on host: $(du -h --apparent-size "$IMAGE" | cut -f1) apparent," \ + "$(du -h "$IMAGE" | cut -f1) allocated" + + if [ "$DIRECT_IO" = 1 ]; then losetup_opts+=(--direct-io=on); fi + log "losetup ${losetup_opts[*]} $IMAGE" + LOOP=$("${SUDO[@]}" losetup "${losetup_opts[@]}" "$IMAGE") echo "I: loop device: $LOOP" + # losetup falls back to buffered I/O when the host fs cannot do + # O_DIRECT; say so rather than let a run be read as direct when not. + if [ "$DIRECT_IO" = 1 ] \ + && [ "$("${SUDO[@]}" losetup --list --noheadings --output DIO "$LOOP" \ + | tr -d ' ')" != 1 ]; then + echo "W: --direct-io requested, but $LOOP is using buffered I/O" >&2 + fi - log "mkfs.$FS $LOOP" - "${SUDO[@]}" "mkfs.$FS" "$LOOP" >/dev/null + log "mkfs.$FS${MKFS_OPTS:+ $MKFS_OPTS} $LOOP" + "${SUDO[@]}" "mkfs.$FS" "${MKFS_ARGS[@]}" "$LOOP" >/dev/null } mount_loop() { + local opts log "mounting $LOOP ($FS) at $MNT" [ -d "$MNT" ] || MNT_CREATED=1 "${SUDO[@]}" mkdir -p "$MNT" @@ -172,10 +239,12 @@ mount_loop() { # option to grant the invoking user write access. case "$FS" in vfat|msdos|exfat|ntfs) - "${SUDO[@]}" mount -o "uid=$INVOKER_UID,gid=$INVOKER_GID" "$LOOP" "$MNT" + opts="uid=$INVOKER_UID,gid=$INVOKER_GID${MOUNT_OPTS:+,$MOUNT_OPTS}" + "${SUDO[@]}" mount -o "$opts" "$LOOP" "$MNT" ;; *) - "${SUDO[@]}" mount "$LOOP" "$MNT" + opts="${MOUNT_OPTS:-defaults}" + "${SUDO[@]}" mount -o "$opts" "$LOOP" "$MNT" "${SUDO[@]}" chown "$INVOKER_UID:$INVOKER_GID" "$MNT" ;; esac diff --git a/tests/eval-under.bats b/tests/eval-under.bats index f06595a..d84a156 100644 --- a/tests/eval-under.bats +++ b/tests/eval-under.bats @@ -328,3 +328,12 @@ FAKE done done } + +# ------------------------------------------------------- loop backend + +@test "loop: an unknown --alloc mode is rejected before anything is created" { + run bash -c "TMPDIR='$BATS_TEST_TMPDIR' '$REPO_ROOT/bin/eval-under-loop' --alloc bogus -- true 2>&1" + [ "$status" -eq 2 ] + [[ "$output" == *"unknown --alloc mode: bogus"* ]] + [ -z "$(ls "$BATS_TEST_TMPDIR")" ] +} From da6eadea568de01bed2dc26ca44902b1b455d071 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:23:02 +0000 Subject: [PATCH 2/2] eval-under-loop: address review of the image/mkfs/mount options - --direct-io on a host fs without O_DIRECT: current kernels refuse to attach rather than fall back, so say why losetup failed instead of exiting with only its EINVAL. Help/GOTCHAS no longer claim tmpfs lacks O_DIRECT (it has it since 6.6; ramfs is the example now). - --alloc fallocate: vfat implements it by zeroing in the kernel, so it is not an example of failing; list NFS < 4.2, ramfs, ext4 w/o extents. - --mount-opts ro no longer aborts on the post-mount chown; warns. - --mkfs-opts folds newlines into spaces (read -a stopped at the first) and documents that there is no quoting. - --size is validated as a positive integer up front, so the three alloc modes cannot read a bad value three different ways. - Help/README/header no longer say the image is always dd'd; README gets a large sparse btrfs example; default columns say (unset) like the nfs backend. - bats: help/parse test for the new options (incl. value consumption), missing-value and --size validation tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0135xivQQnx5bWMzWD91J5TR --- GOTCHAS.md | 4 +-- README.md | 7 +++- bin/eval-under-loop | 76 ++++++++++++++++++++++++++++--------------- tests/eval-under.bats | 40 ++++++++++++++++++++--- 4 files changed, 93 insertions(+), 34 deletions(-) diff --git a/GOTCHAS.md b/GOTCHAS.md index 4f1e850..a55629f 100644 --- a/GOTCHAS.md +++ b/GOTCHAS.md @@ -26,8 +26,8 @@ loop-mounted. | --- | --- | --- | | `mkfs` options | none -- distro defaults | Whatever a user gets from `mkfs.ext4 /dev/sdX`, deliberately. `--mkfs-opts` adds some; CI passes none. | | Image size | per target, `loop-size-mb` in `evals/matrix.yaml` | `git annex test` needs room for many small objects; the other three do not. btrfs refuses anything under 109 MiB. | -| Image allocation | `--alloc zero`: `dd if=/dev/zero`, every block written | Works on any host fs. `sparse` (`truncate`) and `fallocate` are instant, for large images; `fallocate` fails outright where the host lacks it (NFS < 4.2) rather than silently writing every block. | -| Loop I/O | buffered (image cached in the host page cache) | `--direct-io` attaches with `losetup --direct-io=on`; if the kernel keeps the device buffered (host fs without O_DIRECT) the backend warns. | +| Image allocation | `--alloc zero`: `dd if=/dev/zero`, every block written | Works on any host fs. `sparse` (`truncate`) and `fallocate` are instant, for large images; `fallocate` fails outright where the host lacks it (NFS < 4.2, ramfs) rather than silently writing every block -- though vfat implements it by zeroing in the kernel. | +| Loop I/O | buffered (image cached in the host page cache) | `--direct-io` attaches with `losetup --direct-io=on`; on a host fs without O_DIRECT (ramfs; tmpfs before Linux 6.6) current kernels refuse to attach and the backend stops with an error; older ones attach buffered and the backend warns. | | Mount (vfat, msdos, exfat, ntfs) | `-o uid=,gid=` | These filesystems store no ownership. Without `uid=`, everything belongs to root and an unprivileged wrapped command cannot write. | | Mount (everything else) | `mount -o defaults`, then `chown ` on the mountpoint | ext4/xfs/btrfs carry real ownership; setting it once on the root is enough. | diff --git a/README.md b/README.md index aa51479..c8b32a6 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,11 @@ sudo bin/eval-under nfs --set-home -- bash -c 'cd "$HOME" && git annex test' sudo bin/eval-under nfs --set-home --mount-opts actimeo=0 -- \ bash -c 'cd "$HOME" && git annex test' +# A large btrfs volume without writing it out first: sparse image, +# page cache bypassed for the image file +sudo bin/eval-under loop --fs btrfs --size 2000000 --alloc sparse --direct-io \ + --mount-opts discard=async -- bash -c 'df -h "$TMPDIR"' + # Skip teardown to poke around after a failure sudo bin/eval-under beegfs --set-home --keep -- some-failing-command @@ -210,7 +215,7 @@ into `bin/eval-under`, bumped with each release tag. | `bin/eval-under` | Dispatcher: routes to `bin/eval-under-` | | `bin/eval-under-beegfs` | BeeGFS backend (containerised cluster + kernel client mount) | | `bin/eval-under-nfs` | NFS backend (localhost loopback export) | -| `bin/eval-under-loop` | Loop-device backend (dd + losetup + mkfs. + mount) | +| `bin/eval-under-loop` | Loop-device backend (image + losetup + mkfs. + mount) | | `fixtures/beegfs/docker-compose-v7.yml` | BeeGFS v7 test cluster (mgmtd + meta + storage), `network_mode: host` | | `fixtures/beegfs/docker-compose-v8.yml` | Same, for BeeGFS v8.x (different mgmtd command style / gRPC control plane) | | `fixtures/beegfs/beegfs-*.conf.template` | Minimal client + helperd confs for the throwaway cluster | diff --git a/bin/eval-under-loop b/bin/eval-under-loop index a55c20d..2b55b36 100755 --- a/bin/eval-under-loop +++ b/bin/eval-under-loop @@ -5,8 +5,8 @@ # Generated with Claude Code 2.1.233 / Claude Opus 4.7 # # eval-under-loop: run a command with TMPDIR / DATALAD_TESTS_TEMP_DIR (and -# optionally HOME) pointing at a loop-mounted local filesystem (dd a backing -# image, losetup, mkfs., mount). +# optionally HOME) pointing at a loop-mounted local filesystem (create a +# backing image, losetup, mkfs., mount). # # Backend of the eval-under framework. Absorbed from datalad's # tools/eval_under_testloopfs, with framework env-var names, a teardown @@ -41,7 +41,8 @@ Usage: eval-under-loop [OPTIONS] -- CMD [ARGS...] Run CMD with TMPDIR / DATALAD_TESTS_TEMP_DIR (and optionally HOME) pointing at a loop-mounted local filesystem. A fresh backing image is -dd'd, mkfs.'d, and loop-mounted for the duration of the command. +created (see --alloc), mkfs.'d, and loop-mounted for the duration +of the command. Requires `mkfs.` for the chosen filesystem. For the default (`vfat`) that means `dosfstools`. Also common: `xfsprogs` (xfs), @@ -84,27 +85,35 @@ Options (flag / env var / default / purpose): large sizes (a TB image is a TB of writes), but works on any host filesystem. sparse truncate: instant, allocates nothing; the host fills - in blocks as the filesystem inside writes them. The - host running out of space surfaces inside as EIO. - fallocate fallocate(2): instant, reserves every block without - writing it. Fails -- deliberately, with no - write-everything fallback -- where the host does not - support it (e.g. NFS older than 4.2, vfat). + in blocks as the filesystem inside writes them. If + the host runs out of space, writes inside the image + fail with I/O errors rather than ENOSPC. + fallocate fallocate(2): reserves every block without writing + it -- instant on most hosts (vfat zeroes them in the + kernel, so not there). Fails, with no + write-everything fallback of its own, where the host + does not support it (NFS older than 4.2, ramfs, ext4 + without extents). --direct-io EVAL_UNDER_LOOP_DIRECT_IO (unset) Attach with `losetup --direct-io=on`, so image I/O bypasses the host page cache instead of being cached twice (once for the - filesystem inside, once for the image file). The host filesystem - must support O_DIRECT (tmpfs does not); a warning is printed when - the kernel declined. - - --mkfs-opts OPTS EVAL_UNDER_LOOP_MKFS_OPTS (none) - Extra arguments for mkfs., whitespace-split, e.g. "-m single" - or "-K" (skip the initial discard) for btrfs. - - --mount-opts OPTS EVAL_UNDER_LOOP_MOUNT_OPTS (none) - Comma-separated options added to the loop mount's -o, e.g. - "discard=async,compress=zstd" for btrfs. + filesystem inside, once for the image file). The env var turns + it on when set to 1. The host filesystem must support O_DIRECT + (ramfs does not; tmpfs does since Linux 6.6): current kernels + then refuse to attach, and this backend stops with an error. + Older kernels may attach buffered instead; that is warned about. + + --mkfs-opts OPTS EVAL_UNDER_LOOP_MKFS_OPTS (unset) + Extra arguments for mkfs., split on whitespace (newlines + included) with no quoting -- so no argument can contain a space. + E.g. "-m single" or "-K" (skip the initial discard) for btrfs. + + --mount-opts OPTS EVAL_UNDER_LOOP_MOUNT_OPTS (unset) + Comma-separated options for the loop mount's -o (appended to + uid=/gid= for vfat and friends), e.g. + "discard=async,compress=zstd" for btrfs. With "ro" the mountpoint + cannot be chown'd to the invoking user; that is warned about. -h, --help Print this help. @@ -145,14 +154,20 @@ done [ $# -gt 0 ] || { echo "no command given" >&2; usage >&2; exit 2; } +case "$SIZE_MB" in + ''|*[!0-9]*|0) echo "--size must be a positive integer (MB), got: $SIZE_MB" >&2 + exit 2 ;; +esac + case "$ALLOC" in zero|sparse|fallocate) ;; *) echo "unknown --alloc mode: $ALLOC (expected zero|sparse|fallocate)" >&2 exit 2 ;; esac -# Whitespace-split once here, so an option string cannot glob. -read -ra MKFS_ARGS <<<"$MKFS_OPTS" +# Whitespace-split once here, so an option string cannot glob. read +# stops at the first newline, so fold those into spaces first. +read -ra MKFS_ARGS <<<"${MKFS_OPTS//$'\n'/ }" if [ "$(id -u)" -ne 0 ]; then command -v sudo >/dev/null || { @@ -215,17 +230,23 @@ start_loop() { if [ "$DIRECT_IO" = 1 ]; then losetup_opts+=(--direct-io=on); fi log "losetup ${losetup_opts[*]} $IMAGE" - LOOP=$("${SUDO[@]}" losetup "${losetup_opts[@]}" "$IMAGE") + if ! LOOP=$("${SUDO[@]}" losetup "${losetup_opts[@]}" "$IMAGE"); then + if [ "$DIRECT_IO" = 1 ]; then + echo "E: losetup --direct-io=on failed; the host filesystem under" \ + "$IMAGE likely lacks O_DIRECT. Retry without --direct-io." >&2 + fi + exit 1 + fi echo "I: loop device: $LOOP" - # losetup falls back to buffered I/O when the host fs cannot do - # O_DIRECT; say so rather than let a run be read as direct when not. + # Older kernels attach buffered rather than refuse when the host fs + # cannot do O_DIRECT; say so rather than let a run be read as direct. if [ "$DIRECT_IO" = 1 ] \ && [ "$("${SUDO[@]}" losetup --list --noheadings --output DIO "$LOOP" \ | tr -d ' ')" != 1 ]; then echo "W: --direct-io requested, but $LOOP is using buffered I/O" >&2 fi - log "mkfs.$FS${MKFS_OPTS:+ $MKFS_OPTS} $LOOP" + log "mkfs.$FS${MKFS_ARGS[*]:+ ${MKFS_ARGS[*]}} $LOOP" "${SUDO[@]}" "mkfs.$FS" "${MKFS_ARGS[@]}" "$LOOP" >/dev/null } @@ -245,7 +266,8 @@ mount_loop() { *) opts="${MOUNT_OPTS:-defaults}" "${SUDO[@]}" mount -o "$opts" "$LOOP" "$MNT" - "${SUDO[@]}" chown "$INVOKER_UID:$INVOKER_GID" "$MNT" + "${SUDO[@]}" chown "$INVOKER_UID:$INVOKER_GID" "$MNT" \ + || echo "W: could not chown $MNT (mounted with -o $opts)" >&2 ;; esac "${SUDO[@]}" mount | grep -F "$MNT" | sed 's/^/I: /' diff --git a/tests/eval-under.bats b/tests/eval-under.bats index 430126e..d4a3b41 100644 --- a/tests/eval-under.bats +++ b/tests/eval-under.bats @@ -6,7 +6,8 @@ # discovery, dispatch, and version reporting. # # Everything here is unprivileged and mounts nothing -- it covers the -# entry point, not the backends' actual mount/teardown logic (that needs +# entry point and the backends' option parsing, not their mount/teardown +# logic (that needs # root, a kernel module, and a live cluster; see the CI matrix). The # dispatcher is exercised against throwaway trees of stub backends, so # the suite does not change shape every time a real backend is added. @@ -352,9 +353,40 @@ FAKE # ------------------------------------------------------- loop backend -@test "loop: an unknown --alloc mode is rejected before anything is created" { - run bash -c "TMPDIR='$BATS_TEST_TMPDIR' '$REPO_ROOT/bin/eval-under-loop' --alloc bogus -- true 2>&1" +@test "loop: image/mkfs/mount options are documented and parsed" { + run "$REPO_ROOT/bin/eval-under-loop" --help + [ "$status" -eq 0 ] + for opt in --alloc --direct-io --mkfs-opts --mount-opts \ + EVAL_UNDER_LOOP_ALLOC EVAL_UNDER_LOOP_DIRECT_IO \ + EVAL_UNDER_LOOP_MKFS_OPTS EVAL_UNDER_LOOP_MOUNT_OPTS; do + [[ "$output" == *"$opt"* ]] || { echo "--help lacks $opt" >&2; return 1; } + done + # Each value-taking option must consume exactly its value, or --help + # would be swallowed / rejected as an unknown arg. + run "$REPO_ROOT/bin/eval-under-loop" --alloc sparse --direct-io \ + --mkfs-opts "-m 0" --mount-opts noatime --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: eval-under-loop"* ]] +} + +@test "loop: --mkfs-opts without a value is an error, not a silent skip" { + run "$REPO_ROOT/bin/eval-under-loop" --mkfs-opts + [ "$status" -ne 0 ] +} + +@test "loop: an unknown --alloc mode is rejected with exit 2" { + run bash -c "'$REPO_ROOT/bin/eval-under-loop' --alloc bogus -- true 2>&1" [ "$status" -eq 2 ] [[ "$output" == *"unknown --alloc mode: bogus"* ]] - [ -z "$(ls "$BATS_TEST_TMPDIR")" ] +} + +@test "loop: a non-integer or zero --size is rejected with exit 2" { + local size + for size in 1.5 -5 0 abc ""; do + run bash -c "'$REPO_ROOT/bin/eval-under-loop' --size '$size' -- true 2>&1" + if [ "$status" -ne 2 ] || [[ "$output" != *"--size must be a positive integer"* ]]; then + echo "--size '$size': expected exit 2, got $status: $output" >&2 + return 1 + fi + done }