Compare commits
1 Commits
fix/865-te
...
fix/850-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c26278c67 |
@@ -4,32 +4,6 @@ These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone
|
|||||||
|
|
||||||
## Durable review provenance
|
## Durable review provenance
|
||||||
|
|
||||||
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write.
|
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
|
||||||
|
|
||||||
**The write is a direct Gitea REST `POST` that returns the created record's id.** Neither wrapper writes through `tea` — tea 0.11.1 can silently no-op while exiting 0 and cannot emit the id of a record it creates, so its exit code is worthless as proof of a durable write (#865). Instead:
|
`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes.
|
||||||
|
|
||||||
- Comments (`issue-comment.sh`, and the `comment` action of `pr-review.sh`) `POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments`, requiring a `201` and parsing the created comment's `id` from the response body.
|
|
||||||
- Reviews (`approve` / `request-changes`) `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews` with the `event` (`APPROVED` / `REQUEST_CHANGES`), the review `body`, and `commit_id` pinned to the PR's current head, then parse the created review's `id`. The review body travels _in the review submit itself_ — there is no separate detached comment to reconcile (a Gitea `REQUEST_CHANGES` review requires a non-empty body, which the submit carries).
|
|
||||||
|
|
||||||
**Verification keys on that exact provider-returned id.** The wrapper then `GET`s that one record directly — `GET /issues/comments/{id}` or `GET /pulls/{n}/reviews/{id}` — and requires that its `id` equals the created id, its **author login equals the acting identity** (resolved via `GET /api/v1/user` for the token in use), and, for comments, its body exactly matches what was submitted **and its returned web URL belongs to this exact provider and repository** (the `issue_url` / `pull_request_url` origin — scheme, host, and effective port — and full path, i.e. deployment prefix + exact `owner/repo` + kind + number, must match; a suffix/`endsWith` test would accept a look-alike host or a decoy path prefix, so the whole normalized URL is compared). The `comment` action of `pr-review.sh` additionally requires the returned resource be a **pull request** (a populated `pull_request_url`); a bare `issue_url` is rejected, so if issue `#N` exists but PR `#N` does not, an issue comment cannot be reported as a verified PR comment. (`issue-comment.sh` legitimately keeps the broader issue-or-PR acceptance.) For reviews, its state matches the requested action, its reviewed `commit_id` equals the PR head, **and its persisted body equals the submitted body** — an exact, presence- and type-checked equality (a missing/`null` persisted body no longer counts as an empty match), because Gitea can finalize/reuse a pending review id whose stored content was authored elsewhere, so the body is bound too. The write, the `/user` identity lookup, and the read-back all use the **same** credential — the effective login's token, or the host credential when no login is named — so the write is verified against the identity that actually performed it.
|
|
||||||
|
|
||||||
**A review's pinned head is re-checked after verification (current-head TOCTOU).** The `commit_id` is pinned to the PR head read _before_ the submit; between that read and the read-back the branch could advance (a force-push or a new commit), leaving a verified review attached to a now-superseded commit while the live tip carries unreviewed code. After the exact-id read-back succeeds, the wrapper re-reads the live PR head (`GET …/pulls/{n}`) and requires it still equals the submitted SHA; if the head advanced it fails closed (non-zero, no success line) rather than reporting a review that no longer covers the PR's current commit.
|
|
||||||
|
|
||||||
**This closes the concurrency window rather than documenting it.** Because verification keys on the id the create returned, a no-op create yields no id and fails closed with no list-scan fallback, and a _concurrent_ record — even one written by the _same_ identity with an identical body/state — has a _different_ id and cannot be mistaken for this write. There is no residual same-identity window: the earlier boundary-and-author heuristic (accept any `id > pre-write-max` with a matching author) is replaced entirely by exact-id attribution.
|
|
||||||
|
|
||||||
**Exact-id read-back is the sole authority.** Verification is a direct `GET` of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (`?limit=&page=1,2,…`) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id `GET`.
|
|
||||||
|
|
||||||
## Credential handling
|
|
||||||
|
|
||||||
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
|
|
||||||
|
|
||||||
## `tea` invocation notes (Gitea)
|
|
||||||
|
|
||||||
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`** — the `tea pr comment` / `tea issue comment` forms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (`tea comment`, `tea pr approve`/`reject`) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do **not** write reviews or comments through `tea` at all; they use direct Gitea REST `POST`s that return the created record's id (see "Durable review provenance" above). `tea` is consulted only to enumerate the login list for host→login resolution.
|
|
||||||
- Because the review body is carried in the `POST …/reviews` submit itself, there is no separate detached review comment, and the historical `tea pr approve`/`reject` trailing-positional-argument vs. nonexistent `--comment`/`-comment` flag hazard (#835) no longer applies to these wrappers — no review comment is ever passed to `tea`.
|
|
||||||
|
|
||||||
### `--login` override
|
|
||||||
|
|
||||||
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
|
||||||
|
|
||||||
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
|
||||||
|
|||||||
@@ -563,623 +563,6 @@ get_gitea_token() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Stage the Gitea bearer credential for curl OUTSIDE the process argument vector.
|
|
||||||
# Passing "-H 'Authorization: token <value>'" on the curl command line exposes
|
|
||||||
# the token to anyone who can read the process table (ps / /proc/<pid>/cmdline)
|
|
||||||
# for the lifetime of the request. Instead, write the header into a private
|
|
||||||
# (mode 0600) curl config file and have callers pass it with `curl --config`, so
|
|
||||||
# only the FILE PATH — never the token — appears in argv. Prints the temp file
|
|
||||||
# path on success; the caller OWNS the file and MUST remove it on every exit
|
|
||||||
# path (success and failure). $1 = bearer token. Callers must not log the token.
|
|
||||||
gitea_write_auth_config() {
|
|
||||||
local token="$1" auth_file
|
|
||||||
auth_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-gitea-auth.XXXXXX") || return 1
|
|
||||||
# mktemp already creates the file with 0600; be explicit in case of an
|
|
||||||
# unusual umask so the credential is never briefly group/other readable.
|
|
||||||
chmod 600 "$auth_file" 2>/dev/null || true
|
|
||||||
# `header = "..."` is curl's config syntax for an extra request header. Only
|
|
||||||
# this filename reaches curl's argv; the token stays on disk, readable solely
|
|
||||||
# by this user, and is unlinked by the caller's trap after the request.
|
|
||||||
if ! printf 'header = "Authorization: token %s"\n' "$token" > "$auth_file"; then
|
|
||||||
rm -f "$auth_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$auth_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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 datetime
|
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
# PyYAML 6.0.3 SafeLoader implicit resolver patterns (YAML 1.1). An UNQUOTED
|
|
||||||
# plain scalar matching any of these is resolved by PyYAML to a NON-string type
|
|
||||||
# (null->None, bool, int, float, timestamp->date/datetime); a quoted scalar is
|
|
||||||
# ALWAYS a string. These mirror yaml/resolver.py so the PyYAML-absent fallback
|
|
||||||
# types unquoted scalars exactly as PyYAML would (see _implicit_nonstring).
|
|
||||||
_IMPLICIT_NULL = re.compile(r"^(?:~|null|Null|NULL|)$")
|
|
||||||
_IMPLICIT_BOOL = re.compile(
|
|
||||||
r"^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE"
|
|
||||||
r"|on|On|ON|off|Off|OFF)$"
|
|
||||||
)
|
|
||||||
_IMPLICIT_INT = re.compile(
|
|
||||||
r"^(?:[-+]?0b[0-1_]+"
|
|
||||||
r"|[-+]?0[0-7_]+"
|
|
||||||
r"|[-+]?(?:0|[1-9][0-9_]*)"
|
|
||||||
r"|[-+]?0x[0-9a-fA-F_]+"
|
|
||||||
r"|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$"
|
|
||||||
)
|
|
||||||
_IMPLICIT_FLOAT = re.compile(
|
|
||||||
r"^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?"
|
|
||||||
r"|\.[0-9][0-9_]*(?:[eE][-+]?[0-9]+)?"
|
|
||||||
r"|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*"
|
|
||||||
r"|[-+]?\.(?:inf|Inf|INF)"
|
|
||||||
r"|\.(?:nan|NaN|NAN))$"
|
|
||||||
)
|
|
||||||
_IMPLICIT_TIMESTAMP = re.compile(
|
|
||||||
r"^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]"
|
|
||||||
r"|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?"
|
|
||||||
r"(?:[Tt]|[ \t]+)[0-9][0-9]?"
|
|
||||||
r":[0-9][0-9]:[0-9][0-9](?:\.[0-9]*)?"
|
|
||||||
r"(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _implicit_nonstring(text):
|
|
||||||
# True when an UNQUOTED plain scalar would be resolved by PyYAML's SafeLoader
|
|
||||||
# to a non-string type (null/bool/int/float/timestamp). Fuzzed against real
|
|
||||||
# PyYAML 6.0.3: it never returns False where PyYAML types the scalar as a
|
|
||||||
# non-string (i.e. never fail-open), and is at worst MORE conservative on a
|
|
||||||
# couple of degenerate float spellings (e.g. "4.e8") that PyYAML keeps as a
|
|
||||||
# string -- the safe direction for this credential-selecting fallback.
|
|
||||||
return bool(
|
|
||||||
_IMPLICIT_NULL.match(text)
|
|
||||||
or _IMPLICIT_BOOL.match(text)
|
|
||||||
or _IMPLICIT_INT.match(text)
|
|
||||||
or _IMPLICIT_FLOAT.match(text)
|
|
||||||
or _IMPLICIT_TIMESTAMP.match(text)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# PyYAML 6.0.3 SafeConstructor timestamp regexp (yaml/constructor.py). The
|
|
||||||
# constructor RE-parses a timestamp-tagged scalar with THIS pattern and then
|
|
||||||
# builds a datetime.date/datetime, which raises ValueError for an out-of-range
|
|
||||||
# calendar field (e.g. month 99, hour 25). _IMPLICIT_TIMESTAMP (the RESOLVER
|
|
||||||
# pattern) is byte-identical to PyYAML's resolver, so anything it tags is also
|
|
||||||
# tagged by PyYAML and re-matched here.
|
|
||||||
_TIMESTAMP_CONSTRUCT = re.compile(
|
|
||||||
r"""^(?P<year>[0-9][0-9][0-9][0-9])
|
|
||||||
-(?P<month>[0-9][0-9]?)
|
|
||||||
-(?P<day>[0-9][0-9]?)
|
|
||||||
(?:(?:[Tt]|[ \t]+)
|
|
||||||
(?P<hour>[0-9][0-9]?)
|
|
||||||
:(?P<minute>[0-9][0-9])
|
|
||||||
:(?P<second>[0-9][0-9])
|
|
||||||
(?:\.(?P<fraction>[0-9]*))?
|
|
||||||
(?:[ \t]*(?P<tz>Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)
|
|
||||||
(?::(?P<tz_minute>[0-9][0-9]))?))?)?$""",
|
|
||||||
re.X,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _int_constructible(text):
|
|
||||||
# Replicate PyYAML SafeConstructor.construct_yaml_int and report whether it
|
|
||||||
# would succeed. A resolver-tagged int whose radix body is empty after
|
|
||||||
# underscore removal (e.g. "0b_", "0x_", "0x__") makes int(base) raise, so
|
|
||||||
# PyYAML fails the WHOLE document -> the fallback must fail closed too.
|
|
||||||
value = text.replace("_", "")
|
|
||||||
if value[:1] in ("+", "-"):
|
|
||||||
value = value[1:]
|
|
||||||
if value == "0":
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
if value.startswith("0b"):
|
|
||||||
int(value[2:], 2)
|
|
||||||
elif value.startswith("0x"):
|
|
||||||
int(value[2:], 16)
|
|
||||||
elif value[:1] == "0":
|
|
||||||
int(value, 8)
|
|
||||||
elif ":" in value:
|
|
||||||
[int(part) for part in value.split(":")]
|
|
||||||
else:
|
|
||||||
int(value)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _float_constructible(text):
|
|
||||||
# Replicate PyYAML SafeConstructor.construct_yaml_float. Retained for the
|
|
||||||
# WHOLE-document invariant even though PyYAML's float-tagged set is always
|
|
||||||
# float()-constructible: the fallback's float RESOLVER pattern is a strict
|
|
||||||
# superset of PyYAML's (it also matches unsigned-exponent spellings PyYAML
|
|
||||||
# keeps as strings), and every such extra is likewise constructible, so this
|
|
||||||
# never fails closed where PyYAML would emit a token.
|
|
||||||
value = text.replace("_", "").lower()
|
|
||||||
if value[:1] in ("+", "-"):
|
|
||||||
value = value[1:]
|
|
||||||
if value in (".inf", ".nan"):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
if ":" in value:
|
|
||||||
[float(part) for part in value.split(":")]
|
|
||||||
else:
|
|
||||||
float(value)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _timestamp_constructible(text):
|
|
||||||
# Replicate PyYAML SafeConstructor.construct_yaml_timestamp: build the same
|
|
||||||
# datetime.date/datetime and report whether it raises. Returns False for an
|
|
||||||
# out-of-range calendar field (month/day/hour/...), matching PyYAML's
|
|
||||||
# whole-document ValueError.
|
|
||||||
match = _TIMESTAMP_CONSTRUCT.match(text)
|
|
||||||
if not match:
|
|
||||||
return False
|
|
||||||
values = match.groupdict()
|
|
||||||
try:
|
|
||||||
year = int(values["year"])
|
|
||||||
month = int(values["month"])
|
|
||||||
day = int(values["day"])
|
|
||||||
if not values["hour"]:
|
|
||||||
datetime.date(year, month, day)
|
|
||||||
return True
|
|
||||||
hour = int(values["hour"])
|
|
||||||
minute = int(values["minute"])
|
|
||||||
second = int(values["second"])
|
|
||||||
fraction = 0
|
|
||||||
if values["fraction"]:
|
|
||||||
frac = values["fraction"][:6]
|
|
||||||
frac += "0" * (6 - len(frac))
|
|
||||||
fraction = int(frac)
|
|
||||||
tzinfo = None
|
|
||||||
if values["tz_sign"]:
|
|
||||||
tz_hour = int(values["tz_hour"])
|
|
||||||
tz_minute = int(values["tz_minute"] or 0)
|
|
||||||
delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute)
|
|
||||||
if values["tz_sign"] == "-":
|
|
||||||
delta = -delta
|
|
||||||
tzinfo = datetime.timezone(delta)
|
|
||||||
elif values["tz"]:
|
|
||||||
tzinfo = datetime.timezone.utc
|
|
||||||
datetime.datetime(
|
|
||||||
year, month, day, hour, minute, second, fraction, tzinfo=tzinfo
|
|
||||||
)
|
|
||||||
except (ValueError, OverflowError):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _constructible(text):
|
|
||||||
# WHOLE-DOCUMENT INVARIANT: the fallback resolves the SAME login token as
|
|
||||||
# PyYAML safe_load or fails closed -- never less conservative -- INCLUDING
|
|
||||||
# when PyYAML raises a CONSTRUCTOR error anywhere in the document. A plain
|
|
||||||
# scalar can match a typed implicit resolver (int/float/timestamp) yet NOT be
|
|
||||||
# constructible (e.g. 2023-99-99, 0b_, 0x_); PyYAML then raises on the whole
|
|
||||||
# load and yields no token, so the fallback MUST fail closed for the whole
|
|
||||||
# document too. This returns True only when PyYAML's constructor would build
|
|
||||||
# the scalar (null/bool token sets are always constructible), else False so
|
|
||||||
# the caller fails closed. `text` is assumed to satisfy _implicit_nonstring.
|
|
||||||
if _IMPLICIT_NULL.match(text) or _IMPLICIT_BOOL.match(text):
|
|
||||||
return True
|
|
||||||
if _IMPLICIT_TIMESTAMP.match(text):
|
|
||||||
return _timestamp_constructible(text)
|
|
||||||
if _IMPLICIT_INT.match(text):
|
|
||||||
return _int_constructible(text)
|
|
||||||
if _IMPLICIT_FLOAT.match(text):
|
|
||||||
return _float_constructible(text)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# Sentinel: "outside the supported subset -> fail closed". Distinct from a
|
|
||||||
# genuine null (None), which is a valid resolved value.
|
|
||||||
_FAIL = object()
|
|
||||||
# Separators are SPACE-only: PyYAML rejects a tab used as key/value whitespace
|
|
||||||
# (before or after the ':') with a scanner error, so a tab there must NOT be
|
|
||||||
# treated as a benign separator. Space before the colon and one space after it
|
|
||||||
# stay valid (PyYAML strips a plain key's trailing spaces); a tab in either
|
|
||||||
# position makes the whole line fail to match -> the caller fails closed.
|
|
||||||
_KEY_RE = re.compile(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*) *:(?:[ ](.*)|)$")
|
|
||||||
_FLOW = set("[]{}*&!")
|
|
||||||
|
|
||||||
|
|
||||||
class _Bail(Exception):
|
|
||||||
# Raised the instant the document leaves the narrow tea-config subset this
|
|
||||||
# recognizer can prove it resolves IDENTICALLY to PyYAML. Caught by
|
|
||||||
# _safe_parse, which then fails closed (returns _FAIL) rather than guess.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _scalar(raw):
|
|
||||||
# Resolve a single flow scalar (quoted or plain) the way PyYAML would for
|
|
||||||
# tea's simple values, or _FAIL when it is outside the supported subset so
|
|
||||||
# the caller fails closed instead of guessing.
|
|
||||||
#
|
|
||||||
# A quoted scalar is ALWAYS a string (its contents returned verbatim); a '#'
|
|
||||||
# inside quotes is data. 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"), and is then
|
|
||||||
# subject to PyYAML's implicit typing: forms like 12345 / null / ~ / yes /
|
|
||||||
# 3.14 / a timestamp resolve to a NON-string (int/None/bool/float/date), so
|
|
||||||
# they return None here (PyYAML's path rejects a non-str token via _accept).
|
|
||||||
# Strip SPACES only, never tabs: PyYAML raises a scanner error on a tab in a
|
|
||||||
# plain/leading/trailing scalar position, so a tab must be PRESERVED here to
|
|
||||||
# trip the fail-closed guard below rather than be silently normalized away.
|
|
||||||
value = raw.strip(" ")
|
|
||||||
if not value:
|
|
||||||
return None # empty plain scalar -> null
|
|
||||||
if value[0] in ("'", '"'):
|
|
||||||
quote = value[0]
|
|
||||||
end = value.find(quote, 1)
|
|
||||||
if end == -1:
|
|
||||||
return _FAIL # unterminated quote: PyYAML would error / continue
|
|
||||||
rest = value[end + 1:].strip(" ")
|
|
||||||
if rest and not rest.startswith("#"):
|
|
||||||
return _FAIL # trailing junk after a quoted scalar
|
|
||||||
inner = value[1:end]
|
|
||||||
# Single-quote '' escaping and double-quote backslash escapes are NOT
|
|
||||||
# interpreted here; reject any scalar that uses them so we never diverge
|
|
||||||
# from PyYAML on escape handling.
|
|
||||||
if quote == "'" and "'" in inner:
|
|
||||||
return _FAIL
|
|
||||||
if quote == '"' and "\\" in inner:
|
|
||||||
return _FAIL
|
|
||||||
return inner
|
|
||||||
for i, ch in enumerate(value):
|
|
||||||
if ch == "#" and (i == 0 or value[i - 1] in (" ", "\t")):
|
|
||||||
value = value[:i]
|
|
||||||
break
|
|
||||||
value = value.strip(" ") # spaces only; a tab must survive to fail closed
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
if value[0] in _FLOW or value[0] in ("|", ">", "?", "@", "`", '"', "'", "%", ","):
|
|
||||||
return _FAIL # flow / block-scalar / reserved / directive / anchor / quote
|
|
||||||
if value[0] in ("-", "?", ":") and (len(value) == 1 or value[1] in (" ", "\t")):
|
|
||||||
# A bare block indicator, not a plain scalar: '-'/'- ' opens a sequence
|
|
||||||
# entry, '?'/'? ' a complex mapping key, ':'/': ' a mapping value -- all
|
|
||||||
# illegal in a value position, where PyYAML raises a scanner error on the
|
|
||||||
# whole document. "-x"/"-1"/"?x"/":x" (indicator NOT followed by space)
|
|
||||||
# remain valid plain scalars and fall through. Fail closed on the bare
|
|
||||||
# indicator so the fallback never emits a token PyYAML would refuse.
|
|
||||||
return _FAIL
|
|
||||||
if any(ch in _FLOW for ch in value):
|
|
||||||
return _FAIL
|
|
||||||
if "\t" in value:
|
|
||||||
# A tab anywhere in a plain scalar (leading, trailing, or embedded) is a
|
|
||||||
# PyYAML scanner error on the whole document -- it accepts tabs ONLY
|
|
||||||
# inside quoted scalars (handled above, returned verbatim). Fail closed
|
|
||||||
# so the fallback never emits a token PyYAML would refuse over a tab.
|
|
||||||
return _FAIL
|
|
||||||
if ": " in value or value.endswith(":"):
|
|
||||||
return _FAIL # nested-mapping-in-scalar / ambiguous
|
|
||||||
if _implicit_nonstring(value):
|
|
||||||
# A plain scalar PyYAML would tag as a non-string (null/bool/int/float/
|
|
||||||
# timestamp). If PyYAML's CONSTRUCTOR would build it, the value is a
|
|
||||||
# non-string -> null-equivalent for a token field: return None and keep
|
|
||||||
# parsing (as before). But if it matches a typed implicit resolver yet is
|
|
||||||
# NOT constructible (e.g. 2023-99-99, 0b_, 0x_), PyYAML raises on the
|
|
||||||
# WHOLE document and yields no token, so the fallback MUST fail closed
|
|
||||||
# for the whole document too -> _FAIL (which the caller turns into _Bail).
|
|
||||||
if not _constructible(value):
|
|
||||||
return _FAIL
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class _Parser:
|
|
||||||
# A deliberately NARROW, conservative recognizer for the block-style YAML
|
|
||||||
# subset tea writes (mappings of scalar fields; a `logins:` block SEQUENCE of
|
|
||||||
# such mappings; optional shallow nested mappings for e.g. preferences). It
|
|
||||||
# reconstructs the SAME Python object PyYAML's SafeLoader would, but the
|
|
||||||
# instant it meets anything it cannot prove it handles identically -- a
|
|
||||||
# document marker (--- / ...), a block scalar (| / >), a flow collection, a
|
|
||||||
# duplicate mapping key, inconsistent indentation, an escape, or any line
|
|
||||||
# outside the grammar -- it raises _Bail so the whole resolution fails
|
|
||||||
# closed. This guarantees the module invariant (only ever MORE conservative
|
|
||||||
# than PyYAML, never less) at the DOCUMENT level, closing the structural
|
|
||||||
# fail-open classes (nested-logins shadow, block-scalar shadow, duplicate
|
|
||||||
# root key, malformed-after-valid, and extra-document) that a line scan that
|
|
||||||
# does not validate whole-document structure would miss.
|
|
||||||
def __init__(self, lines):
|
|
||||||
self.toks = []
|
|
||||||
for raw in lines:
|
|
||||||
if not raw.strip():
|
|
||||||
continue
|
|
||||||
lead = raw[: len(raw) - len(raw.lstrip(" \t"))]
|
|
||||||
if "\t" in lead:
|
|
||||||
raise _Bail() # tab in indentation: PyYAML scanner error
|
|
||||||
indent = len(lead)
|
|
||||||
body = raw[indent:]
|
|
||||||
if body.lstrip().startswith("#"):
|
|
||||||
continue
|
|
||||||
if re.match(r"^(---|\.\.\.)(\s|$)", body) or body in ("---", "..."):
|
|
||||||
raise _Bail() # document / end marker -> multi-doc -> fail closed
|
|
||||||
# rstrip SPACES only: a trailing tab is a PyYAML scanner error, so it
|
|
||||||
# must be kept on the token body to reach the fail-closed guards
|
|
||||||
# (rstrip() would swallow it and let a bad line resolve a token).
|
|
||||||
self.toks.append((indent, body.rstrip(" ")))
|
|
||||||
self.i = 0
|
|
||||||
|
|
||||||
def peek(self):
|
|
||||||
return self.toks[self.i] if self.i < len(self.toks) else None
|
|
||||||
|
|
||||||
def parse_document(self):
|
|
||||||
if not self.toks:
|
|
||||||
return None
|
|
||||||
node = self.parse_node(0)
|
|
||||||
if self.i != len(self.toks):
|
|
||||||
raise _Bail() # trailing unconsumed content -> malformed
|
|
||||||
return node
|
|
||||||
|
|
||||||
def parse_node(self, min_indent):
|
|
||||||
tok = self.peek()
|
|
||||||
if tok is None:
|
|
||||||
return None
|
|
||||||
indent, body = tok
|
|
||||||
if indent < min_indent:
|
|
||||||
return None
|
|
||||||
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
|
|
||||||
return self.parse_seq(indent)
|
|
||||||
return self.parse_map(indent)
|
|
||||||
|
|
||||||
def parse_map(self, indent):
|
|
||||||
result = {}
|
|
||||||
while True:
|
|
||||||
tok = self.peek()
|
|
||||||
if tok is None:
|
|
||||||
break
|
|
||||||
cur_indent, body = tok
|
|
||||||
if cur_indent < indent:
|
|
||||||
break
|
|
||||||
if cur_indent > indent:
|
|
||||||
raise _Bail() # unexpected deeper line (bad indentation)
|
|
||||||
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
|
|
||||||
raise _Bail() # sequence item where a mapping entry was expected
|
|
||||||
m = _KEY_RE.match(body)
|
|
||||||
if not m:
|
|
||||||
raise _Bail()
|
|
||||||
key = m.group(1)
|
|
||||||
inline = m.group(2)
|
|
||||||
self.i += 1
|
|
||||||
if key in result:
|
|
||||||
raise _Bail() # duplicate mapping key (PyYAML last-wins; we bail)
|
|
||||||
if inline is not None and inline.strip(" ") != "":
|
|
||||||
val = _scalar(inline)
|
|
||||||
if val is _FAIL:
|
|
||||||
raise _Bail()
|
|
||||||
result[key] = val
|
|
||||||
else:
|
|
||||||
result[key] = self.parse_block_value(indent)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def parse_block_value(self, key_indent):
|
|
||||||
# The value after a "key:" with no inline scalar. A block SEQUENCE may sit
|
|
||||||
# at the same indent as the key (YAML permits `- ` aligned with the key --
|
|
||||||
# tea's own on-disk shape) or deeper; a block MAPPING must be strictly
|
|
||||||
# deeper; otherwise the value is null.
|
|
||||||
nxt = self.peek()
|
|
||||||
if nxt is None:
|
|
||||||
return None
|
|
||||||
ni, nb = nxt
|
|
||||||
is_item = nb.startswith("-") and (len(nb) == 1 or nb[1] in (" ", "\t"))
|
|
||||||
if is_item and ni >= key_indent:
|
|
||||||
return self.parse_seq(ni)
|
|
||||||
if ni > key_indent:
|
|
||||||
return self.parse_node(ni)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse_seq(self, indent):
|
|
||||||
result = []
|
|
||||||
while True:
|
|
||||||
tok = self.peek()
|
|
||||||
if tok is None:
|
|
||||||
break
|
|
||||||
cur_indent, body = tok
|
|
||||||
if cur_indent < indent:
|
|
||||||
break
|
|
||||||
if cur_indent > indent:
|
|
||||||
raise _Bail()
|
|
||||||
if not (body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t"))):
|
|
||||||
break # a mapping entry at this indent ends the sequence
|
|
||||||
rest = body[1:].strip(" ") # spaces only; a tab must survive to bail
|
|
||||||
self.i += 1
|
|
||||||
if rest == "":
|
|
||||||
nxt = self.peek()
|
|
||||||
if nxt is not None and nxt[0] > indent:
|
|
||||||
result.append(self.parse_node(indent + 1))
|
|
||||||
else:
|
|
||||||
result.append(None)
|
|
||||||
continue
|
|
||||||
km = _KEY_RE.match(rest)
|
|
||||||
if km:
|
|
||||||
# "- key: value" opens a mapping whose fields continue at the
|
|
||||||
# column where the content after the dash began.
|
|
||||||
field_indent = indent + (len(body) - len(body[1:].lstrip()))
|
|
||||||
result.append(self.parse_inline_map(field_indent, km))
|
|
||||||
else:
|
|
||||||
val = _scalar(rest)
|
|
||||||
if val is _FAIL:
|
|
||||||
raise _Bail()
|
|
||||||
result.append(val)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def parse_inline_map(self, field_indent, first_match):
|
|
||||||
result = {}
|
|
||||||
key = first_match.group(1)
|
|
||||||
inline = first_match.group(2)
|
|
||||||
if inline is not None and inline.strip(" ") != "":
|
|
||||||
val = _scalar(inline)
|
|
||||||
if val is _FAIL:
|
|
||||||
raise _Bail()
|
|
||||||
result[key] = val
|
|
||||||
else:
|
|
||||||
result[key] = self.parse_block_value(field_indent)
|
|
||||||
while True:
|
|
||||||
tok = self.peek()
|
|
||||||
if tok is None:
|
|
||||||
break
|
|
||||||
cur_indent, body = tok
|
|
||||||
if cur_indent != field_indent:
|
|
||||||
if cur_indent > field_indent:
|
|
||||||
raise _Bail()
|
|
||||||
break
|
|
||||||
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
|
|
||||||
raise _Bail()
|
|
||||||
m = _KEY_RE.match(body)
|
|
||||||
if not m:
|
|
||||||
raise _Bail()
|
|
||||||
k = m.group(1)
|
|
||||||
iv = m.group(2)
|
|
||||||
self.i += 1
|
|
||||||
if k in result:
|
|
||||||
raise _Bail()
|
|
||||||
if iv is not None and iv.strip(" ") != "":
|
|
||||||
v = _scalar(iv)
|
|
||||||
if v is _FAIL:
|
|
||||||
raise _Bail()
|
|
||||||
result[k] = v
|
|
||||||
else:
|
|
||||||
result[k] = self.parse_block_value(field_indent)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_parse(lines):
|
|
||||||
# Return the parsed root object (dict/list/scalar/None) when the WHOLE
|
|
||||||
# document is inside the supported subset, else _FAIL (fail closed).
|
|
||||||
try:
|
|
||||||
return _Parser(lines).parse_document()
|
|
||||||
except _Bail:
|
|
||||||
return _FAIL
|
|
||||||
except Exception:
|
|
||||||
return _FAIL
|
|
||||||
|
|
||||||
|
|
||||||
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. It parses config.yml with a
|
|
||||||
# strict recognizer (_safe_parse) of the narrow block-style subset tea writes,
|
|
||||||
# which reconstructs the SAME object PyYAML would OR fails closed (_FAIL) on
|
|
||||||
# ANYTHING it cannot prove it resolves identically -- document markers, block
|
|
||||||
# scalars, flow collections, duplicate keys, inconsistent indentation, or any
|
|
||||||
# line outside the grammar. On a recognized document it then resolves the
|
|
||||||
# login EXACTLY as the PyYAML path does (root `logins` list -> first entry
|
|
||||||
# whose `name` equals the request -> host/port-bound token), so the fallback
|
|
||||||
# can only ever be MORE conservative than PyYAML, never less.
|
|
||||||
with open(config_path, encoding="utf-8") as handle:
|
|
||||||
lines = handle.read().splitlines()
|
|
||||||
|
|
||||||
config = _safe_parse(lines)
|
|
||||||
if config is _FAIL:
|
|
||||||
return None
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
# 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.
|
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||||
get_gitea_basic_auth() {
|
get_gitea_basic_auth() {
|
||||||
|
|||||||
@@ -1,26 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
# Usage: issue-comment.sh -i <issue_number> -c <comment>
|
||||||
#
|
|
||||||
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
|
||||||
# the non-existent `tea issue comment ...` form does not error — tea silently
|
|
||||||
# no-ops and still exits 0, so a caller trusting the exit code believes a
|
|
||||||
# comment was posted when it was not (#865). tea 0.11.1 also cannot reliably
|
|
||||||
# emit the id of a record it created, so an exit code is the ONLY signal it
|
|
||||||
# offers — and that signal is untrustworthy. This script therefore does not
|
|
||||||
# write via tea at all: it POSTs the comment through the Gitea REST API (which
|
|
||||||
# returns the created comment object, including its id), then GETs that exact
|
|
||||||
# id back and fails closed unless it matches. Keying verification to the
|
|
||||||
# provider-returned created id means a concurrent comment cannot masquerade as
|
|
||||||
# this write and a no-op create simply yields no id to verify.
|
|
||||||
#
|
|
||||||
# --login override: the default login is resolved from the local `tea` login
|
|
||||||
# list for this repo's host (get_gitea_login). Pass --login <name> to override
|
|
||||||
# it for this invocation only. The REST write, the /user identity read, and the
|
|
||||||
# read-back are ALL performed with the token of the EFFECTIVE login (the
|
|
||||||
# override when given), so the write and its verification bind to the same
|
|
||||||
# identity — a --login override is never written under one credential and
|
|
||||||
# verified under a different default one.
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -30,7 +10,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
LOGIN_OVERRIDE=""
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -42,17 +21,12 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--login)
|
|
||||||
LOGIN_OVERRIDE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -i, --issue Issue number (required)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -c, --comment Comment text (required)"
|
echo " -c, --comment Comment text (required)"
|
||||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -75,273 +49,20 @@ fi
|
|||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
|
||||||
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
|
||||||
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
|
||||||
#
|
|
||||||
# The token is resolved for the EFFECTIVE login (the --login override when
|
|
||||||
# given, otherwise the detected default) so that the single credential used for
|
|
||||||
# the write ALSO drives the /user identity read and the read-back — write token
|
|
||||||
# and read-back token are the same identity by construction (this is the
|
|
||||||
# credential-ordering fix: a --login override is no longer written under one
|
|
||||||
# credential and verified under a different default one). Falls back to the
|
|
||||||
# host-scoped credential ONLY when NO --login override was supplied (the
|
|
||||||
# best-effort default path). When $2 is "explicit" the login came from a
|
|
||||||
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
|
||||||
# CLOSED rather than silently downgrading the write to the host default
|
|
||||||
# identity — otherwise a caller relying on a dedicated per-role credential would
|
|
||||||
# be told the write succeeded as requested while it was attributed to the shared
|
|
||||||
# default. Returns non-zero (clear stderr) on any resolution failure.
|
|
||||||
gitea_resolve_api_for_login() {
|
|
||||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
|
||||||
|
|
||||||
host=$(get_remote_host)
|
|
||||||
if [[ -n "$override_explicit" ]]; then
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
|
||||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
else
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
|
||||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
|
||||||
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
|
||||||
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
|
||||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
|
||||||
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
|
||||||
# The provider WEB base (scheme + host + effective port + any deployment path
|
|
||||||
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
|
|
||||||
# Read-back verification pins the returned URL's origin + path prefix to THIS,
|
|
||||||
# not just a repo/issue suffix.
|
|
||||||
GITEA_WEB_BASE="${configured_url%/}"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve the login of the identity the API token authenticates as (GET
|
|
||||||
# /user). Used to attribute a read-back record to THIS invocation's writer so
|
|
||||||
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
|
|
||||||
# Prints the login on success.
|
|
||||||
gitea_authenticated_login() {
|
|
||||||
local response_file auth_config status
|
|
||||||
|
|
||||||
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
|
|
||||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
||||||
rm -f "$response_file"
|
|
||||||
echo "Error: could not stage Gitea credential for identity read" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
|
||||||
|
|
||||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
|
||||||
--config "$auth_config" \
|
|
||||||
"$GITEA_API_ROOT/user"); then
|
|
||||||
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$status" != "200" ]]; then
|
|
||||||
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 - "$response_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
|
||||||
user = json.load(response)
|
|
||||||
login = user.get("login") if isinstance(user, dict) else None
|
|
||||||
if not isinstance(login, str) or not login:
|
|
||||||
raise ValueError("missing authenticated login")
|
|
||||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
|
||||||
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(login)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
# Post a comment to a Gitea issue via the supported REST API and verify it
|
|
||||||
# durably against a PROVIDER-RETURNED created id — never trust an exit code
|
|
||||||
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
|
|
||||||
# 0). The write is a direct POST that returns the created comment object, so we
|
|
||||||
# learn the exact id of THIS write; we then GET that exact id and require
|
|
||||||
# id == created id AND author == acting identity AND exact body AND that it
|
|
||||||
# belongs to this issue. Because verification is keyed to the id the create
|
|
||||||
# returned, a concurrent comment (even same identity, same body) CANNOT
|
|
||||||
# masquerade as this write, and a suppressed/no-op write yields no created id
|
|
||||||
# and fails closed — there is no fallback list scan that a concurrent record
|
|
||||||
# could satisfy. Prints the created comment id on success.
|
|
||||||
#
|
|
||||||
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
|
|
||||||
gitea_create_comment_verified() {
|
|
||||||
local issue_number="$1" comment_body="$2" acting_login="$3"
|
|
||||||
local payload write_file readback_file auth_config write_status readback_status created_id
|
|
||||||
|
|
||||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
|
||||||
')
|
|
||||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
|
|
||||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
|
|
||||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
||||||
rm -f "$write_file" "$readback_file"
|
|
||||||
echo "Error: could not stage Gitea credential for comment write" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
|
||||||
|
|
||||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
|
||||||
-X POST \
|
|
||||||
--config "$auth_config" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" \
|
|
||||||
"$GITEA_API_BASE/issues/$issue_number/comments"); then
|
|
||||||
echo "Error: Gitea comment write transport failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$write_status" != "201" ]]; then
|
|
||||||
echo "Error: Gitea comment write failed with HTTP $write_status (#865: no durable comment created)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
created_id=$(python3 - "$write_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
|
||||||
comment = json.load(response)
|
|
||||||
created_id = comment.get("id") if isinstance(comment, dict) else None
|
|
||||||
if not isinstance(created_id, int) or created_id <= 0:
|
|
||||||
raise ValueError("create response carried no positive comment id")
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
||||||
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(created_id)
|
|
||||||
PY
|
|
||||||
) || return 1
|
|
||||||
|
|
||||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
|
||||||
--config "$auth_config" \
|
|
||||||
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
|
||||||
echo "Error: Gitea comment read-back transport failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$readback_status" != "200" ]]; then
|
|
||||||
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
|
||||||
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
|
||||||
EXPECTED_NUMBER="$issue_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
|
|
||||||
python3 - "$readback_file" <<'PY' || return 1
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
|
||||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
|
||||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
|
||||||
# port and its explicit default form compare equal.
|
|
||||||
parsed = urlparse(url or "")
|
|
||||||
scheme = (parsed.scheme or "").lower()
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
default_port = 80 if scheme == "http" else 443
|
|
||||||
port = parsed.port if parsed.port is not None else default_port
|
|
||||||
return (scheme, host, port), parsed.path.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
|
||||||
comment = json.load(response)
|
|
||||||
if not isinstance(comment, dict):
|
|
||||||
raise ValueError("response is not a comment object")
|
|
||||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
|
||||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
|
||||||
acting_login = os.environ["ACTING_LOGIN"]
|
|
||||||
slug = os.environ["EXPECTED_REPO_SLUG"]
|
|
||||||
number = os.environ["EXPECTED_NUMBER"]
|
|
||||||
web_base = os.environ["EXPECTED_WEB_BASE"]
|
|
||||||
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
|
|
||||||
# carries issue_url = <web_base>/<owner>/<repo>/issues/<n> (pull_request_url
|
|
||||||
# empty); a comment posted to a PR's conversation carries
|
|
||||||
# pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (issue_url empty).
|
|
||||||
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
|
|
||||||
# provider + repo + kind + number — an endswith/suffix test would accept a
|
|
||||||
# look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host
|
|
||||||
# decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
|
|
||||||
base_origin, base_path = _origin_and_path(web_base)
|
|
||||||
expected_issue_path = f"{base_path}/{slug}/issues/{number}"
|
|
||||||
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
|
|
||||||
|
|
||||||
def _belongs(url, expected_path):
|
|
||||||
if not url:
|
|
||||||
return False
|
|
||||||
origin, path = _origin_and_path(url)
|
|
||||||
return origin == base_origin and path == expected_path
|
|
||||||
|
|
||||||
if comment.get("id") != expected_id:
|
|
||||||
raise ValueError("read-back id does not match the created id")
|
|
||||||
if (comment.get("user") or {}).get("login") != acting_login:
|
|
||||||
raise ValueError("created comment is not authored by the acting identity")
|
|
||||||
if comment.get("body") != expected_body:
|
|
||||||
raise ValueError("created comment body does not match")
|
|
||||||
if not (
|
|
||||||
_belongs(comment.get("issue_url"), expected_issue_path)
|
|
||||||
or _belongs(comment.get("pull_request_url"), expected_pr_path)
|
|
||||||
):
|
|
||||||
raise ValueError("created comment does not belong to this issue on this provider/repo")
|
|
||||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
|
||||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "$created_id"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# Resolve the login this comment should be attributed to: the --login
|
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
|
||||||
# override when given, otherwise the detected default for this repo's host.
|
# word-splitting) so the comment body — including Markdown backticks, $(...),
|
||||||
# A --login override always wins. Otherwise name this repo host's login only
|
# and quotes — is passed verbatim and never re-split or shell-evaluated.
|
||||||
# as a best effort: the login name merely selects a per-login token, and
|
REPO_SLUG=$(get_repo_slug)
|
||||||
# gitea_resolve_api_for_login falls back to the host credential
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
# (get_gitea_token) when no tea login is named, so the default credential
|
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
|
||||||
# still resolves even when the host tea has no matching login entry.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Bind the REST endpoint + token to the effective login, then derive the
|
|
||||||
# acting identity from that SAME credential (GET /user). The write below and
|
|
||||||
# its read-back both use this credential, so the write is verified against
|
|
||||||
# the identity that actually performed it. Passing "explicit" when --login
|
|
||||||
# was supplied forbids the host-default fallback: an unresolvable explicit
|
|
||||||
# override fails closed instead of writing under the default identity.
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
||||||
|
|
||||||
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
|
||||||
echo "Error: could not create and verify a comment on Gitea issue #$ISSUE_NUMBER via a provider-returned created id (#865)." >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
|
||||||
|
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-review.sh - Review a pull request on GitHub or Gitea
|
# pr-review.sh - Review a pull request on GitHub or Gitea
|
||||||
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]
|
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
|
||||||
#
|
|
||||||
# Gitea reviews and comments are written through the supported REST API, not
|
|
||||||
# `tea`: tea 0.11.1 cannot emit the id of a record it creates and can silently
|
|
||||||
# no-op while exiting 0 (#865 defect class), so an exit code is the only — and
|
|
||||||
# untrustworthy — signal it offers. approve/request-changes POST to
|
|
||||||
# /pulls/{n}/reviews (returns the created review with its id); the `comment`
|
|
||||||
# action POSTs to /issues/{n}/comments (returns the created comment with its
|
|
||||||
# id). Each write is then verified by GETting that exact returned id, so a
|
|
||||||
# concurrent record cannot masquerade as this write and a no-op fails closed.
|
|
||||||
#
|
|
||||||
# --login override: the default login is resolved from the local tea login list
|
|
||||||
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
|
|
||||||
# override it for this invocation only. The REST write, the /user identity read,
|
|
||||||
# and every read-back are ALL performed with the token of the EFFECTIVE login,
|
|
||||||
# so the write and its verification bind to the same identity.
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -27,7 +12,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
ACTION=""
|
ACTION=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
LOGIN_OVERRIDE=""
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -43,18 +27,13 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--login)
|
|
||||||
LOGIN_OVERRIDE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]"
|
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||||
echo " -c, --comment Review comment (required for request-changes)"
|
echo " -c, --comment Review comment (required for request-changes)"
|
||||||
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -77,42 +56,51 @@ fi
|
|||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
# Post a review comment body to a Gitea PR via the supported comments REST API
|
||||||
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
# and verify it durably via provider read-back (see docs on durable review
|
||||||
# write is a direct POST that returns the created comment object, so we learn
|
# provenance in README.md). Used by the `comment` action and, since `tea`
|
||||||
# the exact id of THIS write; we GET that exact id and require id == created id
|
# v0.11.1 defines no `--comment`/`-comment` flag on `pr approve`/`pr reject`,
|
||||||
# AND author == acting identity AND exact body AND that it belongs to this PR.
|
# also by the `approve` and `request-changes` actions to carry an optional
|
||||||
# Keying to the returned id means no concurrent comment (even same identity /
|
# review body that `tea` itself cannot attach.
|
||||||
# body) can masquerade as this write, and a no-op create yields no id and fails
|
|
||||||
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
|
|
||||||
# gitea_resolve_api_for_login). Prints the created comment id on success.
|
|
||||||
#
|
#
|
||||||
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
|
# Args: $1 = PR number, $2 = comment body
|
||||||
gitea_create_comment_verified() {
|
# On success: prints only the created comment ID to stdout, returns 0.
|
||||||
local pr_number="$1" comment_body="$2" acting_login="$3"
|
# On failure: prints an error to stderr, returns 1.
|
||||||
local payload write_file readback_file auth_config write_status readback_status created_id
|
gitea_post_verified_comment() {
|
||||||
|
local pr_number="$1" comment_body="$2"
|
||||||
|
local host token configured_url repo api_base payload
|
||||||
|
local write_response_file readback_response_file comment_id
|
||||||
|
|
||||||
|
host=$(get_remote_host)
|
||||||
|
token=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: Gitea token not found for comment persistence" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||||
|
echo "Error: Configured Gitea URL not found for comment persistence" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||||
|
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
api_base="${configured_url%/}/api/v1/repos/$repo"
|
||||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||||
')
|
')
|
||||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
write_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
||||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-readback.XXXXXX")
|
||||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
trap 'rm -f "$write_response_file" "$readback_response_file"' RETURN
|
||||||
rm -f "$write_file" "$readback_file"
|
|
||||||
echo "Error: could not stage Gitea credential for comment write" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
|
||||||
|
|
||||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
if ! write_status=$(curl -sS -o "$write_response_file" -w '%{http_code}' \
|
||||||
-X POST \
|
-X POST \
|
||||||
--config "$auth_config" \
|
-H "Authorization: token $token" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$payload" \
|
-d "$payload" \
|
||||||
"$GITEA_API_BASE/issues/$pr_number/comments"); then
|
"$api_base/issues/$pr_number/comments"); then
|
||||||
echo "Error: Gitea comment write transport failed" >&2
|
echo "Error: Gitea comment write transport failed" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -121,26 +109,26 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
created_id=$(python3 - "$write_file" <<'PY'
|
comment_id=$(python3 - "$write_response_file" <<'PY'
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
comment = json.load(response)
|
comment = json.load(response)
|
||||||
created_id = comment.get("id") if isinstance(comment, dict) else None
|
comment_id = comment.get("id") if isinstance(comment, dict) else None
|
||||||
if not isinstance(created_id, int) or created_id <= 0:
|
if not isinstance(comment_id, int) or comment_id <= 0:
|
||||||
raise ValueError("create response carried no positive comment id")
|
raise ValueError("missing positive comment id")
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||||
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
print(created_id)
|
print(comment_id)
|
||||||
PY
|
PY
|
||||||
) || return 1
|
) || return 1
|
||||||
|
|
||||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
|
||||||
--config "$auth_config" \
|
-H "Authorization: token $token" \
|
||||||
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
"$api_base/issues/comments/$comment_id"); then
|
||||||
echo "Error: Gitea comment read-back transport failed" >&2
|
echo "Error: Gitea comment read-back transport failed" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -149,28 +137,13 @@ PY
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
if EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$comment_body" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$pr_number" \
|
||||||
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
python3 - "$readback_response_file" <<'PY'
|
||||||
EXPECTED_NUMBER="$pr_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
|
|
||||||
python3 - "$readback_file" <<'PY' || return 1
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
|
||||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
|
||||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
|
||||||
# port and its explicit default form compare equal.
|
|
||||||
parsed = urlparse(url or "")
|
|
||||||
scheme = (parsed.scheme or "").lower()
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
default_port = 80 if scheme == "http" else 443
|
|
||||||
port = parsed.port if parsed.port is not None else default_port
|
|
||||||
return (scheme, host, port), parsed.path.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
comment = json.load(response)
|
comment = json.load(response)
|
||||||
@@ -178,344 +151,27 @@ try:
|
|||||||
raise ValueError("response is not a comment object")
|
raise ValueError("response is not a comment object")
|
||||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||||
acting_login = os.environ["ACTING_LOGIN"]
|
expected_repo = os.environ["EXPECTED_REPO"]
|
||||||
slug = os.environ["EXPECTED_REPO_SLUG"]
|
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
|
||||||
number = os.environ["EXPECTED_NUMBER"]
|
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||||
web_base = os.environ["EXPECTED_WEB_BASE"]
|
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
|
||||||
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
|
|
||||||
# comment carries pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (with
|
|
||||||
# issue_url empty), while a plain issue comment carries
|
|
||||||
# issue_url = <web_base>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
|
|
||||||
# This is the pr-review `comment` action, so the comment MUST land on a pull
|
|
||||||
# request: require pull_request_url. A plain issue_url is REJECTED — if issue
|
|
||||||
# #N exists but PR #N does not, POST /issues/N/comments creates an issue
|
|
||||||
# comment, and accepting that issue_url would let the wrapper falsely report a
|
|
||||||
# verified PR comment (issue-comment.sh legitimately keeps the broader
|
|
||||||
# issue-or-PR acceptance; a PR review does not).
|
|
||||||
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
|
|
||||||
# provider + repo + kind + number — an endswith/suffix test would accept a
|
|
||||||
# look-alike host (evil.example/deceptive/<slug>/pulls/N) or a same-host
|
|
||||||
# decoy prefix (/other/<slug>/pulls/N), so compare the whole thing.
|
|
||||||
base_origin, base_path = _origin_and_path(web_base)
|
|
||||||
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
|
|
||||||
|
|
||||||
def _belongs(url, expected_path):
|
|
||||||
if not url:
|
|
||||||
return False
|
|
||||||
origin, path = _origin_and_path(url)
|
|
||||||
return origin == base_origin and path == expected_path
|
|
||||||
|
|
||||||
if comment.get("id") != expected_id:
|
if comment.get("id") != expected_id:
|
||||||
raise ValueError("read-back id does not match the created id")
|
raise ValueError("comment id mismatch")
|
||||||
if (comment.get("user") or {}).get("login") != acting_login:
|
|
||||||
raise ValueError("created comment is not authored by the acting identity")
|
|
||||||
if comment.get("body") != expected_body:
|
if comment.get("body") != expected_body:
|
||||||
raise ValueError("created comment body does not match")
|
raise ValueError("comment body mismatch")
|
||||||
if not _belongs(comment.get("pull_request_url"), expected_pr_path):
|
if not issue_path.endswith(expected_suffix):
|
||||||
raise ValueError("claimed PR comment did not land on a pull request (kind=pulls) on this provider/repo")
|
raise ValueError("repository or PR mismatch")
|
||||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
PY
|
PY
|
||||||
|
then
|
||||||
echo "$created_id"
|
true
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
|
||||||
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
|
||||||
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
|
||||||
#
|
|
||||||
# The token is resolved for the EFFECTIVE login (the --login override when
|
|
||||||
# given, otherwise the detected default), so the one credential used to submit
|
|
||||||
# the review/comment ALSO drives the /user identity read and every read-back —
|
|
||||||
# write token and read-back token are the same identity by construction. This
|
|
||||||
# is the credential-ordering fix: a --login override is no longer submitted
|
|
||||||
# under one credential and verified under a different default one. Falls back to
|
|
||||||
# the host-scoped credential ONLY when NO --login override was supplied (the
|
|
||||||
# best-effort default path). When $2 is "explicit" the login came from a
|
|
||||||
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
|
||||||
# CLOSED rather than silently downgrading the review/comment to the host default
|
|
||||||
# identity. Returns non-zero (clear stderr) on any resolution failure.
|
|
||||||
gitea_resolve_api_for_login() {
|
|
||||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
|
||||||
|
|
||||||
host=$(get_remote_host)
|
|
||||||
if [[ -n "$override_explicit" ]]; then
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
|
||||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
|
||||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
|
||||||
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
|
||||||
echo "Error: Configured Gitea URL not found for review read-back verification" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
|
||||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
|
||||||
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
|
||||||
# The provider WEB base (scheme + host + effective port + any deployment path
|
|
||||||
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
|
|
||||||
# Read-back verification pins the returned URL's origin + path prefix to THIS,
|
|
||||||
# not just a repo/PR suffix.
|
|
||||||
GITEA_WEB_BASE="${configured_url%/}"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve the login of the identity the API token authenticates as (GET
|
|
||||||
# /user). Used to attribute a read-back review to THIS action's reviewer so a
|
|
||||||
# concurrent review from a DIFFERENT identity cannot satisfy verification.
|
|
||||||
# Prints the login on success.
|
|
||||||
gitea_authenticated_login() {
|
|
||||||
local response_file auth_config status
|
|
||||||
|
|
||||||
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
|
|
||||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
||||||
rm -f "$response_file"
|
|
||||||
echo "Error: could not stage Gitea credential for identity read" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
|
||||||
|
|
||||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
|
||||||
--config "$auth_config" \
|
|
||||||
"$GITEA_API_ROOT/user"); then
|
|
||||||
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$status" != "200" ]]; then
|
|
||||||
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - "$response_file" <<'PY'
|
echo "$comment_id"
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
|
||||||
user = json.load(response)
|
|
||||||
login = user.get("login") if isinstance(user, dict) else None
|
|
||||||
if not isinstance(login, str) or not login:
|
|
||||||
raise ValueError("missing authenticated login")
|
|
||||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
|
||||||
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(login)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
# GET /pulls/{n} into a caller-owned response file and print its head commit
|
|
||||||
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
|
|
||||||
# response file, so it is safe to call from INSIDE another trapped function
|
|
||||||
# (the post-verify re-read below) without clobbering that function's cleanup
|
|
||||||
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
|
|
||||||
gitea_read_pr_head_into() {
|
|
||||||
local pr_number="$1" pr_file="$2" auth_config="$3" status
|
|
||||||
|
|
||||||
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
|
|
||||||
--config "$auth_config" \
|
|
||||||
"$GITEA_API_BASE/pulls/$pr_number"); then
|
|
||||||
echo "Error: Gitea PR head read transport failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$status" != "200" ]]; then
|
|
||||||
echo "Error: Gitea PR head read failed with HTTP $status" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
python3 - "$pr_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
|
||||||
pr = json.load(response)
|
|
||||||
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
|
|
||||||
if not isinstance(head_sha, str) or not head_sha:
|
|
||||||
raise ValueError("missing PR head sha")
|
|
||||||
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
|
|
||||||
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(head_sha)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
|
|
||||||
# submitted against — and later verified as pinned to — this exact commit, so a
|
|
||||||
# stale review left over from an earlier push cannot be mistaken for this one.
|
|
||||||
# Prints the head SHA on success.
|
|
||||||
gitea_pr_head_sha() {
|
|
||||||
local pr_number="$1" pr_file auth_config
|
|
||||||
|
|
||||||
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
|
|
||||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
||||||
rm -f "$pr_file"
|
|
||||||
echo "Error: could not stage Gitea credential for PR head read" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
trap 'rm -f "$pr_file" "$auth_config"' RETURN
|
|
||||||
|
|
||||||
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Submit a review to a Gitea PR via the supported REST API and verify it against
|
|
||||||
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
|
|
||||||
# the id of the review it created and can silently no-op while exiting 0 (#865
|
|
||||||
# defect class), so this does NOT shell out to tea: it POSTs to
|
|
||||||
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
|
|
||||||
# commit_id, and the review body, which returns the created review object
|
|
||||||
# including its id. It then GETs that exact review id and requires
|
|
||||||
# id == created id AND author == acting identity AND state == expected AND
|
|
||||||
# commit_id == PR head. Keying to the returned id means no concurrent review
|
|
||||||
# (even same identity/state/head) can masquerade as this one, and a no-op
|
|
||||||
# submit yields no id and fails closed. Prints the created review id on success.
|
|
||||||
#
|
|
||||||
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
|
|
||||||
# $3 = review body (may be empty for APPROVED), $4 = acting login,
|
|
||||||
# $5 = PR head sha.
|
|
||||||
gitea_submit_review_verified() {
|
|
||||||
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
|
|
||||||
local payload write_file readback_file recheck_file auth_config
|
|
||||||
local write_status readback_status created_id live_head
|
|
||||||
|
|
||||||
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
print(json.dumps({
|
|
||||||
"event": os.environ["REVIEW_EVENT"],
|
|
||||||
"body": os.environ["REVIEW_BODY"],
|
|
||||||
"commit_id": os.environ["REVIEW_COMMIT"],
|
|
||||||
}))
|
|
||||||
')
|
|
||||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
|
|
||||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
|
||||||
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
|
|
||||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
||||||
rm -f "$write_file" "$readback_file" "$recheck_file"
|
|
||||||
echo "Error: could not stage Gitea credential for review submit" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
|
|
||||||
|
|
||||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
|
||||||
-X POST \
|
|
||||||
--config "$auth_config" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" \
|
|
||||||
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
|
|
||||||
echo "Error: Gitea review submit transport failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# Gitea returns 200 (occasionally 201) with the created review object.
|
|
||||||
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
|
||||||
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review created)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
created_id=$(python3 - "$write_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
|
||||||
review = json.load(response)
|
|
||||||
created_id = review.get("id") if isinstance(review, dict) else None
|
|
||||||
if not isinstance(created_id, int) or created_id <= 0:
|
|
||||||
raise ValueError("submit response carried no positive review id")
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
||||||
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(created_id)
|
|
||||||
PY
|
|
||||||
) || return 1
|
|
||||||
|
|
||||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
|
||||||
--config "$auth_config" \
|
|
||||||
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
|
|
||||||
echo "Error: Gitea review read-back transport failed" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$readback_status" != "200" ]]; then
|
|
||||||
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
|
|
||||||
EXPECTED_HEAD_SHA="$head_sha" EXPECTED_REVIEW_BODY="$review_body" \
|
|
||||||
python3 - "$readback_file" <<'PY' || return 1
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
|
||||||
review = json.load(response)
|
|
||||||
if not isinstance(review, dict):
|
|
||||||
raise ValueError("response is not a review object")
|
|
||||||
expected_id = int(os.environ["EXPECTED_REVIEW_ID"])
|
|
||||||
expected_state = os.environ["EXPECTED_STATE"]
|
|
||||||
acting_login = os.environ["ACTING_LOGIN"]
|
|
||||||
expected_head = os.environ["EXPECTED_HEAD_SHA"]
|
|
||||||
expected_body = os.environ["EXPECTED_REVIEW_BODY"]
|
|
||||||
if review.get("id") != expected_id:
|
|
||||||
raise ValueError("read-back id does not match the created id")
|
|
||||||
if (review.get("user") or {}).get("login") != acting_login:
|
|
||||||
raise ValueError("created review is not authored by the acting identity")
|
|
||||||
if review.get("state") != expected_state:
|
|
||||||
raise ValueError("created review is not in the expected state")
|
|
||||||
if review.get("commit_id") != expected_head:
|
|
||||||
raise ValueError("created review is not pinned to the PR head commit")
|
|
||||||
# Bind to the exact submitted body. On Gitea v1.25.4 SubmitReview may
|
|
||||||
# finalize/reuse a pending review id whose Content was authored elsewhere;
|
|
||||||
# the exact GET exposes the persisted body, so a mismatch (a reused/foreign
|
|
||||||
# review carrying different Content) fails closed even when id/author/state/
|
|
||||||
# head all line up. Require presence + string TYPE + exact equality rather
|
|
||||||
# than `(body or "")`: the old coalesce treated a missing/null persisted body
|
|
||||||
# as equal to an empty submitted one, so a non-empty submitted body that
|
|
||||||
# persisted as null (a suppressed/lost body) would have passed. When a
|
|
||||||
# non-empty body was submitted the persisted value MUST be that exact string;
|
|
||||||
# when an empty body was submitted the persisted value must be empty or
|
|
||||||
# absent (a non-empty persisted body is likewise a divergence — vice-versa).
|
|
||||||
persisted_body = review.get("body")
|
|
||||||
if expected_body == "":
|
|
||||||
if persisted_body not in (None, ""):
|
|
||||||
raise ValueError("created review carries a body but none was submitted")
|
|
||||||
elif not isinstance(persisted_body, str) or persisted_body != expected_body:
|
|
||||||
raise ValueError("created review body does not match the submitted body")
|
|
||||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
|
||||||
print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
|
|
||||||
# Current-head TOCTOU close-out: the review verified above is pinned to
|
|
||||||
# head_sha, but that head was read BEFORE the submit. Between then and now
|
|
||||||
# the PR branch may have advanced (a force-push or a new commit), which would
|
|
||||||
# leave this verified review attached to a now-superseded commit while the
|
|
||||||
# live tip carries unreviewed code — yet the wrapper would still report
|
|
||||||
# success. Re-read the LIVE PR head and require it STILL equals the submitted
|
|
||||||
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
|
|
||||||
# success line). This reuses the submit-scoped auth config + recheck file so
|
|
||||||
# it neither leaks the token to argv nor clobbers this function's cleanup.
|
|
||||||
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
|
|
||||||
echo "Error: could not re-read Gitea PR head after review verification" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if [[ "$live_head" != "$head_sha" ]]; then
|
|
||||||
echo "Error: Gitea PR head advanced from $head_sha to $live_head between review submit and verification; refusing to report a review pinned to a superseded commit (#865 current-head TOCTOU)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$created_id"
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,76 +205,40 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
|
repo=$(get_repo_slug)
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
# A --login override always wins. Otherwise name this host's login
|
login=$(get_gitea_login_for_host "$host")
|
||||||
# only as a best effort: the login name merely selects a per-login
|
# tea v0.11.1 defines no --comment/-comment flag on `pr approve`;
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
# route any review body via the durable comment API instead (#835).
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
tea pr approve "$PR_NUMBER" --repo "$repo" --login "$login"
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
echo "Approved Gitea PR #$PR_NUMBER"
|
||||||
# the default credential to resolve. The single resolved token is
|
if [[ -n "$COMMENT" ]]; then
|
||||||
# then used for the write, the /user identity, and the read-back.
|
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
fi
|
||||||
# Bind the REST endpoint + token to the effective login, then derive
|
|
||||||
# the acting identity from that SAME credential so the review submit
|
|
||||||
# and its read-back verify against the identity that performed them.
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
|
||||||
# The review body (if any) travels with the review itself in the REST
|
|
||||||
# submit — the created review record carries it — so there is no
|
|
||||||
# separate detached comment to reconcile.
|
|
||||||
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
|
||||||
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
|
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
repo=$(get_repo_slug)
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
# A --login override always wins. Otherwise name this host's login
|
login=$(get_gitea_login_for_host "$host")
|
||||||
# only as a best effort: the login name merely selects a per-login
|
# tea v0.11.1 defines no --comment/-comment flag on `pr reject`;
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
# route the review body via the durable comment API instead (#835).
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
tea pr reject "$PR_NUMBER" --repo "$repo" --login "$login"
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||||
# the default credential to resolve. The single resolved token is
|
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||||
# then used for the write, the /user identity, and the read-back.
|
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
|
||||||
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
|
||||||
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
|
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
host=$(get_remote_host)
|
|
||||||
# A --login override always wins. Otherwise name this host's login
|
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||||
# only as a best effort: the login name merely selects a per-login
|
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
|
||||||
# the default credential to resolve. The single resolved token is
|
|
||||||
# then used for the write, the /user identity, and the read-back.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
||||||
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
|
||||||
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -312,468 +312,4 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
|
|||||||
fi
|
fi
|
||||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
|
|
||||||
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
|
|
||||||
# bind the credential to the repo host's scheme + host + EFFECTIVE PORT — not the
|
|
||||||
# hostname alone. These fixtures probe the ImportError-dispatched line-parser
|
|
||||||
# fallback under FORCED PyYAML absence with adversarial YAML shapes, asserting it
|
|
||||||
# NEVER misattributes a token from a nested sub-map or a mis-indented line, strips
|
|
||||||
# inline comments like PyYAML, fails closed where PyYAML errors, and rejects a
|
|
||||||
# port mismatch while accepting an exact / default-port match. When PyYAML is
|
|
||||||
# available the same fixtures also assert the PyYAML path agrees (equivalence).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FIXTURE_XDG="$WORK_DIR/tokenfix"
|
|
||||||
NOYAML_DIR="$WORK_DIR/noyaml"
|
|
||||||
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
|
|
||||||
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
|
|
||||||
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
|
|
||||||
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
|
|
||||||
# Confirm the shim really does force ImportError, so the fallback is exercised.
|
|
||||||
if python3 -c 'import yaml' >/dev/null 2>&1; then
|
|
||||||
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
|
|
||||||
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
|
||||||
|
|
||||||
# Resolve a token via the FORCED-fallback path (PyYAML shimmed to ImportError).
|
|
||||||
token_fallback() {
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
|
||||||
get_gitea_token_for_login "$1" "$2"
|
|
||||||
' _ "$1" "$2"
|
|
||||||
) 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve a token via the normal path (uses PyYAML when installed).
|
|
||||||
token_pyyaml() {
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
|
||||||
get_gitea_token_for_login "$1" "$2"
|
|
||||||
' _ "$1" "$2"
|
|
||||||
) 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_token() {
|
|
||||||
local desc="$1" expected="$2" login="$3" host="$4" got
|
|
||||||
got=$(token_fallback "$login" "$host")
|
|
||||||
if [[ "$got" != "$expected" ]]; then
|
|
||||||
echo "FAIL fallback [$desc]: expected [$expected] got [$got]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$HAVE_PYYAML" == true ]]; then
|
|
||||||
got=$(token_pyyaml "$login" "$host")
|
|
||||||
if [[ "$got" != "$expected" ]]; then
|
|
||||||
echo "FAIL pyyaml [$desc]: expected [$expected] got [$got]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Plain, well-formed entry resolves its token.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
'
|
|
||||||
assert_token "plain scalar" "TOK_PLAIN" primary git.example
|
|
||||||
|
|
||||||
# 2. A token nested inside a deeper SUB-MAP must NOT attach to the entry — PyYAML
|
|
||||||
# resolves the entry's own token to None here, so the fallback must too.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
extra:
|
|
||||||
token: TOK_NESTED_ATTACKER
|
|
||||||
- name: other
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_OTHER
|
|
||||||
'
|
|
||||||
assert_token "nested sub-map token is not attributed" "" primary git.example
|
|
||||||
assert_token "sibling entry still resolves its own token" "TOK_OTHER" other git.example
|
|
||||||
|
|
||||||
# 3. A MIS-INDENTED token line (deeper than the entry's fields) must not attach;
|
|
||||||
# PyYAML errors on this shape, so both fail closed.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_MISINDENT
|
|
||||||
'
|
|
||||||
assert_token "mis-indented token fails closed" "" primary git.example
|
|
||||||
|
|
||||||
# 4. A trailing inline comment on a scalar is stripped, exactly as PyYAML does.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_INLINE # trailing note
|
|
||||||
'
|
|
||||||
assert_token "inline comment stripped" "TOK_INLINE" primary git.example
|
|
||||||
|
|
||||||
# 5. A PyYAML-fail-closed case: tab indentation. PyYAML raises a scanner error;
|
|
||||||
# the fallback resolves no token. Both fail closed identically.
|
|
||||||
write_fixture "$(printf 'logins:\n - name: primary\n url: https://git.example\n\ttoken: TOK_TAB\n')"
|
|
||||||
assert_token "tab-indent fails closed like PyYAML" "" primary git.example
|
|
||||||
|
|
||||||
# 6. Host binding is scheme + host + EFFECTIVE PORT, not hostname alone.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: ported
|
|
||||||
url: https://git.example:8443
|
|
||||||
token: TOK_PORTED
|
|
||||||
'
|
|
||||||
assert_token "explicit port exact match accepted" "TOK_PORTED" ported git.example:8443
|
|
||||||
assert_token "portless repo host rejects :8443 login" "" ported git.example
|
|
||||||
assert_token "wrong explicit port rejected" "" ported git.example:9443
|
|
||||||
|
|
||||||
# 7. An implicit (portless) login URL equals the scheme's explicit default port.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: defported
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_DEFPORT
|
|
||||||
'
|
|
||||||
assert_token "implicit https vs explicit :443 match" "TOK_DEFPORT" defported git.example:443
|
|
||||||
assert_token "implicit https vs :8443 rejected" "" defported git.example:8443
|
|
||||||
|
|
||||||
# 8. An UNQUOTED token whose raw text PyYAML's implicit resolver types as a
|
|
||||||
# NON-string (int / null / bool / float) must fail closed: PyYAML yields a
|
|
||||||
# non-str value that _accept rejects, so the fallback must NOT surface the
|
|
||||||
# stringified scalar as a credential. Each raw form fails closed IDENTICALLY
|
|
||||||
# to PyYAML (a prior residual emitted "12345"/"null"/"true"/etc. here).
|
|
||||||
assert_nonstring_token_fails_closed() {
|
|
||||||
local desc="$1" raw="$2"
|
|
||||||
write_fixture "logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: ${raw}
|
|
||||||
"
|
|
||||||
assert_token "$desc" "" primary git.example
|
|
||||||
}
|
|
||||||
assert_nonstring_token_fails_closed "unquoted int token fails closed" "12345"
|
|
||||||
assert_nonstring_token_fails_closed "unquoted null token fails closed" "null"
|
|
||||||
assert_nonstring_token_fails_closed "unquoted tilde-null token fails closed" "~"
|
|
||||||
assert_nonstring_token_fails_closed "unquoted yes(bool) token fails closed" "yes"
|
|
||||||
assert_nonstring_token_fails_closed "unquoted true(bool) token fails closed" "true"
|
|
||||||
assert_nonstring_token_fails_closed "unquoted float token fails closed" "3.14"
|
|
||||||
|
|
||||||
# 9. A QUOTED scalar is ALWAYS a string, even when its contents look like a
|
|
||||||
# non-string implicit form. The quotes force str typing in PyYAML, so the
|
|
||||||
# fallback must accept the literal (quote-stripped) contents as the token.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: "12345"
|
|
||||||
'
|
|
||||||
assert_token "double-quoted digit token is a literal string" "12345" primary git.example
|
|
||||||
write_fixture "logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: 'abc'
|
|
||||||
"
|
|
||||||
assert_token "single-quoted token is a literal string" "abc" primary git.example
|
|
||||||
|
|
||||||
# assert_fallback_fails_closed: the forced-fallback path MUST resolve no token
|
|
||||||
# (fail closed). Used for STRUCTURAL cases where PyYAML would resolve a DIFFERENT
|
|
||||||
# token (e.g. duplicate-key last-wins) — the fallback must never surface the
|
|
||||||
# wrong/stale token, so it fails closed instead; when PyYAML is present we also
|
|
||||||
# confirm it really does resolve a (divergent) token, proving the fallback is the
|
|
||||||
# strictly-more-conservative side and the case is a genuine fail-open guard.
|
|
||||||
assert_fallback_fails_closed() {
|
|
||||||
local desc="$1" login="$2" host="$3" got
|
|
||||||
got=$(token_fallback "$login" "$host")
|
|
||||||
if [[ -n "$got" ]]; then
|
|
||||||
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$HAVE_PYYAML" == true ]]; then
|
|
||||||
got=$(token_pyyaml "$login" "$host")
|
|
||||||
if [[ -z "$got" ]]; then
|
|
||||||
echo "FAIL [$desc]: expected PyYAML to resolve a divergent token" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 10. tea's REAL on-disk shape: the `logins:` block SEQUENCE items sit at the
|
|
||||||
# SAME indentation as the key (dash at column 0), with extra scalar fields.
|
|
||||||
# The recognizer must resolve this exactly like PyYAML (regression guard so
|
|
||||||
# the stricter whole-document recognizer does not fail closed on real input).
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_REAL
|
|
||||||
default: false
|
|
||||||
ssh_host: ""
|
|
||||||
- name: other
|
|
||||||
url: https://other.example
|
|
||||||
token: TOK_REAL_OTHER
|
|
||||||
preferences:
|
|
||||||
editor: false
|
|
||||||
flags: null
|
|
||||||
'
|
|
||||||
assert_token "tea dash-at-column-0 real shape resolves" "TOK_REAL" primary git.example
|
|
||||||
assert_token "tea real shape sibling resolves own token" "TOK_REAL_OTHER" other other.example
|
|
||||||
|
|
||||||
# 11. NESTED-SHADOW: a nested `logins:` (NOT at root scope) must not be mistaken
|
|
||||||
# for the real root logins. The recognizer parses whole-document structure,
|
|
||||||
# so it selects the ROOT logins token exactly as PyYAML does — never the
|
|
||||||
# nested attacker token. (A prior line scan matched the FIRST logins at ANY
|
|
||||||
# indent and returned ATTACKER.)
|
|
||||||
write_fixture 'outer:
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: ATTACKER_NESTED
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: ROOT_TOK
|
|
||||||
'
|
|
||||||
assert_token "nested logins shadow selects ROOT token" "ROOT_TOK" primary git.example
|
|
||||||
|
|
||||||
# 12. BLOCK-SCALAR-SHADOW: text inside a YAML literal/folded block ( | or > ) is
|
|
||||||
# an OPAQUE scalar to PyYAML (so `logins` is a string, not a list) and must
|
|
||||||
# not be scanned as live logins entries. Both fail closed.
|
|
||||||
write_fixture 'logins: |
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: ATTACKER_BLOCK
|
|
||||||
'
|
|
||||||
assert_token "block-scalar logins value fails closed" "" primary git.example
|
|
||||||
# A folded/literal block scalar anywhere is outside the recognizer's subset, so
|
|
||||||
# the fallback fails closed (conservative) even though PyYAML can still resolve
|
|
||||||
# the real root token past the opaque scalar. Fail-closed is the safe side.
|
|
||||||
write_fixture 'note: >
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
token: ATTACKER_FOLDED
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: ROOT_OK
|
|
||||||
'
|
|
||||||
assert_fallback_fails_closed "folded block scalar present fails closed" primary git.example
|
|
||||||
|
|
||||||
# 13. DUPLICATE-ROOT / DUPLICATE-FIELD: a duplicated `logins:` root key (PyYAML
|
|
||||||
# last-wins) or a duplicated field within a login must fail closed rather
|
|
||||||
# than take the FIRST (stale) value. PyYAML resolves the LAST; the fallback
|
|
||||||
# refuses to guess.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: FIRST_DUP
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: LAST_DUP
|
|
||||||
'
|
|
||||||
assert_fallback_fails_closed "duplicate root logins key fails closed" primary git.example
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: FIRST_FIELD
|
|
||||||
token: SECOND_FIELD
|
|
||||||
'
|
|
||||||
assert_fallback_fails_closed "duplicate token field fails closed" primary git.example
|
|
||||||
|
|
||||||
# 14. MALFORMED-AFTER-VALID: a syntax error LATER in the file makes PyYAML reject
|
|
||||||
# the WHOLE document; the recognizer must too (not emit the earlier token).
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
broken: a: b: c
|
|
||||||
'
|
|
||||||
assert_token "malformed line after valid login fails closed" "" primary git.example
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
broken: [unclosed
|
|
||||||
'
|
|
||||||
assert_token "unclosed flow after valid login fails closed" "" primary git.example
|
|
||||||
|
|
||||||
# 15. EXTRA-DOCUMENT: a multi-document file (--- separator, or ... end marker)
|
|
||||||
# makes PyYAML safe_load reject multi-document input; the recognizer fails
|
|
||||||
# closed on ANY document marker rather than emit the first doc's token.
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
---
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: SECOND_DOC
|
|
||||||
'
|
|
||||||
assert_token "second document (--- separator) fails closed" "" primary git.example
|
|
||||||
write_fixture 'logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
...
|
|
||||||
trailing: 1
|
|
||||||
'
|
|
||||||
assert_token "end marker then more content fails closed" "" primary git.example
|
|
||||||
|
|
||||||
# 16. CONSTRUCTOR-VALIDITY / INVALID-INDICATOR: a plain scalar can match a typed
|
|
||||||
# implicit resolver (int/float/timestamp) yet be NON-constructible, or begin
|
|
||||||
# with an indicator a plain scalar may not start with. PyYAML then RAISES on
|
|
||||||
# the WHOLE document (constructor error / scanner error) and yields NO token,
|
|
||||||
# so the fallback must ALSO fail closed for the whole document -- even though
|
|
||||||
# the (unrelated) malformed key sits alongside an otherwise-valid logins
|
|
||||||
# block whose token is itself well-formed. A prior residual proved STRUCTURE
|
|
||||||
# and implicit TYPE but not constructor validity, so it ignored the malformed
|
|
||||||
# key and still emitted the valid login token (fail-open in the dangerous
|
|
||||||
# direction). assert_both_fail_closed asserts fallback == PyYAML == no token.
|
|
||||||
assert_both_fail_closed() {
|
|
||||||
local desc="$1" login="$2" host="$3" got
|
|
||||||
got=$(token_fallback "$login" "$host")
|
|
||||||
if [[ -n "$got" ]]; then
|
|
||||||
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$HAVE_PYYAML" == true ]]; then
|
|
||||||
got=$(token_pyyaml "$login" "$host")
|
|
||||||
if [[ -n "$got" ]]; then
|
|
||||||
echo "FAIL pyyaml [$desc]: expected PyYAML to also fail closed (raise/no token), got a token" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# write_bad_key_fixture: an unrelated root key carrying $1 as its plain scalar,
|
|
||||||
# followed by an otherwise-valid logins block whose token is well-formed.
|
|
||||||
write_bad_key_fixture() {
|
|
||||||
write_fixture "bad: $1
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Non-constructible TIMESTAMP-tagged scalars: match the resolver, but the
|
|
||||||
# calendar field is out of range so PyYAML's datetime construction raises.
|
|
||||||
write_bad_key_fixture '2023-99-99' # month 99 / day 99 invalid
|
|
||||||
assert_both_fail_closed "bad-date 2023-99-99 fails closed like PyYAML" primary git.example
|
|
||||||
write_bad_key_fixture '2023-13-01' # month 13 invalid
|
|
||||||
assert_both_fail_closed "bad-month 2023-13-01 fails closed like PyYAML" primary git.example
|
|
||||||
write_bad_key_fixture '2023-01-15T25:00:00' # hour 25 invalid
|
|
||||||
assert_both_fail_closed "bad-hour timestamp fails closed like PyYAML" primary git.example
|
|
||||||
|
|
||||||
# Non-constructible INT-tagged scalars: match the int resolver, but the radix
|
|
||||||
# body is empty after underscore removal so int(base) raises.
|
|
||||||
write_bad_key_fixture '0b_'
|
|
||||||
assert_both_fail_closed "empty-binary 0b_ fails closed like PyYAML" primary git.example
|
|
||||||
write_bad_key_fixture '0x_'
|
|
||||||
assert_both_fail_closed "empty-hex 0x_ fails closed like PyYAML" primary git.example
|
|
||||||
write_bad_key_fixture '0x__'
|
|
||||||
assert_both_fail_closed "empty-hex 0x__ (multi-underscore) fails closed" primary git.example
|
|
||||||
|
|
||||||
# Invalid plain-scalar INDICATOR forms: a plain scalar may not begin with '%'
|
|
||||||
# (directive) or ',' (flow) -- PyYAML raises a scanner/parser error on the whole
|
|
||||||
# document, so the fallback fails closed on the leading indicator.
|
|
||||||
write_bad_key_fixture '%broken'
|
|
||||||
assert_both_fail_closed "leading-%% directive indicator fails closed" primary git.example
|
|
||||||
write_bad_key_fixture ',bad'
|
|
||||||
assert_both_fail_closed "leading-comma flow indicator fails closed" primary git.example
|
|
||||||
# Bare block indicators in a value position ('-'/'- ', '?'/'? ', ':'/': '):
|
|
||||||
# PyYAML raises a scanner error on the whole document, so the fallback must fail
|
|
||||||
# closed rather than accept the indicator as a plain-scalar string.
|
|
||||||
write_bad_key_fixture '-'
|
|
||||||
assert_both_fail_closed "bare dash (seq indicator) fails closed" primary git.example
|
|
||||||
write_bad_key_fixture '- x'
|
|
||||||
assert_both_fail_closed "dash-space (seq entry) fails closed" primary git.example
|
|
||||||
write_bad_key_fixture '? key'
|
|
||||||
assert_both_fail_closed "question-space (complex key) fails closed" primary git.example
|
|
||||||
# ...but an indicator NOT followed by whitespace is a valid plain scalar string,
|
|
||||||
# so the token still resolves (no over-broad fail-close).
|
|
||||||
write_bad_key_fixture '-x'
|
|
||||||
assert_token "dash-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
|
|
||||||
write_bad_key_fixture ':x'
|
|
||||||
assert_token "colon-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
|
|
||||||
|
|
||||||
# NOT over-broad: a genuinely CONSTRUCTIBLE typed scalar (or a look-alike PyYAML
|
|
||||||
# keeps as a plain string) leaves the document valid, so BOTH still resolve the
|
|
||||||
# login token -- the fix must not fail closed on these.
|
|
||||||
write_bad_key_fixture '2023-01-15'
|
|
||||||
assert_token "valid date unrelated key still resolves token" "TOK_PLAIN" primary git.example
|
|
||||||
write_bad_key_fixture '2023-01-15 10:00:00'
|
|
||||||
assert_token "valid datetime unrelated key still resolves token" "TOK_PLAIN" primary git.example
|
|
||||||
# '0o_' is NOT matched by PyYAML's int resolver (YAML 1.1 octal is 0[0-7]+, not
|
|
||||||
# 0o...), so PyYAML keeps it a STRING and resolves the token; the fallback must
|
|
||||||
# agree (no spurious fail-close).
|
|
||||||
write_bad_key_fixture '0o_'
|
|
||||||
assert_token "0o_ is a plain string in PyYAML, token still resolves" "TOK_PLAIN" primary git.example
|
|
||||||
# '4.e8' matches the fallback's (superset) float pattern but PyYAML keeps it a
|
|
||||||
# string; either way it is constructible, so the token still resolves in both.
|
|
||||||
write_bad_key_fixture '4.e8'
|
|
||||||
assert_token "4.e8 float look-alike still resolves token" "TOK_PLAIN" primary git.example
|
|
||||||
# A valid radix int as an unrelated key must not fail closed.
|
|
||||||
write_bad_key_fixture '0x1f'
|
|
||||||
assert_token "valid hex int unrelated key still resolves token" "TOK_PLAIN" primary git.example
|
|
||||||
|
|
||||||
# 17. TAB / SCANNER PARITY: PyYAML raises a ScannerError on a tab used anywhere
|
|
||||||
# outside a quoted scalar -- leading, trailing, or embedded in a plain value,
|
|
||||||
# immediately after a key colon, before a key colon, or as indentation -- and
|
|
||||||
# yields NO token, accepting tabs ONLY inside single/double-quoted scalars
|
|
||||||
# (where the tab is preserved as string content). A prior fallback swallowed
|
|
||||||
# those tabs (via .strip()/.rstrip() normalization and [ \t] key separators)
|
|
||||||
# and still emitted the login token -- a fail-open in the dangerous direction.
|
|
||||||
# The recognizer now fails CLOSED for the whole document on any tab PyYAML
|
|
||||||
# rejects, while preserving the tabs PyYAML keeps (inside quotes). All tab
|
|
||||||
# positions were verified empirically against PyYAML 6.0.3 (ScannerError for
|
|
||||||
# each rejected position; string-preserved for quoted inner tabs).
|
|
||||||
TAB=$'\t'
|
|
||||||
# Fail-close: a tab in a plain value position (trailing / leading / embedded).
|
|
||||||
write_bad_key_fixture "l4o${TAB}"
|
|
||||||
assert_both_fail_closed "trailing tab in plain value fails closed" primary git.example
|
|
||||||
write_bad_key_fixture "${TAB}9"
|
|
||||||
assert_both_fail_closed "leading tab in plain value fails closed" primary git.example
|
|
||||||
write_bad_key_fixture "a${TAB}b"
|
|
||||||
assert_both_fail_closed "embedded tab in plain value fails closed" primary git.example
|
|
||||||
# Fail-close: a tab immediately after the key colon (no separating space).
|
|
||||||
write_fixture "bad:${TAB}9
|
|
||||||
logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
"
|
|
||||||
assert_both_fail_closed "tab immediately after key colon fails closed" primary git.example
|
|
||||||
# Fail-close: a tab used as indentation (before a sequence dash).
|
|
||||||
write_fixture "logins:
|
|
||||||
${TAB}- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN
|
|
||||||
"
|
|
||||||
assert_both_fail_closed "tab used as indentation fails closed" primary git.example
|
|
||||||
# Fail-close: a tab trailing a sequence-mapping field value.
|
|
||||||
write_fixture "logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: TOK_PLAIN${TAB}
|
|
||||||
"
|
|
||||||
assert_both_fail_closed "tab trailing a seq field value fails closed" primary git.example
|
|
||||||
# NOT over-broad: a tab strictly INSIDE a quoted scalar is valid YAML (PyYAML
|
|
||||||
# keeps it as string content), so the document parses and the login token still
|
|
||||||
# resolves in BOTH paths -- double-quoted and single-quoted.
|
|
||||||
write_bad_key_fixture "\"a${TAB}b\""
|
|
||||||
assert_token "tab inside a double-quoted value still resolves token" "TOK_PLAIN" primary git.example
|
|
||||||
write_bad_key_fixture "'a${TAB}b'"
|
|
||||||
assert_token "tab inside a single-quoted value still resolves token" "TOK_PLAIN" primary git.example
|
|
||||||
# ...and a quoted token value carrying an inner tab resolves to the exact string
|
|
||||||
# (tab preserved), identical to PyYAML's construction.
|
|
||||||
write_fixture "logins:
|
|
||||||
- name: primary
|
|
||||||
url: https://git.example
|
|
||||||
token: \"T${TAB}OK\"
|
|
||||||
"
|
|
||||||
assert_token "quoted token with inner tab resolves verbatim" "T${TAB}OK" primary git.example
|
|
||||||
|
|
||||||
echo "Gitea login resolution regression harness passed"
|
echo "Gitea login resolution regression harness passed"
|
||||||
|
|||||||
@@ -1,571 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for issue-comment.sh's Gitea comment write + verification
|
|
||||||
# (#865).
|
|
||||||
#
|
|
||||||
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
|
|
||||||
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
|
|
||||||
# record it created — so an exit code is worthless as proof of a durable write.
|
|
||||||
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
|
|
||||||
# the Gitea REST API (which returns the created comment object, including its
|
|
||||||
# id), then GETs THAT EXACT id back and requires it to match on id, author
|
|
||||||
# (acting identity), body, and issue. Because verification is keyed to the id
|
|
||||||
# the create returned, no concurrent comment can masquerade as this write, and a
|
|
||||||
# suppressed/no-op create yields no id and fails closed.
|
|
||||||
#
|
|
||||||
# This harness models a REAL server: the curl stub keeps persistent comment
|
|
||||||
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
|
|
||||||
# id, and the read-back GET reads that same state. There is no independently
|
|
||||||
# fabricated record for the wrapper to "find" — the only way verification
|
|
||||||
# passes is if the POST genuinely created the record the read-back retrieves.
|
|
||||||
# It proves the wrapper:
|
|
||||||
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
|
|
||||||
# 2. creates the comment via REST POST and learns the provider-returned id;
|
|
||||||
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
|
|
||||||
# 4. fails closed when the write is a no-op even though a concurrent
|
|
||||||
# SAME-IDENTITY comment with the same body already exists (the closed
|
|
||||||
# concurrency window — no fallback list scan can rescue a no-op);
|
|
||||||
# 5. fails closed when the created record is not authored by the acting
|
|
||||||
# identity;
|
|
||||||
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
|
|
||||||
# list enumeration (the stub exposes no comment-list endpoint, so any
|
|
||||||
# residual enumeration attempt would fail the run);
|
|
||||||
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
|
|
||||||
# lookup, and the read-back ALL under THAT login's token/identity — never
|
|
||||||
# the host default;
|
|
||||||
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
|
|
||||||
# success line) instead of silently downgrading to the host default
|
|
||||||
# identity — the token seam maps each bearer token to the identity it
|
|
||||||
# authenticates as, so a misattributed write is caught;
|
|
||||||
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
|
|
||||||
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
|
|
||||||
# that other host's credential cross-host;
|
|
||||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
|
||||||
# success or the failure path — nested function-scoped RETURN traps do not
|
|
||||||
# clobber each other and every scratch file is removed on all exit paths.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
|
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
XDG_DIR="$WORK_DIR/xdg"
|
|
||||||
TEA_LOG="$WORK_DIR/tea.log"
|
|
||||||
CURL_LOG="$WORK_DIR/curl.log"
|
|
||||||
# Full curl argv per invocation — proves the bearer token never rides in argv.
|
|
||||||
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
|
|
||||||
AUTH_LOG="$WORK_DIR/auth.log"
|
|
||||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
|
||||||
STATE_FILE="$WORK_DIR/comments.json"
|
|
||||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
|
||||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
|
||||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
|
||||||
git -C "$REPO_DIR" init -q
|
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|
||||||
|
|
||||||
ISSUE_NUMBER=7
|
|
||||||
REPO_SLUG="mosaicstack/stack"
|
|
||||||
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
|
|
||||||
API_ROOT="https://git.mosaicstack.dev/api/v1"
|
|
||||||
BODY='durable "note" -- marker'
|
|
||||||
ACTING_LOGIN="primary-reviewer"
|
|
||||||
FOREIGN_LOGIN="other-writer"
|
|
||||||
# A dedicated per-role --login override identity, with its own token stored in
|
|
||||||
# tea's config (exactly the author-not-equal-reviewer hardening path).
|
|
||||||
OVERRIDE_LOGIN="delegated-reviewer"
|
|
||||||
DEFAULT_TOKEN="test-only-placeholder"
|
|
||||||
OVERRIDE_TOKEN="override-token-placeholder"
|
|
||||||
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
|
|
||||||
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
|
|
||||||
# repo host: host-bound selection must fail closed on the host mismatch.
|
|
||||||
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
|
||||||
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
|
||||||
|
|
||||||
# tea config: the override login has its own token here (as tea itself stores
|
|
||||||
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
|
|
||||||
# present, so the no-override default path resolves via the host credential
|
|
||||||
# fallback while an explicit --login must resolve from this file or fail closed.
|
|
||||||
# A second login is configured for a DIFFERENT host to exercise host-bound
|
|
||||||
# rejection.
|
|
||||||
mkdir -p "$XDG_DIR/tea"
|
|
||||||
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
|
||||||
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
|
||||||
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
||||||
handle.write("logins:\n")
|
|
||||||
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
|
||||||
handle.write(" url: https://git.mosaicstack.dev\n")
|
|
||||||
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
|
||||||
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
|
|
||||||
handle.write(" url: https://git.uscllc.com\n")
|
|
||||||
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
|
|
||||||
PY
|
|
||||||
|
|
||||||
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
|
||||||
json.dump({
|
|
||||||
"gitea": {
|
|
||||||
"mosaicstack": {
|
|
||||||
"url": os.environ["CONFIGURED_GITEA_URL"],
|
|
||||||
"token": "test-only-placeholder",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, credentials)
|
|
||||||
PY
|
|
||||||
|
|
||||||
# tea stub: only ever answers the login list (used to resolve the default login
|
|
||||||
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
|
|
||||||
cat > "$BIN_DIR/tea" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
|
|
||||||
|
|
||||||
if [[ "$*" == "login list --output json" ]]; then
|
|
||||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
|
|
||||||
exit 92
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/tea"
|
|
||||||
|
|
||||||
# curl stub: a small REST server backed by persistent on-disk comment state.
|
|
||||||
# GET /user -> acting identity
|
|
||||||
# POST /issues/7/comments -> CREATE + PERSIST, return created object
|
|
||||||
# GET /issues/comments/{id} -> read the persisted record by exact id
|
|
||||||
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
|
|
||||||
# authority, so any residual list enumeration attempt hits the unexpected-request
|
|
||||||
# guard and fails the test.
|
|
||||||
cat > "$BIN_DIR/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Record the FULL argv exactly as spawned, before consumption. The bearer token
|
|
||||||
# must NOT appear here — it is delivered via a curl --config file (#865 ITEM 3a),
|
|
||||||
# so only the config file PATH may show up.
|
|
||||||
printf '%s\n' "$*" >> "$ISSUE_COMMENT_CURL_ARGV_LOG"
|
|
||||||
|
|
||||||
output_file=""
|
|
||||||
method="GET"
|
|
||||||
url=""
|
|
||||||
data=""
|
|
||||||
auth_token=""
|
|
||||||
config_file=""
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
-o) output_file="$2"; shift 2 ;;
|
|
||||||
-H)
|
|
||||||
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
|
|
||||||
shift 2 ;;
|
|
||||||
-K|--config) config_file="$2"; shift 2 ;;
|
|
||||||
-w) shift 2 ;;
|
|
||||||
-X) method="$2"; shift 2 ;;
|
|
||||||
-d|--data) data="$2"; shift 2 ;;
|
|
||||||
-s|-S|-sS) shift ;;
|
|
||||||
http://*|https://*) url="$1"; shift ;;
|
|
||||||
*) shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Resolve the bearer token from the curl --config file (its real, secure source);
|
|
||||||
# fall back to an -H header only for defense in depth. The config line is
|
|
||||||
# `header = "Authorization: token <value>"`.
|
|
||||||
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
|
|
||||||
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
|
|
||||||
if [[ "$config_hdr" == *"token "* ]]; then
|
|
||||||
auth_token="${config_hdr##*token }"
|
|
||||||
auth_token="${auth_token%\"}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
path="${url%%\?*}"
|
|
||||||
query="${url#*\?}"
|
|
||||||
[[ "$query" == "$url" ]] && query=""
|
|
||||||
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
|
|
||||||
|
|
||||||
# Map the presented bearer token to the identity it authenticates as — the same
|
|
||||||
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
|
|
||||||
# read-back must all carry the SAME token, so the acting identity recorded here
|
|
||||||
# reveals which credential actually performed the request.
|
|
||||||
acting_identity=""
|
|
||||||
case "$auth_token" in
|
|
||||||
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
|
|
||||||
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
|
|
||||||
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
|
|
||||||
esac
|
|
||||||
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
|
|
||||||
|
|
||||||
write_response() {
|
|
||||||
local status="$1" body="$2"
|
|
||||||
[[ -n "$output_file" ]] || exit 96
|
|
||||||
printf '%s' "$body" > "$output_file"
|
|
||||||
printf '%s' "$status"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
|
|
||||||
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
|
|
||||||
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
|
|
||||||
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
|
||||||
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
|
|
||||||
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
|
|
||||||
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
|
|
||||||
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
|
|
||||||
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
|
|
||||||
|
|
||||||
with open(state_path, encoding="utf-8") as handle:
|
|
||||||
comments = json.load(handle)
|
|
||||||
|
|
||||||
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
|
|
||||||
# created object) even though a concurrent same-identity comment already exists
|
|
||||||
# in state. Nothing is persisted; there is no created id to verify.
|
|
||||||
if mode == "no-op-concurrent":
|
|
||||||
print("200")
|
|
||||||
print(json.dumps({}))
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
author = foreign if mode == "author-mismatch" else acting
|
|
||||||
new_id = (max((c["id"] for c in comments), default=0)) + 1
|
|
||||||
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API path,
|
|
||||||
# and a plain issue comment leaves pull_request_url empty. The URL-injection
|
|
||||||
# modes persist a record whose id/author/body are all correct but whose
|
|
||||||
# issue_url is forged, so ONLY the origin+path verification can catch them.
|
|
||||||
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
|
||||||
if mode == "url-wrong-host":
|
|
||||||
issue_url = f"https://evil.example/{repo}/issues/7"
|
|
||||||
elif mode == "url-wrong-owner":
|
|
||||||
issue_url = "https://git.mosaicstack.dev/attacker/stack/issues/7"
|
|
||||||
elif mode == "url-wrong-repo":
|
|
||||||
issue_url = "https://git.mosaicstack.dev/mosaicstack/other/issues/7"
|
|
||||||
elif mode == "url-suffix-injection":
|
|
||||||
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
|
|
||||||
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
|
|
||||||
record = {
|
|
||||||
"id": new_id,
|
|
||||||
"body": body,
|
|
||||||
"user": {"login": author},
|
|
||||||
"issue_url": issue_url,
|
|
||||||
"pull_request_url": "",
|
|
||||||
}
|
|
||||||
comments.append(record)
|
|
||||||
with open(state_path, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(comments, handle)
|
|
||||||
print("201")
|
|
||||||
print(json.dumps(record))
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
|
||||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
|
||||||
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
|
||||||
wanted = int(os.environ["ISSUE_COMMENT_GET_ID"])
|
|
||||||
with open(state_path, encoding="utf-8") as handle:
|
|
||||||
comments = json.load(handle)
|
|
||||||
match = next((c for c in comments if c["id"] == wanted), None)
|
|
||||||
if match is None:
|
|
||||||
print("404")
|
|
||||||
print(json.dumps({"message": "not found"}))
|
|
||||||
else:
|
|
||||||
print("200")
|
|
||||||
print(json.dumps(match))
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
|
||||||
else
|
|
||||||
echo "Unexpected curl request: $method $url" >&2
|
|
||||||
exit 97
|
|
||||||
fi
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/curl"
|
|
||||||
|
|
||||||
# Seed persistent server state for a mode, then run the wrapper against it.
|
|
||||||
seed_state() {
|
|
||||||
local mode="$1"
|
|
||||||
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
|
|
||||||
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
|
|
||||||
python3 - "$STATE_FILE" <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
|
|
||||||
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
|
|
||||||
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
|
|
||||||
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
|
|
||||||
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
|
|
||||||
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
|
||||||
|
|
||||||
|
|
||||||
def comment(cid, text, author):
|
|
||||||
return {
|
|
||||||
"id": cid,
|
|
||||||
"body": text,
|
|
||||||
"user": {"login": author},
|
|
||||||
"issue_url": issue_url,
|
|
||||||
"pull_request_url": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if mode == "fresh-success":
|
|
||||||
# 50 pre-existing comments already exist; the comment this run creates
|
|
||||||
# becomes id 51, proving exact-id read-back works regardless of how many
|
|
||||||
# comments precede it (no list enumeration is involved).
|
|
||||||
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
|
|
||||||
elif mode == "no-op-concurrent":
|
|
||||||
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
|
|
||||||
# The wrapper's own write will be a no-op; it must still fail closed because
|
|
||||||
# no created id is returned — it must not scan and accept this record.
|
|
||||||
comments = [comment(55, body, acting)]
|
|
||||||
else: # author-mismatch
|
|
||||||
comments = []
|
|
||||||
|
|
||||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(comments, handle)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
run_comment() {
|
|
||||||
local mode="$1"
|
|
||||||
shift
|
|
||||||
: > "$TEA_LOG"
|
|
||||||
: > "$CURL_LOG"
|
|
||||||
: > "$CURL_ARGV_LOG"
|
|
||||||
: > "$AUTH_LOG"
|
|
||||||
: > "$OUTPUT_FILE"
|
|
||||||
seed_state "$mode"
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
PATH="$BIN_DIR:$PATH" \
|
|
||||||
TMPDIR="$TMP_SCRATCH" \
|
|
||||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
|
||||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
|
||||||
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
|
||||||
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
|
||||||
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
|
|
||||||
ISSUE_COMMENT_STATE="$STATE_FILE" \
|
|
||||||
ISSUE_COMMENT_TEST_MODE="$mode" \
|
|
||||||
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
|
|
||||||
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
|
|
||||||
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
|
|
||||||
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
|
|
||||||
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
|
||||||
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
|
||||||
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
|
||||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
|
||||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
|
||||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
|
||||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
|
||||||
) > "$OUTPUT_FILE" 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
|
|
||||||
# request bodies + metadata). Called after both success and failure paths so a
|
|
||||||
# clobbered/leaked RETURN trap is caught on every exit route.
|
|
||||||
assert_no_temp_leak() {
|
|
||||||
local context="$1" leaked
|
|
||||||
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
|
|
||||||
# bearer token and must be unlinked on every exit path.
|
|
||||||
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-issue-comment-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
|
|
||||||
if [[ -n "$leaked" ]]; then
|
|
||||||
echo "FAIL: issue-comment temp files leaked ($context):" >&2
|
|
||||||
printf '%s\n' "$leaked" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Assert the presented bearer token NEVER appeared in curl's argv (it must travel
|
|
||||||
# via a curl --config file), and that --config auth was actually used. On the
|
|
||||||
# expected path grep matches nothing, so no token value is ever printed.
|
|
||||||
assert_token_not_in_argv() {
|
|
||||||
local context="$1"
|
|
||||||
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
|
|
||||||
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
|
|
||||||
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
|
|
||||||
# provider-returned id — no list enumeration is involved.
|
|
||||||
run_comment fresh-success
|
|
||||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
|
||||||
# The write is a REST POST, never a tea comment.
|
|
||||||
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
|
||||||
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
|
||||||
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Read-back is a DIRECT GET of the exact created id.
|
|
||||||
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
|
||||||
# Acting identity resolved via GET /user.
|
|
||||||
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
|
|
||||||
# No comment-list enumeration is performed — the exact-id GET is authoritative.
|
|
||||||
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
|
|
||||||
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Default path (no --login): the host credential fallback resolves, and the
|
|
||||||
# write is performed AND self-verified under the host-default acting identity.
|
|
||||||
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
|
||||||
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
|
|
||||||
# Success path leaves no scratch temp files behind.
|
|
||||||
assert_no_temp_leak "fresh-success"
|
|
||||||
# ITEM 3a: the token drove the write/read-back chain but never appeared in curl
|
|
||||||
# argv — it was passed via a curl --config file.
|
|
||||||
assert_token_not_in_argv "fresh-success default-token"
|
|
||||||
|
|
||||||
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
|
|
||||||
# already present must FAIL CLOSED — the closed concurrency window.
|
|
||||||
if run_comment no-op-concurrent; then
|
|
||||||
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# It must NOT have fallen back to a list scan that could find the concurrent id.
|
|
||||||
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
|
|
||||||
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
|
|
||||||
if run_comment author-mismatch; then
|
|
||||||
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# Failure-after-read-back path must ALSO leave no scratch temp files behind
|
|
||||||
# (proves the RETURN traps clean up on the error-return route, not just success).
|
|
||||||
assert_no_temp_leak "author-mismatch"
|
|
||||||
|
|
||||||
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
|
|
||||||
# and the read-back must ALL be performed under THAT login's token/identity, not
|
|
||||||
# the host default. The override login has id 1 (empty seed).
|
|
||||||
run_comment override-success --login "$OVERRIDE_LOGIN"
|
|
||||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
|
|
||||||
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
|
||||||
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
|
||||||
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
|
||||||
# The host-default identity must NOT have performed ANY request in this run.
|
|
||||||
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
|
||||||
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
|
|
||||||
cat "$AUTH_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
|
|
||||||
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
|
|
||||||
# no success line, and NO write performed.
|
|
||||||
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
|
|
||||||
echo "FAIL: unresolvable --login override did not fail closed" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: unresolvable --login override reported success" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
|
||||||
echo "FAIL: unresolvable --login override still performed a write" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# And it must not have silently fallen back to the host default identity.
|
|
||||||
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
|
||||||
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Case 6: a --login override that IS present in tea config but whose URL is a
|
|
||||||
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
|
|
||||||
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
|
|
||||||
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
|
|
||||||
echo "FAIL: cross-host --login override did not fail closed" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: cross-host --login override reported success" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# The cross-host credential must not have performed ANY request against the repo
|
|
||||||
# host — no request may be attributed to the cross-host identity.
|
|
||||||
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
|
|
||||||
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
|
|
||||||
cat "$AUTH_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
|
||||||
echo "FAIL: cross-host --login override still performed a write" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# It must not have silently downgraded to the host default identity either.
|
|
||||||
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
|
||||||
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assert_no_temp_leak "cross-host"
|
|
||||||
|
|
||||||
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
|
|
||||||
# correct, but its provider-returned issue_url is forged. Verification pins the
|
|
||||||
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
|
|
||||||
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
|
|
||||||
# endswith/suffix test would wrongly accept the look-alike-host and
|
|
||||||
# prefix-injection variants.
|
|
||||||
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
|
|
||||||
if run_comment "$bad_mode"; then
|
|
||||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
|
||||||
cat "$OUTPUT_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
|
||||||
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
assert_no_temp_leak "$bad_mode"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Sanity: the exact same verification path still ACCEPTS a legitimate web-shaped
|
|
||||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
|
||||||
# is not rejecting genuine writes.
|
|
||||||
|
|
||||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -661,27 +661,13 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||||
const { socket } = await startBroker();
|
const { socket } = await startBroker();
|
||||||
const sessionId = await register(socket);
|
const sessionId = await register(socket);
|
||||||
|
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
||||||
// Establish the lease with a normal (non-racing) TTL first and prove it
|
|
||||||
// authorizes. This "still valid" check is setup, not a TTL-expiry
|
|
||||||
// assertion, so it must not share a lease with a 1-second TTL: on a
|
|
||||||
// contended push-CI host, scheduling delay alone between promote() and
|
|
||||||
// this authorize() call can consume that entire 1-second margin and
|
|
||||||
// spuriously deny it (CI#1945). Using a generous TTL here removes that
|
|
||||||
// real-time race without touching lease-gate security semantics.
|
|
||||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
|
||||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||||
|
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
decision: 'allow',
|
decision: 'allow',
|
||||||
});
|
});
|
||||||
|
|
||||||
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
|
|
||||||
// expiry demonstration below. It is never used for anything but the
|
|
||||||
// wait-then-expire assertion, so there is no setup work racing its
|
|
||||||
// 1-second window.
|
|
||||||
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
|
|
||||||
await promote(socket, sessionId, shortLived.receipt_challenge!);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -689,7 +675,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
decision: 'deny',
|
decision: 'deny',
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
|
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||||
expect(
|
expect(
|
||||||
await request(socket, {
|
await request(socket, {
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Skill: glpi-create — Open a New GLPI Ticket
|
|
||||||
|
|
||||||
> Create a new GLPI helpdesk ticket. Mutates GLPI — confirm the details before running.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- Logging a new incident or request that should live in the helpdesk queue.
|
|
||||||
|
|
||||||
## Required information
|
|
||||||
|
|
||||||
- **title** — short subject line.
|
|
||||||
- **content** — description of the issue / request.
|
|
||||||
|
|
||||||
## Optional
|
|
||||||
|
|
||||||
- **priority** — `1`=VeryLow, `2`=Low, `3`=Medium (default), `4`=High, `5`=VeryHigh, `6`=Major.
|
|
||||||
- **type** — `1`=Incident (default), `2`=Request.
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
Wraps the existing tooling:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/glpi/ticket-create.sh \
|
|
||||||
-t "<title>" \
|
|
||||||
-c "<content>" \
|
|
||||||
[-p <priority>] \
|
|
||||||
[-y <type>] \
|
|
||||||
[-f json]
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/glpi/ticket-create.sh \
|
|
||||||
-t "Paint-area camera install" \
|
|
||||||
-c "Ordered 2 cameras for Paint and stock; schedule mounting + NVR config." \
|
|
||||||
-p 3 -y 2
|
|
||||||
```
|
|
||||||
|
|
||||||
## After creating
|
|
||||||
|
|
||||||
- Note the returned **ticket ID** — you'll need it for **[[glpi-followup]]** and
|
|
||||||
**[[glpi-solve]]**.
|
|
||||||
- If it should also be tracked as brain work, add a matching task (see the `add-task` skill).
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Confirm title/content/priority with the user before creating — a ticket is outward-facing.
|
|
||||||
- Never echo GLPI tokens.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Skill: glpi-followup — Add a Followup to a GLPI Ticket
|
|
||||||
|
|
||||||
> Post a followup (comment / progress note / resolution writeup) to a GLPI ticket.
|
|
||||||
> This documents work but does **not** change the ticket status — to close a ticket
|
|
||||||
> out, follow with **[[glpi-solve]]** to set status to Solved.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- Recording progress, a decision, or a root-cause/resolution note on a ticket.
|
|
||||||
- The documentation step that usually precedes closing a ticket out (`glpi-solve`).
|
|
||||||
|
|
||||||
## Critical quirk
|
|
||||||
|
|
||||||
Use the **top-level `/ITILFollowup` endpoint**, NOT `/Ticket/<id>/ITILFollowup`. The
|
|
||||||
sub-resource path returns permission errors even with a Super-Admin profile.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
### 1. Session + creds
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Post the followup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TICKET_ID=<id>
|
|
||||||
CONTENT="<the followup text>"
|
|
||||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
|
||||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
|
||||||
-H "Session-Token: $SESSION" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(jq -n --argjson id "$TICKET_ID" --arg c "$CONTENT" \
|
|
||||||
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expect HTTP 201. Building the payload with `jq` keeps quotes/newlines in the content safe.
|
|
||||||
|
|
||||||
### 3. Long or multi-paragraph content
|
|
||||||
|
|
||||||
Write the note to a file first, then read it into the payload:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
|
||||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(jq -n --argjson id "$TICKET_ID" --rawfile c /path/to/note.md \
|
|
||||||
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Never echo the GLPI app/user/session tokens.
|
|
||||||
- A followup alone leaves the ticket open. If the work is done, run **[[glpi-solve]]** next.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Skill: glpi-list — Query GLPI Tickets
|
|
||||||
|
|
||||||
> Quick lookups of GLPI helpdesk tickets by status or recency. Read-only.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- "What tickets are open / pending?" · "Show recent tickets" · finding a ticket ID
|
|
||||||
before running **[[glpi-followup]]** or **[[glpi-solve]]**.
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
Wraps the existing tooling:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLPI=~/.config/mosaic/tools/glpi
|
|
||||||
|
|
||||||
# Most recent tickets (default 50, newest first)
|
|
||||||
"$GLPI/ticket-list.sh"
|
|
||||||
|
|
||||||
# Filter by status: new | processing | pending | solved | closed
|
|
||||||
"$GLPI/ticket-list.sh" -s pending
|
|
||||||
|
|
||||||
# JSON output (for parsing / piping to jq) and a custom limit
|
|
||||||
"$GLPI/ticket-list.sh" -s processing -f json -l 20
|
|
||||||
```
|
|
||||||
|
|
||||||
Status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.
|
|
||||||
|
|
||||||
## Details lookup for one ticket
|
|
||||||
|
|
||||||
When you have an ID and want the full record:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
|
||||||
curl -sk "${GLPI_URL}/Ticket/<id>?expand_dropdowns=true" \
|
|
||||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
|
||||||
| jq '{id, name, status, date, date_mod}'
|
|
||||||
|
|
||||||
# Followups on a ticket
|
|
||||||
curl -sk "${GLPI_URL}/Ticket/<id>/ITILFollowup" \
|
|
||||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
|
||||||
| jq '.[] | {date, content}'
|
|
||||||
```
|
|
||||||
|
|
||||||
(Reading followups via the sub-resource is fine — only _creating_ them requires the
|
|
||||||
top-level `/ITILFollowup` endpoint. See **[[glpi-followup]]**.)
|
|
||||||
|
|
||||||
## Present to user
|
|
||||||
|
|
||||||
Group by status, one line per ticket: `#<id> · <title> · <status> · <last-modified>`.
|
|
||||||
Use neutral phrasing — no "OVERDUE"/"URGENT".
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Read-only. Never echo GLPI tokens.
|
|
||||||
- To sync tickets into brain data instead, use `python tools/sync_glpi.py` (not this skill).
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Skill: glpi-solve — Close Out a GLPI Ticket
|
|
||||||
|
|
||||||
> Properly close out a completed GLPI helpdesk ticket. Completing the work is not
|
|
||||||
> enough — the ticket **status must be set to "Solved"**, which is what triggers
|
|
||||||
> GLPI's config-driven auto-close. Posting a resolution followup documents the work
|
|
||||||
> but does **not** change status, so a ticket left at Solved-less status stays open.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- Any time work on a GLPI ticket is finished and it should be closed out.
|
|
||||||
- After posting a root-cause / resolution writeup as an `/ITILFollowup`.
|
|
||||||
- During a cleanup sweep of tickets that are done in reality but still open in GLPI.
|
|
||||||
|
|
||||||
## The rule (from an operator, 2026-07-20)
|
|
||||||
|
|
||||||
**"Solved" is the correct terminal state to set — not "Closed."** GLPI is configured
|
|
||||||
to auto-close Solved tickets after its delay. If you only post a followup and never set
|
|
||||||
status, the ticket sits open (this bit us on a real incident where resolution followups
|
|
||||||
were posted but status was never advanced, leaving tickets open, which the operator had
|
|
||||||
to mark Solved by hand).
|
|
||||||
|
|
||||||
Close-out = **followup (optional but preferred) + set status to Solved.**
|
|
||||||
|
|
||||||
## GLPI status IDs
|
|
||||||
|
|
||||||
| ID | Status | |
|
|
||||||
| ----- | --------------------- | -------------------------------------------- |
|
|
||||||
| 1 | New | |
|
|
||||||
| 2 | Processing (assigned) | |
|
|
||||||
| 3 | Processing (planned) | |
|
|
||||||
| 4 | Pending / Waiting | |
|
|
||||||
| **5** | **Solved** | ← set this on close-out |
|
|
||||||
| 6 | Closed | ← happens automatically; do not set manually |
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
### 1. Get a session token
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. (Preferred) Post the resolution followup
|
|
||||||
|
|
||||||
Use the **top-level `/ITILFollowup` endpoint** — the `/Ticket/<id>/ITILFollowup`
|
|
||||||
sub-resource returns permission errors even as Super-Admin (known GLPI quirk).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TICKET_ID=<id>
|
|
||||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
|
||||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
|
||||||
-H "Session-Token: $SESSION" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"input\":{\"itemtype\":\"Ticket\",\"items_id\":${TICKET_ID},\"content\":\"<resolution summary>\"}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Set status to Solved (the step that actually closes it out)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sk -X PUT "${GLPI_URL}/Ticket/${TICKET_ID}" \
|
|
||||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
|
||||||
-H "Session-Token: $SESSION" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"input\":{\"id\":${TICKET_ID},\"status\":5}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expect HTTP 200/201. GLPI will auto-close it later per its config — leave status at 5.
|
|
||||||
|
|
||||||
### 4. Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sk "${GLPI_URL}/Ticket/${TICKET_ID}?expand_dropdowns=true" \
|
|
||||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
|
||||||
| jq '{id, name, status}'
|
|
||||||
```
|
|
||||||
|
|
||||||
`status` should read `Solved` (or `5`).
|
|
||||||
|
|
||||||
## Optional: sweep for done-but-open tickets
|
|
||||||
|
|
||||||
List tickets still open (New/Processing/Pending) to spot ones whose work is actually
|
|
||||||
finished but were never marked Solved:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/glpi/ticket-list.sh -s processing -f table
|
|
||||||
~/.config/mosaic/tools/glpi/ticket-list.sh -s pending -f table
|
|
||||||
```
|
|
||||||
|
|
||||||
Review each; for any that are genuinely resolved, run steps 2–3.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Read-only until you intend to close — confirm the ticket is actually done first.
|
|
||||||
- Never echo the GLPI app/user/session tokens.
|
|
||||||
- Set **Solved (5)**, never Closed (6) — auto-close owns that transition.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Skill: glpi-sweep — Find Done-But-Open Tickets
|
|
||||||
|
|
||||||
> Read-only sweep for tickets that are finished in reality but still sitting open in
|
|
||||||
> GLPI (never moved to Solved). Surfaces the exact miss an operator caught on 2026-07-20
|
|
||||||
> (a real incident where an affected ticket had resolution followups posted but was left
|
|
||||||
> open). For each one that's genuinely done, close it out with **[[glpi-solve]]**.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- Periodic hygiene pass (e.g. before a weekly update or month-end).
|
|
||||||
- After a burst of ticket work, to catch any you resolved-in-followup but never Solved.
|
|
||||||
|
|
||||||
## Why this exists
|
|
||||||
|
|
||||||
Posting an `/ITILFollowup` documents work but does **not** change status. Tickets only
|
|
||||||
auto-close once set to **Solved (status 5)**. Anything left at New/Processing/Pending
|
|
||||||
stays open indefinitely. This sweep finds those.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
### 1. List still-open tickets by status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GLPI=~/.config/mosaic/tools/glpi
|
|
||||||
"$GLPI/ticket-list.sh" -s new -f table
|
|
||||||
"$GLPI/ticket-list.sh" -s processing -f table
|
|
||||||
"$GLPI/ticket-list.sh" -s pending -f table
|
|
||||||
```
|
|
||||||
|
|
||||||
(GLPI status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.)
|
|
||||||
|
|
||||||
### 2. Triage
|
|
||||||
|
|
||||||
For each open ticket, judge whether the underlying work is actually finished — check
|
|
||||||
its latest followups and cross-reference brain tasks / recent work. Read-only here;
|
|
||||||
change nothing yet.
|
|
||||||
|
|
||||||
Reasonable "probably done" signals:
|
|
||||||
|
|
||||||
- A resolution/root-cause followup already posted, but status never advanced.
|
|
||||||
- The related brain task is `done`, or the fix shipped and was confirmed.
|
|
||||||
- Requester confirmed resolution but the ticket was never Solved.
|
|
||||||
|
|
||||||
### 3. Present the candidates
|
|
||||||
|
|
||||||
List them for review before touching anything — never bulk-solve blindly:
|
|
||||||
|
|
||||||
```
|
|
||||||
Open tickets that look resolved:
|
|
||||||
- #<id> "<title>" — <why it looks done> → glpi-solve?
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Close out the confirmed ones
|
|
||||||
|
|
||||||
For each ticket the user (or clear evidence) confirms is done, run **[[glpi-solve]]**
|
|
||||||
(optionally **[[glpi-followup]]** first if a closing note is warranted).
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Read-only until a ticket is confirmed done — do not auto-solve on a guess.
|
|
||||||
- Never echo GLPI tokens.
|
|
||||||
- Set **Solved (5)**, never Closed (6) — GLPI auto-close owns that transition.
|
|
||||||
Reference in New Issue
Block a user