Skip to content

feat: force HTTPS for all package repos before any download - #20

Open
neohiro wants to merge 8 commits into
mainfrom
feat/apt-https-repo-guard
Open

neohiro wants to merge 8 commits into
mainfrom
feat/apt-https-repo-guard

Conversation

@neohiro

@neohiro neohiro commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Why

A plaintext http:// package mirror means anyone who can intercept the route (hostile Wi-Fi, compromised router, upstream CDN node) can swap the .deb/.rpm/.pkgz you just downloaded for one of theirs. Signature checks catch forged packages, but they do not stop a downgrade to an older, genuinely-signed, vulnerable build. Only TLS on the transport closes that gap.

So every entry point in this repo now runs a guard before anything is fetched.

What it does

On apt (Debian / Ubuntu / Mint / Pop!_OS / Kali and derivatives):

  1. Installs /etc/apt/apt.conf.d/99neohiro-force-https:
    • Acquire::https::AllowRedirect "true" - https to https mirror redirects are fine
    • Acquire::http::AllowRedirect "false" - the important line: a redirect from https:// down to http:// is refused instead of silently followed
    • Acquire::https::Verify-Peer / Verify-Host "true", Acquire::Retries "3"
  2. Rewrites http:// -> https:// on active lines in /etc/apt/sources.list, sources.list.d/*.list (classic) and sources.list.d/*.sources (DEB822 URIs: field only). Commented-out lines and DEB822 structural fields (Suites:, Components:, Signed-By:) are left byte-identical.
  3. Backs every file up to /var/backups/neohiro-apt-https/ before the first edit, and records each in the rollback log so linuxinstall.sh --rollback --apply can undo it too.
  4. Verifies with a real apt-get update. If a mirror does not serve the same paths over TLS, the rewrite is rolled back automatically - you are never left with an unusable package manager. NEOHIRO_APT_HTTPS_STRICT=1 keeps the rewrite and fails loudly instead.
  5. Warns when no CA trust store is present.

On dnf / yum / zypper / pacman / flatpak it audits and reports every plaintext repo URL but deliberately does not blindly rewrite: plenty of upstream mirrors do not serve the same paths over TLS, and silently breaking a working repo is worse than the threat. Opt in with NEOHIRO_APT_HTTPS_REWRITE=1.

Optional belt-and-braces: NEOHIRO_APT_BLOCK_PORT80=1 adds ufw deny out 80/tcp. Off by default because a blanket outbound block also affects unrelated plaintext protocols, and the guard declines to add the rule while any plaintext repo is still configured.

Where it is wired in

Surface Hook
pkg_update / pkg_install / pkg_upgrade / pkg_autoremove apt_https_guard as the first statement
update_system, update_kernel, updates_only_mode guard before any package work
lib/updater.sh all _update_* + the _run_all_updates dispatcher
restore_ssh.sh before installing openssh-server
DeepClean.sh before apt-get autoremove --purge
Maintenance submenu new option 1 (menu is now 21 tools)
CLI --apt-https / --enforce-https, --apt-https-audit, --apt-https-off / --disable-https on linuxinstall.sh, lib/updater.sh and restore_ssh.sh; plus sudo bash lib/apt-https.sh [--enforce|--report|--revert]
--step --step apt_https
Progress checklist new apt_https row, always shown

It is the first workflow step on every profile, including Custom, is idempotent, and is latched to once per process, so AUTO_MODE re-runs and the hot-path guards cost nothing. Escape hatches: NEOHIRO_APT_HTTPS=0, NEOHIRO_APT_HTTPS=audit, or the --apt-https-off revert.

OptimizeLinuxASR.sh downloads no packages, so it needs no guard.

Two latent bugs fixed along the way

  • Helpers that returned their result through $(...) had their own progress messages folded into the return value, which then landed inside an arithmetic expansion (bash tried to resolve a word like Restored as a variable). Results now travel through globals, keeping the message channel and the value channel separate.
  • _step_apt_https used if ! cmd; then rc=$?, which captures the negation's status - a failed enforcement reported success. Now cmd || rc=$?.

Verification

tests/test_apt_https.sh - 92 hermetic assertions. Nothing touches the real /etc: NEOHIRO_APT_ETC_DIR / NEOHIRO_APT_BACKUP_DIR relocate the tree, a fake ufw sits on PATH, _apt_priv is stubbed to simulate root, and NEOHIRO_APT_HTTPS_NOVERIFY=1 prevents any network call. A new NEOHIRO_APT_FAMILY pin lets the apt / dnf / zypper / pacman / none paths run deterministically on any host, including the bash:4 and bash:latest CI images.

