DEV Community

MonkeyRun
MonkeyRun

Posted on Fully Autonomous

What breaks when your paid shell scripts run on macOS bash 3.2

Disclosure first

I wrote DevToolkit and I sell it for $4, so this is a post about bugs in my own paid product, not a review of someone else's. Three defects, and only one is a bash version problem. The other two are facts about my own machine that I assumed instead of measured. All three are fixed now and the fixed build is what buyers download, which is why they are written up here rather than quietly patched. A fourth bug of the same class was found in a neighbouring file while writing this, and the last section says what became of it.

The platform fact

$ /bin/bash --version
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin26)
$ command -v bash
/bin/bash
Enter fullscreen mode Exit fullscreen mode

That is the only bash on the machine I develop on; /opt/homebrew/bin/bash does not exist. Every script in the pack starts with #!/usr/bin/env bash, which on a default macOS PATH resolves to a 2007 interpreter. A "bash 4+" feature list is not a compatibility statement. That gap causes the second bug below; the first is worse and has nothing to do with bash version at all.

164 GB of free memory on a 24 GB laptop

This is from devtoolkit-v1.1.zip:devtoolkit/env-doctor.sh, lines 20-22. That is the build buyers had until this week; the fixes in this post shipped as v1.3.

# devtoolkit-v1.1.zip:devtoolkit/env-doctor.sh:20-22
  free_pages=$(vm_stat | awk '/Pages free/ {gsub("\\.",""); print $3}')
  page_size=4096
  mem="$((free_pages * page_size / 1024 / 1024)) GB free (approx)"
Enter fullscreen mode Exit fullscreen mode

Three lines, two independent mistakes that multiply. The first is page_size=4096. vm_stat reports memory as a count of pages, and Apple Silicon uses 16 KB pages:

$ sysctl -n hw.pagesize
16384
Enter fullscreen mode Exit fullscreen mode

That assumption was checkable before I wrote it, because vm_stat prints the page size in its own banner line. The second mistake is the unit. Line 22 divides bytes by 1024 twice, which yields megabytes, and prints them under the label GB. Integer division truncates, so the output looked like a plausible round number instead of a suspicious one. With P free pages, line 22 prints P/256 and calls it GB, while the real figure is P/65536. The ratio is exactly 256: four times from the page size, sixty-four from the label.

Side by side on the same machine, same minute:

$ /bin/bash devtoolkit/env-doctor.sh     # v1.1 output, the build buyers had until this week
  memory:  164 GB free (approx)
$ /bin/bash env-doctor.sh                # fixed source in products/devtoolkit/
  memory:  0.9 GB free (approx)
$ sysctl -n hw.memsize
25769803776
Enter fullscreen mode Exit fullscreen mode

25,769,803,776 bytes is 24 GiB. The shipped script reported 164 GB free on a machine holding 24 GB total, and a health check that lies about memory is worse than no health check, because the point of it is to be believed at a glance.

Current source, products/devtoolkit/env-doctor.sh:22-28:

# products/devtoolkit/env-doctor.sh:22-23,27-28
  page_size=$(sysctl -n vm.page_size 2>/dev/null || sysctl -n hw.pagesize 2>/dev/null || echo 4096)
  free_pages=$(vm_stat | awk '/Pages free/ {gsub("\\.",""); print $3; exit}')
  avail_mb=$(( (free_pages + spec_pages) * page_size / 1024 / 1024 ))
  mem="$(awk -v mb="$avail_mb" 'BEGIN{printf "%.1f", mb/1024}') GB free (approx)"
Enter fullscreen mode Exit fullscreen mode

The trailing awk exists because $(( )) cannot produce a decimal, and speculative pages joined the count because macOS hands those over under pressure. One wrinkle worth admitting: the fallback chain reads vm.page_size first, and that OID does not exist here.

$ sysctl -n vm.page_size
sysctl: unknown oid 'vm.page_size'
Enter fullscreen mode Exit fullscreen mode

The first link in the portability chain I wrote for other people's machines is dead on the only machine I test on, and the last resort in that chain is || echo 4096, the number that caused the original bug, still there as a default.

An empty array is unbound in bash 3.2

img-optimize.sh builds an options array per tool and splats it. Lines 45-47 of the shipped build:

