diff --git a/docs/INBOUND_VALIDATION.md b/docs/INBOUND_VALIDATION.md index 6925c50..3745afc 100644 --- a/docs/INBOUND_VALIDATION.md +++ b/docs/INBOUND_VALIDATION.md @@ -31,6 +31,51 @@ The configuration tree must be installed through deployment governance. A newer Inbound XML is size-bounded and parsed with external entities and DTD processing disabled before Java runs. KoSIT runs without a shell, with an absolute executable, fixed argument vector, bounded time, bounded captured output, and an isolated result directory. +The validator's stdout and stderr share a fixed 2 MiB capture limit, including +the single separator byte used when inspecting their combined output. Both +streams are drained while the validator runs. If the next byte exceeds that +limit, GovOPlaN kills and reaps the direct validator process and returns +`failed` with the reason `KoSIT runner output exceeded the safety limit.` +Conformance and assessment remain `unknown`, and no handoff is allowed even +if a valid-looking report was already written. The existing timeout (120 seconds +by default, configurable from 1 to 600 seconds) also kills and reaps the +validator; cancellation propagates after the same cleanup. Output is not +included in these technical-failure reasons. Operators should check the +approved engine and configuration using synthetic inputs before retrying an +output-limit failure. The pipe runner uses POSIX process facilities; deployment +resource limits and descendant-process isolation remain outside this capture +bound. + +Report reads are bounded to the existing 16 MiB limit plus one probe byte. +Reports exactly at the limit remain eligible for normal interpretation; +oversized reports cannot supply trusted evidence and yield `incomplete` with +unknown conformance unless an earlier runner failure already determines the +outcome. An output-limit failure skips reading the report entirely. This read +bound does not limit how much report data the validator can write to disk. + +Standardausgabe und Standardfehlerausgabe des Validators teilen eine feste +Erfassungsgrenze von 2 MiB einschließlich eines Trennbytes. Beide Ausgaben +werden während des Laufs gelesen. Bei Überschreitung beendet GovOPlaN den +direkten Validatorprozess und wartet dessen Abschluss ab. Das Ergebnis lautet +`failed` mit unbekannter Konformität und Bewertung (`unknown`); auch ein +scheinbar gültiger Bericht erlaubt keine Übergabe. Das bestehende Zeitlimit +(standardmäßig 120 Sekunden, konfigurierbar von 1 bis 600 Sekunden) beendet den +Prozess ebenfalls; ein Abbruch wird nach der Prozessbereinigung weitergegeben. +Diese technischen Fehlergründe enthalten keine Runner-Ausgabe. Betreibende +sollten das freigegebene Profil vor einem erneuten Versuch mit synthetischen +Eingaben prüfen. Der Pipe-Runner verwendet POSIX-Prozessfunktionen; +Ressourcengrenzen der Installation und die Isolation von Kindprozessen bleiben +außerhalb dieser Erfassungsgrenze. + +Berichte werden höchstens bis zur bestehenden Grenze von 16 MiB zuzüglich +eines Prüfbytes gelesen. Berichte genau an der Grenze werden normal +ausgewertet; übergroße Berichte liefern keinen vertrauenswürdigen Nachweis und +ergeben `incomplete` mit unbekannter Konformität, sofern nicht bereits ein +vorrangiger Runner-Fehler das Ergebnis bestimmt. Bei Überschreitung der +Runner-Ausgabegrenze wird der Bericht gar nicht gelesen. Die Lesegrenze +begrenzt nicht die Berichtsmenge, die der Validator auf Datenträger schreiben +kann. + GovOPlaN does not trust the report alone. A technically complete result requires a zero process status, no technical error marker in runner output, a bounded well-formed VARL report, a matched scenario, at least the configured number of complete validation steps, and exactly one assessment. Formal validity and the accept/reject recommendation are retained separately because warnings can make them differ. This compensates for the known risk that a partial report can look valid after a transformation failure. Semantic invalidity is different from technical failure. A complete reject report is `invalid`; a timeout, crash, partial report, or inconsistent assessment is `unknown`. Neither result may be handed off as valid. diff --git a/src/govoplan_xrechnung/backend/manifest.py b/src/govoplan_xrechnung/backend/manifest.py index 791c770..36569e0 100644 --- a/src/govoplan_xrechnung/backend/manifest.py +++ b/src/govoplan_xrechnung/backend/manifest.py @@ -115,11 +115,14 @@ manifest = ModuleManifest( "No XRechnung/configuration version is activated by default in this release.", "The connector creates a handoff contract but does not own payable approval or booking.", "A syntactically valid report is not trusted when runner output signals a technical failure.", + "Runner stdout and stderr share a fixed 2 MiB capture limit, including one separator byte; the limit is enforced while the validator runs.", + "Reports are limited to 16 MiB and read with at most one extra byte to detect overflow. Pipe capture requires POSIX; deployment resource limits and descendant-process isolation remain separate.", ], "consequences": [ "Changing any pinned artifact changes the profile digest and requires a new validation.", "Invalid invoices retain diagnostics but cannot enter the valid-invoice handoff.", "Technical failure produces unknown conformance, never a semantic rejection or acceptance.", + "Exceeding the output limit stops and reaps the validator and reports technical failure with unknown conformance; operators should check the approved profile using synthetic inputs before retrying.", ], "verification": "Confirm artifact and profile digests, technical outcome, validation-step count, formal validity, VARL assessment, invoice digest, and report digest before following the handoff reference.", }, @@ -143,11 +146,14 @@ manifest = ModuleManifest( "Diese Version aktiviert standardmäßig keine XRechnung- oder Konfigurationsversion.", "Der Konnektor erzeugt einen Übergabevertrag, führt aber weder Freigabe der Verbindlichkeit noch Buchung.", "Ein syntaktisch gültiger Bericht ist nicht vertrauenswürdig, wenn die Runner-Ausgabe einen technischen Fehler meldet.", + "Standardausgabe und Standardfehlerausgabe teilen eine feste Erfassungsgrenze von 2 MiB einschließlich eines Trennbytes; die Grenze wird während des Validatorlaufs durchgesetzt.", + "Berichte sind auf 16 MiB begrenzt und werden mit höchstens einem zusätzlichen Byte zur Erkennung einer Überschreitung gelesen. Die Pipe-Erfassung erfordert POSIX; Ressourcengrenzen der Installation und die Isolation nachgelagerter Prozesse bleiben getrennt.", ], "consequences": [ "Jede Änderung eines festgelegten Artefakts ändert die Profilprüfsumme und erfordert eine neue Validierung.", "Ungültige Rechnungen bewahren Diagnosen, dürfen aber nicht in die gültige Rechnungsübergabe gelangen.", "Technischer Fehler ergibt unbekannte Konformität, niemals semantische Ablehnung oder Annahme.", + "Bei Überschreitung der Ausgabegrenze wird der Validator beendet und sein Prozessabschluss abgewartet; das Ergebnis ist ein technischer Fehler mit unbekannter Konformität. Betreibende sollten das freigegebene Profil vor einem erneuten Versuch mit synthetischen Eingaben prüfen.", ], "verification": "Vor dem Folgen des Übergabeverweises Artefakt- und Profilprüfsummen, technisches Ergebnis, Prüfschrittzahl, formale Gültigkeit, VARL-Bewertung, Rechnungsprüfsumme und Berichtsprüfsumme bestätigen.", } @@ -167,7 +173,12 @@ manifest = ModuleManifest( "approved, suspended, or retired state. Selection uses the invoice receive time and " "never infers a product version. Unknown, not-yet-valid, expired, suspended, retired, " "or digest-mismatched profiles fail closed. Validation evidence distinguishes runner " - "completion, report completeness, formal validity, and semantic assessment. A handoff " + "completion, report completeness, formal validity, and semantic assessment. Stdout and " + "stderr share a fixed 2 MiB capture limit, including one separator byte, enforced during " + "execution. Overflow stops and reaps the validator; the result is technical failure " + "with unknown conformance and no handoff, even if a report looks valid. Reports are " + "read with a 16 MiB limit plus one probe byte; oversized reports cannot supply trusted " + "validation evidence. A handoff " "binds the exact invoice, report, and profile digests but does not approve a payable, " "book an invoice, retain the source file, or replace Records custody." ), @@ -194,7 +205,13 @@ manifest = ModuleManifest( "oder ausgemustert. Die Auswahl verwendet den Eingangszeitpunkt der Rechnung und leitet niemals eine " "Produktversion ab. Unbekannte, noch nicht gültige, abgelaufene, gesperrte, ausgemusterte oder bei der " "Prüfsumme abweichende Profile werden geschlossen abgewiesen. Der Validierungsnachweis unterscheidet " - "Runner-Abschluss, Berichtsvollständigkeit, formale Gültigkeit und semantische Bewertung. Eine Übergabe " + "Runner-Abschluss, Berichtsvollständigkeit, formale Gültigkeit und semantische Bewertung. Standardausgabe " + "und Standardfehlerausgabe teilen eine feste Erfassungsgrenze von 2 MiB einschließlich eines Trennbytes, " + "die während der Ausführung durchgesetzt wird. Bei Überschreitung wird der Validator beendet und sein " + "Prozessabschluss abgewartet; das Ergebnis ist ein technischer Fehler mit unbekannter Konformität ohne " + "Übergabe, selbst wenn ein Bericht gültig erscheint. Berichte werden mit einer Grenze von 16 MiB " + "zuzüglich eines Prüfbytes gelesen; übergroße Berichte liefern keinen vertrauenswürdigen " + "Validierungsnachweis. Eine Übergabe " "bindet exakte Rechnungs-, Berichts- und Profilprüfsummen, genehmigt aber keine Verbindlichkeit, bucht keine " "Rechnung, bewahrt keine Quelldatei und ersetzt nicht die Verwahrung durch Records." ), @@ -206,6 +223,7 @@ manifest = ModuleManifest( "approve_profile": "Makes one exact verified artifact set eligible only in its receive-time window.", "suspend_or_retire_profile": "Blocks selection without rewriting prior validation evidence.", "fail_closed": "Treats unknown profile or technical outcome as untrusted rather than valid or invalid.", + "exceed_output_limit": "Stops and reaps the validator with a technical-failure reason and blocks handoff regardless of report content.", "create_handoff": "Binds valid evidence for an owner workflow without approving, booking, or storing the invoice.", }, }, @@ -216,6 +234,7 @@ manifest = ModuleManifest( "approve_profile": "Macht genau einen geprüften Artefaktsatz ausschließlich in seinem Eingangszeitfenster auswählbar.", "suspend_or_retire_profile": "Sperrt die Auswahl, ohne frühere Validierungsnachweise umzuschreiben.", "fail_closed": "Behandelt unbekanntes Profil oder technisches Ergebnis als nicht vertrauenswürdig statt als gültig oder ungültig.", + "exceed_output_limit": "Beendet den Validator, wartet seinen Prozessabschluss ab, meldet einen technischen Fehler und sperrt die Übergabe unabhängig vom Berichtsinhalt.", "create_handoff": "Bindet gültige Nachweise für einen Eigentümerablauf, ohne die Rechnung zu genehmigen, zu buchen oder zu speichern.", } } diff --git a/src/govoplan_xrechnung/backend/validation.py b/src/govoplan_xrechnung/backend/validation.py index 334aea7..e8d357e 100644 --- a/src/govoplan_xrechnung/backend/validation.py +++ b/src/govoplan_xrechnung/backend/validation.py @@ -7,8 +7,10 @@ import json import os from pathlib import Path import re +import selectors import subprocess import tempfile +import time from typing import Literal from defusedxml import ElementTree @@ -31,6 +33,10 @@ class XRechnungValidationError(RuntimeError): """Stable validation or configuration error without invoice contents.""" +class _RunnerOutputLimitExceeded(Exception): + """The shared stdout/stderr budget was exhausted during execution.""" + + @dataclass(frozen=True, slots=True) class KoSITValidationProfile: """Exact locally installed validator and rule-set selection.""" @@ -158,13 +164,10 @@ class KoSITValidator: if os.environ.get("JAVA_HOME"): env["JAVA_HOME"] = os.environ["JAVA_HOME"] try: - completed = subprocess.run( # noqa: S603 - absolute, digest-pinned executable and artifacts; no shell. + completed = _run_with_bounded_output( command, cwd=self.profile.configuration_root, env=env, - stdin=subprocess.DEVNULL, - capture_output=True, - check=False, timeout=self.timeout_seconds, ) except subprocess.TimeoutExpired: @@ -175,8 +178,7 @@ class KoSITValidator: "failed", "KoSIT validation timed out; no handoff is allowed.", ) - output = completed.stdout + b"\n" + completed.stderr - if len(output) > MAX_RUNNER_OUTPUT_BYTES: + except _RunnerOutputLimitExceeded: return _technical_result( self.profile, profile_sha256, @@ -184,8 +186,12 @@ class KoSITValidator: "failed", "KoSIT runner output exceeded the safety limit.", ) + output = completed.stdout + b"\n" + completed.stderr report_path = workdir / "invoice-report.xml" - report = report_path.read_bytes() if report_path.is_file() else None + report = None + if report_path.is_file(): + with report_path.open("rb") as report_file: + report = report_file.read(MAX_REPORT_BYTES + 1) return interpret_kosit_result( profile=self.profile, profile_sha256=profile_sha256, @@ -196,6 +202,52 @@ class KoSITValidator: ) +def _run_with_bounded_output( + command: list[str], *, cwd: Path, env: dict[str, str], timeout: int +) -> subprocess.CompletedProcess[bytes]: + """Drain both pipes within one budget, killing and reaping on any interruption.""" + with subprocess.Popen( # noqa: S603 - absolute executable, pinned artifacts, fixed argv; no shell. + command, + cwd=cwd, + env=env, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) as process: + try: + deadline = time.monotonic() + timeout + stdout, stderr = bytearray(), bytearray() + # Preserve the existing combined-output cap, including its separator. + captured_bytes = 1 + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ, stdout) + selector.register(process.stderr, selectors.EVENT_READ, stderr) + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(command, timeout) + for key, _events in selector.select(remaining): + # Read at most one byte beyond the budget to detect overflow. + chunk = os.read( + key.fd, min(64 * 1024, MAX_RUNNER_OUTPUT_BYTES - captured_bytes + 1) + ) + if not chunk: + selector.unregister(key.fileobj) + continue + captured_bytes += len(chunk) + if captured_bytes > MAX_RUNNER_OUTPUT_BYTES: + raise _RunnerOutputLimitExceeded + key.data.extend(chunk) + return_code = process.wait(timeout=max(0, deadline - time.monotonic())) + except BaseException: + # Do not communicate() here: draining after termination would be unbounded. + process.kill() + process.wait() + raise + return subprocess.CompletedProcess(command, return_code, bytes(stdout), bytes(stderr)) + + def verify_profile(profile: KoSITValidationProfile) -> str: if not profile.java_executable.is_file() or not os.access(profile.java_executable, os.X_OK): raise XRechnungValidationError("The pinned Java executable is unavailable or not executable.") diff --git a/tests/test_validation.py b/tests/test_validation.py index ac01c98..39078d7 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,15 +1,23 @@ from __future__ import annotations +from collections.abc import Iterator from datetime import UTC, datetime import hashlib +import os from pathlib import Path +import subprocess +import sys +import time import pytest +from govoplan_xrechnung.backend import validation from govoplan_xrechnung.backend.manifest import get_manifest from govoplan_xrechnung.backend.validation import ( InboundInvoice, KoSITValidationProfile, + KoSITValidator, + MAX_RUNNER_OUTPUT_BYTES, XRechnungValidationError, configuration_tree_sha256, create_validated_handoff, @@ -70,6 +78,231 @@ def _invoice() -> InboundInvoice: ) +def _runner_profile(tmp_path: Path, script: str) -> KoSITValidationProfile: + """Install a synthetic executable; never invoke Java or a real invoice validator.""" + profile = _profile(tmp_path) + profile.java_executable.write_text( + f"#!{sys.executable}\n" + "import os, sys, time\n" + "from pathlib import Path\n" + f"Path(sys.argv[-1]).with_name('invoice-report.xml').write_bytes({_report(valid=True)!r})\n" + + script, + encoding="utf-8", + ) + return profile + + +@pytest.fixture +def runner_processes(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[subprocess.Popen[bytes]]]: + processes: list[subprocess.Popen[bytes]] = [] + real_popen = subprocess.Popen + + def start(*args, **kwargs): + process = real_popen(*args, **kwargs) + processes.append(process) + return process + + monkeypatch.setattr(validation.subprocess, "Popen", start) + try: + yield processes + finally: + for process in processes: + if process.poll() is None: + process.kill() + process.wait() + + +def _assert_runner_reaped(processes: list[subprocess.Popen[bytes]]) -> None: + assert len(processes) == 1 + process = processes[0] + assert process.returncode is not None + assert process.stdout is not None and process.stdout.closed + assert process.stderr is not None and process.stderr.closed + with pytest.raises(ChildProcessError): + os.waitpid(process.pid, os.WNOHANG) + + +@pytest.fixture +def report_read_sizes(monkeypatch: pytest.MonkeyPatch) -> list[int]: + sizes: list[int] = [] + real_open = Path.open + + class ReportReader: + def __init__(self, stream): + self.stream = stream + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.stream.close() + + def read(self, size=-1): + sizes.append(size) + assert size == validation.MAX_REPORT_BYTES + 1 + return self.stream.read(size) + + def open_file(path, mode="r", *args, **kwargs): + stream = real_open(path, mode, *args, **kwargs) + if path.name == "invoice-report.xml" and mode == "rb": + return ReportReader(stream) + return stream + + monkeypatch.setattr(Path, "open", open_file) + return sizes + + +@pytest.mark.parametrize("stream", ["stdout", "stderr", "both"]) +def test_noisy_runner_is_stopped_at_shared_output_limit( + tmp_path: Path, + runner_processes: list[subprocess.Popen[bytes]], + report_read_sizes: list[int], + stream: str, +) -> None: + count = MAX_RUNNER_OUTPUT_BYTES + writes = { + "stdout": f"sys.stdout.buffer.write(b'x' * {count})\nsys.stdout.flush()\n", + "stderr": f"sys.stderr.buffer.write(b'x' * {count})\nsys.stderr.flush()\n", + "both": ( + f"sys.stdout.buffer.write(b'x' * {count // 2})\nsys.stdout.flush()\n" + f"sys.stderr.buffer.write(b'x' * {count // 2})\nsys.stderr.flush()\n" + ), + } + profile = _runner_profile(tmp_path, writes[stream] + "time.sleep(30)\n") + + result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice()) + + assert result.technical_outcome == "failed" + assert result.technical_reason == "KoSIT runner output exceeded the safety limit." + assert result.conformance == result.assessment == "unknown" + assert not result.handoff_allowed + assert result.report_sha256 is None + assert result.diagnostics == () + assert report_read_sizes == [] + _assert_runner_reaped(runner_processes) + + +@pytest.mark.parametrize(("extra_bytes", "exit_code"), [(0, 0), (1, 0), (100_000, 0), (100_000, 7)]) +def test_report_reads_are_bounded_and_preserve_exact_limit( + tmp_path: Path, + runner_processes: list[subprocess.Popen[bytes]], + report_read_sizes: list[int], + monkeypatch: pytest.MonkeyPatch, + extra_bytes: int, + exit_code: int, +) -> None: + report_limit = len(_report(valid=True)) + monkeypatch.setattr(validation, "MAX_REPORT_BYTES", report_limit) + profile = _runner_profile( + tmp_path, + "with Path(sys.argv[-1]).with_name('invoice-report.xml').open('ab') as report:\n" + f" report.write(b' ' * {extra_bytes})\n" + f"sys.exit({exit_code})\n", + ) + + result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice()) + + assert report_read_sizes == [report_limit + 1] + if extra_bytes: + assert result.technical_outcome == ("failed" if exit_code else "incomplete") + assert result.technical_reason == ( + f"KoSIT runner exited with status {exit_code}; report semantics are not trusted." + if exit_code else "KoSIT did not produce a bounded XML report." + ) + assert result.conformance == result.assessment == "unknown" + assert result.report_sha256 is None + assert not result.handoff_allowed + else: + assert result.technical_outcome == "complete" + assert result.report_sha256 == hashlib.sha256(_report(valid=True)).hexdigest() + assert result.handoff_allowed + _assert_runner_reaped(runner_processes) + + +@pytest.mark.parametrize("excess_bytes", [0, 1]) +def test_completed_runner_output_preserves_exact_combined_limit( + tmp_path: Path, runner_processes: list[subprocess.Popen[bytes]], excess_bytes: int +) -> None: + stdout_size = MAX_RUNNER_OUTPUT_BYTES // 2 + stderr_size = MAX_RUNNER_OUTPUT_BYTES - stdout_size - 1 + excess_bytes + profile = _runner_profile( + tmp_path, + f"sys.stdout.buffer.write(b'x' * {stdout_size})\n" + f"sys.stderr.buffer.write(b'x' * {stderr_size})\n", + ) + + result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice()) + + assert result.technical_outcome == ("failed" if excess_bytes else "complete") + assert result.handoff_allowed is (excess_bytes == 0) + _assert_runner_reaped(runner_processes) + + +@pytest.mark.parametrize("close_pipes", [False, True]) +def test_hanging_runner_times_out_and_is_reaped( + tmp_path: Path, runner_processes: list[subprocess.Popen[bytes]], close_pipes: bool +) -> None: + script = "os.close(1)\nos.close(2)\n" if close_pipes else "" + profile = _runner_profile(tmp_path, script + "time.sleep(30)\n") + started = time.monotonic() + + result = KoSITValidator(profile, timeout_seconds=1).validate(_invoice()) + + assert time.monotonic() - started < 5 + assert result.technical_outcome == "failed" + assert result.technical_reason == "KoSIT validation timed out; no handoff is allowed." + assert result.conformance == result.assessment == "unknown" + assert not result.handoff_allowed + _assert_runner_reaped(runner_processes) + + +@pytest.mark.parametrize( + ("script", "outcome", "reason"), + [ + ("sys.stderr.write('synthetic failure')\nsys.exit(7)\n", "failed", "status 7"), + ( + "sys.stdout.buffer.write(b'x' * 65532 + b'\\nERROR synthetic failure')\n", + "incomplete", + "technical error", + ), + ("sys.stderr.write('ERROR synthetic failure')\n", "incomplete", "technical error"), + ], +) +def test_runner_failure_and_technical_output_never_trust_valid_report( + tmp_path: Path, + runner_processes: list[subprocess.Popen[bytes]], + script: str, + outcome: str, + reason: str, +) -> None: + profile = _runner_profile(tmp_path, script) + + result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice()) + + assert result.technical_outcome == outcome + assert reason in result.technical_reason + assert result.conformance == result.assessment == "unknown" + assert not result.handoff_allowed + _assert_runner_reaped(runner_processes) + + +def test_runner_cancellation_kills_and_reaps_before_propagating( + tmp_path: Path, + runner_processes: list[subprocess.Popen[bytes]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile = _runner_profile(tmp_path, "time.sleep(30)\n") + + def cancel(_selector, _timeout): + raise KeyboardInterrupt + + monkeypatch.setattr(validation.selectors.DefaultSelector, "select", cancel) + with pytest.raises(KeyboardInterrupt): + KoSITValidator(profile, timeout_seconds=2).validate(_invoice()) + + _assert_runner_reaped(runner_processes) + + def test_profile_verifies_exact_engine_and_complete_configuration_tree(tmp_path: Path) -> None: profile = _profile(tmp_path) first = verify_profile(profile)