Covered: classic + DEB822 rewriting, comment and structural-field preservation, bracketed options, idempotency, backup-before-edit (and that the backup holds the pre-edit contents), refusal-without-backup, no misleading "already https" on refusal, auto-revert, strict mode, exit codes (0/1/2), every env kill switch, the once-per-process latch, the opt-in port-80 block, and that all ten package entry points guard before their first package command. Plus a parity check that linuxinstall.sh's curl|bash inline fallback and lib/apt-https.sh expose the same API.

End-to-end CLI paths were also exercised with functional sudo/apt-get shims: happy path, TLS-less mirror (auto-rollback restores both files byte-for-byte, rc=1), strict mode (keeps rewrite, rc=1), and explicit revert.

$ bash tests/run-all.sh
  [PASS] color_gate         59 tests
  [PASS] curl_pipe_sim      10 tests
  [PASS] fuzz_smoke         50 tests
  [PASS] color_fuzz        200 tests
  [PASS] linuxinstall       67 tests
  [PASS] apt_https          92 tests
  [PASS] updater            45 tests
  [PASS] verify_sync         0 tests
Total tests: 523 passed | Suite failures: 0

bash -n passes on every .sh in the repo.

Drive-by

  • tests/test_color_gate.sh sliced restore_ssh.sh by hardcoded line numbers (sed -n '19,54p'), which silently broke as soon as a source block was added near the top. It now anchors on function names, matching the convention used elsewhere in the suite.
  • README.md had an unresolved merge-conflict marker (<<<<<<</=======/>>>>>>>) sitting in the middle of the "One-step automated setup" section. Removed.

A plaintext http:// package mirror lets anyone on the path swap the
.deb/.rpm/.pkgz you just fetched for one of theirs. Signature checks
catch forged packages but not downgrades to an older, genuinely-signed,
vulnerable build -- only TLS on the transport closes that gap.

Adds lib/apt-https.sh as a repository transport guard that runs BEFORE
anything is fetched:

- apt: installs /etc/apt/apt.conf.d/99neohiro-force-https (the key line
  is Acquire::http::AllowRedirect "false", so an https:// mirror cannot
  silently downgrade a fetch to plaintext), and rewrites http:// -> https://
  on active lines in sources.list, sources.list.d/*.list and DEB822
  *.sources. Comments and DEB822 structural fields are left untouched.
- Every file is backed up to /var/backups/neohiro-apt-https/ before the
  first edit and logged to the rollback log.
- The rewrite is verified with a real `apt-get update`; if a mirror does
  not speak TLS the rewrite is rolled back automatically, so a working
  package manager is never left broken.
- dnf/yum/zypper/pacman/flatpak are audited and reported but not blindly
  rewritten (many mirrors lack TLS on the same paths); opt in with
  NEOHIRO_APT_HTTPS_REWRITE=1.
- Optional belt-and-braces `ufw deny out 80/tcp` behind
  NEOHIRO_APT_BLOCK_PORT80=1, which declines while plaintext repos remain.

Wired into every surface that downloads packages: pkg_update, pkg_install,
pkg_upgrade, pkg_autoremove, update_system, update_kernel,
updates_only_mode, all _update_* in lib/updater.sh plus the
_run_all_updates dispatcher, restore_ssh.sh (before openssh-server),
DeepClean.sh (before apt-get autoremove --purge), the Maintenance submenu
(option 1, now 21 tools), --step apt_https, the progress checklist, and
the --apt-https / --apt-https-audit / --apt-https-off CLI flags on both
linuxinstall.sh and the standalone scripts.

It is the first workflow step on every profile, including Custom, and it
is idempotent and latched to once per process so AUTO_MODE re-runs and
the hot-path guards cost nothing.

Also fixes two latent bugs found while wiring this up:
- helpers that returned their result via command substitution had their
  own progress messages folded into the return value, which then landed
  inside an arithmetic expansion; results now go through globals.
- _step_apt_https used `if ! cmd; then rc=$?`, which captures the
  negation's status, so a failed enforcement reported success.

test_color_gate.sh now anchors its restore_ssh.sh slice on function names
instead of hardcoded line numbers, and README.md had a stray unresolved
merge-conflict marker removed.

Tests: tests/test_apt_https.sh, 92 hermetic assertions (fake /etc, fake
backup dir, fake ufw, NEOHIRO_APT_FAMILY pin) covering the rewrite, the
DEB822 path, comment preservation, idempotency, the backup-before-edit
guarantee, refusal-without-backup, revert, exit codes, every env kill
switch, and that all ten package entry points guard before their first
package command. Full suite: 522 passed, 0 failures.
@neohiro
neohiro enabled auto-merge (squash) October 4, 2026 02:23
The guard was correct on every path the tests exercised, but a review pass
found three places where a package could still be fetched over plaintext
HTTP. All three are now closed.

1. curl|bash inline fallback had unguarded updaters.

lib/updater.sh is never on disk under `curl | sudo bash`, which is the
documented primary install method. linuxinstall.sh carries its own inline
copy of every _update_*, and only _update_apt was guarded - so the dnf,
yum, zypper, pacman, snap, flatpak, docker, brew and firmware paths ran
with no precaution at all, and the inline _run_all_updates dispatcher did
not guard either. Every one of them is guarded now.

2. Four lib/updater.sh sub-steps that hit the network were unguarded.

_update_docker (docker pull), _update_firmware (fwupdmgr refresh/update
against LVFS), _update_geoip and _update_pihole are guarded. The dispatcher
already covered them on a normal run, but each is also reachable directly,
so they should not depend on the caller's ordering.
_update_virsh, _update_suse_snapper and _update_btrfs_balance stay
unguarded on purpose - they only read or write local state.

3. Subscripts fetched at runtime lost the guard entirely.

run_remote_script pulls DeepClean.sh / OptimizeLinuxASR.sh into
$TMP_DIR. A script resolves its helpers relative to its own location, so
$TMP_DIR/lib/ does not exist and the fetched subscript found no
apt_https_guard at all - meaning DeepClean.sh's
`apt-get autoremove --purge` could resolve and fetch dependencies over
plaintext HTTP even though the parent process had just enforced HTTPS. The
child is a separate process with its own once-per-process latch, so the
parent's enforcement does not carry over. run_remote_script now prefetches
lib/apt-https.sh next to the subscript, best-effort: a failure there logs
and continues, because the parent has already enforced for this operation.

A subscript curl|bash'd entirely on its own and finding no library now says
so out loud instead of skipping the precaution silently (restore_ssh.sh,
DeepClean.sh).

Also: GEOIP_URL is the one operator-supplied download target in the update
engine, and a GeoIP database decides which country a packet counts as being
in, so a tampered copy is a traffic-tunneling primitive. An http:// value is
now refused outright rather than fetched.

tests/test_apt_https.sh grows to 120 assertions: the inline _update_*
guards, the GEOIP_URL refusal (checked both structurally and at runtime for
http and https), and a runtime exercise of the lib prefetch that drives
run_remote_script with a fake curl - proving the prefetch happens, lands in
the right place, is non-fatal when it fails, and leaves no empty stub
behind. That last group was negative-controlled: stripping the prefetch
makes it fail.

Full suite: 551 passed, 0 failures. bash -n clean on every .sh.
Third review pass. restore_ssh.sh, DeepClean.sh and the lib/updater.sh
standalone branch all run under `set -e`, which the transport flags were
not accounting for.

- restore_ssh.sh --apt-https called `apt_https_enforce` bare. On failure
  `set -e` aborted the script, so the report never printed and the user saw
  a truncated run. The `return $?` that followed `apt_https_report || true`
  would also have reported the *report's* status, which is always 0 - so the
  exit code was right only by coincidence, on the path where it was least
  needed.
- The same latent pattern sat in lib/updater.sh's standalone flag branches,
  where the correct exit code came out right only because `set -e` happened
  to propagate it, skipping the report.

Both now capture the enforcement status explicitly (`cmd || _rc=$?`), always
print the report, and propagate enforcement's own result.

apt_https_guard is also now guaranteed to return 0. It is called from
pkg_install and friends, some of which run under `set -e`; a precaution
should never be the reason a package operation aborts. Enforcement problems
surface through apt_https_report and the enforce exit status instead.

lib/apt-https.sh's own standalone entry point now sets its exit code
explicitly rather than inheriting whichever command ran last, so the
documented contract (0 clean / 1 plaintext remains / 2 bad usage) is a
guarantee instead of an accident.

tests/test_apt_https.sh grows to 145 assertions. The new ones drive the real
scripts and assert both the exit code and that the report still printed:
restore_ssh.sh through an extracted-main() harness with root and the print
helpers stubbed (set -e is deliberately left active, and main() is invoked
outside any condition so set -e is not silently disabled inside its body),
plus lib/updater.sh run directly, plus apt_https_guard's never-fail
contract and the standalone exit-code contract.

Negative-controlled: re-introducing the original bare-call-plus-`return 0`
in restore_ssh.sh makes 5 assertions fail, including
"prints the report even when enforce fails" with the exact predicted reason.

Full suite: 576 passed, 0 failures. bash -n clean on every .sh.
Automated capture of uncommitted modifications to tracked files.
The guard was named for apt and audited apt plus four RPM/Arch repo
formats. That left the majority of a real machine's app stores unchecked,
including ones that ship plaintext HTTP by default:

  apk (Alpine)     /etc/apk/repositories is http:// on many images
  pip              PIP_INDEX_URL / pip.conf
  npm              NPM_CONFIG_REGISTRY / .npmrc
  cargo            CARGO_REGISTRIES_CRATES_IO_INDEX / ~/.cargo/config.toml
  gem              GEM_SOURCE / .gemrc
  nix              nix.conf substituters + channel
  docker           daemon.json registry-mirrors + insecure-registries
  brew             HOMEBREW_*_GIT_REMOTE / HOMEBREW_API_DOMAIN
  fwupd            remotes.d UpdateURI

Now 16 stores are audited. Detection is deliberately format-agnostic: any
non-comment line carrying an http:// URL is reported. A per-dialect key
list would miss gpgkey=, metalink=, and whatever the next distro release
adds, and a miss is exactly the failure this exists to prevent. One
documented exception for JSON, which has no comment syntax: http:// must
sit at the start of a JSON string, so {"_comment": "see http://docs"} is
not mistaken for a registry.

"if available" is honoured honestly. Only apt is rewritten unattended,
because it is the one store whose rewrite can be verified with a real
`apt-get update` and rolled back if a mirror cannot speak TLS. Everything
else is reported, and rewritten only under NEOHIRO_APT_HTTPS_REWRITE=1,
since whether https://<same host><same path> exists is not knowable
offline and silently breaking a working mirror is worse than the threat.
The opt-in sweeps every installed store in one pass; stores whose tool is
absent are left alone.

--apt-https-audit now prints a per-store verdict table, and the aggregate
exit code covers all of them.

Three defects fixed while doing it:

1. cp into place is not atomic. A crash mid-copy left a truncated
   sources.list, i.e. a broken package manager, which is precisely what
   this tool must never cause. Every write is now a staging file in the
   target's own directory plus rename(2). Mode and ownership are cloned
   with cp -p and content replaced with a second cp, rather than relying
   on sed -i preserving the mode, which differs between GNU and busybox.

2. The CA-trust-store warning printed two or three times per run because
   both enforce and report call the check. Latched to once per process.
   While adding the latch I also rewrote the check itself and broke it:
   the case arm fell through to the warning even when the trust store
   existed. Both the latch and the early return are now pinned by tests.

3. Result globals and the latch were initialised inside a function
   instead of at the top level, so an audit-only run under `set -u` died
   with "unbound variable" -- which is exactly how lib/updater.sh's
   standalone CLI runs. Caught by a set -u harness that exercises report,
   status, per-source and revert.

The curl|bash inline copy is gone. linuxinstall.sh carried ~400 lines of
security-critical code duplicated from lib/apt-https.sh, which is how fixes
and new store coverage silently failed to reach the documented primary
install path. It now resolves the canonical library -- from disk, or by
fetching it from the same REPO_RAW_BASE it already trusts for
DeepClean.sh -- and sources that, so curl|bash users get identical
coverage. If it cannot be loaded, the public entry points become loud
no-ops rather than undefined functions, and the run states plainly that
the precaution is inactive. Net -404 lines from linuxinstall.sh.

tests/test_apt_https.sh grows 247 -> 247 assertions covering: registry
completeness, a label for every store, plaintext detection per store,
comment suppression, the opt-in rewrite writing real https:// into each
config, backup-before-edit, "not touched without opt-in", the one-pass
sweep, skipping stores whose tool is absent, env-configured transports,
aggregate reporting, revert across stores, atomic-replace residue and mode
handling, and the single-implementation invariants.

Also adds repository encoding hygiene (valid UTF-8, no U+FFFD, no cp1252
double-encoding, LF endings per .gitattributes). This bit me twice during
this work: a UTF-8 file read as cp1251 and rewritten, then a repair pass
that injected U+FFFD. The failure mode is nasty because the file still
parses and the tests mostly pass. The CRLF check matches with a shell
`case` rather than `grep $'\r'` because Git Bash's MSYS argument
translation silently swallows a lone CR passed as an argument -- which
made the first version of that check pass unconditionally.

Every new detector is negative-controlled: removing apk from the registry
fails the coverage test, breaking apk's file lookup fails its detection
test, and injecting each class of encoding damage fails the corresponding
hygiene check.

Full suite: 678 passed, 0 failures. bash -n clean on every .sh.
…port

Sign-off review of the multi-store guard. Three concrete defects, one
latent hazard, and a report that could not actually be acted on.

1. Backup filenames collided.

_flattening a path is not injective_: /a/b/c and /a_b/c both became
_a_b_c.orig. A collision means `--apt-https-off` restores one file with
another file's contents -- a silent corruption of a package manager or a
pip/npm config, discovered at the worst possible moment. Backup names now
carry a cksum of the real path. cksum is POSIX and stable across shells,
runs and machines, which matters because these backups get inspected and
moved during an incident. Falls back to the flattened name alone if cksum
is somehow unavailable.

2. The detected family was re-probed 4x per pass.

_apt_https_source_applicable called apt_https_family itself, and the report
and status loops call it once per store. Each call is up to five `command -v`
PATH scans, so a single audit did ~40 of them. Beyond the waste, it was a
consistency hazard: if PATH changed mid-run, a store could be "applicable" in
one check and absent from the next, so the same file could be reported twice
or not at all. Callers now compute the family once and pass it in. Measured
with a probe-counting override: 4 probes per status_text -> 1, and the report
now makes a single consistent decision per run.

3. Unset HOME built paths at the filesystem root.

`"${HOME:-}/.pip/pip.conf"` degrades to "/.pip/pip.conf" when HOME is empty,
so the per-user store detectors probed paths directly under /. Harmless today
because those files do not exist, but it is wrong and would start matching the
moment anything created them. Per-user config is now only consulted when
HOME is non-empty.

4. The report told you something was wrong but not what to do.

It said "repoint at an https:// endpoint" in the abstract. For flatpak and
snap that advice is actively misleading: there is no config file to rewrite,
so the opt-in sweep cannot fix them and no amount of editing helps. Every
dirty store now prints a one-line remediation with the real command or the
real config key -- including `flatpak remote-modify --url=https://...`, and
an honest note that the snapd store cannot be repointed at all.

Also fixed a misstatement in the report footer: it described
NEOHIRO_APT_HTTPS_REWRITE=1 as an "apt only" rewrite, when that flag sweeps
every installed store. apt is the one rewritten *without* the flag.

And corrected a stale comment that still said "the three functions below" now
that the rewrite path has grown a staging helper, two prep functions and a
collision-proof backup path.

Two tests were pinning the old backup filename by reimplementing the naming
scheme. They now ask the library where the backup is, so a future change to
the scheme cannot break them for the wrong reason.

tests/test_apt_https.sh 247 -> 253. New coverage: family probed exactly once
per pass, applicability check does not re-probe when given a hint, backup
names distinct for paths that flatten alike, single path component, and
deterministic across calls. Both fixes are negative-controlled -- ignoring the
family hint fails the probe-count test, and disabling the checksum fails the
collision test.

Full suite: 684 passed, 0 failures. bash -n clean on every .sh.

Skipped, noted rather than guessed:
- Whether `Acquire::Retries` / `AllowRedirect` behave identically on apt 1.x
  and 2.x. Verifying that needs a matrix container per apt release; I have no
  such environment here, and guessing at apt's parser is worse than leaving the
  long-standing keys alone.
- Making flatpak remotes auto-rewritable. It would mean shelling out to
  `flatpak remote-modify`, mutating running-session state during a package
  operation. That is a behaviour change with real blast radius, not a review
  fix, so it wants its own decision and its own tests.
The tmux wrapper existed to make an interrupted install recoverable. It did
not actually deliver that, in three separate ways, and the worst of them is
exactly the "booted out with no way back" failure it was supposed to prevent.

1. A stale session was silently hijacked.

`tmux new-session -A` attaches when the session already exists. A
`linux-setup` left behind by a run that did not exit cleanly therefore
meant the new run never started: the user was dropped into an old session,
possibly running different flags, with no indication their run had not
begun and no way back to it. Now the script checks for the session first,
warns that it is a leftover, and starts `linux-setup-<pid>` instead. The
inner wrapper -- which previously existed as dead code, built and never
passed to tmux -- is now actually used and tears the session down on clean
exit, so leftovers stop accumulating in the first place.

2. Every command-line flag was dropped by the re-exec.

The wrap re-executed `bash "$SCRIPT_PATH"` with no `"$@"`. `--auto`,
`--step`, `--dry-run` and the rest vanished, so wrapping silently changed
what the run did. Arguments are now threaded through and shell-quoted, so
`--note='a b; rm -rf /'` cannot be re-interpreted by tmux's shell.

3. The reattach command was never shown.

`exec` replaces the process, so nothing after the wrap could print. If you
detached or lost the socket you had to remember `tmux attach -t linux-setup`
from the README. The command is now printed before the exec, naming the
session this run actually created -- the PID-suffixed one when the standard
name was taken.

Script hopping (DeepClean.sh / OptimizeLinuxASR.sh via run_remote_script)
had two gaps. The wait loop printed nothing, so a long step looked frozen and
invited exactly the Ctrl-C that orphans the work: the poll dies, the detached
subscript keeps running, and the user is left staring at a stalled run unable
to tell if it is alive. It now emits a heartbeat every 30s with the reattach
command, a completion line, and how to detach without stopping. And when SSH
+ interactive has no tmux to protect the hop, it falls through silently --
now it states plainly that the step cannot be recovered and suggests a local
terminal.

Also fixed two defects in the degraded (library-not-loaded) mode added in the
previous pass. Its warning stood in for a hot-path helper called by every
pkg_*/update_* entry point, so a single run reprinted four lines a dozen
times and buried the installer output; it is now latched to once per process.
And apt_https_status_text returns empty there for "found nothing", which
_auto_skip_if_done read as "already HTTPS-only" -- the tool claiming a
verification it never performed. Callers now consult a new
apt_https_available(), which reports 0 for the real guard and 1 for the stub.