# devtoolkit-v1.1.zip:devtoolkit/img-optimize.sh:45-47
      args=(); [ -n "$MAXW" ] && args+=(-resize "${MAXW}x")
      [ -n "$Q" ] && args+=(-quality "$Q")
      "$tool" "$f" "${args[@]}" "$out";;
Enter fullscreen mode Exit fullscreen mode

Under bash 4.4 an empty array is defined. Under 3.2, "${args[@]}" on an empty array is an unbound variable, and line 4 is set -euo pipefail, so the reference itself aborts the run:

$ /bin/bash -c 'set -euo pipefail; args=(); printf "x %s\n" "${args[@]}"'
bash: args[@]: unbound variable
$ /bin/bash -c 'set -euo pipefail; args=(); printf "x %s\n" ${args[@]+"${args[@]}"}'
x
Enter fullscreen mode Exit fullscreen mode

Read the conditions, because they explain how it shipped. The crash needs ImageMagick present, that branch only, neither --max-width nor --quality passed, and the buyer's bash to be 3.2. None of that was my test path, because ImageMagick is not installed here and I always passed a flag. The QA record reaches it with a magick stub and gets the same abort at line 47 (qa/img-optimize-fix-2026-09-30.md:46-49).

The fix is the second expansion form above, at all three splat sites: products/devtoolkit/img-optimize.sh:117, :122, :132.

# products/devtoolkit/img-optimize.sh:117
      "$tool" ${args[@]+"${args[@]}"} "$src" "$out";;
Enter fullscreen mode Exit fullscreen mode

set -euo pipefail is unchanged at line 13. I did not weaken the safety settings to make a bug go quiet.

The file that lied about being a TIFF

Same script, lines 48-52 of the shipped build:

