From d4dadd94d64042bc17e2ac32fee3ec136a09177c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sat, 5 Sep 2026 00:01:12 +0000 Subject: [PATCH 1/4] fix(rollback): parse .taos-rollback as data instead of sourcing it (tsk-rw62vx) scripts/rollback.sh sourced /.taos-rollback, which executes the file as bash, and the same run escalates with sudo to restart the service. The file sits in the install dir that set_data_dir_ownership chowns to taos, so anything able to write as that account -- a compromised agent container with a bind mount, an updater bug, a partial write after a power cut -- got its shell run under sudo. The script now lifts the fields out with a record_field() helper that greps one `key='value'` line and undoes the writer's '\'' escape, and accepts prev_sha only when it is a hex object name. A recorded branch that does not look like a ref is dropped rather than handed to git, where a leading dash would read as an option. Same run fixes the secondary loss of the recovery route: a truncated record used to abort the script on a bash syntax error, and an empty or malformed prev_sha dead-ended on "cannot resolve". Both now fall through to the newest taos-pre-update-* tag, which is what that fallback exists for. The writer end matches: tinyagentos/rollback.py no longer advertises the file as shell-sourceable, refuses to record a sha that is not a git object name or a branch carrying a newline (which would forge a second record line), and applies the same hex rule when reading, so both readers agree on what is usable. --- ...62vx-rollback-record-parsed-not-sourced.md | 5 + scripts/rollback.sh | 57 ++++-- tests/test_rollback.py | 77 ++++++-- tests/test_rollback_script_parse.py | 167 ++++++++++++++++++ tinyagentos/rollback.py | 39 +++- 5 files changed, 309 insertions(+), 36 deletions(-) create mode 100644 changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md create mode 100644 tests/test_rollback_script_parse.py diff --git a/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md new file mode 100644 index 000000000..f9789c2d2 --- /dev/null +++ b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md @@ -0,0 +1,5 @@ +### Security +- `taos rollback` no longer executes its own state file. `scripts/rollback.sh` used to `source /.taos-rollback`, which runs the file as bash — and the script escalates with `sudo` to restart the service in the same run. The file lives in the install dir, which the installer chowns to the `taos` service account, so anything able to write as `taos` (a compromised agent container with a bind mount, an updater bug, a partial write after a power cut) could get its shell executed with root. The record is now parsed line by line as data and `prev_sha` is accepted only when it is a hex object name; a recorded branch that does not look like a ref is dropped so git can never read it as an option. + +### Fixed +- A truncated or corrupt `.taos-rollback` no longer loses the recovery route. A half-written record used to abort the script with a bash syntax error, and a record with an empty or malformed `prev_sha` dead-ended on "cannot resolve"; both now fall through to the newest `taos-pre-update-*` recovery tag, which is the whole point of having one. `tinyagentos/rollback.py` applies the same hex-SHA rule when it reads a record and refuses to write a target that is not a git object name, so both ends agree on what counts as usable. diff --git a/scripts/rollback.sh b/scripts/rollback.sh index eef38f3d9..c37912949 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -7,7 +7,9 @@ # bash scripts/rollback.sh # roll back to a specific tag/branch/sha # # The updater records the pre-update state in /.taos-rollback before it -# touches anything, so even a clean fast-forward has a restore point. +# touches anything, so even a clean fast-forward has a restore point. That file +# is read as DATA (see record_field below), never sourced: it is writable by the +# taos service account and this script escalates with sudo. set -euo pipefail # --- locate the install (this script lives in /scripts) --- @@ -22,28 +24,61 @@ fi log(){ echo "[rollback] $*"; } +# Read one `key='value'` field out of the record file WITHOUT executing it. +# The record sits in the install dir, which the installer chowns to the taos +# service account, and this script escalates with sudo further down -- so +# `source` would hand arbitrary code from a writable data file straight to +# root. tinyagentos/rollback.py writes the values single-quoted with the +# usual '\'' escape for an embedded quote; anything else is returned verbatim +# and rejected by the validation below. +record_field(){ + local key="$1" line val + line="$(grep -m1 -E "^[[:space:]]*${key}=" .taos-rollback 2>/dev/null)" || return 0 + val="${line#*=}" + if [[ "$val" == \'*\' ]]; then + val="${val:1:${#val}-2}" + val="${val//\'\\\'\'/\'}" + fi + printf '%s' "$val" +} + target_ref="${1:-}" +prev_branch="" +prev_sha="" if [[ -n "$target_ref" ]]; then # Explicit target: a tag, branch, or sha the user named. - prev_branch="" prev_sha="$target_ref" log "explicit target: $target_ref" elif [[ -f .taos-rollback ]]; then - # shellcheck disable=SC1091 - source .taos-rollback - prev_branch="${prev_branch:-}" - prev_sha="${prev_sha:-}" - log "recorded target: branch='${prev_branch}' commit='${prev_sha:0:12}'" -else - # Fallback for installs predating the recorded file: newest recovery tag. + prev_branch="$(record_field prev_branch)" + prev_sha="$(record_field prev_sha)" + # A truncated or tampered record must not reach git: only a hex object name + # counts as a recorded commit, and a branch name must look like a ref (never + # a leading dash, which git would read as an option). + if [[ ! "$prev_sha" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + log "record file has no usable commit; falling back to the recovery tag" + prev_branch="" + prev_sha="" + else + if [[ ! "$prev_branch" =~ ^[A-Za-z0-9_][A-Za-z0-9._/-]*$ ]]; then + log "record file has no usable branch; restoring the commit only" + prev_branch="" + fi + log "recorded target: branch='${prev_branch}' commit='${prev_sha:0:12}'" + fi +fi + +if [[ -z "$prev_sha" ]]; then + # No usable record (install predates the file, or it is missing/corrupt): + # fall back to the newest recovery tag. prev_sha="$(git tag --list 'taos-pre-update-*' --sort=-creatordate | head -1)" prev_branch="" if [[ -z "$prev_sha" ]]; then - echo "taos rollback: no recorded rollback target and no taos-pre-update-* tag found" >&2 + echo "taos rollback: no usable rollback target and no taos-pre-update-* tag found" >&2 exit 1 fi - log "no record file; using newest recovery tag: $prev_sha" + log "using newest recovery tag: $prev_sha" fi # Best-effort fetch so an explicit branch/tag that only exists on the remote diff --git a/tests/test_rollback.py b/tests/test_rollback.py index 424f7f260..8c42fe826 100644 --- a/tests/test_rollback.py +++ b/tests/test_rollback.py @@ -1,9 +1,34 @@ import subprocess +from pathlib import Path import pytest from tinyagentos.rollback import ROLLBACK_FILE, read_rollback_target, record_pre_update +ROLLBACK_SH = Path(__file__).resolve().parent.parent / "scripts" / "rollback.sh" + + +def _shell_record_field(record_dir: Path, key: str) -> str: + """Run rollback.sh's own record_field() against a record file. + + The function is lifted straight out of the script so the shell reader and + the Python writer are proven to agree on one file, instead of each being + tested against its own idea of the format. + """ + collected: list[str] = [] + depth = 0 + for line in ROLLBACK_SH.read_text().splitlines(): + if not collected and not line.startswith("record_field()"): + continue + collected.append(line) + depth += line.count("{") - line.count("}") + if depth <= 0 and len(collected) > 1: + break + script = "\n".join(collected) + f"\nrecord_field {key}\n" + return subprocess.check_output( + ["bash", "-c", script], cwd=str(record_dir), text=True + ) + def test_record_then_read_roundtrip(tmp_path): record_pre_update(tmp_path, branch="dev", sha="abc123def", ts=1700000000) @@ -12,34 +37,52 @@ def test_record_then_read_roundtrip(tmp_path): def test_record_overwrites(tmp_path): - record_pre_update(tmp_path, branch="dev", sha="aaa", ts=1) - record_pre_update(tmp_path, branch="feat/x", sha="bbb", ts=2) - assert read_rollback_target(tmp_path) == {"branch": "feat/x", "sha": "bbb", "ts": "2"} + record_pre_update(tmp_path, branch="dev", sha="aaaaaaa", ts=1) + record_pre_update(tmp_path, branch="feat/x", sha="bbbbbbb", ts=2) + assert read_rollback_target(tmp_path) == { + "branch": "feat/x", + "sha": "bbbbbbb", + "ts": "2", + } def test_read_none_when_absent(tmp_path): assert read_rollback_target(tmp_path) is None -def test_file_is_shell_sourceable(tmp_path): - """scripts/rollback.sh sources this file, so bash must read the same values.""" +def test_shell_parser_reads_the_same_values(tmp_path): + """rollback.sh parses this file, so bash must read what Python wrote.""" record_pre_update(tmp_path, branch="feat/odd-name", sha="deadbeef", ts=42) - out = subprocess.check_output( - ["bash", "-c", f"source '{tmp_path / ROLLBACK_FILE}' && echo \"$prev_branch|$prev_sha|$prev_ts\""], - text=True, - ).strip() - assert out == "feat/odd-name|deadbeef|42" + assert _shell_record_field(tmp_path, "prev_branch") == "feat/odd-name" + assert _shell_record_field(tmp_path, "prev_sha") == "deadbeef" + assert _shell_record_field(tmp_path, "prev_ts") == "42" def test_quote_injection_is_safe(tmp_path): - # A branch name with a quote must not break the sourceable file. - record_pre_update(tmp_path, branch="a'b", sha="c", ts=1) + # A branch name with a quote must survive the escape both readers undo. + record_pre_update(tmp_path, branch="a'b", sha="ccccccc", ts=1) assert read_rollback_target(tmp_path)["branch"] == "a'b" - out = subprocess.check_output( - ["bash", "-c", f"source '{tmp_path / ROLLBACK_FILE}' && printf '%s' \"$prev_branch\""], - text=True, - ) - assert out == "a'b" + assert _shell_record_field(tmp_path, "prev_branch") == "a'b" + + +def test_record_rejects_a_non_sha(tmp_path): + """Only a git object name is recordable, so no reader has to guess.""" + with pytest.raises(ValueError): + record_pre_update(tmp_path, branch="dev", sha="origin/dev", ts=1) + assert not (tmp_path / ROLLBACK_FILE).exists() + + +def test_record_rejects_a_newline_in_the_branch(tmp_path): + """A newline would forge a second prev_sha= line in the record.""" + with pytest.raises(ValueError): + record_pre_update(tmp_path, branch="dev\nprev_sha='beef'", sha="deadbeef", ts=1) + assert not (tmp_path / ROLLBACK_FILE).exists() + + +def test_read_rejects_a_tampered_sha(tmp_path): + """A truncated or edited record reads as no record, not as a bad target.""" + (tmp_path / ROLLBACK_FILE).write_text("prev_branch='dev'\nprev_sha='ab") + assert read_rollback_target(tmp_path) is None @pytest.mark.asyncio diff --git a/tests/test_rollback_script_parse.py b/tests/test_rollback_script_parse.py new file mode 100644 index 000000000..06a53c465 --- /dev/null +++ b/tests/test_rollback_script_parse.py @@ -0,0 +1,167 @@ +"""Tests for how ``scripts/rollback.sh`` reads the ``.taos-rollback`` record. + +The record lives in the install dir, which the installer chowns to the ``taos`` +service account, and the rollback script escalates with ``sudo`` when it +restarts the unit. So the record must be *parsed as data* -- never executed -- +and a corrupt or truncated record must fall through to the recovery-tag path +instead of dead-ending on "cannot resolve". +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +ROLLBACK_SH = Path(__file__).resolve().parent.parent / "scripts" / "rollback.sh" +RECOVERY_TAG = "taos-pre-update-main-1700000000" + + +def _git(repo: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip() + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + """A tiny git checkout with two commits and one taos-pre-update-* tag.""" + checkout = tmp_path / "install" + checkout.mkdir() + subprocess.check_call( + ["git", "init", "-q", "-b", "main", str(checkout)], + stdout=subprocess.DEVNULL, + ) + _git(checkout, "config", "user.name", "taos test") + _git(checkout, "config", "user.email", "test@example.invalid") + (checkout / "VERSION").write_text("old\n") + _git(checkout, "add", "VERSION") + _git(checkout, "commit", "-qm", "old version") + _git(checkout, "tag", RECOVERY_TAG) + (checkout / "VERSION").write_text("new\n") + _git(checkout, "commit", "-qam", "new version") + return checkout + + +@pytest.fixture() +def stub_bin(tmp_path: Path) -> Path: + """PATH shim so the script's service-restart step is a no-op in the test.""" + bindir = tmp_path / "bin" + bindir.mkdir() + for name in ("systemctl", "launchctl", "sudo", "pgrep"): + stub = bindir / name + stub.write_text("#!/bin/sh\nexit 1\n") + stub.chmod(0o755) + return bindir + + +def _run_rollback(repo: Path, stub_bin: Path, tmp_path: Path, *args: str): + env = { + "PATH": f"{stub_bin}{os.pathsep}{os.environ['PATH']}", + "HOME": str(tmp_path / "home"), + "TAOS_INSTALL_DIR": str(repo), + } + return subprocess.run( + ["bash", str(ROLLBACK_SH), *args], + cwd=str(repo), + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + +def test_payload_in_record_is_not_executed(repo, stub_bin, tmp_path): + """A record carrying shell metacharacters must never run as code.""" + sentinel_sub = tmp_path / "pwned-substitution" + sentinel_cmd = tmp_path / "pwned-command" + (repo / ".taos-rollback").write_text( + "# taOS rollback target\n" + "prev_branch='main'\n" + f"prev_sha=$(touch '{sentinel_sub}')\n" + f"touch '{sentinel_cmd}'\n" + ) + + _run_rollback(repo, stub_bin, tmp_path) + + assert not sentinel_sub.exists(), ( + f"sentinel file {sentinel_sub} was created (expected: not created) -- " + "rollback.sh executed the record file" + ) + assert not sentinel_cmd.exists(), ( + f"sentinel file {sentinel_cmd} was created (expected: not created) -- " + "rollback.sh executed the record file" + ) + + +def test_truncated_record_falls_back_to_recovery_tag(repo, stub_bin, tmp_path): + """A half-written record (power cut mid-write) must use the recovery tag.""" + tag_sha = _git(repo, "rev-parse", RECOVERY_TAG) + (repo / ".taos-rollback").write_text( + "# taOS rollback target\nprev_branch='main'\nprev_sha='ab" + ) + + result = _run_rollback(repo, stub_bin, tmp_path) + + combined = result.stdout + result.stderr + assert "cannot resolve" not in combined, ( + f'got "cannot resolve", expected the recovery-tag path; output:\n{combined}' + ) + assert _git(repo, "rev-parse", "HEAD") == tag_sha, ( + f"expected rollback to the recovery tag {tag_sha[:12]}; output:\n{combined}" + ) + + +def test_record_without_sha_falls_back_to_recovery_tag(repo, stub_bin, tmp_path): + """A record whose prev_sha line never landed must use the recovery tag.""" + tag_sha = _git(repo, "rev-parse", RECOVERY_TAG) + (repo / ".taos-rollback").write_text("# taOS rollback target\nprev_branch='main'\n") + + result = _run_rollback(repo, stub_bin, tmp_path) + + combined = result.stdout + result.stderr + assert _git(repo, "rev-parse", "HEAD") == tag_sha, ( + f"expected rollback to the recovery tag {tag_sha[:12]}; output:\n{combined}" + ) + + +def test_non_hex_sha_falls_back_to_recovery_tag(repo, stub_bin, tmp_path): + """prev_sha must be a hex SHA; anything else is treated as no record.""" + tag_sha = _git(repo, "rev-parse", RECOVERY_TAG) + (repo / ".taos-rollback").write_text( + "# taOS rollback target\nprev_branch='main'\nprev_sha='--upload-pack=touch'\n" + ) + + result = _run_rollback(repo, stub_bin, tmp_path) + + combined = result.stdout + result.stderr + assert _git(repo, "rev-parse", "HEAD") == tag_sha, ( + f"expected rollback to the recovery tag {tag_sha[:12]}; output:\n{combined}" + ) + + +def test_wellformed_record_restores_branch_and_commit(repo, stub_bin, tmp_path): + """The happy path keeps working: both branch and commit come back.""" + old_sha = _git(repo, "rev-parse", "HEAD~1") + (repo / ".taos-rollback").write_text( + "# taOS rollback target\n" + "prev_branch='main'\n" + f"prev_sha='{old_sha}'\n" + "prev_ts='1700000000'\n" + ) + + result = _run_rollback(repo, stub_bin, tmp_path) + + combined = result.stdout + result.stderr + assert _git(repo, "rev-parse", "HEAD") == old_sha, combined + assert _git(repo, "rev-parse", "--abbrev-ref", "HEAD") == "main", combined + + +def test_explicit_target_still_wins(repo, stub_bin, tmp_path): + """An explicit ref argument bypasses the record entirely, as before.""" + old_sha = _git(repo, "rev-parse", "HEAD~1") + (repo / ".taos-rollback").write_text( + "# taOS rollback target\nprev_branch='main'\nprev_sha='ab" + ) + + result = _run_rollback(repo, stub_bin, tmp_path, old_sha) + + assert _git(repo, "rev-parse", "HEAD") == old_sha, result.stdout + result.stderr diff --git a/tinyagentos/rollback.py b/tinyagentos/rollback.py index 899fdbaff..710496182 100644 --- a/tinyagentos/rollback.py +++ b/tinyagentos/rollback.py @@ -2,23 +2,35 @@ Before every update, the updater records the exact branch + commit it is leaving so a later ``taos rollback`` can restore BOTH (the previous version and the -previous branch, even if both changed). The record is written as a tiny -shell-sourceable file so ``scripts/rollback.sh`` can read it with no Python and -no dashboard, which is the whole point: rollback must work when an update has +previous branch, even if both changed). The record is a tiny ``key='value'`` +text file so ``scripts/rollback.sh`` can read it with no Python and no +dashboard, which is the whole point: rollback must work when an update has broken the app. +The file is DATA, never code. It lives in the install dir, which the installer +chowns to the ``taos`` service account, and ``scripts/rollback.sh`` escalates +with ``sudo`` when it restarts the unit -- so both ends parse it line by line +and accept ``prev_sha`` only when it is a hex object name. Anything else is +treated as "no usable record", which sends the script to its recovery-tag +fallback instead of dead-ending. + File: ``/.taos-rollback`` (single record, overwritten each update). """ from __future__ import annotations +import re from pathlib import Path ROLLBACK_FILE = ".taos-rollback" +# A recorded commit is a git object name and nothing else. Kept in sync with the +# same check in scripts/rollback.sh so both readers agree on what is usable. +_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$") + def _shq(value: str) -> str: - """Single-quote a value so the file stays safe to `source` in bash.""" + """Single-quote a value for the ``key='value'`` record format.""" return "'" + str(value).replace("'", "'\\''") + "'" @@ -27,11 +39,20 @@ def record_pre_update(project_dir, *, branch: str, sha: str, ts: int) -> Path: Overwrites any prior record: rollback targets the state immediately before the most recent update, which is the one a user would want to undo. + + Raises ``ValueError`` for a ``sha`` that is not a git object name or a + ``branch`` carrying a newline (which would forge a second record line). + The caller records best-effort, so a rejected write simply leaves the + rollback script on its recovery-tag fallback rather than on a bad target. """ + if not _SHA_RE.match(str(sha)): + raise ValueError(f"rollback sha is not a git object name: {sha!r}") + if "\n" in str(branch) or "\r" in str(branch): + raise ValueError(f"rollback branch contains a newline: {branch!r}") path = Path(project_dir) / ROLLBACK_FILE path.write_text( "# taOS rollback target -- the branch + commit the last update left.\n" - "# Shell-sourceable on purpose so scripts/rollback.sh needs no Python.\n" + "# Data only: scripts/rollback.sh parses these lines, it never sources them.\n" f"prev_branch={_shq(branch)}\n" f"prev_sha={_shq(sha)}\n" f"prev_ts={_shq(ts)}\n" @@ -40,10 +61,12 @@ def record_pre_update(project_dir, *, branch: str, sha: str, ts: int) -> Path: def read_rollback_target(project_dir) -> dict | None: - """Read the recorded rollback target, or None if there is no record. + """Read the recorded rollback target, or None if there is no usable record. Returns ``{"branch": str, "sha": str, "ts": str}``. Parses the simple - ``key='value'`` lines without sourcing (so it is safe to call on any input). + ``key='value'`` lines without sourcing (so it is safe to call on any input), + and rejects a truncated or tampered record whose ``prev_sha`` is not a hex + object name -- the same rule scripts/rollback.sh applies. """ path = Path(project_dir) / ROLLBACK_FILE if not path.is_file(): @@ -58,6 +81,6 @@ def read_rollback_target(project_dir) -> dict | None: if len(val) >= 2 and val[0] == val[-1] == "'": val = val[1:-1].replace("'\\''", "'") out[key.strip()] = val - if "prev_branch" not in out or "prev_sha" not in out: + if "prev_branch" not in out or not _SHA_RE.match(out.get("prev_sha", "")): return None return {"branch": out["prev_branch"], "sha": out["prev_sha"], "ts": out.get("prev_ts", "")} From 76b86bdce2e7222001b5ca136cd75bf54a5f99fd Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sat, 5 Sep 2026 01:19:11 +0000 Subject: [PATCH 2/4] fix(rollback): tighten the record's sha and branch rules to git's own (tsk-rw62vx) Folds the Kilo review on #2782. sha: the writer records `git rev-parse HEAD`, which is never abbreviated, so accepting 7-40 hex on read let a truncated or forged prefix look legitimate -- and it would resolve, which is worse than failing. Both ends now require a full object name (40 hex, or 64 in a sha256 checkout) and send anything else to the recovery tag. branch: the hand-rolled charset regex allowed `..`, a leading `.` and a trailing `.lock`, all of which git refuses. `git checkout -B` then failed on both the plain and the --force attempt and `set -e` aborted the run, so a cosmetically bad branch name cost the whole rollback rather than just the branch. The shell now asks `git check-ref-format refs/heads/` -- the authority -- plus one rule that is ours: no leading dash, since git calls `refs/heads/--force` valid while `checkout -B --force` reads it as an option. The Python reader gets the same two rules (_ref_safe mirrors check-ref-format; an unusable branch blanks the branch and keeps the commit, exactly as the shell does), and a parametrised test pins the reimplementation to git's own answers over a table of 31 names so the two readers cannot drift. Also: the record_field() extractor in the tests anchored on brace depth, which only balanced by luck given the `${...}` in the body; it now slices from the `name()` line to the first `}` in column 0. The payload test asserts the run finished down the recovery-tag route (exit 0, tag reached), not merely that the sentinels are absent -- dying before reading the record would satisfy that too. The script-test fixture grew a third commit so the recorded target and the recovery tag are different shas; with two they coincided and the abbreviated-sha case passed either way. --- ...62vx-rollback-record-parsed-not-sourced.md | 5 +- scripts/rollback.sh | 28 ++- tests/test_rollback.py | 163 +++++++++++++++--- tests/test_rollback_script_parse.py | 87 +++++++++- tinyagentos/rollback.py | 59 +++++-- 5 files changed, 299 insertions(+), 43 deletions(-) diff --git a/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md index f9789c2d2..1576ccfb4 100644 --- a/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md +++ b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md @@ -1,5 +1,6 @@ ### Security -- `taos rollback` no longer executes its own state file. `scripts/rollback.sh` used to `source /.taos-rollback`, which runs the file as bash — and the script escalates with `sudo` to restart the service in the same run. The file lives in the install dir, which the installer chowns to the `taos` service account, so anything able to write as `taos` (a compromised agent container with a bind mount, an updater bug, a partial write after a power cut) could get its shell executed with root. The record is now parsed line by line as data and `prev_sha` is accepted only when it is a hex object name; a recorded branch that does not look like a ref is dropped so git can never read it as an option. +- `taos rollback` no longer executes its own state file. `scripts/rollback.sh` used to `source /.taos-rollback`, which runs the file as bash — and the script escalates with `sudo` to restart the service in the same run. The file lives in the install dir, which the installer chowns to the `taos` service account, so anything able to write as `taos` (a compromised agent container with a bind mount, an updater bug, a partial write after a power cut) could get its shell executed with root. The record is now parsed line by line as data, `prev_sha` is accepted only when it is a full 40- (or 64-) character object name, and a recorded branch is restored only when `git check-ref-format` accepts it and it does not start with a dash — git considers `refs/heads/--force` a valid ref, but `git checkout -B --force` would read it as an option. ### Fixed -- A truncated or corrupt `.taos-rollback` no longer loses the recovery route. A half-written record used to abort the script with a bash syntax error, and a record with an empty or malformed `prev_sha` dead-ended on "cannot resolve"; both now fall through to the newest `taos-pre-update-*` recovery tag, which is the whole point of having one. `tinyagentos/rollback.py` applies the same hex-SHA rule when it reads a record and refuses to write a target that is not a git object name, so both ends agree on what counts as usable. +- A truncated or corrupt `.taos-rollback` no longer loses the recovery route. A half-written record used to abort the script with a bash syntax error, and a record with an empty or malformed `prev_sha` dead-ended on "cannot resolve"; both now fall through to the newest `taos-pre-update-*` recovery tag, which is the whole point of having one. A branch name git would refuse (`feat/..evil`, a trailing `.lock`, a leading `.`) used to abort the rollback outright when both the plain and the `--force` checkout failed; it now costs only the branch, and the recorded commit is still restored. +- `tinyagentos/rollback.py` applies the same object-name and ref-name rules when it reads a record, and refuses to write a target that is not a full object name, so both ends agree on what counts as usable. diff --git a/scripts/rollback.sh b/scripts/rollback.sh index c37912949..2383c99f5 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -42,6 +42,24 @@ record_field(){ printf '%s' "$val" } +# A recorded commit is a FULL object name. The writer records `git rev-parse +# HEAD`, which is 40 hex (64 in a sha256 checkout) and never abbreviated -- so a +# short value in the record is a truncated or forged one, not a legitimate +# prefix, even when git would happily resolve it. +sha_safe(){ + [[ "$1" =~ ^[0-9a-fA-F]{40}$ || "$1" =~ ^[0-9a-fA-F]{64}$ ]] +} + +# A recorded branch is usable only if git itself calls it a valid ref name, and +# only if it does not start with a dash: `git checkout -B --force ` would +# read the name as an option, and git considers `refs/heads/--force` a perfectly +# valid ref. check-ref-format is the authority on the rest (no `..`, no leading +# `.`, no trailing `.lock`, no `@{`, no control characters, space or ~^:?*[\) -- +# hand-rolling that grammar in a regex is how these checks drift out of date. +ref_safe(){ + [[ -n "$1" && "$1" != -* ]] && git check-ref-format "refs/heads/$1" 2>/dev/null +} + target_ref="${1:-}" prev_branch="" prev_sha="" @@ -53,15 +71,15 @@ if [[ -n "$target_ref" ]]; then elif [[ -f .taos-rollback ]]; then prev_branch="$(record_field prev_branch)" prev_sha="$(record_field prev_sha)" - # A truncated or tampered record must not reach git: only a hex object name - # counts as a recorded commit, and a branch name must look like a ref (never - # a leading dash, which git would read as an option). - if [[ ! "$prev_sha" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + # A truncated or tampered record must not reach git. An unusable commit sends + # the whole run to the recovery tag; an unusable branch costs only the branch, + # because getting the commit back still beats not rolling back at all. + if ! sha_safe "$prev_sha"; then log "record file has no usable commit; falling back to the recovery tag" prev_branch="" prev_sha="" else - if [[ ! "$prev_branch" =~ ^[A-Za-z0-9_][A-Za-z0-9._/-]*$ ]]; then + if ! ref_safe "$prev_branch"; then log "record file has no usable branch; restoring the commit only" prev_branch="" fi diff --git a/tests/test_rollback.py b/tests/test_rollback.py index 8c42fe826..9a489e6c3 100644 --- a/tests/test_rollback.py +++ b/tests/test_rollback.py @@ -3,10 +3,76 @@ import pytest -from tinyagentos.rollback import ROLLBACK_FILE, read_rollback_target, record_pre_update +from tinyagentos.rollback import ( + ROLLBACK_FILE, + _ref_safe, + read_rollback_target, + record_pre_update, +) ROLLBACK_SH = Path(__file__).resolve().parent.parent / "scripts" / "rollback.sh" +# Full git object names, as `git rev-parse HEAD` emits them. Short strings would +# not survive the writer's own validation, and pretending otherwise is how the +# old fixtures let an abbreviated sha look legitimate. +SHA_A = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" +SHA_B = "b2c3d4e5f60718293a4b5c6d7e8f90123456789a" + +# Branch names and whether `git check-ref-format refs/heads/` accepts them, +# plus the two extra rules the readers impose: a name is unusable when empty, and +# when it starts with a dash (`git checkout -B -x` would read it as an option -- +# git itself calls `refs/heads/-foo` a perfectly valid ref). +REF_CASES = [ + ("main", True), + ("dev", True), + ("feat/x", True), + ("feat/odd-name", True), + ("a.b", True), + ("a-b", True), + ("HEAD", True), + ("@", True), + ("", False), + ("-foo", False), + ("feat/..evil", False), + ("..", False), + (".hidden", False), + ("feat/.hidden", False), + ("x.lock", False), + ("feat/x.lock", False), + ("a@{b", False), + ("a b", False), + ("a~b", False), + ("a^b", False), + ("a:b", False), + ("a?b", False), + ("a*b", False), + ("a[b", False), + ("a\\b", False), + ("a.", False), + ("feat/", False), + ("/feat", False), + ("feat//x", False), + ("a\tb", False), + ("a\x7fb", False), +] + + +def _shell_func(name: str) -> str: + """Return one shell function's source from scripts/rollback.sh. + + Anchored at both ends -- the `name()` line and the first following `}` in + column 0 -- rather than counting braces, which a `${var}` in the body makes + a coin flip. + """ + lines = ROLLBACK_SH.read_text().splitlines() + start = next( + (i for i, line in enumerate(lines) if line.startswith(f"{name}()")), None + ) + assert start is not None, f"{name}() not found in {ROLLBACK_SH}" + end = next((i for i in range(start + 1, len(lines)) if lines[i] == "}"), None) + assert end is not None, f"no closing brace for {name}() in {ROLLBACK_SH}" + return "\n".join(lines[start : end + 1]) + def _shell_record_field(record_dir: Path, key: str) -> str: """Run rollback.sh's own record_field() against a record file. @@ -15,33 +81,30 @@ def _shell_record_field(record_dir: Path, key: str) -> str: the Python writer are proven to agree on one file, instead of each being tested against its own idea of the format. """ - collected: list[str] = [] - depth = 0 - for line in ROLLBACK_SH.read_text().splitlines(): - if not collected and not line.startswith("record_field()"): - continue - collected.append(line) - depth += line.count("{") - line.count("}") - if depth <= 0 and len(collected) > 1: - break - script = "\n".join(collected) + f"\nrecord_field {key}\n" + script = _shell_func("record_field") + f"\nrecord_field {key}\n" return subprocess.check_output( ["bash", "-c", script], cwd=str(record_dir), text=True ) +def _shell_ref_safe(name: str) -> bool: + """Ask rollback.sh's own ref_safe() whether it would restore this branch.""" + script = _shell_func("ref_safe") + '\nref_safe "$1"\n' + return subprocess.run(["bash", "-c", script, "bash", name]).returncode == 0 + + def test_record_then_read_roundtrip(tmp_path): - record_pre_update(tmp_path, branch="dev", sha="abc123def", ts=1700000000) + record_pre_update(tmp_path, branch="dev", sha=SHA_A, ts=1700000000) target = read_rollback_target(tmp_path) - assert target == {"branch": "dev", "sha": "abc123def", "ts": "1700000000"} + assert target == {"branch": "dev", "sha": SHA_A, "ts": "1700000000"} def test_record_overwrites(tmp_path): - record_pre_update(tmp_path, branch="dev", sha="aaaaaaa", ts=1) - record_pre_update(tmp_path, branch="feat/x", sha="bbbbbbb", ts=2) + record_pre_update(tmp_path, branch="dev", sha=SHA_A, ts=1) + record_pre_update(tmp_path, branch="feat/x", sha=SHA_B, ts=2) assert read_rollback_target(tmp_path) == { "branch": "feat/x", - "sha": "bbbbbbb", + "sha": SHA_B, "ts": "2", } @@ -52,15 +115,15 @@ def test_read_none_when_absent(tmp_path): def test_shell_parser_reads_the_same_values(tmp_path): """rollback.sh parses this file, so bash must read what Python wrote.""" - record_pre_update(tmp_path, branch="feat/odd-name", sha="deadbeef", ts=42) + record_pre_update(tmp_path, branch="feat/odd-name", sha=SHA_A, ts=42) assert _shell_record_field(tmp_path, "prev_branch") == "feat/odd-name" - assert _shell_record_field(tmp_path, "prev_sha") == "deadbeef" + assert _shell_record_field(tmp_path, "prev_sha") == SHA_A assert _shell_record_field(tmp_path, "prev_ts") == "42" def test_quote_injection_is_safe(tmp_path): # A branch name with a quote must survive the escape both readers undo. - record_pre_update(tmp_path, branch="a'b", sha="ccccccc", ts=1) + record_pre_update(tmp_path, branch="a'b", sha=SHA_A, ts=1) assert read_rollback_target(tmp_path)["branch"] == "a'b" assert _shell_record_field(tmp_path, "prev_branch") == "a'b" @@ -72,10 +135,32 @@ def test_record_rejects_a_non_sha(tmp_path): assert not (tmp_path / ROLLBACK_FILE).exists() +@pytest.mark.parametrize("sha", ["abc123d", "abc1234567", SHA_A[:12], SHA_A[:39]]) +def test_record_rejects_an_abbreviated_sha(tmp_path, sha): + """The writer records `git rev-parse HEAD`, which is never abbreviated. + + A short value in the record is therefore a truncated or forged one, so it + must not be writable and must not read back as a usable target. + """ + with pytest.raises(ValueError): + record_pre_update(tmp_path, branch="dev", sha=sha, ts=1) + assert not (tmp_path / ROLLBACK_FILE).exists() + + (tmp_path / ROLLBACK_FILE).write_text(f"prev_branch='dev'\nprev_sha='{sha}'\n") + assert read_rollback_target(tmp_path) is None + + +def test_record_accepts_a_sha256_object_name(tmp_path): + """A sha256 checkout emits 64-hex names; that is a full name, not a forgery.""" + sha256 = "c" * 64 + record_pre_update(tmp_path, branch="dev", sha=sha256, ts=1) + assert read_rollback_target(tmp_path)["sha"] == sha256 + + def test_record_rejects_a_newline_in_the_branch(tmp_path): """A newline would forge a second prev_sha= line in the record.""" with pytest.raises(ValueError): - record_pre_update(tmp_path, branch="dev\nprev_sha='beef'", sha="deadbeef", ts=1) + record_pre_update(tmp_path, branch="dev\nprev_sha='beef'", sha=SHA_A, ts=1) assert not (tmp_path / ROLLBACK_FILE).exists() @@ -85,6 +170,40 @@ def test_read_rejects_a_tampered_sha(tmp_path): assert read_rollback_target(tmp_path) is None +@pytest.mark.parametrize("name,valid", REF_CASES) +def test_ref_safe_matches_git_check_ref_format(name, valid): + """The Python rule is a reimplementation, so pin it to git's own checker. + + `git check-ref-format` is what scripts/rollback.sh asks, so agreeing with it + is what makes the two readers agree with each other. + """ + by_git = ( + name != "" + and not name.startswith("-") + and subprocess.run( + ["git", "check-ref-format", f"refs/heads/{name}"], + capture_output=True, + ).returncode + == 0 + ) + assert by_git == valid, f"REF_CASES disagrees with git for {name!r}" + assert _ref_safe(name) == valid, f"python _ref_safe({name!r}) != {valid}" + + +@pytest.mark.parametrize("name,valid", REF_CASES) +def test_shell_ref_safe_matches_python(name, valid): + """Same rule on the shell end, asked of the script's own function.""" + assert _shell_ref_safe(name) == valid, f"shell ref_safe({name!r}) != {valid}" + + +def test_read_drops_an_unsafe_branch_but_keeps_the_commit(tmp_path): + """Matching the shell: a bad branch costs the branch, never the commit.""" + (tmp_path / ROLLBACK_FILE).write_text( + f"prev_branch='feat/..evil'\nprev_sha='{SHA_A}'\nprev_ts='7'\n" + ) + assert read_rollback_target(tmp_path) == {"branch": "", "sha": SHA_A, "ts": "7"} + + @pytest.mark.asyncio async def test_update_records_rollback_target(tmp_path, monkeypatch): """update_to_master records the pre-update branch + sha before mutating.""" @@ -98,7 +217,7 @@ async def fake_run(args, cwd): if "rev-parse --abbrev-ref" in joined: return (0, "dev\n") if "rev-parse HEAD" in joined: - return (0, "abc1234567\n") + return (0, f"{SHA_A}\n") if "status --porcelain" in joined: return (0, "") # clean return (0, "") @@ -108,4 +227,4 @@ async def fake_run(args, cwd): target = read_rollback_target(tmp_path) assert target is not None assert target["branch"] == "dev" - assert target["sha"] == "abc1234567" + assert target["sha"] == SHA_A diff --git a/tests/test_rollback_script_parse.py b/tests/test_rollback_script_parse.py index 06a53c465..06618e203 100644 --- a/tests/test_rollback_script_parse.py +++ b/tests/test_rollback_script_parse.py @@ -23,7 +23,13 @@ def _git(repo: Path, *args: str) -> str: @pytest.fixture() def repo(tmp_path: Path) -> Path: - """A tiny git checkout with two commits and one taos-pre-update-* tag.""" + """A tiny git checkout with three commits and one taos-pre-update-* tag. + + Three, not two: the recovery tag sits on the oldest commit and the recorded + rollback target on ``HEAD~1``, so every assertion about which of the two + routes ran is decided by a different sha. With two commits they coincide and + the tests pass either way. + """ checkout = tmp_path / "install" checkout.mkdir() subprocess.check_call( @@ -32,10 +38,12 @@ def repo(tmp_path: Path) -> Path: ) _git(checkout, "config", "user.name", "taos test") _git(checkout, "config", "user.email", "test@example.invalid") - (checkout / "VERSION").write_text("old\n") + (checkout / "VERSION").write_text("oldest\n") _git(checkout, "add", "VERSION") - _git(checkout, "commit", "-qm", "old version") + _git(checkout, "commit", "-qm", "oldest version") _git(checkout, "tag", RECOVERY_TAG) + (checkout / "VERSION").write_text("old\n") + _git(checkout, "commit", "-qam", "old version") (checkout / "VERSION").write_text("new\n") _git(checkout, "commit", "-qam", "new version") return checkout @@ -80,7 +88,7 @@ def test_payload_in_record_is_not_executed(repo, stub_bin, tmp_path): f"touch '{sentinel_cmd}'\n" ) - _run_rollback(repo, stub_bin, tmp_path) + result = _run_rollback(repo, stub_bin, tmp_path) assert not sentinel_sub.exists(), ( f"sentinel file {sentinel_sub} was created (expected: not created) -- " @@ -90,6 +98,15 @@ def test_payload_in_record_is_not_executed(repo, stub_bin, tmp_path): f"sentinel file {sentinel_cmd} was created (expected: not created) -- " "rollback.sh executed the record file" ) + # Absent sentinels alone would also be satisfied by the script dying before + # it ever read the record, so pin the path it actually took: the payload is + # not a commit, so the run must complete down the recovery-tag route. + combined = result.stdout + result.stderr + assert "recovery tag" in combined, combined + assert result.returncode == 0, combined + assert _git(repo, "rev-parse", "HEAD") == _git(repo, "rev-parse", RECOVERY_TAG), ( + combined + ) def test_truncated_record_falls_back_to_recovery_tag(repo, stub_bin, tmp_path): @@ -155,6 +172,68 @@ def test_wellformed_record_restores_branch_and_commit(repo, stub_bin, tmp_path): assert _git(repo, "rev-parse", "--abbrev-ref", "HEAD") == "main", combined +def test_abbreviated_sha_falls_back_to_recovery_tag(repo, stub_bin, tmp_path): + """An abbreviated prev_sha is a truncated record, even when git resolves it. + + The writer only ever records `git rev-parse HEAD`, so a 7-char prefix in the + record did not come from the writer. It still names a real commit here, which + is exactly why resolving it would be the wrong answer. + """ + tag_sha = _git(repo, "rev-parse", RECOVERY_TAG) + old_sha = _git(repo, "rev-parse", "HEAD~1") + (repo / ".taos-rollback").write_text( + f"# taOS rollback target\nprev_branch='main'\nprev_sha='{old_sha[:7]}'\n" + ) + + result = _run_rollback(repo, stub_bin, tmp_path) + + combined = result.stdout + result.stderr + assert _git(repo, "rev-parse", "HEAD") == tag_sha, ( + f"expected the recovery tag {tag_sha[:12]}, not the abbreviated " + f"{old_sha[:7]}; output:\n{combined}" + ) + + +def test_unsafe_branch_restores_the_commit_detached(repo, stub_bin, tmp_path): + """A branch git would reject costs the branch, never the commit. + + `git checkout -B 'feat/..evil'` fails, and both the plain and the --force + attempt failing aborts the whole script under `set -e` -- turning a + recoverable rollback into no rollback at all. + """ + old_sha = _git(repo, "rev-parse", "HEAD~1") + (repo / ".taos-rollback").write_text( + f"# taOS rollback target\nprev_branch='feat/..evil'\nprev_sha='{old_sha}'\n" + ) + + result = _run_rollback(repo, stub_bin, tmp_path) + + combined = result.stdout + result.stderr + assert result.returncode == 0, combined + assert _git(repo, "rev-parse", "HEAD") == old_sha, combined + + +def test_dash_leading_branch_is_not_passed_to_git(repo, stub_bin, tmp_path): + """`-B -x` would read the branch as an option; git calls the ref itself fine.""" + old_sha = _git(repo, "rev-parse", "HEAD~1") + (repo / ".taos-rollback").write_text( + f"# taOS rollback target\nprev_branch='--force'\nprev_sha='{old_sha}'\n" + ) + + result = _run_rollback(repo, stub_bin, tmp_path) + + combined = result.stdout + result.stderr + assert result.returncode == 0, combined + assert _git(repo, "rev-parse", "HEAD") == old_sha, combined + # `git branch --list --force` would eat the name as an option, so ask for the + # ref by its full path instead. + made = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", "refs/heads/--force"], + capture_output=True, + ) + assert made.returncode != 0, "a branch literally named --force was created" + + def test_explicit_target_still_wins(repo, stub_bin, tmp_path): """An explicit ref argument bypasses the record entirely, as before.""" old_sha = _git(repo, "rev-parse", "HEAD~1") diff --git a/tinyagentos/rollback.py b/tinyagentos/rollback.py index 710496182..a5827ac01 100644 --- a/tinyagentos/rollback.py +++ b/tinyagentos/rollback.py @@ -10,8 +10,8 @@ The file is DATA, never code. It lives in the install dir, which the installer chowns to the ``taos`` service account, and ``scripts/rollback.sh`` escalates with ``sudo`` when it restarts the unit -- so both ends parse it line by line -and accept ``prev_sha`` only when it is a hex object name. Anything else is -treated as "no usable record", which sends the script to its recovery-tag +and accept ``prev_sha`` only when it is a full hex object name. Anything else +is treated as "no usable record", which sends the script to its recovery-tag fallback instead of dead-ending. File: ``/.taos-rollback`` (single record, overwritten each update). @@ -24,9 +24,40 @@ ROLLBACK_FILE = ".taos-rollback" -# A recorded commit is a git object name and nothing else. Kept in sync with the -# same check in scripts/rollback.sh so both readers agree on what is usable. -_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$") +# A recorded commit is a FULL git object name and nothing else: the writer +# records ``git rev-parse HEAD``, which is 40 hex (64 in a sha256 checkout) and +# never abbreviated. A short value is therefore a truncated or forged record, +# not a legitimate prefix. Kept in sync with sha_safe() in scripts/rollback.sh. +_SHA_RE = re.compile(r"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$") + +# Characters git bans anywhere in a ref name: ASCII control characters, space, +# and ~ ^ : ? * [ \ (git-check-ref-format(1)). +_REF_BANNED = frozenset("~^:?*[\\ \x7f") | frozenset(chr(c) for c in range(0x20)) + + +def _ref_safe(name: str) -> bool: + """Would ``scripts/rollback.sh`` restore a branch by this name? + + A reimplementation of ``git check-ref-format refs/heads/`` plus the + one rule that is ours rather than git's: a name may not start with a dash, + because ``git checkout -B --force `` reads it as an option while git + itself calls ``refs/heads/--force`` a perfectly valid ref. The shell end + asks git directly; ``tests/test_rollback.py`` pins this copy to the same + answers so the two readers cannot drift apart. + """ + if not name or name.startswith("-"): + return False + if ".." in name or "@{" in name or name.endswith("."): + return False + if any(ch in _REF_BANNED for ch in name): + return False + # Slash-separated components: none empty (which also covers a leading or + # trailing slash and a doubled one), none starting with '.', none ending + # in '.lock'. + return all( + part and not part.startswith(".") and not part.endswith(".lock") + for part in name.split("/") + ) def _shq(value: str) -> str: @@ -40,7 +71,7 @@ def record_pre_update(project_dir, *, branch: str, sha: str, ts: int) -> Path: Overwrites any prior record: rollback targets the state immediately before the most recent update, which is the one a user would want to undo. - Raises ``ValueError`` for a ``sha`` that is not a git object name or a + Raises ``ValueError`` for a ``sha`` that is not a full git object name or a ``branch`` carrying a newline (which would forge a second record line). The caller records best-effort, so a rejected write simply leaves the rollback script on its recovery-tag fallback rather than on a bad target. @@ -65,8 +96,11 @@ def read_rollback_target(project_dir) -> dict | None: Returns ``{"branch": str, "sha": str, "ts": str}``. Parses the simple ``key='value'`` lines without sourcing (so it is safe to call on any input), - and rejects a truncated or tampered record whose ``prev_sha`` is not a hex - object name -- the same rule scripts/rollback.sh applies. + and applies exactly the rules scripts/rollback.sh applies to the same file: + a ``prev_sha`` that is not a full object name makes the whole record + unusable (None), while a ``prev_branch`` git would refuse costs only the + branch and comes back as ``""`` -- restoring the commit alone still beats + not rolling back. """ path = Path(project_dir) / ROLLBACK_FILE if not path.is_file(): @@ -81,6 +115,11 @@ def read_rollback_target(project_dir) -> dict | None: if len(val) >= 2 and val[0] == val[-1] == "'": val = val[1:-1].replace("'\\''", "'") out[key.strip()] = val - if "prev_branch" not in out or not _SHA_RE.match(out.get("prev_sha", "")): + if not _SHA_RE.match(out.get("prev_sha", "")): return None - return {"branch": out["prev_branch"], "sha": out["prev_sha"], "ts": out.get("prev_ts", "")} + branch = out.get("prev_branch", "") + return { + "branch": branch if _ref_safe(branch) else "", + "sha": out["prev_sha"], + "ts": out.get("prev_ts", ""), + } From 1817efe71f6e7dc64f9575446db47e4aed8fd791 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sat, 5 Sep 2026 06:12:19 +0000 Subject: [PATCH 3/4] fix(rollback): anchor the sha check with fullmatch so a trailing newline is refused (tsk-rw62vx) Folds the CodeRabbit finding on #2782. Python's `$` also matches just before one final newline, so `_SHA_RE.match()` accepted `<40 hex>\n`. Bash's `=~` in sha_safe() does not -- verified, not assumed -- and that asymmetry is the damaging half: the writer would record a value scripts/rollback.sh then refuses, so the install ends up with a rollback target that silently does not work, which is exactly the class of two-ends disagreement this card exists to close. Both call sites now use fullmatch(): the writer's validation and the reader's. The reader's input is already whitespace-stripped by the line parser, so that one is defence in depth rather than a live path, but the two must not diverge on the shared constant. The `^...$` anchors stay so a future `.search()` cannot reopen it from the other side. sha_safe() in scripts/rollback.sh needed no change; the new test_shell_sha_safe_matches_the_writer pins the two ends together over a table that includes the whitespace cases, so the next divergence fails a test instead of shipping. --- ...62vx-rollback-record-parsed-not-sourced.md | 2 +- tests/test_rollback.py | 43 +++++++++++++++++++ tinyagentos/rollback.py | 8 +++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md index 1576ccfb4..5f94dd259 100644 --- a/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md +++ b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md @@ -3,4 +3,4 @@ ### Fixed - A truncated or corrupt `.taos-rollback` no longer loses the recovery route. A half-written record used to abort the script with a bash syntax error, and a record with an empty or malformed `prev_sha` dead-ended on "cannot resolve"; both now fall through to the newest `taos-pre-update-*` recovery tag, which is the whole point of having one. A branch name git would refuse (`feat/..evil`, a trailing `.lock`, a leading `.`) used to abort the rollback outright when both the plain and the `--force` checkout failed; it now costs only the branch, and the recorded commit is still restored. -- `tinyagentos/rollback.py` applies the same object-name and ref-name rules when it reads a record, and refuses to write a target that is not a full object name, so both ends agree on what counts as usable. +- `tinyagentos/rollback.py` applies the same object-name and ref-name rules when it reads a record, and refuses to write a target that is not a full object name, so both ends agree on what counts as usable — including whitespace: Python's `$` also matches before a final newline, so a `<40 hex>\n` value used to be writable while `scripts/rollback.sh` refused it, silently leaving the install with a rollback target that did not work. diff --git a/tests/test_rollback.py b/tests/test_rollback.py index 9a489e6c3..076fd611f 100644 --- a/tests/test_rollback.py +++ b/tests/test_rollback.py @@ -93,6 +93,12 @@ def _shell_ref_safe(name: str) -> bool: return subprocess.run(["bash", "-c", script, "bash", name]).returncode == 0 +def _shell_sha_safe(sha: str) -> bool: + """Ask rollback.sh's own sha_safe() whether it would use this commit.""" + script = _shell_func("sha_safe") + '\nsha_safe "$1"\n' + return subprocess.run(["bash", "-c", script, "bash", sha]).returncode == 0 + + def test_record_then_read_roundtrip(tmp_path): record_pre_update(tmp_path, branch="dev", sha=SHA_A, ts=1700000000) target = read_rollback_target(tmp_path) @@ -128,6 +134,43 @@ def test_quote_injection_is_safe(tmp_path): assert _shell_record_field(tmp_path, "prev_branch") == "a'b" +@pytest.mark.parametrize( + "sha", + [SHA_A + "\n", SHA_A + " ", " " + SHA_A, SHA_A + "\r\n", SHA_A + "\t"], +) +def test_record_rejects_a_sha_with_surrounding_whitespace(tmp_path, sha): + """Whitespace around the object name makes it unusable, so refuse to write it. + + Python's ``$`` matches before one final newline, so a plain ``re.match`` lets + ``<40 hex>\\n`` through -- and bash's ``=~`` does not, which is the worse + half: the writer would happily record a value the shell then refuses, losing + the rollback target rather than reporting anything. + """ + with pytest.raises(ValueError): + record_pre_update(tmp_path, branch="dev", sha=sha, ts=1) + assert not (tmp_path / ROLLBACK_FILE).exists() + + +@pytest.mark.parametrize( + "sha", [SHA_A, "c" * 64, SHA_A + "\n", SHA_A + " ", " " + SHA_A, SHA_A[:39], "dev"] +) +def test_shell_sha_safe_matches_the_writer(tmp_path, sha): + """Both ends must draw the line in the same place, whitespace included. + + Compares the shell's own sha_safe() against what the writer will actually + put in the file: a value the writer accepts but the shell refuses is a + rollback target that silently does not work. + """ + try: + record_pre_update(tmp_path, branch="dev", sha=sha, ts=1) + writable = True + except ValueError: + writable = False + assert _shell_sha_safe(sha) == writable, ( + f"shell sha_safe({sha!r}) disagrees with the writer" + ) + + def test_record_rejects_a_non_sha(tmp_path): """Only a git object name is recordable, so no reader has to guess.""" with pytest.raises(ValueError): diff --git a/tinyagentos/rollback.py b/tinyagentos/rollback.py index a5827ac01..bf8b501ad 100644 --- a/tinyagentos/rollback.py +++ b/tinyagentos/rollback.py @@ -28,6 +28,10 @@ # records ``git rev-parse HEAD``, which is 40 hex (64 in a sha256 checkout) and # never abbreviated. A short value is therefore a truncated or forged record, # not a legitimate prefix. Kept in sync with sha_safe() in scripts/rollback.sh. +# Matched with fullmatch(), never match(): Python's `$` also matches just before +# a final newline, so `<40 hex>\n` would pass here while bash's `=~` in +# sha_safe() rejects it -- the writer would record a value the shell then +# refuses, which loses the rollback target instead of reporting anything. _SHA_RE = re.compile(r"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$") # Characters git bans anywhere in a ref name: ASCII control characters, space, @@ -76,7 +80,7 @@ def record_pre_update(project_dir, *, branch: str, sha: str, ts: int) -> Path: The caller records best-effort, so a rejected write simply leaves the rollback script on its recovery-tag fallback rather than on a bad target. """ - if not _SHA_RE.match(str(sha)): + if not _SHA_RE.fullmatch(str(sha)): raise ValueError(f"rollback sha is not a git object name: {sha!r}") if "\n" in str(branch) or "\r" in str(branch): raise ValueError(f"rollback branch contains a newline: {branch!r}") @@ -115,7 +119,7 @@ def read_rollback_target(project_dir) -> dict | None: if len(val) >= 2 and val[0] == val[-1] == "'": val = val[1:-1].replace("'\\''", "'") out[key.strip()] = val - if not _SHA_RE.match(out.get("prev_sha", "")): + if not _SHA_RE.fullmatch(out.get("prev_sha", "")): return None branch = out.get("prev_branch", "") return { From b1fe4f2fa04fa1ae847f8f068dda0a2dc69b7d77 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 6 Sep 2026 05:44:13 +0000 Subject: [PATCH 4/4] fix(rollback): strip trailing CRLF before parsing a record field scripts/rollback.sh kept a trailing \r on the end of a key='value' line when the record used CRLF line endings, so the shell's quote-stripping never matched and sha_safe rejected an otherwise valid recorded sha -- falling back to the recovery tag while tinyagentos/rollback.py (whose splitlines() already normalizes CRLF) reported the real target. Strip the \r before parsing so both readers agree on the same record. --- ...62vx-rollback-record-parsed-not-sourced.md | 1 + scripts/rollback.sh | 1 + tests/test_rollback.py | 29 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md index 5f94dd259..de9c26fd4 100644 --- a/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md +++ b/changelog.d/tsk-rw62vx-rollback-record-parsed-not-sourced.md @@ -4,3 +4,4 @@ ### Fixed - A truncated or corrupt `.taos-rollback` no longer loses the recovery route. A half-written record used to abort the script with a bash syntax error, and a record with an empty or malformed `prev_sha` dead-ended on "cannot resolve"; both now fall through to the newest `taos-pre-update-*` recovery tag, which is the whole point of having one. A branch name git would refuse (`feat/..evil`, a trailing `.lock`, a leading `.`) used to abort the rollback outright when both the plain and the `--force` checkout failed; it now costs only the branch, and the recorded commit is still restored. - `tinyagentos/rollback.py` applies the same object-name and ref-name rules when it reads a record, and refuses to write a target that is not a full object name, so both ends agree on what counts as usable — including whitespace: Python's `$` also matches before a final newline, so a `<40 hex>\n` value used to be writable while `scripts/rollback.sh` refused it, silently leaving the install with a rollback target that did not work. +- `scripts/rollback.sh` strips a trailing `\r` before parsing a `key='value'` line. A CRLF-formatted record (e.g. written or edited on Windows) left the `\r` glued to the end of the value, so the shell's quote-stripping never matched, `sha_safe` rejected the quoted value, and the script fell back to the recovery tag while `tinyagentos/rollback.py` (whose `splitlines()` already normalizes CRLF) reported the real recorded target — the two readers disagreed about an otherwise valid record. diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 2383c99f5..f17b9b4c7 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -34,6 +34,7 @@ log(){ echo "[rollback] $*"; } record_field(){ local key="$1" line val line="$(grep -m1 -E "^[[:space:]]*${key}=" .taos-rollback 2>/dev/null)" || return 0 + line="${line%$'\r'}" val="${line#*=}" if [[ "$val" == \'*\' ]]; then val="${val:1:${#val}-2}" diff --git a/tests/test_rollback.py b/tests/test_rollback.py index 076fd611f..20972c645 100644 --- a/tests/test_rollback.py +++ b/tests/test_rollback.py @@ -134,6 +134,35 @@ def test_quote_injection_is_safe(tmp_path): assert _shell_record_field(tmp_path, "prev_branch") == "a'b" +def test_crlf_record_is_normalized_by_both_readers(tmp_path): + """A CRLF-formatted record must parse identically in Python and bash. + + Python's own read leaves no trailing `\\r` (`str.strip()` eats it), but + `grep`-then-substring in the shell reader keeps a `\\r` on the end of each + line it reads. Left in place, that `\\r` sits after the closing quote, the + shell's quote-stripping `[[ "$val" == \\'*\\' ]]` no longer matches, and + `record_field` hands back the value with its quotes (and the `\\r`) still + attached -- which `sha_safe` then rejects. The shell falls back to the + recovery tag while Python reports the real recorded target: the two + readers disagree about a record that is otherwise perfectly good. + """ + (tmp_path / ROLLBACK_FILE).write_bytes( + ( + "# taOS rollback target\r\n" + "prev_branch='feat/x'\r\n" + f"prev_sha='{SHA_A}'\r\n" + "prev_ts='42'\r\n" + ).encode() + ) + assert read_rollback_target(tmp_path) == { + "branch": "feat/x", + "sha": SHA_A, + "ts": "42", + } + assert _shell_record_field(tmp_path, "prev_sha") == SHA_A + assert _shell_sha_safe(_shell_record_field(tmp_path, "prev_sha")) + + @pytest.mark.parametrize( "sha", [SHA_A + "\n", SHA_A + " ", " " + SHA_A, SHA_A + "\r\n", SHA_A + "\t"],