One test bug worth noting: an env-transport case reused the variable name
SRC for the store id, shadowing the path to linuxinstall.sh and silently
breaking every later assertion that grepped it. Renamed, and the symptom is
recorded here because three unrelated assertions failing at once with no
apparent connection is exactly the kind of thing that gets misdiagnosed as a
product bug.

tests/test_apt_https.sh 261 -> 275. The continuity properties are asserted
against the real ensure_tmux_if_ssh driven with a fake tmux and a captured
exec: arguments survive the re-exec, -A is never used, a stale session
forces a distinct name and is reported, the printed reattach command matches
the session actually created, metacharacters are escaped, a bare invocation
emits no empty argument, and the staged reaper really does kill the session
on clean exit. Script-hop warnings and the heartbeat are pinned too.

Full suite: 706 passed, 0 failures. bash -n clean on every .sh.
…stics

CI caught what the previous commit missed. shellcheck reported SC2120 on
ensure_tmux_if_ssh: "references arguments, but none are ever passed."

That is a real bug, not a lint nit. I had fixed the function body to thread
"$@" into the re-exec, and my tests passed -- because they invoked
ensure_tmux_if_ssh with arguments directly. Production called it bare:

    ensure_tmux_if_ssh

so the tmux re-exec still restarted `bash "$SCRIPT_PATH"` with no flags. Over
SSH, --auto / --step / --dry-run were silently dropped and the run behaved
differently from what the user asked for. The call site now forwards "$@".

Lesson recorded in the tests: asserting only the function body is not enough
when the interesting bug lives at the call site. The suite now checks the call
site forwards arguments, that no bare call remains, and that the
references-args-but-nobody-passes shape cannot come back -- so the suite
catches this without needing shellcheck installed. Negative-controlled: putting
the bare call back fails all three.

Also fixed four test assertions whose failure messages could never be
trustworthy. They were written as:

    if [ $? -eq 0 ]; then ... else fail_t "..." "rc=$?"; fi

where $? in the else branch is the status of the `[ ... ]` test, not of the
command being checked. Every failure message reported a meaningless rc --
precisely when someone needs the diagnostic. The status is now captured
immediately after the command. shellcheck's SC2319 flags this; CI's pinned
shellcheck predates SC2319, so it passed there, but the local newer build
caught it and the finding is real either way.

Verified with the stricter locally-available shellcheck: the whole tree is
now clean at --severity=warning, not just under CI's exclusion set.

Full suite: 709 passed, 0 failures. bash -n clean. All .sh/.md valid UTF-8
with no double-encoding artifacts and LF endings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant