mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(pre-push-safety-net): replay immutable revisions through the full transaction
This commit is contained in:
@@ -110,6 +110,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="ousast")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
replay_parser = subparsers.add_parser("pre-push", help="replay explicit local base/head revisions (experimental)")
|
||||
replay_parser.add_argument("path", type=Path)
|
||||
replay_parser.add_argument("--base", required=True)
|
||||
replay_parser.add_argument("--head", required=True)
|
||||
replay_parser.add_argument("--artifact", type=Path, required=True)
|
||||
replay_parser.add_argument("--deadline", type=float, default=30.0)
|
||||
replay_parser.add_argument("--cancellation-allowance", type=float, default=2.0)
|
||||
replay_parser.add_argument("--max-regions", type=int, default=500)
|
||||
replay_parser.add_argument("--mode", choices=("advisory", "blocking"), default="advisory")
|
||||
replay_parser.add_argument("--incomplete-coverage", choices=("allow", "block"), default="allow")
|
||||
|
||||
scan = subparsers.add_parser("scan", help="scan a local repository")
|
||||
scan.add_argument("path", type=Path)
|
||||
scan.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
|
||||
@@ -189,6 +200,24 @@ def main(argv: list[str] | None = None) -> int:
|
||||
candidates.add_argument("--json", action="store_true")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "pre-push":
|
||||
from .config import PushConfig
|
||||
from .push.runner import replay
|
||||
|
||||
try:
|
||||
settings = PushConfig(
|
||||
deadline_seconds=args.deadline,
|
||||
cancellation_allowance_seconds=args.cancellation_allowance,
|
||||
mode=args.mode,
|
||||
incomplete_coverage_policy=args.incomplete_coverage,
|
||||
)
|
||||
delivery = replay(
|
||||
args.path, base=args.base, head=args.head, artifact=args.artifact, config=settings, max_regions=args.max_regions
|
||||
)
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
print(delivery.text, end="")
|
||||
return delivery.exit_code
|
||||
if args.command == "scan":
|
||||
return _scan(args.path, args.config, args.mode, args.fail_on)
|
||||
if args.command == "index":
|
||||
|
||||
@@ -195,9 +195,25 @@ def _valid_answer(scan: ModelScanResult, question: QuestionIdentity, operations:
|
||||
return len(rows) == sum(op.question == question for op in operations)
|
||||
|
||||
|
||||
def _context_boundaries(scan: ModelScanResult) -> tuple[str, ...]:
|
||||
# A missing scheduling vector is not missing arbiter evidence after its question completed.
|
||||
return tuple(b for b in scan.scope.unresolved_boundaries if not b.startswith("evidence_unknown:")) if scan.scope else ("scope_missing",)
|
||||
def _context_boundaries(scan: ModelScanResult, question: QuestionIdentity | None = None) -> tuple[str, ...]:
|
||||
# Aggregate coverage keeps every gap. A candidate may ignore only a recognized
|
||||
# question-owned boundary attached to a different, recorded question. Unknown
|
||||
# ownership and graph/declaration gaps remain global.
|
||||
if scan.scope is None:
|
||||
return ("scope_missing",)
|
||||
owned_prefixes = (
|
||||
"context_projection_unavailable:contributor-scan:",
|
||||
"context_scope_empty:contributor-scan:",
|
||||
"dynamic_external_or_depth_context_unresolved:contributor-scan:",
|
||||
)
|
||||
others = {q.identity.question_id for q in scan.scope.selected if q.identity != question}
|
||||
others.update(q.identity.question_id for q in scan.scope.deferred if q.identity != question)
|
||||
return tuple(
|
||||
boundary
|
||||
for boundary in scan.scope.unresolved_boundaries
|
||||
if not boundary.startswith("evidence_unknown:")
|
||||
and not (question is not None and boundary.startswith(owned_prefixes) and boundary.rsplit(":", 1)[-1] in others)
|
||||
)
|
||||
|
||||
|
||||
def _counterpart(question: QuestionIdentity, context: ChangeContext) -> QuestionIdentity:
|
||||
@@ -268,7 +284,7 @@ def compare_evidence(
|
||||
base
|
||||
and base.scope
|
||||
and base.scope.population_complete
|
||||
and not base.scope.deferred
|
||||
and all(question.reason == "tier_zero" for question in base.scope.deferred)
|
||||
and not _context_boundaries(base)
|
||||
and not base.degradations
|
||||
and _valid_answer(base, counterpart, base_ops)
|
||||
@@ -285,7 +301,7 @@ def compare_evidence(
|
||||
reason = "change_context_unresolved"
|
||||
elif (
|
||||
not head.scope
|
||||
or _context_boundaries(head)
|
||||
or _context_boundaries(head, op.question)
|
||||
or head.degradations
|
||||
or not _valid_answer(head, op.question, head_ops)
|
||||
or op.question not in {q.identity for q in head.scope.selected}
|
||||
|
||||
@@ -15,9 +15,9 @@ from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Literal
|
||||
|
||||
from openultrasast.model.contracts import ExecutionBudget
|
||||
from openultrasast.model.contracts import ChangeContext, ExecutionBudget
|
||||
from openultrasast.model.scan import ModelScanResult
|
||||
from openultrasast.push.contracts import PushComparison, PushResolution, PushResult
|
||||
from openultrasast.push.contracts import PushComparison, PushResolution, PushResult, SnapshotManifest
|
||||
from openultrasast.push.policy import AdmissionResult, _context_boundaries
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ class PushReport:
|
||||
provenance: Mapping[str, str]
|
||||
timings: Mapping[str, float]
|
||||
resolution: PushResolution | None = None
|
||||
snapshots: tuple[SnapshotManifest, ...] = ()
|
||||
change_context: ChangeContext | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "provenance", MappingProxyType(dict(self.provenance)))
|
||||
@@ -109,6 +111,8 @@ def _write_artifact(report: PushReport, target: Path, temporary: Path, connectio
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"snapshots": [item.to_payload() for item in report.snapshots],
|
||||
"change_context": report.change_context.to_payload() if report.change_context else None,
|
||||
"result": report.result.to_payload(),
|
||||
"admission": report.admission.to_payload(),
|
||||
"scans": [asdict(record) for record in report.scans],
|
||||
@@ -223,7 +227,9 @@ def render_report(report: PushReport, *, artifact: Path | None, error: str | Non
|
||||
notices.append(
|
||||
"Some checks could not be completed. Review the comparison details and resolve the recorded gaps before retrying."
|
||||
)
|
||||
if error:
|
||||
if error == "artifact_inside_repository":
|
||||
notices.append("Details could not be saved. Choose an artifact path outside the analyzed repository.")
|
||||
elif error:
|
||||
notices.append("Details could not be saved. Retry with a writable artifact path and available reporting time.")
|
||||
if notices:
|
||||
lines.append("Notice: " + " ".join(notices))
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Bounded, local base/head replay through the existing repository driver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import multiprocessing
|
||||
import os
|
||||
import pickle
|
||||
import selectors
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypeVar, cast
|
||||
|
||||
from openultrasast.config import PushConfig
|
||||
from openultrasast.cpg.backend import joern_version, resolve_cpg_backend
|
||||
from openultrasast.mapping import analyze_entry_points
|
||||
from openultrasast.model.contracts import ExecutionBudget
|
||||
from openultrasast.model.regions import ScanRegion, regions_for
|
||||
from openultrasast.model.scan import ScanBudget, scan_repository
|
||||
from openultrasast.model.shipped import declared_sources
|
||||
from openultrasast.preprocess import build_file_target, enumerate_source_files
|
||||
from openultrasast.push.contracts import ComparisonAnalysis, SnapshotManifest
|
||||
from openultrasast.push.policy import (
|
||||
AdmissionCandidate,
|
||||
AdmissionResult,
|
||||
CapabilityKey,
|
||||
admission_push_result,
|
||||
admit_candidates,
|
||||
compare_targeted_base,
|
||||
semantics_digest,
|
||||
)
|
||||
from openultrasast.push.report import PushReport, ReportDelivery, ScanRecord, _completed_scan, deliver_report, render_report
|
||||
from openultrasast.push.snapshot import SnapshotAdapter
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def _prepare(function: Callable[[], _T], budget: ExecutionBudget) -> _T:
|
||||
"""Bound pure local discovery/metadata work, including serialization, on Linux.
|
||||
|
||||
Read incrementally while the child writes; no pipe-buffer deadlock or unbounded
|
||||
blocking recv. The child never launches engines or project code.
|
||||
"""
|
||||
if time.monotonic() >= budget.deadline_monotonic:
|
||||
raise TimeoutError("deadline_exhausted")
|
||||
reader, writer = os.pipe()
|
||||
|
||||
def child() -> None:
|
||||
os.close(reader)
|
||||
try:
|
||||
payload: tuple[bool, Any]
|
||||
try:
|
||||
payload = (True, function())
|
||||
except Exception as error:
|
||||
payload = (False, type(error).__name__)
|
||||
with os.fdopen(writer, "wb") as stream:
|
||||
pickle.dump(payload, stream, protocol=5)
|
||||
finally:
|
||||
os._exit(0)
|
||||
|
||||
process = multiprocessing.get_context("fork").Process(target=child)
|
||||
data = bytearray()
|
||||
try:
|
||||
process.start()
|
||||
os.close(writer)
|
||||
writer = -1
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(reader, selectors.EVENT_READ)
|
||||
while selector.get_map():
|
||||
remaining = budget.deadline_monotonic - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("deadline_exhausted")
|
||||
for key, _ in selector.select(min(0.05, remaining)):
|
||||
chunk = os.read(key.fd, 65536)
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
else:
|
||||
data.extend(chunk)
|
||||
if len(data) > 32 * 1024**2:
|
||||
raise ValueError("preparation_output_limit")
|
||||
process.join(max(0, budget.deadline_monotonic - time.monotonic()))
|
||||
if process.is_alive() or process.exitcode != 0 or not data:
|
||||
raise RuntimeError("preparation_worker_failed")
|
||||
success, result = pickle.loads(data) # trusted child, never repository-provided pickle
|
||||
if not success:
|
||||
raise RuntimeError("preparation_failed:" + result)
|
||||
return cast(_T, result)
|
||||
finally:
|
||||
os.close(reader)
|
||||
if writer >= 0:
|
||||
os.close(writer)
|
||||
if process.pid is not None:
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join(max(0, budget.deadline_monotonic + budget.cancellation_allowance_seconds - time.monotonic()))
|
||||
if not process.is_alive():
|
||||
process.close()
|
||||
|
||||
|
||||
def _discover(root: Path) -> tuple[tuple[ScanRegion, ...], tuple[bytes, ...]]:
|
||||
# Same proposer and declaration-based shipping rules as ordinary scans. Do not
|
||||
# call preprocess's live-HEAD probe on a materialized, intentionally Git-free tree.
|
||||
files = enumerate_source_files(root)
|
||||
targets = [build_file_target(root, path) for path in files]
|
||||
regions = regions_for(analyze_entry_points(root, targets), targets, shipped=declared_sources(root))
|
||||
# Conservatively seed every tracked declaration/non-source file. This is change
|
||||
# context only, never a graph allowlist or a second scope selector.
|
||||
sources = set(files)
|
||||
declarations = tuple(os.fsencode(str(p.relative_to(root))) for p in root.rglob("*") if p.is_file() and p not in sources)
|
||||
return regions, declarations
|
||||
|
||||
|
||||
def _provenance(repository: Path, scan_budget: ScanBudget) -> dict[str, str]:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
|
||||
def digest(paths: list[Path]) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
for path in sorted(paths):
|
||||
hasher.update(str(path.relative_to(root)).encode())
|
||||
hasher.update(path.read_bytes())
|
||||
return hasher.hexdigest()
|
||||
|
||||
version = joern_version()
|
||||
engine = "joern-" + (".".join(map(str, version)) if version else "unavailable_or_unidentified")
|
||||
return {
|
||||
"repository": str(repository.absolute()),
|
||||
"engine": engine,
|
||||
"facts": digest(list((root / "ruleset").rglob("*.toml"))),
|
||||
"queries": digest(list((root / "cpg/queries").glob("*.sc"))),
|
||||
"policy": digest(list((root / "push").glob("*.py"))),
|
||||
"core": digest(list((root / "model").glob("*.py")) + list((root / "cpg").glob("*.py"))),
|
||||
"semantics": semantics_digest(engine_identity=engine, options=asdict(scan_budget)),
|
||||
"ranking_mode": "evidence",
|
||||
"model": "disabled",
|
||||
"capabilities": "empty_registry_pending_task_8.3",
|
||||
"cache": "cold_no_reuse",
|
||||
}
|
||||
|
||||
|
||||
def replay(
|
||||
repository: Path,
|
||||
*,
|
||||
base: str,
|
||||
head: str,
|
||||
artifact: Path,
|
||||
config: PushConfig | None = None,
|
||||
backend: Any = None,
|
||||
max_regions: int = 500,
|
||||
) -> ReportDelivery:
|
||||
"""Run a single explicit comparison; exit policy never stands for coverage."""
|
||||
config = config or PushConfig()
|
||||
if max_regions < 1:
|
||||
raise ValueError("max_regions must be positive")
|
||||
started = time.monotonic()
|
||||
budget = ExecutionBudget(started + config.deadline_seconds, config.cancellation_allowance_seconds)
|
||||
scan_budget = ScanBudget(max_model_calls=0, max_regions=max_regions, order_by_evidence=True)
|
||||
records: list[ScanRecord] = []
|
||||
manifests: list[SnapshotManifest] = []
|
||||
reasons: list[str] = []
|
||||
admission = AdmissionResult((), (), ())
|
||||
comparison = None
|
||||
context = None
|
||||
timings: dict[str, float] = {
|
||||
"deadline_seconds": config.deadline_seconds,
|
||||
"cancellation_allowance_seconds": config.cancellation_allowance_seconds,
|
||||
}
|
||||
provenance = {key: "unavailable_preparation_incomplete" for key in ("engine", "facts", "queries", "policy")}
|
||||
provenance.update(repository=str(repository.absolute()), model="disabled", requested_base=base, requested_head=head)
|
||||
stage = "resolution"
|
||||
stage_started = started
|
||||
try:
|
||||
adapter = SnapshotAdapter(repository, execution_budget=budget)
|
||||
comparison = adapter.resolve_replay(base, head)
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
stage, stage_started = "provenance", time.monotonic()
|
||||
provenance.update(_prepare(lambda: _provenance(repository, scan_budget), budget))
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
stage, stage_started = "preparation", time.monotonic()
|
||||
with adapter.materialize(comparison.head_oid, budget=budget) as tip:
|
||||
manifests.append(tip.manifest)
|
||||
assert comparison.base_oid is not None
|
||||
with adapter.materialize(comparison.base_oid, budget=budget) as old:
|
||||
manifests.append(old.manifest)
|
||||
for manifest in manifests:
|
||||
reasons.extend("snapshot:" + boundary.reason for boundary in manifest.boundaries)
|
||||
head_regions, head_declarations = _prepare(lambda: _discover(tip.root), budget)
|
||||
base_regions, base_declarations = _prepare(lambda: _discover(old.root), budget)
|
||||
context = adapter.compare(comparison, declaration_paths=tuple(set(head_declarations + base_declarations)), budget=budget)
|
||||
context = replace(context, unresolved_boundaries=tuple(dict.fromkeys((*context.unresolved_boundaries, *reasons))))
|
||||
reasons.extend(context.unresolved_boundaries)
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
stage, stage_started = "head", time.monotonic()
|
||||
head_scan = scan_repository(
|
||||
tip.root,
|
||||
head_regions,
|
||||
backend=backend or resolve_cpg_backend(),
|
||||
budget=scan_budget,
|
||||
execution_budget=budget,
|
||||
ranking_mode="evidence",
|
||||
unit="repository",
|
||||
population_complete=all(m.complete for m in manifests),
|
||||
change_context=context,
|
||||
)
|
||||
records.append(ScanRecord(comparison, "head", head_scan))
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
stage, stage_started = "base", time.monotonic()
|
||||
delta = compare_targeted_base(
|
||||
old.root,
|
||||
head=head_scan,
|
||||
head_regions=head_regions,
|
||||
base_regions=base_regions,
|
||||
context=context,
|
||||
backend=backend or resolve_cpg_backend(),
|
||||
execution_budget=budget,
|
||||
scan_budget=scan_budget,
|
||||
head_semantics=provenance["semantics"],
|
||||
base_semantics=provenance["semantics"],
|
||||
)
|
||||
if delta.base_scan is not None:
|
||||
records.append(ScanRecord(comparison, "base", delta.base_scan))
|
||||
reasons.extend(delta.coverage_reasons)
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
stage, stage_started = "admission", time.monotonic()
|
||||
candidates = tuple(
|
||||
AdmissionCandidate(
|
||||
c,
|
||||
CapabilityKey(
|
||||
c.head_operation.question.language if c.head_operation else "unknown",
|
||||
"unspecified",
|
||||
"unspecified",
|
||||
c.family,
|
||||
c.head_operation.mechanism if c.head_operation else "taint",
|
||||
"unreviewed",
|
||||
delta.semantics,
|
||||
),
|
||||
comparison,
|
||||
tuple(reasons),
|
||||
)
|
||||
for c in delta.candidates
|
||||
)
|
||||
admission = _prepare(lambda: admit_candidates(candidates), budget)
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
stage, stage_started = "cleanup", time.monotonic()
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
except Exception as error:
|
||||
reasons.append(stage + "_failed:" + type(error).__name__)
|
||||
timings[stage + "_seconds"] = time.monotonic() - stage_started
|
||||
if time.monotonic() >= budget.deadline_monotonic:
|
||||
reasons.append("deadline_exhausted")
|
||||
head_records = [r.scan for r in records if r.side == "head"]
|
||||
for record in records:
|
||||
if record.scan.scope is not None:
|
||||
reasons.extend(record.scan.scope.unresolved_boundaries)
|
||||
if not records or not all(_completed_scan(r.scan) for r in records):
|
||||
reasons.append("analysis_incomplete")
|
||||
if not any(r.side == "base" for r in records):
|
||||
reasons.append("base_comparison_unavailable")
|
||||
admission = replace(admission, coverage_reasons=tuple(dict.fromkeys((*admission.coverage_reasons, *reasons))))
|
||||
analyses: tuple[ComparisonAnalysis, ...] = ()
|
||||
if comparison is not None:
|
||||
scopes = tuple(scan.scope for scan in head_records if scan.scope is not None)
|
||||
completed = tuple(outcome.identity for scan in head_records for outcome in scan.question_outcomes if outcome.status == "completed")
|
||||
coverage: Literal["unavailable", "incomplete", "complete_within_scope"] = (
|
||||
"unavailable" if not records else "incomplete" if admission.coverage_reasons else "complete_within_scope"
|
||||
)
|
||||
analyses = (ComparisonAnalysis(comparison, scopes, completed, coverage),)
|
||||
result = admission_push_result(admission, analyses, config=config)
|
||||
timings["total_seconds"] = time.monotonic() - started
|
||||
report = PushReport(result, admission, tuple(records), provenance, timings, snapshots=tuple(manifests), change_context=context)
|
||||
try:
|
||||
artifact.resolve().relative_to(repository.resolve())
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
write_error = "artifact_inside_repository"
|
||||
return ReportDelivery(result, render_report(report, artifact=None, error=write_error), None, write_error)
|
||||
return deliver_report(report, artifact, execution_budget=budget)
|
||||
@@ -44,7 +44,8 @@ class SnapshotInputError(ValueError):
|
||||
|
||||
|
||||
class SnapshotAdapter:
|
||||
def __init__(self, repository: Path, *, comparison_base: str | None = None) -> None:
|
||||
def __init__(self, repository: Path, *, comparison_base: str | None = None, execution_budget: ExecutionBudget | None = None) -> None:
|
||||
self.execution_budget = execution_budget
|
||||
self.repository = repository.resolve()
|
||||
self.comparison_base = comparison_base
|
||||
# Hook-inherited Git variables may redirect objects or worktrees elsewhere.
|
||||
@@ -72,10 +73,22 @@ class SnapshotAdapter:
|
||||
|
||||
def _repository_path(self, *args: str) -> Path:
|
||||
# Binary framing also matters for the repository's own whitespace/non-UTF8 name.
|
||||
raw = self._bounded_git(("rev-parse", *args), 65536, ExecutionBudget(time.monotonic() + 10.0, 2.0), None, [])
|
||||
raw = self._bounded_git(
|
||||
("rev-parse", *args), 65536, self.execution_budget or ExecutionBudget(time.monotonic() + 10.0, 2.0), None, []
|
||||
)
|
||||
return Path(os.fsdecode(raw.removesuffix(b"\n"))).resolve()
|
||||
|
||||
def _git(self, *args: str, input: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
if self.execution_budget is not None:
|
||||
if input not in (None, ""):
|
||||
raise SnapshotInputError("bounded_resolution_requires_empty_stdin")
|
||||
try:
|
||||
raw = self._bounded_git(tuple(args), 16 * 1024**2, self.execution_budget, None, [])
|
||||
return subprocess.CompletedProcess(args, 0, raw.decode("utf-8", "surrogateescape"), "")
|
||||
except SnapshotInputError as error:
|
||||
if str(error).startswith("local_git_") and "_failed_exit_" in str(error):
|
||||
return subprocess.CompletedProcess(args, 1, "", "")
|
||||
raise
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "-c", "protocol.allow=never", "-c", "core.hooksPath=" + os.devnull, "-C", str(self.repository), *args],
|
||||
@@ -97,6 +110,17 @@ class SnapshotAdapter:
|
||||
raise SnapshotInputError(f"local Git {args[0]} failed (exit {result.returncode})")
|
||||
return result.stdout.strip()
|
||||
|
||||
def resolve_replay(self, base: str, head: str) -> PushComparison:
|
||||
"""Resolve explicitly supplied local revisions without inferred baselines or fetching."""
|
||||
resolved = []
|
||||
for revision in (base, head):
|
||||
oid = self._required("rev-parse", "--verify", "--end-of-options", revision)
|
||||
commit, kind = self._commit(oid)
|
||||
if commit is None:
|
||||
raise SnapshotInputError("replay_revision_" + kind)
|
||||
resolved.append(commit)
|
||||
return PushComparison(resolved[1], resolved[0], "explicit_local_replay", ("refs/heads/replay",))
|
||||
|
||||
def parse_updates(self, data: str) -> tuple[PushUpdate, ...]:
|
||||
"""Validate every record before attempting resolution; malformed input fails closed."""
|
||||
updates = []
|
||||
|
||||
Reference in New Issue
Block a user