Portability notes
August 30, 2026 · View on GitHub
Shell- and platform-specific behaviour that has actually broken this library, with the evidence and the resulting rule. Every entry here cost a red CI leg or a bug report to find; none of it is speculative.
This is a lookup table for when something is red on one platform and green everywhere else, so it is grouped by the shell or system that surprises you rather than by the shlib function involved. The project's own conventions, architecture and open work live in CONTRIBUTING.md.
Two habits that make this file shorter over time:
- When a leg goes red for a reason that is not shlib's bug, add the entry here rather than working around it silently.
- Prove the surprise before recording it. Several entries below were first diagnosed wrongly, and the wrong diagnosis suggested a fix that would not have worked.
Shells
bash 3.2 — macOS /bin/sh
- bash 3.2 (macOS
/bin/sh) mis-parsescaseinside$( )— it reads the pattern's closing)as the end of the command substitution. The balanced form(pattern)fixes it, but shfmt normalises that straight back out, so the practical rule is: nocaseinside a command substitution.
ksh93
-
ksh93 on Linux implements pipelines with socketpairs, so
/dev/stdinis a socket and cannot be opened by path. Never pass/dev/stdinas a filename; invoke the tool with no file operand so it reads fd 0. -
ksh93 does not honour a function redefined inside a subshell — the caller still resolves the outer definition, where sh/dash/bash/zsh all pick up the stub. So test stubs cannot be scoped with
( … ). Stub at the top level and drive behaviour with variables; see theexecutetests ininstall_test.sh. Repeatedly redefining andunset -f-ing functions also made ksh93 crash. -
ksh93 loses a function's stderr when the function contains a nested command substitution and the caller captures it with
$(f 2>&1). This silently swallowed an error message; the fix was to move the command substitution out of the function — seenormalize_platformsininstall/runner.sh.
zsh
-
zsh sets
$0to the function name inside a function (FUNCTION_ARGZERO); POSIX shells report the script name in every scope. Solog_prefixprints "log_prefix" under zsh. Not portably fixable — capturing at file scope gives zsh the sourced library's own path — and it only affects a log-line prefix, whichlog_prefixexists to let callers override.log_test.shskips that one assertion on zsh rather than asserting something false. -
zsh treats an unmatched glob as an error (
NOMATCH), where POSIX shells pass the pattern through. Sofor f in "$d"/*is not a portable way to walk a possibly-empty directory. -
zsh does not word-split unquoted parameters.
for p in $LISTiterates once over the whole string. Usecase " $LIST " in *" $item "*)instead — seecheck_platformininstall/runner.sh. The same applies to iterating a list:executepeelsBINARIESwith parameter expansion rather thanfor. -
zshDOES word-split command substitution, even though it does not word-split parameter expansions. Sofor x in $(printf '%s' "$LIST")works on all of sh/dash/bash/ksh/zsh, and default IFS folds tabs and newlines for free. It is still not used forBINARIES, because the results are then pathname-expanded and that behaviour inverts: a*in the list globs against the cwd on the POSIX shells and passes through literally on zsh.set -fwould cost back the lines saved. The peel loop inexecutecarries this reasoning so it does not get "simplified" into the unguarded form.
posh
- posh's
getoptsis unreliable in a-c/eval context with no positional parameters: it walks garbage, reportsinvalid option -- '', and drivesparse_argsinto its\?branch andusage'sexit 2. It is a harness artifact, not a shlib bug -- a real assembled installer run as a script under posh parses-b DIRand an explicit tag correctly.nounset_test.shskips that one assertion whenPOSH_VERSIONis set.
Any shell
-
while readfrom a pipe runs in a subshell; from a redirect it does not. Accumulating into a variable needs the redirect form. -
evalconfines a nounset abort in ksh93 and zsh. sh, dash and bash tear the whole subshell down whenset -uhits an unset parameter; ksh93 and zsh stop only theevaland carry on. So( set -u; eval "$code"; echo OK )reaches the sentinel unconditionally on exactly those two shells, and every assertion built on it passes vacuously.nounset_test.shappends the sentinel INSIDE the eval string instead. Itstest_detects_a_violation-- a self-check that the harness can still fail -- is what caught this; a probe that cannot be shown to fail is not a test.
Platforms
Solaris and illumos
-
Solaris ships pre-POSIX tools.
/usr/bin/grepis the SVR4 one: none of-o,-E,-F,-x,-f.findhas no-mindepth/-maxdepth. Usesed -n 's/…/\1/p',ls -A | wc -l, andsort | uniq -ufor a set difference. Note the linter pushes the other way -- SC2012 objects tols, and taking its advice is what broke Solaris. -
head -cdoes not exist. POSIXheadspecifies only-n, and Solaris ships exactly that;-cis a GNU/BSD extension. Instead of failing, SVR4 head printsusage: head [-n #] [-#] [filename...]on stderr and exits 2, so a pipeline using it silently yields the usage line or nothing at all.http_last_modifiedshipped withtail -c 31 | head -c 29for years and returned nothing on Solaris the whole time. Usedd if=f bs=N count=1for bytes, or match the text withsed.Cheap to rehearse without a VM: put a wrapper named
headearly onPATHthat rejects-cthe way SVR4 head does, and run the suite under it.make lintnow refuseshead -c,grep -o/-E/-F/-x/-f,find -maxdepthandsed -iin anything that runs on Solaris -- the library, the tests andinstall/. It matches the tool in command position and ignores comments, so a note explaining why not to use one does not trip it.scripts/is exempt: it is developer tooling and never runs there. -
SVR4 sed drops a final line with no terminator.
echo x | tr -s '\n' ' 'turns the trailing newline into a space, so sed sees an unterminated line and Solaris yields nothing where BSD and GNU yield the answer. This silently brokegithub_release. Feed sed withprintf '%s\n', neverprintf '%s'. -
command -v NAME's exit status does not mean "the config defined a function". It means a command of that name exists anywhere -- and Solaris and illumos ship/usr/bin/unpack, so theunpackhook guard found it, skipped its default, and every install there ran the SVR4unpack(1). Compare the output instead:command -vprints the bare name for a function or builtin, an absolute path for an external program. Any hook name can collide; the guard, not the name, is what has to be right. Rehearse this leg without a VM by putting a fake executable of that name on PATH.
NetBSD
[ -w ]is not a capability check, and on NetBSD it is not even an approximation. NetBSD'stest(1)deliberately does not callaccess(2): it stats the file and readsst_modedirectly, and for root treats "any of the three write bits set" as writable (bin/test/test.c,test_access). A mode0500directory has no write bit anywhere, so[ -w ]says no while root writes to it happily. Linux, macOS and FreeBSD useaccess()/eaccess(), where root simply gets yes -- so a root-guard written as[ -w "$dir" ]skipped correctly everywhere else and went red only on NetBSD. Guard by probing the capability (touch "$dir/probe"), which is what the assertion actually depends on. Seeinstall_exe_test.sh.
FreeBSD
- FreeBSD needs
gmake: the Makefile uses.DEFAULT_GOAL,MAKEFILE_LISTand--no-print-directory, all GNU-only. It deliberately does not install curl or wget — base has neither, onlyfetch(1)— so it is the only leg that exerciseshttp_download's fetch branch. The workflow asserts curl/wget are absent, so a transitive dependency pulling curl in cannot silently stop that coverage.
DragonFly BSD
-
6.4 bundles its own LibreSSL, and its TLS 1.3 path fails intermittently against GitHub. The CI leg has failed with an
EPIPEraised insidelibressl/ssl/tls13_legacy.c, surfaced byfetch(1)as the genericAuthentication error-- fetch reports every TLS failure that way, so the message cannot distinguish a bad certificate, a flaky network and a handshake incompatibility.Measured at the time:
raw.githubusercontent.comnegotiates TLS 1.3 (TLS_AES_128_GCM_SHA256) and TLS 1.2, and verifies clean from a current OpenSSL, so the server was not at fault. The FreeBSD legs download the same URL through the samehttp_download_fetchon a newer TLS stack.It is intermittent. An unchanged re-push went green, which is the evidence that separates a flaky QEMU network from a real handshake incompatibility -- the two are indistinguishable from fetch's message alone.
The leg retries three times and, on the third failure, prints
uname -a,openssl versionand ans_clientprobe. That is deliberately in the workflow and not inhttp_download_fetch: the library must not grow retry semantics for every consumer because one CI image is unreliable.A retried success logs
(succeeded on attempt N), so the frequency stays visible rather than being silently absorbed. If the diagnostics ever show it failing all three times consistently, the fix is to bump the DragonFly release in the matrix, not to weaken the assertion.
Linux
- Inode numbers are not a proxy for "was unlinked". Linux reuses a
just-freed inode, so an inode-identity check reported "not unlinked" on ext4
while passing on APFS.
install_exe_test.shasserts via a hard link instead: after an unlink-then-copy the link keeps the old contents.
Windows — git bash and msys2
-
On Windows,
[ -x FILE ]is not a mode-bit test. msys2 infers executability from content (a#!line, a PE header) or a.exesuffix, sochmod 0755on a file of arbitrary text still reports non-executable. A stub standing in for a downloaded binary must therefore look like one -- writing#!/bin/shis enough. This is whyinstall_exe's own tests pass on Windows while a bare-binary fixture did not. -
.gitattributespins the whole tree toeol=lf, and marksfixtures/**as-textso it is never converted. Git on Windows defaults tocore.autocrlf=true; a shell script with CRLF fails in ways that look like anything but line endings, and a CRLF fixture fails worse — the hash tests report a digest that looks arbitrary (md5 offoobar\r\n, notfoobar\n). An extension-by-extension attributes file missed the fixtures and produced exactly that. The windows workflow asserts LF rather than trusting it.
Container images
-
A container image is not a platform, and routinely has no downloader at all. Measured:
debian:stable-slim,ubuntu:24.04,node:22-slimandpython:3.12-slimall ship neither curl nor wget.image curl/wget usable interpreter python:3.12-slimnone python3node:22-slimnone nodedebian:stable-slimnone none ubuntu:24.04none none -
Perl is present on all four and useless on all four. Debian's
perl-basecarries neitherIO::Socket::SSLnorLWP, and the images ship noopensslbinary to shell out to, so there is no path to an HTTPS URL. That is whyhttp_download_pythonandhttp_download_nodeexist and no perl branch does -- perl is available exactly where it cannot help. -
node's https module does not follow redirects;fetch()does. Every GitHub release download redirects toobjects.githubusercontent.com, so an https-based branch would need its own redirect loop.http_download_nodeuses the globalfetch()(node 18+) and streams the body to disk rather than buffering it -- release artifacts run to hundreds of megabytes. -
Debian and Ubuntu called the interpreter
nodejsfor years, becausenodebelonged to the ax25 package.http_download_nodeaccepts either name. -
debian:stable-slimandubuntu:24.04are not short of networking -- they are short of TRUST. They shipapt, and/usr/lib/apt/apt-helper download-file <url> <dest>is a perfectly good general-purpose HTTPS client with a full set of transport methods in/usr/lib/apt/methods/(includinghttps). What they do not ship is a CA store:image system CA roots why it still installs packages debian:stable-slim0 aptpoints athttp://deb.debian.organd verifies by GPG signature, not TLSubuntu:24.040 same, http://ports.ubuntu.comnode:22-slim0 node does not use the system store -- it bundles 145 roots of its own ( tls.rootCertificates)python:3.12-slim150 that image installs ca-certificatesSo on the two bare distro images, nothing can verify a TLS peer -- apt-helper included:
SSL connection failed: error:0A000086:SSL routines::certificate verify failedInstall
ca-certificatesand apt-helper downloads GitHub release assets fine, cross-host redirects and all. The gap is the trust store, not the transport. -
An
apt-helperbranch was considered and rejected. It is a real base tool on the one family that has it, which is more than can be said for the runtimes -- but:- On the images where it would be the only option it cannot verify certificates, so adding it would turn a clear "no downloader found" into a confusing TLS error that reads like an shlib bug.
- Where
ca-certificatesis installed, curl or wget almost always is too. - It cannot send arbitrary request headers -- there is no
Acquire::config key for them -- sogithub_releasecould not resolvelatestthrough it, the same limitation asfetch(1)andftp(1). - It lives at
/usr/lib/apt/apt-helper, offPATH, and is an apt internal with no stability promise.
The honest outcome on a bare
debian:stable-slimis the errorhttp_downloadalready prints. The fix is anapt-get install, and it is the user's to make. -
Installing curl on those images is not always enough.
ca-certificatesis a Recommends of curl, not a Depends, so the very common Dockerfile idiom leaves you with a downloader and still no trust store:apt-get install -y --no-install-recommends curl curl: (77) error setting certificate file: /etc/ssl/certs/ca-certificates.crtAnyone reporting that an installer "fails on Debian" is likely to have hit this rather than anything in shlib. The fix is
apt-get install -y --no-install-recommends ca-certificates curl.
Tools that are not POSIX
install(1)
install(1)is not POSIX either, and its grammar differs by platform. GNU and BSD takeinstall SRC DST; Solaris and illumos ship the SVR4 version, which does not, so every install failed there. That is whatinstall_exeexists for -- it is a library function, not installer-local, because placing a file portably is a shell primitive.
mktemp(1)
mktempis not POSIX and its mode is not guaranteed. git-bash creates0755, not0700, somktmpdirsets the mode explicitly instead of inheriting it. On Windows the bits are an emulation over NTFS ACLs, somktmpdir_test.shasserts owner-usability there rather than a mode string.
Downloaders: fetch(1) and the two ftp(1)s
-
fetch(1)cannot send arbitrary headers — there is no-H. It honoursHTTP_ACCEPT, which coversgithub_release'sAccept: application/json.Authorization(github_api withGITHUB_TOKEN) has no equivalent and fails with a clear message rather than silently sending an unauthenticated request. -
Two different programs answer to
ftp, and only one can send headers. OpenBSD's ftp (getopt46AaCc:dD:EeN:gik:Mmno:pP:r:S:s:TtU:uvVw:) has no-Hat all; NetBSD's tnftp (:46Aab:defgH:iN:no:P:pq:Rr:s:T:tu:Vvx:) does.-oand-Vare the only flags common to both — OpenBSD's-Mdoes not exist in tnftp. Sohttp_download_ftpprobes the binary, notuname -s:ftp -Z </dev/null 2>&1 | grep '\[-H '.-Zis invalid in both, so each prints its usage line and exits without touching the network;</dev/nullstops a legacy client dropping into its interactive loop. The openbsd and netbsd legs assert the probe's answer against the real binary, in opposite directions, so the day either usage line changes CI says so. -
A header that is delivered is not necessarily a header that works. tnftp's
-Hreally does put the header on the wire — and it is still useless forAccept, becauseprint_getinusr.bin/ftp/fetch.cwrites its ownAccept: */*first and nothing suppresses it. GitHub honours the firstAcceptit sees, sogithub_releaseover tnftp got the HTML release page and parsed<!DOCTYPE html> <html lang=as the tag. Verified against the live endpoint:application/jsonalone returns JSON;*/*ahead of it returns HTML, as does every q-value arrangement, including a singleAccept: application/json, */*;q=0— GitHub is not doing real q negotiation. Sohttp_download_ftprefusesAccept:outright, on both flavours, while still passing other headers through-H. A silently wrong body is worse than a clear failure.fetch(1)does not have this problem:HTTP_ACCEPTreplaces itsAcceptrather than adding to it. The dragonflybsd leg proves that end to end, and it is the reason the fetch branch supports the header and the ftp branch does not.Consequence worth knowing: on both OpenBSD and NetBSD,
github_release'slatestlookup cannot work and says so. Plain release downloads — the artifact and its checksum file — are unaffected, so an installer pinned to an explicit tag works fine. Closing the gap is open work; see Resolvinglatestwithout content negotiation.
Rehearsing a platform without the platform
-
Reproduce any leg locally with docker; that is how every change here was verified before pushing. The BSD/SunOS legs cannot boot locally, but the part that usually breaks -- which downloader is present -- can be rehearsed without a VM: build a directory of symlinks to every base tool except curl/wget/fetch, drop in a shell script named
ftp(orfetch) that emulates the real one's flag parsing and shells out to curl by absolute path, then run the workflow'srun:block underenv -i PATH=thatdir sh -u(-ubecause the vmactions runner uses it). That ran the whole suite throughhttp_download_ftpfor real before any BSD leg had booted.The fake must reproduce what the real tool sends, not just the flags it accepts. The first fake
ftpparsed-Hand passed it to curl, so the Accept test passed locally and failed on NetBSD — the real tnftp adds its ownAccept: */*first. Once the fake did that too, it reproduced the CI failure exactly. A fake that only models the interface validates the interface, not the behaviour. What no fake can check is the base system's own tools, which is why each leg prints an inventory first.