From feae1102401fff7c03315df09c00d91b8f658305 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 12:38:28 -0400 Subject: [PATCH 01/21] docs: document binary and source installation --- README.md | 8 ++- docs/installation.md | 162 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 docs/installation.md diff --git a/README.md b/README.md index 32bf706..6e9b5d8 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,19 @@ User policy is defined in `config.toml`; see [`docs/configuration.md`](docs/configuration.md) for path resolution, the strict schema, and safe fallback behavior. +## Installation + +See [`docs/installation.md`](docs/installation.md) for supported release +archives, checksum verification, PATH setup, and source build instructions. +Package-manager distribution is not currently available. + ## Development Prerequisites: - Rust 1.85 or newer. - `git` available on `PATH`. -- `gh` is optional for future GitHub workflows. +- `gh` is optional and required only for GitHub-specific workflows. Run local checks: diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..5dc5e40 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,162 @@ +# Installation + +`bitbygit` can be installed from a GitHub Release archive or built from source. +No tagged release has been published yet. Use the archive instructions once the +version you want appears on the +[Releases page](https://github.com/cosentinode/bitbygit/releases). + +## Runtime prerequisites + +- `git` must be installed and available on `PATH`. +- [GitHub CLI (`gh`)](https://cli.github.com/) is optional. It is required only + for GitHub-specific operations such as opening a pull request; run + `gh auth login` before using those operations. +- Rust is not required when using a release archive. Building from source + requires Rust 1.85 or newer and Cargo. + +## Supported release targets + +| Platform | Target | Archive | +| --- | --- | --- | +| Linux x86-64 | `x86_64-unknown-linux-gnu` | `bitbygit--x86_64-unknown-linux-gnu.tar.gz` | +| macOS Intel | `x86_64-apple-darwin` | `bitbygit--x86_64-apple-darwin.tar.gz` | +| macOS Apple silicon | `aarch64-apple-darwin` | `bitbygit--aarch64-apple-darwin.tar.gz` | +| Windows x86-64 | `x86_64-pc-windows-msvc` | `bitbygit--x86_64-pc-windows-msvc.zip` | + +The Linux artifact uses GNU libc and is checked not to require a GLIBC symbol +newer than 2.35. Other architectures and operating systems must currently use +a source build. + +Each tagged release also includes `SHA256SUMS`. Verify the downloaded archive +before extracting or running it. The examples below use `0.1.0`; set `VERSION` +or `$Version` to an available release version without the leading `v`. + +## Linux x86-64 archive + +The commands require `curl`, `sha256sum`, and `tar`: + +```sh +VERSION=0.1.0 +TARGET=x86_64-unknown-linux-gnu +ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" +PACKAGE="bitbygit-${VERSION}-${TARGET}" +BASE_URL="https://github.com/cosentinode/bitbygit/releases/download/v${VERSION}" + +curl -fLO "${BASE_URL}/${ARCHIVE}" +curl -fLO "${BASE_URL}/SHA256SUMS" +grep -F " ${ARCHIVE}" SHA256SUMS | sha256sum --check - +tar -xzf "${ARCHIVE}" + +mkdir -p "${HOME}/.local/bin" +install -m 0755 "${PACKAGE}/bitbygit" "${HOME}/.local/bin/bitbygit" +export PATH="${HOME}/.local/bin:${PATH}" +``` + +Add `export PATH="$HOME/.local/bin:$PATH"` to your shell startup file to keep +the command available in new shells. + +## macOS archive + +This selects the Intel or Apple silicon artifact automatically. The commands +require `curl`, `shasum`, and `tar`, which are included with macOS: + +```sh +VERSION=0.1.0 +case "$(uname -m)" in + x86_64) TARGET=x86_64-apple-darwin ;; + arm64) TARGET=aarch64-apple-darwin ;; + *) echo "Unsupported macOS architecture: $(uname -m)" >&2; exit 1 ;; +esac +ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" +PACKAGE="bitbygit-${VERSION}-${TARGET}" +BASE_URL="https://github.com/cosentinode/bitbygit/releases/download/v${VERSION}" + +curl -fLO "${BASE_URL}/${ARCHIVE}" +curl -fLO "${BASE_URL}/SHA256SUMS" +grep -F " ${ARCHIVE}" SHA256SUMS | shasum -a 256 --check - +tar -xzf "${ARCHIVE}" + +mkdir -p "${HOME}/.local/bin" +install -m 0755 "${PACKAGE}/bitbygit" "${HOME}/.local/bin/bitbygit" +export PATH="${HOME}/.local/bin:${PATH}" +``` + +Add `export PATH="$HOME/.local/bin:$PATH"` to `~/.zprofile` (or the startup file +for your shell) to keep the command available in new shells. + +## Windows x86-64 archive + +Run these commands in PowerShell: + +```powershell +$Version = "0.1.0" +$Target = "x86_64-pc-windows-msvc" +$Archive = "bitbygit-$Version-$Target.zip" +$Package = "bitbygit-$Version-$Target" +$BaseUrl = "https://github.com/cosentinode/bitbygit/releases/download/v$Version" + +Invoke-WebRequest -Uri "$BaseUrl/$Archive" -OutFile $Archive +Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS" -OutFile SHA256SUMS +$Expected = ((Get-Content SHA256SUMS | Where-Object { $_.EndsWith(" $Archive") }) -split '\s+')[0] +$Actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash +if ($Actual -ne $Expected) { throw "Checksum verification failed for $Archive" } +Expand-Archive -Path $Archive -DestinationPath . + +$InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +Copy-Item "$Package\bitbygit.exe" $InstallDir +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if (($UserPath -split ";") -notcontains $InstallDir) { + [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") +} +$env:Path = "$InstallDir;$env:Path" +``` + +New shells will use the updated user `PATH`. + +## Build from source + +Install [Rust](https://www.rust-lang.org/tools/install) 1.85 or newer, Cargo, +and `git`, then build the locked workspace package: + +```sh +git clone https://github.com/cosentinode/bitbygit.git +cd bitbygit +cargo build --locked --release -p bitbygit +``` + +On Linux or macOS, install the resulting binary in the same user directory used +above: + +```sh +mkdir -p "${HOME}/.local/bin" +install -m 0755 target/release/bitbygit "${HOME}/.local/bin/bitbygit" +export PATH="${HOME}/.local/bin:${PATH}" +``` + +On Windows, run this in PowerShell from the repository: + +```powershell +$InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +Copy-Item "target\release\bitbygit.exe" $InstallDir +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if (($UserPath -split ";") -notcontains $InstallDir) { + [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") +} +$env:Path = "$InstallDir;$env:Path" +``` + +## Package managers + +Package-manager distribution is deferred. `bitbygit` is not currently +published to crates.io, Homebrew, WinGet, Scoop, or Linux package repositories. +Use a release archive when available or build from source. + +## Verify the installation + +The final check for every installation method is: + +```sh +bitbygit --version +``` From 43b057f2140b5206979334208b978e8bdbc37912 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 13:00:01 -0400 Subject: [PATCH 02/21] fix: validate installation instructions --- .github/workflows/ci.yml | 4 + docs/installation.md | 121 +++++++++++++++----- scripts/test-installation-docs.sh | 184 ++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 29 deletions(-) create mode 100755 scripts/test-installation-docs.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac0463a..baf59f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,10 @@ jobs: "${tool_dir}/actionlint" bash scripts/test-release-workflow.sh + - name: Validate installation documentation + shell: bash + run: bash scripts/test-installation-docs.sh + - name: Format run: cargo fmt --all --check diff --git a/docs/installation.md b/docs/installation.md index 5dc5e40..290ccd3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,8 +1,8 @@ # Installation -`bitbygit` can be installed from a GitHub Release archive or built from source. -No tagged release has been published yet. Use the archive instructions once the -version you want appears on the +`bitbygit` can be installed from a GitHub Release archive or built from a +published source tag. No tagged release has been published yet. Use these +instructions once the version you want appears on the [Releases page](https://github.com/cosentinode/bitbygit/releases). ## Runtime prerequisites @@ -11,8 +11,8 @@ version you want appears on the - [GitHub CLI (`gh`)](https://cli.github.com/) is optional. It is required only for GitHub-specific operations such as opening a pull request; run `gh auth login` before using those operations. -- Rust is not required when using a release archive. Building from source - requires Rust 1.85 or newer and Cargo. +- Rust and a native build toolchain are not required when using a release + archive. See [Build from source](#build-from-source) for source prerequisites. ## Supported release targets @@ -24,8 +24,8 @@ version you want appears on the | Windows x86-64 | `x86_64-pc-windows-msvc` | `bitbygit--x86_64-pc-windows-msvc.zip` | The Linux artifact uses GNU libc and is checked not to require a GLIBC symbol -newer than 2.35. Other architectures and operating systems must currently use -a source build. +newer than 2.35. The release workflow does not produce artifacts for other +architectures or operating systems. Each tagged release also includes `SHA256SUMS`. Verify the downloaded archive before extracting or running it. The examples below use `0.1.0`; set `VERSION` @@ -33,9 +33,12 @@ or `$Version` to an available release version without the leading `v`. ## Linux x86-64 archive -The commands require `curl`, `sha256sum`, and `tar`: +Run these commands in Bash. They require `curl`, `sha256sum`, and `tar` and stop +before extraction or installation if any download or checksum check fails: + +```bash +set -euo pipefail -```sh VERSION=0.1.0 TARGET=x86_64-unknown-linux-gnu ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" @@ -49,6 +52,11 @@ tar -xzf "${ARCHIVE}" mkdir -p "${HOME}/.local/bin" install -m 0755 "${PACKAGE}/bitbygit" "${HOME}/.local/bin/bitbygit" +output="$("${HOME}/.local/bin/bitbygit" --version)" +if [[ "${output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${output}" >&2 + exit 1 +fi export PATH="${HOME}/.local/bin:${PATH}" ``` @@ -57,10 +65,14 @@ the command available in new shells. ## macOS archive -This selects the Intel or Apple silicon artifact automatically. The commands -require `curl`, `shasum`, and `tar`, which are included with macOS: +Run these commands in Bash. They select the Intel or Apple silicon artifact +automatically and require `curl`, `shasum`, and `tar`, which are included with +macOS. The shell stops before extraction or installation if any download or +checksum check fails: + +```bash +set -euo pipefail -```sh VERSION=0.1.0 case "$(uname -m)" in x86_64) TARGET=x86_64-apple-darwin ;; @@ -78,6 +90,11 @@ tar -xzf "${ARCHIVE}" mkdir -p "${HOME}/.local/bin" install -m 0755 "${PACKAGE}/bitbygit" "${HOME}/.local/bin/bitbygit" +output="$("${HOME}/.local/bin/bitbygit" --version)" +if [[ "${output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${output}" >&2 + exit 1 +fi export PATH="${HOME}/.local/bin:${PATH}" ``` @@ -89,6 +106,8 @@ for your shell) to keep the command available in new shells. Run these commands in PowerShell: ```powershell +$ErrorActionPreference = "Stop" + $Version = "0.1.0" $Target = "x86_64-pc-windows-msvc" $Archive = "bitbygit-$Version-$Target.zip" @@ -97,7 +116,9 @@ $BaseUrl = "https://github.com/cosentinode/bitbygit/releases/download/v$Version" Invoke-WebRequest -Uri "$BaseUrl/$Archive" -OutFile $Archive Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS" -OutFile SHA256SUMS -$Expected = ((Get-Content SHA256SUMS | Where-Object { $_.EndsWith(" $Archive") }) -split '\s+')[0] +$ExpectedLine = Get-Content SHA256SUMS | Where-Object { $_.EndsWith(" $Archive") } +if (-not $ExpectedLine) { throw "No checksum found for $Archive" } +$Expected = ($ExpectedLine -split '\s+')[0] $Actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash if ($Actual -ne $Expected) { throw "Checksum verification failed for $Archive" } Expand-Archive -Path $Archive -DestinationPath . @@ -105,6 +126,11 @@ Expand-Archive -Path $Archive -DestinationPath . $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null Copy-Item "$Package\bitbygit.exe" $InstallDir +$InstalledBinary = Join-Path $InstallDir "bitbygit.exe" +$Output = & $InstalledBinary --version +if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { + throw "Expected bitbygit $Version, got $Output" +} $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") if (($UserPath -split ";") -notcontains $InstallDir) { [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") @@ -116,30 +142,69 @@ New shells will use the updated user `PATH`. ## Build from source -Install [Rust](https://www.rust-lang.org/tools/install) 1.85 or newer, Cargo, -and `git`, then build the locked workspace package: +Source builds require `git`, [Rust](https://www.rust-lang.org/tools/install) 1.85 +or newer, Cargo, and the native tools used by the selected Rust target: + +- Linux GNU targets require a C compiler, linker, and libc development headers, + commonly installed through the distribution's `build-essential` or + equivalent package. +- macOS requires the Xcode Command Line Tools (`xcode-select --install`). +- `x86_64-pc-windows-msvc` requires Visual Studio 2022 Build Tools with the + **Desktop development with C++** workload, including MSVC and a Windows SDK. -```sh -git clone https://github.com/cosentinode/bitbygit.git +The following commands build the selected published release tag, not the moving +development branch. Because no tag has been published yet, `v0.1.0` will become +usable only if that release appears on the Releases page. + +On Linux or macOS, run: + +```bash +set -euo pipefail + +VERSION=0.1.0 +git clone --branch "v${VERSION}" --depth 1 https://github.com/cosentinode/bitbygit.git cd bitbygit cargo build --locked --release -p bitbygit -``` +built_output="$(target/release/bitbygit --version)" +if [[ "${built_output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${built_output}" >&2 + exit 1 +fi -On Linux or macOS, install the resulting binary in the same user directory used -above: - -```sh mkdir -p "${HOME}/.local/bin" install -m 0755 target/release/bitbygit "${HOME}/.local/bin/bitbygit" +installed_output="$("${HOME}/.local/bin/bitbygit" --version)" +if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then + printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${installed_output}" >&2 + exit 1 +fi export PATH="${HOME}/.local/bin:${PATH}" ``` -On Windows, run this in PowerShell from the repository: +On Windows, run in PowerShell: ```powershell +$ErrorActionPreference = "Stop" + +$Version = "0.1.0" +git clone --branch "v$Version" --depth 1 https://github.com/cosentinode/bitbygit.git +if ($LASTEXITCODE -ne 0) { throw "git clone failed" } +Set-Location bitbygit +cargo build --locked --release -p bitbygit +if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } +$BuiltOutput = & "target\release\bitbygit.exe" --version +if ($LASTEXITCODE -ne 0 -or $BuiltOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version, got $BuiltOutput" +} + $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null Copy-Item "target\release\bitbygit.exe" $InstallDir +$InstalledBinary = Join-Path $InstallDir "bitbygit.exe" +$InstalledOutput = & $InstalledBinary --version +if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version, got $InstalledOutput" +} $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") if (($UserPath -split ";") -notcontains $InstallDir) { [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") @@ -151,12 +216,10 @@ $env:Path = "$InstallDir;$env:Path" Package-manager distribution is deferred. `bitbygit` is not currently published to crates.io, Homebrew, WinGet, Scoop, or Linux package repositories. -Use a release archive when available or build from source. +Use an archive or source tag after a release is published. ## Verify the installation -The final check for every installation method is: - -```sh -bitbygit --version -``` +Every installation block above finishes by running the installed file directly +with `--version` and requiring `bitbygit `. This avoids +mistaking an older `bitbygit` elsewhere on `PATH` for the binary just installed. diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh new file mode 100755 index 0000000..116342e --- /dev/null +++ b/scripts/test-installation-docs.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +docs="${root}/docs/installation.md" +workflow="${root}/.github/workflows/release.yml" +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +fail() { + echo "installation docs validation failed: $*" >&2 + exit 1 +} + +extract_block() { + local heading="$1" + local language="$2" + HEADING="${heading}" LANGUAGE="${language}" perl -0777 -ne ' + $heading = quotemeta($ENV{HEADING}); + $language = quotemeta($ENV{LANGUAGE}); + if (/^## $heading\n.*?^```$language\n(.*?)^```/ms) { + print $1; + } + ' "${docs}" +} + +docs_text="$(<"${docs}")" +workflow_text="$(<"${workflow}")" +workspace_version="$(perl -ne ' + $workspace = 1 if /^\[workspace\.package\]$/; + if ($workspace && /^version = "([^"]+)"$/) { print $1; exit } +' "${root}/Cargo.toml")" +[[ -n "${workspace_version}" ]] || fail "workspace version was not found" +[[ "${docs_text}" == *"VERSION=${workspace_version}"* ]] || fail "Bash examples do not use workspace version ${workspace_version}" +[[ "${docs_text}" == *"\$Version = \"${workspace_version}\""* ]] || fail "PowerShell examples do not use workspace version ${workspace_version}" + +mapfile -t release_artifacts < <(perl -0777 -ne ' + while (/^\s+- runner:.*?^\s+target:\s+(\S+)\s*\n^\s+archive_extension:\s+(\S+)/msg) { + print "bitbygit--$1.$2\n"; + } +' "${workflow}") +[[ "${#release_artifacts[@]}" -eq 4 ]] || fail "expected four release artifacts" +for artifact in "${release_artifacts[@]}"; do + [[ "${docs_text}" == *"${artifact}"* ]] || fail "missing release artifact ${artifact}" +done +[[ "${workflow_text}" == *'package="bitbygit-${version}-${{ matrix.target }}"'* ]] || fail "release package directory contract changed" +[[ "${docs_text}" == *'PACKAGE="bitbygit-${VERSION}-${TARGET}"'* ]] || fail "Bash package directory does not match release layout" +[[ "${docs_text}" == *'$Package = "bitbygit-$Version-$Target"'* ]] || fail "PowerShell package directory does not match release layout" +[[ "${docs_text}" == *'"${PACKAGE}/bitbygit"'* ]] || fail "Unix binary path does not match release layout" +[[ "${docs_text}" == *'"$Package\bitbygit.exe"'* ]] || fail "Windows binary path does not match release layout" + +perl -0777 -ne 'while (/^```bash\n(.*?)^```/msg) { print "$1\n" }' "${docs}" > "${tmp}/snippets.bash" +bash -n "${tmp}/snippets.bash" + +perl -0777 -ne 'while (/^```powershell\n(.*?)^```/msg) { print "$1\n" }' "${docs}" > "${tmp}/snippets.ps1" +SNIPPETS_PS1="${tmp}/snippets.ps1" pwsh -NoProfile -NonInteractive -Command ' + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($env:SNIPPETS_PS1, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 } +' + +mkdir -p "${tmp}/mock-bin" +cat > "${tmp}/mock-bin/curl" <<'MOCK' +#!/usr/bin/env bash +output="${*: -1}" +output="${output##*/}" +if [[ "${output}" == "SHA256SUMS" ]]; then + printf '%064d %s\n' 0 "${MOCK_ARCHIVE}" > "${output}" +else + : > "${output}" +fi +MOCK +cat > "${tmp}/mock-bin/sha256sum" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK +cat > "${tmp}/mock-bin/shasum" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK +for command in tar install; do + cat > "${tmp}/mock-bin/${command}" <<'MOCK' +#!/usr/bin/env bash +: > "${MOCK_SIDE_EFFECTS}" +MOCK +done +chmod +x "${tmp}/mock-bin/"* + +check_unix_checksum_gate() { + local heading="$1" + local target="$2" + local extension="$3" + local case_dir="${tmp}/${target}" + mkdir -p "${case_dir}/home" + extract_block "${heading}" bash > "${case_dir}/snippet.bash" + [[ -s "${case_dir}/snippet.bash" ]] || fail "missing ${heading} Bash block" + + if ( + cd "${case_dir}" + PATH="${tmp}/mock-bin:${PATH}" \ + HOME="${case_dir}/home" \ + MOCK_ARCHIVE="bitbygit-${workspace_version}-${target}.${extension}" \ + MOCK_SIDE_EFFECTS="${case_dir}/side-effects" \ + bash "${case_dir}/snippet.bash" + ); then + fail "${heading} continued after checksum failure" + fi + [[ ! -e "${case_dir}/side-effects" ]] || fail "${heading} extracted or installed after checksum failure" +} + +check_unix_checksum_gate "Linux x86-64 archive" x86_64-unknown-linux-gnu tar.gz +check_unix_checksum_gate "macOS archive" x86_64-apple-darwin tar.gz + +extract_block "Windows x86-64 archive" powershell > "${tmp}/windows-install.ps1" +[[ -s "${tmp}/windows-install.ps1" ]] || fail "missing Windows PowerShell block" +mkdir -p "${tmp}/windows-case" +if MOCK_ARCHIVE="bitbygit-${workspace_version}-x86_64-pc-windows-msvc.zip" \ + MOCK_CHECKSUM_REACHED="${tmp}/windows-checksum-reached" \ + MOCK_SIDE_EFFECTS="${tmp}/windows-side-effects" \ + WINDOWS_CASE_DIR="${tmp}/windows-case" \ + WINDOWS_INSTALL_PS1="${tmp}/windows-install.ps1" \ + pwsh -NoProfile -NonInteractive -Command ' + Set-Location $env:WINDOWS_CASE_DIR + function Invoke-WebRequest { param($Uri, $OutFile) Set-Content -LiteralPath $OutFile -Value "mock" } + function Get-Content { "0000000000000000000000000000000000000000000000000000000000000000 $env:MOCK_ARCHIVE" } + function Get-FileHash { + Set-Content -LiteralPath $env:MOCK_CHECKSUM_REACHED -Value "checked" + [pscustomobject]@{ Hash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } + } + function Expand-Archive { Set-Content -LiteralPath $env:MOCK_SIDE_EFFECTS -Value "expanded"; throw "continued" } + & $env:WINDOWS_INSTALL_PS1 +' >/dev/null 2>&1; then + fail "Windows archive block continued after checksum failure" +fi +[[ -e "${tmp}/windows-checksum-reached" ]] || fail "Windows archive block did not reach checksum verification" +[[ ! -e "${tmp}/windows-side-effects" ]] || fail "Windows archive extracted after checksum failure" + +[[ "${docs_text}" == *'git clone --branch "v${VERSION}" --depth 1'* ]] || fail "Bash source build is not pinned to the selected tag" +[[ "${docs_text}" == *'git clone --branch "v$Version" --depth 1'* ]] || fail "PowerShell source build is not pinned to the selected tag" +[[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" +[[ "${docs_text}" == *'& $InstalledBinary --version'* ]] || fail "Windows installation does not validate the installed path" + +check_links() { + local markdown="$1" + local base + base="$(dirname "${markdown}")" + while IFS= read -r destination; do + case "${destination}" in + https://*) curl -fsSL --retry 3 --output /dev/null "${destination}" || fail "unreachable link ${destination}" ;; + http://*) fail "insecure link ${destination}" ;; + *) + local fragment="" + local target="${destination%%#*}" + if [[ "${destination}" == *#* ]]; then + fragment="${destination#*#}" + fi + if [[ -z "${target}" ]]; then + target="${markdown}" + else + target="${base}/${target}" + fi + [[ -e "${target}" ]] || fail "missing local link ${destination} in ${markdown#"${root}/"}" + if [[ -n "${fragment}" ]]; then + ANCHOR="${fragment}" perl -ne ' + next unless /^#+\s+(.+)/; + $heading = lc $1; + $heading =~ s/[`*_]//g; + $heading =~ s/[^a-z0-9 -]//g; + $heading =~ s/\s+/-/g; + if ($heading eq $ENV{ANCHOR}) { $found = 1; last } + END { exit($found ? 0 : 1) } + ' "${target}" || fail "missing anchor #${fragment} in ${target#"${root}/"}" + fi + ;; + esac + done < <(perl -ne 'while (/\[[^]]+\]\(([^ )]+)(?:\s+"[^"]*")?\)/g) { print "$1\n" }' "${markdown}") +} + +check_links "${root}/README.md" +check_links "${docs}" + +echo "installation docs validation passed" From 19860ca7ec70a02d6bb6f9b8fb46ebf6f0112f35 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 13:28:51 -0400 Subject: [PATCH 03/21] fix: exercise platform installation docs --- .github/workflows/ci.yml | 29 ++++- README.md | 13 ++- docs/installation.md | 24 ++-- scripts/setup-dev.sh | 2 + scripts/test-installation-docs.ps1 | 144 +++++++++++++++++++++++ scripts/test-installation-docs.sh | 177 ++++++++++++++++++----------- 6 files changed, 308 insertions(+), 81 deletions(-) create mode 100644 scripts/test-installation-docs.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baf59f8..f2e76aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: "${tool_dir}/actionlint" bash scripts/test-release-workflow.sh - - name: Validate installation documentation + - name: Validate Linux installation documentation shell: bash run: bash scripts/test-installation-docs.sh @@ -53,3 +53,30 @@ jobs: - name: Build run: cargo build --locked --workspace + + installation-docs-macos: + name: macOS installation docs + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Validate installation documentation + shell: bash + run: bash scripts/test-installation-docs.sh + + installation-docs-windows: + name: Windows installation docs + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Validate installation documentation + shell: pwsh + run: ./scripts/test-installation-docs.ps1 diff --git a/README.md b/README.md index 6e9b5d8..bc9ed04 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,19 @@ Prerequisites: Run local checks: ```sh +bash scripts/test-release-workflow.sh +bash scripts/test-installation-docs.sh cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -cargo build --workspace +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace +cargo build --locked --workspace ``` +The Bash installation validator runs on Linux and macOS without PowerShell. +Windows contributors can run the platform-specific archive validation with +`pwsh -NoProfile -File scripts/test-installation-docs.ps1`; CI runs that command +on a Windows runner. + Or run: ```sh diff --git a/docs/installation.md b/docs/installation.md index 290ccd3..62792d6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,12 +33,12 @@ or `$Version` to an available release version without the leading `v`. ## Linux x86-64 archive -Run these commands in Bash. They require `curl`, `sha256sum`, and `tar` and stop -before extraction or installation if any download or checksum check fails: +Run these commands in Bash. They require `curl`, `tar`, `grep`, and GNU +coreutils (`sha256sum`, `mkdir`, and `install`) and stop before extraction or +installation if any download or checksum check fails: ```bash -set -euo pipefail - +bash -euo pipefail <<'BITBYGIT_INSTALL' && VERSION=0.1.0 TARGET=x86_64-unknown-linux-gnu ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" @@ -57,6 +57,7 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${output}" >&2 exit 1 fi +BITBYGIT_INSTALL export PATH="${HOME}/.local/bin:${PATH}" ``` @@ -71,8 +72,7 @@ macOS. The shell stops before extraction or installation if any download or checksum check fails: ```bash -set -euo pipefail - +bash -euo pipefail <<'BITBYGIT_INSTALL' && VERSION=0.1.0 case "$(uname -m)" in x86_64) TARGET=x86_64-apple-darwin ;; @@ -95,6 +95,7 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${output}" >&2 exit 1 fi +BITBYGIT_INSTALL export PATH="${HOME}/.local/bin:${PATH}" ``` @@ -106,6 +107,7 @@ for your shell) to keep the command available in new shells. Run these commands in PowerShell: ```powershell +& { $ErrorActionPreference = "Stop" $Version = "0.1.0" @@ -136,6 +138,7 @@ if (($UserPath -split ";") -notcontains $InstallDir) { [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") } $env:Path = "$InstallDir;$env:Path" +} ``` New shells will use the updated user `PATH`. @@ -147,7 +150,8 @@ or newer, Cargo, and the native tools used by the selected Rust target: - Linux GNU targets require a C compiler, linker, and libc development headers, commonly installed through the distribution's `build-essential` or - equivalent package. + equivalent package. The installation commands also use GNU coreutils + (`mkdir` and `install`). - macOS requires the Xcode Command Line Tools (`xcode-select --install`). - `x86_64-pc-windows-msvc` requires Visual Studio 2022 Build Tools with the **Desktop development with C++** workload, including MSVC and a Windows SDK. @@ -159,8 +163,7 @@ usable only if that release appears on the Releases page. On Linux or macOS, run: ```bash -set -euo pipefail - +bash -euo pipefail <<'BITBYGIT_INSTALL' && VERSION=0.1.0 git clone --branch "v${VERSION}" --depth 1 https://github.com/cosentinode/bitbygit.git cd bitbygit @@ -178,12 +181,14 @@ if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${installed_output}" >&2 exit 1 fi +BITBYGIT_INSTALL export PATH="${HOME}/.local/bin:${PATH}" ``` On Windows, run in PowerShell: ```powershell +& { $ErrorActionPreference = "Stop" $Version = "0.1.0" @@ -210,6 +215,7 @@ if (($UserPath -split ";") -notcontains $InstallDir) { [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") } $env:Path = "$InstallDir;$env:Path" +} ``` ## Package managers diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index 40dd776..8697e52 100755 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -1,6 +1,8 @@ #!/usr/bin/env sh set -eu +bash scripts/test-release-workflow.sh +bash scripts/test-installation-docs.sh cargo fmt --all --check cargo clippy --locked --workspace --all-targets -- -D warnings cargo test --locked --workspace diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 new file mode 100644 index 0000000..ae6ec7c --- /dev/null +++ b/scripts/test-installation-docs.ps1 @@ -0,0 +1,144 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$DocsPath = Join-Path $Root "docs/installation.md" +$CargoPath = Join-Path $Root "Cargo.toml" +$Temp = Join-Path ([System.IO.Path]::GetTempPath()) "bitbygit-install-$([guid]::NewGuid())" + +function Fail([string] $Message) { + throw "installation docs validation failed: $Message" +} + +function Get-Block([string] $Heading, [string] $Language) { + $Pattern = '(?ms)^## {0}\r?\n.*?^```{1}\r?\n(.*?)^```' -f [regex]::Escape($Heading), [regex]::Escape($Language) + $Match = [regex]::Match($script:DocsText, $Pattern) + if (-not $Match.Success) { Fail "missing $Heading $Language block" } + return $Match.Groups[1].Value +} + +$DocsText = Get-Content -Raw $DocsPath +$CargoText = Get-Content -Raw $CargoPath +$VersionMatch = [regex]::Match($CargoText, '(?ms)^\[workspace\.package\]\r?\n.*?^version = "([^"]+)"') +if (-not $VersionMatch.Success) { Fail "workspace version was not found" } +$Version = $VersionMatch.Groups[1].Value +if (-not $DocsText.Contains("`$Version = `"$Version`"")) { + Fail "PowerShell examples do not use workspace version $Version" +} + +foreach ($Match in [regex]::Matches($DocsText, '(?ms)^```powershell\r?\n(.*?)^```')) { + $Tokens = $null + $Errors = $null + [System.Management.Automation.Language.Parser]::ParseInput( + $Match.Groups[1].Value, + [ref] $Tokens, + [ref] $Errors + ) | Out-Null + if ($Errors.Count -ne 0) { Fail "PowerShell snippet has syntax errors: $($Errors -join '; ')" } +} + +$InstallBlock = Get-Block "Windows x86-64 archive" "powershell" +if (-not $InstallBlock.Contains('$Package = "bitbygit-$Version-$Target"')) { + Fail "Windows package directory does not match the release layout" +} +if (-not $InstallBlock.Contains('& $InstalledBinary --version')) { + Fail "Windows installation does not validate the installed path" +} +if (-not $DocsText.Contains('git clone --branch "v$Version" --depth 1')) { + Fail "PowerShell source build is not pinned to the selected tag" +} + +New-Item -ItemType Directory -Path $Temp | Out-Null +$OriginalLocalAppData = $env:LOCALAPPDATA +$OriginalProcessPath = $env:Path +$OriginalUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + +try { + cargo build --locked --release -p bitbygit + if ($LASTEXITCODE -ne 0) { Fail "cargo build failed" } + + $Target = "x86_64-pc-windows-msvc" + $Archive = "bitbygit-$Version-$Target.zip" + $Package = "bitbygit-$Version-$Target" + $Assets = Join-Path $Temp "assets" + $PackageDir = Join-Path $Assets $Package + New-Item -ItemType Directory -Path $PackageDir | Out-Null + Copy-Item (Join-Path $Root "target/release/bitbygit.exe") $PackageDir + Compress-Archive -Path $PackageDir -DestinationPath (Join-Path $Assets $Archive) + $Digest = (Get-FileHash -Algorithm SHA256 (Join-Path $Assets $Archive)).Hash + Set-Content -Path (Join-Path $Assets "SHA256SUMS") -Value "$Digest $Archive" + + $SuccessDir = Join-Path $Temp "success" + New-Item -ItemType Directory -Path $SuccessDir | Out-Null + $SuccessScript = Join-Path $SuccessDir "install.ps1" + Set-Content -Path $SuccessScript -Value $InstallBlock + $env:LOCALAPPDATA = Join-Path $SuccessDir "local-app-data" + $env:Path = $OriginalProcessPath + $env:MOCK_DOWNLOAD_DIR = $Assets + $ErrorActionPreference = "Continue" + + function Invoke-WebRequest { + param($Uri, $OutFile) + Copy-Item (Join-Path $env:MOCK_DOWNLOAD_DIR ([System.IO.Path]::GetFileName([uri] $Uri))) $OutFile + } + + Push-Location $SuccessDir + try { + . $SuccessScript + } finally { + Pop-Location + } + + $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" + $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" + if ($ErrorActionPreference -ne "Continue") { Fail "archive block changed the caller error preference" } + if (($env:Path -split ';')[0] -ne $InstallDir) { Fail "archive block did not update the process PATH" } + if (([Environment]::GetEnvironmentVariable("Path", "User") -split ';') -notcontains $InstallDir) { + Fail "archive block did not update the user PATH" + } + if (-not (Test-Path $InstalledBinary)) { Fail "archive block did not install bitbygit.exe" } + $Output = & $InstalledBinary --version + if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { + Fail "archive block installed the wrong version" + } + + $FailureAssets = Join-Path $Temp "failure-assets" + $FailureDir = Join-Path $Temp "failure" + New-Item -ItemType Directory -Path $FailureAssets, $FailureDir | Out-Null + Copy-Item (Join-Path $Assets $Archive) $FailureAssets + Set-Content -Path (Join-Path $FailureAssets "SHA256SUMS") -Value "$('0' * 64) $Archive" + $FailureScript = Join-Path $FailureDir "install.ps1" + Set-Content -Path $FailureScript -Value $InstallBlock + $env:LOCALAPPDATA = Join-Path $FailureDir "local-app-data" + $env:Path = $OriginalProcessPath + $env:MOCK_DOWNLOAD_DIR = $FailureAssets + $env:MOCK_SIDE_EFFECT = Join-Path $FailureDir "expanded" + $ErrorActionPreference = "Continue" + + function Expand-Archive { + Set-Content -Path $env:MOCK_SIDE_EFFECT -Value "expanded" + throw "archive extraction unexpectedly reached" + } + + $ChecksumFailed = $false + Push-Location $FailureDir + try { + try { + . $FailureScript + } catch { + $ChecksumFailed = $true + } + } finally { + Pop-Location + } + if (-not $ChecksumFailed) { Fail "archive block accepted an invalid checksum" } + if (Test-Path $env:MOCK_SIDE_EFFECT) { Fail "archive block extracted after checksum failure" } + if ($ErrorActionPreference -ne "Continue") { Fail "failed archive block changed the caller error preference" } + if ($env:Path -ne $OriginalProcessPath) { Fail "failed archive block changed the process PATH" } + + Write-Output "installation docs validation passed on Windows" +} finally { + $env:LOCALAPPDATA = $OriginalLocalAppData + $env:Path = $OriginalProcessPath + [Environment]::SetEnvironmentVariable("Path", $OriginalUserPath, "User") + Remove-Item -Recurse -Force $Temp -ErrorAction SilentlyContinue +} diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 116342e..7de363a 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -35,7 +35,10 @@ workspace_version="$(perl -ne ' [[ "${docs_text}" == *"VERSION=${workspace_version}"* ]] || fail "Bash examples do not use workspace version ${workspace_version}" [[ "${docs_text}" == *"\$Version = \"${workspace_version}\""* ]] || fail "PowerShell examples do not use workspace version ${workspace_version}" -mapfile -t release_artifacts < <(perl -0777 -ne ' +release_artifacts=() +while IFS= read -r artifact; do + release_artifacts+=("${artifact}") +done < <(perl -0777 -ne ' while (/^\s+- runner:.*?^\s+target:\s+(\S+)\s*\n^\s+archive_extension:\s+(\S+)/msg) { print "bitbygit--$1.$2\n"; } @@ -53,89 +56,127 @@ done perl -0777 -ne 'while (/^```bash\n(.*?)^```/msg) { print "$1\n" }' "${docs}" > "${tmp}/snippets.bash" bash -n "${tmp}/snippets.bash" -perl -0777 -ne 'while (/^```powershell\n(.*?)^```/msg) { print "$1\n" }' "${docs}" > "${tmp}/snippets.ps1" -SNIPPETS_PS1="${tmp}/snippets.ps1" pwsh -NoProfile -NonInteractive -Command ' - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($env:SNIPPETS_PS1, [ref]$tokens, [ref]$errors) | Out-Null - if ($errors.Count -ne 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 } -' +mkdir -p "${tmp}/assets" "${tmp}/mock-bin" +for target in x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin; do + package="bitbygit-${workspace_version}-${target}" + mkdir -p "${tmp}/package/${package}" + printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${workspace_version}" > "${tmp}/package/${package}/bitbygit" + chmod +x "${tmp}/package/${package}/bitbygit" + tar -C "${tmp}/package" -czf "${tmp}/assets/${package}.tar.gz" "${package}" + rm -rf "${tmp}/package/${package}" +done +( + cd "${tmp}/assets" + for archive in bitbygit-*.tar.gz; do + if command -v sha256sum >/dev/null; then + digest="$(sha256sum "${archive}")" + else + digest="$(shasum -a 256 "${archive}")" + fi + printf '%s %s\n' "${digest%% *}" "${archive}" + done > SHA256SUMS +) -mkdir -p "${tmp}/mock-bin" cat > "${tmp}/mock-bin/curl" <<'MOCK' #!/usr/bin/env bash -output="${*: -1}" -output="${output##*/}" -if [[ "${output}" == "SHA256SUMS" ]]; then - printf '%064d %s\n' 0 "${MOCK_ARCHIVE}" > "${output}" -else - : > "${output}" -fi -MOCK -cat > "${tmp}/mock-bin/sha256sum" <<'MOCK' -#!/usr/bin/env bash -exit 1 +for source in "$@"; do :; done +cp "${MOCK_DOWNLOAD_DIR}/${source##*/}" . MOCK -cat > "${tmp}/mock-bin/shasum" <<'MOCK' +cat > "${tmp}/mock-bin/uname" <<'MOCK' #!/usr/bin/env bash -exit 1 -MOCK -for command in tar install; do - cat > "${tmp}/mock-bin/${command}" <<'MOCK' -#!/usr/bin/env bash -: > "${MOCK_SIDE_EFFECTS}" +if [[ "${1:-}" == "-m" ]]; then + printf '%s\n' "${MOCK_UNAME_MACHINE}" +else + /usr/bin/uname "$@" +fi MOCK -done chmod +x "${tmp}/mock-bin/"* -check_unix_checksum_gate() { +check_unix_success() { local heading="$1" local target="$2" - local extension="$3" - local case_dir="${tmp}/${target}" + local machine="$3" + local case_dir="${tmp}/success-${target}" mkdir -p "${case_dir}/home" extract_block "${heading}" bash > "${case_dir}/snippet.bash" [[ -s "${case_dir}/snippet.bash" ]] || fail "missing ${heading} Bash block" - if ( + ( cd "${case_dir}" - PATH="${tmp}/mock-bin:${PATH}" \ - HOME="${case_dir}/home" \ - MOCK_ARCHIVE="bitbygit-${workspace_version}-${target}.${extension}" \ - MOCK_SIDE_EFFECTS="${case_dir}/side-effects" \ - bash "${case_dir}/snippet.bash" - ); then - fail "${heading} continued after checksum failure" - fi - [[ ! -e "${case_dir}/side-effects" ]] || fail "${heading} extracted or installed after checksum failure" + set +e + set +u + set +o pipefail + export HOME="${case_dir}/home" + export MOCK_DOWNLOAD_DIR="${tmp}/assets" + export MOCK_UNAME_MACHINE="${machine}" + export PATH="${tmp}/mock-bin:${PATH}" + + source "${case_dir}/snippet.bash" || fail "${heading} failed a valid ${target} installation" + [[ "$-" != *e* && "$-" != *u* ]] || fail "${heading} changed caller shell options" + if shopt -qo pipefail; then + fail "${heading} enabled pipefail in the caller shell" + fi + [[ "${PATH%%:*}" == "${HOME}/.local/bin" ]] || fail "${heading} did not update the caller PATH" + [[ -x "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not install the binary" + [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${workspace_version}" ]] || fail "${heading} installed the wrong version" + ) } -check_unix_checksum_gate "Linux x86-64 archive" x86_64-unknown-linux-gnu tar.gz -check_unix_checksum_gate "macOS archive" x86_64-apple-darwin tar.gz - -extract_block "Windows x86-64 archive" powershell > "${tmp}/windows-install.ps1" -[[ -s "${tmp}/windows-install.ps1" ]] || fail "missing Windows PowerShell block" -mkdir -p "${tmp}/windows-case" -if MOCK_ARCHIVE="bitbygit-${workspace_version}-x86_64-pc-windows-msvc.zip" \ - MOCK_CHECKSUM_REACHED="${tmp}/windows-checksum-reached" \ - MOCK_SIDE_EFFECTS="${tmp}/windows-side-effects" \ - WINDOWS_CASE_DIR="${tmp}/windows-case" \ - WINDOWS_INSTALL_PS1="${tmp}/windows-install.ps1" \ - pwsh -NoProfile -NonInteractive -Command ' - Set-Location $env:WINDOWS_CASE_DIR - function Invoke-WebRequest { param($Uri, $OutFile) Set-Content -LiteralPath $OutFile -Value "mock" } - function Get-Content { "0000000000000000000000000000000000000000000000000000000000000000 $env:MOCK_ARCHIVE" } - function Get-FileHash { - Set-Content -LiteralPath $env:MOCK_CHECKSUM_REACHED -Value "checked" - [pscustomobject]@{ Hash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } - } - function Expand-Archive { Set-Content -LiteralPath $env:MOCK_SIDE_EFFECTS -Value "expanded"; throw "continued" } - & $env:WINDOWS_INSTALL_PS1 -' >/dev/null 2>&1; then - fail "Windows archive block continued after checksum failure" -fi -[[ -e "${tmp}/windows-checksum-reached" ]] || fail "Windows archive block did not reach checksum verification" -[[ ! -e "${tmp}/windows-side-effects" ]] || fail "Windows archive extracted after checksum failure" +host_os="${INSTALLATION_DOCS_TEST_OS:-$(uname -s)}" +case "${host_os}" in + Linux) + check_unix_success "Linux x86-64 archive" x86_64-unknown-linux-gnu x86_64 + failure_heading="Linux x86-64 archive" + failure_target=x86_64-unknown-linux-gnu + failure_machine=x86_64 + ;; + Darwin) + check_unix_success "macOS archive" x86_64-apple-darwin x86_64 + check_unix_success "macOS archive" aarch64-apple-darwin arm64 + failure_heading="macOS archive" + failure_target=x86_64-apple-darwin + failure_machine=x86_64 + ;; + *) fail "unsupported validation host ${host_os}" ;; +esac + +mkdir -p "${tmp}/failure-assets" "${tmp}/failure-bin" +cp "${tmp}/assets/bitbygit-${workspace_version}-${failure_target}.tar.gz" "${tmp}/failure-assets/" +printf '%064d bitbygit-%s-%s.tar.gz\n' 0 "${workspace_version}" "${failure_target}" > "${tmp}/failure-assets/SHA256SUMS" +cp "${tmp}/mock-bin/curl" "${tmp}/failure-bin/curl" +cp "${tmp}/mock-bin/uname" "${tmp}/failure-bin/uname" +for command in tar install; do + cat > "${tmp}/failure-bin/${command}" <<'MOCK' +#!/usr/bin/env bash +: > "${MOCK_SIDE_EFFECTS}" +MOCK +done +chmod +x "${tmp}/failure-bin/"* + +failure_dir="${tmp}/failure-${failure_target}" +mkdir -p "${failure_dir}/home" +extract_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" +( + cd "${failure_dir}" + set +e + set +u + set +o pipefail + original_path="${tmp}/failure-bin:${PATH}" + export HOME="${failure_dir}/home" + export MOCK_DOWNLOAD_DIR="${tmp}/failure-assets" + export MOCK_SIDE_EFFECTS="${failure_dir}/side-effects" + export MOCK_UNAME_MACHINE="${failure_machine}" + export PATH="${original_path}" + if source "${failure_dir}/snippet.bash"; then + fail "${failure_heading} continued after checksum failure" + fi + [[ "$-" != *e* && "$-" != *u* ]] || fail "failed ${failure_heading} changed caller shell options" + if shopt -qo pipefail; then + fail "failed ${failure_heading} enabled pipefail in the caller shell" + fi + [[ "${PATH}" == "${original_path}" ]] || fail "failed ${failure_heading} changed the caller PATH" +) +[[ ! -e "${failure_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed after checksum failure" [[ "${docs_text}" == *'git clone --branch "v${VERSION}" --depth 1'* ]] || fail "Bash source build is not pinned to the selected tag" [[ "${docs_text}" == *'git clone --branch "v$Version" --depth 1'* ]] || fail "PowerShell source build is not pinned to the selected tag" @@ -181,4 +222,4 @@ check_links() { check_links "${root}/README.md" check_links "${docs}" -echo "installation docs validation passed" +echo "installation docs validation passed on ${host_os}" From b1f072dfcbd24dcca2b9acf9ff92bf91c2619f19 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 13:50:43 -0400 Subject: [PATCH 04/21] fix: honor installation platform contracts --- .github/workflows/ci.yml | 16 +++++ README.md | 5 +- crates/bitbygit-core/src/config.rs | 14 ++--- docs/installation.md | 11 ++-- scripts/test-installation-docs.ps1 | 97 ++++++++++++++++++++++++++++-- 5 files changed, 123 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2e76aa..3fd4f28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,22 @@ permissions: contents: read jobs: + minimum-rust: + name: Rust 1.85 source build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust 1.85 + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.85.0 + + - name: Build release binary + run: cargo build --locked --release -p bitbygit + rust: name: Rust runs-on: ubuntu-latest diff --git a/README.md b/README.md index bc9ed04..4e2eab9 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,10 @@ cargo build --locked --workspace ``` The Bash installation validator runs on Linux and macOS without PowerShell. -Windows contributors can run the platform-specific archive validation with +Windows contributors can run the platform-specific installation validation with `pwsh -NoProfile -File scripts/test-installation-docs.ps1`; CI runs that command -on a Windows runner. +on a Windows runner. The validator uses temporary process-scoped PATH state and +never changes the persistent user PATH. Or run: diff --git a/crates/bitbygit-core/src/config.rs b/crates/bitbygit-core/src/config.rs index 8665b56..6f9ec84 100644 --- a/crates/bitbygit-core/src/config.rs +++ b/crates/bitbygit-core/src/config.rs @@ -37,13 +37,13 @@ impl AppConfig { } self.policy.validate()?; - if let Some(branch) = &self.pull_requests.default_base_branch - && !valid_branch_name(branch) - { - return Err(invalid_value( - "pull-requests.default-base-branch", - "must be a valid local branch name", - )); + if let Some(branch) = &self.pull_requests.default_base_branch { + if !valid_branch_name(branch) { + return Err(invalid_value( + "pull-requests.default-base-branch", + "must be a valid local branch name", + )); + } } Ok(()) } diff --git a/docs/installation.md b/docs/installation.md index 62792d6..ff87cc2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -192,19 +192,20 @@ On Windows, run in PowerShell: $ErrorActionPreference = "Stop" $Version = "0.1.0" -git clone --branch "v$Version" --depth 1 https://github.com/cosentinode/bitbygit.git +$SourceDir = Join-Path (Get-Location) "bitbygit" +git clone --branch "v$Version" --depth 1 https://github.com/cosentinode/bitbygit.git $SourceDir if ($LASTEXITCODE -ne 0) { throw "git clone failed" } -Set-Location bitbygit -cargo build --locked --release -p bitbygit +cargo build --manifest-path (Join-Path $SourceDir "Cargo.toml") --locked --release -p bitbygit if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } -$BuiltOutput = & "target\release\bitbygit.exe" --version +$BuiltBinary = Join-Path $SourceDir "target\release\bitbygit.exe" +$BuiltOutput = & $BuiltBinary --version if ($LASTEXITCODE -ne 0 -or $BuiltOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $BuiltOutput" } $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -Copy-Item "target\release\bitbygit.exe" $InstallDir +Copy-Item $BuiltBinary $InstallDir $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" $InstalledOutput = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index ae6ec7c..d84c72a 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -16,6 +16,21 @@ function Get-Block([string] $Heading, [string] $Language) { return $Match.Groups[1].Value } +function ConvertTo-IsolatedPathBlock([string] $Block) { + $GetUserPath = 'GetEnvironmentVariable("Path", "User")' + $SetUserPath = 'SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User")' + if (-not $Block.Contains($GetUserPath) -or -not $Block.Contains($SetUserPath)) { + Fail "Windows block does not contain the expected user PATH operations" + } + + $Isolated = $Block.Replace($GetUserPath, 'GetEnvironmentVariable("Path", "Process")') + $Isolated = $Isolated.Replace($SetUserPath, 'SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "Process")') + if ($Isolated.Contains('"User"')) { + Fail "Windows block still accesses persistent user PATH" + } + return $Isolated +} + $DocsText = Get-Content -Raw $DocsPath $CargoText = Get-Content -Raw $CargoPath $VersionMatch = [regex]::Match($CargoText, '(?ms)^\[workspace\.package\]\r?\n.*?^version = "([^"]+)"') @@ -36,21 +51,21 @@ foreach ($Match in [regex]::Matches($DocsText, '(?ms)^```powershell\r?\n(.*?)^`` if ($Errors.Count -ne 0) { Fail "PowerShell snippet has syntax errors: $($Errors -join '; ')" } } -$InstallBlock = Get-Block "Windows x86-64 archive" "powershell" +$InstallBlock = ConvertTo-IsolatedPathBlock (Get-Block "Windows x86-64 archive" "powershell") if (-not $InstallBlock.Contains('$Package = "bitbygit-$Version-$Target"')) { Fail "Windows package directory does not match the release layout" } if (-not $InstallBlock.Contains('& $InstalledBinary --version')) { Fail "Windows installation does not validate the installed path" } -if (-not $DocsText.Contains('git clone --branch "v$Version" --depth 1')) { +$SourceBlock = ConvertTo-IsolatedPathBlock (Get-Block "Build from source" "powershell") +if (-not $SourceBlock.Contains('git clone --branch "v$Version" --depth 1')) { Fail "PowerShell source build is not pinned to the selected tag" } New-Item -ItemType Directory -Path $Temp | Out-Null $OriginalLocalAppData = $env:LOCALAPPDATA $OriginalProcessPath = $env:Path -$OriginalUserPath = [Environment]::GetEnvironmentVariable("Path", "User") try { cargo build --locked --release -p bitbygit @@ -92,8 +107,8 @@ try { $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "archive block changed the caller error preference" } if (($env:Path -split ';')[0] -ne $InstallDir) { Fail "archive block did not update the process PATH" } - if (([Environment]::GetEnvironmentVariable("Path", "User") -split ';') -notcontains $InstallDir) { - Fail "archive block did not update the user PATH" + if (([Environment]::GetEnvironmentVariable("Path", "Process") -split ';') -notcontains $InstallDir) { + Fail "archive block did not update its isolated PATH" } if (-not (Test-Path $InstalledBinary)) { Fail "archive block did not install bitbygit.exe" } $Output = & $InstalledBinary --version @@ -135,10 +150,80 @@ try { if ($ErrorActionPreference -ne "Continue") { Fail "failed archive block changed the caller error preference" } if ($env:Path -ne $OriginalProcessPath) { Fail "failed archive block changed the process PATH" } + function git { + $CloneDir = Join-Path (Get-Location) "bitbygit" + $BuildDir = Join-Path $CloneDir "target/release" + New-Item -ItemType Directory -Path $BuildDir | Out-Null + Copy-Item (Join-Path $Root "target/release/bitbygit.exe") $BuildDir + $global:LASTEXITCODE = 0 + } + + function cargo { + if ($env:MOCK_CARGO_FAILURE -eq "1") { + $global:LASTEXITCODE = 1 + return + } + $global:LASTEXITCODE = 0 + } + + $SourceSuccessDir = Join-Path $Temp "source-success" + New-Item -ItemType Directory -Path $SourceSuccessDir | Out-Null + $SourceScript = Join-Path $SourceSuccessDir "install-from-source.ps1" + Set-Content -Path $SourceScript -Value $SourceBlock + $env:LOCALAPPDATA = Join-Path $SourceSuccessDir "local-app-data" + $env:Path = $OriginalProcessPath + $env:MOCK_CARGO_FAILURE = "0" + $ErrorActionPreference = "Continue" + Push-Location $SourceSuccessDir + try { + $StartingLocation = (Get-Location).Path + . $SourceScript + if ((Get-Location).Path -ne $StartingLocation) { + Fail "source block changed the caller working directory" + } + } finally { + Pop-Location + } + $SourceInstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" + $SourceInstalledBinary = Join-Path $SourceInstallDir "bitbygit.exe" + if ($ErrorActionPreference -ne "Continue") { Fail "source block changed the caller error preference" } + if (($env:Path -split ';')[0] -ne $SourceInstallDir) { Fail "source block did not update the process PATH" } + if (-not (Test-Path $SourceInstalledBinary)) { Fail "source block did not install bitbygit.exe" } + $SourceOutput = & $SourceInstalledBinary --version + if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $Version") { + Fail "source block installed the wrong version" + } + + $SourceFailureDir = Join-Path $Temp "source-failure" + New-Item -ItemType Directory -Path $SourceFailureDir | Out-Null + $SourceFailureScript = Join-Path $SourceFailureDir "install-from-source.ps1" + Set-Content -Path $SourceFailureScript -Value $SourceBlock + $env:LOCALAPPDATA = Join-Path $SourceFailureDir "local-app-data" + $env:Path = $OriginalProcessPath + $env:MOCK_CARGO_FAILURE = "1" + $ErrorActionPreference = "Continue" + $SourceBuildFailed = $false + Push-Location $SourceFailureDir + try { + $StartingLocation = (Get-Location).Path + try { + . $SourceFailureScript + } catch { + $SourceBuildFailed = $true + } + if ((Get-Location).Path -ne $StartingLocation) { + Fail "failed source block changed the caller working directory" + } + } finally { + Pop-Location + } + if (-not $SourceBuildFailed) { Fail "source block continued after a failed build" } + if ($ErrorActionPreference -ne "Continue") { Fail "failed source block changed the caller error preference" } + if ($env:Path -ne $OriginalProcessPath) { Fail "failed source block changed the process PATH" } + Write-Output "installation docs validation passed on Windows" } finally { $env:LOCALAPPDATA = $OriginalLocalAppData $env:Path = $OriginalProcessPath - [Environment]::SetEnvironmentVariable("Path", $OriginalUserPath, "User") Remove-Item -Recurse -Force $Temp -ErrorAction SilentlyContinue } From 39e2ed35652f3d197619111b77184b0cac385648 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 13:53:06 -0400 Subject: [PATCH 05/21] fix: clear Windows validator mock status --- scripts/test-installation-docs.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index d84c72a..ab762ec 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -221,6 +221,7 @@ try { if ($ErrorActionPreference -ne "Continue") { Fail "failed source block changed the caller error preference" } if ($env:Path -ne $OriginalProcessPath) { Fail "failed source block changed the process PATH" } + $global:LASTEXITCODE = 0 Write-Output "installation docs validation passed on Windows" } finally { $env:LOCALAPPDATA = $OriginalLocalAppData From f364f448317a3756c9f71add04f74723892de10c Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 14:17:32 -0400 Subject: [PATCH 06/21] fix: pin source installs to release tags --- .github/workflows/ci.yml | 19 +++++++-- docs/installation.md | 28 ++++++++++--- scripts/test-installation-docs.ps1 | 63 ++++++++++++++++++++++++------ scripts/test-installation-docs.sh | 52 +++++++++++++++++++++++- 4 files changed, 141 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fd4f28..7aab24d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,20 @@ permissions: jobs: minimum-rust: - name: Rust 1.85 source build - runs-on: ubuntu-latest + name: Rust 1.85 source build (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: macos-15 + target: aarch64-apple-darwin + - runner: windows-latest + target: x86_64-pc-windows-msvc steps: - name: Checkout @@ -24,9 +36,10 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: 1.85.0 + targets: ${{ matrix.target }} - name: Build release binary - run: cargo build --locked --release -p bitbygit + run: cargo build --locked --release -p bitbygit --target ${{ matrix.target }} rust: name: Rust diff --git a/docs/installation.md b/docs/installation.md index ff87cc2..a874cd6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -135,7 +135,8 @@ if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { } $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") if (($UserPath -split ";") -notcontains $InstallDir) { - [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") + $NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" } + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") } $env:Path = "$InstallDir;$env:Path" } @@ -165,7 +166,13 @@ On Linux or macOS, run: ```bash bash -euo pipefail <<'BITBYGIT_INSTALL' && VERSION=0.1.0 -git clone --branch "v${VERSION}" --depth 1 https://github.com/cosentinode/bitbygit.git +TAG="refs/tags/v${VERSION}" +mkdir bitbygit +git -C bitbygit init +git -C bitbygit fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}" +tag_commit="$(git -C bitbygit rev-parse --verify "${TAG}^{commit}")" +git -C bitbygit checkout --detach "${tag_commit}" +[[ "$(git -C bitbygit rev-parse --verify HEAD)" == "${tag_commit}" ]] cd bitbygit cargo build --locked --release -p bitbygit built_output="$(target/release/bitbygit --version)" @@ -192,9 +199,19 @@ On Windows, run in PowerShell: $ErrorActionPreference = "Stop" $Version = "0.1.0" +$Tag = "refs/tags/v$Version" $SourceDir = Join-Path (Get-Location) "bitbygit" -git clone --branch "v$Version" --depth 1 https://github.com/cosentinode/bitbygit.git $SourceDir -if ($LASTEXITCODE -ne 0) { throw "git clone failed" } +New-Item -ItemType Directory -Path $SourceDir | Out-Null +git -C $SourceDir init +if ($LASTEXITCODE -ne 0) { throw "git init failed" } +git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}" +if ($LASTEXITCODE -ne 0) { throw "git fetch failed" } +$TagCommit = git -C $SourceDir rev-parse --verify "${Tag}^{commit}" +if ($LASTEXITCODE -ne 0) { throw "release tag verification failed" } +git -C $SourceDir checkout --detach $TagCommit +if ($LASTEXITCODE -ne 0) { throw "release tag checkout failed" } +$HeadCommit = git -C $SourceDir rev-parse --verify HEAD +if ($LASTEXITCODE -ne 0 -or $HeadCommit -ne $TagCommit) { throw "release tag checkout verification failed" } cargo build --manifest-path (Join-Path $SourceDir "Cargo.toml") --locked --release -p bitbygit if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } $BuiltBinary = Join-Path $SourceDir "target\release\bitbygit.exe" @@ -213,7 +230,8 @@ if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { } $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") if (($UserPath -split ";") -notcontains $InstallDir) { - [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") + $NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" } + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") } $env:Path = "$InstallDir;$env:Path" } diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index ab762ec..e669cc2 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -18,13 +18,13 @@ function Get-Block([string] $Heading, [string] $Language) { function ConvertTo-IsolatedPathBlock([string] $Block) { $GetUserPath = 'GetEnvironmentVariable("Path", "User")' - $SetUserPath = 'SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User")' + $SetUserPath = '[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")' if (-not $Block.Contains($GetUserPath) -or -not $Block.Contains($SetUserPath)) { Fail "Windows block does not contain the expected user PATH operations" } - $Isolated = $Block.Replace($GetUserPath, 'GetEnvironmentVariable("Path", "Process")') - $Isolated = $Isolated.Replace($SetUserPath, 'SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "Process")') + $Isolated = $Block.Replace($GetUserPath, 'GetEnvironmentVariable("BITBYGIT_TEST_USER_PATH", "Process")') + $Isolated = $Isolated.Replace($SetUserPath, '$script:CapturedUserPath = $NewUserPath') if ($Isolated.Contains('"User"')) { Fail "Windows block still accesses persistent user PATH" } @@ -39,6 +39,10 @@ $Version = $VersionMatch.Groups[1].Value if (-not $DocsText.Contains("`$Version = `"$Version`"")) { Fail "PowerShell examples do not use workspace version $Version" } +$EmptyPathGuard = '$NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" }' +if ([regex]::Matches($DocsText, [regex]::Escape($EmptyPathGuard)).Count -ne 2) { + Fail "Windows examples do not handle empty user PATH values consistently" +} foreach ($Match in [regex]::Matches($DocsText, '(?ms)^```powershell\r?\n(.*?)^```')) { $Tokens = $null @@ -59,7 +63,9 @@ if (-not $InstallBlock.Contains('& $InstalledBinary --version')) { Fail "Windows installation does not validate the installed path" } $SourceBlock = ConvertTo-IsolatedPathBlock (Get-Block "Build from source" "powershell") -if (-not $SourceBlock.Contains('git clone --branch "v$Version" --depth 1')) { +if (-not $SourceBlock.Contains('git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"') -or + -not $SourceBlock.Contains('git -C $SourceDir checkout --detach $TagCommit') -or + -not $SourceBlock.Contains('$HeadCommit -ne $TagCommit')) { Fail "PowerShell source build is not pinned to the selected tag" } @@ -88,6 +94,8 @@ try { Set-Content -Path $SuccessScript -Value $InstallBlock $env:LOCALAPPDATA = Join-Path $SuccessDir "local-app-data" $env:Path = $OriginalProcessPath + Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue + $CapturedUserPath = $null $env:MOCK_DOWNLOAD_DIR = $Assets $ErrorActionPreference = "Continue" @@ -107,9 +115,7 @@ try { $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "archive block changed the caller error preference" } if (($env:Path -split ';')[0] -ne $InstallDir) { Fail "archive block did not update the process PATH" } - if (([Environment]::GetEnvironmentVariable("Path", "Process") -split ';') -notcontains $InstallDir) { - Fail "archive block did not update its isolated PATH" - } + if ($CapturedUserPath -ne $InstallDir) { Fail "archive block malformed an empty user PATH" } if (-not (Test-Path $InstalledBinary)) { Fail "archive block did not install bitbygit.exe" } $Output = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { @@ -151,10 +157,35 @@ try { if ($env:Path -ne $OriginalProcessPath) { Fail "failed archive block changed the process PATH" } function git { - $CloneDir = Join-Path (Get-Location) "bitbygit" - $BuildDir = Join-Path $CloneDir "target/release" - New-Item -ItemType Directory -Path $BuildDir | Out-Null - Copy-Item (Join-Path $Root "target/release/bitbygit.exe") $BuildDir + param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Arguments) + + $ExpectedTag = "refs/tags/v$Version" + if ($Arguments.Count -eq 3 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "init") { + $BuildDir = Join-Path $Arguments[1] "target/release" + New-Item -ItemType Directory -Path $BuildDir | Out-Null + Copy-Item (Join-Path $Root "target/release/bitbygit.exe") $BuildDir + } elseif ($Arguments.Count -eq 7 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "fetch" -and $Arguments[3] -eq "--depth" -and + $Arguments[4] -eq "1" -and + $Arguments[5] -eq "https://github.com/cosentinode/bitbygit.git" -and + $Arguments[6] -eq "${ExpectedTag}:${ExpectedTag}") { + $script:FetchedExactTag = $true + } elseif ($Arguments.Count -eq 5 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "rev-parse" -and $Arguments[3] -eq "--verify" -and + $Arguments[4] -eq "${ExpectedTag}^{commit}" -and $script:FetchedExactTag) { + return "tag-commit" + } elseif ($Arguments.Count -eq 5 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "checkout" -and $Arguments[3] -eq "--detach" -and + $Arguments[4] -eq "tag-commit" -and $script:FetchedExactTag) { + $script:CheckedOutExactTag = $true + } elseif ($Arguments.Count -eq 5 -and $Arguments[0] -eq "-C" -and + $Arguments[2] -eq "rev-parse" -and $Arguments[3] -eq "--verify" -and + $Arguments[4] -eq "HEAD" -and $script:CheckedOutExactTag) { + return "tag-commit" + } else { + throw "unexpected git arguments: $Arguments" + } $global:LASTEXITCODE = 0 } @@ -172,6 +203,10 @@ try { Set-Content -Path $SourceScript -Value $SourceBlock $env:LOCALAPPDATA = Join-Path $SourceSuccessDir "local-app-data" $env:Path = $OriginalProcessPath + $env:BITBYGIT_TEST_USER_PATH = $OriginalProcessPath + $CapturedUserPath = $null + $FetchedExactTag = $false + $CheckedOutExactTag = $false $env:MOCK_CARGO_FAILURE = "0" $ErrorActionPreference = "Continue" Push-Location $SourceSuccessDir @@ -188,6 +223,10 @@ try { $SourceInstalledBinary = Join-Path $SourceInstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "source block changed the caller error preference" } if (($env:Path -split ';')[0] -ne $SourceInstallDir) { Fail "source block did not update the process PATH" } + if ($CapturedUserPath -ne "$OriginalProcessPath;$SourceInstallDir") { + Fail "source block did not append to the existing user PATH" + } + if (-not $FetchedExactTag -or -not $CheckedOutExactTag) { Fail "source block did not check out the exact tag" } if (-not (Test-Path $SourceInstalledBinary)) { Fail "source block did not install bitbygit.exe" } $SourceOutput = & $SourceInstalledBinary --version if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $Version") { @@ -200,6 +239,8 @@ try { Set-Content -Path $SourceFailureScript -Value $SourceBlock $env:LOCALAPPDATA = Join-Path $SourceFailureDir "local-app-data" $env:Path = $OriginalProcessPath + $FetchedExactTag = $false + $CheckedOutExactTag = $false $env:MOCK_CARGO_FAILURE = "1" $ErrorActionPreference = "Continue" $SourceBuildFailed = $false diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 7de363a..be124f1 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -5,6 +5,7 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" docs="${root}/docs/installation.md" workflow="${root}/.github/workflows/release.yml" +ci_workflow="${root}/.github/workflows/ci.yml" tmp="$(mktemp -d)" trap 'rm -rf "${tmp}"' EXIT @@ -27,6 +28,7 @@ extract_block() { docs_text="$(<"${docs}")" workflow_text="$(<"${workflow}")" +ci_workflow_text="$(<"${ci_workflow}")" workspace_version="$(perl -ne ' $workspace = 1 if /^\[workspace\.package\]$/; if ($workspace && /^version = "([^"]+)"$/) { print $1; exit } @@ -47,6 +49,10 @@ done < <(perl -0777 -ne ' for artifact in "${release_artifacts[@]}"; do [[ "${docs_text}" == *"${artifact}"* ]] || fail "missing release artifact ${artifact}" done +[[ "${ci_workflow_text}" == *"toolchain: 1.85.0"* ]] || fail "CI does not pin the documented minimum Rust version" +for target in x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin x86_64-pc-windows-msvc; do + [[ "${ci_workflow_text}" == *"target: ${target}"* ]] || fail "Rust 1.85 CI does not build ${target}" +done [[ "${workflow_text}" == *'package="bitbygit-${version}-${{ matrix.target }}"'* ]] || fail "release package directory contract changed" [[ "${docs_text}" == *'PACKAGE="bitbygit-${VERSION}-${TARGET}"'* ]] || fail "Bash package directory does not match release layout" [[ "${docs_text}" == *'$Package = "bitbygit-$Version-$Target"'* ]] || fail "PowerShell package directory does not match release layout" @@ -178,11 +184,53 @@ extract_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" ) [[ ! -e "${failure_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed after checksum failure" -[[ "${docs_text}" == *'git clone --branch "v${VERSION}" --depth 1'* ]] || fail "Bash source build is not pinned to the selected tag" -[[ "${docs_text}" == *'git clone --branch "v$Version" --depth 1'* ]] || fail "PowerShell source build is not pinned to the selected tag" +[[ "${docs_text}" == *'git -C bitbygit fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}"'* ]] || fail "Bash source build does not fetch the exact selected tag" +[[ "${docs_text}" == *'git -C bitbygit checkout --detach "${tag_commit}"'* ]] || fail "Bash source build does not detach at the selected tag" +[[ "${docs_text}" == *'git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"'* ]] || fail "PowerShell source build does not fetch the exact selected tag" +[[ "${docs_text}" == *'git -C $SourceDir checkout --detach $TagCommit'* ]] || fail "PowerShell source build does not detach at the selected tag" [[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" [[ "${docs_text}" == *'& $InstalledBinary --version'* ]] || fail "Windows installation does not validate the installed path" +source_remote="${tmp}/source-remote.git" +source_seed="${tmp}/source-seed" +git init --bare --quiet "${source_remote}" +git init --quiet "${source_seed}" +git -C "${source_seed}" config user.email validator@example.invalid +git -C "${source_seed}" config user.name "Installation validator" +mkdir -p "${source_seed}/target/release" +printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${workspace_version}" > "${source_seed}/target/release/bitbygit" +chmod +x "${source_seed}/target/release/bitbygit" +git -C "${source_seed}" add -f target/release/bitbygit +git -C "${source_seed}" commit --quiet -m "tagged source" +git -C "${source_seed}" tag -a "v${workspace_version}" -m "release ${workspace_version}" +printf '#!/usr/bin/env sh\nprintf '\''bitbygit 9.9.9\\n'\''\n' > "${source_seed}/target/release/bitbygit" +git -C "${source_seed}" commit --quiet -am "same-named branch" +git -C "${source_seed}" branch "v${workspace_version}" +git -C "${source_seed}" push --quiet "${source_remote}" \ + "refs/heads/v${workspace_version}:refs/heads/v${workspace_version}" \ + "refs/tags/v${workspace_version}:refs/tags/v${workspace_version}" + +source_dir="${tmp}/source-install" +mkdir -p "${source_dir}/home" "${source_dir}/mock-bin" +cat > "${source_dir}/mock-bin/cargo" <<'MOCK' +#!/usr/bin/env sh +exit 0 +MOCK +chmod +x "${source_dir}/mock-bin/cargo" +source_snippet="$(extract_block "Build from source" bash)" +source_snippet="${source_snippet/https:\/\/github.com\/cosentinode\/bitbygit.git/${source_remote}}" +printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" +( + cd "${source_dir}" + export HOME="${source_dir}/home" + export PATH="${source_dir}/mock-bin:${PATH}" + source "${source_dir}/snippet.bash" || fail "Bash source block failed with colliding branch and tag names" + tag_commit="$(git --git-dir="${source_remote}" rev-parse "refs/tags/v${workspace_version}^{commit}")" + branch_commit="$(git --git-dir="${source_remote}" rev-parse "refs/heads/v${workspace_version}")" + head_commit="$(git -C bitbygit rev-parse HEAD)" + [[ "${head_commit}" == "${tag_commit}" && "${head_commit}" != "${branch_commit}" ]] || fail "Bash source block did not check out the exact tag object" +) + check_links() { local markdown="$1" local base From 05d3247d700f8af29876881e16e438c1cc7018d8 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 14:45:04 -0400 Subject: [PATCH 07/21] fix: validate installed command resolution --- docs/installation.md | 49 +++++++++++++++++++++++++----- scripts/test-installation-docs.ps1 | 33 +++++++++++++------- scripts/test-installation-docs.sh | 13 +++++++- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index a874cd6..59bc81f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -58,7 +58,13 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then exit 1 fi BITBYGIT_INSTALL -export PATH="${HOME}/.local/bin:${PATH}" +export PATH="${HOME}/.local/bin:${PATH}" && +if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit 0.1.0" ]]; then + bitbygit --version +else + printf 'Expected bitbygit 0.1.0 on PATH, got %s\n' "${path_output:-no output}" >&2 + false +fi ``` Add `export PATH="$HOME/.local/bin:$PATH"` to your shell startup file to keep @@ -96,7 +102,13 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then exit 1 fi BITBYGIT_INSTALL -export PATH="${HOME}/.local/bin:${PATH}" +export PATH="${HOME}/.local/bin:${PATH}" && +if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit 0.1.0" ]]; then + bitbygit --version +else + printf 'Expected bitbygit 0.1.0 on PATH, got %s\n' "${path_output:-no output}" >&2 + false +fi ``` Add `export PATH="$HOME/.local/bin:$PATH"` to `~/.zprofile` (or the startup file @@ -138,7 +150,14 @@ if (($UserPath -split ";") -notcontains $InstallDir) { $NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" } [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") } -$env:Path = "$InstallDir;$env:Path" +if (($env:Path -split ";") -notcontains $InstallDir) { + $env:Path = if ([string]::IsNullOrEmpty($env:Path)) { $InstallDir } else { "$InstallDir;$env:Path" } +} +$PathOutput = bitbygit --version +if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version on PATH, got $PathOutput" +} +bitbygit --version } ``` @@ -189,7 +208,13 @@ if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then exit 1 fi BITBYGIT_INSTALL -export PATH="${HOME}/.local/bin:${PATH}" +export PATH="${HOME}/.local/bin:${PATH}" && +if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit 0.1.0" ]]; then + bitbygit --version +else + printf 'Expected bitbygit 0.1.0 on PATH, got %s\n' "${path_output:-no output}" >&2 + false +fi ``` On Windows, run in PowerShell: @@ -233,7 +258,14 @@ if (($UserPath -split ";") -notcontains $InstallDir) { $NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" } [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") } -$env:Path = "$InstallDir;$env:Path" +if (($env:Path -split ";") -notcontains $InstallDir) { + $env:Path = if ([string]::IsNullOrEmpty($env:Path)) { $InstallDir } else { "$InstallDir;$env:Path" } +} +$PathOutput = bitbygit --version +if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version on PATH, got $PathOutput" +} +bitbygit --version } ``` @@ -245,6 +277,7 @@ Use an archive or source tag after a release is published. ## Verify the installation -Every installation block above finishes by running the installed file directly -with `--version` and requiring `bitbygit `. This avoids -mistaking an older `bitbygit` elsewhere on `PATH` for the binary just installed. +Every installation block above first runs the installed file directly, then +updates `PATH` and finishes with `bitbygit --version`. Both checks require and +report `bitbygit `, so the final check also verifies command +resolution through the documented `PATH` setup. diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index e669cc2..98a22bd 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -43,6 +43,13 @@ $EmptyPathGuard = '$NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $Ins if ([regex]::Matches($DocsText, [regex]::Escape($EmptyPathGuard)).Count -ne 2) { Fail "Windows examples do not handle empty user PATH values consistently" } +$ProcessPathGuard = '$env:Path = if ([string]::IsNullOrEmpty($env:Path)) { $InstallDir } else { "$InstallDir;$env:Path" }' +if ([regex]::Matches($DocsText, [regex]::Escape($ProcessPathGuard)).Count -ne 2) { + Fail "Windows examples do not handle process PATH values consistently" +} +if ([regex]::Matches($DocsText, '(?m)^bitbygit --version\r?$').Count -ne 2) { + Fail "Windows examples do not finish with PATH-resolved version output" +} foreach ($Match in [regex]::Matches($DocsText, '(?ms)^```powershell\r?\n(.*?)^```')) { $Tokens = $null @@ -93,7 +100,7 @@ try { $SuccessScript = Join-Path $SuccessDir "install.ps1" Set-Content -Path $SuccessScript -Value $InstallBlock $env:LOCALAPPDATA = Join-Path $SuccessDir "local-app-data" - $env:Path = $OriginalProcessPath + $env:Path = $null Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue $CapturedUserPath = $null $env:MOCK_DOWNLOAD_DIR = $Assets @@ -114,10 +121,12 @@ try { $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "archive block changed the caller error preference" } - if (($env:Path -split ';')[0] -ne $InstallDir) { Fail "archive block did not update the process PATH" } + if ($env:Path -ne $InstallDir) { Fail "archive block malformed an empty process PATH" } if ($CapturedUserPath -ne $InstallDir) { Fail "archive block malformed an empty user PATH" } if (-not (Test-Path $InstalledBinary)) { Fail "archive block did not install bitbygit.exe" } - $Output = & $InstalledBinary --version + $ResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + if ($ResolvedBinary -ne $InstalledBinary) { Fail "archive block did not resolve the installed command through PATH" } + $Output = bitbygit --version if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { Fail "archive block installed the wrong version" } @@ -202,8 +211,10 @@ try { $SourceScript = Join-Path $SourceSuccessDir "install-from-source.ps1" Set-Content -Path $SourceScript -Value $SourceBlock $env:LOCALAPPDATA = Join-Path $SourceSuccessDir "local-app-data" - $env:Path = $OriginalProcessPath - $env:BITBYGIT_TEST_USER_PATH = $OriginalProcessPath + $SourceInstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" + $SourceStartingPath = "$SourceInstallDir;$OriginalProcessPath" + $env:Path = $SourceStartingPath + $env:BITBYGIT_TEST_USER_PATH = $SourceStartingPath $CapturedUserPath = $null $FetchedExactTag = $false $CheckedOutExactTag = $false @@ -219,16 +230,16 @@ try { } finally { Pop-Location } - $SourceInstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" $SourceInstalledBinary = Join-Path $SourceInstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "source block changed the caller error preference" } - if (($env:Path -split ';')[0] -ne $SourceInstallDir) { Fail "source block did not update the process PATH" } - if ($CapturedUserPath -ne "$OriginalProcessPath;$SourceInstallDir") { - Fail "source block did not append to the existing user PATH" - } + if ($env:Path -ne $SourceStartingPath) { Fail "source block duplicated an existing process PATH entry" } + if (($env:Path -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the process PATH entry" } + if ($null -ne $CapturedUserPath) { Fail "source block duplicated an existing user PATH entry" } if (-not $FetchedExactTag -or -not $CheckedOutExactTag) { Fail "source block did not check out the exact tag" } if (-not (Test-Path $SourceInstalledBinary)) { Fail "source block did not install bitbygit.exe" } - $SourceOutput = & $SourceInstalledBinary --version + $SourceResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + if ($SourceResolvedBinary -ne $SourceInstalledBinary) { Fail "source block did not resolve the installed command through PATH" } + $SourceOutput = bitbygit --version if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $Version") { Fail "source block installed the wrong version" } diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index be124f1..00a2f5c 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -96,6 +96,10 @@ else /usr/bin/uname "$@" fi MOCK +cat > "${tmp}/mock-bin/bitbygit" <<'MOCK' +#!/usr/bin/env sh +printf 'bitbygit 9.9.9\n' +MOCK chmod +x "${tmp}/mock-bin/"* check_unix_success() { @@ -125,6 +129,8 @@ check_unix_success() { [[ "${PATH%%:*}" == "${HOME}/.local/bin" ]] || fail "${heading} did not update the caller PATH" [[ -x "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not install the binary" [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${workspace_version}" ]] || fail "${heading} installed the wrong version" + [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not resolve the installed command through PATH" + [[ "$(bitbygit --version)" == "bitbygit ${workspace_version}" ]] || fail "${heading} resolved the wrong version through PATH" ) } @@ -190,6 +196,8 @@ extract_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" [[ "${docs_text}" == *'git -C $SourceDir checkout --detach $TagCommit'* ]] || fail "PowerShell source build does not detach at the selected tag" [[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" [[ "${docs_text}" == *'& $InstalledBinary --version'* ]] || fail "Windows installation does not validate the installed path" +[[ "$(grep -c '^ bitbygit --version$' "${docs}")" -eq 3 ]] || fail "Unix installations do not finish with PATH-resolved version output" +[[ "$(grep -c '^bitbygit --version$' "${docs}")" -eq 2 ]] || fail "Windows installations do not finish with PATH-resolved version output" source_remote="${tmp}/source-remote.git" source_seed="${tmp}/source-seed" @@ -216,7 +224,8 @@ cat > "${source_dir}/mock-bin/cargo" <<'MOCK' #!/usr/bin/env sh exit 0 MOCK -chmod +x "${source_dir}/mock-bin/cargo" +cp "${tmp}/mock-bin/bitbygit" "${source_dir}/mock-bin/bitbygit" +chmod +x "${source_dir}/mock-bin/"* source_snippet="$(extract_block "Build from source" bash)" source_snippet="${source_snippet/https:\/\/github.com\/cosentinode\/bitbygit.git/${source_remote}}" printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" @@ -229,6 +238,8 @@ printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" branch_commit="$(git --git-dir="${source_remote}" rev-parse "refs/heads/v${workspace_version}")" head_commit="$(git -C bitbygit rev-parse HEAD)" [[ "${head_commit}" == "${tag_commit}" && "${head_commit}" != "${branch_commit}" ]] || fail "Bash source block did not check out the exact tag object" + [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed command through PATH" + [[ "$(bitbygit --version)" == "bitbygit ${workspace_version}" ]] || fail "Bash source block resolved the wrong version through PATH" ) check_links() { From e5c7f32b044bf2f9d1adf07fda7c738d53050f75 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 14:49:10 -0400 Subject: [PATCH 08/21] fix: keep policy evaluation on Rust 1.85 --- crates/bitbygit-core/src/policy.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/bitbygit-core/src/policy.rs b/crates/bitbygit-core/src/policy.rs index 27d5245..faee385 100644 --- a/crates/bitbygit-core/src/policy.rs +++ b/crates/bitbygit-core/src/policy.rs @@ -73,16 +73,17 @@ impl EffectivePolicy { confirmation_label(requirement) )]; - if let Some(branch) = branch - && self.is_protected_branch(branch) - && let Some(minimum) = protected_branch_requirement(operation) - { - requirement = requirement.max(minimum); - reasons.push(format!( - "protected branch {branch}: {} requires at least {} confirmation", - operation.action_label(), - confirmation_label(minimum) - )); + if let Some(branch) = branch { + if self.is_protected_branch(branch) { + if let Some(minimum) = protected_branch_requirement(operation) { + requirement = requirement.max(minimum); + reasons.push(format!( + "protected branch {branch}: {} requires at least {} confirmation", + operation.action_label(), + confirmation_label(minimum) + )); + } + } } PolicyEvaluation { From 09a4d2511a1ab6ad976fe8845c3d8c93760c80cd Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:07:43 -0400 Subject: [PATCH 09/21] fix: validate selected installation version --- docs/installation.md | 18 ++++++------- scripts/test-installation-docs.sh | 43 +++++++++++++++++++------------ 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 59bc81f..9cbe3d0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -38,8 +38,8 @@ coreutils (`sha256sum`, `mkdir`, and `install`) and stop before extraction or installation if any download or checksum check fails: ```bash -bash -euo pipefail <<'BITBYGIT_INSTALL' && VERSION=0.1.0 +VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && TARGET=x86_64-unknown-linux-gnu ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" PACKAGE="bitbygit-${VERSION}-${TARGET}" @@ -59,10 +59,10 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then fi BITBYGIT_INSTALL export PATH="${HOME}/.local/bin:${PATH}" && -if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit 0.1.0" ]]; then +if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then bitbygit --version else - printf 'Expected bitbygit 0.1.0 on PATH, got %s\n' "${path_output:-no output}" >&2 + printf 'Expected bitbygit %s on PATH, got %s\n' "${VERSION}" "${path_output:-no output}" >&2 false fi ``` @@ -78,8 +78,8 @@ macOS. The shell stops before extraction or installation if any download or checksum check fails: ```bash -bash -euo pipefail <<'BITBYGIT_INSTALL' && VERSION=0.1.0 +VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && case "$(uname -m)" in x86_64) TARGET=x86_64-apple-darwin ;; arm64) TARGET=aarch64-apple-darwin ;; @@ -103,10 +103,10 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then fi BITBYGIT_INSTALL export PATH="${HOME}/.local/bin:${PATH}" && -if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit 0.1.0" ]]; then +if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then bitbygit --version else - printf 'Expected bitbygit 0.1.0 on PATH, got %s\n' "${path_output:-no output}" >&2 + printf 'Expected bitbygit %s on PATH, got %s\n' "${VERSION}" "${path_output:-no output}" >&2 false fi ``` @@ -183,8 +183,8 @@ usable only if that release appears on the Releases page. On Linux or macOS, run: ```bash -bash -euo pipefail <<'BITBYGIT_INSTALL' && VERSION=0.1.0 +VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && TAG="refs/tags/v${VERSION}" mkdir bitbygit git -C bitbygit init @@ -209,10 +209,10 @@ if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then fi BITBYGIT_INSTALL export PATH="${HOME}/.local/bin:${PATH}" && -if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit 0.1.0" ]]; then +if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then bitbygit --version else - printf 'Expected bitbygit 0.1.0 on PATH, got %s\n' "${path_output:-no output}" >&2 + printf 'Expected bitbygit %s on PATH, got %s\n' "${VERSION}" "${path_output:-no output}" >&2 false fi ``` diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 00a2f5c..6a17855 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -34,9 +34,18 @@ workspace_version="$(perl -ne ' if ($workspace && /^version = "([^"]+)"$/) { print $1; exit } ' "${root}/Cargo.toml")" [[ -n "${workspace_version}" ]] || fail "workspace version was not found" +selected_version=2.3.4 +[[ "${selected_version}" != "${workspace_version}" && "${selected_version}" != "0.1.0" ]] || fail "selected validator version must differ from the documented example" [[ "${docs_text}" == *"VERSION=${workspace_version}"* ]] || fail "Bash examples do not use workspace version ${workspace_version}" [[ "${docs_text}" == *"\$Version = \"${workspace_version}\""* ]] || fail "PowerShell examples do not use workspace version ${workspace_version}" +extract_selected_block() { + local block + block="$(extract_block "$1" "$2")" + [[ "${block}" == *"VERSION=${workspace_version}"* ]] || fail "missing selected version in $1 $2 block" + printf '%s' "${block/VERSION=${workspace_version}/VERSION=${selected_version}}" +} + release_artifacts=() while IFS= read -r artifact; do release_artifacts+=("${artifact}") @@ -64,9 +73,9 @@ bash -n "${tmp}/snippets.bash" mkdir -p "${tmp}/assets" "${tmp}/mock-bin" for target in x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin; do - package="bitbygit-${workspace_version}-${target}" + package="bitbygit-${selected_version}-${target}" mkdir -p "${tmp}/package/${package}" - printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${workspace_version}" > "${tmp}/package/${package}/bitbygit" + printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${selected_version}" > "${tmp}/package/${package}/bitbygit" chmod +x "${tmp}/package/${package}/bitbygit" tar -C "${tmp}/package" -czf "${tmp}/assets/${package}.tar.gz" "${package}" rm -rf "${tmp}/package/${package}" @@ -108,7 +117,7 @@ check_unix_success() { local machine="$3" local case_dir="${tmp}/success-${target}" mkdir -p "${case_dir}/home" - extract_block "${heading}" bash > "${case_dir}/snippet.bash" + extract_selected_block "${heading}" bash > "${case_dir}/snippet.bash" [[ -s "${case_dir}/snippet.bash" ]] || fail "missing ${heading} Bash block" ( @@ -128,9 +137,9 @@ check_unix_success() { fi [[ "${PATH%%:*}" == "${HOME}/.local/bin" ]] || fail "${heading} did not update the caller PATH" [[ -x "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not install the binary" - [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${workspace_version}" ]] || fail "${heading} installed the wrong version" + [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} installed the wrong version" [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not resolve the installed command through PATH" - [[ "$(bitbygit --version)" == "bitbygit ${workspace_version}" ]] || fail "${heading} resolved the wrong version through PATH" + [[ "$(bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} resolved the wrong version through PATH" ) } @@ -153,8 +162,8 @@ case "${host_os}" in esac mkdir -p "${tmp}/failure-assets" "${tmp}/failure-bin" -cp "${tmp}/assets/bitbygit-${workspace_version}-${failure_target}.tar.gz" "${tmp}/failure-assets/" -printf '%064d bitbygit-%s-%s.tar.gz\n' 0 "${workspace_version}" "${failure_target}" > "${tmp}/failure-assets/SHA256SUMS" +cp "${tmp}/assets/bitbygit-${selected_version}-${failure_target}.tar.gz" "${tmp}/failure-assets/" +printf '%064d bitbygit-%s-%s.tar.gz\n' 0 "${selected_version}" "${failure_target}" > "${tmp}/failure-assets/SHA256SUMS" cp "${tmp}/mock-bin/curl" "${tmp}/failure-bin/curl" cp "${tmp}/mock-bin/uname" "${tmp}/failure-bin/uname" for command in tar install; do @@ -167,7 +176,7 @@ chmod +x "${tmp}/failure-bin/"* failure_dir="${tmp}/failure-${failure_target}" mkdir -p "${failure_dir}/home" -extract_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" +extract_selected_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" ( cd "${failure_dir}" set +e @@ -206,17 +215,17 @@ git init --quiet "${source_seed}" git -C "${source_seed}" config user.email validator@example.invalid git -C "${source_seed}" config user.name "Installation validator" mkdir -p "${source_seed}/target/release" -printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${workspace_version}" > "${source_seed}/target/release/bitbygit" +printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${selected_version}" > "${source_seed}/target/release/bitbygit" chmod +x "${source_seed}/target/release/bitbygit" git -C "${source_seed}" add -f target/release/bitbygit git -C "${source_seed}" commit --quiet -m "tagged source" -git -C "${source_seed}" tag -a "v${workspace_version}" -m "release ${workspace_version}" +git -C "${source_seed}" tag -a "v${selected_version}" -m "release ${selected_version}" printf '#!/usr/bin/env sh\nprintf '\''bitbygit 9.9.9\\n'\''\n' > "${source_seed}/target/release/bitbygit" git -C "${source_seed}" commit --quiet -am "same-named branch" -git -C "${source_seed}" branch "v${workspace_version}" +git -C "${source_seed}" branch "v${selected_version}" git -C "${source_seed}" push --quiet "${source_remote}" \ - "refs/heads/v${workspace_version}:refs/heads/v${workspace_version}" \ - "refs/tags/v${workspace_version}:refs/tags/v${workspace_version}" + "refs/heads/v${selected_version}:refs/heads/v${selected_version}" \ + "refs/tags/v${selected_version}:refs/tags/v${selected_version}" source_dir="${tmp}/source-install" mkdir -p "${source_dir}/home" "${source_dir}/mock-bin" @@ -226,7 +235,7 @@ exit 0 MOCK cp "${tmp}/mock-bin/bitbygit" "${source_dir}/mock-bin/bitbygit" chmod +x "${source_dir}/mock-bin/"* -source_snippet="$(extract_block "Build from source" bash)" +source_snippet="$(extract_selected_block "Build from source" bash)" source_snippet="${source_snippet/https:\/\/github.com\/cosentinode\/bitbygit.git/${source_remote}}" printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" ( @@ -234,12 +243,12 @@ printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" export HOME="${source_dir}/home" export PATH="${source_dir}/mock-bin:${PATH}" source "${source_dir}/snippet.bash" || fail "Bash source block failed with colliding branch and tag names" - tag_commit="$(git --git-dir="${source_remote}" rev-parse "refs/tags/v${workspace_version}^{commit}")" - branch_commit="$(git --git-dir="${source_remote}" rev-parse "refs/heads/v${workspace_version}")" + tag_commit="$(git --git-dir="${source_remote}" rev-parse "refs/tags/v${selected_version}^{commit}")" + branch_commit="$(git --git-dir="${source_remote}" rev-parse "refs/heads/v${selected_version}")" head_commit="$(git -C bitbygit rev-parse HEAD)" [[ "${head_commit}" == "${tag_commit}" && "${head_commit}" != "${branch_commit}" ]] || fail "Bash source block did not check out the exact tag object" [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed command through PATH" - [[ "$(bitbygit --version)" == "bitbygit ${workspace_version}" ]] || fail "Bash source block resolved the wrong version through PATH" + [[ "$(bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "Bash source block resolved the wrong version through PATH" ) check_links() { From f106e93a6ae2a8344c6160c1eacf0e92d6af5dcd Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:27:09 -0400 Subject: [PATCH 10/21] fix: decouple Windows version validation --- scripts/test-installation-docs.ps1 | 55 +++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 98a22bd..76e1095 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -31,13 +31,27 @@ function ConvertTo-IsolatedPathBlock([string] $Block) { return $Isolated } +function ConvertTo-SelectedVersionBlock([string] $Block, [string] $Heading) { + $DocumentedAssignment = '$Version = "{0}"' -f $script:WorkspaceVersion + if (-not $Block.Contains($DocumentedAssignment)) { + Fail "missing selected version in $Heading powershell block" + } + + $SelectedAssignment = '$Version = "{0}"' -f $script:SelectedVersion + return $Block.Replace($DocumentedAssignment, $SelectedAssignment) +} + $DocsText = Get-Content -Raw $DocsPath $CargoText = Get-Content -Raw $CargoPath $VersionMatch = [regex]::Match($CargoText, '(?ms)^\[workspace\.package\]\r?\n.*?^version = "([^"]+)"') if (-not $VersionMatch.Success) { Fail "workspace version was not found" } -$Version = $VersionMatch.Groups[1].Value -if (-not $DocsText.Contains("`$Version = `"$Version`"")) { - Fail "PowerShell examples do not use workspace version $Version" +$WorkspaceVersion = $VersionMatch.Groups[1].Value +$SelectedVersion = "2.3.4" +if ($SelectedVersion -eq $WorkspaceVersion -or $SelectedVersion -eq "0.1.0") { + Fail "selected validator version must differ from the documented example" +} +if (-not $DocsText.Contains("`$Version = `"$WorkspaceVersion`"")) { + Fail "PowerShell examples do not use workspace version $WorkspaceVersion" } $EmptyPathGuard = '$NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" }' if ([regex]::Matches($DocsText, [regex]::Escape($EmptyPathGuard)).Count -ne 2) { @@ -62,14 +76,18 @@ foreach ($Match in [regex]::Matches($DocsText, '(?ms)^```powershell\r?\n(.*?)^`` if ($Errors.Count -ne 0) { Fail "PowerShell snippet has syntax errors: $($Errors -join '; ')" } } -$InstallBlock = ConvertTo-IsolatedPathBlock (Get-Block "Windows x86-64 archive" "powershell") +$InstallBlock = Get-Block "Windows x86-64 archive" "powershell" +$InstallBlock = ConvertTo-SelectedVersionBlock $InstallBlock "Windows x86-64 archive" +$InstallBlock = ConvertTo-IsolatedPathBlock $InstallBlock if (-not $InstallBlock.Contains('$Package = "bitbygit-$Version-$Target"')) { Fail "Windows package directory does not match the release layout" } if (-not $InstallBlock.Contains('& $InstalledBinary --version')) { Fail "Windows installation does not validate the installed path" } -$SourceBlock = ConvertTo-IsolatedPathBlock (Get-Block "Build from source" "powershell") +$SourceBlock = Get-Block "Build from source" "powershell" +$SourceBlock = ConvertTo-SelectedVersionBlock $SourceBlock "Build from source" +$SourceBlock = ConvertTo-IsolatedPathBlock $SourceBlock if (-not $SourceBlock.Contains('git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"') -or -not $SourceBlock.Contains('git -C $SourceDir checkout --detach $TagCommit') -or -not $SourceBlock.Contains('$HeadCommit -ne $TagCommit')) { @@ -84,13 +102,26 @@ try { cargo build --locked --release -p bitbygit if ($LASTEXITCODE -ne 0) { Fail "cargo build failed" } + $WorkspaceBinary = Join-Path $Root "target/release/bitbygit.exe" + $WorkspaceOutput = & $WorkspaceBinary --version + if ($LASTEXITCODE -ne 0 -or $WorkspaceOutput -ne "bitbygit $WorkspaceVersion") { + Fail "workspace binary reported the wrong version" + } + + $FixtureSource = Join-Path $Temp "selected-version-fixture.rs" + $FixtureBinary = Join-Path $Temp "selected-version-bitbygit.exe" + $FixtureSourceText = 'fn main() {{ println!("bitbygit {0}"); }}' -f $SelectedVersion + Set-Content -Path $FixtureSource -Value $FixtureSourceText + rustc --crate-name selected_version_fixture $FixtureSource -o $FixtureBinary + if ($LASTEXITCODE -ne 0) { Fail "selected-version fixture build failed" } + $Target = "x86_64-pc-windows-msvc" - $Archive = "bitbygit-$Version-$Target.zip" - $Package = "bitbygit-$Version-$Target" + $Archive = "bitbygit-$SelectedVersion-$Target.zip" + $Package = "bitbygit-$SelectedVersion-$Target" $Assets = Join-Path $Temp "assets" $PackageDir = Join-Path $Assets $Package New-Item -ItemType Directory -Path $PackageDir | Out-Null - Copy-Item (Join-Path $Root "target/release/bitbygit.exe") $PackageDir + Copy-Item $FixtureBinary (Join-Path $PackageDir "bitbygit.exe") Compress-Archive -Path $PackageDir -DestinationPath (Join-Path $Assets $Archive) $Digest = (Get-FileHash -Algorithm SHA256 (Join-Path $Assets $Archive)).Hash Set-Content -Path (Join-Path $Assets "SHA256SUMS") -Value "$Digest $Archive" @@ -127,7 +158,7 @@ try { $ResolvedBinary = (Get-Command bitbygit -CommandType Application).Source if ($ResolvedBinary -ne $InstalledBinary) { Fail "archive block did not resolve the installed command through PATH" } $Output = bitbygit --version - if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { + if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $SelectedVersion") { Fail "archive block installed the wrong version" } @@ -168,12 +199,12 @@ try { function git { param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Arguments) - $ExpectedTag = "refs/tags/v$Version" + $ExpectedTag = "refs/tags/v$SelectedVersion" if ($Arguments.Count -eq 3 -and $Arguments[0] -eq "-C" -and $Arguments[2] -eq "init") { $BuildDir = Join-Path $Arguments[1] "target/release" New-Item -ItemType Directory -Path $BuildDir | Out-Null - Copy-Item (Join-Path $Root "target/release/bitbygit.exe") $BuildDir + Copy-Item $FixtureBinary (Join-Path $BuildDir "bitbygit.exe") } elseif ($Arguments.Count -eq 7 -and $Arguments[0] -eq "-C" -and $Arguments[2] -eq "fetch" -and $Arguments[3] -eq "--depth" -and $Arguments[4] -eq "1" -and @@ -240,7 +271,7 @@ try { $SourceResolvedBinary = (Get-Command bitbygit -CommandType Application).Source if ($SourceResolvedBinary -ne $SourceInstalledBinary) { Fail "source block did not resolve the installed command through PATH" } $SourceOutput = bitbygit --version - if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $Version") { + if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $SelectedVersion") { Fail "source block installed the wrong version" } From e565b4b52367965f97bfd1e4a62dea9b9e4ddc93 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:48:12 -0400 Subject: [PATCH 11/21] fix: prioritize Windows installation path --- docs/installation.md | 32 +++++++++++++++----------- scripts/test-installation-docs.ps1 | 37 ++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 9cbe3d0..f9e25dd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -146,13 +146,15 @@ if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $Output" } $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") -if (($UserPath -split ";") -notcontains $InstallDir) { - $NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" } - [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") -} -if (($env:Path -split ";") -notcontains $InstallDir) { - $env:Path = if ([string]::IsNullOrEmpty($env:Path)) { $InstallDir } else { "$InstallDir;$env:Path" } -} +$UserPathEntries = @($UserPath -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";" +[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") +$ProcessPathEntries = @($env:Path -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" $PathOutput = bitbygit --version if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version on PATH, got $PathOutput" @@ -254,13 +256,15 @@ if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $InstalledOutput" } $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") -if (($UserPath -split ";") -notcontains $InstallDir) { - $NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" } - [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") -} -if (($env:Path -split ";") -notcontains $InstallDir) { - $env:Path = if ([string]::IsNullOrEmpty($env:Path)) { $InstallDir } else { "$InstallDir;$env:Path" } -} +$UserPathEntries = @($UserPath -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";" +[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") +$ProcessPathEntries = @($env:Path -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir +}) +$env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" $PathOutput = bitbygit --version if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version on PATH, got $PathOutput" diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 76e1095..0500bed 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -53,13 +53,17 @@ if ($SelectedVersion -eq $WorkspaceVersion -or $SelectedVersion -eq "0.1.0") { if (-not $DocsText.Contains("`$Version = `"$WorkspaceVersion`"")) { Fail "PowerShell examples do not use workspace version $WorkspaceVersion" } -$EmptyPathGuard = '$NewUserPath = if ([string]::IsNullOrEmpty($UserPath)) { $InstallDir } else { "$UserPath;$InstallDir" }' -if ([regex]::Matches($DocsText, [regex]::Escape($EmptyPathGuard)).Count -ne 2) { - Fail "Windows examples do not handle empty user PATH values consistently" +$UserPathOrder = '$NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";"' +if ([regex]::Matches($DocsText, [regex]::Escape($UserPathOrder)).Count -ne 2) { + Fail "Windows examples do not prioritize the install directory in user PATH" } -$ProcessPathGuard = '$env:Path = if ([string]::IsNullOrEmpty($env:Path)) { $InstallDir } else { "$InstallDir;$env:Path" }' -if ([regex]::Matches($DocsText, [regex]::Escape($ProcessPathGuard)).Count -ne 2) { - Fail "Windows examples do not handle process PATH values consistently" +$ProcessPathOrder = '$env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";"' +if ([regex]::Matches($DocsText, [regex]::Escape($ProcessPathOrder)).Count -ne 2) { + Fail "Windows examples do not prioritize the install directory in process PATH" +} +$PathEntryFilter = '-not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir' +if ([regex]::Matches($DocsText, [regex]::Escape($PathEntryFilter)).Count -ne 4) { + Fail "Windows examples do not remove empty and duplicate install directory PATH entries" } if ([regex]::Matches($DocsText, '(?m)^bitbygit --version\r?$').Count -ne 2) { Fail "Windows examples do not finish with PATH-resolved version output" @@ -243,7 +247,14 @@ try { Set-Content -Path $SourceScript -Value $SourceBlock $env:LOCALAPPDATA = Join-Path $SourceSuccessDir "local-app-data" $SourceInstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" - $SourceStartingPath = "$SourceInstallDir;$OriginalProcessPath" + $OldBinaryDir = Join-Path $SourceSuccessDir "old-bin" + New-Item -ItemType Directory -Path $OldBinaryDir | Out-Null + Copy-Item $WorkspaceBinary (Join-Path $OldBinaryDir "bitbygit.exe") + $SourceStartingPath = "$OldBinaryDir;$OriginalProcessPath;$SourceInstallDir;$SourceInstallDir" + $SourceExpectedEntries = @($OldBinaryDir) + @($OriginalProcessPath -split ";" | Where-Object { + -not [string]::IsNullOrEmpty($_) + }) + $SourceExpectedPath = (@($SourceInstallDir) + $SourceExpectedEntries) -join ";" $env:Path = $SourceStartingPath $env:BITBYGIT_TEST_USER_PATH = $SourceStartingPath $CapturedUserPath = $null @@ -263,9 +274,10 @@ try { } $SourceInstalledBinary = Join-Path $SourceInstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "source block changed the caller error preference" } - if ($env:Path -ne $SourceStartingPath) { Fail "source block duplicated an existing process PATH entry" } + if ($env:Path -ne $SourceExpectedPath) { Fail "source block did not prioritize and deduplicate the process PATH entry" } if (($env:Path -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the process PATH entry" } - if ($null -ne $CapturedUserPath) { Fail "source block duplicated an existing user PATH entry" } + if ($CapturedUserPath -ne $SourceExpectedPath) { Fail "source block did not prioritize and deduplicate the user PATH entry" } + if (($CapturedUserPath -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the user PATH entry" } if (-not $FetchedExactTag -or -not $CheckedOutExactTag) { Fail "source block did not check out the exact tag" } if (-not (Test-Path $SourceInstalledBinary)) { Fail "source block did not install bitbygit.exe" } $SourceResolvedBinary = (Get-Command bitbygit -CommandType Application).Source @@ -274,6 +286,13 @@ try { if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $SelectedVersion") { Fail "source block installed the wrong version" } + $env:Path = $CapturedUserPath + $NewShellResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + if ($NewShellResolvedBinary -ne $SourceInstalledBinary) { Fail "new shell PATH resolved the older bitbygit.exe" } + $NewShellOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $NewShellOutput -ne "bitbygit $SelectedVersion") { + Fail "new shell PATH resolved the wrong bitbygit version" + } $SourceFailureDir = Join-Path $Temp "source-failure" New-Item -ItemType Directory -Path $SourceFailureDir | Out-Null From 3f0d40c8c3782c44aaf037c595890a9ead9aa318 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 15:50:49 -0400 Subject: [PATCH 12/21] test: assert Windows path precedence --- scripts/test-installation-docs.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 0500bed..273ff84 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -257,6 +257,12 @@ try { $SourceExpectedPath = (@($SourceInstallDir) + $SourceExpectedEntries) -join ";" $env:Path = $SourceStartingPath $env:BITBYGIT_TEST_USER_PATH = $SourceStartingPath + $OldResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source + if ($OldResolvedBinary -ne (Join-Path $OldBinaryDir "bitbygit.exe")) { Fail "older bitbygit.exe fixture was not first on PATH" } + $OldOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $OldOutput -ne "bitbygit $WorkspaceVersion") { + Fail "older bitbygit.exe fixture reported the wrong version" + } $CapturedUserPath = $null $FetchedExactTag = $false $CheckedOutExactTag = $false @@ -280,14 +286,14 @@ try { if (($CapturedUserPath -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the user PATH entry" } if (-not $FetchedExactTag -or -not $CheckedOutExactTag) { Fail "source block did not check out the exact tag" } if (-not (Test-Path $SourceInstalledBinary)) { Fail "source block did not install bitbygit.exe" } - $SourceResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + $SourceResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source if ($SourceResolvedBinary -ne $SourceInstalledBinary) { Fail "source block did not resolve the installed command through PATH" } $SourceOutput = bitbygit --version if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $SelectedVersion") { Fail "source block installed the wrong version" } $env:Path = $CapturedUserPath - $NewShellResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + $NewShellResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source if ($NewShellResolvedBinary -ne $SourceInstalledBinary) { Fail "new shell PATH resolved the older bitbygit.exe" } $NewShellOutput = bitbygit --version if ($LASTEXITCODE -ne 0 -or $NewShellOutput -ne "bitbygit $SelectedVersion") { From 5f17c6b51bc7e11981df7c90838f13f2db4bc3c9 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 16:11:23 -0400 Subject: [PATCH 13/21] fix: guard Windows machine path precedence --- docs/installation.md | 33 +++++++++++- scripts/test-installation-docs.ps1 | 81 +++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index f9e25dd..7c13335 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -145,6 +145,16 @@ $Output = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $Output" } +$MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") +$MachineCommand = $MachinePath -split ";" | ForEach-Object { + $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) + if (-not [string]::IsNullOrWhiteSpace($Entry)) { + Join-Path $Entry "bitbygit.exe" + } +} | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 +if ($MachineCommand) { + throw "Machine PATH already contains $MachineCommand. User PATH cannot override it in new shells. Remove or update that machine-level installation, then rerun; until then invoke $InstalledBinary explicitly." +} $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") $UserPathEntries = @($UserPath -split ";" | Where-Object { -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir @@ -163,7 +173,18 @@ bitbygit --version } ``` -New shells will use the updated user `PATH`. +Windows places machine `PATH` entries before user entries in new processes. The +block therefore refuses to update user `PATH` when a machine entry already +contains `bitbygit.exe`; otherwise an older machine-level installation could +silently win after restarting PowerShell. Remove or update that machine-level +installation and rerun the block. The selected file is still available through +the unambiguous explicit invocation: + +```powershell +& "$env:LOCALAPPDATA\Programs\bitbygit\bitbygit.exe" --version +``` + +When no machine-level conflict exists, new shells use the updated user `PATH`. ## Build from source @@ -255,6 +276,16 @@ $InstalledOutput = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $InstalledOutput" } +$MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") +$MachineCommand = $MachinePath -split ";" | ForEach-Object { + $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) + if (-not [string]::IsNullOrWhiteSpace($Entry)) { + Join-Path $Entry "bitbygit.exe" + } +} | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 +if ($MachineCommand) { + throw "Machine PATH already contains $MachineCommand. User PATH cannot override it in new shells. Remove or update that machine-level installation, then rerun; until then invoke $InstalledBinary explicitly." +} $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") $UserPathEntries = @($UserPath -split ";" | Where-Object { -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 273ff84..8f2c66c 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -18,15 +18,18 @@ function Get-Block([string] $Heading, [string] $Language) { function ConvertTo-IsolatedPathBlock([string] $Block) { $GetUserPath = 'GetEnvironmentVariable("Path", "User")' + $GetMachinePath = 'GetEnvironmentVariable("Path", "Machine")' $SetUserPath = '[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")' - if (-not $Block.Contains($GetUserPath) -or -not $Block.Contains($SetUserPath)) { - Fail "Windows block does not contain the expected user PATH operations" + if (-not $Block.Contains($GetUserPath) -or -not $Block.Contains($GetMachinePath) -or + -not $Block.Contains($SetUserPath)) { + Fail "Windows block does not contain the expected PATH operations" } $Isolated = $Block.Replace($GetUserPath, 'GetEnvironmentVariable("BITBYGIT_TEST_USER_PATH", "Process")') + $Isolated = $Isolated.Replace($GetMachinePath, 'GetEnvironmentVariable("BITBYGIT_TEST_MACHINE_PATH", "Process")') $Isolated = $Isolated.Replace($SetUserPath, '$script:CapturedUserPath = $NewUserPath') - if ($Isolated.Contains('"User"')) { - Fail "Windows block still accesses persistent user PATH" + if ($Isolated.Contains('"User"') -or $Isolated.Contains('"Machine"')) { + Fail "Windows block still accesses persistent PATH state" } return $Isolated } @@ -65,6 +68,11 @@ $PathEntryFilter = '-not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir' if ([regex]::Matches($DocsText, [regex]::Escape($PathEntryFilter)).Count -ne 4) { Fail "Windows examples do not remove empty and duplicate install directory PATH entries" } +$MachinePathLookup = 'GetEnvironmentVariable("Path", "Machine")' +if ([regex]::Matches($DocsText, [regex]::Escape($MachinePathLookup)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape('User PATH cannot override it in new shells')).Count -ne 2) { + Fail "Windows examples do not reject machine-level PATH shadowing" +} if ([regex]::Matches($DocsText, '(?m)^bitbygit --version\r?$').Count -ne 2) { Fail "Windows examples do not finish with PATH-resolved version output" } @@ -124,7 +132,9 @@ try { $Package = "bitbygit-$SelectedVersion-$Target" $Assets = Join-Path $Temp "assets" $PackageDir = Join-Path $Assets $Package + $MachinePathDir = Join-Path $Temp "machine-bin" New-Item -ItemType Directory -Path $PackageDir | Out-Null + New-Item -ItemType Directory -Path $MachinePathDir | Out-Null Copy-Item $FixtureBinary (Join-Path $PackageDir "bitbygit.exe") Compress-Archive -Path $PackageDir -DestinationPath (Join-Path $Assets $Archive) $Digest = (Get-FileHash -Algorithm SHA256 (Join-Path $Assets $Archive)).Hash @@ -137,6 +147,7 @@ try { $env:LOCALAPPDATA = Join-Path $SuccessDir "local-app-data" $env:Path = $null Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir $CapturedUserPath = $null $env:MOCK_DOWNLOAD_DIR = $Assets $ErrorActionPreference = "Continue" @@ -165,6 +176,50 @@ try { if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $SelectedVersion") { Fail "archive block installed the wrong version" } + $env:Path = "$($env:BITBYGIT_TEST_MACHINE_PATH);$CapturedUserPath" + $NewShellResolvedBinary = (Get-Command bitbygit -CommandType Application).Source + if ($NewShellResolvedBinary -ne $InstalledBinary) { Fail "archive new shell PATH did not resolve the installed command" } + $NewShellOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $NewShellOutput -ne "bitbygit $SelectedVersion") { + Fail "archive new shell PATH resolved the wrong version" + } + + $MachineConflictDir = Join-Path $Temp "machine-conflict" + $MachineOldBinaryDir = Join-Path $MachineConflictDir "machine-bin" + New-Item -ItemType Directory -Path $MachineOldBinaryDir -Force | Out-Null + Copy-Item $WorkspaceBinary (Join-Path $MachineOldBinaryDir "bitbygit.exe") + $MachineConflictScript = Join-Path $MachineConflictDir "install.ps1" + Set-Content -Path $MachineConflictScript -Value $InstallBlock + $env:LOCALAPPDATA = Join-Path $MachineConflictDir "local-app-data" + $env:Path = "$MachineOldBinaryDir;$OriginalProcessPath" + $env:BITBYGIT_TEST_MACHINE_PATH = $MachineOldBinaryDir + Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue + $CapturedUserPath = $null + $env:MOCK_DOWNLOAD_DIR = $Assets + $MachineConflictStartingPath = $env:Path + $OldMachineOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $OldMachineOutput -ne "bitbygit $WorkspaceVersion") { + Fail "older machine-level bitbygit.exe fixture reported the wrong version" + } + $MachineConflictFailed = $false + Push-Location $MachineConflictDir + try { + try { + . $MachineConflictScript + } catch { + $MachineConflictFailed = $_.Exception.Message.Contains("User PATH cannot override it in new shells") + } + } finally { + Pop-Location + } + if (-not $MachineConflictFailed) { Fail "archive block did not reject machine-level PATH shadowing" } + if ($null -ne $CapturedUserPath) { Fail "machine-level conflict changed persistent user PATH" } + if ($env:Path -ne $MachineConflictStartingPath) { Fail "machine-level conflict changed process PATH" } + $ConflictInstalledBinary = Join-Path $env:LOCALAPPDATA "Programs\bitbygit\bitbygit.exe" + $ConflictOutput = & $ConflictInstalledBinary --version + if ($LASTEXITCODE -ne 0 -or $ConflictOutput -ne "bitbygit $SelectedVersion") { + Fail "machine-level conflict did not leave the selected binary available by explicit path" + } $FailureAssets = Join-Path $Temp "failure-assets" $FailureDir = Join-Path $Temp "failure" @@ -175,6 +230,7 @@ try { Set-Content -Path $FailureScript -Value $InstallBlock $env:LOCALAPPDATA = Join-Path $FailureDir "local-app-data" $env:Path = $OriginalProcessPath + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir $env:MOCK_DOWNLOAD_DIR = $FailureAssets $env:MOCK_SIDE_EFFECT = Join-Path $FailureDir "expanded" $ErrorActionPreference = "Continue" @@ -250,13 +306,13 @@ try { $OldBinaryDir = Join-Path $SourceSuccessDir "old-bin" New-Item -ItemType Directory -Path $OldBinaryDir | Out-Null Copy-Item $WorkspaceBinary (Join-Path $OldBinaryDir "bitbygit.exe") - $SourceStartingPath = "$OldBinaryDir;$OriginalProcessPath;$SourceInstallDir;$SourceInstallDir" - $SourceExpectedEntries = @($OldBinaryDir) + @($OriginalProcessPath -split ";" | Where-Object { - -not [string]::IsNullOrEmpty($_) - }) - $SourceExpectedPath = (@($SourceInstallDir) + $SourceExpectedEntries) -join ";" + $SourceUserPath = "$OldBinaryDir;$SourceInstallDir;$SourceInstallDir" + $SourceStartingPath = "$MachinePathDir;$SourceUserPath" + $SourceExpectedPath = "$SourceInstallDir;$MachinePathDir;$OldBinaryDir" + $SourceExpectedUserPath = "$SourceInstallDir;$OldBinaryDir" $env:Path = $SourceStartingPath - $env:BITBYGIT_TEST_USER_PATH = $SourceStartingPath + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir + $env:BITBYGIT_TEST_USER_PATH = $SourceUserPath $OldResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source if ($OldResolvedBinary -ne (Join-Path $OldBinaryDir "bitbygit.exe")) { Fail "older bitbygit.exe fixture was not first on PATH" } $OldOutput = bitbygit --version @@ -282,7 +338,7 @@ try { if ($ErrorActionPreference -ne "Continue") { Fail "source block changed the caller error preference" } if ($env:Path -ne $SourceExpectedPath) { Fail "source block did not prioritize and deduplicate the process PATH entry" } if (($env:Path -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the process PATH entry" } - if ($CapturedUserPath -ne $SourceExpectedPath) { Fail "source block did not prioritize and deduplicate the user PATH entry" } + if ($CapturedUserPath -ne $SourceExpectedUserPath) { Fail "source block did not prioritize and deduplicate the user PATH entry" } if (($CapturedUserPath -split ';' | Where-Object { $_ -eq $SourceInstallDir }).Count -ne 1) { Fail "source block duplicated the user PATH entry" } if (-not $FetchedExactTag -or -not $CheckedOutExactTag) { Fail "source block did not check out the exact tag" } if (-not (Test-Path $SourceInstalledBinary)) { Fail "source block did not install bitbygit.exe" } @@ -292,7 +348,7 @@ try { if ($LASTEXITCODE -ne 0 -or $SourceOutput -ne "bitbygit $SelectedVersion") { Fail "source block installed the wrong version" } - $env:Path = $CapturedUserPath + $env:Path = "$($env:BITBYGIT_TEST_MACHINE_PATH);$CapturedUserPath" $NewShellResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source if ($NewShellResolvedBinary -ne $SourceInstalledBinary) { Fail "new shell PATH resolved the older bitbygit.exe" } $NewShellOutput = bitbygit --version @@ -306,6 +362,7 @@ try { Set-Content -Path $SourceFailureScript -Value $SourceBlock $env:LOCALAPPDATA = Join-Path $SourceFailureDir "local-app-data" $env:Path = $OriginalProcessPath + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir $FetchedExactTag = $false $CheckedOutExactTag = $false $env:MOCK_CARGO_FAILURE = "1" From 41e1a75ded08ee23bf7fc2687bb191efd657879b Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 16:37:37 -0400 Subject: [PATCH 14/21] fix: isolate installation working files --- docs/installation.md | 76 ++++++++++++++----- scripts/test-installation-docs.ps1 | 116 +++++++++++++++++++++++++++-- scripts/test-installation-docs.sh | 65 +++++++++++++--- 3 files changed, 221 insertions(+), 36 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 7c13335..c0fcfcf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -44,6 +44,15 @@ TARGET=x86_64-unknown-linux-gnu ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" PACKAGE="bitbygit-${VERSION}-${TARGET}" BASE_URL="https://github.com/cosentinode/bitbygit/releases/download/v${VERSION}" +WORK_DIR="$(mktemp -d)" +cleanup() { + status=$? + trap - EXIT + rm -rf -- "${WORK_DIR}" + exit "${status}" +} +trap cleanup EXIT +cd "${WORK_DIR}" curl -fLO "${BASE_URL}/${ARCHIVE}" curl -fLO "${BASE_URL}/SHA256SUMS" @@ -88,6 +97,15 @@ esac ARCHIVE="bitbygit-${VERSION}-${TARGET}.tar.gz" PACKAGE="bitbygit-${VERSION}-${TARGET}" BASE_URL="https://github.com/cosentinode/bitbygit/releases/download/v${VERSION}" +WORK_DIR="$(mktemp -d)" +cleanup() { + status=$? + trap - EXIT + rm -rf -- "${WORK_DIR}" + exit "${status}" +} +trap cleanup EXIT +cd "${WORK_DIR}" curl -fLO "${BASE_URL}/${ARCHIVE}" curl -fLO "${BASE_URL}/SHA256SUMS" @@ -127,19 +145,23 @@ $Target = "x86_64-pc-windows-msvc" $Archive = "bitbygit-$Version-$Target.zip" $Package = "bitbygit-$Version-$Target" $BaseUrl = "https://github.com/cosentinode/bitbygit/releases/download/v$Version" - -Invoke-WebRequest -Uri "$BaseUrl/$Archive" -OutFile $Archive -Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS" -OutFile SHA256SUMS -$ExpectedLine = Get-Content SHA256SUMS | Where-Object { $_.EndsWith(" $Archive") } +$WorkDir = (New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()))).FullName + +try { +$ArchivePath = Join-Path $WorkDir $Archive +$ChecksumsPath = Join-Path $WorkDir "SHA256SUMS" +Invoke-WebRequest -Uri "$BaseUrl/$Archive" -OutFile $ArchivePath +Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS" -OutFile $ChecksumsPath +$ExpectedLine = Get-Content $ChecksumsPath | Where-Object { $_.EndsWith(" $Archive") } if (-not $ExpectedLine) { throw "No checksum found for $Archive" } $Expected = ($ExpectedLine -split '\s+')[0] -$Actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash +$Actual = (Get-FileHash -Algorithm SHA256 $ArchivePath).Hash if ($Actual -ne $Expected) { throw "Checksum verification failed for $Archive" } -Expand-Archive -Path $Archive -DestinationPath . +Expand-Archive -Path $ArchivePath -DestinationPath $WorkDir $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -Copy-Item "$Package\bitbygit.exe" $InstallDir +Copy-Item (Join-Path $WorkDir "$Package\bitbygit.exe") $InstallDir $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" $Output = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { @@ -170,6 +192,9 @@ if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version on PATH, got $PathOutput" } bitbygit --version +} finally { + Remove-Item -LiteralPath $WorkDir -Recurse -Force +} } ``` @@ -209,22 +234,31 @@ On Linux or macOS, run: VERSION=0.1.0 VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && TAG="refs/tags/v${VERSION}" -mkdir bitbygit -git -C bitbygit init -git -C bitbygit fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}" -tag_commit="$(git -C bitbygit rev-parse --verify "${TAG}^{commit}")" -git -C bitbygit checkout --detach "${tag_commit}" -[[ "$(git -C bitbygit rev-parse --verify HEAD)" == "${tag_commit}" ]] -cd bitbygit -cargo build --locked --release -p bitbygit -built_output="$(target/release/bitbygit --version)" +WORK_DIR="$(mktemp -d)" +SOURCE_DIR="${WORK_DIR}/bitbygit" +cleanup() { + status=$? + trap - EXIT + rm -rf -- "${WORK_DIR}" + exit "${status}" +} +trap cleanup EXIT + +mkdir "${SOURCE_DIR}" +git -C "${SOURCE_DIR}" init +git -C "${SOURCE_DIR}" fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}" +tag_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify "${TAG}^{commit}")" +git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}" +[[ "$(git -C "${SOURCE_DIR}" rev-parse --verify HEAD)" == "${tag_commit}" ]] +cargo build --manifest-path "${SOURCE_DIR}/Cargo.toml" --locked --release -p bitbygit +built_output="$("${SOURCE_DIR}/target/release/bitbygit" --version)" if [[ "${built_output}" != "bitbygit ${VERSION}" ]]; then printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${built_output}" >&2 exit 1 fi mkdir -p "${HOME}/.local/bin" -install -m 0755 target/release/bitbygit "${HOME}/.local/bin/bitbygit" +install -m 0755 "${SOURCE_DIR}/target/release/bitbygit" "${HOME}/.local/bin/bitbygit" installed_output="$("${HOME}/.local/bin/bitbygit" --version)" if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${installed_output}" >&2 @@ -248,7 +282,10 @@ $ErrorActionPreference = "Stop" $Version = "0.1.0" $Tag = "refs/tags/v$Version" -$SourceDir = Join-Path (Get-Location) "bitbygit" +$WorkDir = (New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()))).FullName +$SourceDir = Join-Path $WorkDir "bitbygit" + +try { New-Item -ItemType Directory -Path $SourceDir | Out-Null git -C $SourceDir init if ($LASTEXITCODE -ne 0) { throw "git init failed" } @@ -301,6 +338,9 @@ if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version on PATH, got $PathOutput" } bitbygit --version +} finally { + Remove-Item -LiteralPath $WorkDir -Recurse -Force +} } ``` diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 8f2c66c..a81884a 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -76,6 +76,12 @@ if ([regex]::Matches($DocsText, [regex]::Escape($MachinePathLookup)).Count -ne 2 if ([regex]::Matches($DocsText, '(?m)^bitbygit --version\r?$').Count -ne 2) { Fail "Windows examples do not finish with PATH-resolved version output" } +$RandomTempName = '[System.IO.Path]::GetRandomFileName()' +if ([regex]::Matches($DocsText, [regex]::Escape($RandomTempName)).Count -ne 2 -or + [regex]::Matches($DocsText, 'finally \{').Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape('Remove-Item -LiteralPath $WorkDir -Recurse -Force')).Count -ne 2) { + Fail "Windows examples do not securely stage and clean up temporary work" +} foreach ($Match in [regex]::Matches($DocsText, '(?ms)^```powershell\r?\n(.*?)^```')) { $Tokens = $null @@ -109,6 +115,8 @@ if (-not $SourceBlock.Contains('git -C $SourceDir fetch --depth 1 https://github New-Item -ItemType Directory -Path $Temp | Out-Null $OriginalLocalAppData = $env:LOCALAPPDATA $OriginalProcessPath = $env:Path +$OriginalTemp = $env:TEMP +$OriginalTmp = $env:TMP try { cargo build --locked --release -p bitbygit @@ -141,10 +149,20 @@ try { Set-Content -Path (Join-Path $Assets "SHA256SUMS") -Value "$Digest $Archive" $SuccessDir = Join-Path $Temp "success" - New-Item -ItemType Directory -Path $SuccessDir | Out-Null + $SuccessTemp = Join-Path $SuccessDir "temp" + $ProtectedPackage = Join-Path $SuccessDir "protected-package" + New-Item -ItemType Directory -Path $SuccessDir, $SuccessTemp, $ProtectedPackage | Out-Null + $ProtectedFile = Join-Path $SuccessDir "protected-file" + Set-Content -Path $ProtectedFile -Value "protected" + Set-Content -Path (Join-Path $ProtectedPackage "marker") -Value "protected" + New-Item -ItemType SymbolicLink -Path (Join-Path $SuccessDir $Archive) -Target $ProtectedFile | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $SuccessDir "SHA256SUMS") -Target $ProtectedFile | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $SuccessDir $Package) -Target $ProtectedPackage | Out-Null $SuccessScript = Join-Path $SuccessDir "install.ps1" Set-Content -Path $SuccessScript -Value $InstallBlock $env:LOCALAPPDATA = Join-Path $SuccessDir "local-app-data" + $env:TEMP = $SuccessTemp + $env:TMP = $SuccessTemp $env:Path = $null Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir @@ -159,11 +177,30 @@ try { Push-Location $SuccessDir try { - . $SuccessScript + $StartingLocation = (Get-Location).Path + foreach ($Attempt in 1..2) { + . $SuccessScript + if ((Get-Location).Path -ne $StartingLocation) { + Fail "archive block changed the caller working directory" + } + if ((Get-ChildItem -Force $SuccessTemp).Count -ne 0) { + Fail "archive block left temporary installation files behind" + } + } } finally { Pop-Location } + if ((Get-Item -Force (Join-Path $SuccessDir $Archive)).LinkType -ne "SymbolicLink" -or + (Get-Item -Force (Join-Path $SuccessDir "SHA256SUMS")).LinkType -ne "SymbolicLink" -or + (Get-Item -Force (Join-Path $SuccessDir $Package)).LinkType -ne "SymbolicLink") { + Fail "archive block replaced a pre-existing working-directory symlink" + } + if ((Get-Content $ProtectedFile) -ne "protected" -or + (Get-Content (Join-Path $ProtectedPackage "marker")) -ne "protected") { + Fail "archive block modified a pre-existing working-directory symlink target" + } + $InstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" $InstalledBinary = Join-Path $InstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "archive block changed the caller error preference" } @@ -190,7 +227,11 @@ try { Copy-Item $WorkspaceBinary (Join-Path $MachineOldBinaryDir "bitbygit.exe") $MachineConflictScript = Join-Path $MachineConflictDir "install.ps1" Set-Content -Path $MachineConflictScript -Value $InstallBlock + $MachineConflictTemp = Join-Path $MachineConflictDir "temp" + New-Item -ItemType Directory -Path $MachineConflictTemp | Out-Null $env:LOCALAPPDATA = Join-Path $MachineConflictDir "local-app-data" + $env:TEMP = $MachineConflictTemp + $env:TMP = $MachineConflictTemp $env:Path = "$MachineOldBinaryDir;$OriginalProcessPath" $env:BITBYGIT_TEST_MACHINE_PATH = $MachineOldBinaryDir Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue @@ -213,6 +254,7 @@ try { Pop-Location } if (-not $MachineConflictFailed) { Fail "archive block did not reject machine-level PATH shadowing" } + if ((Get-ChildItem -Force $MachineConflictTemp).Count -ne 0) { Fail "failed archive block left temporary installation files behind" } if ($null -ne $CapturedUserPath) { Fail "machine-level conflict changed persistent user PATH" } if ($env:Path -ne $MachineConflictStartingPath) { Fail "machine-level conflict changed process PATH" } $ConflictInstalledBinary = Join-Path $env:LOCALAPPDATA "Programs\bitbygit\bitbygit.exe" @@ -220,15 +262,32 @@ try { if ($LASTEXITCODE -ne 0 -or $ConflictOutput -ne "bitbygit $SelectedVersion") { Fail "machine-level conflict did not leave the selected binary available by explicit path" } + $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir + Push-Location $MachineConflictDir + try { + $StartingLocation = (Get-Location).Path + . $MachineConflictScript + if ((Get-Location).Path -ne $StartingLocation) { Fail "retried archive block changed the caller working directory" } + } finally { + Pop-Location + } + if ((Get-ChildItem -Force $MachineConflictTemp).Count -ne 0) { Fail "retried archive block left temporary installation files behind" } + $ConflictRetryOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $ConflictRetryOutput -ne "bitbygit $SelectedVersion") { + Fail "archive block could not retry after removing a machine-level conflict" + } $FailureAssets = Join-Path $Temp "failure-assets" $FailureDir = Join-Path $Temp "failure" - New-Item -ItemType Directory -Path $FailureAssets, $FailureDir | Out-Null + $FailureTemp = Join-Path $FailureDir "temp" + New-Item -ItemType Directory -Path $FailureAssets, $FailureDir, $FailureTemp | Out-Null Copy-Item (Join-Path $Assets $Archive) $FailureAssets Set-Content -Path (Join-Path $FailureAssets "SHA256SUMS") -Value "$('0' * 64) $Archive" $FailureScript = Join-Path $FailureDir "install.ps1" Set-Content -Path $FailureScript -Value $InstallBlock $env:LOCALAPPDATA = Join-Path $FailureDir "local-app-data" + $env:TEMP = $FailureTemp + $env:TMP = $FailureTemp $env:Path = $OriginalProcessPath $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir $env:MOCK_DOWNLOAD_DIR = $FailureAssets @@ -253,6 +312,7 @@ try { } if (-not $ChecksumFailed) { Fail "archive block accepted an invalid checksum" } if (Test-Path $env:MOCK_SIDE_EFFECT) { Fail "archive block extracted after checksum failure" } + if ((Get-ChildItem -Force $FailureTemp).Count -ne 0) { Fail "failed archive block left temporary installation files behind" } if ($ErrorActionPreference -ne "Continue") { Fail "failed archive block changed the caller error preference" } if ($env:Path -ne $OriginalProcessPath) { Fail "failed archive block changed the process PATH" } @@ -298,10 +358,16 @@ try { } $SourceSuccessDir = Join-Path $Temp "source-success" - New-Item -ItemType Directory -Path $SourceSuccessDir | Out-Null + $SourceSuccessTemp = Join-Path $SourceSuccessDir "temp" + $ProtectedSource = Join-Path $SourceSuccessDir "protected-source" + New-Item -ItemType Directory -Path $SourceSuccessDir, $SourceSuccessTemp, $ProtectedSource | Out-Null + Set-Content -Path (Join-Path $ProtectedSource "marker") -Value "protected" + New-Item -ItemType SymbolicLink -Path (Join-Path $SourceSuccessDir "bitbygit") -Target $ProtectedSource | Out-Null $SourceScript = Join-Path $SourceSuccessDir "install-from-source.ps1" Set-Content -Path $SourceScript -Value $SourceBlock $env:LOCALAPPDATA = Join-Path $SourceSuccessDir "local-app-data" + $env:TEMP = $SourceSuccessTemp + $env:TMP = $SourceSuccessTemp $SourceInstallDir = Join-Path $env:LOCALAPPDATA "Programs\bitbygit" $OldBinaryDir = Join-Path $SourceSuccessDir "old-bin" New-Item -ItemType Directory -Path $OldBinaryDir | Out-Null @@ -327,13 +393,24 @@ try { Push-Location $SourceSuccessDir try { $StartingLocation = (Get-Location).Path - . $SourceScript - if ((Get-Location).Path -ne $StartingLocation) { - Fail "source block changed the caller working directory" + foreach ($Attempt in 1..2) { + $FetchedExactTag = $false + $CheckedOutExactTag = $false + . $SourceScript + if ((Get-Location).Path -ne $StartingLocation) { + Fail "source block changed the caller working directory" + } + if ((Get-ChildItem -Force $SourceSuccessTemp).Count -ne 0) { + Fail "source block left temporary build files behind" + } } } finally { Pop-Location } + if ((Get-Item -Force (Join-Path $SourceSuccessDir "bitbygit")).LinkType -ne "SymbolicLink" -or + (Get-Content (Join-Path $ProtectedSource "marker")) -ne "protected") { + Fail "source block modified a pre-existing working-directory source symlink" + } $SourceInstalledBinary = Join-Path $SourceInstallDir "bitbygit.exe" if ($ErrorActionPreference -ne "Continue") { Fail "source block changed the caller error preference" } if ($env:Path -ne $SourceExpectedPath) { Fail "source block did not prioritize and deduplicate the process PATH entry" } @@ -357,10 +434,13 @@ try { } $SourceFailureDir = Join-Path $Temp "source-failure" - New-Item -ItemType Directory -Path $SourceFailureDir | Out-Null + $SourceFailureTemp = Join-Path $SourceFailureDir "temp" + New-Item -ItemType Directory -Path $SourceFailureDir, $SourceFailureTemp | Out-Null $SourceFailureScript = Join-Path $SourceFailureDir "install-from-source.ps1" Set-Content -Path $SourceFailureScript -Value $SourceBlock $env:LOCALAPPDATA = Join-Path $SourceFailureDir "local-app-data" + $env:TEMP = $SourceFailureTemp + $env:TMP = $SourceFailureTemp $env:Path = $OriginalProcessPath $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir $FetchedExactTag = $false @@ -383,13 +463,33 @@ try { Pop-Location } if (-not $SourceBuildFailed) { Fail "source block continued after a failed build" } + if ((Get-ChildItem -Force $SourceFailureTemp).Count -ne 0) { Fail "failed source block left temporary build files behind" } if ($ErrorActionPreference -ne "Continue") { Fail "failed source block changed the caller error preference" } if ($env:Path -ne $OriginalProcessPath) { Fail "failed source block changed the process PATH" } + $env:MOCK_CARGO_FAILURE = "0" + $CapturedUserPath = $null + Remove-Item Env:BITBYGIT_TEST_USER_PATH -ErrorAction SilentlyContinue + Push-Location $SourceFailureDir + try { + $StartingLocation = (Get-Location).Path + . $SourceFailureScript + if ((Get-Location).Path -ne $StartingLocation) { Fail "retried source block changed the caller working directory" } + } finally { + Pop-Location + } + if ((Get-ChildItem -Force $SourceFailureTemp).Count -ne 0) { Fail "retried source block left temporary build files behind" } + $SourceRetryOutput = bitbygit --version + if ($LASTEXITCODE -ne 0 -or $SourceRetryOutput -ne "bitbygit $SelectedVersion") { + Fail "source block could not retry after a failed build" + } + $global:LASTEXITCODE = 0 Write-Output "installation docs validation passed on Windows" } finally { $env:LOCALAPPDATA = $OriginalLocalAppData $env:Path = $OriginalProcessPath + $env:TEMP = $OriginalTemp + $env:TMP = $OriginalTmp Remove-Item -Recurse -Force $Temp -ErrorAction SilentlyContinue } diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 6a17855..dee7f75 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -116,7 +116,14 @@ check_unix_success() { local target="$2" local machine="$3" local case_dir="${tmp}/success-${target}" - mkdir -p "${case_dir}/home" + local archive="bitbygit-${selected_version}-${target}.tar.gz" + local package="bitbygit-${selected_version}-${target}" + mkdir -p "${case_dir}/home" "${case_dir}/temp" "${case_dir}/protected-package" + printf 'protected\n' > "${case_dir}/protected-file" + printf 'protected\n' > "${case_dir}/protected-package/marker" + ln -s protected-file "${case_dir}/${archive}" + ln -s protected-file "${case_dir}/SHA256SUMS" + ln -s protected-package "${case_dir}/${package}" extract_selected_block "${heading}" bash > "${case_dir}/snippet.bash" [[ -s "${case_dir}/snippet.bash" ]] || fail "missing ${heading} Bash block" @@ -126,11 +133,15 @@ check_unix_success() { set +u set +o pipefail export HOME="${case_dir}/home" + export TMPDIR="${case_dir}/temp" export MOCK_DOWNLOAD_DIR="${tmp}/assets" export MOCK_UNAME_MACHINE="${machine}" export PATH="${tmp}/mock-bin:${PATH}" + starting_dir="${PWD}" source "${case_dir}/snippet.bash" || fail "${heading} failed a valid ${target} installation" + source "${case_dir}/snippet.bash" || fail "${heading} failed when retried" + [[ "${PWD}" == "${starting_dir}" ]] || fail "${heading} changed the caller working directory" [[ "$-" != *e* && "$-" != *u* ]] || fail "${heading} changed caller shell options" if shopt -qo pipefail; then fail "${heading} enabled pipefail in the caller shell" @@ -140,7 +151,15 @@ check_unix_success() { [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} installed the wrong version" [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not resolve the installed command through PATH" [[ "$(bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} resolved the wrong version through PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "${heading} left temporary installation files behind" + fi ) + [[ -L "${case_dir}/${archive}" && "$(readlink "${case_dir}/${archive}")" == protected-file ]] || fail "${heading} replaced the pre-existing archive symlink" + [[ -L "${case_dir}/SHA256SUMS" && "$(readlink "${case_dir}/SHA256SUMS")" == protected-file ]] || fail "${heading} replaced the pre-existing checksum symlink" + [[ -L "${case_dir}/${package}" && "$(readlink "${case_dir}/${package}")" == protected-package ]] || fail "${heading} replaced the pre-existing package symlink" + [[ "$(<"${case_dir}/protected-file")" == protected ]] || fail "${heading} modified a symlink target" + [[ "$(<"${case_dir}/protected-package/marker")" == protected ]] || fail "${heading} extracted through a package symlink" } host_os="${INSTALLATION_DOCS_TEST_OS:-$(uname -s)}" @@ -175,7 +194,7 @@ done chmod +x "${tmp}/failure-bin/"* failure_dir="${tmp}/failure-${failure_target}" -mkdir -p "${failure_dir}/home" +mkdir -p "${failure_dir}/home" "${failure_dir}/temp" extract_selected_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" ( cd "${failure_dir}" @@ -184,6 +203,7 @@ extract_selected_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" set +o pipefail original_path="${tmp}/failure-bin:${PATH}" export HOME="${failure_dir}/home" + export TMPDIR="${failure_dir}/temp" export MOCK_DOWNLOAD_DIR="${tmp}/failure-assets" export MOCK_SIDE_EFFECTS="${failure_dir}/side-effects" export MOCK_UNAME_MACHINE="${failure_machine}" @@ -196,11 +216,14 @@ extract_selected_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" fail "failed ${failure_heading} enabled pipefail in the caller shell" fi [[ "${PATH}" == "${original_path}" ]] || fail "failed ${failure_heading} changed the caller PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "failed ${failure_heading} left temporary installation files behind" + fi ) [[ ! -e "${failure_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed after checksum failure" -[[ "${docs_text}" == *'git -C bitbygit fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}"'* ]] || fail "Bash source build does not fetch the exact selected tag" -[[ "${docs_text}" == *'git -C bitbygit checkout --detach "${tag_commit}"'* ]] || fail "Bash source build does not detach at the selected tag" +[[ "${docs_text}" == *'git -C "${SOURCE_DIR}" fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}"'* ]] || fail "Bash source build does not fetch the exact selected tag" +[[ "${docs_text}" == *'git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}"'* ]] || fail "Bash source build does not detach at the selected tag" [[ "${docs_text}" == *'git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"'* ]] || fail "PowerShell source build does not fetch the exact selected tag" [[ "${docs_text}" == *'git -C $SourceDir checkout --detach $TagCommit'* ]] || fail "PowerShell source build does not detach at the selected tag" [[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" @@ -228,9 +251,14 @@ git -C "${source_seed}" push --quiet "${source_remote}" \ "refs/tags/v${selected_version}:refs/tags/v${selected_version}" source_dir="${tmp}/source-install" -mkdir -p "${source_dir}/home" "${source_dir}/mock-bin" +mkdir -p "${source_dir}/home" "${source_dir}/mock-bin" "${source_dir}/temp" "${source_dir}/protected-source" +printf 'protected\n' > "${source_dir}/protected-source/marker" +ln -s protected-source "${source_dir}/bitbygit" cat > "${source_dir}/mock-bin/cargo" <<'MOCK' #!/usr/bin/env sh +if [ "${MOCK_CARGO_FAILURE:-0}" = 1 ]; then + exit 1 +fi exit 0 MOCK cp "${tmp}/mock-bin/bitbygit" "${source_dir}/mock-bin/bitbygit" @@ -241,15 +269,32 @@ printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" ( cd "${source_dir}" export HOME="${source_dir}/home" + export TMPDIR="${source_dir}/temp" export PATH="${source_dir}/mock-bin:${PATH}" - source "${source_dir}/snippet.bash" || fail "Bash source block failed with colliding branch and tag names" - tag_commit="$(git --git-dir="${source_remote}" rev-parse "refs/tags/v${selected_version}^{commit}")" - branch_commit="$(git --git-dir="${source_remote}" rev-parse "refs/heads/v${selected_version}")" - head_commit="$(git -C bitbygit rev-parse HEAD)" - [[ "${head_commit}" == "${tag_commit}" && "${head_commit}" != "${branch_commit}" ]] || fail "Bash source block did not check out the exact tag object" + starting_dir="${PWD}" + original_path="${PATH}" + export MOCK_CARGO_FAILURE=1 + if source "${source_dir}/snippet.bash"; then + fail "Bash source block continued after a failed build" + fi + [[ "${PWD}" == "${starting_dir}" ]] || fail "failed Bash source block changed the caller working directory" + [[ "${PATH}" == "${original_path}" ]] || fail "failed Bash source block changed the caller PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "failed Bash source block left temporary build files behind" + fi + + export MOCK_CARGO_FAILURE=0 + source "${source_dir}/snippet.bash" || fail "Bash source block could not retry a failed build" + source "${source_dir}/snippet.bash" || fail "Bash source block failed when retried" + [[ "${PWD}" == "${starting_dir}" ]] || fail "Bash source block changed the caller working directory" [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed command through PATH" [[ "$(bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "Bash source block resolved the wrong version through PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "Bash source block left temporary build files behind" + fi ) +[[ -L "${source_dir}/bitbygit" && "$(readlink "${source_dir}/bitbygit")" == protected-source ]] || fail "Bash source block replaced a pre-existing source symlink" +[[ "$(<"${source_dir}/protected-source/marker")" == protected ]] || fail "Bash source block wrote through a pre-existing source symlink" check_links() { local markdown="$1" From b6348dffdbf5787699fe9d9ffe76ffa98e87f903 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 17:27:39 -0400 Subject: [PATCH 15/21] fix: harden installation verification --- docs/installation.md | 135 ++++++++++++++++++++++------- scripts/test-installation-docs.ps1 | 76 +++++++++++++++- scripts/test-installation-docs.sh | 86 ++++++++++++++++-- 3 files changed, 257 insertions(+), 40 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index c0fcfcf..9c9d081 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -48,7 +48,9 @@ WORK_DIR="$(mktemp -d)" cleanup() { status=$? trap - EXIT - rm -rf -- "${WORK_DIR}" + if ! rm -rf -- "${WORK_DIR}"; then + printf 'Warning: failed to remove temporary directory %s\n' "${WORK_DIR}" >&2 + fi exit "${status}" } trap cleanup EXIT @@ -67,13 +69,24 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then exit 1 fi BITBYGIT_INSTALL -export PATH="${HOME}/.local/bin:${PATH}" && -if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then - bitbygit --version +{ +install_dir="${HOME}/.local/bin" +new_path="${install_dir}" +IFS=: read -r -a path_entries <<< "${PATH-}" +for entry in "${path_entries[@]}"; do + [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" +done +export PATH="${new_path}" +resolved_binary="$(type -P bitbygit || true)" +if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && + path_output="$(command bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then + command bitbygit --version else - printf 'Expected bitbygit %s on PATH, got %s\n' "${VERSION}" "${path_output:-no output}" >&2 + printf 'Expected %s to resolve as bitbygit %s, got %s (%s)\n' \ + "${install_dir}/bitbygit" "${VERSION}" "${resolved_binary:-no application}" "${path_output:-no output}" >&2 false fi +} ``` Add `export PATH="$HOME/.local/bin:$PATH"` to your shell startup file to keep @@ -101,7 +114,9 @@ WORK_DIR="$(mktemp -d)" cleanup() { status=$? trap - EXIT - rm -rf -- "${WORK_DIR}" + if ! rm -rf -- "${WORK_DIR}"; then + printf 'Warning: failed to remove temporary directory %s\n' "${WORK_DIR}" >&2 + fi exit "${status}" } trap cleanup EXIT @@ -120,13 +135,24 @@ if [[ "${output}" != "bitbygit ${VERSION}" ]]; then exit 1 fi BITBYGIT_INSTALL -export PATH="${HOME}/.local/bin:${PATH}" && -if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then - bitbygit --version +{ +install_dir="${HOME}/.local/bin" +new_path="${install_dir}" +IFS=: read -r -a path_entries <<< "${PATH-}" +for entry in "${path_entries[@]}"; do + [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" +done +export PATH="${new_path}" +resolved_binary="$(type -P bitbygit || true)" +if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && + path_output="$(command bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then + command bitbygit --version else - printf 'Expected bitbygit %s on PATH, got %s\n' "${VERSION}" "${path_output:-no output}" >&2 + printf 'Expected %s to resolve as bitbygit %s, got %s (%s)\n' \ + "${install_dir}/bitbygit" "${VERSION}" "${resolved_binary:-no application}" "${path_output:-no output}" >&2 false fi +} ``` Add `export PATH="$HOME/.local/bin:$PATH"` to `~/.zprofile` (or the startup file @@ -182,18 +208,34 @@ $UserPathEntries = @($UserPath -split ";" | Where-Object { -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir }) $NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";" -[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") $ProcessPathEntries = @($env:Path -split ";" | Where-Object { -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir }) -$env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" -$PathOutput = bitbygit --version -if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { - throw "Expected bitbygit $Version on PATH, got $PathOutput" +$PreviousProcessPath = $env:Path +try { + $env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" + $ResolvedCommand = Get-Command bitbygit -CommandType Application -ErrorAction Stop | Select-Object -First 1 + $ResolvedPath = (Resolve-Path -LiteralPath $ResolvedCommand.Source).Path + $InstalledPath = (Resolve-Path -LiteralPath $InstalledBinary).Path + if (-not [string]::Equals($ResolvedPath, $InstalledPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Expected $InstalledPath on PATH, got $ResolvedPath" + } + $PathOutput = & $ResolvedPath --version + if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version on PATH, got $PathOutput" + } + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") +} catch { + $env:Path = $PreviousProcessPath + throw } -bitbygit --version +& $ResolvedPath --version } finally { - Remove-Item -LiteralPath $WorkDir -Recurse -Force + try { + Remove-Item -LiteralPath $WorkDir -Recurse -Force + } catch { + [Console]::Error.WriteLine("Warning: failed to remove temporary directory ${WorkDir}: $($_.Exception.Message)") + } } } ``` @@ -239,7 +281,9 @@ SOURCE_DIR="${WORK_DIR}/bitbygit" cleanup() { status=$? trap - EXIT - rm -rf -- "${WORK_DIR}" + if ! rm -rf -- "${WORK_DIR}"; then + printf 'Warning: failed to remove temporary directory %s\n' "${WORK_DIR}" >&2 + fi exit "${status}" } trap cleanup EXIT @@ -265,13 +309,24 @@ if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then exit 1 fi BITBYGIT_INSTALL -export PATH="${HOME}/.local/bin:${PATH}" && -if path_output="$(bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then - bitbygit --version +{ +install_dir="${HOME}/.local/bin" +new_path="${install_dir}" +IFS=: read -r -a path_entries <<< "${PATH-}" +for entry in "${path_entries[@]}"; do + [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" +done +export PATH="${new_path}" +resolved_binary="$(type -P bitbygit || true)" +if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && + path_output="$(command bitbygit --version)" && [[ "${path_output}" == "bitbygit ${VERSION}" ]]; then + command bitbygit --version else - printf 'Expected bitbygit %s on PATH, got %s\n' "${VERSION}" "${path_output:-no output}" >&2 + printf 'Expected %s to resolve as bitbygit %s, got %s (%s)\n' \ + "${install_dir}/bitbygit" "${VERSION}" "${resolved_binary:-no application}" "${path_output:-no output}" >&2 false fi +} ``` On Windows, run in PowerShell: @@ -328,18 +383,34 @@ $UserPathEntries = @($UserPath -split ";" | Where-Object { -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir }) $NewUserPath = (@($InstallDir) + $UserPathEntries) -join ";" -[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") $ProcessPathEntries = @($env:Path -split ";" | Where-Object { -not [string]::IsNullOrEmpty($_) -and $_ -ne $InstallDir }) -$env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" -$PathOutput = bitbygit --version -if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { - throw "Expected bitbygit $Version on PATH, got $PathOutput" +$PreviousProcessPath = $env:Path +try { + $env:Path = (@($InstallDir) + $ProcessPathEntries) -join ";" + $ResolvedCommand = Get-Command bitbygit -CommandType Application -ErrorAction Stop | Select-Object -First 1 + $ResolvedPath = (Resolve-Path -LiteralPath $ResolvedCommand.Source).Path + $InstalledPath = (Resolve-Path -LiteralPath $InstalledBinary).Path + if (-not [string]::Equals($ResolvedPath, $InstalledPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Expected $InstalledPath on PATH, got $ResolvedPath" + } + $PathOutput = & $ResolvedPath --version + if ($LASTEXITCODE -ne 0 -or $PathOutput -ne "bitbygit $Version") { + throw "Expected bitbygit $Version on PATH, got $PathOutput" + } + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") +} catch { + $env:Path = $PreviousProcessPath + throw } -bitbygit --version +& $ResolvedPath --version } finally { - Remove-Item -LiteralPath $WorkDir -Recurse -Force + try { + Remove-Item -LiteralPath $WorkDir -Recurse -Force + } catch { + [Console]::Error.WriteLine("Warning: failed to remove temporary directory ${WorkDir}: $($_.Exception.Message)") + } } } ``` @@ -353,6 +424,6 @@ Use an archive or source tag after a release is published. ## Verify the installation Every installation block above first runs the installed file directly, then -updates `PATH` and finishes with `bitbygit --version`. Both checks require and -report `bitbygit `, so the final check also verifies command -resolution through the documented `PATH` setup. +updates `PATH`, resolves an application while ignoring same-named aliases and +functions, and verifies that it is the installed file before reporting +`bitbygit `. diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index a81884a..ef5ccec 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -73,13 +73,19 @@ if ([regex]::Matches($DocsText, [regex]::Escape($MachinePathLookup)).Count -ne 2 [regex]::Matches($DocsText, [regex]::Escape('User PATH cannot override it in new shells')).Count -ne 2) { Fail "Windows examples do not reject machine-level PATH shadowing" } -if ([regex]::Matches($DocsText, '(?m)^bitbygit --version\r?$').Count -ne 2) { +if ([regex]::Matches($DocsText, '(?m)^& \$ResolvedPath --version\r?$').Count -ne 2) { Fail "Windows examples do not finish with PATH-resolved version output" } +$ApplicationResolution = 'Get-Command bitbygit -CommandType Application -ErrorAction Stop' +if ([regex]::Matches($DocsText, [regex]::Escape($ApplicationResolution)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape('[System.StringComparison]::OrdinalIgnoreCase')).Count -ne 2) { + Fail "Windows examples do not bypass aliases and functions or verify resolved path identity" +} $RandomTempName = '[System.IO.Path]::GetRandomFileName()' if ([regex]::Matches($DocsText, [regex]::Escape($RandomTempName)).Count -ne 2 -or [regex]::Matches($DocsText, 'finally \{').Count -ne 2 -or - [regex]::Matches($DocsText, [regex]::Escape('Remove-Item -LiteralPath $WorkDir -Recurse -Force')).Count -ne 2) { + [regex]::Matches($DocsText, [regex]::Escape('Remove-Item -LiteralPath $WorkDir -Recurse -Force')).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape('Warning: failed to remove temporary directory')).Count -ne 2) { Fail "Windows examples do not securely stage and clean up temporary work" } @@ -169,6 +175,19 @@ try { $CapturedUserPath = $null $env:MOCK_DOWNLOAD_DIR = $Assets $ErrorActionPreference = "Continue" + $ShadowInvoked = $false + + function bitbygit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + function Invoke-ShadowedBitByGit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + Set-Alias -Name bitbygit -Value Invoke-ShadowedBitByGit function Invoke-WebRequest { param($Uri, $OutFile) @@ -179,7 +198,10 @@ try { try { $StartingLocation = (Get-Location).Path foreach ($Attempt in 1..2) { + $ShadowInvoked = $false . $SuccessScript + if ($ShadowInvoked) { Fail "archive block invoked a shadowing alias or function" } + if ($Attempt -eq 1) { Remove-Item Alias:bitbygit } if ((Get-Location).Path -ne $StartingLocation) { Fail "archive block changed the caller working directory" } @@ -190,6 +212,8 @@ try { } finally { Pop-Location } + Remove-Item Function:bitbygit + Remove-Item Function:Invoke-ShadowedBitByGit if ((Get-Item -Force (Join-Path $SuccessDir $Archive)).LinkType -ne "SymbolicLink" -or (Get-Item -Force (Join-Path $SuccessDir "SHA256SUMS")).LinkType -ne "SymbolicLink" -or @@ -316,6 +340,36 @@ try { if ($ErrorActionPreference -ne "Continue") { Fail "failed archive block changed the caller error preference" } if ($env:Path -ne $OriginalProcessPath) { Fail "failed archive block changed the process PATH" } + $CleanupFailureBlock = $InstallBlock.Replace( + ' Remove-Item -LiteralPath $WorkDir -Recurse -Force', + ' throw "forced cleanup failure"' + ) + if ($CleanupFailureBlock -eq $InstallBlock) { Fail "could not force an archive cleanup failure" } + $CleanupFailureScript = Join-Path $FailureDir "install-cleanup-failure.ps1" + Set-Content -Path $CleanupFailureScript -Value $CleanupFailureBlock + $CleanupErrorWriter = [System.IO.StringWriter]::new() + $OriginalErrorWriter = [Console]::Error + $CleanupPrimaryMessage = $null + [Console]::SetError($CleanupErrorWriter) + Push-Location $FailureDir + try { + try { + . $CleanupFailureScript + } catch { + $CleanupPrimaryMessage = $_.Exception.Message + } + } finally { + Pop-Location + [Console]::SetError($OriginalErrorWriter) + } + if ([string]::IsNullOrEmpty($CleanupPrimaryMessage) -or + -not $CleanupPrimaryMessage.Contains("Checksum verification failed")) { + Fail "archive cleanup failure masked the checksum failure: $CleanupPrimaryMessage" + } + if (-not $CleanupErrorWriter.ToString().Contains("forced cleanup failure")) { + Fail "archive block did not report the cleanup failure" + } + function git { param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Arguments) @@ -390,13 +444,29 @@ try { $CheckedOutExactTag = $false $env:MOCK_CARGO_FAILURE = "0" $ErrorActionPreference = "Continue" + $ShadowInvoked = $false + + function bitbygit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + function Invoke-ShadowedBitByGit { + $script:ShadowInvoked = $true + return "bitbygit $SelectedVersion" + } + + Set-Alias -Name bitbygit -Value Invoke-ShadowedBitByGit Push-Location $SourceSuccessDir try { $StartingLocation = (Get-Location).Path foreach ($Attempt in 1..2) { $FetchedExactTag = $false $CheckedOutExactTag = $false + $ShadowInvoked = $false . $SourceScript + if ($ShadowInvoked) { Fail "source block invoked a shadowing alias or function" } + if ($Attempt -eq 1) { Remove-Item Alias:bitbygit } if ((Get-Location).Path -ne $StartingLocation) { Fail "source block changed the caller working directory" } @@ -407,6 +477,8 @@ try { } finally { Pop-Location } + Remove-Item Function:bitbygit + Remove-Item Function:Invoke-ShadowedBitByGit if ((Get-Item -Force (Join-Path $SourceSuccessDir "bitbygit")).LinkType -ne "SymbolicLink" -or (Get-Content (Join-Path $ProtectedSource "marker")) -ne "protected") { Fail "source block modified a pre-existing working-directory source symlink" diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index dee7f75..8559069 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -139,18 +139,40 @@ check_unix_success() { export PATH="${tmp}/mock-bin:${PATH}" starting_dir="${PWD}" + bitbygit() { + : > "${case_dir}/function-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } source "${case_dir}/snippet.bash" || fail "${heading} failed a valid ${target} installation" + [[ ! -e "${case_dir}/function-shadow-used" ]] || fail "${heading} invoked a shadowing function" + unset -f bitbygit + shadow_bitbygit() { + : > "${case_dir}/alias-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } + shopt -s expand_aliases + alias bitbygit=shadow_bitbygit source "${case_dir}/snippet.bash" || fail "${heading} failed when retried" + [[ ! -e "${case_dir}/alias-shadow-used" ]] || fail "${heading} invoked a shadowing alias" + unalias bitbygit + unset -f shadow_bitbygit [[ "${PWD}" == "${starting_dir}" ]] || fail "${heading} changed the caller working directory" [[ "$-" != *e* && "$-" != *u* ]] || fail "${heading} changed caller shell options" if shopt -qo pipefail; then fail "${heading} enabled pipefail in the caller shell" fi [[ "${PATH%%:*}" == "${HOME}/.local/bin" ]] || fail "${heading} did not update the caller PATH" + path_entry_count=0 + IFS=: read -r -a path_entries <<< "${PATH}" + for entry in "${path_entries[@]}"; do + [[ "${entry}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) + done + [[ "${path_entry_count}" -eq 1 ]] || fail "${heading} duplicated the install directory on PATH" [[ -x "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not install the binary" [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} installed the wrong version" - [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not resolve the installed command through PATH" - [[ "$(bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} resolved the wrong version through PATH" + resolved_binary="$(type -P bitbygit)" + [[ "${resolved_binary}" -ef "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not resolve the installed application through PATH" + [[ "$(command bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} resolved the wrong version through PATH" if compgen -G "${TMPDIR}/*" >/dev/null; then fail "${heading} left temporary installation files behind" fi @@ -222,14 +244,44 @@ extract_selected_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" ) [[ ! -e "${failure_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed after checksum failure" +cleanup_failure_dir="${tmp}/cleanup-failure-${failure_target}" +mkdir -p "${cleanup_failure_dir}/home" "${cleanup_failure_dir}/temp" "${cleanup_failure_dir}/mock-bin" +cp "${tmp}/failure-bin/"* "${cleanup_failure_dir}/mock-bin/" +cat > "${cleanup_failure_dir}/mock-bin/rm" <<'MOCK' +#!/usr/bin/env sh +exit 73 +MOCK +chmod +x "${cleanup_failure_dir}/mock-bin/rm" +extract_selected_block "${failure_heading}" bash > "${cleanup_failure_dir}/snippet.bash" +( + cd "${cleanup_failure_dir}" + export HOME="${cleanup_failure_dir}/home" + export TMPDIR="${cleanup_failure_dir}/temp" + export MOCK_DOWNLOAD_DIR="${tmp}/failure-assets" + export MOCK_SIDE_EFFECTS="${cleanup_failure_dir}/side-effects" + export MOCK_UNAME_MACHINE="${failure_machine}" + export PATH="${cleanup_failure_dir}/mock-bin:${PATH}" + if source "${cleanup_failure_dir}/snippet.bash" > "${cleanup_failure_dir}/output" 2>&1; then + fail "${failure_heading} accepted an invalid checksum when cleanup failed" + else + cleanup_status=$? + fi + [[ "${cleanup_status}" -eq 1 ]] || fail "${failure_heading} cleanup failure masked status 1 with ${cleanup_status}" + cleanup_output="$(<"${cleanup_failure_dir}/output")" + [[ "${cleanup_output}" == *"Warning: failed to remove temporary directory"* ]] || fail "${failure_heading} did not report cleanup failure" +) +[[ ! -e "${cleanup_failure_dir}/side-effects" ]] || fail "${failure_heading} continued after checksum and cleanup failures" + [[ "${docs_text}" == *'git -C "${SOURCE_DIR}" fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${TAG}:${TAG}"'* ]] || fail "Bash source build does not fetch the exact selected tag" [[ "${docs_text}" == *'git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}"'* ]] || fail "Bash source build does not detach at the selected tag" [[ "${docs_text}" == *'git -C $SourceDir fetch --depth 1 https://github.com/cosentinode/bitbygit.git "${Tag}:${Tag}"'* ]] || fail "PowerShell source build does not fetch the exact selected tag" [[ "${docs_text}" == *'git -C $SourceDir checkout --detach $TagCommit'* ]] || fail "PowerShell source build does not detach at the selected tag" [[ "${docs_text}" == *'"${HOME}/.local/bin/bitbygit" --version'* ]] || fail "Unix installation does not validate the installed path" [[ "${docs_text}" == *'& $InstalledBinary --version'* ]] || fail "Windows installation does not validate the installed path" -[[ "$(grep -c '^ bitbygit --version$' "${docs}")" -eq 3 ]] || fail "Unix installations do not finish with PATH-resolved version output" -[[ "$(grep -c '^bitbygit --version$' "${docs}")" -eq 2 ]] || fail "Windows installations do not finish with PATH-resolved version output" +[[ "$(grep -c 'resolved_binary="$(type -P bitbygit || true)"' "${docs}")" -eq 3 ]] || fail "Unix installations do not bypass aliases and functions during PATH resolution" +[[ "$(grep -c -- '-ef "${install_dir}/bitbygit"' "${docs}")" -eq 3 ]] || fail "Unix installations do not verify resolved file identity" +[[ "$(grep -c '^ command bitbygit --version$' "${docs}")" -eq 3 ]] || fail "Unix installations do not finish with PATH-resolved version output" +[[ "$(grep -c '^& \$ResolvedPath --version$' "${docs}")" -eq 2 ]] || fail "Windows installations do not finish with PATH-resolved version output" source_remote="${tmp}/source-remote.git" source_seed="${tmp}/source-seed" @@ -284,11 +336,33 @@ printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" fi export MOCK_CARGO_FAILURE=0 + bitbygit() { + : > "${source_dir}/function-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } source "${source_dir}/snippet.bash" || fail "Bash source block could not retry a failed build" + [[ ! -e "${source_dir}/function-shadow-used" ]] || fail "Bash source block invoked a shadowing function" + unset -f bitbygit + shadow_bitbygit() { + : > "${source_dir}/alias-shadow-used" + printf 'bitbygit %s\n' "${selected_version}" + } + shopt -s expand_aliases + alias bitbygit=shadow_bitbygit source "${source_dir}/snippet.bash" || fail "Bash source block failed when retried" + [[ ! -e "${source_dir}/alias-shadow-used" ]] || fail "Bash source block invoked a shadowing alias" + unalias bitbygit + unset -f shadow_bitbygit [[ "${PWD}" == "${starting_dir}" ]] || fail "Bash source block changed the caller working directory" - [[ "$(command -v bitbygit)" == "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed command through PATH" - [[ "$(bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "Bash source block resolved the wrong version through PATH" + path_entry_count=0 + IFS=: read -r -a path_entries <<< "${PATH}" + for entry in "${path_entries[@]}"; do + [[ "${entry}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) + done + [[ "${path_entry_count}" -eq 1 ]] || fail "Bash source block duplicated the install directory on PATH" + resolved_binary="$(type -P bitbygit)" + [[ "${resolved_binary}" -ef "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed application through PATH" + [[ "$(command bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "Bash source block resolved the wrong version through PATH" if compgen -G "${TMPDIR}/*" >/dev/null; then fail "Bash source block left temporary build files behind" fi From af7ad5dd430a9ad47ec1b03070ddb7430fc410cf Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 17:29:57 -0400 Subject: [PATCH 16/21] test: scope Windows cleanup assertion --- scripts/test-installation-docs.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index ef5ccec..4d24cd8 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -82,10 +82,11 @@ if ([regex]::Matches($DocsText, [regex]::Escape($ApplicationResolution)).Count - Fail "Windows examples do not bypass aliases and functions or verify resolved path identity" } $RandomTempName = '[System.IO.Path]::GetRandomFileName()' +$CleanupWarning = '[Console]::Error.WriteLine("Warning: failed to remove temporary directory' if ([regex]::Matches($DocsText, [regex]::Escape($RandomTempName)).Count -ne 2 -or [regex]::Matches($DocsText, 'finally \{').Count -ne 2 -or [regex]::Matches($DocsText, [regex]::Escape('Remove-Item -LiteralPath $WorkDir -Recurse -Force')).Count -ne 2 -or - [regex]::Matches($DocsText, [regex]::Escape('Warning: failed to remove temporary directory')).Count -ne 2) { + [regex]::Matches($DocsText, [regex]::Escape($CleanupWarning)).Count -ne 2) { Fail "Windows examples do not securely stage and clean up temporary work" } From bb232487e20def067282dca868e643a84f4499ce Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 17:45:03 -0400 Subject: [PATCH 17/21] fix: preserve PATH resolution semantics --- docs/installation.md | 37 +++++++++++++++++------ scripts/test-installation-docs.ps1 | 47 +++++++++++++++++++----------- scripts/test-installation-docs.sh | 13 ++++++--- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 9c9d081..bf972a2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -72,7 +72,9 @@ BITBYGIT_INSTALL { install_dir="${HOME}/.local/bin" new_path="${install_dir}" -IFS=: read -r -a path_entries <<< "${PATH-}" +IFS=: read -r -a path_entries <<< "${PATH-}:__BITBYGIT_PATH_END__" +path_entry_count="${#path_entries[@]}" +unset "path_entries[$((path_entry_count - 1))]" for entry in "${path_entries[@]}"; do [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" done @@ -138,7 +140,9 @@ BITBYGIT_INSTALL { install_dir="${HOME}/.local/bin" new_path="${install_dir}" -IFS=: read -r -a path_entries <<< "${PATH-}" +IFS=: read -r -a path_entries <<< "${PATH-}:__BITBYGIT_PATH_END__" +path_entry_count="${#path_entries[@]}" +unset "path_entries[$((path_entry_count - 1))]" for entry in "${path_entries[@]}"; do [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" done @@ -193,11 +197,17 @@ $Output = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $Output" } +$PathExtensions = @($env:PATHEXT -split ";" | ForEach-Object { + $Extension = $_.Trim().ToLowerInvariant() + if ($Extension -in @(".exe", ".com", ".bat", ".cmd")) { $Extension } +} | Select-Object -Unique) $MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") $MachineCommand = $MachinePath -split ";" | ForEach-Object { $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) if (-not [string]::IsNullOrWhiteSpace($Entry)) { - Join-Path $Entry "bitbygit.exe" + foreach ($Extension in $PathExtensions) { + Join-Path $Entry "bitbygit$Extension" + } } } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if ($MachineCommand) { @@ -242,10 +252,11 @@ try { Windows places machine `PATH` entries before user entries in new processes. The block therefore refuses to update user `PATH` when a machine entry already -contains `bitbygit.exe`; otherwise an older machine-level installation could -silently win after restarting PowerShell. Remove or update that machine-level -installation and rerun the block. The selected file is still available through -the unambiguous explicit invocation: +contains a `bitbygit` application with a standard executable extension enabled +by `PATHEXT` (`.exe`, `.com`, `.bat`, or `.cmd`); otherwise an older +machine-level installation could silently win after restarting PowerShell. +Remove or update that machine-level installation and rerun the block. The +selected file is still available through the unambiguous explicit invocation: ```powershell & "$env:LOCALAPPDATA\Programs\bitbygit\bitbygit.exe" --version @@ -312,7 +323,9 @@ BITBYGIT_INSTALL { install_dir="${HOME}/.local/bin" new_path="${install_dir}" -IFS=: read -r -a path_entries <<< "${PATH-}" +IFS=: read -r -a path_entries <<< "${PATH-}:__BITBYGIT_PATH_END__" +path_entry_count="${#path_entries[@]}" +unset "path_entries[$((path_entry_count - 1))]" for entry in "${path_entries[@]}"; do [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" done @@ -368,11 +381,17 @@ $InstalledOutput = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $InstalledOutput" } +$PathExtensions = @($env:PATHEXT -split ";" | ForEach-Object { + $Extension = $_.Trim().ToLowerInvariant() + if ($Extension -in @(".exe", ".com", ".bat", ".cmd")) { $Extension } +} | Select-Object -Unique) $MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") $MachineCommand = $MachinePath -split ";" | ForEach-Object { $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) if (-not [string]::IsNullOrWhiteSpace($Entry)) { - Join-Path $Entry "bitbygit.exe" + foreach ($Extension in $PathExtensions) { + Join-Path $Entry "bitbygit$Extension" + } } } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if ($MachineCommand) { diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 4d24cd8..e441130 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -73,6 +73,12 @@ if ([regex]::Matches($DocsText, [regex]::Escape($MachinePathLookup)).Count -ne 2 [regex]::Matches($DocsText, [regex]::Escape('User PATH cannot override it in new shells')).Count -ne 2) { Fail "Windows examples do not reject machine-level PATH shadowing" } +$PathExtLookup = '$env:PATHEXT -split ";"' +$ExecutableExtensions = '@(".exe", ".com", ".bat", ".cmd")' +if ([regex]::Matches($DocsText, [regex]::Escape($PathExtLookup)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape($ExecutableExtensions)).Count -ne 2) { + Fail "Windows examples do not inspect every standard PATHEXT application candidate" +} if ([regex]::Matches($DocsText, '(?m)^& \$ResolvedPath --version\r?$').Count -ne 2) { Fail "Windows examples do not finish with PATH-resolved version output" } @@ -124,6 +130,7 @@ $OriginalLocalAppData = $env:LOCALAPPDATA $OriginalProcessPath = $env:Path $OriginalTemp = $env:TEMP $OriginalTmp = $env:TMP +$OriginalPathExt = $env:PATHEXT try { cargo build --locked --release -p bitbygit @@ -141,6 +148,7 @@ try { Set-Content -Path $FixtureSource -Value $FixtureSourceText rustc --crate-name selected_version_fixture $FixtureSource -o $FixtureBinary if ($LASTEXITCODE -ne 0) { Fail "selected-version fixture build failed" } + $env:PATHEXT = ".CoM;.EXE;.BaT;.CMD" $Target = "x86_64-pc-windows-msvc" $Archive = "bitbygit-$SelectedVersion-$Target.zip" @@ -267,25 +275,29 @@ try { if ($LASTEXITCODE -ne 0 -or $OldMachineOutput -ne "bitbygit $WorkspaceVersion") { Fail "older machine-level bitbygit.exe fixture reported the wrong version" } - $MachineConflictFailed = $false - Push-Location $MachineConflictDir - try { + foreach ($Extension in ".exe", ".com", ".bat", ".cmd") { + Remove-Item (Join-Path $MachineOldBinaryDir "bitbygit.*") -Force + Copy-Item $WorkspaceBinary (Join-Path $MachineOldBinaryDir "bitbygit$Extension") + $MachineConflictFailed = $false + Push-Location $MachineConflictDir try { - . $MachineConflictScript - } catch { - $MachineConflictFailed = $_.Exception.Message.Contains("User PATH cannot override it in new shells") + try { + . $MachineConflictScript + } catch { + $MachineConflictFailed = $_.Exception.Message.Contains("User PATH cannot override it in new shells") + } + } finally { + Pop-Location + } + if (-not $MachineConflictFailed) { Fail "archive block did not reject machine-level $Extension PATH shadowing" } + if ((Get-ChildItem -Force $MachineConflictTemp).Count -ne 0) { Fail "failed archive block left temporary installation files behind" } + if ($null -ne $CapturedUserPath) { Fail "machine-level conflict changed persistent user PATH" } + if ($env:Path -ne $MachineConflictStartingPath) { Fail "machine-level conflict changed process PATH" } + $ConflictInstalledBinary = Join-Path $env:LOCALAPPDATA "Programs\bitbygit\bitbygit.exe" + $ConflictOutput = & $ConflictInstalledBinary --version + if ($LASTEXITCODE -ne 0 -or $ConflictOutput -ne "bitbygit $SelectedVersion") { + Fail "machine-level conflict did not leave the selected binary available by explicit path" } - } finally { - Pop-Location - } - if (-not $MachineConflictFailed) { Fail "archive block did not reject machine-level PATH shadowing" } - if ((Get-ChildItem -Force $MachineConflictTemp).Count -ne 0) { Fail "failed archive block left temporary installation files behind" } - if ($null -ne $CapturedUserPath) { Fail "machine-level conflict changed persistent user PATH" } - if ($env:Path -ne $MachineConflictStartingPath) { Fail "machine-level conflict changed process PATH" } - $ConflictInstalledBinary = Join-Path $env:LOCALAPPDATA "Programs\bitbygit\bitbygit.exe" - $ConflictOutput = & $ConflictInstalledBinary --version - if ($LASTEXITCODE -ne 0 -or $ConflictOutput -ne "bitbygit $SelectedVersion") { - Fail "machine-level conflict did not leave the selected binary available by explicit path" } $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir Push-Location $MachineConflictDir @@ -564,5 +576,6 @@ try { $env:Path = $OriginalProcessPath $env:TEMP = $OriginalTemp $env:TMP = $OriginalTmp + $env:PATHEXT = $OriginalPathExt Remove-Item -Recurse -Force $Temp -ErrorAction SilentlyContinue } diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 8559069..19c8a7b 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -136,7 +136,9 @@ check_unix_success() { export TMPDIR="${case_dir}/temp" export MOCK_DOWNLOAD_DIR="${tmp}/assets" export MOCK_UNAME_MACHINE="${machine}" - export PATH="${tmp}/mock-bin:${PATH}" + base_path="${PATH}" + expected_path="${HOME}/.local/bin::${tmp}/mock-bin::${base_path}:" + export PATH=":${tmp}/mock-bin::${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" starting_dir="${PWD}" bitbygit() { @@ -161,7 +163,7 @@ check_unix_success() { if shopt -qo pipefail; then fail "${heading} enabled pipefail in the caller shell" fi - [[ "${PATH%%:*}" == "${HOME}/.local/bin" ]] || fail "${heading} did not update the caller PATH" + [[ "${PATH}" == "${expected_path}" ]] || fail "${heading} did not preserve empty PATH entries while deduplicating the install directory" path_entry_count=0 IFS=: read -r -a path_entries <<< "${PATH}" for entry in "${path_entries[@]}"; do @@ -322,9 +324,11 @@ printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" cd "${source_dir}" export HOME="${source_dir}/home" export TMPDIR="${source_dir}/temp" - export PATH="${source_dir}/mock-bin:${PATH}" + base_path="${PATH}" + original_path=":${source_dir}/mock-bin::${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" + expected_path="${HOME}/.local/bin::${source_dir}/mock-bin::${base_path}:" + export PATH="${original_path}" starting_dir="${PWD}" - original_path="${PATH}" export MOCK_CARGO_FAILURE=1 if source "${source_dir}/snippet.bash"; then fail "Bash source block continued after a failed build" @@ -354,6 +358,7 @@ printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" unalias bitbygit unset -f shadow_bitbygit [[ "${PWD}" == "${starting_dir}" ]] || fail "Bash source block changed the caller working directory" + [[ "${PATH}" == "${expected_path}" ]] || fail "Bash source block did not preserve empty PATH entries while deduplicating the install directory" path_entry_count=0 IFS=: read -r -a path_entries <<< "${PATH}" for entry in "${path_entries[@]}"; do From 281d4d2a7ed45ff8b507c2a6ac43356439d8c9bf Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:02:25 -0400 Subject: [PATCH 18/21] fix: honor custom Windows path extensions --- docs/installation.md | 26 ++++++++++++++++---------- scripts/test-installation-docs.ps1 | 15 ++++++++++----- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index bf972a2..99ab280 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -197,10 +197,13 @@ $Output = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $Output -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $Output" } -$PathExtensions = @($env:PATHEXT -split ";" | ForEach-Object { - $Extension = $_.Trim().ToLowerInvariant() - if ($Extension -in @(".exe", ".com", ".bat", ".cmd")) { $Extension } -} | Select-Object -Unique) +$PathExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$env:PATHEXT -split ";" | ForEach-Object { + $Extension = $_.Trim() + if (-not [string]::IsNullOrWhiteSpace($Extension)) { + [void] $PathExtensions.Add($Extension) + } +} $MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") $MachineCommand = $MachinePath -split ";" | ForEach-Object { $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) @@ -252,8 +255,8 @@ try { Windows places machine `PATH` entries before user entries in new processes. The block therefore refuses to update user `PATH` when a machine entry already -contains a `bitbygit` application with a standard executable extension enabled -by `PATHEXT` (`.exe`, `.com`, `.bat`, or `.cmd`); otherwise an older +contains a `bitbygit` application with any non-empty executable extension +enabled by `PATHEXT`, matched case-insensitively; otherwise an older machine-level installation could silently win after restarting PowerShell. Remove or update that machine-level installation and rerun the block. The selected file is still available through the unambiguous explicit invocation: @@ -381,10 +384,13 @@ $InstalledOutput = & $InstalledBinary --version if ($LASTEXITCODE -ne 0 -or $InstalledOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $InstalledOutput" } -$PathExtensions = @($env:PATHEXT -split ";" | ForEach-Object { - $Extension = $_.Trim().ToLowerInvariant() - if ($Extension -in @(".exe", ".com", ".bat", ".cmd")) { $Extension } -} | Select-Object -Unique) +$PathExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$env:PATHEXT -split ";" | ForEach-Object { + $Extension = $_.Trim() + if (-not [string]::IsNullOrWhiteSpace($Extension)) { + [void] $PathExtensions.Add($Extension) + } +} $MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") $MachineCommand = $MachinePath -split ";" | ForEach-Object { $Entry = [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('"')) diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index e441130..6934d8b 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -74,10 +74,12 @@ if ([regex]::Matches($DocsText, [regex]::Escape($MachinePathLookup)).Count -ne 2 Fail "Windows examples do not reject machine-level PATH shadowing" } $PathExtLookup = '$env:PATHEXT -split ";"' -$ExecutableExtensions = '@(".exe", ".com", ".bat", ".cmd")' +$CaseInsensitivePathExtensions = '$PathExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)' +$AddPathExtension = '[void] $PathExtensions.Add($Extension)' if ([regex]::Matches($DocsText, [regex]::Escape($PathExtLookup)).Count -ne 2 -or - [regex]::Matches($DocsText, [regex]::Escape($ExecutableExtensions)).Count -ne 2) { - Fail "Windows examples do not inspect every standard PATHEXT application candidate" + [regex]::Matches($DocsText, [regex]::Escape($CaseInsensitivePathExtensions)).Count -ne 2 -or + [regex]::Matches($DocsText, [regex]::Escape($AddPathExtension)).Count -ne 2) { + Fail "Windows examples do not inspect every PATHEXT application candidate case-insensitively" } if ([regex]::Matches($DocsText, '(?m)^& \$ResolvedPath --version\r?$').Count -ne 2) { Fail "Windows examples do not finish with PATH-resolved version output" @@ -148,7 +150,8 @@ try { Set-Content -Path $FixtureSource -Value $FixtureSourceText rustc --crate-name selected_version_fixture $FixtureSource -o $FixtureBinary if ($LASTEXITCODE -ne 0) { Fail "selected-version fixture build failed" } - $env:PATHEXT = ".CoM;.EXE;.BaT;.CMD" + $CustomPathExtensions = @(".VbS", ".BiTbYgIt-Test") + $env:PATHEXT = (@(".CoM", ".EXE", ".BaT", ".CMD") + $CustomPathExtensions + @(".vbs", "", " ")) -join ";" $Target = "x86_64-pc-windows-msvc" $Archive = "bitbygit-$SelectedVersion-$Target.zip" @@ -271,11 +274,12 @@ try { $CapturedUserPath = $null $env:MOCK_DOWNLOAD_DIR = $Assets $MachineConflictStartingPath = $env:Path + $MachineConflictStartingPathExt = $env:PATHEXT $OldMachineOutput = bitbygit --version if ($LASTEXITCODE -ne 0 -or $OldMachineOutput -ne "bitbygit $WorkspaceVersion") { Fail "older machine-level bitbygit.exe fixture reported the wrong version" } - foreach ($Extension in ".exe", ".com", ".bat", ".cmd") { + foreach ($Extension in (@(".exe", ".com", ".bat", ".cmd") + $CustomPathExtensions)) { Remove-Item (Join-Path $MachineOldBinaryDir "bitbygit.*") -Force Copy-Item $WorkspaceBinary (Join-Path $MachineOldBinaryDir "bitbygit$Extension") $MachineConflictFailed = $false @@ -293,6 +297,7 @@ try { if ((Get-ChildItem -Force $MachineConflictTemp).Count -ne 0) { Fail "failed archive block left temporary installation files behind" } if ($null -ne $CapturedUserPath) { Fail "machine-level conflict changed persistent user PATH" } if ($env:Path -ne $MachineConflictStartingPath) { Fail "machine-level conflict changed process PATH" } + if ($env:PATHEXT -ne $MachineConflictStartingPathExt) { Fail "machine-level conflict changed PATHEXT" } $ConflictInstalledBinary = Join-Path $env:LOCALAPPDATA "Programs\bitbygit\bitbygit.exe" $ConflictOutput = & $ConflictInstalledBinary --version if ($LASTEXITCODE -ne 0 -or $ConflictOutput -ne "bitbygit $SelectedVersion") { From 98c0c3e263b7247f8296806e02b03a8519844044 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:21:40 -0400 Subject: [PATCH 19/21] fix: harden Unix installation validation --- docs/installation.md | 25 +++++++++---- scripts/test-installation-docs.sh | 61 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 99ab280..0f88252 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,7 @@ or `$Version` to an available release version without the leading `v`. ## Linux x86-64 archive -Run these commands in Bash. They require `curl`, `tar`, `grep`, and GNU +Run these commands in Bash. They require `curl`, `tar`, `awk`, and GNU coreutils (`sha256sum`, `mkdir`, and `install`) and stop before extraction or installation if any download or checksum check fails: @@ -58,7 +58,8 @@ cd "${WORK_DIR}" curl -fLO "${BASE_URL}/${ARCHIVE}" curl -fLO "${BASE_URL}/SHA256SUMS" -grep -F " ${ARCHIVE}" SHA256SUMS | sha256sum --check - +awk -v archive="${ARCHIVE}" '$2 == archive && NF == 2 { print; found=1 } END { exit !found }' SHA256SUMS | + sha256sum --check - tar -xzf "${ARCHIVE}" mkdir -p "${HOME}/.local/bin" @@ -72,7 +73,10 @@ BITBYGIT_INSTALL { install_dir="${HOME}/.local/bin" new_path="${install_dir}" -IFS=: read -r -a path_entries <<< "${PATH-}:__BITBYGIT_PATH_END__" +if [[ -z "${PATH+x}" ]]; then + PATH="$(command -p getconf PATH)" +fi +IFS=: read -r -a path_entries <<< "${PATH}:__BITBYGIT_PATH_END__" path_entry_count="${#path_entries[@]}" unset "path_entries[$((path_entry_count - 1))]" for entry in "${path_entries[@]}"; do @@ -97,7 +101,7 @@ the command available in new shells. ## macOS archive Run these commands in Bash. They select the Intel or Apple silicon artifact -automatically and require `curl`, `shasum`, and `tar`, which are included with +automatically and require `curl`, `awk`, `shasum`, and `tar`, which are included with macOS. The shell stops before extraction or installation if any download or checksum check fails: @@ -126,7 +130,8 @@ cd "${WORK_DIR}" curl -fLO "${BASE_URL}/${ARCHIVE}" curl -fLO "${BASE_URL}/SHA256SUMS" -grep -F " ${ARCHIVE}" SHA256SUMS | shasum -a 256 --check - +awk -v archive="${ARCHIVE}" '$2 == archive && NF == 2 { print; found=1 } END { exit !found }' SHA256SUMS | + shasum -a 256 --check - tar -xzf "${ARCHIVE}" mkdir -p "${HOME}/.local/bin" @@ -140,7 +145,10 @@ BITBYGIT_INSTALL { install_dir="${HOME}/.local/bin" new_path="${install_dir}" -IFS=: read -r -a path_entries <<< "${PATH-}:__BITBYGIT_PATH_END__" +if [[ -z "${PATH+x}" ]]; then + PATH="$(command -p getconf PATH)" +fi +IFS=: read -r -a path_entries <<< "${PATH}:__BITBYGIT_PATH_END__" path_entry_count="${#path_entries[@]}" unset "path_entries[$((path_entry_count - 1))]" for entry in "${path_entries[@]}"; do @@ -326,7 +334,10 @@ BITBYGIT_INSTALL { install_dir="${HOME}/.local/bin" new_path="${install_dir}" -IFS=: read -r -a path_entries <<< "${PATH-}:__BITBYGIT_PATH_END__" +if [[ -z "${PATH+x}" ]]; then + PATH="$(command -p getconf PATH)" +fi +IFS=: read -r -a path_entries <<< "${PATH}:__BITBYGIT_PATH_END__" path_entry_count="${#path_entries[@]}" unset "path_entries[$((path_entry_count - 1))]" for entry in "${path_entries[@]}"; do diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 19c8a7b..09bd64d 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -46,6 +46,12 @@ extract_selected_block() { printf '%s' "${block/VERSION=${workspace_version}/VERSION=${selected_version}}" } +extract_selected_path_block() { + extract_selected_block "$1" bash | perl -0777 -ne ' + if (/^BITBYGIT_INSTALL\n(.*)\z/ms) { print $1 } + ' +} + release_artifacts=() while IFS= read -r artifact; do release_artifacts+=("${artifact}") @@ -125,6 +131,7 @@ check_unix_success() { ln -s protected-file "${case_dir}/SHA256SUMS" ln -s protected-package "${case_dir}/${package}" extract_selected_block "${heading}" bash > "${case_dir}/snippet.bash" + extract_selected_path_block "${heading}" > "${case_dir}/path-snippet.bash" [[ -s "${case_dir}/snippet.bash" ]] || fail "missing ${heading} Bash block" ( @@ -175,6 +182,11 @@ check_unix_success() { resolved_binary="$(type -P bitbygit)" [[ "${resolved_binary}" -ef "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not resolve the installed application through PATH" [[ "$(command bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} resolved the wrong version through PATH" + default_path="$(command -p getconf PATH)" + unset PATH + source "${case_dir}/path-snippet.bash" || fail "${heading} failed with an originally unset PATH" + [[ "${PATH}" == "${HOME}/.local/bin:${default_path}" ]] || fail "${heading} did not retain default command lookup for an originally unset PATH" + [[ -n "$(type -P ls)" ]] || fail "${heading} lost standard command lookup for an originally unset PATH" if compgen -G "${TMPDIR}/*" >/dev/null; then fail "${heading} left temporary installation files behind" fi @@ -246,6 +258,49 @@ extract_selected_block "${failure_heading}" bash > "${failure_dir}/snippet.bash" ) [[ ! -e "${failure_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed after checksum failure" +collision_dir="${tmp}/checksum-collision-${failure_target}" +collision_assets="${tmp}/checksum-collision-assets" +mkdir -p "${collision_dir}/home" "${collision_dir}/temp" "${collision_dir}/mock-bin" "${collision_assets}" +collision_archive="bitbygit-${selected_version}-${failure_target}.tar.gz" +cp "${tmp}/assets/${collision_archive}" "${collision_assets}/${collision_archive}" +printf 'malicious sidecar\n' > "${collision_assets}/${collision_archive}.sig" +if command -v sha256sum >/dev/null; then + collision_digest="$(sha256sum "${collision_assets}/${collision_archive}.sig")" +else + collision_digest="$(shasum -a 256 "${collision_assets}/${collision_archive}.sig")" +fi +printf '%s %s.sig\n' "${collision_digest%% *}" "${collision_archive}" > "${collision_assets}/SHA256SUMS" +cp "${tmp}/failure-bin/uname" "${tmp}/failure-bin/tar" "${tmp}/failure-bin/install" "${collision_dir}/mock-bin/" +cat > "${collision_dir}/mock-bin/curl" <<'MOCK' +#!/usr/bin/env bash +for source in "$@"; do :; done +name="${source##*/}" +cp "${MOCK_DOWNLOAD_DIR}/${name}" . +if [[ -f "${MOCK_DOWNLOAD_DIR}/${name}.sig" ]]; then + cp "${MOCK_DOWNLOAD_DIR}/${name}.sig" . +fi +MOCK +chmod +x "${collision_dir}/mock-bin/"* +extract_selected_block "${failure_heading}" bash > "${collision_dir}/snippet.bash" +( + cd "${collision_dir}" + original_path="${collision_dir}/mock-bin:${PATH}" + export HOME="${collision_dir}/home" + export TMPDIR="${collision_dir}/temp" + export MOCK_DOWNLOAD_DIR="${collision_assets}" + export MOCK_SIDE_EFFECTS="${collision_dir}/side-effects" + export MOCK_UNAME_MACHINE="${failure_machine}" + export PATH="${original_path}" + if source "${collision_dir}/snippet.bash"; then + fail "${failure_heading} accepted a checksum for a similarly named file" + fi + [[ "${PATH}" == "${original_path}" ]] || fail "checksum collision changed the caller PATH" + if compgen -G "${TMPDIR}/*" >/dev/null; then + fail "checksum collision left temporary installation files behind" + fi +) +[[ ! -e "${collision_dir}/side-effects" ]] || fail "${failure_heading} extracted or installed without an exact checksum entry" + cleanup_failure_dir="${tmp}/cleanup-failure-${failure_target}" mkdir -p "${cleanup_failure_dir}/home" "${cleanup_failure_dir}/temp" "${cleanup_failure_dir}/mock-bin" cp "${tmp}/failure-bin/"* "${cleanup_failure_dir}/mock-bin/" @@ -320,6 +375,7 @@ chmod +x "${source_dir}/mock-bin/"* source_snippet="$(extract_selected_block "Build from source" bash)" source_snippet="${source_snippet/https:\/\/github.com\/cosentinode\/bitbygit.git/${source_remote}}" printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" +extract_selected_path_block "Build from source" > "${source_dir}/path-snippet.bash" ( cd "${source_dir}" export HOME="${source_dir}/home" @@ -368,6 +424,11 @@ printf '%s' "${source_snippet}" > "${source_dir}/snippet.bash" resolved_binary="$(type -P bitbygit)" [[ "${resolved_binary}" -ef "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed application through PATH" [[ "$(command bitbygit --version)" == "bitbygit ${selected_version}" ]] || fail "Bash source block resolved the wrong version through PATH" + default_path="$(command -p getconf PATH)" + unset PATH + source "${source_dir}/path-snippet.bash" || fail "Bash source block failed with an originally unset PATH" + [[ "${PATH}" == "${HOME}/.local/bin:${default_path}" ]] || fail "Bash source block did not retain default command lookup for an originally unset PATH" + [[ -n "$(type -P git)" ]] || fail "Bash source block lost standard command lookup for an originally unset PATH" if compgen -G "${TMPDIR}/*" >/dev/null; then fail "Bash source block left temporary build files behind" fi From fb82aa52eb3aae642c15a5f707a0b655bd24f0f4 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:58:28 -0400 Subject: [PATCH 20/21] fix: isolate source build outputs --- docs/installation.md | 54 +++++++++++++------- scripts/test-installation-docs.ps1 | 41 ++++++++++++++-- scripts/test-installation-docs.sh | 79 ++++++++++++++++++++++++------ 3 files changed, 140 insertions(+), 34 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 0f88252..72b5315 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -76,12 +76,13 @@ new_path="${install_dir}" if [[ -z "${PATH+x}" ]]; then PATH="$(command -p getconf PATH)" fi -IFS=: read -r -a path_entries <<< "${PATH}:__BITBYGIT_PATH_END__" -path_entry_count="${#path_entries[@]}" -unset "path_entries[$((path_entry_count - 1))]" -for entry in "${path_entries[@]}"; do +remaining_path="${PATH}" +while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" done +[[ "${remaining_path}" == "${install_dir}" ]] || new_path+=":${remaining_path}" export PATH="${new_path}" resolved_binary="$(type -P bitbygit || true)" if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && @@ -148,12 +149,13 @@ new_path="${install_dir}" if [[ -z "${PATH+x}" ]]; then PATH="$(command -p getconf PATH)" fi -IFS=: read -r -a path_entries <<< "${PATH}:__BITBYGIT_PATH_END__" -path_entry_count="${#path_entries[@]}" -unset "path_entries[$((path_entry_count - 1))]" -for entry in "${path_entries[@]}"; do +remaining_path="${PATH}" +while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" done +[[ "${remaining_path}" == "${install_dir}" ]] || new_path+=":${remaining_path}" export PATH="${new_path}" resolved_binary="$(type -P bitbygit || true)" if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && @@ -300,6 +302,7 @@ VERSION="${VERSION}" bash -euo pipefail <<'BITBYGIT_INSTALL' && TAG="refs/tags/v${VERSION}" WORK_DIR="$(mktemp -d)" SOURCE_DIR="${WORK_DIR}/bitbygit" +TARGET_DIR="${WORK_DIR}/cargo-target" cleanup() { status=$? trap - EXIT @@ -316,15 +319,25 @@ git -C "${SOURCE_DIR}" fetch --depth 1 https://github.com/cosentinode/bitbygit.g tag_commit="$(git -C "${SOURCE_DIR}" rev-parse --verify "${TAG}^{commit}")" git -C "${SOURCE_DIR}" checkout --detach "${tag_commit}" [[ "$(git -C "${SOURCE_DIR}" rev-parse --verify HEAD)" == "${tag_commit}" ]] -cargo build --manifest-path "${SOURCE_DIR}/Cargo.toml" --locked --release -p bitbygit -built_output="$("${SOURCE_DIR}/target/release/bitbygit" --version)" +rustc_version="$(rustc -vV)" +host_target= +while IFS= read -r rustc_line; do + case "${rustc_line}" in + "host: "*) host_target="${rustc_line#host: }"; break ;; + esac +done <<< "${rustc_version}" +[[ -n "${host_target}" ]] +cargo build --manifest-path "${SOURCE_DIR}/Cargo.toml" --locked --release -p bitbygit \ + --target-dir "${TARGET_DIR}" --target "${host_target}" +built_binary="${TARGET_DIR}/${host_target}/release/bitbygit" +built_output="$("${built_binary}" --version)" if [[ "${built_output}" != "bitbygit ${VERSION}" ]]; then printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${built_output}" >&2 exit 1 fi mkdir -p "${HOME}/.local/bin" -install -m 0755 "${SOURCE_DIR}/target/release/bitbygit" "${HOME}/.local/bin/bitbygit" +install -m 0755 "${built_binary}" "${HOME}/.local/bin/bitbygit" installed_output="$("${HOME}/.local/bin/bitbygit" --version)" if [[ "${installed_output}" != "bitbygit ${VERSION}" ]]; then printf 'Expected bitbygit %s, got %s\n' "${VERSION}" "${installed_output}" >&2 @@ -337,12 +350,13 @@ new_path="${install_dir}" if [[ -z "${PATH+x}" ]]; then PATH="$(command -p getconf PATH)" fi -IFS=: read -r -a path_entries <<< "${PATH}:__BITBYGIT_PATH_END__" -path_entry_count="${#path_entries[@]}" -unset "path_entries[$((path_entry_count - 1))]" -for entry in "${path_entries[@]}"; do +remaining_path="${PATH}" +while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" [[ "${entry}" == "${install_dir}" ]] || new_path+=":${entry}" done +[[ "${remaining_path}" == "${install_dir}" ]] || new_path+=":${remaining_path}" export PATH="${new_path}" resolved_binary="$(type -P bitbygit || true)" if [[ -n "${resolved_binary}" && "${resolved_binary}" -ef "${install_dir}/bitbygit" ]] && @@ -366,6 +380,7 @@ $Version = "0.1.0" $Tag = "refs/tags/v$Version" $WorkDir = (New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()))).FullName $SourceDir = Join-Path $WorkDir "bitbygit" +$TargetDir = Join-Path $WorkDir "cargo-target" try { New-Item -ItemType Directory -Path $SourceDir | Out-Null @@ -379,9 +394,14 @@ git -C $SourceDir checkout --detach $TagCommit if ($LASTEXITCODE -ne 0) { throw "release tag checkout failed" } $HeadCommit = git -C $SourceDir rev-parse --verify HEAD if ($LASTEXITCODE -ne 0 -or $HeadCommit -ne $TagCommit) { throw "release tag checkout verification failed" } -cargo build --manifest-path (Join-Path $SourceDir "Cargo.toml") --locked --release -p bitbygit +$RustcVersion = rustc -vV +if ($LASTEXITCODE -ne 0) { throw "rustc version detection failed" } +$HostLine = $RustcVersion | Where-Object { $_.StartsWith("host: ") } | Select-Object -First 1 +if (-not $HostLine) { throw "rustc host target detection failed" } +$HostTarget = $HostLine.Substring(6) +cargo build --manifest-path (Join-Path $SourceDir "Cargo.toml") --locked --release -p bitbygit --target-dir $TargetDir --target $HostTarget if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } -$BuiltBinary = Join-Path $SourceDir "target\release\bitbygit.exe" +$BuiltBinary = Join-Path $TargetDir "$HostTarget\release\bitbygit.exe" $BuiltOutput = & $BuiltBinary --version if ($LASTEXITCODE -ne 0 -or $BuiltOutput -ne "bitbygit $Version") { throw "Expected bitbygit $Version, got $BuiltOutput" diff --git a/scripts/test-installation-docs.ps1 b/scripts/test-installation-docs.ps1 index 6934d8b..7f576b0 100644 --- a/scripts/test-installation-docs.ps1 +++ b/scripts/test-installation-docs.ps1 @@ -133,6 +133,8 @@ $OriginalProcessPath = $env:Path $OriginalTemp = $env:TEMP $OriginalTmp = $env:TMP $OriginalPathExt = $env:PATHEXT +$OriginalCargoTargetDir = $env:CARGO_TARGET_DIR +$OriginalCargoBuildTarget = $env:CARGO_BUILD_TARGET try { cargo build --locked --release -p bitbygit @@ -394,9 +396,6 @@ try { $ExpectedTag = "refs/tags/v$SelectedVersion" if ($Arguments.Count -eq 3 -and $Arguments[0] -eq "-C" -and $Arguments[2] -eq "init") { - $BuildDir = Join-Path $Arguments[1] "target/release" - New-Item -ItemType Directory -Path $BuildDir | Out-Null - Copy-Item $FixtureBinary (Join-Path $BuildDir "bitbygit.exe") } elseif ($Arguments.Count -eq 7 -and $Arguments[0] -eq "-C" -and $Arguments[2] -eq "fetch" -and $Arguments[3] -eq "--depth" -and $Arguments[4] -eq "1" -and @@ -422,10 +421,37 @@ try { } function cargo { + param( + [Alias("p")] [string] $Package, + [Parameter(ValueFromRemainingArguments = $true)] [object[]] $Arguments + ) + if ($env:MOCK_CARGO_FAILURE -eq "1") { $global:LASTEXITCODE = 1 return } + + $ManifestIndex = [array]::IndexOf($Arguments, "--manifest-path") + $TargetDirIndex = [array]::IndexOf($Arguments, "--target-dir") + $TargetIndex = [array]::IndexOf($Arguments, "--target") + if ($Arguments[0] -ne "build" -or $ManifestIndex -lt 0 -or $TargetDirIndex -lt 0 -or + $TargetIndex -lt 0 -or + -not $Arguments.Contains("--locked") -or -not $Arguments.Contains("--release") -or + $Package -ne "bitbygit") { + throw "unexpected cargo arguments: $Arguments" + } + $ManifestPath = [string] $Arguments[$ManifestIndex + 1] + $TargetDir = [string] $Arguments[$TargetDirIndex + 1] + $Target = [string] $Arguments[$TargetIndex + 1] + $ExpectedTargetDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ManifestPath)) "cargo-target" + if ($TargetDir -ne $ExpectedTargetDir -or $TargetDir -eq $env:CARGO_TARGET_DIR -or + $Target -ne $script:HostTarget) { + throw "cargo output was not isolated for the native target: $Arguments" + } + $BuildDir = Join-Path $TargetDir "$Target\release" + New-Item -ItemType Directory -Path $BuildDir | Out-Null + Copy-Item $FixtureBinary (Join-Path $BuildDir "bitbygit.exe") + $script:CargoUsedIsolatedTarget = $true $global:LASTEXITCODE = 0 } @@ -451,6 +477,11 @@ try { $env:Path = $SourceStartingPath $env:BITBYGIT_TEST_MACHINE_PATH = $MachinePathDir $env:BITBYGIT_TEST_USER_PATH = $SourceUserPath + $env:CARGO_TARGET_DIR = Join-Path $SourceSuccessDir "configured-target" + $env:CARGO_BUILD_TARGET = "configured-non-host-target" + $HostLine = rustc -vV | Where-Object { $_.StartsWith("host: ") } | Select-Object -First 1 + if (-not $HostLine) { Fail "could not determine validator host target" } + $HostTarget = $HostLine.Substring(6) $OldResolvedBinary = (Get-Command bitbygit -CommandType Application | Select-Object -First 1).Source if ($OldResolvedBinary -ne (Join-Path $OldBinaryDir "bitbygit.exe")) { Fail "older bitbygit.exe fixture was not first on PATH" } $OldOutput = bitbygit --version @@ -481,6 +512,7 @@ try { foreach ($Attempt in 1..2) { $FetchedExactTag = $false $CheckedOutExactTag = $false + $CargoUsedIsolatedTarget = $false $ShadowInvoked = $false . $SourceScript if ($ShadowInvoked) { Fail "source block invoked a shadowing alias or function" } @@ -491,6 +523,7 @@ try { if ((Get-ChildItem -Force $SourceSuccessTemp).Count -ne 0) { Fail "source block left temporary build files behind" } + if (-not $CargoUsedIsolatedTarget) { Fail "source block did not use isolated native Cargo output" } } } finally { Pop-Location @@ -582,5 +615,7 @@ try { $env:TEMP = $OriginalTemp $env:TMP = $OriginalTmp $env:PATHEXT = $OriginalPathExt + $env:CARGO_TARGET_DIR = $OriginalCargoTargetDir + $env:CARGO_BUILD_TARGET = $OriginalCargoBuildTarget Remove-Item -Recurse -Force $Temp -ErrorAction SilentlyContinue } diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 09bd64d..3c7fcea 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -34,6 +34,8 @@ workspace_version="$(perl -ne ' if ($workspace && /^version = "([^"]+)"$/) { print $1; exit } ' "${root}/Cargo.toml")" [[ -n "${workspace_version}" ]] || fail "workspace version was not found" +validator_host_target="$(rustc -vV | perl -ne 'if (/^host: (.+)$/) { print $1; exit }')" +[[ -n "${validator_host_target}" ]] || fail "rustc host target was not found" selected_version=2.3.4 [[ "${selected_version}" != "${workspace_version}" && "${selected_version}" != "0.1.0" ]] || fail "selected validator version must differ from the documented example" [[ "${docs_text}" == *"VERSION=${workspace_version}"* ]] || fail "Bash examples do not use workspace version ${workspace_version}" @@ -144,8 +146,9 @@ check_unix_success() { export MOCK_DOWNLOAD_DIR="${tmp}/assets" export MOCK_UNAME_MACHINE="${machine}" base_path="${PATH}" - expected_path="${HOME}/.local/bin::${tmp}/mock-bin::${base_path}:" - export PATH=":${tmp}/mock-bin::${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" + newline_entry="${case_dir}/line"$'\n'"break" + expected_path="${HOME}/.local/bin::${tmp}/mock-bin::${newline_entry}:${base_path}:" + export PATH=":${tmp}/mock-bin::${newline_entry}:${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" starting_dir="${PWD}" bitbygit() { @@ -172,10 +175,13 @@ check_unix_success() { fi [[ "${PATH}" == "${expected_path}" ]] || fail "${heading} did not preserve empty PATH entries while deduplicating the install directory" path_entry_count=0 - IFS=: read -r -a path_entries <<< "${PATH}" - for entry in "${path_entries[@]}"; do + remaining_path="${PATH}" + while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" [[ "${entry}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) done + [[ "${remaining_path}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) [[ "${path_entry_count}" -eq 1 ]] || fail "${heading} duplicated the install directory on PATH" [[ -x "${HOME}/.local/bin/bitbygit" ]] || fail "${heading} did not install the binary" [[ "$("${HOME}/.local/bin/bitbygit" --version)" == "bitbygit ${selected_version}" ]] || fail "${heading} installed the wrong version" @@ -339,6 +345,10 @@ extract_selected_block "${failure_heading}" bash > "${cleanup_failure_dir}/snipp [[ "$(grep -c -- '-ef "${install_dir}/bitbygit"' "${docs}")" -eq 3 ]] || fail "Unix installations do not verify resolved file identity" [[ "$(grep -c '^ command bitbygit --version$' "${docs}")" -eq 3 ]] || fail "Unix installations do not finish with PATH-resolved version output" [[ "$(grep -c '^& \$ResolvedPath --version$' "${docs}")" -eq 2 ]] || fail "Windows installations do not finish with PATH-resolved version output" +[[ "$(grep -c 'remaining_path="${PATH}"' "${docs}")" -eq 3 ]] || fail "Unix installations do not parse PATH by its colon delimiter" +[[ "${docs_text}" != *'read -r -a path_entries'* ]] || fail "Unix installations use line-oriented PATH serialization" +[[ "${docs_text}" == *'--target-dir "${TARGET_DIR}" --target "${host_target}"'* ]] || fail "Bash source build does not isolate and pin Cargo output" +[[ "${docs_text}" == *'--target-dir $TargetDir --target $HostTarget'* ]] || fail "PowerShell source build does not isolate and pin Cargo output" source_remote="${tmp}/source-remote.git" source_seed="${tmp}/source-seed" @@ -346,13 +356,11 @@ git init --bare --quiet "${source_remote}" git init --quiet "${source_seed}" git -C "${source_seed}" config user.email validator@example.invalid git -C "${source_seed}" config user.name "Installation validator" -mkdir -p "${source_seed}/target/release" -printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${selected_version}" > "${source_seed}/target/release/bitbygit" -chmod +x "${source_seed}/target/release/bitbygit" -git -C "${source_seed}" add -f target/release/bitbygit +printf '[workspace]\nmembers = []\n' > "${source_seed}/Cargo.toml" +git -C "${source_seed}" add Cargo.toml git -C "${source_seed}" commit --quiet -m "tagged source" git -C "${source_seed}" tag -a "v${selected_version}" -m "release ${selected_version}" -printf '#!/usr/bin/env sh\nprintf '\''bitbygit 9.9.9\\n'\''\n' > "${source_seed}/target/release/bitbygit" +printf '[workspace]\nmembers = []\n# branch, not tag\n' > "${source_seed}/Cargo.toml" git -C "${source_seed}" commit --quiet -am "same-named branch" git -C "${source_seed}" branch "v${selected_version}" git -C "${source_seed}" push --quiet "${source_remote}" \ @@ -368,7 +376,39 @@ cat > "${source_dir}/mock-bin/cargo" <<'MOCK' if [ "${MOCK_CARGO_FAILURE:-0}" = 1 ]; then exit 1 fi -exit 0 +manifest= +target_dir= +target= +locked=0 +release=0 +package= +while [ "$#" -gt 0 ]; do + case "$1" in + build) ;; + --manifest-path) shift; manifest="$1" ;; + --target-dir) shift; target_dir="$1" ;; + --target) shift; target="$1" ;; + --locked) locked=1 ;; + --release) release=1 ;; + -p) shift; package="$1" ;; + *) exit 64 ;; + esac + shift +done +[ -n "${manifest}" ] && [ -f "${manifest}" ] || exit 65 +[ "${target}" = "${MOCK_HOST_TARGET}" ] || exit 66 +[ "${target_dir}" != "${CARGO_TARGET_DIR}" ] || exit 67 +case "${target_dir}" in */cargo-target) ;; *) exit 68 ;; esac +[ "${locked}" = 1 ] && [ "${release}" = 1 ] && [ "${package}" = bitbygit ] || exit 69 +mkdir -p "${target_dir}/${target}/release" +printf '#!/usr/bin/env sh\nprintf '\''bitbygit %s\\n'\''\n' "${MOCK_SELECTED_VERSION}" > "${target_dir}/${target}/release/bitbygit" +chmod +x "${target_dir}/${target}/release/bitbygit" +printf '%s\n' "${target_dir}/${target}/release/bitbygit" > "${MOCK_CARGO_RECORD}" +MOCK +cat > "${source_dir}/mock-bin/rustc" <<'MOCK' +#!/usr/bin/env sh +[ "$#" -eq 1 ] && [ "$1" = -vV ] || exit 64 +printf 'rustc 1.85.0\nhost: %s\nrelease: 1.85.0\n' "${MOCK_HOST_TARGET}" MOCK cp "${tmp}/mock-bin/bitbygit" "${source_dir}/mock-bin/bitbygit" chmod +x "${source_dir}/mock-bin/"* @@ -380,9 +420,15 @@ extract_selected_path_block "Build from source" > "${source_dir}/path-snippet.ba cd "${source_dir}" export HOME="${source_dir}/home" export TMPDIR="${source_dir}/temp" + export CARGO_TARGET_DIR="${source_dir}/configured-target" + export CARGO_BUILD_TARGET="configured-non-host-target" + export MOCK_HOST_TARGET="${validator_host_target}" + export MOCK_SELECTED_VERSION="${selected_version}" + export MOCK_CARGO_RECORD="${source_dir}/cargo-output" base_path="${PATH}" - original_path=":${source_dir}/mock-bin::${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" - expected_path="${HOME}/.local/bin::${source_dir}/mock-bin::${base_path}:" + newline_entry="${source_dir}/line"$'\n'"break" + original_path=":${source_dir}/mock-bin::${newline_entry}:${HOME}/.local/bin:${base_path}:${HOME}/.local/bin:" + expected_path="${HOME}/.local/bin::${source_dir}/mock-bin::${newline_entry}:${base_path}:" export PATH="${original_path}" starting_dir="${PWD}" export MOCK_CARGO_FAILURE=1 @@ -401,6 +447,8 @@ extract_selected_path_block "Build from source" > "${source_dir}/path-snippet.ba printf 'bitbygit %s\n' "${selected_version}" } source "${source_dir}/snippet.bash" || fail "Bash source block could not retry a failed build" + [[ -s "${MOCK_CARGO_RECORD}" ]] || fail "Bash source block did not use the isolated Cargo output" + [[ "$(<"${MOCK_CARGO_RECORD}")" != "${CARGO_TARGET_DIR}"* ]] || fail "Bash source block honored an external Cargo target directory" [[ ! -e "${source_dir}/function-shadow-used" ]] || fail "Bash source block invoked a shadowing function" unset -f bitbygit shadow_bitbygit() { @@ -416,10 +464,13 @@ extract_selected_path_block "Build from source" > "${source_dir}/path-snippet.ba [[ "${PWD}" == "${starting_dir}" ]] || fail "Bash source block changed the caller working directory" [[ "${PATH}" == "${expected_path}" ]] || fail "Bash source block did not preserve empty PATH entries while deduplicating the install directory" path_entry_count=0 - IFS=: read -r -a path_entries <<< "${PATH}" - for entry in "${path_entries[@]}"; do + remaining_path="${PATH}" + while [[ "${remaining_path}" == *:* ]]; do + entry="${remaining_path%%:*}" + remaining_path="${remaining_path#*:}" [[ "${entry}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) done + [[ "${remaining_path}" == "${HOME}/.local/bin" ]] && path_entry_count=$((path_entry_count + 1)) [[ "${path_entry_count}" -eq 1 ]] || fail "Bash source block duplicated the install directory on PATH" resolved_binary="$(type -P bitbygit)" [[ "${resolved_binary}" -ef "${HOME}/.local/bin/bitbygit" ]] || fail "Bash source block did not resolve the installed application through PATH" From 89b6bc5e3c571ddb8d2e8cf77e10cbad79ef9102 Mon Sep 17 00:00:00 2001 From: Adrian Cosentino Date: Wed, 15 Jul 2026 18:59:33 -0400 Subject: [PATCH 21/21] test: avoid host detection pipe failure --- scripts/test-installation-docs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-installation-docs.sh b/scripts/test-installation-docs.sh index 3c7fcea..ab47f2d 100755 --- a/scripts/test-installation-docs.sh +++ b/scripts/test-installation-docs.sh @@ -34,7 +34,7 @@ workspace_version="$(perl -ne ' if ($workspace && /^version = "([^"]+)"$/) { print $1; exit } ' "${root}/Cargo.toml")" [[ -n "${workspace_version}" ]] || fail "workspace version was not found" -validator_host_target="$(rustc -vV | perl -ne 'if (/^host: (.+)$/) { print $1; exit }')" +validator_host_target="$(rustc -vV | perl -ne 'print $1 if /^host: (.+)$/')" [[ -n "${validator_host_target}" ]] || fail "rustc host target was not found" selected_version=2.3.4 [[ "${selected_version}" != "${workspace_version}" && "${selected_version}" != "0.1.0" ]] || fail "selected validator version must differ from the documented example"