diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bd5bda..15fbbea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,10 @@ jobs: - name: shellcheck uses: docker://koalaman/shellcheck@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d # v0.11.0 with: - args: -x scripts/lib.sh scripts/resolve.sh scripts/install.sh scripts/verify-checksum.sh scripts/check.sh test/checksum-test.sh + args: >- + -x scripts/lib.sh scripts/resolve.sh scripts/install.sh scripts/verify-checksum.sh scripts/check.sh + test/checksum-test.sh test/harness.sh test/lib-test.sh test/resolve-test.sh + test/verify-checksum-test.sh test/install-test.sh test/check-test.sh test/fixtures/fake-cli/vericto - name: Every action is pinned by full commit SHA with a version comment shell: bash @@ -47,6 +50,30 @@ jobs: exit 1 fi + unit: + name: Unit tests, offline (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + # GNU (Linux) and BSD (macOS) userlands: tar, sed, mktemp, sha256sum/shasum. + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # scripts/*.sh against local fixtures and a stand-in CLI: no network. + - name: test/*-test.sh + shell: bash + run: | + set -euo pipefail + for t in lib resolve verify-checksum install check; do + echo "::group::test/$t-test.sh" + bash "test/$t-test.sh" + echo "::endgroup::" + done + checksum: name: Checksum gate (tampered checksum and archive must fail) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 10bf699..028104c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to ## [Unreleased] +## [1.0.2] - 2026-10-18 + +### Fixed +- Multi-line inputs no longer pass validation. The validators matched with + `grep`, which works line by line, so a `cli-version`, `dialect`, `timeout`, + `api-url`, `workspace` or `since` with a line break passed as long as one of + its lines was valid; `check.sh` then handed the CLI one argument with a + newline in it (not a shell injection, but validation was defeated), and + `resolve.sh` stopped with exit 1 at `set_output` instead of the input error. + A value containing a line feed or a carriage return, including a trailing + one, is now an input error (exit 2) with the input's usual message. All + validators share one whole-value helper, `matches` in `scripts/lib.sh` + (bash `=~`, bash 3.2 and 5.x). If a workflow passes one of these inputs as a + YAML block scalar (`dialect: |`), which adds a trailing newline, write it on + one line instead. +- `check.sh` no longer aborts with "VA_FILES: unbound variable" (exit 1) on + bash 4+ when `VA_FILES` is unset. Only direct runs of the script were + affected; `action.yml` always sets it. + +### Added +- Offline unit tests for every script (`test/lib-test.sh`, + `test/resolve-test.sh`, `test/verify-checksum-test.sh`, + `test/install-test.sh`, `test/check-test.sh`), with a stand-in CLI and a + `file://` release fixture, run in CI on Linux and macOS and covered by + shellcheck. Line coverage of `scripts/` measured with kcov: 21% to 97%. No + behaviour change. + ## [1.0.1] - 2026-10-12 ### Changed @@ -33,6 +60,7 @@ and this project adheres to - SARIF upload to Code Scanning, with paths made relative to the repository root, and a job summary. -[Unreleased]: https://github.com/vericto/check-sql/compare/v1.0.1...HEAD +[Unreleased]: https://github.com/vericto/check-sql/compare/v1.0.2...HEAD +[1.0.2]: https://github.com/vericto/check-sql/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/vericto/check-sql/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/vericto/check-sql/releases/tag/v1.0.0 diff --git a/README.md b/README.md index e0b7c2d..f42655c 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,22 @@ Releases follow [Semantic Versioning](https://semver.org). The major tag Actions; `@v1.0.0` or a commit SHA stays fixed. Each action release pins a CLI version, and a CLI bump ships as a new action release. +## Tests + +The scripts in `scripts/` have offline unit tests (bash 3.2+, `git` and `jq`), +run in CI on Linux and macOS: + +```bash +for t in lib resolve verify-checksum install check; do bash "test/$t-test.sh"; done +``` + +They use local fixtures only: `test/install-test.sh` serves the release +download from `file://` through a `curl` stand-in, and `test/check-test.sh` +runs `check.sh` against a stand-in CLI (`test/fixtures/fake-cli/vericto`). A +case marked `xfail` is a known bug, kept visible until it is fixed. +`test/checksum-test.sh VERSION` checks the same gate against the real release +asset and needs the network. + ## License [Elastic License 2.0](LICENSE) (SPDX `Elastic-2.0`), the same license as the diff --git a/scripts/check.sh b/scripts/check.sh index 4ed17c9..b27924e 100644 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -85,8 +85,6 @@ bool_input() { # NAME VALUE VAR: stores true/false in VAR esac } -matches() { printf '%s' "$2" | grep -Eq "$1"; } - : "${VA_BIN:?the install step did not provide a binary}" # ── Validate inputs ────────────────────────────────────────────────────────── @@ -131,7 +129,9 @@ if [ -n "$since" ] && ! matches '^[^-[:space:][:cntrl:]][^[:space:][:cntrl:]]*$' fail 2 "input 'since' must be a git ref, e.g. origin/main (got '$since')" fi baseline=${VA_BASELINE:-} -case "$baseline" in *$'\n'* | *$'\r'*) fail 2 "input 'baseline' must be a single path" ;; esac +is_single_line "$baseline" || fail 2 "input 'baseline' must be a single path" +# Optional: action.yml always sets it, a direct run may not (set -u). +files=${VA_FILES:-} # ── Credentials ────────────────────────────────────────────────────────────── args=(check "--format=$format") @@ -235,7 +235,7 @@ expand_files() { # OUT local out=$1 pat m n f i local -a pats found shopt -s nullglob - read -r -d '' -a pats <<<"$VA_FILES" || true + read -r -d '' -a pats <<<"$files" || true [ "${#pats[@]}" -gt 0 ] || fail 2 "input 'files' holds no patterns" # From here on, expand globs without word splitting. local IFS='' @@ -260,7 +260,7 @@ expand_files() { # OUT while [ "$i" -lt "${#found[@]}" ]; do m=${found[$i]} i=$((i + 1)) - case "$m" in *$'\n'* | *$'\r'*) fail 2 "file names with line breaks are not supported" ;; esac + is_single_line "$m" || fail 2 "file names with line breaks are not supported" m=${m#./} if [ -d "$m" ]; then while IFS= read -r f; do @@ -277,7 +277,7 @@ expand_files() { # OUT awk '!seen[$0]++' "$out.raw" >"$out" } -if [ -n "${VA_FILES//[[:space:]]/}" ]; then +if [ -n "${files//[[:space:]]/}" ]; then [ "$changed" = true ] && fail 2 "set either files or changed: true, not both" [ -n "$since" ] && fail 2 "set either files or since, not both" mode=files diff --git a/scripts/lib.sh b/scripts/lib.sh index dad28df..4bedba4 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -26,15 +26,32 @@ $1 EOF } +# is_single_line VALUE: true when VALUE holds no line feed and no carriage +# return. +is_single_line() { + case "$1" in + *$'\n'* | *$'\r'*) return 1 ;; + esac + return 0 +} + +# matches ERE VALUE: true when VALUE is a single line and matches the extended +# regex ERE as a whole (anchor ERE with ^...$). Every input validator uses +# this. grep would not do: it matches line by line, so a multi-line value would +# pass as long as one of its lines matched. Bash's =~ matches the whole string, +# in bash 3.2 (the macOS runners' bash) and 5.x alike. +matches() { + is_single_line "$2" || return 1 + [[ $2 =~ $1 ]] +} + # set_output NAME VALUE. Values are single-line by construction (paths and # numbers); a newline would be rejected rather than allowed to forge an output. set_output() { - case "$2" in - *$'\n'* | *$'\r'*) - gh_error "internal: refusing a multi-line value for output '$1'" - return 1 - ;; - esac + if ! is_single_line "$2"; then + gh_error "internal: refusing a multi-line value for output '$1'" + return 1 + fi if [ -n "${GITHUB_OUTPUT:-}" ]; then printf '%s=%s\n' "$1" "$2" >>"$GITHUB_OUTPUT" else diff --git a/scripts/resolve.sh b/scripts/resolve.sh index edd05a1..4ad380f 100644 --- a/scripts/resolve.sh +++ b/scripts/resolve.sh @@ -11,7 +11,7 @@ set -euo pipefail version=${VA_CLI_VERSION:-} version=${version#v} -if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then +if ! matches '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' "$version"; then gh_error "cli-version must be an exact release such as 1.7.1 (got '${VA_CLI_VERSION:-}'). 'latest' is not accepted: pin a version." exit 2 fi diff --git a/scripts/verify-checksum.sh b/scripts/verify-checksum.sh index 1ee16d6..effc8ba 100644 --- a/scripts/verify-checksum.sh +++ b/scripts/verify-checksum.sh @@ -33,7 +33,7 @@ name=${line#"${line%%[[:space:]]*}"} name=${name#"${name%%[![:space:]]*}"} name=${name#\*} -if ! printf '%s' "$expected" | grep -Eq '^[0-9a-f]{64}$'; then +if ! matches '^[0-9a-f]{64}$' "$expected"; then gh_error "checksum: $sumfile does not hold a SHA-256 digest" exit 1 fi diff --git a/test/check-test.sh b/test/check-test.sh new file mode 100644 index 0000000..ff5543c --- /dev/null +++ b/test/check-test.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env bash +# Offline tests for scripts/check.sh with a stand-in CLI +# (test/fixtures/fake-cli/vericto): input validation, credentials, which SQL +# is selected (files, directories, globs, changed files), report formats, +# exit codes, outputs, annotations and the job summary. +# +# Usage: bash test/check-test.sh +set -euo pipefail + +# shellcheck source=test/harness.sh +. "$(dirname "$0")/harness.sh" + +if ! command -v jq >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then + echo "::error::test/check-test.sh needs jq and git (GitHub-hosted runners have both)" + exit 1 +fi + +mkdir -p "$TMP/bin" "$TMP/log" +cp "$FIXTURES/fake-cli/vericto" "$TMP/bin/vericto" +chmod +x "$TMP/bin/vericto" +FAKE="$TMP/bin/vericto" +SUMMARY="$TMP/summary.md" +KEY=vtro_unit_test_key_0123456789 + +# A workspace outside git with the SQL fixtures, a hidden directory, a name +# with a space and a file that is not SQL. +ws="$TMP/ws" +mkdir -p "$ws/.hidden" "$ws/spaced" +cp -R "$FIXTURES/migrations" "$FIXTURES/nested" "$ws/" +printf 'DELETE FROM secrets;\n' >"$ws/.hidden/secret.sql" +printf 'SELECT 1;\n' >"$ws/spaced/with space.sql" +printf 'not sql\n' >"$ws/migrations/README.txt" + +# check DIR [VAR=VALUE ...]: runs check.sh from DIR with the stand-in CLI. +# VA_FILES is always set, possibly empty, as action.yml always sets it. +check() { + local dir=$1 here=$PWD + shift + rm -f "$TMP/log/"* + : >"$SUMMARY" + cd "$dir" + run VA_BIN="$FAKE" FAKE_LOG_DIR="$TMP/log" GITHUB_STEP_SUMMARY="$SUMMARY" VA_FILES= "$@" bash "$SCRIPTS/check.sh" + cd "$here" +} +logged() { if [ -f "$TMP/log/$1" ]; then cat "$TMP/log/$1"; else printf ''; fi; } +summary() { cat "$SUMMARY"; } + +expect_usage_error() { # DESCRIPTION MESSAGE + expect_rc 2 "$1" + expect_out "$2" "$1: says why" + expect_output exit-code 2 "$1: exit-code output" + expect_output result fail "$1: result output" + expect_eq "$(logged args)" "" "$1: the CLI did not run" +} + +echo "# input validation (exit 2, the CLI never runs)" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_FORMAT=html +expect_usage_error "unknown format" "input 'format' must be sarif, json, text, gitlab-codequality or gitlab-sast (got 'html')" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_OIDC=yes +expect_usage_error "oidc not a boolean" "input 'oidc' must be true or false (got 'yes')" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_UPLOAD_SARIF=1 +expect_usage_error "upload-sarif not a boolean" "input 'upload-sarif' must be true or false (got '1')" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_CHANGED=maybe +expect_usage_error "changed not a boolean" "input 'changed' must be true or false (got 'maybe')" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_DIALECT='postgres"; touch "pwned' +expect_usage_error "hostile dialect" "input 'dialect' must be a dialect name" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_DIALECT=Postgres +expect_usage_error "upper-case dialect" "input 'dialect' must be a dialect name" +for t in 0 -1 1.5 30s 100000; do + check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_TIMEOUT="$t" + expect_usage_error "timeout '$t'" "input 'timeout' must be a whole number of seconds (got '$t')" +done +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_API_URL=ftp://api.vericto.com +expect_usage_error "api-url not http(s)" "input 'api-url' must be an http(s) URL" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_API_URL='https://api.vericto.com/ x' +expect_usage_error "api-url with a space" "input 'api-url' must be an http(s) URL" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_WORKSPACE='ws_1;rm' +expect_usage_error "workspace with a ;" "input 'workspace' must be a workspace ID such as ws_123" +check "$ws" VA_API_KEY="$KEY" VA_SINCE=--output=/etc/passwd +expect_usage_error "since that looks like an option" "input 'since' must be a git ref, e.g. origin/main" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_BASELINE="$(printf 'a.json\nb.json')" +expect_usage_error "multi-line baseline" "input 'baseline' must be a single path" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_CHANGED=true +expect_usage_error "files and changed" "set either files or changed: true, not both" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_SINCE=origin/main +expect_usage_error "files and since" "set either files or since, not both" +check "$ws" VA_API_KEY="$KEY" VA_CHANGED=false +expect_usage_error "nothing to check" "nothing to check: set files, or leave changed on" +expect_out "::error title=Vericto SQL Check::" "usage errors are error annotations" +case "$(summary)" in + *"### Vericto SQL Check: FAIL"*"| Exit code | \`2\` (usage error) |"*) ok "usage errors are in the job summary" ;; + *) not_ok "summary for a usage error" "$(summary)" ;; +esac +run FAKE_LOG_DIR="$TMP/log" bash "$SCRIPTS/check.sh" +expect_rc 1 "fails without the install step's binary" +expect_out "the install step did not provide a binary" "says so" + +# A value with a line break is an input error even when its first line is +# valid: LF, CRLF, a trailing LF and a trailing CR, on every validated input. +# (Validation used to match line by line, so these reached the CLI as one +# argument with a newline in it.) +while read -r var good second msg <&3; do + for v in "$good"$'\n'"$second" "$good"$'\r\n'"$second" "$good"$'\n' "$good"$'\r'; do + label="multi-line $var $(printf '%q' "$v")" + if [ "$var" = VA_SINCE ]; then + check "$ws" VA_API_KEY="$KEY" "$var=$v" + else + check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations "$var=$v" + fi + expect_usage_error "$label" "$msg" + done +done 3<<'EOF' +VA_DIALECT postgres --format=text input 'dialect' must be a dialect name +VA_TIMEOUT 5 9 input 'timeout' must be a whole number of seconds +VA_API_URL https://api.vericto.com http://evil.example input 'api-url' must be an http(s) URL +VA_WORKSPACE ws_1 ws_2 input 'workspace' must be a workspace ID such as ws_123 +VA_SINCE origin/main --x input 'since' must be a git ref, e.g. origin/main +EOF +check "$ws" VA_OIDC=true ACTIONS_ID_TOKEN_REQUEST_URL=https://token.example VA_FILES=migrations/0001_create_users.sql \ + VA_WORKSPACE=$'ws_1\nws_2' +expect_usage_error "multi-line workspace under oidc" "input 'workspace' must be a workspace ID such as ws_123" +for v in $'a.json\r\nb.json' $'a.json\n' $'a.json\r'; do + check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations VA_BASELINE="$v" + expect_usage_error "multi-line baseline $(printf '%q' "$v")" "input 'baseline' must be a single path" +done + +# Every optional input unset (a direct run, not through action.yml): set -u +# must not abort, and no option is passed for them. +rm -f "$TMP/log/"* +cd "$ws" +run VA_BIN="$FAKE" FAKE_LOG_DIR="$TMP/log" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql bash "$SCRIPTS/check.sh" +cd "$ROOT" +expect_rc 0 "every optional input unset" +expect_no_out "unbound variable" "every optional input unset: no set -u abort" +case "$(logged args)" in + *--dialect* | *--baseline* | *--timeout* | *--api-url* | *--since* | *--oidc*) not_ok "unset inputs add flags" "$(logged args)" ;; + *--stdin-file-list*) ok "every optional input unset: no flags for them" ;; + *) not_ok "unset inputs: files mode" "$(logged args)" ;; +esac + +echo "# credentials" +check "$ws" VA_FILES=migrations/0001_create_users.sql +expect_rc 3 "no api-key, no VERICTO_API_KEY, no oidc" +expect_out "no credentials: set 'api-key: \${{ secrets.VERICTO_API_KEY }}', or 'oidc: true' with 'workspace'" "explains the options" +expect_output exit-code 3 "exit-code 3" +expect_output result fail "result fail" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql +expect_rc 0 "an api-key" +expect_eq "$(printf '%s\n' "$out" | head -n 1)" "::add-mask::$KEY" "masks the key before anything else is printed" +expect_eq "$(logged api-key)" "$KEY" "passes it to the CLI as VERICTO_API_KEY" +case "$(logged args)$(printf '%s' "$out" | sed 1d)" in + *"$KEY"*) not_ok "the key appears on the command line or in the log" ;; + *) ok "the key is never on the command line or in the log" ;; +esac +check "$ws" VERICTO_API_KEY=vtro_job_level VA_FILES=migrations/0001_create_users.sql +expect_rc 0 "a job-level VERICTO_API_KEY" +expect_out "::add-mask::vtro_job_level" "is masked too" +expect_eq "$(logged api-key)" vtro_job_level "and reaches the CLI" +check "$ws" VA_API_KEY="$KEY" VERICTO_API_KEY=vtro_job_level VA_FILES=migrations/0001_create_users.sql +expect_eq "$(logged api-key)" "$KEY" "the api-key input wins over the job-level variable" +check "$ws" VA_OIDC=true VA_API_KEY="$KEY" VA_FILES=migrations +expect_usage_error "api-key and oidc" "set either api-key or oidc: true, not both" +check "$ws" VA_OIDC=true VA_WORKSPACE=ws_1 VA_FILES=migrations +expect_rc 3 "oidc without id-token: write" +expect_out "oidc: true needs 'permissions: id-token: write' on the job" "explains the permission" +check "$ws" VA_OIDC=TRUE VA_WORKSPACE=ws_123 VERICTO_API_KEY=vtro_job_level ACTIONS_ID_TOKEN_REQUEST_URL=https://token.example \ + VA_FILES=migrations/0001_create_users.sql +expect_rc 0 "oidc with a token endpoint" +case "$(logged args)" in *--oidc*) ok "passes --oidc" ;; *) not_ok "--oidc" "$(logged args)" ;; esac +expect_eq "$(logged api-key)" "" "a job-level static key does not reach the CLI with oidc" +expect_eq "$(logged workspace-id)" ws_123 "exports VERICTO_WORKSPACE_ID" +check "$ws" VA_OIDC=true ACTIONS_ID_TOKEN_REQUEST_URL=https://token.example VA_FILES=migrations/0001_create_users.sql +expect_eq "$(logged workspace-id)" "" "no workspace input: left to the CLI (.vericto.toml)" + +echo "# CLI arguments" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql VA_DIALECT=mysql VA_BASELINE="base line.json" \ + VA_TIMEOUT=45 VA_API_URL=https://eu.vericto.example +expect_rc 0 "optional inputs" +args=$(logged args) +expect_eq "$(printf '%s\n' "$args" | sed -n '1,5p')" "--format=sarif +--dialect=mysql +--baseline=base line.json +--timeout=45 +--api-url=https://eu.vericto.example" "each input is one argument, spaces included" +expect_no_out "plain http" "no warning for https" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql VA_API_URL=http://127.0.0.1:8080 +expect_rc 0 "a plain http api-url is allowed" +expect_out "::warning title=Vericto SQL Check::api-url uses plain http: the API key and the SQL travel unencrypted." "with a warning" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql +case "$(logged args)" in *--dialect* | *--baseline* | *--timeout* | *--api-url*) not_ok "empty inputs add flags" "$(logged args)" ;; *) ok "empty inputs add no flags (CLI defaults)" ;; esac + +echo "# files: paths, directories and globs" +files_for() { # PATTERNS: prints the file list the CLI received, or the exit code + check "$ws" VA_API_KEY="$KEY" VA_FILES="$1" + if [ -f "$TMP/log/files" ]; then logged files; else printf 'exit %s' "$rc"; fi +} +expect_eq "$(files_for migrations/0001_create_users.sql)" "migrations/0001_create_users.sql" "one file" +expect_eq "$(files_for ./migrations/0001_create_users.sql)" "migrations/0001_create_users.sql" "a leading ./ is dropped" +expect_eq "$(files_for 'migrations/*.sql')" "migrations/0001_create_users.sql +migrations/0002_cleanup.sql" "a * glob" +expect_eq "$(files_for 'migrations/000?_*.sql')" "migrations/0001_create_users.sql +migrations/0002_cleanup.sql" "a ? glob" +expect_eq "$(files_for 'migrations/000[2-9]_*.sql')" "migrations/0002_cleanup.sql" "a [...] glob" +expect_eq "$(files_for migrations)" "migrations/0001_create_users.sql +migrations/0002_cleanup.sql" "a directory: its *.sql files only" +expect_eq "$(files_for spaced)" "spaced/with space.sql" "a file name with a space, found through a directory" +expect_eq "$(files_for '**/*.sql')" "migrations/0001_create_users.sql +migrations/0002_cleanup.sql +nested/deep/0003_index.sql +spaced/with space.sql" "** finds nested files, sorted, and skips hidden directories" +expect_eq "$(files_for 'nested/**/*.sql')" "nested/deep/0003_index.sql" "** below a literal base" +expect_eq "$(files_for '**/0003_index.sql')" "nested/deep/0003_index.sql" "**/ matches any depth" +expect_eq "$(files_for 'nested/**')" "nested/deep/0003_index.sql" "a trailing ** matches everything below" +expect_eq "$(files_for '**.sql')" "migrations/0001_create_users.sql +migrations/0002_cleanup.sql +nested/deep/0003_index.sql +spaced/with space.sql" "** without a slash crosses directories" +expect_eq "$(files_for '**/000[!12]_*.sql')" "nested/deep/0003_index.sql" "** with a negated bracket" +expect_eq "$(files_for '**/0001_create_users.s?l')" "migrations/0001_create_users.sql" "** with ?" +expect_eq "$(files_for "$(printf 'migrations\nmigrations/*.sql nested')")" "migrations/0001_create_users.sql +migrations/0002_cleanup.sql +nested/deep/0003_index.sql" "patterns on several lines; duplicates checked once" +expect_eq "$(files_for '**/0001_create_users_sql')" "exit 2" "a . in a ** pattern is literal" +expect_eq "$(files_for '**/[abc')" "exit 2" "an unclosed [ in a ** pattern is literal" +expect_eq "$(files_for 'missing/**/*.sql')" "exit 2" "a ** pattern under a missing directory matches nothing" +check "$ws" VA_API_KEY="$KEY" VA_FILES='nothing/*.sql' +expect_usage_error "no SQL files found" "no SQL files match 'nothing/*.sql'" +check "$ws" VA_API_KEY="$KEY" VA_FILES='migrations/*.sql nothing.sql' +expect_usage_error "every pattern must match" "no SQL files match 'nothing.sql'" +check "$ws" VA_API_KEY="$KEY" VA_FILES=--help +expect_usage_error "a pattern that looks like an option" "file pattern '--help' must not start with '-'" +check "$ws" VA_API_KEY="$KEY" VA_FILES="\$(touch $TMP/pwned-files) \`touch $TMP/pwned-files\`" +expect_rc 2 "hostile files" +if ls "$TMP"/pwned-* >/dev/null 2>&1; then not_ok "a files pattern was executed"; else ok "hostile files ran nothing"; fi +check "$ws" VA_API_KEY="$KEY" VA_FILES=' ' +expect_usage_error "whitespace-only files means changed files, which needs git" "changed needs a git checkout" + +echo "# results, outputs and annotations" +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql +expect_rc 0 "no findings: exit 0" +expect_output result pass "result pass" +expect_output blocked 0 "blocked 0" +expect_output flagged 0 "flagged 0" +expect_output monitored 0 "monitored 0" +expect_output parse-errors 0 "parse-errors 0" +sarif=$(output_of sarif-file) +if [ -s "$sarif" ]; then ok "sarif-file points at the report"; else not_ok "sarif-file" "$outputs"; fi +expect_output report-file "$sarif" "report-file is the same report" +expect_out "Running: vericto check --format=sarif --stdin-file-list --output=" "logs the command" +case "$(summary)" in + *"### Vericto SQL Check: PASS"*"| Blocked / flagged / monitored / parse errors | 0 / 0 / 0 / 0 |"*"| SQL checked | 1 file(s) from \`files\` |"*"| CLI | \`vericto 1.7.1\` |"*) + ok "job summary: result, counts, selection and CLI version" ;; + *) not_ok "job summary" "$(summary)" ;; +esac + +check "$ws" VA_API_KEY="$KEY" VA_FILES='migrations/*.sql' +expect_rc 1 "a BLOCKED finding: exit 1" +expect_output result fail "result fail" +expect_output blocked 1 "blocked 1" +expect_out "migrations/0002_cleanup.sql:2: [BLOCKED] VERICTO-001" "the CLI's findings are in the log" +expect_out "::error title=Vericto SQL Check::Vericto found SQL at or above fail_on in this change." "an error annotation" +if [ -s "$(output_of sarif-file)" ]; then ok "the SARIF is still uploaded on exit 1"; else not_ok "sarif-file on exit 1"; fi + +check "$ws" VA_API_KEY="$KEY" VA_FILES='migrations/*.sql' FAKE_DECISION=FLAGGED +expect_rc 0 "FLAGGED findings below fail_on: exit 0" +expect_output result warn "result warn" +expect_output flagged 1 "flagged 1" +case "$(summary)" in *"### Vericto SQL Check: WARN"*) ok "WARN in the summary" ;; *) not_ok "WARN summary" "$(summary)" ;; esac + +check "$ws" VA_API_KEY="$KEY" VA_FILES='migrations/*.sql' VA_FORMAT=json FAKE_DECISION=FLAGGED +expect_rc 0 "json format" +expect_output flagged 1 "counts come from the json summary" +expect_output result warn "result warn from json" +expect_output sarif-file "" "no sarif-file for json" +case "$(output_of report-file)" in *.json) ok "report-file is the json report" ;; *) not_ok "json report-file" "$outputs" ;; esac + +for f in gitlab-codequality:gl-code-quality.json gitlab-sast:gl-sast-report.json; do + check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql VA_FORMAT="${f%%:*}" + case "$(output_of report-file)" in *"/vericto.${f#*:}") ok "${f%%:*}: report-file ends in .${f#*:}" ;; *) not_ok "${f%%:*} report-file" "$outputs" ;; esac + expect_output sarif-file "" "${f%%:*}: no sarif-file" + expect_output blocked "" "${f%%:*}: no counts" + expect_output result pass "${f%%:*}: result from the exit code" +done + +check "$ws" VA_API_KEY="$KEY" VA_FILES='migrations/*.sql' VA_FORMAT=text +expect_rc 1 "text format" +case "$(logged args)" in *--output*) not_ok "text has --output" ;; *) ok "text goes to the log, no --output" ;; esac +expect_output report-file "" "no report-file for text" +expect_output result fail "result from the exit code" + +for c in 2:"vericto check failed with a usage error (exit 2); see the log above." \ + 3:"Vericto rejected the credentials (exit 3): check the API key or the OIDC trust policy" \ + 4:"The Vericto API was unreachable or returned an error (exit 4). No verdict was given and no result is assumed." \ + 70:"vericto check exited with 70."; do + code=${c%%:*} + check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations FAKE_EXIT="$code" + expect_rc "$code" "the CLI's exit $code is the step's" + expect_output exit-code "$code" "exit-code $code" + expect_output result fail "exit $code: result fail" + expect_output sarif-file "" "exit $code: no report, no sarif-file" + expect_output blocked "" "exit $code: no counts" + expect_out "::error title=Vericto SQL Check::${c#*:}" "exit $code: annotation" +done +case "$(summary)" in *"| Exit code | \`70\` (unexpected exit code) |"*) ok "an unknown exit code is named in the summary" ;; *) not_ok "summary for exit 70" "$(summary)" ;; esac + +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql FAKE_NO_REPORT=1 +expect_rc 0 "exit 0 without a report (nothing to check)" +expect_output blocked 0 "counts are 0" +expect_output sarif-file "" "and there is nothing to upload" +expect_output result pass "result pass" + +check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql FAKE_REPORT="$FIXTURES/reports/mixed.sarif" +expect_output blocked 1 "SARIF counts: [BLOCKED]" +expect_output flagged 2 "SARIF counts: [FLAGGED]" +expect_output monitored 1 "SARIF counts: [MONITORED]" +expect_output parse-errors 1 "SARIF counts: Could not parse" +expect_output result warn "findings with exit 0 are a warning" +expect_output checkout-path "" "no checkout-path outside git" + +tools=(bash env dirname grep git mktemp rm tr find sort awk wc cat cp sed head printf) +nojq=$(minimal_path "$TMP/no-jq" "${tools[@]}") +TEST_PATH="$nojq" check "$ws" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql +expect_rc 0 "runs without jq" +expect_output blocked "" "no counts without jq" +if [ -n "$(output_of sarif-file)" ]; then ok "the SARIF is still reported without jq"; else not_ok "sarif-file without jq"; fi + +echo "# inside a git checkout" +repo="$TMP/repo" +mkdir -p "$repo/db" +cp -R "$FIXTURES/migrations" "$repo/db/" +g() { git -C "$repo" -c user.name=test -c user.email=test@example.com -c commit.gpgsign=false "$@"; } +g init -q +g add -A +g commit -q -m base +base=$(g rev-parse HEAD) +printf 'DELETE FROM users WHERE id = 1;\n' >"$repo/db/migrations/0003_fix.sql" +g add -A +g commit -q -m change +toplevel=$(cd "$repo" && git rev-parse --show-toplevel) + +check "$repo/db" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql FAKE_REPORT="$FIXTURES/reports/mixed.sarif" +expect_rc 0 "files mode from a subdirectory" +expect_output checkout-path "$toplevel" "checkout-path is the repository root" +uris=$(jq -r '.runs[].results[].locations[].physicalLocation.artifactLocation.uri' "$(output_of sarif-file)" | tr '\n' ' ') +expect_eq "$uris" "db/migrations/0002_cleanup.sql /abs/path/report.sql file:///tmp/x.sql C:/runner/x.sql " \ + "SARIF paths are made relative to the repository root; absolute paths and URIs are kept" +TEST_PATH="$nojq" check "$repo/db" VA_API_KEY="$KEY" VA_FILES=migrations/0001_create_users.sql +expect_out "jq not found: SARIF paths stay relative to working-directory" "warns when it cannot rewrite the paths" + +echo "# changed files" +changed_args() { logged args | grep -E '^--(since|changed)' || true; } +check "$repo" VA_API_KEY="$KEY" +expect_rc 0 "changed is the default when files is empty" +expect_eq "$(changed_args)" "--changed" "no base known: the CLI's --changed" +case "$(summary)" in *"| SQL checked | *.sql changed vs \`origin/HEAD\` |"*) ok "summary names the base" ;; *) not_ok "summary for --changed" "$(summary)" ;; esac +check "$repo" VA_API_KEY="$KEY" GITHUB_BASE_REF=main VA_EVENT_NAME=pull_request VA_EVENT_BEFORE="$base" +expect_eq "$(changed_args)" "--changed" "pull_request: the CLI diffs against the base branch" +case "$(summary)" in *"changed vs \`origin/main\`"*) ok "pull_request summary" ;; *) not_ok "pull_request summary" "$(summary)" ;; esac +check "$repo" VA_API_KEY="$KEY" VA_EVENT_NAME=push VA_EVENT_BEFORE="$base" +expect_eq "$(changed_args)" "--since=$base" "push: the previous commit" +check "$repo" VA_API_KEY="$KEY" VA_EVENT_NAME=push VA_EVENT_BEFORE=0000000000000000000000000000000000000000 +expect_eq "$(changed_args)" "--changed" "push of a new branch (zero before): no --since" +check "$repo" VA_API_KEY="$KEY" VA_EVENT_NAME=merge_group VA_MERGE_GROUP_BASE="$base" +expect_eq "$(changed_args)" "--since=$base" "merge_group: the queue's base" +check "$repo" VA_API_KEY="$KEY" VA_EVENT_NAME=merge_group VA_MERGE_GROUP_BASE=not-a-sha +expect_eq "$(changed_args)" "--changed" "merge_group with an invalid base: no --since" +g update-ref refs/remotes/origin/main "$base" +check "$repo" VA_API_KEY="$KEY" VA_EVENT_NAME=workflow_dispatch VA_DEFAULT_BRANCH=main +expect_eq "$(changed_args)" "--since=origin/main" "other events: the default branch" +check "$repo" VA_API_KEY="$KEY" VA_DEFAULT_BRANCH=trunk +expect_eq "$(changed_args)" "--changed" "a default branch without a remote ref: no --since" +check "$repo" VA_API_KEY="$KEY" VA_SINCE="v1.0.0" VA_EVENT_NAME=push VA_EVENT_BEFORE="$base" +expect_eq "$(changed_args)" "--since=v1.0.0" "since wins over the event" +case "$(summary)" in *"| SQL checked | *.sql changed since \`v1.0.0\` |"*) ok "since in the summary" ;; *) not_ok "since summary" "$(summary)" ;; esac +check "$repo" VA_API_KEY="$KEY" VA_SINCE='main|x' +# shellcheck disable=SC2016 # literal backticks of the Markdown summary +case "$(summary)" in *'changed since `main_x`'*) ok "a ref is made inert in the Markdown summary" ;; *) not_ok "md_safe" "$(summary)" ;; esac +check "$repo" VA_API_KEY="$KEY" VA_CHANGED=true +expect_rc 0 "changed: true explicitly" +check "$repo/db" VA_API_KEY="$KEY" +expect_usage_error "changed from a subdirectory" "changed must run from the root of the git checkout" +check "$ws" VA_API_KEY="$KEY" +expect_usage_error "changed outside git" "changed needs a git checkout: use actions/checkout with fetch-depth: 0" +check "$repo" VA_API_KEY="$KEY" FAKE_NO_REPORT=1 +expect_output result pass "no changed SQL: pass" +expect_output blocked 0 "no changed SQL: counts 0" +for v in $'origin/main\n--x' $'origin/main\r\n--x' $'origin/main\n'; do + check "$repo" VA_API_KEY="$KEY" VA_SINCE="$v" + expect_usage_error "multi-line since in a git checkout $(printf '%q' "$v")" "input 'since' must be a git ref, e.g. origin/main" +done + +# VA_FILES unset (a direct run; action.yml always sets it), and every other +# optional input with it: changed files, on bash 3.2 and 4+ alike (set -u +# used to abort on bash 4+ with "VA_FILES: unbound variable"). +rm -f "$TMP/log/"* +cd "$repo" +run VA_BIN="$FAKE" FAKE_LOG_DIR="$TMP/log" VA_API_KEY="$KEY" bash "$SCRIPTS/check.sh" +cd "$ROOT" +expect_rc 0 "an unset VA_FILES means changed files" +expect_no_out "unbound variable" "an unset VA_FILES: no set -u abort" +expect_eq "$(changed_args)" "--changed" "an unset VA_FILES: --changed" + +shallow="$TMP/shallow" +git clone -q --depth 1 "file://$repo" "$shallow" +check "$shallow" VA_API_KEY="$KEY" +expect_out "::warning title=Vericto SQL Check::shallow clone: the base commit may be missing. Use actions/checkout with fetch-depth: 0." "warns about a shallow clone" +check "$shallow" VA_API_KEY="$KEY" FAKE_EXIT=2 +expect_out "The checkout is shallow: use actions/checkout with fetch-depth: 0." "a usage error in a shallow clone says why" + +finish diff --git a/test/fixtures/fake-cli/vericto b/test/fixtures/fake-cli/vericto new file mode 100644 index 0000000..6e0e9a7 --- /dev/null +++ b/test/fixtures/fake-cli/vericto @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# A stand-in for the Vericto CLI in test/check-test.sh. It never contacts +# anything. +# +# vericto --version prints "vericto $FAKE_VERSION" (default 1.7.1) +# vericto check ... records its arguments, the credentials it was given +# and the file list read from stdin under $FAKE_LOG_DIR, +# then reports a finding for every line that starts with +# "DELETE FROM" and has no WHERE, with the decision +# $FAKE_DECISION (default BLOCKED). +# +# The report goes to --output in --format. Exit code: $FAKE_EXIT when set +# (no report is written for a non-zero FAKE_EXIT), else 1 when a BLOCKED finding +# exists, else 0. FAKE_REPORT copies a fixture report instead; FAKE_NO_REPORT=1 +# writes none (what the CLI does when there is nothing to check). +set -euo pipefail + +if [ "${1:-}" = --version ]; then + echo "vericto ${FAKE_VERSION:-1.7.1}" + exit 0 +fi +if [ "${1:-}" != check ]; then + echo "fake vericto: unexpected command: $*" >&2 + exit 2 +fi +shift + +log=${FAKE_LOG_DIR:?} +printf '%s\n' "$@" >"$log/args" +printf '%s' "${VERICTO_API_KEY-}" >"$log/api-key" +printf '%s' "${VERICTO_WORKSPACE_ID-}" >"$log/workspace-id" + +format=sarif output="" list=false +for a in "$@"; do + case "$a" in + --format=*) format=${a#--format=} ;; + --output=*) output=${a#--output=} ;; + --stdin-file-list) list=true ;; + esac +done + +: >"$log/files" +if [ "$list" = true ]; then cat >"$log/files"; fi + +if [ -n "${FAKE_EXIT:-}" ] && [ "$FAKE_EXIT" != 0 ]; then + echo "error: simulated CLI failure (exit $FAKE_EXIT)" >&2 + exit "$FAKE_EXIT" +fi + +decision=${FAKE_DECISION:-BLOCKED} +results="" n=0 +while IFS= read -r f; do + [ -n "$f" ] || continue + while IFS=: read -r line _; do + [ -n "$line" ] || continue + n=$((n + 1)) + [ -n "$results" ] && results="$results," + results="$results{\"ruleId\":\"VERICTO-001\",\"level\":\"error\",\"message\":{\"text\":\"[$decision] DELETE without WHERE\"},\"locations\":[{\"physicalLocation\":{\"artifactLocation\":{\"uri\":\"$f\"},\"region\":{\"startLine\":$line}}}]}" + echo "$f:$line: [$decision] VERICTO-001 DELETE without WHERE" + done < <(grep -n '^DELETE FROM' "$f" | grep -v 'WHERE' || true) +done <"$log/files" + +blocked=0 flagged=0 +if [ "$decision" = BLOCKED ]; then blocked=$n; else flagged=$n; fi + +if [ -n "$output" ] && [ "${FAKE_NO_REPORT:-}" != 1 ]; then + if [ -n "${FAKE_REPORT:-}" ]; then + cp "$FAKE_REPORT" "$output" + else + case "$format" in + sarif) printf '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"vericto"}},"results":[%s]}]}\n' "$results" >"$output" ;; + json) printf '{"summary":{"blocked":%d,"flagged":%d,"monitored":0,"parse_errors":0}}\n' "$blocked" "$flagged" >"$output" ;; + *) printf '[]\n' >"$output" ;; + esac + fi +fi + +[ "$blocked" -gt 0 ] && exit 1 +exit 0 diff --git a/test/fixtures/reports/mixed.sarif b/test/fixtures/reports/mixed.sarif new file mode 100644 index 0000000..8ebf5ed --- /dev/null +++ b/test/fixtures/reports/mixed.sarif @@ -0,0 +1,35 @@ +{ + "version": "2.1.0", + "runs": [ + { + "tool": { "driver": { "name": "vericto" } }, + "results": [ + { + "ruleId": "VERICTO-001", + "message": { "text": "[BLOCKED] DELETE without WHERE" }, + "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "migrations/0002_cleanup.sql" } } }] + }, + { + "ruleId": "VERICTO-050", + "message": { "text": "[MONITORED] full table scan" }, + "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "/abs/path/report.sql" } } }] + }, + { + "ruleId": "VERICTO-030", + "message": { "text": "[FLAGGED] SELECT without LIMIT" }, + "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "file:///tmp/x.sql" } } }] + }, + { + "ruleId": "parse-error", + "message": { "text": "Could not parse statement 3" }, + "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "" } } }] + }, + { + "ruleId": "VERICTO-030", + "message": { "text": "[FLAGGED] SELECT without LIMIT" }, + "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "C:/runner/x.sql" } } }] + } + ] + } + ] +} diff --git a/test/harness.sh b/test/harness.sh new file mode 100644 index 0000000..b21f588 --- /dev/null +++ b/test/harness.sh @@ -0,0 +1,125 @@ +# shellcheck shell=bash +# Shared helpers for the offline unit tests (test/*-test.sh other than +# checksum-test.sh). Sourced, never executed. Compatible with bash 3.2. +# +# Every script under test runs through `run`, with a clean environment +# (`env -i`): variables the runner sets (GITHUB_*, RUNNER_*, VERICTO_API_KEY, +# ACTIONS_ID_TOKEN_REQUEST_URL...) never leak into a case. Nothing here, or in +# the tests, touches the network. + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck disable=SC2034 # SCRIPTS and FIXTURES are used by the scripts that source this file +SCRIPTS="$ROOT/scripts" +# shellcheck disable=SC2034 +FIXTURES="$ROOT/test/fixtures" + +tmp_base=${RUNNER_TEMP:-${TMPDIR:-/tmp}} +TMP=$(mktemp -d "${tmp_base%/}/check-sql-test.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +failures=0 +passes=0 +out="" rc=0 outputs="" + +ok() { + passes=$((passes + 1)) + echo "ok - $1" +} + +not_ok() { + failures=$((failures + 1)) + echo "FAIL - $1" + if [ -n "${2:-}" ]; then printf '%s\n' "$2" | sed 's/^/ | /'; fi +} + +# run [VAR=VALUE ...] COMMAND [ARG ...] +# Runs COMMAND with only PATH, HOME, TMPDIR, RUNNER_TEMP and GITHUB_OUTPUT set, +# plus the VAR=VALUE pairs. Sets $out (stdout and stderr), $rc and $outputs +# (what the command wrote to GITHUB_OUTPUT). +run() { + local gho="$TMP/github_output" cov=() + : >"$gho" + # Under kcov (coverage runs), keep the variables its bash tracing needs. + if [ -n "${KCOV_BASH_XTRACEFD:-}" ]; then + cov=(BASH_ENV="${BASH_ENV:-}" BASH_XTRACEFD="${BASH_XTRACEFD:-}" KCOV_BASH_XTRACEFD="$KCOV_BASH_XTRACEFD") + fi + set +e + out=$(env -i ${cov[@]+"${cov[@]}"} PATH="${TEST_PATH:-$PATH}" HOME="$HOME" TMPDIR="$TMP" RUNNER_TEMP="$TMP" \ + GITHUB_OUTPUT="$gho" "$@" 2>&1) + rc=$? + set -e + outputs=$(cat "$gho") +} + +# output_of NAME: the last value written for step output NAME. +output_of() { + printf '%s\n' "$outputs" | sed -n "s/^$1=//p" | tail -n 1 +} + +expect_rc() { # WANT DESCRIPTION + if [ "$rc" = "$1" ]; then ok "$2 (exit $rc)"; else not_ok "$2: expected exit $1, got $rc" "$out"; fi +} + +expect_out() { # TEXT DESCRIPTION: the output contains TEXT (fixed string) + case "$out" in + *"$1"*) ok "$2" ;; + *) not_ok "$2: output lacks '$1'" "$out" ;; + esac +} + +expect_no_out() { # TEXT DESCRIPTION: the output does not contain TEXT + case "$out" in + *"$1"*) not_ok "$2: output contains '$1'" "$out" ;; + *) ok "$2" ;; + esac +} + +expect_output() { # NAME VALUE DESCRIPTION: step output NAME equals VALUE + local got + got=$(output_of "$1") + if [ "$got" = "$2" ]; then ok "$3"; else not_ok "$3: output '$1' is '$got', expected '$2'" "$outputs"; fi +} + +expect_eq() { # GOT WANT DESCRIPTION + if [ "$1" = "$2" ]; then ok "$3"; else not_ok "$3" "expected: $2"$'\n'"got: $1"; fi +} + +# minimal_path DIR CMD...: a directory holding links to only these commands, to +# run a script where some tool (gh, jq, sha256sum...) is not installed. +minimal_path() { + local dir=$1 cmd p + shift + mkdir -p "$dir" + for cmd in "$@"; do + p=$(command -v "$cmd" 2>/dev/null) || continue + ln -sf "$p" "$dir/$cmd" + done + printf '%s' "$dir" +} + +xfails=0 + +# known_bug DESCRIPTION EXPECT_CALL...: an expect_* call that fails today +# because of a reported bug. Counted as "xfail"; once the bug is fixed it +# passes, and the suite fails until this is turned into a plain assertion. +known_bug() { + local desc=$1 before_f=$failures before_p=$passes + shift + "$@" >/dev/null + if [ "$failures" -gt "$before_f" ]; then + failures=$before_f + xfails=$((xfails + 1)) + echo "xfail - $desc" + else + passes=$before_p + not_ok "XPASS - $desc: the known bug looks fixed; make this a plain assertion" + fi +} + +finish() { + echo "# $passes passed, $failures failed, $xfails expected failure(s) (known bugs)" + if [ "$failures" -ne 0 ]; then + echo "::error::$failures test(s) failed in $(basename "$0")" + exit 1 + fi +} diff --git a/test/install-test.sh b/test/install-test.sh new file mode 100644 index 0000000..c8ef11b --- /dev/null +++ b/test/install-test.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Offline tests for scripts/install.sh: download, checksum gate, cache reuse +# and poisoning, network failures, archive contents, the version check and the +# advisory build attestation. +# +# No network: a `curl` stand-in first on PATH maps +# https://github.com/vericto/vericto-cli/releases/download// to +# file://$RELEASES// and runs the real curl (without --proto, which +# would refuse file://). A missing fixture file is a failed download, as a +# network error or a missing release would be. A `gh` stand-in plays the +# attestation check. +# +# Usage: bash test/install-test.sh +set -euo pipefail + +# shellcheck source=test/harness.sh +. "$(dirname "$0")/harness.sh" + +RELEASES="$TMP/releases" +SHIM="$TMP/shim" +CURL_LOG="$TMP/curl.log" +GH_LOG="$TMP/gh.log" +mkdir -p "$RELEASES" "$SHIM" +real_curl=$(command -v curl) + +cat >"$SHIM/curl" <<'EOF' +#!/usr/bin/env bash +# curl stand-in: serves release downloads from $FAKE_RELEASES over file://. +set -euo pipefail +args=() url="" +while [ "$#" -gt 0 ]; do + case "$1" in + --proto) shift ;; + https://github.com/vericto/vericto-cli/releases/download/*) + url=$1 + args+=("file://$FAKE_RELEASES/${1#https://github.com/vericto/vericto-cli/releases/download/}") + ;; + https://* | http://*) echo "curl stand-in: refusing a real URL: $1" >&2; exit 7 ;; + *) args+=("$1") ;; + esac + shift +done +printf '%s\n' "$url" >>"$FAKE_CURL_LOG" +exec "$REAL_CURL" "${args[@]}" +EOF +cat >"$SHIM/gh" <<'EOF' +#!/usr/bin/env bash +# gh stand-in: `gh attestation verify` succeeds or fails as FAKE_GH says. +printf '%s|GH_TOKEN=%s\n' "$*" "${GH_TOKEN:-}" >>"$FAKE_GH_LOG" +if [ "${FAKE_GH:-ok}" = ok ]; then echo "Verification succeeded"; exit 0; fi +printf 'line one\nline two\nno attestations found\n' >&2 +exit 1 +EOF +chmod +x "$SHIM/curl" "$SHIM/gh" + +# release TAG ASSET [REPORTED_VERSION] [LAYOUT]: writes ASSET and ASSET.sha256 +# under $RELEASES/TAG. LAYOUT: ok (default), no-binary. +release() { + local tag=$1 asset=$2 reported=${3:-${1#v}} layout=${4:-ok} stage name + stage=$(mktemp -d "$TMP/stage.XXXXXX") + mkdir -p "$RELEASES/$tag" + case "$asset" in + *.tar.xz | *.tar.gz) + name=${asset%.tar.*} + mkdir -p "$stage/$name" + if [ "$layout" = ok ]; then + printf '#!/bin/sh\necho "vericto %s"\n' "$reported" >"$stage/$name/vericto" + else + printf 'readme\n' >"$stage/$name/README.md" + fi + chmod +x "$stage/$name/"* 2>/dev/null || true + case "$asset" in + *.tar.xz) (cd "$stage" && COPYFILE_DISABLE=1 tar -cJf "$RELEASES/$tag/$asset" "$name") ;; + *) (cd "$stage" && COPYFILE_DISABLE=1 tar -czf "$RELEASES/$tag/$asset" "$name") ;; + esac + ;; + *.zip) + printf '#!/bin/sh\necho "vericto %s"\n' "$reported" >"$stage/vericto.exe" + python3 -c 'import sys, zipfile +with zipfile.ZipFile(sys.argv[1], "w") as z: z.write(sys.argv[2], "vericto.exe")' "$RELEASES/$tag/$asset" "$stage/vericto.exe" + ;; + esac + printf '%s *%s\n\n' "$(bash -c '. "$1"; sha256_of "$2"' lib "$SCRIPTS/lib.sh" "$RELEASES/$tag/$asset")" "$asset" \ + >"$RELEASES/$tag/$asset.sha256" + rm -rf "$stage" +} + +install() { # [VAR=VALUE ...] + : >"$CURL_LOG" + : >"$GITHUB_PATH_FILE" + TEST_PATH="${INSTALL_PATH:-$SHIM:$PATH}" run REAL_CURL="$real_curl" FAKE_RELEASES="$RELEASES" FAKE_CURL_LOG="$CURL_LOG" \ + FAKE_GH_LOG="$GH_LOG" GITHUB_PATH="$GITHUB_PATH_FILE" "$@" bash "$SCRIPTS/install.sh" +} +GITHUB_PATH_FILE="$TMP/github_path" + +asset=vericto-cli-x86_64-unknown-linux-musl.tar.xz +release v1.7.1 "$asset" +cache="$TMP/cache/vericto-cli/1.7.1/x86_64-unknown-linux-musl" + +echo "# fresh download" +install VA_VERSION=1.7.1 VA_ASSET="$asset" VA_CACHE_DIR="$cache" +expect_rc 0 "installs the pinned release" +bin=$(output_of bin) +expect_eq "$(basename "$bin")" vericto "bin output names the binary" +if [ -x "$bin" ] && [ "$("$bin" --version)" = "vericto 1.7.1" ]; then ok "the binary is executable and is the release"; else not_ok "installed binary" "$bin"; fi +expect_output version 1.7.1 "version output" +expect_eq "$(cat "$GITHUB_PATH_FILE")" "$(dirname "$bin")" "adds the binary's directory to GITHUB_PATH" +expect_out "SHA-256 OK: $asset" "verifies the checksum before extracting" +expect_out "Installed vericto 1.7.1 to" "logs the install" +expect_eq "$(cat "$CURL_LOG")" "https://github.com/vericto/vericto-cli/releases/download/v1.7.1/$asset.sha256 +https://github.com/vericto/vericto-cli/releases/download/v1.7.1/$asset" "downloads the checksum, then the archive, from the release" +if [ -f "$cache/$asset" ]; then ok "keeps the verified archive in the cache dir"; else not_ok "cache populated"; fi +expect_out "no token; skipped the build attestation check (the SHA-256 check passed)." "attestation is skipped without a token" +case "$bin" in "$TMP"/vericto-bin.*) ok "the binary lives in a fresh directory under RUNNER_TEMP" ;; *) not_ok "bin location" "$bin" ;; esac +leftover=$(find "$TMP" -maxdepth 1 -name 'vericto-install.*') +expect_eq "$leftover" "" "removes its work directory" + +echo "# cache" +install VA_VERSION=1.7.1 VA_ASSET="$asset" VA_CACHE_DIR="$cache" +expect_rc 0 "installs from the cache" +expect_out "Using the cached archive (checksum re-verified)." "re-verifies the cached archive" +expect_eq "$(cat "$CURL_LOG")" "https://github.com/vericto/vericto-cli/releases/download/v1.7.1/$asset.sha256" "downloads only the checksum" +expect_no_out "attestation" "no attestation check for a cached archive" + +printf 'x' >>"$cache/$asset" +install VA_VERSION=1.7.1 VA_ASSET="$asset" VA_CACHE_DIR="$cache" +expect_rc 0 "a poisoned cache is replaced" +expect_out "::warning title=Vericto SQL Check::the cached $asset does not match the release checksum; downloading it again." "warns about the poisoned cache" +if run bash "$SCRIPTS/verify-checksum.sh" "$cache/$asset" "$RELEASES/v1.7.1/$asset.sha256" && [ "$rc" = 0 ]; then + ok "the cache now holds the verified archive" +else + not_ok "cache after re-download" "$out" +fi + +echo "# checksum gate" +release v1.7.2 "$asset" +printf '%s *%s\n' "0000000000000000000000000000000000000000000000000000000000000000" "$asset" >"$RELEASES/v1.7.2/$asset.sha256" +cache2="$TMP/cache-bad" +install VA_VERSION=1.7.2 VA_ASSET="$asset" VA_CACHE_DIR="$cache2" +expect_rc 1 "a checksum mismatch stops the install" +expect_out "checksum mismatch for $asset" "says so" +expect_output bin "" "no bin output" +expect_eq "$(cat "$GITHUB_PATH_FILE")" "" "nothing added to PATH" +if [ -e "$cache2/$asset" ]; then not_ok "an unverified archive was cached"; else ok "the unverified archive is not cached"; fi + +echo "# network failures and missing releases" +install VA_VERSION=0.0.0 VA_ASSET="$asset" VA_CACHE_DIR="$TMP/cache-none" +expect_rc 1 "a missing checksum download fails" +expect_out "could not download https://github.com/vericto/vericto-cli/releases/download/v0.0.0/$asset.sha256. Does release v0.0.0 exist?" "names the URL and the release" +mkdir -p "$RELEASES/v1.7.3" +cp "$RELEASES/v1.7.1/$asset.sha256" "$RELEASES/v1.7.3/" +install VA_VERSION=1.7.3 VA_ASSET="$asset" VA_CACHE_DIR="$TMP/cache-3" +expect_rc 1 "a failed archive download fails" +expect_out "could not download https://github.com/vericto/vericto-cli/releases/download/v1.7.3/$asset" "names the archive URL" +run REAL_CURL="$real_curl" FAKE_RELEASES="$RELEASES" FAKE_CURL_LOG="$CURL_LOG" VA_ASSET="$asset" VA_CACHE_DIR="$TMP/c" bash "$SCRIPTS/install.sh" +expect_rc 1 "VA_VERSION is required" + +echo "# archive contents" +release v1.7.4 "$asset" 1.7.4 no-binary +install VA_VERSION=1.7.4 VA_ASSET="$asset" VA_CACHE_DIR="$TMP/cache-4" +expect_rc 1 "an archive without the binary fails" +expect_out "the archive does not contain the expected binary (vericto)" "says what is missing" +release v1.7.5 "$asset" 9.9.9 +install VA_VERSION=1.7.5 VA_ASSET="$asset" VA_CACHE_DIR="$TMP/cache-5" +expect_rc 1 "a binary reporting another version fails" +expect_out "the installed binary reports 'vericto 9.9.9', expected 'vericto 1.7.5'" "names both versions" +expect_eq "$(cat "$GITHUB_PATH_FILE")" "" "and is not added to PATH" +release v1.7.6 vericto-cli-x86_64-unknown-linux-musl.tar.gz +install VA_VERSION=1.7.6 VA_ASSET=vericto-cli-x86_64-unknown-linux-musl.tar.gz VA_CACHE_DIR="$TMP/cache-6" +expect_rc 1 "an unknown archive type fails after the checksum" +expect_out "unknown archive type: vericto-cli-x86_64-unknown-linux-musl.tar.gz" "names it" + +if command -v unzip >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then + zip_asset=vericto-cli-x86_64-pc-windows-msvc.zip + release v1.7.1 "$zip_asset" + install VA_VERSION=1.7.1 VA_ASSET="$zip_asset" VA_CACHE_DIR="$TMP/cache-zip" + expect_rc 0 "installs a .zip asset (Windows layout)" + expect_eq "$(basename "$(output_of bin)")" vericto.exe "bin output is vericto.exe" +else + echo "skip - .zip install (needs unzip and python3)" +fi + +echo "# build attestation (advisory)" +: >"$GH_LOG" +install VA_VERSION=1.7.1 VA_ASSET="$asset" VA_CACHE_DIR="$TMP/cache-a1" VA_GH_TOKEN=ghs_test FAKE_GH=ok +expect_rc 0 "a verified attestation" +expect_out "Build attestation verified: $asset was built by vericto/vericto-cli." "logs it" +case "$(cat "$GH_LOG")" in + "attestation verify $TMP/cache-a1/$asset --repo vericto/vericto-cli|GH_TOKEN=ghs_test") ok "asks gh with the token for the cached archive" ;; + *) not_ok "gh invocation" "$(cat "$GH_LOG")" ;; +esac +install VA_VERSION=1.7.1 VA_ASSET="$asset" VA_CACHE_DIR="$TMP/cache-a2" VA_GH_TOKEN=ghs_test FAKE_GH=fail +expect_rc 0 "a failed attestation only warns: the checksum is the gate" +expect_out "::warning title=Vericto SQL Check::could not verify the build attestation of $asset (the SHA-256 check passed): line one line two no attestations found" "with the tail of gh's output" +tools=(bash env curl tar xz gzip mkdir mktemp rm mv cp chmod grep tr sed basename dirname sha256sum shasum perl openssl cat tail uname) +minimal=$(minimal_path "$TMP/no-gh" "${tools[@]}") +ln -sf "$SHIM/curl" "$minimal/curl" +INSTALL_PATH="$minimal" install VA_VERSION=1.7.1 VA_ASSET="$asset" VA_CACHE_DIR="$TMP/cache-a3" VA_GH_TOKEN=ghs_test +expect_rc 0 "installs without gh" +expect_out "gh not found; skipped the build attestation check (the SHA-256 check passed)." "with a notice" + +finish diff --git a/test/lib-test.sh b/test/lib-test.sh new file mode 100644 index 0000000..95e0180 --- /dev/null +++ b/test/lib-test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Offline tests for scripts/lib.sh: workflow-command escaping, masking, step +# outputs, path helpers, SHA-256 and temporary directories. +# +# Usage: bash test/lib-test.sh +# shellcheck disable=SC2016 # the snippets are single-quoted on purpose: the child bash expands them +set -euo pipefail + +# shellcheck source=test/harness.sh +. "$(dirname "$0")/harness.sh" + +# Runs a snippet with lib.sh sourced, in a clean environment. +lib() { # [VAR=VALUE ...] SNIPPET + local snippet=${*: -1} vars=() + while [ "$#" -gt 1 ]; do + vars[${#vars[@]}]=$1 + shift + done + run ${vars[@]+"${vars[@]}"} bash -c ". \"\$1\"; $snippet" lib "$SCRIPTS/lib.sh" +} + +echo "# gh_escape and annotations" +lib 'gh_escape "100% done"' +expect_eq "$out" "100%25 done" "escapes %" +lib 'gh_escape "$(printf "a\r\nb")"' +expect_eq "$out" "a%0D%0Ab" "escapes CR and LF, so a message cannot start a new workflow command" +lib 'gh_error "bad input: $(printf "x\n::add-mask::y")"' +expect_eq "$out" "::error title=Vericto SQL Check::bad input: x%0A::add-mask::y" "gh_error keeps a forged command on one line" +lib 'gh_warning "careful"; gh_notice "fyi"' +expect_eq "$out" "::warning title=Vericto SQL Check::careful"$'\n'"::notice title=Vericto SQL Check::fyi" "gh_warning and gh_notice" + +echo "# gh_mask" +lib 'gh_mask "$(printf "line1\n\nline2")"' +expect_eq "$out" "::add-mask::line1"$'\n'"::add-mask::line2" "masks every non-empty line of a multi-line secret" +lib 'gh_mask "vtro_no_trailing_newline"' +expect_eq "$out" "::add-mask::vtro_no_trailing_newline" "masks a value without a trailing newline" +lib 'gh_mask "50%"' +expect_eq "$out" "::add-mask::50%25" "escapes the masked value" + +echo "# set_output" +lib 'set_output exit-code 3; set_output result fail' +expect_output exit-code 3 "writes to GITHUB_OUTPUT" +expect_output result fail "appends further outputs" +expect_eq "$out" "" "prints nothing when GITHUB_OUTPUT is set" +run bash -c '. "$1"; unset GITHUB_OUTPUT; set_output bin /x/vericto' lib "$SCRIPTS/lib.sh" +expect_eq "$out" "output bin=/x/vericto" "prints the output without GITHUB_OUTPUT (local runs)" +lib 'set_output evil "$(printf "a\nexit-code=0")"' +expect_rc 1 "refuses a multi-line value" +expect_out "refusing a multi-line value for output 'evil'" "says why" +expect_eq "$outputs" "" "and writes nothing that could forge another output" +lib 'set_output evil "$(printf "a\rb")"' +expect_rc 1 "refuses a value with a CR" + +echo "# is_single_line and matches (whole-value input validation)" +lib 'is_single_line "one line"; is_single_line ""' +expect_rc 0 "is_single_line: one line, and the empty string" +for v in $'a\nb' $'a\r\nb' $'a\n' $'a\r' $'\n'; do + run bash -c '. "$1"; is_single_line "$2"' lib "$SCRIPTS/lib.sh" "$v" + expect_rc 1 "is_single_line: rejects $(printf '%q' "$v")" +done +run bash -c '. "$1"; matches "$2" "$3"' lib "$SCRIPTS/lib.sh" '^[a-z]+$' postgres +expect_rc 0 "matches: a valid single-line value" +run bash -c '. "$1"; matches "$2" "$3"' lib "$SCRIPTS/lib.sh" '^[a-z]+$' Postgres +expect_rc 1 "matches: an invalid value" +run bash -c '. "$1"; matches "$2" "$3"' lib "$SCRIPTS/lib.sh" '[a-z]+' 'postgres;x' +expect_rc 0 "matches: an unanchored ERE matches a substring, so validators anchor theirs" +for v in $'postgres\nmysql' $'postgres\r\nmysql' $'postgres\n' $'postgres\r' $'\npostgres'; do + run bash -c '. "$1"; matches "$2" "$3"' lib "$SCRIPTS/lib.sh" '^[a-z]+$' "$v" + expect_rc 1 "matches: rejects $(printf '%q' "$v"), although each line matches" +done + +echo "# is_windows, native_path, unix_path" +for os in Windows MINGW64_NT-10.0 MSYS_NT CYGWIN_NT-10.0; do + lib "RUNNER_OS=$os" 'is_windows' + expect_rc 0 "is_windows for RUNNER_OS=$os" +done +for os in Linux macOS; do + lib "RUNNER_OS=$os" 'is_windows' + expect_rc 1 "not is_windows for RUNNER_OS=$os" +done +lib 'is_windows' +expect_rc 1 "not is_windows from uname on this host" +lib RUNNER_OS=Linux 'native_path "/tmp/a b"; echo; unix_path "/tmp/a b"' +expect_eq "$out" "/tmp/a b"$'\n'"/tmp/a b" "paths are unchanged off Windows" +fake_bin="$TMP/fake-cygpath" +mkdir -p "$fake_bin" +cat >"$fake_bin/cygpath" <<'EOF' +#!/usr/bin/env bash +# cygpath stand-in: -m gives C:/..., -u gives /c/... +case "$1" in + -m) printf 'C:%s' "${2#/c}" ;; + -u) printf '/c%s' "${2#C:}" ;; +esac +EOF +chmod +x "$fake_bin/cygpath" +TEST_PATH="$fake_bin:$PATH" lib RUNNER_OS=Windows 'native_path /c/runner/tmp; echo; unix_path C:/runner/tmp' +expect_eq "$out" "C:/runner/tmp"$'\n'"/c/runner/tmp" "uses cygpath on Windows" + +echo "# sha256_of" +printf 'abc' >"$TMP/abc" +abc=ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad +lib "sha256_of \"$TMP/abc\"" +expect_eq "$out" "$abc" "SHA-256 of a known input, lowercase hex" +for tool in sha256sum shasum openssl; do + command -v "$tool" >/dev/null 2>&1 || { + echo "skip - $tool is not installed" + continue + } + dir=$(minimal_path "$TMP/only-$tool" "$tool" bash cat tr printf perl) + TEST_PATH="$dir" lib "sha256_of \"$TMP/abc\"" + expect_eq "$out" "$abc" "SHA-256 with only $tool installed" +done +dir=$(minimal_path "$TMP/no-sha" bash cat tr printf) +TEST_PATH="$dir" lib "sha256_of \"$TMP/abc\"" +expect_rc 1 "fails without any SHA-256 tool" +expect_out "no SHA-256 tool found" "names the missing tools" + +echo "# make_temp_dir" +lib 'd=$(make_temp_dir unit); test -d "$d" && printf "%s" "$d"' +case "$out" in + "$TMP"/vericto-unit.*) ok "creates vericto-NAME.XXXXXX under RUNNER_TEMP" ;; + *) not_ok "make_temp_dir under RUNNER_TEMP" "$out" ;; +esac + +finish diff --git a/test/resolve-test.sh b/test/resolve-test.sh new file mode 100644 index 0000000..5cfd775 --- /dev/null +++ b/test/resolve-test.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Offline tests for scripts/resolve.sh: the CLI version (pinned, never +# "latest"), the release asset for each runner OS/arch, unsupported runners and +# the cache location. +# +# Usage: bash test/resolve-test.sh +set -euo pipefail + +# shellcheck source=test/harness.sh +. "$(dirname "$0")/harness.sh" + +resolve() { run "$@" bash "$SCRIPTS/resolve.sh"; } + +pin=$(bash "$ROOT/test/pinned-version.sh") +pin=${pin#version=} + +echo "# version" +resolve VA_CLI_VERSION="$pin" RUNNER_OS=Linux RUNNER_ARCH=X64 RUNNER_TOOL_CACHE=/opt/hostedtoolcache +expect_rc 0 "the pinned version from action.yml ($pin)" +expect_output version "$pin" "version output" +expect_output cache-key "vericto-cli-$pin-x86_64-unknown-linux-musl" "cache key names version and target" +expect_output cache-dir "/opt/hostedtoolcache/vericto-cli/$pin/x86_64-unknown-linux-musl" "cache dir under RUNNER_TOOL_CACHE" +expect_out "Vericto CLI $pin, asset vericto-cli-x86_64-unknown-linux-musl.tar.xz" "logs what it resolved" + +resolve VA_CLI_VERSION=v1.7.1 RUNNER_OS=Linux RUNNER_ARCH=X64 +expect_output version 1.7.1 "a leading v is accepted and dropped" +resolve VA_CLI_VERSION=2.0.0-rc.1 RUNNER_OS=Linux RUNNER_ARCH=X64 +expect_output version 2.0.0-rc.1 "a pre-release version is accepted" + +for bad in latest LATEST "" 1.7 1.7.1.2 "1.7.1; touch pwned" "1.7.1 " "../1.7.1" "v"; do + resolve VA_CLI_VERSION="$bad" RUNNER_OS=Linux RUNNER_ARCH=X64 + expect_rc 2 "rejects cli-version '$bad'" + expect_eq "$outputs" "" "no outputs for cli-version '$bad'" +done +resolve VA_CLI_VERSION=latest RUNNER_OS=Linux RUNNER_ARCH=X64 +expect_out "'latest' is not accepted: pin a version." "explains why latest is refused" +expect_out "::error title=Vericto SQL Check::" "as an error annotation" +run RUNNER_OS=Linux RUNNER_ARCH=X64 bash "$SCRIPTS/resolve.sh" +expect_rc 2 "rejects a missing cli-version" + +# A value with a line break is an input error even when its first line is a +# valid version (LF, CRLF, a trailing LF or CR). +for bad in $'1.7.1\n../../evil' $'1.7.1\r\n../../evil' $'1.7.1\n' $'1.7.1\r' $'\n1.7.1'; do + label=$(printf '%q' "$bad") + resolve VA_CLI_VERSION="$bad" RUNNER_OS=Linux RUNNER_ARCH=X64 + expect_rc 2 "rejects multi-line cli-version $label" + expect_out "cli-version must be an exact release such as 1.7.1" "multi-line cli-version $label: the input error" + expect_no_out "refusing a multi-line value" "multi-line cli-version $label: stopped before set_output" + expect_eq "$outputs" "" "multi-line cli-version $label: no outputs" +done + +echo "# runner OS and architecture" +while read -r os arch target ext; do + resolve VA_CLI_VERSION=1.7.1 RUNNER_OS="$os" RUNNER_ARCH="$arch" + expect_rc 0 "$os/$arch is supported" + expect_output target "$target" "$os/$arch target $target" + expect_output asset "vericto-cli-$target.$ext" "$os/$arch asset .$ext" +done <<'EOF' +Linux X64 x86_64-unknown-linux-musl tar.xz +Linux ARM64 aarch64-unknown-linux-musl tar.xz +macOS X64 x86_64-apple-darwin tar.xz +macOS ARM64 aarch64-apple-darwin tar.xz +Windows X64 x86_64-pc-windows-msvc zip +EOF + +for combo in Windows/ARM64 Linux/X86 Linux/ARM macOS/X86 FreeBSD/X64; do + resolve VA_CLI_VERSION=1.7.1 RUNNER_OS="${combo%/*}" RUNNER_ARCH="${combo#*/}" + expect_rc 2 "$combo is unsupported" + expect_out "unsupported runner '$combo'. Supported: Linux X64/ARM64, macOS X64/ARM64, Windows X64." "names $combo and the supported runners" +done + +echo "# OS and arch from uname (self-hosted runners without RUNNER_OS/RUNNER_ARCH)" +fake_uname() { # DIR KERNEL MACHINE + mkdir -p "$1" + # shellcheck disable=SC2016 # $1 belongs to the generated script + printf '#!/bin/sh\ncase "$1" in -s) echo %s ;; -m) echo %s ;; esac\n' "$2" "$3" >"$1/uname" + chmod +x "$1/uname" +} +while read -r kernel machine target; do + fake_uname "$TMP/uname-$kernel-$machine" "$kernel" "$machine" + TEST_PATH="$TMP/uname-$kernel-$machine:$PATH" resolve VA_CLI_VERSION=1.7.1 + expect_output target "$target" "uname $kernel $machine is $target" +done <<'EOF' +Linux x86_64 x86_64-unknown-linux-musl +Linux aarch64 aarch64-unknown-linux-musl +Darwin arm64 aarch64-apple-darwin +Darwin x86_64 x86_64-apple-darwin +MINGW64_NT-10.0 amd64 x86_64-pc-windows-msvc +EOF +fake_uname "$TMP/uname-sunos" SunOS sparc +TEST_PATH="$TMP/uname-sunos:$PATH" resolve VA_CLI_VERSION=1.7.1 +expect_rc 2 "an unknown uname is unsupported" +expect_out "unsupported runner '/'." "with empty OS and arch in the message" + +echo "# cache location" +resolve VA_CLI_VERSION=1.7.1 RUNNER_OS=Linux RUNNER_ARCH=X64 +expect_output cache-dir "$TMP/vericto-cli/1.7.1/x86_64-unknown-linux-musl" "falls back to RUNNER_TEMP without a tool cache" + +echo "# without GITHUB_OUTPUT (local runs)" +out=$(env -i PATH="$PATH" VA_CLI_VERSION=1.7.1 RUNNER_OS=macOS RUNNER_ARCH=ARM64 RUNNER_TOOL_CACHE=/c bash "$SCRIPTS/resolve.sh") +expect_out "output asset=vericto-cli-aarch64-apple-darwin.tar.xz" "prints the outputs" + +finish diff --git a/test/verify-checksum-test.sh b/test/verify-checksum-test.sh new file mode 100644 index 0000000..4d1d8fc --- /dev/null +++ b/test/verify-checksum-test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Offline tests for scripts/verify-checksum.sh, the install gate, with local +# fixtures generated here (test/checksum-test.sh does the same against the real +# release asset, over the network). +# +# Usage: bash test/verify-checksum-test.sh +set -euo pipefail + +# shellcheck source=test/harness.sh +. "$(dirname "$0")/harness.sh" + +verify() { run bash "$SCRIPTS/verify-checksum.sh" "$@"; } + +asset=vericto-cli-x86_64-unknown-linux-musl.tar.xz +dir="$TMP/release" +mkdir -p "$dir" +printf 'pretend this is a release archive\n' >"$dir/$asset" +sum=$(bash -c '. "$1"; sha256_of "$2"' lib "$SCRIPTS/lib.sh" "$dir/$asset") +upper=$(printf '%s' "$sum" | tr 'a-f' 'A-F') + +sumfile() { # NAME CONTENT: writes a checksum file, prints its path + printf '%b' "$2" >"$dir/$1" + printf '%s' "$dir/$1" +} + +echo "# accepted" +verify "$dir/$asset" "$(sumfile cargo-dist.sha256 "$sum *$asset\n\n")" +expect_rc 0 "cargo-dist format ' *' plus a blank line" +expect_out "SHA-256 OK: $asset ($sum)" "logs the verified digest" +verify "$dir/$asset" "$(sumfile coreutils.sha256 "$sum $asset\n")" +expect_rc 0 "sha256sum format ' '" +verify "$dir/$asset" "$(sumfile bare.sha256 "$sum\n")" +expect_rc 0 "a bare digest without a file name" +verify "$dir/$asset" "$(sumfile upper.sha256 "$upper *$asset\n")" +expect_rc 0 "an upper-case digest" +verify "$dir/$asset" "$(sumfile crlf.sha256 "$sum *$asset\r\n")" +expect_rc 0 "CRLF line endings" +verify "$dir/$asset" "$(sumfile leading-blank.sha256 "\n \n$sum *$asset\n")" +expect_rc 0 "leading blank lines" + +echo "# rejected" +first=${sum:0:1} +if [ "$first" = 0 ]; then flip=1; else flip=0; fi +verify "$dir/$asset" "$(sumfile tampered.sha256 "$flip${sum:1} *$asset\n")" +expect_rc 1 "a tampered digest" +expect_out "checksum mismatch for $asset: expected $flip${sum:1}, got $sum. Refusing to install." "says expected and actual" +cp "$dir/$asset" "$TMP/$asset" +printf 'x' >>"$TMP/$asset" +verify "$TMP/$asset" "$dir/cargo-dist.sha256" +expect_rc 1 "a tampered archive (one extra byte)" +verify "$dir/$asset" "$(sumfile other.sha256 "$sum *vericto-cli-aarch64-unknown-linux-musl.tar.xz\n")" +expect_rc 1 "a checksum for another asset" +expect_out "is for 'vericto-cli-aarch64-unknown-linux-musl.tar.xz', not '$asset'" "names both files" +verify "$dir/$asset" "$(sumfile short.sha256 "${sum:0:63} *$asset\n")" +expect_rc 1 "a 63-digit digest" +expect_out "does not hold a SHA-256 digest" "says the file is malformed" +verify "$dir/$asset" "$(sumfile garbage.sha256 "not a checksum\n")" +expect_rc 1 "a file that is not a checksum" +verify "$dir/$asset" "$(sumfile blank.sha256 "\n \n")" +expect_rc 1 "a checksum file with only blank lines" +verify "$dir/$asset" "$(sumfile empty.sha256 "")" +expect_rc 1 "an empty checksum file" +expect_out "checksum file missing or empty" "says it is empty" +verify "$dir/$asset" "$dir/does-not-exist.sha256" +expect_rc 1 "a missing checksum file" +verify "$dir/missing.tar.xz" "$dir/cargo-dist.sha256" +expect_rc 1 "a missing archive" +expect_out "checksum: file not found: $dir/missing.tar.xz" "names the missing archive" + +echo "# usage" +verify "$dir/$asset" +expect_rc 2 "one argument is a usage error" +expect_out "usage:" "prints the usage" +verify "$dir/$asset" "$dir/cargo-dist.sha256" extra +expect_rc 2 "three arguments are a usage error" + +finish