All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Round-6 remediation for PR #866 addressing four cross-host exact-diff audit blockers (REQUEST_CHANGES governs): Blocker 1 (detect-platform.sh get_gitea_token_for_login line parser): the PyYAML-absence fallback attributed any `key: value` at any depth to the current login, so a token from a nested sub-map or a mis-indented line could be selected where PyYAML fails closed, and inline comments were not stripped. The fallback is now scope-aware — a field attaches only at the entry's own direct-field indentation, only list items at the login list's own dash indent open an entry — and _strip_scalar strips a trailing inline comment like PyYAML. It is therefore only ever MORE conservative than PyYAML, never less. Blocker 2 (detect-platform.sh host bind): credential binding compared parsed.hostname only, dropping the port, so a :9443 login satisfied a portless host and a matching :8443 login was rejected. Binding now normalizes scheme + host + effective port (scheme default applied symmetrically) exactly like gitea_url_matches_host. Blocker 3 (issue-comment.sh + pr-review.sh read-back URL check): verification used path.endswith, accepting a look-alike host or a decoy path prefix. It now pins the returned issue_url/pull_request_url ORIGIN (scheme+host+effective-port) and FULL path (deployment prefix + exact owner/repo + kind + number). A new GITEA_WEB_BASE is exported from gitea_resolve_api_for_login for this. Blocker 4 (pr-review.sh gitea_submit_review_verified): the submitted review body was not verified, so a finalized/reused pending review id carrying foreign Content passed. The persisted body is now bound to the exact submitted body. Tests: added forced-PyYAML-absence parser-equivalence fixtures (nested sub-map, sibling, mis-indent, inline comment, tab-indent fail-closed, port match/mismatch) to test-gitea-login-resolution.sh; URL-forgery fail-closed cases (wrong-host/owner/repo + prefix injection) to both write suites; and a reused-review-id body-mismatch case to the pr-review suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
808 lines
27 KiB
Bash
Executable File
808 lines
27 KiB
Bash
Executable File
#!/bin/bash
|
|
# detect-platform.sh - Detect git platform (Gitea or GitHub) for current repo
|
|
# Usage: source detect-platform.sh && detect_platform
|
|
# or: ./detect-platform.sh (prints platform name)
|
|
|
|
detect_platform() {
|
|
local remote_url
|
|
remote_url=$(git remote get-url origin 2>/dev/null)
|
|
|
|
if [[ -z "$remote_url" ]]; then
|
|
echo "error: not a git repository or no origin remote" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Check for GitHub
|
|
if [[ "$remote_url" == *"github.com"* ]]; then
|
|
PLATFORM="github"
|
|
export PLATFORM
|
|
echo "github"
|
|
return 0
|
|
fi
|
|
|
|
# Check for common Gitea indicators
|
|
# Gitea URLs typically don't contain github.com, gitlab.com, bitbucket.org
|
|
if [[ "$remote_url" != *"gitlab.com"* ]] && \
|
|
[[ "$remote_url" != *"bitbucket.org"* ]]; then
|
|
# Assume Gitea for self-hosted repos
|
|
PLATFORM="gitea"
|
|
export PLATFORM
|
|
echo "gitea"
|
|
return 0
|
|
fi
|
|
|
|
PLATFORM="unknown"
|
|
export PLATFORM
|
|
echo "unknown"
|
|
return 1
|
|
}
|
|
|
|
get_repo_info() {
|
|
local remote_url
|
|
remote_url=$(git remote get-url origin 2>/dev/null)
|
|
|
|
if [[ -z "$remote_url" ]]; then
|
|
echo "error: not a git repository or no origin remote" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Extract owner/repo from URL
|
|
# Handles: git@host:owner/repo.git, https://host/owner/repo.git, https://host/owner/repo
|
|
local repo_path
|
|
if [[ "$remote_url" == git@* ]]; then
|
|
repo_path="${remote_url#*:}"
|
|
else
|
|
repo_path="${remote_url#*://}"
|
|
repo_path="${repo_path#*/}"
|
|
fi
|
|
|
|
# Remove .git suffix if present
|
|
repo_path="${repo_path%.git}"
|
|
|
|
echo "$repo_path"
|
|
}
|
|
|
|
get_repo_owner() {
|
|
local repo_info
|
|
repo_info=$(get_repo_info)
|
|
echo "${repo_info%%/*}"
|
|
}
|
|
|
|
get_repo_name() {
|
|
local repo_info
|
|
repo_info=$(get_repo_info)
|
|
echo "${repo_info##*/}"
|
|
}
|
|
|
|
get_repo_slug() {
|
|
get_repo_info
|
|
}
|
|
|
|
gitea_url_matches_host() {
|
|
local url="${1:-}" host="${2:-}"
|
|
[[ -n "$url" && -n "$host" ]] || return 1
|
|
python3 - "$url" "$host" <<'PY'
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
url, remote_host = sys.argv[1:]
|
|
configured = urlparse(url)
|
|
remote = urlparse(f"//{remote_host}")
|
|
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
|
raise SystemExit(1)
|
|
|
|
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit
|
|
# default-port form (":80" for http, ":443" for https) name the same
|
|
# provider endpoint. Apply that equivalence symmetrically -- whichever side
|
|
# omits the port is treated as carrying the scheme's default port -- so
|
|
# "configured implicit vs. remote explicit" and "configured explicit vs.
|
|
# remote implicit" both match. (The remote side here is always an HTTP(S)
|
|
# authority; an SSH remote's transport port is stripped by get_remote_host
|
|
# before reaching this comparison, since it identifies an unrelated
|
|
# service on the same host, not the HTTP(S) provider port.)
|
|
default_port = 80 if configured.scheme == "http" else 443
|
|
normalized_configured = configured.port if configured.port is not None else default_port
|
|
normalized_remote = remote.port if remote.port is not None else default_port
|
|
if normalized_configured != normalized_remote:
|
|
raise SystemExit(1)
|
|
raise SystemExit(0)
|
|
PY
|
|
}
|
|
|
|
get_gitea_service_for_host() {
|
|
local host="$1"
|
|
local cred_file="${MOSAIC_CREDENTIALS_FILE:-}"
|
|
if [[ -z "$cred_file" ]]; then
|
|
# Same resolution chain as _lib/credentials.sh: profile HOME, then
|
|
# host-level /etc only if it exists; neither existing keeps the
|
|
# $HOME default (matches the lib's final := fallback).
|
|
cred_file="$HOME/.config/mosaic/credentials.json"
|
|
if [[ ! -f "$cred_file" && -f /etc/mosaic/credentials.json ]]; then
|
|
cred_file="/etc/mosaic/credentials.json"
|
|
fi
|
|
fi
|
|
|
|
case "$host" in
|
|
git.mosaicstack.dev)
|
|
echo "mosaicstack"
|
|
return 0
|
|
;;
|
|
git.uscllc.com)
|
|
echo "usc"
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
[[ -f "$cred_file" ]] || return 1
|
|
command -v jq >/dev/null 2>&1 || return 1
|
|
|
|
jq -r --arg host "$host" '
|
|
.gitea // {}
|
|
| to_entries[]
|
|
| select((.value.url // "" | sub("/+$"; "")) | test("https?://" + $host + "$"))
|
|
| .key
|
|
' "$cred_file" | head -n 1
|
|
}
|
|
|
|
find_tea_login_for_host() {
|
|
local host="$1"
|
|
local logins_json
|
|
|
|
command -v tea >/dev/null 2>&1 || return 1
|
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
|
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
host = sys.argv[1]
|
|
try:
|
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
|
except Exception:
|
|
raise SystemExit(1)
|
|
|
|
for login in logins if isinstance(logins, list) else []:
|
|
url = str(login.get("url") or login.get("URL") or "")
|
|
name = str(login.get("name") or login.get("Name") or "")
|
|
parsed = urlparse(url)
|
|
if parsed.hostname == host and name:
|
|
print(name)
|
|
raise SystemExit(0)
|
|
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
tea_login_matches_host() {
|
|
local login_name="$1" host="$2"
|
|
local logins_json
|
|
|
|
command -v tea >/dev/null 2>&1 || return 1
|
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
|
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" "$host" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
login_name, host = sys.argv[1], sys.argv[2]
|
|
try:
|
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
|
except Exception:
|
|
raise SystemExit(1)
|
|
|
|
for login in logins if isinstance(logins, list) else []:
|
|
url = str(login.get("url") or login.get("URL") or "")
|
|
name = str(login.get("name") or login.get("Name") or "")
|
|
parsed = urlparse(url)
|
|
if name == login_name and parsed.hostname == host:
|
|
raise SystemExit(0)
|
|
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
# Emit an actionable diagnostic to stderr when no tea login resolves for a host.
|
|
# Callers that have a working API fallback may ignore the non-zero return of
|
|
# get_gitea_login_for_host; this turns the previously SILENT failure into a loud,
|
|
# greppable hint (available logins + override + add-login instructions). Printed to
|
|
# stderr only, so it never contaminates stdout (the resolved login name) or log
|
|
# assertions that capture tea/curl invocations.
|
|
print_gitea_login_diagnostic() {
|
|
local host="${1:-<unknown>}"
|
|
local available
|
|
available=$(
|
|
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
|
|
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
|
|
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
|
|
import json, os
|
|
from urllib.parse import urlparse
|
|
try:
|
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
|
except Exception:
|
|
logins = []
|
|
rows = []
|
|
for login in logins if isinstance(logins, list) else []:
|
|
name = str(login.get("name") or login.get("Name") or "")
|
|
url = str(login.get("url") or login.get("URL") or "")
|
|
host = urlparse(url).hostname or "?"
|
|
if name:
|
|
rows.append(f"{name} (host: {host})")
|
|
print("; ".join(rows) if rows else "(none configured)")
|
|
PY
|
|
)
|
|
{
|
|
echo "Error: no Gitea tea login matches host '$host'."
|
|
echo " Available tea logins: ${available}"
|
|
echo " Fix: set GITEA_LOGIN to a login whose URL host is '$host',"
|
|
echo " or add one: tea login add --name <name> --url https://$host --token <token>"
|
|
} >&2
|
|
}
|
|
|
|
get_gitea_login_for_host() {
|
|
local host="${1:-}"
|
|
local login
|
|
|
|
if [[ -z "$host" ]]; then
|
|
host=$(get_remote_host) || return 1
|
|
fi
|
|
|
|
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
|
if tea_login_matches_host "$GITEA_LOGIN" "$host"; then
|
|
echo "$GITEA_LOGIN"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
login=$(find_tea_login_for_host "$host" || true)
|
|
if [[ -n "$login" ]]; then
|
|
echo "$login"
|
|
return 0
|
|
fi
|
|
|
|
print_gitea_login_diagnostic "$host"
|
|
return 1
|
|
}
|
|
|
|
# Validate the current authenticated Gitea user for a resolved Tea login.
|
|
# Tea stores a user name with each login which can become stale after user rename,
|
|
# token rotation, or server migration. Querying /user derives the identity from the
|
|
# active credential instead of trusting that saved name. Callers fall back to the
|
|
# host-scoped API path when this validation fails.
|
|
get_gitea_authenticated_user() {
|
|
local login_name="$1" response
|
|
|
|
command -v tea >/dev/null 2>&1 || return 1
|
|
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
|
|
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
try:
|
|
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
|
|
except (KeyError, json.JSONDecodeError):
|
|
raise SystemExit(1)
|
|
|
|
login = user.get("login") if isinstance(user, dict) else None
|
|
if isinstance(login, str) and login:
|
|
print(login)
|
|
raise SystemExit(0)
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
get_default_tea_login() {
|
|
local logins_json
|
|
|
|
command -v tea >/dev/null 2>&1 || return 1
|
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
|
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
try:
|
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
|
except Exception:
|
|
raise SystemExit(1)
|
|
|
|
if not isinstance(logins, list) or not logins:
|
|
raise SystemExit(1)
|
|
|
|
for login in logins:
|
|
if not isinstance(login, dict):
|
|
continue
|
|
is_default = str(login.get("default") or login.get("Default") or "").lower()
|
|
name = str(login.get("name") or login.get("Name") or "")
|
|
if name and is_default == "true":
|
|
print(name)
|
|
raise SystemExit(0)
|
|
|
|
for login in logins:
|
|
if not isinstance(login, dict):
|
|
continue
|
|
name = str(login.get("name") or login.get("Name") or "")
|
|
if name:
|
|
print(name)
|
|
raise SystemExit(0)
|
|
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
get_gitea_login_for_repo_override() {
|
|
local login
|
|
|
|
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
|
echo "$GITEA_LOGIN"
|
|
return 0
|
|
fi
|
|
|
|
login=$(get_default_tea_login || true)
|
|
if [[ -n "$login" ]]; then
|
|
echo "$login"
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
get_host_from_url() {
|
|
local url="${1:-}"
|
|
[[ -n "$url" ]] || return 1
|
|
|
|
python3 - "$url" <<'PY'
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(sys.argv[1])
|
|
if parsed.hostname:
|
|
print(parsed.hostname)
|
|
raise SystemExit(0)
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
get_gitea_api_host_for_repo_override() {
|
|
if [[ -n "${GITEA_HOST:-}" ]]; then
|
|
echo "$GITEA_HOST"
|
|
return 0
|
|
fi
|
|
|
|
get_host_from_url "${GITEA_URL:-}"
|
|
}
|
|
|
|
# Resolve owner/repo relative to a configured Gitea base URL. HTTP(S) clone
|
|
# URLs can include the deployment prefix (for example /gitea/owner/repo.git),
|
|
# but Gitea's /repos API expects only owner/repo. Root-mounted and SSH clone
|
|
# forms retain their existing owner/repo behavior.
|
|
get_gitea_repo_slug_for_url() {
|
|
local configured_url="$1" remote_url
|
|
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
|
|
|
|
if [[ "$remote_url" =~ ^https?:// ]]; then
|
|
python3 - "$remote_url" "$configured_url" <<'PY'
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
remote = urlparse(sys.argv[1])
|
|
base = urlparse(sys.argv[2])
|
|
remote_path = remote.path.strip("/")
|
|
if remote_path.endswith(".git"):
|
|
remote_path = remote_path[:-4]
|
|
base_path = base.path.strip("/")
|
|
remote_parts = [part for part in remote_path.split("/") if part]
|
|
base_parts = [part for part in base_path.split("/") if part]
|
|
|
|
if base_parts and remote_parts[:len(base_parts)] == base_parts:
|
|
repo_parts = remote_parts[len(base_parts):]
|
|
elif len(remote_parts) == 2:
|
|
# Preserve a root-shaped clone URL when provider API configuration carries
|
|
# a reverse-proxy prefix separately.
|
|
repo_parts = remote_parts
|
|
else:
|
|
raise SystemExit(1)
|
|
|
|
if len(repo_parts) != 2:
|
|
raise SystemExit(1)
|
|
print("/".join(repo_parts))
|
|
PY
|
|
return
|
|
fi
|
|
|
|
get_repo_slug
|
|
}
|
|
|
|
get_gitea_repo_args() {
|
|
local repo host login
|
|
repo=$(get_repo_slug) || return 1
|
|
host=$(get_remote_host) || return 1
|
|
login=$(get_gitea_login_for_host "$host") || return 1
|
|
printf -- '--repo %q --login %q' "$repo" "$login"
|
|
}
|
|
|
|
get_gitea_login() {
|
|
get_gitea_login_for_host "$(get_remote_host)"
|
|
}
|
|
|
|
get_remote_host() {
|
|
local remote_url
|
|
remote_url=$(git remote get-url origin 2>/dev/null || true)
|
|
if [[ -z "$remote_url" ]]; then
|
|
return 1
|
|
fi
|
|
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
|
|
local host="${BASH_REMATCH[1]}"
|
|
echo "${host##*@}"
|
|
return 0
|
|
fi
|
|
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
|
local host="${BASH_REMATCH[1]}"
|
|
host="${host##*@}"
|
|
# Strip an SSH transport port (e.g. "git.example:2222"): it names the
|
|
# SSH daemon port, not the HTTP(S) provider API port, and must not
|
|
# feed gitea_url_matches_host's port comparison (#850).
|
|
echo "${host%%:*}"
|
|
return 0
|
|
fi
|
|
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
|
echo "${BASH_REMATCH[1]}"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Resolve the configured Gitea base URL for a host from the same credential
|
|
# source used by get_gitea_token. The scheme and any deployment path prefix are
|
|
# provider configuration and must not be reconstructed from the git remote.
|
|
get_gitea_url_for_host() {
|
|
local host="$1" script_dir cred_loader url
|
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
cred_loader="$script_dir/../_lib/credentials.sh"
|
|
|
|
if [[ -f "$cred_loader" ]]; then
|
|
url=$(
|
|
# shellcheck source=/dev/null
|
|
source "$cred_loader"
|
|
unset GITEA_TOKEN GITEA_URL
|
|
case "$host" in
|
|
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
|
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
|
*)
|
|
for svc in gitea-mosaicstack gitea-usc; do
|
|
unset GITEA_TOKEN GITEA_URL
|
|
load_credentials "$svc" 2>/dev/null || continue
|
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
|
break
|
|
fi
|
|
unset GITEA_TOKEN GITEA_URL
|
|
done
|
|
;;
|
|
esac
|
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
|
printf '%s' "${GITEA_URL%/}"
|
|
fi
|
|
)
|
|
if [[ -n "$url" ]]; then
|
|
printf '%s\n' "$url"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
|
printf '%s\n' "${GITEA_URL%/}"
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Resolve a Gitea API token for the given host.
|
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
|
get_gitea_token() {
|
|
local host="$1"
|
|
local script_dir
|
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
local cred_loader="$script_dir/../_lib/credentials.sh"
|
|
|
|
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
|
if [[ -f "$cred_loader" ]]; then
|
|
local token
|
|
token=$(
|
|
# shellcheck source=/dev/null
|
|
source "$cred_loader"
|
|
# Host-specific wrapper resolution must not inherit caller/global GITEA_*.
|
|
# load_credentials intentionally preserves existing env vars for interactive use,
|
|
# but metadata/merge wrappers need credentials matching the remote host.
|
|
unset GITEA_TOKEN GITEA_URL
|
|
case "$host" in
|
|
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
|
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
|
*)
|
|
local matched=false
|
|
for svc in gitea-mosaicstack gitea-usc; do
|
|
unset GITEA_TOKEN GITEA_URL
|
|
load_credentials "$svc" 2>/dev/null || continue
|
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
|
matched=true
|
|
break
|
|
fi
|
|
done
|
|
if [[ "$matched" != true ]]; then
|
|
unset GITEA_TOKEN GITEA_URL
|
|
fi
|
|
;;
|
|
esac
|
|
echo "${GITEA_TOKEN:-}"
|
|
)
|
|
if [[ -n "$token" ]]; then
|
|
echo "$token"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
|
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
|
echo "$GITEA_TOKEN"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# 3. ~/.git-credentials file
|
|
local creds="$HOME/.git-credentials"
|
|
if [[ -f "$creds" ]]; then
|
|
local token
|
|
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
|
if [[ -n "$token" ]]; then
|
|
echo "$token"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Resolve the API token for a SPECIFIC tea login name from tea's own config
|
|
# (the same store tea itself writes/reads for `--login <name>`). This is what
|
|
# lets a REST write be performed AS the selected --login identity: tea keys its
|
|
# per-login tokens by `name` in $XDG_CONFIG_HOME/tea/config.yml (default
|
|
# ~/.config/tea/config.yml), exactly as the `tea` CLI resolves them, so a
|
|
# --login override and its REST read-back bind to the SAME credential/identity.
|
|
#
|
|
# $2 (repo host) binds the selected credential to the TARGET host: a tea login
|
|
# also records the `url` it authenticates against, and the matched login's URL
|
|
# host MUST equal the repo host. This fails closed when an override login is
|
|
# configured for a DIFFERENT host than the repo remote, so a login name shared
|
|
# across hosts (or a mistargeted override) can never send one host's credential
|
|
# to another host (cross-host credential leak). When $2 is empty the host bind
|
|
# is skipped (host-agnostic lookup) — callers that write should always pass it.
|
|
#
|
|
# Prints the token on success; returns non-zero (no output) if the config, a
|
|
# matching login token, or the host bind cannot be satisfied. Callers must not
|
|
# log the result.
|
|
get_gitea_token_for_login() {
|
|
local login_name="$1" repo_host="${2:-}" config_file
|
|
[[ -n "$login_name" ]] || return 1
|
|
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
|
|
[[ -f "$config_file" ]] || return 1
|
|
|
|
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
|
|
import os
|
|
import re
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
wanted = os.environ["LOGIN_NAME"]
|
|
repo_host = os.environ.get("REPO_HOST", "").strip().lower()
|
|
config_path = sys.argv[1]
|
|
|
|
|
|
def _strip_scalar(value):
|
|
# Resolve a YAML flow scalar the way PyYAML would for tea's simple scalars:
|
|
# honor surrounding quotes and strip a trailing inline comment. A quoted
|
|
# scalar keeps its literal contents (any '#' inside is data, not a comment);
|
|
# an unquoted scalar ends at the first whitespace-preceded '#' (a YAML
|
|
# comment must be preceded by whitespace or line start), so "abc#def" stays
|
|
# literal while "abc # note" becomes "abc".
|
|
value = value.strip()
|
|
if not value:
|
|
return value
|
|
if value[0] in ("'", '"'):
|
|
quote = value[0]
|
|
end = value.find(quote, 1)
|
|
if end != -1:
|
|
return value[1:end]
|
|
# Unterminated quote: PyYAML would error; return best-effort remainder so
|
|
# the (more conservative) caller still compares against the wanted name.
|
|
return value[1:]
|
|
for index, char in enumerate(value):
|
|
if char == "#" and (index == 0 or value[index - 1] in (" ", "\t")):
|
|
value = value[:index]
|
|
break
|
|
return value.strip()
|
|
|
|
|
|
def _url_matches_repo_host(url):
|
|
# Mirror gitea_url_matches_host (detect-platform.sh): the login's recorded
|
|
# URL must name the SAME host AND the SAME effective port as the repo remote
|
|
# host, not merely the same hostname. A login configured for an explicit,
|
|
# non-default provider port (e.g. :9443) must NOT satisfy a portless (default
|
|
# -port) repo host, and a login on the matching port (e.g. :8443) must NOT be
|
|
# rejected. Ports are normalized by applying the login URL's scheme default
|
|
# (80 for http, else 443) to whichever side omits the port, symmetrically, so
|
|
# an implicit port and its explicit default-port form compare equal.
|
|
if not isinstance(url, str) or not url:
|
|
return False
|
|
configured = urlparse(url if "//" in url else f"//{url}")
|
|
remote = urlparse(f"//{repo_host}")
|
|
configured_host = configured.hostname
|
|
if not configured_host or configured_host.lower() != (remote.hostname or "").lower():
|
|
return False
|
|
default_port = 80 if configured.scheme == "http" else 443
|
|
configured_port = configured.port if configured.port is not None else default_port
|
|
remote_port = remote.port if remote.port is not None else default_port
|
|
return configured_port == remote_port
|
|
|
|
|
|
def _accept(token, url):
|
|
# Enforce the host bind before surfacing a token. When a repo host is given,
|
|
# the login's recorded URL host AND port must match it; a login with no
|
|
# usable URL (or a mismatched host/port) is rejected (fail closed) so a
|
|
# cross-host (or cross-port) credential is never emitted.
|
|
if not isinstance(token, str) or not token:
|
|
return None
|
|
if repo_host:
|
|
if not _url_matches_repo_host(url):
|
|
return None
|
|
return token
|
|
|
|
|
|
def _token_via_pyyaml():
|
|
# Preferred, fully general path when PyYAML is installed. Raises ImportError
|
|
# (caught by the caller) when the module is unavailable so the environment
|
|
# -robust fallback can take over instead of failing closed on every host
|
|
# that lacks PyYAML.
|
|
import yaml
|
|
|
|
with open(config_path, encoding="utf-8") as handle:
|
|
config = yaml.safe_load(handle)
|
|
logins = config.get("logins") if isinstance(config, dict) else None
|
|
if not isinstance(logins, list):
|
|
return None
|
|
for login in logins:
|
|
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
|
|
return _accept(login.get("token"), login.get("url"))
|
|
return None
|
|
|
|
|
|
def _token_via_lines():
|
|
# Conservative fallback for hosts without PyYAML. tea writes config.yml in a
|
|
# fixed, flat shape (a `logins:` list of maps with scalar name/url/token
|
|
# fields). This scan is SCOPE-AWARE: a field is attributed to a login entry
|
|
# ONLY when it sits at that entry's own direct-field indentation. A field
|
|
# nested inside a deeper sub-map (e.g. `extra:\n token: X`) or a mis-indented
|
|
# line is NEVER attached to the entry — exactly the cases where PyYAML resolves
|
|
# the entry's own `token` to None (or errors) and thus fails closed. Likewise
|
|
# only list items at the login list's own dash indent open a new entry, so a
|
|
# nested list item cannot masquerade as a sibling login. It returns the
|
|
# `token` of the entry whose `name` EXACTLY equals the requested login AND
|
|
# whose url host/port matches the repo host; anything else yields None (fail
|
|
# closed). This can only ever be MORE conservative than PyYAML (it never
|
|
# selects a token where PyYAML would refuse), never less.
|
|
with open(config_path, encoding="utf-8") as handle:
|
|
lines = handle.read().splitlines()
|
|
|
|
logins_indent = None
|
|
start = len(lines)
|
|
for index, line in enumerate(lines):
|
|
match = re.match(r"^(\s*)logins\s*:\s*$", line)
|
|
if match:
|
|
logins_indent = len(match.group(1))
|
|
start = index + 1
|
|
break
|
|
if logins_indent is None:
|
|
return None
|
|
|
|
entries = []
|
|
current = None
|
|
item_indent = None # dash column of the login list's own items
|
|
field_indent = None # exact column of the current entry's direct fields
|
|
for line in lines[start:]:
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
indent = len(line) - len(line.lstrip(" "))
|
|
if indent <= logins_indent:
|
|
break
|
|
item = re.match(r"^(\s*)-(\s*)(.*)$", line)
|
|
if item:
|
|
dash_indent = len(item.group(1))
|
|
if item_indent is None:
|
|
item_indent = dash_indent
|
|
if dash_indent != item_indent:
|
|
# A more-deeply-indented (nested) or dedented list item: not a
|
|
# direct login entry. Ignore it and its scope.
|
|
continue
|
|
current = {}
|
|
entries.append(current)
|
|
content = item.group(3)
|
|
if content:
|
|
# First field shares this line; its column is the direct-field
|
|
# indent for the rest of the entry.
|
|
field_indent = dash_indent + 1 + len(item.group(2))
|
|
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", content)
|
|
if pair:
|
|
current[pair.group(1)] = _strip_scalar(pair.group(2))
|
|
else:
|
|
# Bare "-": the first following field line establishes the indent.
|
|
field_indent = None
|
|
continue
|
|
if current is None:
|
|
continue
|
|
if field_indent is None:
|
|
field_indent = indent
|
|
if indent != field_indent:
|
|
# Deeper => a nested sub-map's field (not this entry's own); anything
|
|
# else at an unexpected column is not a direct field. Skip either way.
|
|
continue
|
|
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", line.strip())
|
|
if pair:
|
|
current[pair.group(1)] = _strip_scalar(pair.group(2))
|
|
|
|
for entry in entries:
|
|
if str(entry.get("name") or "") == wanted:
|
|
return _accept(entry.get("token"), entry.get("url"))
|
|
return None
|
|
|
|
|
|
try:
|
|
try:
|
|
token = _token_via_pyyaml()
|
|
except ImportError:
|
|
token = _token_via_lines()
|
|
except Exception:
|
|
raise SystemExit(1)
|
|
|
|
if isinstance(token, str) and token:
|
|
print(token)
|
|
raise SystemExit(0)
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
|
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
|
get_gitea_basic_auth() {
|
|
local host="$1"
|
|
local creds="$HOME/.git-credentials"
|
|
if [[ ! -f "$creds" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
python3 - "$host" "$creds" <<'PY'
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
host = sys.argv[1]
|
|
creds = Path(sys.argv[2])
|
|
|
|
for line in creds.read_text(encoding="utf-8").splitlines():
|
|
parsed = urlparse(line.strip())
|
|
if parsed.hostname != host:
|
|
continue
|
|
username = unquote(parsed.username or "")
|
|
password = unquote(parsed.password or "")
|
|
if username and password:
|
|
print(f"{username}:{password}")
|
|
raise SystemExit(0)
|
|
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
# If script is run directly (not sourced), output the platform
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
detect_platform
|
|
fi
|