diff --git a/packages/mosaic/framework/tools/git/pr-edit.sh b/packages/mosaic/framework/tools/git/pr-edit.sh index d91e97bc..47a12d8e 100755 --- a/packages/mosaic/framework/tools/git/pr-edit.sh +++ b/packages/mosaic/framework/tools/git/pr-edit.sh @@ -21,7 +21,16 @@ AUTH_CONFIG="" cleanup() { [[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG" } -trap cleanup EXIT HUP INT TERM +terminate() { + local signal="$1" + trap - "$signal" + cleanup + kill -s "$signal" "$$" +} +trap cleanup EXIT +trap 'terminate HUP' HUP +trap 'terminate INT' INT +trap 'terminate TERM' TERM usage() { cat <&2 exit 1 } - AUTHENTICATED_USER=$(get_gitea_authenticated_user "$GITEA_LOGIN_NAME") || { - echo "Error: could not authenticate Gitea login '$GITEA_LOGIN_NAME'" >&2 - exit 1 - } - [[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || { - echo "Error: Gitea login '$GITEA_LOGIN_NAME' authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2 - exit 1 - } AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || { echo "Error: could not stage private Gitea authentication" >&2 exit 1 } unset TOKEN - API_BASE="https://${HOST}/api/v1/repos/${REPO_SLUG}" - # Preflight the explicit host/repo pair before any mutation. This prevents - # a slug inferred from one checkout being combined with another host. - curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE" >/dev/null || { + API_BASE="https://${HOST}/api/v1" + # Resolve identity through the SAME private curl config used for the + # mutation. Tea login names are globally scoped and can be duplicated + # across hosts; a separate `tea api --login NAME` could validate another + # credential than this host-bound token. + AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \ + | python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || { + echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2 + exit 1 + } + [[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || { + echo "Error: host-bound credential authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2 + exit 1 + } + + REPO_API="$API_BASE/repos/${REPO_SLUG}" + curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || { echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2 exit 1 } @@ -164,7 +178,7 @@ PY ) curl -fsS --config "$AUTH_CONFIG" -X PATCH \ -H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \ - -d "$PAYLOAD" "$API_BASE/pulls/${PR_NUMBER}" + -d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}" echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2 ;; *) echo "Error: Could not detect git platform" >&2; exit 1 ;; diff --git a/packages/mosaic/framework/tools/git/test-pr-edit.sh b/packages/mosaic/framework/tools/git/test-pr-edit.sh index a915b7bc..5dbeb29a 100755 --- a/packages/mosaic/framework/tools/git/test-pr-edit.sh +++ b/packages/mosaic/framework/tools/git/test-pr-edit.sh @@ -28,16 +28,24 @@ YAML cat > "$BIN_DIR/tea" <<'SH' #!/usr/bin/env bash set -euo pipefail -[[ "$*" == "api --login usc-coder3 /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; } -[[ "$*" == "api --login same-host-other /user" ]] && { printf '{"login":"other"}\n'; exit 0; } -[[ "$*" == "api --login mosaic-coder3 /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; } +# Deliberately misleading duplicate-name response: the wrapper must never use +# tea for identity validation because its name lookup is not host-bound. +[[ "$*" == "api --login duplicate /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; } exit 1 SH cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash set -euo pipefail printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG" -[[ " $* " == *" -X PATCH "* ]] && printf '{"number":42,"draft":false}\n' || printf '{"name":"repo"}\n' +if [[ "${*: -1}" == */user ]]; then + printf '{"login":"%s"}\n' "${MOSAIC_STUB_AUTH_USER:-coder3}" +elif [[ "${*: -1}" == */repos/* && " $* " != *" -X PATCH "* ]]; then + [[ "${MOSAIC_STUB_SIGNAL:-}" == "TERM" ]] && { kill -TERM "$PPID"; sleep 1; } + [[ "${MOSAIC_STUB_SIGNAL:-}" == "INT" ]] && { kill -INT "$PPID"; sleep 1; } + printf '{"name":"repo"}\n' +else + printf '{"number":42,"draft":false}\n' +fi SH cat > "$BIN_DIR/gh" <<'SH' #!/usr/bin/env bash @@ -62,12 +70,13 @@ MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r mosaicstac python3 - "$LOG_FILE" <<'PY' import json, pathlib, sys lines = pathlib.Path(sys.argv[1]).read_text().splitlines() -assert len(lines) == 2, lines -assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[0], lines -assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[1], lines -assert "--config" in lines[0] and "--config" in lines[1], lines +assert len(lines) == 3, lines +assert "https://git.mosaicstack.dev/api/v1/user" in lines[0], lines +assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[1], lines +assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[2], lines +assert all("--config" in line for line in lines), lines assert "Authorization:" not in "\n".join(lines), lines -payload = lines[1].split(" <-d> <", 1)[1].split("> <", 1)[1].split("> /dev/null 2>&1; then echo "Unset identity wrote" >&2; exit 1; fi [[ ! -s "$LOG_FILE" ]] || { echo "Unset identity reached curl" >&2; exit 1; } -# Explicit and ambient same-host wrong principals both refuse before preflight/write. +# Explicit and ambient same-host wrong principals both refuse after identity +# lookup but before repo preflight/PATCH. The /user read is expected curl #1. for mode in explicit ambient; do : > "$LOG_FILE" if [[ "$mode" == explicit ]]; then @@ -90,11 +100,12 @@ for mode in explicit ambient; do else cmd=(); export GITEA_LOGIN=same-host-other fi - if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then + if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then echo "$mode wrong identity wrote" >&2; exit 1 fi unset GITEA_LOGIN - [[ ! -s "$LOG_FILE" ]] || { echo "$mode wrong identity reached curl" >&2; exit 1; } + [[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "$mode wrong identity passed identity lookup" >&2; exit 1; } + ! grep -q '/repos/' "$LOG_FILE" || { echo "$mode wrong identity reached repo preflight/PATCH" >&2; exit 1; } done # Set identity with no explicit/ambient login refuses rather than selecting first host login. @@ -104,6 +115,32 @@ if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 -r USC/uconnect -H git.uscllc.co fi [[ ! -s "$LOG_FILE" ]] || { echo "Missing login reached curl" >&2; exit 1; } +# Split-credential probe for the duplicate-name cross-host seam: tea's +# name-only /user would report coder3, while the selected host-bound curl token +# reports other. The wrapper must trust only the latter handle used by PATCH. +: > "$LOG_FILE" +if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 \ + -r mosaicstack/stack -H git.mosaicstack.dev --draft >/dev/null 2>&1; then + echo "Duplicate-name split credential reached PATCH" >&2; exit 1 +fi +[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "Duplicate-name identity mismatch passed /user" >&2; cat "$LOG_FILE" >&2; exit 1; } +! grep -q -- '-X> &2; exit 1; } + +# TERM and INT during repo preflight clean up, do not mutate, and return the +# signal status rather than swallowing termination into success. +for sig in TERM INT; do + : > "$LOG_FILE" + set +e + MOSAIC_STUB_SIGNAL="$sig" MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 \ + -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1 + rc=$? + set -e + [[ "$rc" -ne 0 ]] || { echo "$sig was swallowed into success" >&2; exit 1; } + [[ "$rc" -eq 143 || "$rc" -eq 130 ]] || { echo "$sig returned unexpected status $rc" >&2; exit 1; } + ! grep -q -- '-X> &2; exit 1; } + assert_no_secret +done + # Cross-host credential fails before curl; explicit target preflight failure blocks PATCH. : > "$LOG_FILE" if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then