Skip to content

Commit 12fc128

Browse files
Crash0v3rrid3claude
andcommitted
fix(scripts): track latest main for self-update + harden mechanism (DEVA11Y-475,477,478)
Addresses PR #30 review. Per maintainer intent, auto-update should always take the latest from main rather than pin to a commit, so: - Revert self-update fetch to main HEAD and restore auto-update on every run (best-effort: script_self_update || true, so offline/integrity failures never block the tool). - Keep SHA-256 verification and commit the 6 .sha256 sidecars so the integrity check actually works against main (regenerate on every script change to main). - Fetch the sidecar first and skip when the on-disk copy already matches (avoids a redundant download/rewrite each run). - Portable hashing: prefer sha256sum, fall back to shasum -a 256; guard empty actual_sum. - Resolve SCRIPT_PATH absolutely so the replace never depends on CWD; stage within the target dir then mv so the replace is atomic on the same filesystem. - Add curl --connect-timeout/--max-time; run the shebang sanity-check after checksum verification; mark the branch/relpath constants readonly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fc2a1ef commit 12fc128

12 files changed

Lines changed: 408 additions & 198 deletions

File tree

scripts/bash/cli.sh

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,39 @@ a11y_scan() {
7878
$BINARY_PATH a11y $EXTRA_ARGS
7979
}
8080

81-
# Pinned, immutable git revision the self-update is allowed to fetch from.
82-
# DEVA11Y-475: never fetch executable code from a mutable branch HEAD.
83-
# Bump this (and the published .sha256 sidecars) on every release.
84-
SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a"
81+
# Self-update tracks the latest launcher on `main` so users always run the
82+
# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather
83+
# than a pinned revision (per maintainer intent: always take the latest).
84+
# Hardening retained from the pinning work: download to a temp dir, verify a
85+
# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature --
86+
# script and checksum share one origin), sanity-check the shebang, then
87+
# atomically replace the on-disk script. Keep scripts/bash/cli.sh.sha256 on main in
88+
# sync with this file (regenerate on every change) or updates will abort.
89+
SELF_UPDATE_BRANCH="main"
90+
readonly SELF_UPDATE_BRANCH
8591
SELF_UPDATE_RELPATH="scripts/bash/cli.sh"
92+
readonly SELF_UPDATE_RELPATH
93+
94+
# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256`
95+
# (macOS / Perl Digest::SHA).
96+
_self_update_sha256() {
97+
if command -v sha256sum >/dev/null 2>&1; then
98+
sha256sum "$1" | awk '{print $1}'
99+
else
100+
shasum -a 256 "$1" | awk '{print $1}'
101+
fi
102+
}
86103

87-
# DEVA11Y-475 / F-003: self-update is OPT-IN (run with `--self-update`),
88-
# fetches from a pinned revision (not a mutable branch), verifies a SHA-256
89-
# checksum before use, and atomically replaces the script instead of
90-
# overwriting the currently-running file in place.
91104
script_self_update() {
92-
local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}"
93-
local tmp_dir tmp_script tmp_sum expected_sum actual_sum
105+
local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}"
106+
local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file
107+
108+
# Resolve the on-disk target absolutely so the replace never depends on CWD.
109+
if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then
110+
target_path="${GIT_ROOT}/${SCRIPT_PATH}"
111+
else
112+
target_path="$SCRIPT_PATH"
113+
fi
94114

95115
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || {
96116
echo "Self-update: failed to create temp dir." >&2
@@ -101,36 +121,50 @@ script_self_update() {
101121
tmp_script="${tmp_dir}/cli.sh"
102122
tmp_sum="${tmp_dir}/cli.sh.sha256"
103123

104-
if ! curl -fsSL "$base_url" -o "$tmp_script"; then
105-
echo "Self-update: failed to download script from pinned revision." >&2
106-
return 1
124+
# Fetch the checksum first; if our on-disk copy already matches, we're current.
125+
if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then
126+
echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2
127+
return 0
107128
fi
108-
if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then
109-
echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2
110-
return 1
129+
# Published sidecar is "<sha256> <filename>"; take the first field.
130+
expected_sum=$(awk '{print $1; exit}' "$tmp_sum")
131+
if [[ -f "$target_path" ]]; then
132+
local_sum=$(_self_update_sha256 "$target_path")
133+
if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then
134+
return 0
135+
fi
111136
fi
112137

113-
if ! head -c2 "$tmp_script" | grep -q '^#!'; then
114-
echo "Self-update: downloaded file is not a script; aborting." >&2
115-
return 1
138+
if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then
139+
echo "Self-update: could not download latest script; skipping update." >&2
140+
return 0
116141
fi
117142

118-
# Published sidecar is "<sha256> <filename>"; take the first field.
119-
expected_sum=$(awk '{print $1; exit}' "$tmp_sum")
120-
actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}')
121-
if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then
143+
actual_sum=$(_self_update_sha256 "$tmp_script")
144+
if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then
122145
echo "Self-update: checksum mismatch; refusing to apply." >&2
123146
echo " expected: ${expected_sum:-<empty>}" >&2
124-
echo " actual: ${actual_sum}" >&2
147+
echo " actual: ${actual_sum:-<empty>}" >&2
148+
return 1
149+
fi
150+
151+
# Sanity check AFTER integrity: ensure the verified payload is a script.
152+
if ! head -c2 "$tmp_script" | grep -q '^#!'; then
153+
echo "Self-update: downloaded file is not a script; aborting." >&2
125154
return 1
126155
fi
127156

128-
chmod 0755 "$tmp_script"
129-
# Atomic replace: never overwrite the running script in place.
130-
if mv -f "$tmp_script" "$SCRIPT_PATH"; then
131-
echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}."
157+
# Stage inside the target's directory so the rename is atomic (mv across
158+
# filesystems would degrade to a non-atomic copy).
159+
stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || {
160+
echo "Self-update: failed to stage update next to ${target_path}." >&2
161+
return 1
162+
}
163+
if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then
164+
echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}."
132165
else
133-
echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2
166+
rm -f -- "$stage_file"
167+
echo "Self-update: failed to replace ${target_path}." >&2
134168
return 1
135169
fi
136170
}
@@ -140,10 +174,10 @@ download_binary() {
140174
bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH"
141175
}
142176

143-
if [[ $SUBCOMMAND == "--self-update" ]]; then
144-
script_self_update
145-
exit $?
146-
fi
177+
# Best-effort auto-update: always fetch the latest launcher from main before
178+
# running. Failures (offline, integrity) are non-fatal -- the current script
179+
# keeps working and any update applies on the next invocation.
180+
script_self_update || true
147181

148182
if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then
149183
register_git_hook

scripts/bash/cli.sh.sha256

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9af5ce77ada28741e91d2323e4664c47e7e7531e10b34168cfe6bc50a74f5d62 cli.sh

scripts/bash/spm.sh

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,39 @@ EOF
8686
scan $EXTRA_ARGS
8787
}
8888

89-
# Pinned, immutable git revision the self-update is allowed to fetch from.
90-
# DEVA11Y-478: never fetch executable code from a mutable branch HEAD.
91-
# Bump this (and the published .sha256 sidecars) on every release.
92-
SELF_UPDATE_REF="db817c37cf74cba47e2fef535f53a35bfc88ec6a"
89+
# Self-update tracks the latest launcher on `main` so users always run the
90+
# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather
91+
# than a pinned revision (per maintainer intent: always take the latest).
92+
# Hardening retained from the pinning work: download to a temp dir, verify a
93+
# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature --
94+
# script and checksum share one origin), sanity-check the shebang, then
95+
# atomically replace the on-disk script. Keep scripts/bash/spm.sh.sha256 on main in
96+
# sync with this file (regenerate on every change) or updates will abort.
97+
SELF_UPDATE_BRANCH="main"
98+
readonly SELF_UPDATE_BRANCH
9399
SELF_UPDATE_RELPATH="scripts/bash/spm.sh"
100+
readonly SELF_UPDATE_RELPATH
101+
102+
# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256`
103+
# (macOS / Perl Digest::SHA).
104+
_self_update_sha256() {
105+
if command -v sha256sum >/dev/null 2>&1; then
106+
sha256sum "$1" | awk '{print $1}'
107+
else
108+
shasum -a 256 "$1" | awk '{print $1}'
109+
fi
110+
}
94111

