Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73709c65a | ||
|
|
c2be55d46e |
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-xrechnung"
|
name = "govoplan-xrechnung"
|
||||||
version = "0.1.20"
|
version = "0.1.21"
|
||||||
description = "GovOPlaN inbound XRechnung validation connector."
|
description = "GovOPlaN inbound XRechnung validation connector."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
"""GovOPlaN XRechnung connector."""
|
"""GovOPlaN XRechnung connector."""
|
||||||
|
|
||||||
__version__ = "0.1.19"
|
__version__ = "0.1.21"
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from govoplan_core.core.provider_governance import declared_module_architecture
|
|||||||
|
|
||||||
|
|
||||||
MODULE_ID = "xrechnung"
|
MODULE_ID = "xrechnung"
|
||||||
MODULE_VERSION = "0.1.20"
|
MODULE_VERSION = "0.1.21"
|
||||||
READ_SCOPE = "xrechnung:validation:read"
|
READ_SCOPE = "xrechnung:validation:read"
|
||||||
EXECUTE_SCOPE = "xrechnung:validation:execute"
|
EXECUTE_SCOPE = "xrechnung:validation:execute"
|
||||||
HANDOFF_SCOPE = "xrechnung:handoff:create"
|
HANDOFF_SCOPE = "xrechnung:handoff:create"
|
||||||
@@ -115,16 +115,132 @@ manifest = ModuleManifest(
|
|||||||
"No XRechnung/configuration version is activated by default in this release.",
|
"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.",
|
"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.",
|
"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": [
|
"consequences": [
|
||||||
"Changing any pinned artifact changes the profile digest and requires a new validation.",
|
"Changing any pinned artifact changes the profile digest and requires a new validation.",
|
||||||
"Invalid invoices retain diagnostics but cannot enter the valid-invoice handoff.",
|
"Invalid invoices retain diagnostics but cannot enter the valid-invoice handoff.",
|
||||||
"Technical failure produces unknown conformance, never a semantic rejection or acceptance.",
|
"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.",
|
"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.",
|
||||||
},
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"prerequisites": [
|
||||||
|
"Eine exakte KoSIT-Engine und eine eigenständige XRechnung-Konfiguration sind lokal installiert.",
|
||||||
|
"Die Administration hat alle Artefaktprüfsummen und die erwartete Zahl der Prüfschritte freigegeben.",
|
||||||
|
"Das Profil ist in der installationsbezogenen Positivliste für den Eingangszeitpunkt der Rechnung freigegeben.",
|
||||||
|
"Der fachlich verantwortliche Rechnungsablauf liefert einen stabilen Mandanten- und Quellverweis.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Ausführbare Datei, Engine-Prüfsumme, vollständige Prüfsumme der Konfigurationsstruktur und Szenariopfad prüfen.",
|
||||||
|
"Ein ausdrückliches Positivlistenprofil oder den ausdrücklich konfigurierten Installationsstandard auswählen.",
|
||||||
|
"Unsicheres oder übergroßes XML abweisen und anschließend das festgelegte Profil mit begrenzter Zeit und Ausgabe ausführen.",
|
||||||
|
"Technische Vollständigkeit getrennt von der semantischen Gültigkeit des Berichts prüfen.",
|
||||||
|
"Nur bei vollständigem, formal gültigem und die Rechnung annehmendem Bericht eine Übergabe erzeugen.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"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.",
|
||||||
|
}
|
||||||
|
},
|
||||||
order=100,
|
order=100,
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="xrechnung.reference.validation-profile-and-handoff",
|
||||||
|
title="XRechnung validation-profile and handoff consequences",
|
||||||
|
summary=(
|
||||||
|
"Understand receive-time profile selection, immutable artifact approval, "
|
||||||
|
"technical uncertainty, and the evidence boundary of a valid-invoice handoff."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"A validation profile names exact engine, configuration, scenario, and artifact "
|
||||||
|
"digests. Its approval has an actor, decision reference, receive-time window, and "
|
||||||
|
"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. 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."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("files", "procurement", "payments", "records", "audit"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Inbound validation profile",
|
||||||
|
href="docs/INBOUND_VALIDATION.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Folgen von XRechnung-Prüfprofil und Übergabe",
|
||||||
|
"summary": (
|
||||||
|
"Profilauswahl nach Eingangszeit, unveränderliche Artefaktfreigabe, technische Ungewissheit und die Nachweisgrenze einer gültigen Rechnungsübergabe verstehen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Ein Prüfprofil bezeichnet exakte Prüfsummen von Engine, Konfiguration, Szenario und Artefakten. Seine "
|
||||||
|
"Freigabe enthält Akteur, Entscheidungsverweis, Eingangszeitfenster und den Zustand freigegeben, gesperrt "
|
||||||
|
"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. 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."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"consequence_classes": {
|
||||||
|
"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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"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.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order=110,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
architecture=declared_module_architecture(
|
architecture=declared_module_architecture(
|
||||||
layer="data_reporting_integration",
|
layer="data_reporting_integration",
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import selectors
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from defusedxml import ElementTree
|
from defusedxml import ElementTree
|
||||||
@@ -31,6 +33,10 @@ class XRechnungValidationError(RuntimeError):
|
|||||||
"""Stable validation or configuration error without invoice contents."""
|
"""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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class KoSITValidationProfile:
|
class KoSITValidationProfile:
|
||||||
"""Exact locally installed validator and rule-set selection."""
|
"""Exact locally installed validator and rule-set selection."""
|
||||||
@@ -158,13 +164,10 @@ class KoSITValidator:
|
|||||||
if os.environ.get("JAVA_HOME"):
|
if os.environ.get("JAVA_HOME"):
|
||||||
env["JAVA_HOME"] = os.environ["JAVA_HOME"]
|
env["JAVA_HOME"] = os.environ["JAVA_HOME"]
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run( # noqa: S603 - absolute, digest-pinned executable and artifacts; no shell.
|
completed = _run_with_bounded_output(
|
||||||
command,
|
command,
|
||||||
cwd=self.profile.configuration_root,
|
cwd=self.profile.configuration_root,
|
||||||
env=env,
|
env=env,
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=self.timeout_seconds,
|
timeout=self.timeout_seconds,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -175,8 +178,7 @@ class KoSITValidator:
|
|||||||
"failed",
|
"failed",
|
||||||
"KoSIT validation timed out; no handoff is allowed.",
|
"KoSIT validation timed out; no handoff is allowed.",
|
||||||
)
|
)
|
||||||
output = completed.stdout + b"\n" + completed.stderr
|
except _RunnerOutputLimitExceeded:
|
||||||
if len(output) > MAX_RUNNER_OUTPUT_BYTES:
|
|
||||||
return _technical_result(
|
return _technical_result(
|
||||||
self.profile,
|
self.profile,
|
||||||
profile_sha256,
|
profile_sha256,
|
||||||
@@ -184,8 +186,12 @@ class KoSITValidator:
|
|||||||
"failed",
|
"failed",
|
||||||
"KoSIT runner output exceeded the safety limit.",
|
"KoSIT runner output exceeded the safety limit.",
|
||||||
)
|
)
|
||||||
|
output = completed.stdout + b"\n" + completed.stderr
|
||||||
report_path = workdir / "invoice-report.xml"
|
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(
|
return interpret_kosit_result(
|
||||||
profile=self.profile,
|
profile=self.profile,
|
||||||
profile_sha256=profile_sha256,
|
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:
|
def verify_profile(profile: KoSITValidationProfile) -> str:
|
||||||
if not profile.java_executable.is_file() or not os.access(profile.java_executable, os.X_OK):
|
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.")
|
raise XRechnungValidationError("The pinned Java executable is unavailable or not executable.")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import documentation_structured_translation_issues
|
||||||
|
from govoplan_xrechnung.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_xrechnung_german_workflow_and_reference_are_complete() -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
assert len(topics) == 2
|
||||||
|
assert topics["xrechnung.inbound-validation"].metadata["kind"] == "workflow"
|
||||||
|
assert topics["xrechnung.reference.validation-profile-and-handoff"].metadata["kind"] == "reference"
|
||||||
|
for topic in topics.values():
|
||||||
|
assert all(topic.translations["de"].get(key) for key in ("title", "summary", "body"))
|
||||||
|
assert topic.structured_translation_version == "1"
|
||||||
|
assert "de" in topic.structured_translations
|
||||||
|
assert documentation_structured_translation_issues(topic) == ()
|
||||||
+234
-1
@@ -1,15 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_xrechnung.backend import validation
|
||||||
from govoplan_xrechnung.backend.manifest import get_manifest
|
from govoplan_xrechnung.backend.manifest import get_manifest
|
||||||
from govoplan_xrechnung.backend.validation import (
|
from govoplan_xrechnung.backend.validation import (
|
||||||
InboundInvoice,
|
InboundInvoice,
|
||||||
KoSITValidationProfile,
|
KoSITValidationProfile,
|
||||||
|
KoSITValidator,
|
||||||
|
MAX_RUNNER_OUTPUT_BYTES,
|
||||||
XRechnungValidationError,
|
XRechnungValidationError,
|
||||||
configuration_tree_sha256,
|
configuration_tree_sha256,
|
||||||
create_validated_handoff,
|
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:
|
def test_profile_verifies_exact_engine_and_complete_configuration_tree(tmp_path: Path) -> None:
|
||||||
profile = _profile(tmp_path)
|
profile = _profile(tmp_path)
|
||||||
first = verify_profile(profile)
|
first = verify_profile(profile)
|
||||||
@@ -154,5 +387,5 @@ def test_technical_failures_never_trust_a_valid_looking_report(
|
|||||||
|
|
||||||
def test_manifest_does_not_select_an_active_standard_version() -> None:
|
def test_manifest_does_not_select_an_active_standard_version() -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
assert manifest.version == "0.1.20"
|
assert manifest.version == "0.1.21"
|
||||||
assert "none is activated by default" in manifest.architecture.known_limits[0].lower()
|
assert "none is activated by default" in manifest.architecture.known_limits[0].lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user