fix(files): isolate archive workers and make handoff event driven
This commit is contained in:
@@ -682,6 +682,54 @@ and restart workers after installation. Both paths retain complete header and
|
||||
path validation, selection and actual output limits; the native path also
|
||||
independently checks size and CRC and never extracts to filesystem paths.
|
||||
|
||||
All ZIP/TAR metadata parsing (including ZIP central directories and TAR PAX
|
||||
headers) and decoding now run in fresh, credential-stripped child processes.
|
||||
Preview/password checks have 120 seconds wall time, 90 CPU seconds and 512 MiB
|
||||
address space. Confirmation uses one child for the entire archive, with 600
|
||||
seconds wall time and 300 CPU seconds. Its address-space limit is the larger of
|
||||
512 MiB or `3 × configured member limit + 128 MiB`. A member limit must be positive
|
||||
and at most 2 GiB; the default 50 MiB member limit and the existing 250 MiB request,
|
||||
10,000-entry, 2 GiB expanded and 100:1 limits are unchanged. The kernel file-size
|
||||
ceiling is `max(member limit, 16 KiB)`; actual member/cumulative bytes are checked
|
||||
independently. Metadata transport has a 64 MiB ceiling, final extraction receipts
|
||||
64 KiB, and status records 16 KiB. Raw archive/member bytes are not pipe DTOs.
|
||||
|
||||
`GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` is shared with other isolated module work:
|
||||
one admitted operation per API/worker process by default, configurable from one
|
||||
to 16. Admission covers source snapshot preparation before starting the child,
|
||||
so a busy request does not first copy its source. Capacity is not global across
|
||||
replicas. Include each replica's address-space and temporary-disk budgets when
|
||||
sizing the deployment. Resource controls are required; there is no inline parser
|
||||
fallback. These are process resource limits, not a filesystem/network sandbox or
|
||||
an aggregate cgroup memory guarantee.
|
||||
|
||||
Each admitted operation creates a separate local OS-temporary `0700` directory
|
||||
and `0600` source snapshot, beyond the upload-once work-root quota described
|
||||
above. Plan additional capacity for one compressed source copy and at most one
|
||||
extracted member per admitted archive. Snapshot copying is bounded to the initial
|
||||
regular-file size and rejects changed sources. The staging wrapper writes only
|
||||
numbered internal filenames, never archive names. The parent rechecks selected
|
||||
logical paths, regular/no-symlink file identity, size, digest and monotonic
|
||||
progress, persists the member with the existing Files authority/transaction, and
|
||||
deletes it before acknowledging the child to decode the next member. A private
|
||||
`0600` FIFO carries one eight-byte sequence acknowledgement; completed members
|
||||
wake the parent immediately, with no fixed per-member sleep. Wake notifications
|
||||
are capped at 32 KiB; configurations allowing more than 32,768 members use Core's
|
||||
ordinary progress polling after that budget, still within the wall-time limit.
|
||||
Passwords remain request-only and are never written to these files. Handled success/error
|
||||
paths reap the child and remove the private directory. Abrupt host/parent loss
|
||||
can leave private temporary files; use the deployment's OS-temporary cleanup
|
||||
policy without deleting active work.
|
||||
|
||||
The confirmation wall budget includes time waiting for parent storage; a slow
|
||||
destination may exhaust it after earlier members were staged for persistence.
|
||||
Existing rollback and blob recovery remain authoritative. Busy capacity, missing
|
||||
controls, CPU/memory/time/output limits, or invalid staging records produce a
|
||||
controlled failure; split the archive or explicitly retry after resolving the
|
||||
cause. There is no resumable worker or automatic confirmation retry. An optional
|
||||
UI progress-write failure remains non-fatal, but a failed private worker status
|
||||
or acknowledgement channel must abort safely.
|
||||
|
||||
Verified members are read and stored one at a time, avoiding a whole expanded
|
||||
archive in memory. Storage-client reuse is confined to one archive operation;
|
||||
authorization, tenant isolation and policy are not cached. Batched response
|
||||
|
||||
@@ -184,6 +184,7 @@ def _archive_topic(
|
||||
"A requested preview may safely reupload the selected file if its stage expired; confirmation is never retried automatically. "
|
||||
"Unsafe paths and special filesystem entries are rejected. Actual extracted bytes are counted instead of trusting archive headers. "
|
||||
"Archive paths are limited to 4096 UTF-8 bytes and 128 components; derived parent directories count toward the entry limit. TAR inspection checks each header's size and ratio before advancing across that member's payload. "
|
||||
"Metadata parsing and decoding run in disposable resource-limited children, including native ZIP decoding. Preview allows 120 seconds and confirmation 600 seconds; busy workers and CPU, memory, time or transport limits fail closed. Split a rejected archive or explicitly retry later; confirmation is not automatically retried. "
|
||||
"The shared blurred dialog overlay separates upload transfer, inspection, extraction/storage and final commit, without an envelope animation. "
|
||||
"Selected file/byte totals appear immediately and extraction progress uses actual server counters. Unknown progress is indeterminate; completion is shown only after the transaction succeeds. "
|
||||
"Keep the dialog open while processing; errors release the overlay and preserve the selection for review."
|
||||
@@ -200,6 +201,7 @@ def _archive_topic(
|
||||
"Bei einer angeforderten Vorschau darf die Oberfläche die ausgewählte Datei nach Ablauf erneut übertragen; die Bestätigung wird niemals automatisch wiederholt. "
|
||||
"Unsichere Pfade und besondere Dateisystemeinträge werden abgelehnt; tatsächlich gelesene Bytes werden gezählt. "
|
||||
"Archivpfade sind auf 4096 UTF-8-Bytes und 128 Komponenten begrenzt; abgeleitete übergeordnete Ordner zählen zur Eintragsgrenze. Die TAR-Prüfung kontrolliert Größe und Expansionsverhältnis bei jedem Header, bevor dessen Nutzdaten übersprungen werden. "
|
||||
"Metadatenprüfung und Dekodieren einschließlich nativem ZIP laufen in kurzlebigen ressourcenbegrenzten Kindprozessen. Für die Vorschau gelten 120 Sekunden, für die Bestätigung 600 Sekunden; ausgelastete Worker sowie CPU-, Speicher-, Zeit- oder Transportgrenzen führen zum sicheren Abbruch. Ein abgelehntes Archiv aufteilen oder später ausdrücklich erneut versuchen; die Bestätigung wird nicht automatisch wiederholt. "
|
||||
"Die gemeinsame Überlagerung des weichgezeichneten Dialogs unterscheidet Übertragung, Prüfung, Entpacken/Speichern und abschließende Transaktionsbestätigung, ohne Briefumschlaganimation. "
|
||||
"Ausgewählte Datei- und Bytezahlen erscheinen sofort; der Entpackfortschritt nutzt tatsächliche Serverzähler. Unbekannter Fortschritt bleibt unbestimmt, der Abschluss wird erst nach erfolgreicher Transaktion angezeigt. "
|
||||
"Den Dialog während der Verarbeitung geöffnet lassen; Fehler entfernen die Überlagerung und erhalten die Auswahl zur Prüfung."
|
||||
|
||||
@@ -127,7 +127,7 @@ _TRANSLATIONS = {
|
||||
"Core stellt das gemeinsame lokale/S3-Objektspeicher-Backend bereit; Files verantwortet Dateimetadaten und Objektschlüssel. Lokaler Speicher eignet sich für einen Laufzeitprozess, ein gemeinsames Host-Volume für Replikate auf demselben Host. Unabhängige Hosts benötigen einen ausdrücklich vertrauenswürdigen HTTPS-S3-kompatiblen Endpunkt. PostgreSQL, Objekte und Hauptschlüssel müssen auf denselben abgestimmten Recovery-Zeitpunkt wiederhergestellt werden. "
|
||||
"Die Archivoberfläche fordert ausdrücklich einmalige Übertragung mit Zwischenspeicherung an: Erneute Passwortprüfung und Bestätigung verwenden eine an Mandant und Person gebundene Kopie, speichern jedoch niemals Passwörter. Temporäre Archiv- und Fortschrittsdateien haben Modus 0600 unter einem privaten Verzeichnis mit Modus 0700. FILE_ARCHIVE_WORK_ROOT ersetzt den Standard im temporären Betriebssystemverzeichnis, dessen Name von Dienst-UID und Hash des konfigurierten Speicherwurzelpfads abgeleitet wird. Alle Worker desselben Hosts müssen darauf zugreifen; unabhängige Hosts benötigen gemeinsamen POSIX-Speicher mit funktionierenden flock-Sperren oder feste Host-Zuordnung für Archiv- und Fortschrittsanfragen, auch bei dauerhaftem S3-Blob-Speicher. "
|
||||
"Standardgrenzen sind 2 GiB zwischengespeicherte Archive pro Arbeitsverzeichnis, vier Kopien pro Mandant/Person und 1.800 Sekunden ab der ersten Speicherung ohne Verlängerung. Abbruch- und Wechselanfragen sowie erfolgreicher Abschluss geben Kopien frei; verlassene abgelaufene Kopien werden bei späterer Archivverarbeitung bereinigt, nicht garantiert zu einem exakten Zeitpunkt ohne weitere Aktivität. Aktive POSIX-Leases schützen benutzte Kopien auch nach Ablauf vor Verdrängung. Kopien und Fortschrittsdaten sind keine fortsetzbaren Hintergrundaufträge. "
|
||||
"Eine optionale, aktuell gehaltene Systembibliothek libarchive beschleunigt klassisches ZIPCrypto mit gespeicherten oder Deflate-komprimierten Einträgen und ASCII- oder ausdrücklich als UTF-8 markierten Namen. Fehlende Bibliotheken, AES und nicht unterstützte Kodierungen/Formate wählen vor dem Dekodieren den bisherigen Python-Leser; native Fehler führen zum sicheren Abbruch. Vollständige Header-, Pfad- und Grenzprüfungen bleiben erhalten; der native Pfad prüft CRC und Größe zusätzlich unabhängig und entpackt niemals direkt ins Dateisystem. "
|
||||
"Eine optionale, aktuell gehaltene Systembibliothek libarchive beschleunigt klassisches ZIPCrypto mit gespeicherten oder Deflate-komprimierten Einträgen und ASCII- oder ausdrücklich als UTF-8 markierten Namen. Fehlende Bibliotheken, AES und nicht unterstützte Kodierungen/Formate wählen vor dem Dekodieren den bisherigen Python-Leser; native Fehler führen zum sicheren Abbruch. Beide Leser laufen in einem begrenzten Kindprozess mit vollständigen Header-, Pfad- und Grenzprüfungen; der native Pfad prüft CRC und Größe zusätzlich unabhängig. Nur die Zwischenspeicherschicht schreibt Dateien unter privaten servergenerierten numerischen Namen, niemals unter Archivpfaden. "
|
||||
"Die Verarbeitung speichert jeweils einen geprüften Eintrag. Wiederverwendung des Speicher-Clients innerhalb eines Archivs und gebündelte Antwortmetadaten vermeiden Zusatzarbeit, ohne Autorisierung zwischenzuspeichern oder dauerhafte Recovery-, Integritäts- und Commit-Prüfungen pro Datei zu entfernen. Fortschritt zeigt tatsächliche Datei-/Bytezähler und eine eigene Abschlussphase statt Zeitschätzungen oder vorzeitiger Erfolgsmeldung. Ein schnellerer Decoder verspricht keine entsprechende Beschleunigung des gesamten Imports oder von S3."
|
||||
),
|
||||
},
|
||||
|
||||
@@ -652,6 +652,56 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=localize_documentation_topics((
|
||||
DocumentationTopic(
|
||||
id="files.archive-worker-limits",
|
||||
title="Bound archive inspection and extraction work",
|
||||
summary="ZIP and TAR parsers run in disposable resource-limited processes while Files retains authorization and storage.",
|
||||
body=(
|
||||
"Archive preview and password verification run in a fresh child with 120 seconds wall time, 90 CPU seconds and 512 MiB address space. "
|
||||
"Confirmation uses one child for the entire archive, including metadata validation and native or Python decoding, with 600 seconds wall time and 300 CPU seconds. "
|
||||
"Its address-space ceiling is the larger of 512 MiB or three times the configured member limit plus 128 MiB; the supported member limit is positive and at most 2 GiB. "
|
||||
"The ordinary defaults remain 50 MiB per member, 250 MiB compressed request, 10,000 entries, 2 GiB expanded and 100:1 expansion. "
|
||||
"Core's GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY admits archive snapshot creation and processing together with other isolated module work; its default is one slot per API/worker process, configurable from one to 16. "
|
||||
"Budget every replica separately. A busy request fails before copying its source and must be retried explicitly; missing process controls, CPU/memory/time/output limits and invalid staging records fail closed without inline parsing. "
|
||||
"Provide OS-temporary disk capacity for one additional compressed source snapshot and one extracted member per admitted archive, beyond the upload-once staging quota. "
|
||||
"Private 0700 directories contain only server-generated 0600 filenames. The child waits for parent storage and deletion of each verified member before decoding the next. A private FIFO carries one eight-byte sequence acknowledgement; completed members wake the parent without a fixed per-file sleep. Wake bytes are capped at 32 KiB; larger non-default entry counts use ordinary progress polling thereafter. "
|
||||
"Member paths, selection, regular-file identity, size, digest and monotonic file/byte progress are rechecked by the parent. Passwords are request-only and not written to staging. "
|
||||
"Metadata transport is bounded to 64 MiB and status records to 16 KiB; the child file-size limit is the greater of the member limit or 16 KiB, with actual member and cumulative limits checked independently. "
|
||||
"Slow destination storage consumes the confirmation wall-time budget. Failures use existing transaction rollback/recovery; successful decoding alone is not a committed import, and confirmation is never retried automatically. "
|
||||
"The child has no inherited application credentials or SQL session. These are process resource controls, not a filesystem/network sandbox or an aggregate cgroup memory guarantee."
|
||||
),
|
||||
translations={"de": {
|
||||
"title": "Archivprüfung und Entpacken begrenzen",
|
||||
"summary": "ZIP- und TAR-Parser laufen in kurzlebigen ressourcenbegrenzten Prozessen; Files behält Autorisierung und Speicherung.",
|
||||
"body": (
|
||||
"Archivvorschau und Passwortprüfung laufen in einem frischen Kindprozess mit 120 Sekunden Laufzeit, 90 CPU-Sekunden und 512 MiB Adressraum. "
|
||||
"Die Bestätigung verwendet einen Kindprozess für das gesamte Archiv einschließlich Metadatenprüfung und nativem oder Python-Dekodieren, mit 600 Sekunden Laufzeit und 300 CPU-Sekunden. "
|
||||
"Seine Adressraumgrenze ist das Maximum aus 512 MiB und dem Dreifachen der konfigurierten Dateigrenze plus 128 MiB; die unterstützte Dateigrenze ist positiv und höchstens 2 GiB. "
|
||||
"Die üblichen Standardgrenzen bleiben 50 MiB pro Datei, 250 MiB komprimierte Anfrage, 10.000 Einträge, 2 GiB entpackte Daten und 100:1 Expansion. "
|
||||
"GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY von Core begrenzt Quellkopie und Verarbeitung gemeinsam mit isolierter Arbeit anderer Module: standardmäßig ein Platz pro API-/Worker-Prozess, konfigurierbar von eins bis 16. "
|
||||
"Jedes Replikat separat budgetieren. Bei Auslastung wird vor der Quellkopie abgelehnt; ein neuer Versuch muss ausdrücklich erfolgen. Fehlende Prozesskontrollen, CPU-/Speicher-/Zeit-/Ausgabegrenzen und ungültige Zwischenspeicherdaten führen zum sicheren Abbruch ohne Parsing im Elternprozess. "
|
||||
"Zusätzlich zur Quote für einmalig hochgeladene Archive benötigt jedes zugelassene Archiv im temporären Betriebssystemverzeichnis Platz für eine weitere komprimierte Quellkopie und eine entpackte Datei. "
|
||||
"Private Verzeichnisse mit Modus 0700 enthalten ausschließlich servergenerierte Dateinamen mit Modus 0600. Der Kindprozess wartet nach jeder geprüften Datei auf Speicherung und Löschung durch den Elternprozess, bevor er die nächste dekodiert. Ein privater FIFO überträgt eine acht Byte lange Sequenzbestätigung; fertige Dateien wecken den Elternprozess ohne feste Pause pro Datei. Wecksignale sind auf 32 KiB begrenzt; größere abweichend konfigurierte Eintragszahlen nutzen danach die gewöhnliche Fortschrittsabfrage. "
|
||||
"Dieser prüft Pfade, Auswahl, reguläre Dateiidentität, Größe, Prüfsumme und monotonen Datei-/Bytefortschritt erneut. Passwörter bleiben auf die Anfrage beschränkt und werden nicht zwischengespeichert. "
|
||||
"Der Metadatentransport ist auf 64 MiB, Statusdatensätze auf 16 KiB begrenzt; die Dateigrößengrenze des Kindprozesses ist das Maximum aus Dateilimit und 16 KiB. Tatsächliche Einzel- und Gesamtgrößen werden unabhängig kontrolliert. "
|
||||
"Langsamer Zielspeicher zählt zur Laufzeit der Bestätigung. Fehler nutzen bestehendes Transaktions-Rollback und Recovery; erfolgreiches Dekodieren ist kein bestätigter Import und die Bestätigung wird nie automatisch wiederholt. "
|
||||
"Der Kindprozess erbt weder Anwendungszugangsdaten noch SQL-Sitzungen. Die Kontrollen begrenzen Prozessressourcen, sind aber keine Dateisystem-/Netzwerk-Sandbox und keine aggregierte cgroup-Speichergarantie."
|
||||
),
|
||||
}},
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("file_user", "file_admin", "operator"),
|
||||
configuration_keys=("GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY",),
|
||||
conditions=(DocumentationCondition(
|
||||
required_modules=("files",),
|
||||
any_scopes=("files:file:upload", "files:file:admin", "system:settings:read"),
|
||||
),),
|
||||
links=(
|
||||
DocumentationLink(label="Files", href="/files", kind="runtime"),
|
||||
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
order=28,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="files.tabular-content",
|
||||
title="Use managed CSV and XLSX versions as governed data sources",
|
||||
@@ -1775,7 +1825,7 @@ manifest = ModuleManifest(
|
||||
"Core supplies the common local/S3 object-storage backend while Files owns file metadata and object-key semantics. Local storage is valid for one runtime process; a shared host volume supports same-host replicas; independent hosts require an explicitly trusted HTTPS S3-compatible endpoint. Restore PostgreSQL, objects, and the master key to one coordinated recovery point. "
|
||||
"The archive UI opts into upload-once staging: password repreview and confirmation reuse a tenant/user-bound copy, but never persist passwords. Temporary archive and progress files use mode 0600 below a private 0700 directory. FILE_ARCHIVE_WORK_ROOT overrides the default OS-temporary directory derived from the service uid and configured storage-root hash. All same-host workers must share it; independent hosts need shared POSIX storage with working flock locks or sticky routing for archive and progress requests, even when durable blobs use S3. "
|
||||
"Defaults are 2 GiB of staged archives per work root, four stages per tenant/user, and 1,800 seconds from initial staging without renewal. Cancel/replacement requests and successful confirmation release copies; abandoned expired copies are cleaned opportunistically by later archive work, not on an exact idle deadline. POSIX active leases prevent eviction of an in-use copy even after its TTL; stages and progress are not resumable background jobs. "
|
||||
"A maintained optional system libarchive accelerates classic stored/deflated ZIPCrypto decoding for ASCII or UTF-8-flagged names. Missing libraries, AES and unsupported encodings/formats select the original Python reader before decoding; native errors fail closed. Both paths retain full header/path/limit checks, and the native path independently verifies CRC and size without filesystem extraction. "
|
||||
"A maintained optional system libarchive accelerates classic stored/deflated ZIPCrypto decoding for ASCII or UTF-8-flagged names. Missing libraries, AES and unsupported encodings/formats select the original Python reader before decoding; native errors fail closed. Both readers run in a bounded child and retain full header/path/limit checks; the native path independently verifies CRC and size. Only the staging wrapper writes files, using private server-generated numeric names, never archive paths. "
|
||||
"Extraction stores one verified member at a time. Archive-local backend-client reuse and batched response metadata reduce avoidable work without caching authorization or removing durable per-file recovery, integrity or commit verification. Progress reports actual file/byte counters and a separate finalization phase, never a timed estimate or an early success. Native decoder speed does not promise an equivalent whole-import or S3 improvement."
|
||||
),
|
||||
layer="configured",
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Disposable archive parsers with bounded, acknowledged member staging.
|
||||
|
||||
This is a resource boundary for server-owned parsers, not a filesystem/network
|
||||
sandbox. Only the parent owns database state, credentials and durable storage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager, closing
|
||||
from dataclasses import asdict, replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
|
||||
from govoplan_core.security.bounded_process import (
|
||||
ProcessBudgetError,
|
||||
ProcessLimits,
|
||||
bounded_operation_admission,
|
||||
run_bounded_operation,
|
||||
)
|
||||
from govoplan_core.security.worker_payload import (
|
||||
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
|
||||
)
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
|
||||
|
||||
INSPECTION_LIMITS = ProcessLimits(
|
||||
wall_seconds=120, cpu_seconds=90, memory_bytes=512 * 1024 * 1024,
|
||||
input_bytes=64 * 1024 * 1024, output_bytes=64 * 1024 * 1024,
|
||||
)
|
||||
EXTRACTION_LIMITS = ProcessLimits(
|
||||
wall_seconds=600, cpu_seconds=300, memory_bytes=512 * 1024 * 1024,
|
||||
input_bytes=64 * 1024 * 1024, output_bytes=64 * 1024,
|
||||
file_bytes=50 * 1024 * 1024,
|
||||
)
|
||||
_STATUS_BYTES = 16 * 1024
|
||||
_MAX_INTEGER = 2**63 - 1
|
||||
_MAX_WAKE_BYTES = 32 * 1024
|
||||
|
||||
|
||||
def _failure(message="Archive worker returned an invalid staging record"):
|
||||
return FileStorageError(message)
|
||||
|
||||
|
||||
def _read_regular(path: Path, maximum: int, *, atomic_record: bool = False) -> bytes:
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
allowed_links = {0, 1} if atomic_record else {1}
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink not in allowed_links or info.st_size > maximum:
|
||||
raise _failure()
|
||||
except BaseException:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
with os.fdopen(descriptor, "rb") as source:
|
||||
# BufferedReader.read(n) can reserve n bytes even for a tiny file. Use
|
||||
# the validated actual size, never the potentially multi-GiB policy cap.
|
||||
value = source.read(info.st_size + 1)
|
||||
after = os.fstat(source.fileno())
|
||||
if (len(value) != info.st_size or after.st_nlink not in allowed_links
|
||||
or _fingerprint(info, atomic_record=atomic_record) != _fingerprint(after, atomic_record=atomic_record)):
|
||||
raise _failure()
|
||||
return value
|
||||
|
||||
|
||||
def _fingerprint(info, *, atomic_record=False):
|
||||
# Replacing a status/ack atomically may unlink an already-open old inode and
|
||||
# update its ctime; its content, size and mtime must nevertheless be stable.
|
||||
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns,
|
||||
None if atomic_record else (info.st_nlink, info.st_ctime_ns))
|
||||
|
||||
|
||||
def _write_record(directory: Path, name: str, value: object) -> None:
|
||||
# name is a server constant: neither member names nor worker DTO paths.
|
||||
payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
if len(payload) > _STATUS_BYTES:
|
||||
raise _failure()
|
||||
temporary = directory / f".{name}.tmp"
|
||||
try:
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
output.write(payload)
|
||||
os.replace(temporary, directory / name)
|
||||
except OSError as exc:
|
||||
raise _failure("Archive worker staging channel is unavailable") from exc
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass # The owning private-directory context retries final cleanup.
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _ack_channel(directory: Path, *, create: bool = False):
|
||||
"""One fixed private FIFO; archive names never select a channel or file."""
|
||||
path = directory / "ack"
|
||||
descriptor = None
|
||||
try:
|
||||
try:
|
||||
if create:
|
||||
os.mkfifo(path, 0o600)
|
||||
flags = os.O_RDWR if create else os.O_RDONLY
|
||||
descriptor = os.open(path, flags | os.O_NONBLOCK | os.O_NOFOLLOW)
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISFIFO(info.st_mode) or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600:
|
||||
raise _failure()
|
||||
if not create:
|
||||
# Validate before blocking: a substituted regular file or FIFO
|
||||
# must not hang the open. The parent holds a RDWR handle.
|
||||
os.set_blocking(descriptor, True)
|
||||
except OSError as exc:
|
||||
raise _failure("Archive worker staging channel is unavailable") from exc
|
||||
yield descriptor
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _send_ack(descriptor: int, sequence: int) -> None:
|
||||
try:
|
||||
# Eight bytes are within PIPE_BUF: one nonblocking write is atomic.
|
||||
if os.write(descriptor, sequence.to_bytes(8, "big")) != 8:
|
||||
raise _failure()
|
||||
except OSError as exc:
|
||||
raise _failure("Archive worker staging channel is unavailable") from exc
|
||||
|
||||
|
||||
def _receive_ack(descriptor: int, sequence: int) -> None:
|
||||
value = b""
|
||||
while len(value) < 8:
|
||||
chunk = os.read(descriptor, 8 - len(value))
|
||||
if not chunk:
|
||||
raise _failure()
|
||||
value += chunk
|
||||
if int.from_bytes(value, "big") != sequence:
|
||||
raise _failure()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _source_stage(archive_data):
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
raise FileStorageError("Required isolated-worker resource controls are unavailable")
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-archive-worker-") as temporary:
|
||||
directory = Path(temporary)
|
||||
os.chmod(directory, 0o700)
|
||||
source_path = directory / "source"
|
||||
descriptor = os.open(source_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as target:
|
||||
if isinstance(archive_data, bytes):
|
||||
target.write(archive_data)
|
||||
else:
|
||||
source_descriptor = os.open(os.fspath(archive_data), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(source_descriptor, "rb") as source:
|
||||
before = os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(before.st_mode):
|
||||
raise FileStorageError("Archive source must be a regular file")
|
||||
# Never follow a growing producer to EOF. Callers apply
|
||||
# the compressed request cap before supplying this path.
|
||||
remaining = before.st_size
|
||||
while remaining:
|
||||
chunk = source.read(min(remaining, 1024 * 1024))
|
||||
if not chunk:
|
||||
raise FileStorageError("Archive source changed during inspection")
|
||||
target.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
extra = source.read(1)
|
||||
after = os.fstat(source.fileno())
|
||||
if extra or _fingerprint(before) != _fingerprint(after):
|
||||
raise FileStorageError("Archive source changed during inspection")
|
||||
except OSError as exc:
|
||||
raise FileStorageError("Archive source could not be staged safely") from exc
|
||||
yield directory, source_path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _admitted_stage(archive_data):
|
||||
# Share Core capacity with other expensive module work before copying any
|
||||
# source bytes, not just after preparation has already consumed resources.
|
||||
try:
|
||||
with bounded_operation_admission() as admission, _source_stage(archive_data) as (directory, source):
|
||||
yield directory, source, admission
|
||||
except ProcessBudgetError as exc:
|
||||
raise FileStorageError(f"Archive processing failed ({exc.code}): {exc}") from exc
|
||||
|
||||
|
||||
def _run(operation, data, *, limits, tick=None, admission=None):
|
||||
try:
|
||||
payload = encode_worker_payload(data, max_bytes=limits.input_bytes)
|
||||
wire = run_bounded_operation(operation, payload, limits=limits, cancelled=tick, admission=admission)
|
||||
result = decode_worker_payload(wire, max_bytes=limits.output_bytes)
|
||||
except ProcessBudgetError as exc:
|
||||
raise FileStorageError(f"Archive processing failed ({exc.code}): {exc}") from exc
|
||||
except WorkerPayloadError as exc:
|
||||
raise FileStorageError("Archive worker data exceeded safe transport limits") from exc
|
||||
if type(result) is not dict:
|
||||
raise _failure()
|
||||
if "error" in result:
|
||||
from govoplan_files.backend.storage.archives import ArchivePasswordError
|
||||
if type(result.get("error")) is not str or type(result.get("password_error")) is not bool:
|
||||
raise _failure()
|
||||
error_type = ArchivePasswordError if result["password_error"] else FileStorageError
|
||||
raise error_type(result["error"])
|
||||
return result
|
||||
|
||||
|
||||
def inspect_archive_isolated(archive_data, **options):
|
||||
from govoplan_files.backend.storage.archives import ArchiveEntry, ArchiveInspection, archive_format_for_filename
|
||||
archive_format_for_filename(options["filename"])
|
||||
with _admitted_stage(archive_data) as (_directory, source, admission):
|
||||
result = _run(_inspect_worker, {"source": str(source), "options": options},
|
||||
limits=INSPECTION_LIMITS, admission=admission)
|
||||
inspection = result.get("inspection")
|
||||
if type(inspection) is not dict or type(inspection.get("entries")) is not tuple:
|
||||
raise _failure()
|
||||
if len(inspection["entries"]) > options["max_entries"]:
|
||||
raise _failure()
|
||||
try:
|
||||
inspection["entries"] = tuple(ArchiveEntry(**entry) for entry in inspection["entries"])
|
||||
return ArchiveInspection(**inspection)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise _failure() from exc
|
||||
|
||||
|
||||
def _inspect_worker(payload: bytes) -> bytes:
|
||||
from govoplan_files.backend.storage.archives import ArchivePasswordError, _inspect_archive_content
|
||||
data = decode_worker_payload(payload, max_bytes=INSPECTION_LIMITS.input_bytes)
|
||||
try:
|
||||
inspection = _inspect_archive_content(data["source"], **data["options"])
|
||||
result = {"inspection": asdict(inspection)}
|
||||
except FileStorageError as exc:
|
||||
result = {"error": str(exc), "password_error": isinstance(exc, ArchivePasswordError)}
|
||||
return encode_worker_payload(result, max_bytes=INSPECTION_LIMITS.output_bytes)
|
||||
|
||||
|
||||
def extract_archive_isolated(session, *, archive_data, filename, password, selected_paths,
|
||||
max_entries, max_file_bytes, max_expanded_bytes,
|
||||
max_expansion_ratio, progress, store_options):
|
||||
from govoplan_files.backend.storage.archives import (
|
||||
_safe_member_path, _store_archive_members, archive_format_for_filename,
|
||||
)
|
||||
from govoplan_files.backend.storage.files import archive_storage_backend_scope
|
||||
archive_format_for_filename(filename)
|
||||
if type(max_file_bytes) is not int or not 0 < max_file_bytes <= 2 * 1024 * 1024 * 1024:
|
||||
raise FileStorageError("Isolated archive members require a positive limit of at most 2 GiB")
|
||||
limits = replace(
|
||||
EXTRACTION_LIMITS,
|
||||
memory_bytes=max(EXTRACTION_LIMITS.memory_bytes, 3 * max_file_bytes + 128 * 1024 * 1024),
|
||||
file_bytes=max(max_file_bytes, _STATUS_BYTES),
|
||||
)
|
||||
uploaded = []
|
||||
stored_bytes = 0
|
||||
sequence = 0
|
||||
seen_paths = set()
|
||||
last_extracted_bytes = 0
|
||||
last_extracted_files = 0
|
||||
expected_totals = None
|
||||
selected = None if selected_paths is None else {_safe_member_path(path) for path in selected_paths}
|
||||
if progress:
|
||||
progress("inspecting", 0, 0, 0, 0)
|
||||
with (
|
||||
_admitted_stage(archive_data) as (directory, source, admission),
|
||||
_ack_channel(directory, create=True) as acknowledgement,
|
||||
archive_storage_backend_scope(),
|
||||
):
|
||||
actual_total_limit = min(max_expanded_bytes, source.stat().st_size * max_expansion_ratio)
|
||||
|
||||
def poll():
|
||||
nonlocal stored_bytes, sequence, last_extracted_bytes, last_extracted_files, expected_totals
|
||||
try:
|
||||
record = json.loads(_read_regular(directory / "status", _STATUS_BYTES, atomic_record=True))
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except (OSError, ValueError, RecursionError) as exc:
|
||||
raise _failure() from exc
|
||||
if type(record) is not dict or type(record.get("sequence")) is not int:
|
||||
raise _failure()
|
||||
next_sequence = record["sequence"]
|
||||
if not 0 < next_sequence <= _MAX_INTEGER or next_sequence < sequence:
|
||||
raise _failure()
|
||||
if next_sequence == sequence:
|
||||
return False
|
||||
kind = record.get("kind")
|
||||
counters = record.get("progress")
|
||||
if kind not in {"progress", "member"} or type(counters) is not list or len(counters) != 5:
|
||||
raise _failure()
|
||||
keys = {"sequence", "kind", "progress"}
|
||||
if kind == "member":
|
||||
keys |= {"path", "size", "sha256"}
|
||||
if set(record) != keys:
|
||||
raise _failure()
|
||||
phase, completed, total, completed_bytes, total_bytes = counters
|
||||
if phase != "extracting" or any(type(value) is not int for value in counters[1:]):
|
||||
raise _failure()
|
||||
if not (last_extracted_files <= completed <= total <= max_entries
|
||||
and 0 <= total_bytes <= actual_total_limit
|
||||
and last_extracted_bytes <= completed_bytes <= total_bytes):
|
||||
raise _failure()
|
||||
if expected_totals is not None and expected_totals != (total, total_bytes):
|
||||
raise _failure()
|
||||
if completed not in {len(uploaded), len(uploaded) + 1}:
|
||||
raise _failure()
|
||||
sequence = next_sequence
|
||||
last_extracted_bytes = completed_bytes
|
||||
last_extracted_files = completed
|
||||
expected_totals = total, total_bytes
|
||||
if progress:
|
||||
progress(*counters)
|
||||
if kind == "progress":
|
||||
return False
|
||||
if completed != len(uploaded) + 1 or type(record.get("path")) is not str:
|
||||
raise _failure()
|
||||
inner_path = _safe_member_path(record["path"])
|
||||
if inner_path != record["path"] or inner_path in seen_paths:
|
||||
raise _failure()
|
||||
if selected is not None and not any(inner_path == path or inner_path.startswith(path + "/") for path in selected):
|
||||
raise _failure()
|
||||
size = record.get("size")
|
||||
if type(size) is not int or not 0 <= size <= max_file_bytes or stored_bytes + size != completed_bytes:
|
||||
raise _failure()
|
||||
member_path = directory / f"member-{completed:08d}"
|
||||
try:
|
||||
data = _read_regular(member_path, max_file_bytes)
|
||||
except OSError as exc:
|
||||
raise _failure() from exc
|
||||
if len(data) != size or hashlib.sha256(data).hexdigest() != record.get("sha256"):
|
||||
raise _failure()
|
||||
# The established persistence API accepts bytes, not a stream/path.
|
||||
# Keep one member resident, with the existing transaction and quotas.
|
||||
uploaded.extend(_store_archive_members(session, members=((inner_path, data),), **store_options))
|
||||
stored_bytes += len(data)
|
||||
seen_paths.add(inner_path)
|
||||
del data
|
||||
try:
|
||||
member_path.unlink()
|
||||
except OSError as exc:
|
||||
raise _failure("Archive worker staging channel is unavailable") from exc
|
||||
if progress:
|
||||
progress("storing", len(uploaded), total, stored_bytes, total_bytes)
|
||||
_send_ack(acknowledgement, sequence)
|
||||
return False
|
||||
|
||||
result = _run(_extract_worker, {
|
||||
"source": str(source), "directory": str(directory),
|
||||
"options": {"filename": filename, "password": password, "max_entries": max_entries,
|
||||
"max_expanded_bytes": max_expanded_bytes, "max_expansion_ratio": max_expansion_ratio},
|
||||
"selected_paths": None if selected is None else tuple(selected),
|
||||
"max_file_bytes": max_file_bytes,
|
||||
}, limits=limits, tick=poll, admission=admission)
|
||||
if type(result.get("completed")) is not int or result["completed"] != len(uploaded):
|
||||
raise _failure()
|
||||
return uploaded
|
||||
|
||||
|
||||
def _extract_worker(payload: bytes) -> bytes:
|
||||
from govoplan_files.backend.storage.archives import (
|
||||
ArchivePasswordError, _inspect_archive_content, _read_selected_tar_members,
|
||||
_read_selected_zip_members, _selected_file_paths,
|
||||
)
|
||||
data = decode_worker_payload(payload, max_bytes=EXTRACTION_LIMITS.input_bytes)
|
||||
directory = Path(data["directory"])
|
||||
sequence = 0
|
||||
completed = 0
|
||||
|
||||
def report(phase, files, total, count, total_bytes, **member):
|
||||
nonlocal sequence
|
||||
sequence += 1
|
||||
_write_record(directory, "status", {
|
||||
"sequence": sequence, "kind": "member" if member else "progress",
|
||||
"progress": [phase, files, total, count, total_bytes], **member,
|
||||
})
|
||||
|
||||
try:
|
||||
inspection = _inspect_archive_content(data["source"], **data["options"])
|
||||
if inspection.requires_password and not inspection.password_verified:
|
||||
raise ArchivePasswordError("Archive password is required")
|
||||
selected = _selected_file_paths(inspection.entries, data["selected_paths"])
|
||||
if not selected:
|
||||
raise FileStorageError("Select at least one archive file to import")
|
||||
total_bytes = sum(entry.size_bytes for entry in inspection.entries if entry.path in selected)
|
||||
total_limit = min(data["options"]["max_expanded_bytes"],
|
||||
inspection.compressed_size_bytes * data["options"]["max_expansion_ratio"])
|
||||
arguments = {"selected_files": selected, "max_file_bytes": data["max_file_bytes"],
|
||||
"max_total_bytes": total_limit, "progress": report, "total_bytes": total_bytes}
|
||||
if inspection.archive_format == "zip":
|
||||
members = _read_selected_zip_members(data["source"], password=data["options"]["password"], **arguments)
|
||||
else:
|
||||
members = _read_selected_tar_members(data["source"], **arguments)
|
||||
actual = 0
|
||||
with closing(members), _ack_channel(directory) as acknowledgement:
|
||||
for inner_path, content in members:
|
||||
completed += 1
|
||||
actual += len(content)
|
||||
member_path = directory / f"member-{completed:08d}"
|
||||
descriptor = os.open(member_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
output.write(content)
|
||||
report("extracting", completed, len(selected), actual, total_bytes,
|
||||
path=inner_path, size=len(content), sha256=hashlib.sha256(content).hexdigest())
|
||||
del content
|
||||
# Wake Core's selector after publishing a completed member. One
|
||||
# discarded stderr byte per member fits its 64 KiB ceiling for
|
||||
# the default 10,000-entry cap; intermediate progress never
|
||||
# wakes. Non-default >32,768-member workloads retain ordinary
|
||||
# Core progress polling after the fixed wake budget is spent.
|
||||
if completed <= _MAX_WAKE_BYTES:
|
||||
os.write(2, b".")
|
||||
# Acknowledgement applies backpressure: the parent stores and
|
||||
# removes this member before we allocate/decode the next one.
|
||||
# Blocking on a bounded FIFO read avoids a fixed delay for each
|
||||
# tiny file. Core still enforces the overall wall/CPU budgets.
|
||||
_receive_ack(acknowledgement, sequence)
|
||||
result = {"completed": completed}
|
||||
except FileStorageError as exc:
|
||||
result = {"error": str(exc), "password_error": isinstance(exc, ArchivePasswordError)}
|
||||
return encode_worker_payload(result, max_bytes=EXTRACTION_LIMITS.output_bytes)
|
||||
@@ -6,7 +6,6 @@ import stat
|
||||
import tarfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from contextlib import closing
|
||||
from io import BytesIO
|
||||
from os import PathLike
|
||||
from pathlib import Path, PurePosixPath
|
||||
@@ -26,7 +25,6 @@ from govoplan_files.backend.storage.common import (
|
||||
UploadedStoredFile,
|
||||
)
|
||||
from govoplan_files.backend.storage.files import (
|
||||
archive_storage_backend_scope,
|
||||
create_file_asset,
|
||||
current_versions_and_blobs,
|
||||
)
|
||||
@@ -142,6 +140,22 @@ def inspect_archive(
|
||||
max_entries: int = ARCHIVE_UPLOAD_MAX_ENTRIES,
|
||||
max_expanded_bytes: int = 2 * 1024 * 1024 * 1024,
|
||||
max_expansion_ratio: int = 100,
|
||||
) -> ArchiveInspection:
|
||||
from govoplan_files.backend.storage.archive_workers import inspect_archive_isolated
|
||||
return inspect_archive_isolated(
|
||||
archive_data, filename=filename, password=password, max_entries=max_entries,
|
||||
max_expanded_bytes=max_expanded_bytes, max_expansion_ratio=max_expansion_ratio,
|
||||
)
|
||||
|
||||
|
||||
def _inspect_archive_content(
|
||||
archive_data: bytes | str | PathLike[str],
|
||||
*,
|
||||
filename: str,
|
||||
password: str | None = None,
|
||||
max_entries: int = ARCHIVE_UPLOAD_MAX_ENTRIES,
|
||||
max_expanded_bytes: int = 2 * 1024 * 1024 * 1024,
|
||||
max_expansion_ratio: int = 100,
|
||||
) -> ArchiveInspection:
|
||||
archive_format = archive_format_for_filename(filename)
|
||||
compressed_size = _archive_size(archive_data)
|
||||
@@ -206,68 +220,18 @@ def extract_archive_upload(
|
||||
max_expansion_ratio: int = 100,
|
||||
progress: ArchiveProgress | None = None,
|
||||
) -> list[UploadedStoredFile]:
|
||||
if progress:
|
||||
progress("inspecting", 0, 0, 0, 0)
|
||||
inspection = inspect_archive(
|
||||
archive_data,
|
||||
filename=filename,
|
||||
password=password,
|
||||
max_entries=max_entries,
|
||||
max_expanded_bytes=max_expanded_bytes,
|
||||
max_expansion_ratio=max_expansion_ratio,
|
||||
from govoplan_files.backend.storage.archive_workers import extract_archive_isolated
|
||||
return extract_archive_isolated(
|
||||
session, archive_data=archive_data, filename=filename, password=password,
|
||||
selected_paths=selected_paths, max_entries=max_entries, max_file_bytes=max_file_bytes,
|
||||
max_expanded_bytes=max_expanded_bytes, max_expansion_ratio=max_expansion_ratio,
|
||||
progress=progress, store_options={
|
||||
"tenant_id": tenant_id, "owner_type": owner_type, "owner_id": owner_id,
|
||||
"user_id": user_id, "folder": folder, "campaign_id": campaign_id,
|
||||
"conflict_strategy": conflict_strategy, "conflict_resolutions": conflict_resolutions,
|
||||
"metadata": metadata, "is_admin": is_admin, "encryption_vault_id": encryption_vault_id,
|
||||
},
|
||||
)
|
||||
if inspection.requires_password and not inspection.password_verified:
|
||||
raise ArchivePasswordError("Archive password is required")
|
||||
selected_files = _selected_file_paths(inspection.entries, selected_paths)
|
||||
if not selected_files:
|
||||
raise FileStorageError("Select at least one archive file to import")
|
||||
selected_total_bytes = sum(
|
||||
entry.size_bytes for entry in inspection.entries if entry.path in selected_files
|
||||
)
|
||||
actual_total_limit = min(
|
||||
max_expanded_bytes,
|
||||
inspection.compressed_size_bytes * max_expansion_ratio,
|
||||
)
|
||||
if inspection.archive_format == "zip":
|
||||
members = _read_selected_zip_members(
|
||||
archive_data,
|
||||
selected_files=selected_files,
|
||||
password=password,
|
||||
max_file_bytes=max_file_bytes,
|
||||
max_total_bytes=actual_total_limit,
|
||||
progress=progress,
|
||||
total_bytes=selected_total_bytes,
|
||||
)
|
||||
else:
|
||||
members = _read_selected_tar_members(
|
||||
archive_data,
|
||||
selected_files=selected_files,
|
||||
max_file_bytes=max_file_bytes,
|
||||
max_total_bytes=actual_total_limit,
|
||||
progress=progress,
|
||||
total_bytes=selected_total_bytes,
|
||||
)
|
||||
# Release Python/native archive handles immediately if storage or a
|
||||
# callback fails while the member iterator is suspended at a yield.
|
||||
with closing(members), archive_storage_backend_scope():
|
||||
return _store_archive_members(
|
||||
session,
|
||||
members=members,
|
||||
tenant_id=tenant_id,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
user_id=user_id,
|
||||
folder=folder,
|
||||
campaign_id=campaign_id,
|
||||
conflict_strategy=conflict_strategy,
|
||||
conflict_resolutions=conflict_resolutions,
|
||||
metadata=metadata,
|
||||
is_admin=is_admin,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
progress=progress,
|
||||
total_files=len(selected_files),
|
||||
total_bytes=selected_total_bytes,
|
||||
)
|
||||
|
||||
|
||||
def extract_zip_upload(
|
||||
|
||||
@@ -4,7 +4,7 @@ import tarfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_files.backend.storage.archives import _safe_member_path, inspect_archive
|
||||
from govoplan_files.backend.storage.archives import _inspect_archive_content, _safe_member_path, inspect_archive
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
from test_archives import _tar_bytes, _zip_bytes
|
||||
|
||||
@@ -38,21 +38,21 @@ class ArchiveInspectionBoundTests(unittest.TestCase):
|
||||
headers = _HeadersOnly(4)
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", return_value=headers):
|
||||
with self.assertRaisesRegex(FileStorageError, "too many entries"):
|
||||
inspect_archive(b"fixture", filename="fixture.tar.gz", max_entries=2)
|
||||
_inspect_archive_content(b"fixture", filename="fixture.tar.gz", max_entries=2)
|
||||
self.assertEqual(3, headers.seen)
|
||||
|
||||
def test_tar_expanded_size_rejected_before_payload_decompression(self):
|
||||
headers = _HeadersOnly(1, size=100)
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", return_value=headers):
|
||||
with self.assertRaisesRegex(FileStorageError, "too large after extraction"):
|
||||
inspect_archive(b"fixture", filename="fixture.tar.gz", max_expanded_bytes=10)
|
||||
_inspect_archive_content(b"fixture", filename="fixture.tar.gz", max_expanded_bytes=10)
|
||||
self.assertEqual(1, headers.seen)
|
||||
|
||||
def test_tar_ratio_rejected_before_payload_decompression(self):
|
||||
headers = _HeadersOnly(1, size=100)
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", return_value=headers):
|
||||
with self.assertRaisesRegex(FileStorageError, "expansion ratio"):
|
||||
inspect_archive(b"fixture", filename="fixture.tar.gz", max_expansion_ratio=2)
|
||||
_inspect_archive_content(b"fixture", filename="fixture.tar.gz", max_expansion_ratio=2)
|
||||
|
||||
def test_derived_directories_are_included_in_entry_limit(self):
|
||||
for filename, payload in (("fixture.zip", _zip_bytes({"a/b/c/file.txt": b"x"})), ("fixture.tar.gz", _tar_bytes({"a/b/c/file.txt": b"x"}))):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
@@ -204,24 +205,16 @@ class NativeArchivePerformanceTests(unittest.TestCase):
|
||||
"govoplan_files.backend.storage.archives.native_zip_library",
|
||||
return_value=proxy,
|
||||
),
|
||||
patch(
|
||||
"govoplan_files.backend.storage.archives.create_file_asset",
|
||||
side_effect=FileStorageError("Destination failure"),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(FileStorageError, "Destination failure"):
|
||||
extract_archive_upload(
|
||||
object(),
|
||||
tenant_id="tenant",
|
||||
owner_type="user",
|
||||
owner_id="user",
|
||||
user_id="user",
|
||||
archive_data=self.archive,
|
||||
filename="archive.zip",
|
||||
folder="",
|
||||
campaign_id=None,
|
||||
password="fixture-only",
|
||||
)
|
||||
# This unit test owns the native handle locally; public imports
|
||||
# now own it in a fresh child (covered by worker cleanup tests).
|
||||
with closing(_read_selected_zip_members(
|
||||
self.archive, selected_files=set(self.contents), password="fixture-only",
|
||||
max_file_bytes=100_000, max_total_bytes=100_000,
|
||||
)) as members:
|
||||
next(members)
|
||||
raise FileStorageError("Destination failure")
|
||||
close.assert_called_once()
|
||||
|
||||
def test_native_read_enforces_actual_member_and_total_limits(self):
|
||||
|
||||
@@ -221,7 +221,17 @@ class ArchiveStagingTests(unittest.TestCase):
|
||||
def test_progress_persistence_failure_does_not_turn_a_committed_import_into_an_error(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
with patch.object(archive_work.os, "replace", side_effect=OSError("Progress write unavailable")):
|
||||
original_replace = archive_work.os.replace
|
||||
|
||||
def replace(source, destination):
|
||||
# Only the optional UI progress receipt is unavailable. The private
|
||||
# worker ack is a required integrity/backpressure channel, not UI
|
||||
# progress, and must not be disabled by this module-global mock.
|
||||
if Path(source).name.startswith(".progress-"):
|
||||
raise OSError("Progress write unavailable")
|
||||
return original_replace(source, destination)
|
||||
|
||||
with patch.object(archive_work.os, "replace", side_effect=replace):
|
||||
result = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
self.assertEqual(self.session.query(FileAsset).count(), 2)
|
||||
@@ -229,6 +239,21 @@ class ArchiveStagingTests(unittest.TestCase):
|
||||
# the confirmation response remains the authoritative outcome.
|
||||
self.assertEqual(self.progress(operation_id).json()["status"], "running")
|
||||
|
||||
def test_required_worker_ack_failure_rolls_back_and_preserves_upload_for_explicit_retry(self):
|
||||
preview = self.preview().json()
|
||||
stage = self.staged_path(preview)
|
||||
operation_id = str(uuid4())
|
||||
with patch("govoplan_files.backend.storage.archive_workers._send_ack",
|
||||
side_effect=FileStorageError("Archive worker staging channel is unavailable")):
|
||||
result = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(400, result.status_code, result.text)
|
||||
self.assertIn("staging channel is unavailable", result.text)
|
||||
self.assert_no_extraction()
|
||||
self.assertEqual("failed", self.progress(operation_id).json()["status"])
|
||||
self.assertTrue(stage.exists())
|
||||
result = self.confirm(preview, operation_id=str(uuid4()))
|
||||
self.assertEqual(200, result.status_code, result.text)
|
||||
|
||||
def test_lease_cleanup_failure_cannot_mask_a_committed_import(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import tracemalloc
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zlib
|
||||
|
||||
from govoplan_core.security import bounded_process
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_files.backend.storage import archive_workers as workers
|
||||
from govoplan_files.backend.storage.archives import extract_archive_upload, inspect_archive
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
from test_archives import _zip_bytes
|
||||
|
||||
|
||||
def _cpu_exhaustion_worker(payload: bytes) -> bytes:
|
||||
# A real imported child operation proves the Files wrapper's CPU error path;
|
||||
# the metadata tests below execute the actual archive parser, not this probe.
|
||||
while True:
|
||||
pass
|
||||
|
||||
|
||||
def _extract(payload, **options):
|
||||
return extract_archive_upload(
|
||||
object(), tenant_id="tenant", owner_type="user", owner_id="owner",
|
||||
user_id="owner", filename="fixture.zip", archive_data=payload,
|
||||
folder="destination", campaign_id=None, **options,
|
||||
)
|
||||
|
||||
|
||||
class ArchiveWorkerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directories = []
|
||||
self.children = []
|
||||
real_directory = tempfile.TemporaryDirectory
|
||||
real_popen = subprocess.Popen
|
||||
|
||||
def directory(*args, **kwargs):
|
||||
context = real_directory(*args, **kwargs)
|
||||
self.directories.append(Path(context.name))
|
||||
return context
|
||||
|
||||
def popen(*args, **kwargs):
|
||||
child = real_popen(*args, **kwargs)
|
||||
self.children.append(child)
|
||||
return child
|
||||
|
||||
self.enterContext(patch.object(workers.tempfile, "TemporaryDirectory", side_effect=directory))
|
||||
self.enterContext(patch.object(bounded_process.subprocess, "Popen", side_effect=popen))
|
||||
self.store = self.enterContext(patch(
|
||||
"govoplan_files.backend.storage.archives.create_file_asset",
|
||||
return_value=SimpleNamespace(asset=SimpleNamespace(id="asset")),
|
||||
))
|
||||
|
||||
def tearDown(self):
|
||||
self.assertTrue(all(not directory.exists() for directory in self.directories))
|
||||
self.assertTrue(all(child.returncode is not None for child in self.children))
|
||||
|
||||
def test_parent_never_parses_metadata_or_extracts_and_one_child_handles_all_members(self):
|
||||
payload = _zip_bytes({"one.txt": b"one", "two.txt": b"two"})
|
||||
with (
|
||||
patch("govoplan_files.backend.storage.archives._inspect_archive_content", side_effect=AssertionError("parent parser")),
|
||||
patch("govoplan_files.backend.storage.archives._read_selected_zip_members", side_effect=AssertionError("parent decoder")),
|
||||
):
|
||||
self.assertEqual(2, inspect_archive(payload, filename="fixture.zip").file_count)
|
||||
self.assertEqual(2, len(_extract(payload)))
|
||||
self.assertEqual(2, len(self.children)) # one preview, one whole extraction
|
||||
self.assertEqual([b"one", b"two"], [call.kwargs["data"] for call in self.store.call_args_list])
|
||||
|
||||
def test_hostile_pax_metadata_allocation_is_confined_before_any_store(self):
|
||||
header = tarfile.TarInfo("pax")
|
||||
header.type = tarfile.XHDTYPE
|
||||
header.size = 1024 * 1024 * 1024
|
||||
payload = header.tobuf() + b"\0" * 1024
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", side_effect=AssertionError("parent TAR parser")):
|
||||
with self.assertRaisesRegex(FileStorageError, "memory_limit|Invalid TAR|could not complete safely"):
|
||||
inspect_archive(payload, filename="hostile.tar")
|
||||
self.store.assert_not_called()
|
||||
self.assertEqual(1, len(self.children))
|
||||
# A rejected archive neither consumes the admission slot permanently nor
|
||||
# prevents a later ordinary parse in the same parent process.
|
||||
self.assertEqual(1, inspect_archive(_zip_bytes({"ok": b"ok"}), filename="ok.zip").file_count)
|
||||
|
||||
def test_compressed_pax_metadata_hits_real_child_memory_limit(self):
|
||||
header = tarfile.TarInfo("pax")
|
||||
header.type = tarfile.XHDTYPE
|
||||
header.size = 1024 * 1024 * 1024
|
||||
compressor = zlib.compressobj(1, wbits=31)
|
||||
chunks = [compressor.compress(header.tobuf())]
|
||||
block = b"\0" * (1024 * 1024)
|
||||
# Build the hostile fixture incrementally: ~2.3 MiB compressed, never a
|
||||
# 512 MiB parent allocation. TAR consumes PAX metadata before ordinary
|
||||
# returned-member limits can inspect it; the child AS limit must win.
|
||||
for _ in range(512):
|
||||
chunks.append(compressor.compress(block))
|
||||
chunks.append(compressor.flush())
|
||||
payload = b"".join(chunks)
|
||||
self.assertLess(len(payload), 3 * 1024 * 1024)
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", side_effect=AssertionError("parent TAR parser")):
|
||||
with self.assertRaisesRegex(FileStorageError, "memory_limit"):
|
||||
inspect_archive(payload, filename="hostile.tar.gz")
|
||||
self.store.assert_not_called()
|
||||
self.assertEqual(1, len(self.children))
|
||||
|
||||
def test_real_child_timeout_reaps_and_removes_snapshot(self):
|
||||
with patch.object(workers, "INSPECTION_LIMITS", replace(workers.INSPECTION_LIMITS, wall_seconds=0.001)):
|
||||
with self.assertRaisesRegex(FileStorageError, "timeout"):
|
||||
inspect_archive(_zip_bytes({"one": b"one"}), filename="fixture.zip")
|
||||
self.assertEqual(1, len(self.children))
|
||||
self.store.assert_not_called()
|
||||
|
||||
def test_real_child_cpu_limit_has_sanitized_files_error(self):
|
||||
with (
|
||||
patch.object(workers, "_inspect_worker", _cpu_exhaustion_worker),
|
||||
patch.object(workers, "INSPECTION_LIMITS", replace(workers.INSPECTION_LIMITS, cpu_seconds=1, wall_seconds=10)),
|
||||
):
|
||||
with self.assertRaisesRegex(FileStorageError, "cpu_limit"):
|
||||
inspect_archive(_zip_bytes({"one": b"one"}), filename="fixture.zip")
|
||||
|
||||
def test_real_preview_output_is_bounded(self):
|
||||
with patch.object(workers, "INSPECTION_LIMITS", replace(workers.INSPECTION_LIMITS, output_bytes=256)):
|
||||
with self.assertRaisesRegex(FileStorageError, "output_limit"):
|
||||
inspect_archive(_zip_bytes({f"file-{index}": b"x" for index in range(20)}), filename="fixture.zip")
|
||||
|
||||
def test_destination_failure_kills_waiting_child_and_preserves_error(self):
|
||||
self.store.side_effect = FileStorageError("Destination failure")
|
||||
with self.assertRaisesRegex(FileStorageError, "Destination failure"):
|
||||
_extract(_zip_bytes({"one": b"one", "two": b"two"}))
|
||||
self.assertEqual(1, self.store.call_count)
|
||||
self.assertEqual(1, len(self.children))
|
||||
|
||||
def test_missing_ack_times_out_and_wrong_ack_fails_in_real_child(self):
|
||||
real_send = workers._send_ack
|
||||
for send, error in ((lambda descriptor, sequence: None, "timeout"),
|
||||
(lambda descriptor, sequence: real_send(descriptor, sequence + 1), "invalid staging record")):
|
||||
with self.subTest(error=error), patch.object(workers, "_send_ack", side_effect=send):
|
||||
with patch.object(workers, "EXTRACTION_LIMITS", replace(workers.EXTRACTION_LIMITS, wall_seconds=2)):
|
||||
with self.assertRaisesRegex(FileStorageError, error):
|
||||
_extract(_zip_bytes({"one": b"one", "two": b"two"}))
|
||||
|
||||
def test_parent_progress_exception_is_not_reclassified_as_transport_error(self):
|
||||
def progress(stage, *counters):
|
||||
if stage == "extracting":
|
||||
raise ValueError("parent progress failure")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "parent progress failure"):
|
||||
_extract(_zip_bytes({"one": b"one"}), progress=progress)
|
||||
self.store.assert_not_called()
|
||||
|
||||
def test_source_path_snapshot_rejects_growth_and_shrink_without_spawning(self):
|
||||
# A source path is server-owned; it must still not turn into an unbounded
|
||||
# copy when another writer changes it during snapshot creation.
|
||||
for replacement in (b"fixture plus unexpected growth", b"x"):
|
||||
with self.subTest(replacement=replacement), tempfile.TemporaryDirectory() as temporary:
|
||||
source = Path(temporary) / "archive"
|
||||
source.write_bytes(b"fixture")
|
||||
real_fstat = os.fstat
|
||||
changed = False
|
||||
|
||||
def fstat(descriptor):
|
||||
nonlocal changed
|
||||
result = real_fstat(descriptor)
|
||||
if not changed:
|
||||
changed = True
|
||||
source.write_bytes(replacement)
|
||||
return result
|
||||
|
||||
with patch.object(workers.os, "fstat", side_effect=fstat):
|
||||
with self.assertRaisesRegex(FileStorageError, "source changed"):
|
||||
inspect_archive(source, filename="fixture.zip")
|
||||
self.assertEqual([], self.children)
|
||||
|
||||
def test_busy_rejects_before_source_snapshot(self):
|
||||
with patch.object(settings, "isolated_process_concurrency", 1), bounded_process.bounded_operation_admission():
|
||||
with patch.object(workers, "_source_stage", side_effect=AssertionError("must admit before copying")):
|
||||
with self.assertRaisesRegex(FileStorageError, "busy"):
|
||||
inspect_archive(b"fixture", filename="fixture.zip")
|
||||
self.assertEqual([], self.children)
|
||||
self.assertEqual([], self.directories)
|
||||
|
||||
def test_unsupported_format_rejects_before_snapshot(self):
|
||||
with self.assertRaisesRegex(FileStorageError, "Unsupported archive format"):
|
||||
inspect_archive(b"fixture", filename="fixture.exe")
|
||||
self.assertEqual([], self.directories)
|
||||
|
||||
def test_symlinked_staged_member_is_never_read_or_stored(self):
|
||||
real_read = workers._read_regular
|
||||
attacked = False
|
||||
|
||||
def read(path, maximum, **options):
|
||||
nonlocal attacked
|
||||
if path.name.startswith("member-") and not attacked:
|
||||
attacked = True
|
||||
path.unlink()
|
||||
path.symlink_to(path.parent / "source")
|
||||
return real_read(path, maximum, **options)
|
||||
|
||||
with patch.object(workers, "_read_regular", side_effect=read):
|
||||
with self.assertRaisesRegex(FileStorageError, "invalid staging record"):
|
||||
_extract(_zip_bytes({"one": b"one"}))
|
||||
self.assertTrue(attacked)
|
||||
self.store.assert_not_called()
|
||||
|
||||
def test_forged_member_records_reject_before_store(self):
|
||||
real_read = workers._read_regular
|
||||
for mutation in (
|
||||
{"path": "outside/one"}, {"path": "../escape"}, {"size": -1},
|
||||
{"sha256": "incorrect"}, {"sequence": True}, {"unexpected": True},
|
||||
{"progress": ["extracting", 1, 1, 4, 3]},
|
||||
):
|
||||
with self.subTest(mutation=mutation):
|
||||
self.store.reset_mock()
|
||||
|
||||
def read(path, maximum, **options):
|
||||
value = real_read(path, maximum, **options)
|
||||
if path.name == "status":
|
||||
record = json.loads(value)
|
||||
if record.get("kind") == "member":
|
||||
record.update(mutation)
|
||||
return json.dumps(record).encode()
|
||||
return value
|
||||
|
||||
with patch.object(workers, "_read_regular", side_effect=read):
|
||||
with self.assertRaises(FileStorageError):
|
||||
_extract(_zip_bytes({"selected/one": b"one"}), selected_paths=("selected",))
|
||||
self.store.assert_not_called()
|
||||
|
||||
def test_regressing_sequence_or_changing_totals_fail_before_second_store(self):
|
||||
real_read = workers._read_regular
|
||||
for mutation in ({"sequence": 1}, {"progress": ["extracting", 2, 3, 6, 6]}):
|
||||
with self.subTest(mutation=mutation):
|
||||
self.store.reset_mock()
|
||||
|
||||
def read(path, maximum, **options):
|
||||
value = real_read(path, maximum, **options)
|
||||
if path.name == "status":
|
||||
record = json.loads(value)
|
||||
if record.get("kind") == "member" and record["progress"][1] == 2:
|
||||
record.update(mutation)
|
||||
return json.dumps(record).encode()
|
||||
return value
|
||||
|
||||
with patch.object(workers, "_read_regular", side_effect=read):
|
||||
with self.assertRaisesRegex(FileStorageError, "invalid staging record"):
|
||||
_extract(_zip_bytes({"one": b"one", "two": b"two"}))
|
||||
self.assertEqual(1, self.store.call_count)
|
||||
|
||||
def test_record_reads_are_bounded_and_reject_non_regular_files(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
status = directory / "status"
|
||||
status.write_bytes(b"x" * (workers._STATUS_BYTES + 1))
|
||||
with self.assertRaises(FileStorageError):
|
||||
workers._read_regular(status, workers._STATUS_BYTES)
|
||||
with self.assertRaises(FileStorageError):
|
||||
workers._read_regular(directory, workers._STATUS_BYTES)
|
||||
|
||||
def test_tiny_member_read_does_not_allocate_the_configured_gibibyte_cap(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
member = Path(temporary) / "member"
|
||||
member.write_bytes(b"x")
|
||||
tracemalloc.start()
|
||||
try:
|
||||
self.assertEqual(b"x", workers._read_regular(member, 2 * 1024 * 1024 * 1024))
|
||||
_current, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
self.assertLess(peak, 1024 * 1024)
|
||||
|
||||
def test_atomic_status_replacement_keeps_open_snapshot_valid_but_member_identity_is_strict(self):
|
||||
for atomic_record in (True, False):
|
||||
with self.subTest(atomic_record=atomic_record), tempfile.TemporaryDirectory() as temporary:
|
||||
status = Path(temporary) / "status"
|
||||
status.write_bytes(b"old")
|
||||
replacement = Path(temporary) / "replacement"
|
||||
replacement.write_bytes(b"new")
|
||||
real_fstat = os.fstat
|
||||
replaced = False
|
||||
|
||||
def fstat(descriptor):
|
||||
nonlocal replaced
|
||||
info = real_fstat(descriptor)
|
||||
if not replaced:
|
||||
replaced = True
|
||||
os.replace(replacement, status)
|
||||
return info
|
||||
|
||||
with patch.object(workers.os, "fstat", side_effect=fstat):
|
||||
if atomic_record:
|
||||
self.assertEqual(b"old", workers._read_regular(status, 16, atomic_record=True))
|
||||
else:
|
||||
with self.assertRaises(FileStorageError):
|
||||
workers._read_regular(status, 16)
|
||||
self.assertEqual(b"new", status.read_bytes())
|
||||
|
||||
def test_private_ack_channel_is_bounded_and_validates_type_permissions_and_sequence(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
with workers._ack_channel(directory, create=True) as writer:
|
||||
with workers._ack_channel(directory) as reader:
|
||||
workers._send_ack(writer, 7)
|
||||
workers._receive_ack(reader, 7)
|
||||
workers._send_ack(writer, 8)
|
||||
with self.assertRaises(FileStorageError):
|
||||
workers._receive_ack(reader, 9)
|
||||
with patch.object(workers.os, "write", side_effect=BlockingIOError()):
|
||||
with self.assertRaisesRegex(FileStorageError, "staging channel is unavailable"):
|
||||
workers._send_ack(writer, 10)
|
||||
ack = directory / "ack"
|
||||
os.chmod(ack, 0o644)
|
||||
with self.assertRaises(FileStorageError), workers._ack_channel(directory):
|
||||
pass
|
||||
ack.unlink()
|
||||
ack.write_bytes(b"not a FIFO")
|
||||
with self.assertRaises(FileStorageError), workers._ack_channel(directory):
|
||||
pass
|
||||
ack.unlink()
|
||||
ack.symlink_to(directory / "missing")
|
||||
with self.assertRaises(FileStorageError), workers._ack_channel(directory):
|
||||
pass
|
||||
|
||||
def test_tiny_member_import_has_no_per_member_sleep_or_polling_delay(self):
|
||||
self.assertNotIn("sleep(", inspect.getsource(workers._extract_worker))
|
||||
for count in (100, 1000):
|
||||
with self.subTest(count=count):
|
||||
payload = _zip_bytes({f"member-{index}.txt": b"x" for index in range(count)})
|
||||
started = time.monotonic()
|
||||
self.assertEqual(count, len(_extract(payload)))
|
||||
elapsed = time.monotonic() - started
|
||||
# Broad synthetic regression ceiling, not a storage-throughput
|
||||
# promise: 50 ms per member would take >=50 s for 1,000 files.
|
||||
self.assertLess(elapsed, 15)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
|
||||
|
||||
STATIC_TOPIC_IDS = {
|
||||
"files.archive-worker-limits",
|
||||
"files.configuration-package.managed-storage",
|
||||
"files.quick-access-and-product-area",
|
||||
"files.search.managed-content",
|
||||
@@ -45,6 +46,18 @@ class FilesManifestDocumentationTests(unittest.TestCase):
|
||||
def topic(self, topic_id: str):
|
||||
return self.topics[topic_id]
|
||||
|
||||
def test_archive_resource_boundary_is_static_and_bilingual(self):
|
||||
topic = self.topic("files.archive-worker-limits")
|
||||
self.assertEqual("available", topic.layer)
|
||||
self.assertEqual({"admin", "user"}, set(topic.documentation_types))
|
||||
self.assertIn("GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY", topic.configuration_keys)
|
||||
for body in (topic.body, topic.translations["de"]["body"]):
|
||||
for limit in ("120", "90", "512 MiB", "600", "300", "128 MiB", "64 MiB", "16 KiB", "2 GiB", "250 MiB"):
|
||||
self.assertIn(limit, body)
|
||||
self.assertIn("before copying", topic.body)
|
||||
self.assertIn("not a filesystem/network sandbox", topic.body)
|
||||
self.assertIn("keine Dateisystem-/Netzwerk-Sandbox", topic.translations["de"]["body"])
|
||||
|
||||
def test_workspace_action_locations_and_read_only_reload_are_documented_in_both_languages(self) -> None:
|
||||
topic = self.topic("files.quick-access-and-product-area")
|
||||
self.assertEqual({"user", "admin"}, set(topic.documentation_types))
|
||||
|
||||
Reference in New Issue
Block a user