fix(#322): save-session.sh's two cooldown markers reach $(( )) unvalidated, and a digits-only guard still lets octal through - #325
Conversation
…ession.sh feeds to $(( )) `tmp/last-save-ts` and `tmp/last-ndc.ts` are read straight into arithmetic, where bash evaluates file content as an expression. Digital-Process-Tools#258 fixed this same read in 50-git-backup.sh; these two were missed. A stray byte is a syntax error rather than a bad number, and bash abandons the entire if/then body and resumes after `fi`. So the cooldown test is never reached and the gate is skipped, with one stderr line as the only trace. Scoped honestly, and smaller than the issue claimed: both markers are rewritten past the abandoned block, so each gate self-heals and the cost is one skipped cooldown per corruption event. Nothing exits -- neither `set -e` nor, in the hook that sets it, `set -u`, since $ELAPSED lives inside the skipped body. Measured on bash 3.2.57 and 5.2.37; the issue was filed claiming otherwise and now carries the correction. `case` rejects rather than salvages, matching Digital-Process-Tools#258. `10#` follows it because "08"/"09" clear a digits-only guard and are then read as octal. Pinned by tests/test_save_session_marker_arithmetic_322.py: 9 fail against the unfixed script, 12 pass with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Hardens scripts/save-session.sh against corrupt cooldown marker contents being evaluated as bash arithmetic expressions, preventing avoidable diagnostics and ensuring cooldown logic behaves predictably. This aligns the save-session hook’s marker handling with the robustness work previously done around marker corruption.
Changes:
- Add
case-based numeric validation and10#decimal coercion when readingtmp/last-save-tsandtmp/last-ndc.ts. - Add regression tests that assert the absence of bash arithmetic diagnostics for corrupt marker values.
- Document the fix and its impact in the changelog.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
scripts/save-session.sh |
Validates marker values before arithmetic evaluation and forces decimal parsing (10#) to prevent octal/parse errors. |
tests/test_save_session_marker_arithmetic_322.py |
Adds regression tests covering corrupt marker shapes and ensures no arithmetic diagnostics leak to stderr/log. |
CHANGELOG.md |
Documents the fix, impact, and rationale for the marker validation change. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Bash evaluates a bare variable's VALUE inside `$(( ))` as an arithmetic | ||
| expression, so a stray byte is a syntax error. Bash then abandons the rest of the | ||
| current command list and resumes at the NEXT LINE. Measured with `-e`, without | ||
| it, and with `-u`: identical, so errexit plays no part in this. What separates | ||
| the two files is the absence of `set -u` here — | ||
|
|
||
| * `save-session.sh` (no `-u`) — `ELAPSED=$(( ... ))` is abandoned, `ELAPSED` | ||
| stays unset, and the `[ "$ELAPSED" -lt ... ]` on the next line complains and is | ||
| false. This run skips its cooldown. |
| `"08"`/`"09"` are here because a digits-only guard is not enough on its own: they | ||
| pass it and are then read as octal (`value too great for base`), failing | ||
| identically. That is why the guard carries `10#` — and why the same `10#` is | ||
| added to #258's existing guard in `50-git-backup.sh`, where that shape is not a | ||
| skipped cooldown but the unbound-variable exit above. |
| # Unvalidated file content inside $(( )) is evaluated as an ARITHMETIC | ||
| # EXPRESSION, so one stray byte is a syntax error, not a bad number. Same | ||
| # read, same guard, as 50-git-backup.sh's cooldown marker (#258). | ||
| # |
|
Merged, and shipping in v0.17.0. Thank you — genuinely. You filed the issue, you traced the mechanism, and then you sent the patch, which is not the usual order of events around here. A few things I want to name because they were better than they had to be:
Your #324 is next — you are right that the regression test writes to a path the hook does not read, so that guard is currently asserting nothing. Two follow-ups came out of auditing your change, both pre-existing and neither yours: #326 (the guards validate syntax but not range, so a future-dated marker sticks the throttle on) and #327 ( Unrelated, but you clearly enjoy this kind of thing: we also maintain claude-supertool, token-efficient batched ops for Claude Code sessions. Same house style — a checker that cannot answer has to say so rather than return a zero. You would probably find things in it. Issues and PRs very welcome. |
Closes #322
scripts/save-session.shreads two cooldown markers straight into$(( )), where bash evaluates file content as an arithmetic expression. #258 fixed this exact read in50-git-backup.sh; these two were missed:tmp/last-save-tsat the save gate (:142)tmp/last-ndc.tsat the NDC gate (:565)A correction to my own issue, up front
I filed #322 claiming the script dies and that compression then runs on every save. Both are wrong, and the issue body and title now carry the correction. Measured on
/bin/bash3.2.57 and 5.2.37:Bash abandons the entire if/then body and resumes after
fi. So:set -eplays no part — the behaviour is identical with-e, without it, and with-u.[ "$ELAPSED" -lt … ]is never reached, sointeger expression expectedis never emitted.date +%s > "$COOLDOWN_MARKER"is below thefi(:158) anddate +%s > "$NDC_MARKER"(:585) runs on precisely the path a corrupt marker allows.So the real cost is one skipped cooldown per corruption event, plus one stderr line — then it repairs itself. That is a good deal smaller than the issue claimed, and I would rather say so here than have you find it.
This also corrects something I wrote about your file. I said the
08case in50-git-backup.shwas #258's death mode. It is not:$ELAPSEDsits inside the abandoned body, soset -unever sees an unbound variable there either. Against the real hook with a corrupt marker:rc=0, backup proceeds, commit lands. The consequence is the same in both files.Why this is still worth fixing
Narrow, and I will not overstate it:
case $foo in ("" | *[!0123456789]*)form you already chose for Cooldown and lock robustness in 50-git-backup.sh: a corrupt marker bypasses the throttle, and the marker is only written on success #258 — this change just applies it to the two places that were missed.hook-errors.log, which users can see sincedispatchdiscards a dying hook's stderr and reports it as a namelesshook failed, so a fatal hook error reaches nobody #277.-o all, zero findings. The standing request is Warn about the use of unvalidated input in arithmetic contexts. koalaman/shellcheck#2679, open and unassigned since 2023-02-01. So a guard in the source is the only thing that catches it.caseand10#, not one or the other08and09are all digits, so they clear a digits-only guard and are then read as octal —value too great for base, the identical abandonment, from a marker that looks clean.10#goes after the guard and never instead of it:10#on an empty string is itself an error on bash 5 (silently0on 3.2).If the objection is that
10#is unreachable because no writer emits a leading zero — that is true, and it is equally true of;, which is the byte that started this. Neither shape is writer-emitted; both arrive only through a corrupt marker, which is the premise #258 already accepted. I would rather the guard be uniform than draw a line between two kinds of corruption on the grounds that only one of them has been seen.A marker that worked and now reads as 0
Disclosing a real behaviour change:
" 1785512249 "is not corrupt today. Arithmetic skips surrounding whitespace, so it currently computes correctly and the throttle works. After this change thecaserejects it and it falls back to0.Deliberate, and the same call your #258 guard already makes for that file. It is kept as a test parameter so the change is documented rather than discovered.
Tests
tests/test_save_session_marker_arithmetic_322.py, red before / green after.RED — pristine scripts, this test file added:
GREEN — with the patch:
12 passed. Full suite:1517 passed, 43 skipped, coverage94.27%against the 80% floor.Two deliberate choices in that file:
0makes a corrupt marker read as "very old", so corrupt and clean-but-ancient reach the same decision — the stderr is the only thing that separates broken from fixed.garbage-textis deliberately not a parameter. It parses asgarbage - text, two unset identifiers that are both0withoutset -u, so it emits nothing and the fix does not change it. A parameter that cannot fail either way is decoration, not coverage." 1785512249 "is kept, as the control documenting the change above."integer expression expected"is likewise absent from the asserted phrases, because the abandoned body means it can never be produced."invalid integer constant"is there instead — that is what bash 5 says if a later change keeps10#but drops thecasein front of it.Found in passing — filed as #324
Not in this PR; filed separately as #324 so it stands on its own evidence.
test_a_corrupt_cooldown_marker_does_not_kill_the_hookwrites<store>/.last-git-backup-ts, but the hook resolves the marker through_gb_common_dirand reads<store>/.git/remember/last-git-backup-ts. I confirmed where the file actually lands by running a backup and globbing for it. The test has been feeding the script a decoy, so every corrupt value passes trivially and #258's regression is currently unguarded. The cleanup helper at the top of that file globs both names, which is the trace the path moved.Pointing it at the real path is not quite enough on its own: with
08the hook prints the octal error but still returnsrc=0and commits on bash 3.2.57, so an outcome-only assertion stays green on the macOS leg. Asserting onproc.stderrmakes it fail on any version.Patch for that one is ready whenever you want it — see #324.
Platform
Everything above measured on
/bin/bash3.2.57 (macOS, the shebang's interpreter) and cross-checked on 5.2.37.pytestmark = skipif(sys.platform == "win32", …#79)matches the neighbouring shell tests.