fix(gitea): direct REST comment/review with fail-closed read-back (#865)
Closes #865 Mos (id-11) Gate-16 merge: fresh confirmatory independent APPROVE @8ac7e70f (1241-case fuzz 0 fail-open), author id2 != approver id11, clean commit-author, CI green wp1966. Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #866.
This commit is contained in:
@@ -563,6 +563,873 @@ get_gitea_token() {
|
||||
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])+)$"
|
||||
)
|
||||
# NOTE: the exponent group requires an EXPLICIT sign ([eE][-+][0-9]+), matching
|
||||
# PyYAML 6.0.3's resolver.py float regex exactly. An unsigned exponent (e.g.
|
||||
# "1.0e10", ".5e10", "4.e8") is NOT matched by PyYAML's implicit float resolver
|
||||
# -- PyYAML resolves those as plain strings -- so this pattern must not match
|
||||
# them either, or the fallback over-rejects a token PyYAML would emit verbatim.
|
||||
_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 (post exponent-sign fix) no longer
|
||||
# over-rejects unsigned-exponent spellings like "4.e8" or "1.0e10" -- those
|
||||
# match PyYAML's own implicit float resolver exactly (explicit sign only),
|
||||
# so PyYAML keeps them as strings and this function now agrees.
|
||||
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: _IMPLICIT_FLOAT now matches PyYAML's resolver
|
||||
# pattern exactly (explicit-sign exponent only), and every scalar it tags
|
||||
# is float()-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
|
||||
|
||||
|
||||
# PyYAML's Reader.check_printable scans the ENTIRE raw input stream (not just
|
||||
# scalar contents, and NOT scoped by quoting) for code points outside its
|
||||
# printable set and raises ReaderError -- a whole-document reject -- the instant
|
||||
# one is found, no matter where it sits (an unrelated field, a comment, inside or
|
||||
# outside quotes). To guarantee parity WITHOUT a hand-rolled subset (which missed
|
||||
# the C1 block and BMP noncharacters), this is PyYAML 6.0.3's EXACT Reader
|
||||
# printable definition, negated verbatim:
|
||||
# Reader.NON_PRINTABLE =
|
||||
# re.compile('[^\x09\x0A\x0D\x20-\x7E\x85\xA0--<2D>'
|
||||
# '\U00010000-\U0010FFFF]')
|
||||
# i.e. PRINTABLE = {TAB(0x09), LF(0x0A), CR(0x0D), 0x20-0x7E, NEL(0x85),
|
||||
# 0xA0-0xD7FF, 0xE000-0xFFFD, 0x10000-0x10FFFF}; EVERYTHING else (all other C0
|
||||
# controls, DEL 0x7F, the entire C1 block 0x80-0x84/0x86-0x9F, the surrogate
|
||||
# range 0xD800-0xDFFF, and the BMP noncharacters 0xFFFE/0xFFFF) is non-printable
|
||||
# -> whole-document fail closed. Verified empirically against real PyYAML 6.0.3
|
||||
# in both directions: the C1 bytes and 0xFFFE/0xFFFF reject; NEL(0x85), 0xA0, and
|
||||
# astral code points (e.g. U+1F600) are accepted and resolve the token. The prior
|
||||
# C0/DEL/tab behavior is a strict subset of this. (A surrogate code point cannot
|
||||
# occur in valid UTF-8, so it also trips the UTF-8 decode on read and fails
|
||||
# closed; it is included here for exact definitional parity.)
|
||||
_FORBIDDEN_CONTROL = re.compile(
|
||||
"[^\x09\x0a\x0d\x20-\x7e\x85\xa0--<2D>\U00010000-\U0010ffff]"
|
||||
)
|
||||
|
||||
# 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 _strip_properties(value):
|
||||
# Consume a leading YAML node non-specific tag ('!' + space) and return the
|
||||
# remaining node text for normal resolution. PyYAML's SafeLoader applies
|
||||
# this TRANSPARENT property and then resolves the following node with its
|
||||
# ordinary implicit typing, so (verified against real PyYAML 6.0.3)
|
||||
# '! x' -> 'x', '! ' -> null, exactly as the bare node would resolve.
|
||||
# Returns _FAIL for every form we do NOT reproduce identically, so the
|
||||
# caller fails closed: a tag shorthand ('!x' -> ConstructorError), an
|
||||
# explicit or verbatim tag ('!!str' / '!!int' / '!<...>' / '!foo') whose
|
||||
# type coercion we do not emulate, a tab separator (PyYAML scanner error),
|
||||
# a duplicate tag ('! ! x' -> ParserError).
|
||||
#
|
||||
# ANCHORS ('&name'): deliberately rejected in FULL, not just duplicates.
|
||||
# Real PyYAML tracks anchor NAMES in a document-scoped composer registry
|
||||
# and raises ComposerError ("found duplicate anchor") the instant the
|
||||
# SAME anchor name is declared on a SECOND node anywhere in the document
|
||||
# -- including on a totally unrelated node far from the token field. This
|
||||
# line-oriented fallback has no such document-wide registry (and building
|
||||
# one reliably, across every node shape this recognizer does not even
|
||||
# parse, risks missing a scope and re-opening the fail-open). So instead
|
||||
# of emulating the registry, every '&'-anchor property fails closed here,
|
||||
# unconditionally. This is a conservative OVER-reject relative to PyYAML:
|
||||
# a single, non-duplicated '&a x' is valid YAML that real PyYAML resolves
|
||||
# to 'x', but we refuse it too. That is intentional and safe -- fail
|
||||
# closed can only cost an emitted token PyYAML would have allowed, never
|
||||
# emit one PyYAML would reject.
|
||||
seen_tag = False
|
||||
while value[:1] in ("!", "&"):
|
||||
if value[0] == "!":
|
||||
# Non-specific tag: '!' then AT LEAST ONE SPACE. A tab is a PyYAML
|
||||
# scanner error; '!x' / '!!type' / '!<verbatim>' are typed/short tags.
|
||||
if seen_tag or len(value) < 2 or value[1] != " ":
|
||||
return _FAIL
|
||||
seen_tag = True
|
||||
value = value[1:].lstrip(" ")
|
||||
else: # anchor '&name' -- always fail closed, see comment above
|
||||
return _FAIL
|
||||
return value
|
||||
|
||||
|
||||
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 ("!", "&"):
|
||||
# Leading node property (non-specific tag '! ' / plain anchor '&name '):
|
||||
# PyYAML applies it to the FOLLOWING node and resolves THAT, so strip the
|
||||
# transparent forms and re-resolve the remainder EXACTLY (implicit typing
|
||||
# and all). _strip_properties returns _FAIL for the tag/anchor forms
|
||||
# PyYAML rejects or type-coerces, which fail closed here.
|
||||
remainder = _strip_properties(value)
|
||||
if remainder is _FAIL:
|
||||
return _FAIL
|
||||
return _scalar(remainder)
|
||||
if value[0] in _FLOW or value[0] in ("|", ">", "@", "`", '"', "'", "%", ","):
|
||||
return _FAIL # flow / block-scalar / reserved / directive / quote
|
||||
# NOTE: "?" is deliberately NOT in the blanket-reject set above. Empirically
|
||||
# verified against real PyYAML 6.0.3: "?x" (indicator immediately followed by
|
||||
# a non-space) is a plain scalar string "?x" like "-x" and ":x" are -- only
|
||||
# "?" alone or "? " (followed by space/EOL) opens a complex mapping key and
|
||||
# is illegal in a value position. Blanket-rejecting every leading "?" would
|
||||
# over-reject a token PyYAML accepts verbatim; the narrower check below
|
||||
# (mirroring "-" and ":") handles exactly the illegal bare-indicator forms.
|
||||
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
|
||||
# NO blanket internal-indicator reject here. In BLOCK context (where a tea
|
||||
# config value always sits) PyYAML treats ',[]{}' as ordinary plain-scalar
|
||||
# content -- they are flow indicators ONLY inside a flow collection -- and
|
||||
# treats '!&*|>#%@`' plus quotes as significant ONLY at the FIRST non-space
|
||||
# character of a node (a leading one starts a tag/anchor/alias/block-scalar/
|
||||
# comment/directive/reserved/quote), which the leading-char guard above
|
||||
# (value[0] ...) already rejects. Verified empirically against real PyYAML
|
||||
# 6.0.3: an INTERNAL '!' ',' '[' ']' '{' '}' '&' '*' '#'(not space-preceded)
|
||||
# ':'(not space-followed) all resolve as a plain-string token. The only
|
||||
# internal positions PyYAML treats as significant in block context are ' #'
|
||||
# (whitespace-preceded '#' -> comment; stripped by the loop above) and ': '
|
||||
# or a trailing ':' (-> mapping; rejected by the ": "/endswith(":") guard
|
||||
# below). A blanket any(ch in _FLOW ...) scan over-rejected those internal
|
||||
# indicators, dropping a token PyYAML emits verbatim; removing it restores
|
||||
# parity while the position-specific guards keep the fail-open direction shut.
|
||||
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
|
||||
|
||||
|
||||
_BREAKS = "\r\n\x85
"
|
||||
_VALUE_OPEN_RE = re.compile(r"^\s*(?:-\s+)*[A-Za-z0-9_][A-Za-z0-9_.\-]*:$")
|
||||
_SEQ_OPEN_RE = re.compile(r"^\s*(?:-\s+)*-$")
|
||||
|
||||
|
||||
def _value_open_before(prefix):
|
||||
# True when a quote appearing at the END of `prefix` (the current logical
|
||||
# line so far) begins a flow SCALAR value -- the only position where a quote
|
||||
# opens a quoted scalar whose embedded line breaks PyYAML folds instead of
|
||||
# splitting. That is: the prefix is empty/all-indent (root or block scalar
|
||||
# start), or ends with a mapping ': ' or a sequence '- ' indicator that is
|
||||
# SPACE-separated from the quote. A quote glued to the previous char (e.g.
|
||||
# 'key:"x') is NOT a value opener (PyYAML needs the space), and a quote mid
|
||||
# -content is literal -- both fail this test, so their breaks split the line
|
||||
# exactly as before (fail closed / plain-scalar behavior preserved).
|
||||
stripped = prefix.rstrip(" ")
|
||||
if stripped == "":
|
||||
return True # start of line (after any indentation) -> node value start
|
||||
if prefix == stripped:
|
||||
return False # no space before the quote -> not a value opener
|
||||
return bool(_VALUE_OPEN_RE.match(stripped) or _SEQ_OPEN_RE.match(stripped))
|
||||
|
||||
|
||||
def _fold_quoted_break_run(run):
|
||||
# Reproduce PyYAML's flow-scalar folding (scan_flow_scalar_spaces +
|
||||
# scan_flow_scalar_breaks) for the maximal ' \t' + line-break run inside a
|
||||
# quoted scalar. A run with NO break is literal whitespace (verbatim). With a
|
||||
# break: surrounding spaces/tabs are dropped; a single \n-class break
|
||||
# (\n / \r / \r\n / \x85 NEL) folds to ONE space;
(LS) /
(PS)
|
||||
# are kept verbatim; each ADDITIONAL break (a blank line) contributes its own
|
||||
# break char (\n for the \n-class). Verified against real PyYAML 6.0.3 for
|
||||
# both double- and single-quoted scalars (they fold identically here).
|
||||
def _lb(s, j):
|
||||
if s[j] == "\r" and j + 1 < len(s) and s[j + 1] == "\n":
|
||||
return "\n", 2
|
||||
if s[j] in "\r\n\x85":
|
||||
return "\n", 1
|
||||
return s[j], 1 #
/
preserved verbatim
|
||||
n = len(run)
|
||||
j = 0
|
||||
while j < n and run[j] in " \t":
|
||||
j += 1
|
||||
if j >= n:
|
||||
return run # pure whitespace, no break -> literal content
|
||||
line_break, adv = _lb(run, j)
|
||||
j += adv
|
||||
breaks = []
|
||||
while True:
|
||||
while j < n and run[j] in " \t":
|
||||
j += 1
|
||||
if j < n and run[j] in _BREAKS:
|
||||
b, adv = _lb(run, j)
|
||||
j += adv
|
||||
breaks.append(b)
|
||||
else:
|
||||
break
|
||||
out = ""
|
||||
if line_break != "\n":
|
||||
out += line_break
|
||||
elif not breaks:
|
||||
out += " "
|
||||
return out + "".join(breaks)
|
||||
|
||||
|
||||
def _split_logical_lines(raw_text):
|
||||
# Split like str.splitlines() EXCEPT a line break INSIDE a value-opening
|
||||
# quoted scalar does NOT end the line: PyYAML keeps the quoted scalar together
|
||||
# and folds the break (flow folding above), so we fold it and continue the
|
||||
# same logical line. Only the break chars that survive the _FORBIDDEN_CONTROL
|
||||
# gate reach here: \n, \r, \x85 (NEL),
(LS),
(PS). OUTSIDE
|
||||
# quotes every one ends the line (matching PyYAML's block-level line breaks
|
||||
# and str.splitlines), so a NEL/LS/PS used as the document's line-terminator
|
||||
# style, and an UNQUOTED plain scalar carrying an embedded break, split and
|
||||
# fail closed exactly as before. A quoted scalar left unterminated at EOF
|
||||
# stays on the final line and fails closed in _scalar (PyYAML errors too).
|
||||
out = []
|
||||
cur = []
|
||||
i = 0
|
||||
n = len(raw_text)
|
||||
quote = None
|
||||
while i < n:
|
||||
ch = raw_text[i]
|
||||
if quote is None:
|
||||
if ch in _BREAKS:
|
||||
out.append("".join(cur))
|
||||
cur = []
|
||||
i += 2 if (ch == "\r" and i + 1 < n and raw_text[i + 1] == "\n") else 1
|
||||
continue
|
||||
if ch in ("'", '"') and _value_open_before("".join(cur)):
|
||||
quote = ch
|
||||
cur.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
cur.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
# inside a value-opening quoted scalar
|
||||
if ch == quote:
|
||||
cur.append(ch)
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
if ch in " \t" or ch in _BREAKS:
|
||||
j = i
|
||||
has_break = False
|
||||
while j < n and (raw_text[j] in " \t" or raw_text[j] in _BREAKS):
|
||||
if raw_text[j] in _BREAKS:
|
||||
has_break = True
|
||||
j += 1
|
||||
run = raw_text[i:j]
|
||||
if has_break:
|
||||
# A continuation line beginning (at column 0, no indent) with a
|
||||
# document marker is a PyYAML scanner error even inside a quoted
|
||||
# scalar; when the run ends on a break the continuation content at
|
||||
# j is at column 0, so guard it and fail closed. (An INDENTED
|
||||
# '---' is literal content to PyYAML and folds normally.)
|
||||
if run[-1] in _BREAKS and raw_text[j:j + 3] in ("---", "...") and (
|
||||
j + 3 >= n or raw_text[j + 3] in "\0 \t" + _BREAKS
|
||||
):
|
||||
raise _Bail()
|
||||
cur.append(_fold_quoted_break_run(run))
|
||||
else:
|
||||
cur.append(run)
|
||||
i = j
|
||||
continue
|
||||
cur.append(ch)
|
||||
i += 1
|
||||
if cur or not out:
|
||||
out.append("".join(cur))
|
||||
return out
|
||||
|
||||
|
||||
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:
|
||||
raw_text = handle.read()
|
||||
|
||||
# Whole-document, position-independent reject: PyYAML's Reader rejects the
|
||||
# ENTIRE document (ReaderError) if a NON-printable code point (per its exact
|
||||
# printable definition -- see _FORBIDDEN_CONTROL: C0 controls, DEL, the C1
|
||||
# block, surrogates, and 0xFFFE/0xFFFF) appears ANYWHERE in the raw stream --
|
||||
# in a plain scalar, inside single- or double-quoted scalars, in a comment,
|
||||
# or in a field this recognizer never even looks at. Checking the raw text
|
||||
# (before line splitting, which would otherwise split on some of these same
|
||||
# code points -- e.g. \x0b, \x0c, \x1c-\x1e -- and obscure them) mirrors that
|
||||
# exactly: fail closed for the whole document. (NEL 0x85, LS 0x2028 and PS
|
||||
# 0x2029 are PRINTABLE to PyYAML's Reader, so they are NOT in
|
||||
# _FORBIDDEN_CONTROL; _split_logical_lines below reproduces PyYAML's line-break
|
||||
# semantics for them -- a structural break outside quotes, a FOLDED break
|
||||
# inside a quoted scalar -- rather than str.splitlines(), which would wrongly
|
||||
# split them mid-quote and fail-close a token PyYAML resolves.)
|
||||
if _FORBIDDEN_CONTROL.search(raw_text):
|
||||
return None
|
||||
|
||||
lines = _split_logical_lines(raw_text)
|
||||
|
||||
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.
|
||||
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||
get_gitea_basic_auth() {
|
||||
|
||||
Reference in New Issue
Block a user