◐ Off-By-One · answer catalog

bash-cmdsub-newline-strip-integrity-mask

1 answer(s)shellbash

echo "ERROR: cannot read $orig or $restored" >&2 # never a silent MATCH

📦 Source in repository (JSON)

Answer

The masking mechanism. POSIX command substitution ($(...)) captures the command's stdout, then strips every trailing <newline> character before substituting the result into the shell string. Both sides of the comparison are files that end in \n:

Both stringify to the identical string "hello", so [[ ... == ... ]] is true and the nightly job reports MATCH with exit 0 — the alarm never fires, week after week. Only the stringified contents are compared, and a byte at EOF is exactly what stringification destroys. The same mask hides any difference consisting only of trailing LFs: a\n\n vs a\n, a lone \n file vs an empty file, a\nb\n vs a\nb. Two further latent holes in the same pattern: (1) bash drops NUL bytes from command-substitution strings (with a warning), so binary files are compared corrupted; (2) if both cats fail (unreadable files), both sides are empty strings → MATCH. Note [[ "$(<file)" == ... ]] is not a fix — $(<file) is still command substitution and gets the same trailing-newline strip.

The minimal byte-exact rewrite — compare the files, not their stringified contents:

#!/usr/bin/env bash
set -euo pipefail

# FIX A (recommended): byte-exact, streams, stops at the first differing byte.
# cmp exits: 0 = identical, 1 = differ, 2 = error reading a file.
# The if/elif form is safe under `set -e` (condition context is exempt).
if cmp -s -- "$orig" "$restored"; then
  echo "MATCH"
elif [[ $? -eq 2 ]]; then
  echo "ERROR: cannot read $orig or $restored" >&2   # never a silent MATCH
else
  echo "DIFFER"                                       # byte-level corruption found
fi
# FIX B (keeps the [[ == ]] shape): checksum the files, not their text.
if [[ "$(sha256sum < "$orig")" == "$(sha256sum < "$restored")" ]]; then
  echo "MATCH"
else
  echo "DIFFER"
fi

Why the checksum variant is safe: the trailing newline that command substitution strips is the formatting newline after the hex digest, never a file byte. The digest is computed over every byte of the file — including the final LF — so hello\n (5891b5b5…) and hello (2cf24dba…) hash differently. sha256sum < file (redirection) keeps the digest independent of the path and immune to leading-dash filename issues. cmp is the minimal fix: no checksum collision surface, no full-file buffering, and it exits as soon as the first differing byte is found.


Evidence & signatures

Fixtures: `orig` = `hello\n` (6 bytes), `restored` = `hello` (5 bytes, truncated write lost the final LF).

```
[buggy]  [[ "$(cat o)" == "$(cat r)" ]]  -> MATCH        # false negative: job stays green
[fixed]  cmp -s -- o r                   -> DIFFER       # caught
[fixed]  sha256sum < o vs < r            -> DIFFER       # caught
cmdsub(o) = [hello]   cmdsub(r) = [hello]                # both sides stripped -> identical strings
sha(o)=5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03
sha(r)=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
```

Edge cases tested (comprehensive suite, `set -euo pipefail`, 18/18 PASS):

| Case | Buggy `$(cat)` | `cmp -s` |
|---|---|---|
| Lost exactly final LF (`hello\n` vs `hello`) — the reported bug | **MATCH** (masked) | DIFFER ✓ |
| Lost two trailing LFs (`a\n\n` vs `a\n`) | MATCH (masked) | DIFFER ✓ |
| LF-only file (1 byte) vs empty file (0 bytes) | MATCH (masked) | DIFFER ✓ |
| Truncation at embedded LF (`a\nb\n` vs `a\nb`) | MATCH (masked) | DIFFER ✓ |
| Identical files | MATCH ✓ | MATCH ✓ (no false positives) |
| Genuine content change (`x\n` vs `y\n`) | DIFFER ✓ | DIFFER ✓ |
| Binary with NUL bytes (`a\0b\n` vs `a\0b`) | MATCH (bash drops NUL, corrupts strings) | DIFFER ✓ |
| Both files unreadable | MATCH (two empty strings) | exit 2 → ERROR branch, never silent ✓ |
| Filenames with spaces / leading dashes | — | handled via `cmp --` ✓ |
| 10 MiB file, diff at byte 1 | reads both files fully | DIFFER in 5 ms (short-circuits at byte 1) ✓ |
| Under live `set -euo pipefail` | job exits 0 ("MATCH → exit 0, alarm never fires") | fixed job exits 1 (alarm fires) ✓ |

Note on environment: verified under bash 5.3.9; the trailing-newline stripping of command substitution is POSIX-mandated (unchanged in 5.2), so the bug and fix are identical in 5.2. `cmp`'s exit 2 (unreadable) is surfaced via the `elif [[ $? -eq 2 ]]` branch — `$?` still holds `cmp`'s status when the elif test evaluates.

---
{"model": "deepseek-v4-flash", "problem_class": "bash-cmdsub-newline-strip-integrity-mask", "result": "passed", "tests": 18}
Generated from the verified corpus · MIT licensedBack to the catalog