diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index 818d993..10b4eb2 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -78,12 +78,94 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/bash/cli.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH +SELF_UPDATE_RELPATH="scripts/bash/cli.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/bash/cli.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/cli.sh" + tmp_sum="${tmp_dir}/cli.sh.sha256" + + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 + fi + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi + fi + + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 + fi + + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." + else + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 + return 1 fi } @@ -92,7 +174,11 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -script_self_update +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/bash/cli.sh.sha256 b/scripts/bash/cli.sh.sha256 new file mode 100644 index 0000000..b213bde --- /dev/null +++ b/scripts/bash/cli.sh.sha256 @@ -0,0 +1 @@ +9af5ce77ada28741e91d2323e4664c47e7e7531e10b34168cfe6bc50a74f5d62 cli.sh diff --git a/scripts/bash/spm.sh b/scripts/bash/spm.sh index 1202e11..21da3b1 100644 --- a/scripts/bash/spm.sh +++ b/scripts/bash/spm.sh @@ -60,7 +60,10 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + // DEVA11Y-477 / F-005: pin to an immutable revision instead of a mutable + // branch HEAD. No release tags exist yet; this is the current origin/main + // SHA. Bump to a release tag (.exact("x.y.z")) once tags are published. + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "db817c37cf74cba47e2fef535f53a35bfc88ec6a") ], targets: [] ) @@ -83,16 +86,102 @@ EOF scan $EXTRA_ARGS } +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/bash/spm.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH +SELF_UPDATE_RELPATH="scripts/bash/spm.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/bash/spm.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/spm.sh" + tmp_sum="${tmp_dir}/spm.sh.sha256" - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 + fi + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi + fi + + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 + fi + + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." + else + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 + return 1 fi } -script_self_update +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/bash/spm.sh.sha256 b/scripts/bash/spm.sh.sha256 new file mode 100644 index 0000000..61897ed --- /dev/null +++ b/scripts/bash/spm.sh.sha256 @@ -0,0 +1 @@ +9be47b26350acd877948997dce43c6582da9cb0206c5c2e56db88c415c63579c spm.sh diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index e509be7..d710922 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -90,12 +90,94 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/fish/cli.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH +SELF_UPDATE_RELPATH="scripts/fish/cli.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/fish/cli.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/cli.sh" + tmp_sum="${tmp_dir}/cli.sh.sha256" + + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 + fi + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi + fi + + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 + fi + + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." + else + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 + return 1 fi } @@ -104,7 +186,11 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -script_self_update +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/fish/cli.sh.sha256 b/scripts/fish/cli.sh.sha256 new file mode 100644 index 0000000..dc810d3 --- /dev/null +++ b/scripts/fish/cli.sh.sha256 @@ -0,0 +1 @@ +637518c077cf013e1d420d49334a5bd792b674b9b9fd04469f2198875afd7ea1 cli.sh diff --git a/scripts/fish/spm.sh b/scripts/fish/spm.sh index 9ac8a67..30e749b 100644 --- a/scripts/fish/spm.sh +++ b/scripts/fish/spm.sh @@ -73,7 +73,10 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + // DEVA11Y-477 / F-005: pin to an immutable revision instead of a mutable + // branch HEAD. No release tags exist yet; this is the current origin/main + // SHA. Bump to a release tag (.exact("x.y.z")) once tags are published. + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "db817c37cf74cba47e2fef535f53a35bfc88ec6a") ], targets: [] ) @@ -96,16 +99,102 @@ EOF scan $EXTRA_ARGS } +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/fish/spm.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH +SELF_UPDATE_RELPATH="scripts/fish/spm.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/fish/spm.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/spm.sh" + tmp_sum="${tmp_dir}/spm.sh.sha256" - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 + fi + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi + fi + + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 + fi + + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." + else + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 + return 1 fi } -script_self_update +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/fish/spm.sh.sha256 b/scripts/fish/spm.sh.sha256 new file mode 100644 index 0000000..224af1f --- /dev/null +++ b/scripts/fish/spm.sh.sha256 @@ -0,0 +1 @@ +813d10eb97ebef74e28da3037cf87d631f771c8eb9af452c5d78434bfda586aa spm.sh diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index a7e6e4c..5aaa171 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -89,12 +89,94 @@ a11y_scan() { $BINARY_PATH a11y $EXTRA_ARGS } +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/zsh/cli.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH +SELF_UPDATE_RELPATH="scripts/zsh/cli.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/zsh/cli.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/cli.sh" + tmp_sum="${tmp_dir}/cli.sh.sha256" + + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 + fi + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi + fi + + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 + fi + + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." + else + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 + return 1 fi } @@ -103,7 +185,11 @@ download_binary() { bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" } -script_self_update +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/zsh/cli.sh.sha256 b/scripts/zsh/cli.sh.sha256 new file mode 100644 index 0000000..9cb4653 --- /dev/null +++ b/scripts/zsh/cli.sh.sha256 @@ -0,0 +1 @@ +b40064af6839493f5677b1935532b498933575cd8a92f008e495b0611c52b27d cli.sh diff --git a/scripts/zsh/spm.sh b/scripts/zsh/spm.sh index 35df10f..e59dc27 100644 --- a/scripts/zsh/spm.sh +++ b/scripts/zsh/spm.sh @@ -72,7 +72,10 @@ import PackageDescription let package = Package( name: "Dummy", dependencies: [ - .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", branch: "main") + // DEVA11Y-477 / F-005: pin to an immutable revision instead of a mutable + // branch HEAD. No release tags exist yet; this is the current origin/main + // SHA. Bump to a release tag (.exact("x.y.z")) once tags are published. + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", revision: "db817c37cf74cba47e2fef535f53a35bfc88ec6a") ], targets: [] ) @@ -95,16 +98,102 @@ EOF scan $EXTRA_ARGS } +# Self-update tracks the latest launcher on `main` so users always run the +# newest version. DEVA11Y-475/477/478: we deliberately follow main HEAD rather +# than a pinned revision (per maintainer intent: always take the latest). +# Hardening retained from the pinning work: download to a temp dir, verify a +# SHA-256 sidecar (a download-integrity check, NOT an authenticity signature -- +# script and checksum share one origin), sanity-check the shebang, then +# atomically replace the on-disk script. Keep scripts/zsh/spm.sh.sha256 on main in +# sync with this file (regenerate on every change) or updates will abort. +SELF_UPDATE_BRANCH="main" +readonly SELF_UPDATE_BRANCH +SELF_UPDATE_RELPATH="scripts/zsh/spm.sh" +readonly SELF_UPDATE_RELPATH + +# sha256 with a portable fallback: GNU `sha256sum` (Linux) or `shasum -a 256` +# (macOS / Perl Digest::SHA). +_self_update_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + script_self_update() { - local remote_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/main/scripts/zsh/spm.sh" + local base_url="https://raw.githubusercontent.com/browserstack/AccessibilityDevTools/refs/heads/${SELF_UPDATE_BRANCH}/${SELF_UPDATE_RELPATH}" + local tmp_dir tmp_script tmp_sum expected_sum actual_sum local_sum target_path stage_file + + # Resolve the on-disk target absolutely so the replace never depends on CWD. + if [[ -n "$GIT_ROOT" && "$SCRIPT_PATH" != /* ]]; then + target_path="${GIT_ROOT}/${SCRIPT_PATH}" + else + target_path="$SCRIPT_PATH" + fi + + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/bs-a11y-selfupdate.XXXXXX") || { + echo "Self-update: failed to create temp dir." >&2 + return 1 + } + # shellcheck disable=SC2064 + trap "rm -rf -- '${tmp_dir}'" RETURN + tmp_script="${tmp_dir}/spm.sh" + tmp_sum="${tmp_dir}/spm.sh.sha256" - updated_script=$(curl -R -z "$SCRIPT_PATH" "$remote_url") - if [[ $updated_script =~ ^#! ]]; then - echo "$updated_script" > "$SCRIPT_PATH" + # Fetch the checksum first; if our on-disk copy already matches, we're current. + if ! curl -fsSL --connect-timeout 10 --max-time 30 "${base_url}.sha256" -o "$tmp_sum"; then + echo "Self-update: could not fetch checksum from ${SELF_UPDATE_BRANCH}; skipping update." >&2 + return 0 + fi + # Published sidecar is " "; take the first field. + expected_sum=$(awk '{print $1; exit}' "$tmp_sum") + if [[ -f "$target_path" ]]; then + local_sum=$(_self_update_sha256 "$target_path") + if [[ -n "$expected_sum" && "$local_sum" == "$expected_sum" ]]; then + return 0 + fi + fi + + if ! curl -fsSL --connect-timeout 10 --max-time 30 "$base_url" -o "$tmp_script"; then + echo "Self-update: could not download latest script; skipping update." >&2 + return 0 + fi + + actual_sum=$(_self_update_sha256 "$tmp_script") + if [[ -z "$expected_sum" || -z "$actual_sum" || "$expected_sum" != "$actual_sum" ]]; then + echo "Self-update: checksum mismatch; refusing to apply." >&2 + echo " expected: ${expected_sum:-}" >&2 + echo " actual: ${actual_sum:-}" >&2 + return 1 + fi + + # Sanity check AFTER integrity: ensure the verified payload is a script. + if ! head -c2 "$tmp_script" | grep -q '^#!'; then + echo "Self-update: downloaded file is not a script; aborting." >&2 + return 1 + fi + + # Stage inside the target's directory so the rename is atomic (mv across + # filesystems would degrade to a non-atomic copy). + stage_file=$(mktemp "$(dirname "$target_path")/.bs-a11y-update.XXXXXX") || { + echo "Self-update: failed to stage update next to ${target_path}." >&2 + return 1 + } + if cp "$tmp_script" "$stage_file" && chmod 0755 "$stage_file" && mv -f "$stage_file" "$target_path"; then + echo "Self-update: updated ${target_path} to latest ${SELF_UPDATE_BRANCH}." + else + rm -f -- "$stage_file" + echo "Self-update: failed to replace ${target_path}." >&2 + return 1 fi } -script_self_update +# Best-effort auto-update: always fetch the latest launcher from main before +# running. Failures (offline, integrity) are non-fatal -- the current script +# keeps working and any update applies on the next invocation. +script_self_update || true + if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then register_git_hook exit 0 diff --git a/scripts/zsh/spm.sh.sha256 b/scripts/zsh/spm.sh.sha256 new file mode 100644 index 0000000..8eeee6c --- /dev/null +++ b/scripts/zsh/spm.sh.sha256 @@ -0,0 +1 @@ +0ec0dc70730a10f0933ab680fe359ae163f46770cdcba72e0d5a4f727f2a67bd spm.sh