fix(tools): use top-level tea comment invocation and formalize --login passthrough (#865) #866
@@ -612,6 +612,7 @@ get_gitea_token_for_login() {
|
||||
[[ -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
|
||||
@@ -671,6 +672,138 @@ def _implicit_nonstring(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()
|
||||
@@ -724,14 +857,31 @@ def _scalar(raw):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if value[0] in _FLOW or value[0] in ("|", ">", "?", "@", "`", '"', "'"):
|
||||
return _FAIL # flow / block-scalar / reserved / anchor / quote indicator
|
||||
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 ": " in value or value.endswith(":") or "\t" in value:
|
||||
return _FAIL # nested-mapping-in-scalar / ambiguous
|
||||
if _implicit_nonstring(value):
|
||||
return None # non-string implicit type -> null-equivalent for a token
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -621,4 +621,102 @@ 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
|
||||
|
||||
echo "Gitea login resolution regression harness passed"
|
||||
|
||||
Reference in New Issue
Block a user