95-
# DEVA11Y-478 / F-006: self-update is OPT-IN (run with `--self-update`),
96-
# fetches from a pinned revision (not a mutable branch), verifies a SHA-256
97-
# checksum before use, and atomically replaces the script instead of
98-
# overwriting the currently-running file in place.
99112
script_self_update() {
100-
local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/${SELF_UPDATE_REF}/${SELF_UPDATE_RELPATH}"
101-
local tmp_dir tmp_script tmp_sum expected_sum actual_sum
113+
local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}"
114+
local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file
115+
116+
# Resolve the on-disk target absolutely so the replace never depends on CWD.
117+
if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then
118+
target_path="${GIT_ROOT}/${SCRIPT_PATH}"
119+
else
120+
target_path="$SCRIPT_PATH"
121+
fi
102122

103123
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || {
104124
echo "Self-update: failed to create temp dir." >&2
@@ -109,44 +129,58 @@ script_self_update() {
109129
tmp_script="${tmp_dir}/spm.sh"
110130
tmp_sum="${tmp_dir}/spm.sh.sha256"
111131

112-
if ! curl -fsSL "$base_url" -o "$tmp_script"; then
113-
echo "Self-update: failed to download script from pinned revision." >&2
114-
return 1
132+
# Fetch the checksum first; if our on-disk copy already matches, we're current.
133+
if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then
134+
echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2
135+
return 0
115136
fi
116-
if ! curl -fsSL "${base_url}.sha256" -o "$tmp_sum"; then
117-
echo "Self-update: failed to download checksum; aborting (integrity unverifiable)." >&2
118-
return 1
137+
# Published sidecar is "<sha256> <filename>"; take the first field.
138+
expected_sum=$(awk '{print $1; exit}' "$tmp_sum")
139+
if [[ -f "$target_path" ]]; then
140+
local_sum=$(_self_update_sha256 "$target_path")
141+
if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then
142+
return 0
143+
fi
119144
fi
120145

121-
if ! head -c2 "$tmp_script" | grep -q '^#!'; then
122-
echo "Self-update: downloaded file is not a script; aborting." >&2
123-
return 1
146+
if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then
147+
echo "Self-update: could not download latest script; skipping update." >&2
148+
return 0
124149
fi
125150

126-
# Published sidecar is "<sha256> <filename>"; take the first field.
127-
expected_sum=$(awk '{print $1; exit}' "$tmp_sum")
128-
actual_sum=$(shasum -a 256 "$tmp_script" | awk '{print $1}')
129-
if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then
151+
actual_sum=$(_self_update_sha256 "$tmp_script")
152+
if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then
130153
echo "Self-update: checksum mismatch; refusing to apply." >&2
131154
echo " expected: ${expected_sum:-<empty>}" >&2
132-
echo " actual: ${actual_sum}" >&2
155+
echo " actual: ${actual_sum:-<empty>}" >&2
156+
return 1
157+
fi
158+
159+
# Sanity check AFTER integrity: ensure the verified payload is a script.
160+
if ! head -c2 "$tmp_script" | grep -q '^#!'; then
161+
echo "Self-update: downloaded file is not a script; aborting." >&2
133162
return 1
134163
fi
135164

136-
chmod 0755 "$tmp_script"
137-
# Atomic replace: never overwrite the running script in place.
138-
if mv -f "$tmp_script" "$SCRIPT_PATH"; then
139-
echo "Self-update: updated ${SCRIPT_PATH} to pinned revision ${SELF_UPDATE_REF}."
165+
# Stage inside the target's directory so the rename is atomic (mv across
166+
# filesystems would degrade to a non-atomic copy).
167+
stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || {
168+
echo "Self-update: failed to stage update next to ${target_path}." >&2
169+
return 1
170+
}
171+
if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then
172+
echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}."
140173
else
141-
echo "Self-update: failed to replace ${SCRIPT_PATH}." >&2
174+
rm -f -- "$stage_file"
175+
echo "Self-update: failed to replace ${target_path}." >&2
142176
return 1
143177
fi
144178
}
145179

146-
if [[ $SUBCOMMAND == "--self-update" ]]; then
147-
script_self_update
148-
exit $?
149-
fi
180+
# Best-effort auto-update: always fetch the latest launcher from main before
181+
# running. Failures (offline, integrity) are non-fatal -- the current script
182+
# keeps working and any update applies on the next invocation.
183+
script_self_update || true
150184

151185
if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then
152186
register_git_hook

scripts/bash/spm.sh.sha256

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9be47b26350acd877948997dce43c6582da9cb0206c5c2e56db88c415c63579c spm.sh

0 commit comments

Comments
 (0)