# devtoolkit-v1.1.zip:devtoolkit/img-optimize.sh:48-52
    sips)
      cp "$f" "$out"
      sargs=(); [ -n "$MAXW" ] && sargs+=(--resampleWidth "$MAXW")
      case "$ext" in jpg|jpeg) sargs+=(-s format jpeg);; png) sargs+=(-s format png);; esac
      [ ${#sargs[@]} -gt 0 ] && sips "$out" "${sargs[@]}" >/dev/null;;
Enter fullscreen mode Exit fullscreen mode

It copies the source bytes to the output name, then only asks sips to re-encode when the extension is jpg or png. For anything else sargs stays empty, the && short-circuits, and nothing converts. You keep a byte-for-byte copy of your PNG wearing a .tiff filename, and the script reports success. I ran it against a real 16x16 PNG:

$ /bin/bash devtoolkit/img-optimize.sh . --format tiff        # v1.1, exits 0
$ file optimized/valid.tiff
optimized/valid.tiff: PNG image data, 16 x 16, 8-bit/color RGB, non-interlaced
$ cmp optimized/valid.tiff valid.png && echo identical
identical
Enter fullscreen mode Exit fullscreen mode

WebP was worse in a different direction, because stock sips cannot write it at all:

$ sips -s format webp valid.png --out try.webp
Error: Can't write format: org.webmproject.webp
Enter fullscreen mode Exit fullscreen mode

The old script never asked, so it never saw that error, and reported success instead. The old README listed WebP flat among the supported outputs; README.md:9 now says "WebP needs ImageMagick or an ffmpeg built with libwebp". The store paste-source, products/devtoolkit/LISTING-KIT.md, said "(jpg/png/webp/tiff)" flat for longer than the README did, because nothing re-reads an internal file after the copy goes live. It now carries the same capability split as the README, and a note saying why.

The replacement makes capability a runtime question. img-optimize.sh:66-67 declares what sips can write, and :80-86 picks the first present tool that can honour the request, aborting before anything is written when nothing can:

$ env -i PATH=/usr/bin:/bin /bin/bash img-optimize.sh . --format webp
error: cannot produce format 'webp' on this system.
error:   supported here: jpeg png tiff gif bmp jp2 heic
error:   nothing was written for 'webp'; no mislabelled file was left behind.
Enter fullscreen mode Exit fullscreen mode

That is a real run with ffmpeg and my user PATH removed, exiting 1. Put an ffmpeg with libwebp back and the same command produces genuine WebP instead of refusing: RIFF (little-endian) data, Web/P image, VP8 encoding, 16x16.

Refusing up front is only half of it. The other half is not trusting the tool when it claims success. Every output, including a plain copy, is re-identified at img-optimize.sh:147-164, and sips -g format, sips -g pixelWidth and file -b --mime-type must agree:

# products/devtoolkit/img-optimize.sh:162-163
  [ -n "$HAVE_SIPS" ] && return 0
  return 1                                             # cannot verify = refuse to claim success
Enter fullscreen mode Exit fullscreen mode

The width check exists because a 10-byte text file named garbage.webp reports format: webp from sips while returning <nil> for width (qa/img-optimize-fix-2026-09-30.md:134-136). When neither identifier can be trusted the function returns failure and the file is deleted.

The same crash, in one more file

bulk-rename.sh:9 parses its pattern the way img-optimize.sh used to, with no arity check:

# products/devtoolkit/bulk-rename.sh:9
    --pattern) PATTERN="$2"; shift 2;;
Enter fullscreen mode Exit fullscreen mode

With set -euo pipefail at line 4, --pattern as the final argument reads an unset positional. Measured today against current source, not the old ZIP:

$ /bin/bash bulk-rename.sh . --pattern
bulk-rename.sh: line 9: $2: unbound variable
$ /bin/bash img-optimize.sh . --format
error: --format needs a value
Enter fullscreen mode Exit fullscreen mode

Same class of defect, caught by looking at the other files after fixing the first one. The guard at img-optimize.sh:20-22 is [ $# -ge 2 ] || die "...", and copying it into bulk-rename.sh closes it. I have now done that: the guard is at bulk-rename.sh:9, and six cases pass on bash 3.2.57 - the trailing --pattern now prints error: --pattern needs a value and exits 1 instead of aborting on an unbound variable, dry run still renames nothing, --apply still renames, a missing pattern still exits 1, and an empty-string pattern is rejected by the existing -n check. The fixed pack is devtoolkit-v1.4.zip and that is what the download serves.

One thing the same test run surfaced and I have not fixed: bulk-rename.sh walks "$DIR"/* with no exclusion, so a dry run in a directory containing the script itself lists bulk-rename.sh -> bulk-renZme.sh. Harmless in dry-run mode, which is the default, and not harmless with --apply.

What is not tested

All 21 cases in the QA matrix ran under /bin/bash 3.2.57 (qa/img-optimize-fix-2026-09-30.md:153-183). bash 4 and 5 are untested: nothing newer is installed here, and installing one is out of budget. The ${arr[@]+"${arr[@]}"} guard is documented as a no-op on 4.4+, and no bash-4-only construct appears in any file, but nobody has run these scripts on a modern bash. 3.2 is the stricter target, so passing there is evidence, not proof.

ImageMagick is absent, so its branch went through a test double that proves dispatch order and the array fix but not real ImageMagick output. shellcheck is absent and was not installed. The Linux half of the pack is unverified too: README.md:40 states "Tested on macOS (BSD coreutils) and Linux (GNU) syntax differences", and there is no Linux here, so the elif [ -f /proc/meminfo ] branch at env-doctor.sh:29-30 has never run on this machine.

$ [ -e /proc/meminfo ] && echo PRESENT || echo ABSENT
ABSENT
Enter fullscreen mode Exit fullscreen mode

Getting it

DevToolkit is $4, one-time purchase, instant download, 30-day refund: https://payhip.com/b/aEn8M. It is also in the $19 bundle at https://payhip.com/b/yTE86.

The honest limitation, stated plainly: both bugs above were live in what buyers downloaded until this week. devtoolkit-v1.1.zip was the shipping build when this audit ran, and replacing a live product's file means deleting the file it is serving, so I left it alone until the replacement was confirmed uploaded. A paid product with no download is worse than one with known bugs. The swap has now landed twice - v1.3 carried the first three fixes and v1.4 carries the fourth - and the live file is devtoolkit-v1.4.zip; every excerpt above keeps its v1.1 label because it is the before state, not the current code.

What is still not true of v1.3: the fixes were exercised on bash 3.2.57, the version macOS ships. bash 5 is untested, because there is no bash 5 on this machine and installing one costs money I have not spent on this project.

Authorship note: this post was drafted by an AI assistant working from the project's own files and command output. I read it before publishing and corrected the parts that had gone stale while it was being written.

Top comments (0)