python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Crash-safe private queue and content store for real-Hancom render jobs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .render_security import (
|
|
19
|
+
HWPX_MEDIA_TYPE,
|
|
20
|
+
RenderInputInspection,
|
|
21
|
+
RenderSecurityPolicy,
|
|
22
|
+
RenderSecurityViolation,
|
|
23
|
+
validate_render_input,
|
|
24
|
+
)
|
|
25
|
+
# Shared contracts come from the leaf module that removed the import cycle;
|
|
26
|
+
# sign_submission is re-exported for existing importers of this module.
|
|
27
|
+
from .render_contracts import ( # noqa: F401 - compatibility re-export
|
|
28
|
+
RenderJobV2,
|
|
29
|
+
RenderReceiptV2,
|
|
30
|
+
RenderStatus,
|
|
31
|
+
sign_submission,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RenderQueueError(RuntimeError):
|
|
36
|
+
def __init__(self, code: str, message: str) -> None:
|
|
37
|
+
self.code = code
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def utcnow() -> datetime:
|
|
42
|
+
return datetime.now(timezone.utc)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _iso(value: datetime) -> str:
|
|
46
|
+
return value.astimezone(timezone.utc).isoformat()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dt(value: str | None) -> datetime | None:
|
|
50
|
+
return datetime.fromisoformat(value) if value else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def authenticate_submission(secret: bytes, job: RenderJobV2, signature: str) -> None:
|
|
54
|
+
expected = sign_submission(secret, job)
|
|
55
|
+
if not signature or not hmac.compare_digest(expected, signature):
|
|
56
|
+
raise RenderSecurityViolation("AUTH_SIGNATURE_INVALID", "render submission signature rejected")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def inspect_hwpx(data: bytes, *, filename: str, principal_id: str) -> RenderInputInspection:
|
|
60
|
+
"""Inspect the actual archive; no caller-supplied ZIP facts are trusted."""
|
|
61
|
+
|
|
62
|
+
encrypted = symlink = traversal = False
|
|
63
|
+
total = 0
|
|
64
|
+
try:
|
|
65
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
66
|
+
infos = archive.infolist()
|
|
67
|
+
names = {info.filename.replace("\\", "/") for info in infos}
|
|
68
|
+
for info in infos:
|
|
69
|
+
normalized = info.filename.replace("\\", "/")
|
|
70
|
+
parts = Path(normalized).parts
|
|
71
|
+
traversal |= normalized.startswith("/") or ".." in parts
|
|
72
|
+
encrypted |= bool(info.flag_bits & 0x1)
|
|
73
|
+
mode = info.external_attr >> 16
|
|
74
|
+
symlink |= stat.S_ISLNK(mode)
|
|
75
|
+
total += info.file_size
|
|
76
|
+
if "mimetype" not in names or not any(name.startswith("Contents/") for name in names):
|
|
77
|
+
raise RenderSecurityViolation("INVALID_HWPX_PACKAGE", "required HWPX package members are missing")
|
|
78
|
+
try:
|
|
79
|
+
media = archive.read("mimetype", pwd=None).decode("ascii").strip()
|
|
80
|
+
except (KeyError, UnicodeDecodeError, RuntimeError) as exc:
|
|
81
|
+
raise RenderSecurityViolation("INVALID_HWPX_PACKAGE", "HWPX mimetype is unreadable") from exc
|
|
82
|
+
if media != HWPX_MEDIA_TYPE:
|
|
83
|
+
raise RenderSecurityViolation("INVALID_HWPX_PACKAGE", "HWPX mimetype is invalid")
|
|
84
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
85
|
+
raise RenderSecurityViolation("INVALID_HWPX_ZIP", "render input is not a readable HWPX ZIP") from exc
|
|
86
|
+
return RenderInputInspection(
|
|
87
|
+
authenticated=True,
|
|
88
|
+
principal_id=principal_id,
|
|
89
|
+
filename=filename,
|
|
90
|
+
media_type=HWPX_MEDIA_TYPE,
|
|
91
|
+
compressed_bytes=len(data),
|
|
92
|
+
uncompressed_bytes=total,
|
|
93
|
+
zip_entries=len(infos),
|
|
94
|
+
has_encrypted_entry=encrypted,
|
|
95
|
+
has_symlink_entry=symlink,
|
|
96
|
+
has_path_traversal=traversal,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class RenderLease:
|
|
102
|
+
job: RenderJobV2
|
|
103
|
+
worker_id: str
|
|
104
|
+
attempt: int
|
|
105
|
+
lease_expires_at: datetime
|
|
106
|
+
source_path: Path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ContentAddressedStore:
|
|
110
|
+
def __init__(self, root: Path) -> None:
|
|
111
|
+
self.root = root.resolve()
|
|
112
|
+
self.blobs = self.root / "blobs"
|
|
113
|
+
self.blobs.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
def path_for(self, content_hash: str) -> Path:
|
|
116
|
+
digest = content_hash.removeprefix("sha256:")
|
|
117
|
+
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
|
|
118
|
+
raise RenderQueueError("INVALID_CONTENT_HASH", "invalid SHA-256 content address")
|
|
119
|
+
return self.blobs / digest[:2] / digest
|
|
120
|
+
|
|
121
|
+
def put(self, data: bytes, expected_hash: str) -> Path:
|
|
122
|
+
actual = "sha256:" + hashlib.sha256(data).hexdigest()
|
|
123
|
+
if actual != expected_hash:
|
|
124
|
+
raise RenderQueueError("CONTENT_HASH_MISMATCH", "input bytes do not match source_content_hash")
|
|
125
|
+
target = self.path_for(actual)
|
|
126
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
if target.exists():
|
|
128
|
+
return target
|
|
129
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".incoming-", dir=target.parent)
|
|
130
|
+
try:
|
|
131
|
+
with os.fdopen(fd, "wb") as handle:
|
|
132
|
+
handle.write(data)
|
|
133
|
+
handle.flush()
|
|
134
|
+
os.fsync(handle.fileno())
|
|
135
|
+
os.replace(tmp_name, target)
|
|
136
|
+
finally:
|
|
137
|
+
try:
|
|
138
|
+
os.unlink(tmp_name)
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
pass
|
|
141
|
+
return target
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DurableRenderQueue:
|
|
145
|
+
"""SQLite WAL/FULL queue with leases, idempotency, and terminal receipts."""
|
|
146
|
+
|
|
147
|
+
TERMINAL = {"succeeded", "failed", "unavailable", "cancelled"}
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
root: Path,
|
|
152
|
+
*,
|
|
153
|
+
secret: bytes,
|
|
154
|
+
policy: RenderSecurityPolicy,
|
|
155
|
+
max_attempts: int = 3,
|
|
156
|
+
) -> None:
|
|
157
|
+
if not secret:
|
|
158
|
+
raise ValueError("queue authentication secret must not be empty")
|
|
159
|
+
if max_attempts < 1:
|
|
160
|
+
raise ValueError("max_attempts must be positive")
|
|
161
|
+
self.root = root.resolve()
|
|
162
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
self.db_path = self.root / "render-queue.sqlite3"
|
|
164
|
+
self.secret = secret
|
|
165
|
+
self.policy = policy
|
|
166
|
+
self.max_attempts = max_attempts
|
|
167
|
+
self.content = ContentAddressedStore(self.root / "content")
|
|
168
|
+
self._init_db()
|
|
169
|
+
|
|
170
|
+
def _connect(self) -> sqlite3.Connection:
|
|
171
|
+
con = sqlite3.connect(self.db_path, timeout=10, isolation_level=None)
|
|
172
|
+
con.row_factory = sqlite3.Row
|
|
173
|
+
con.execute("PRAGMA foreign_keys=ON")
|
|
174
|
+
return con
|
|
175
|
+
|
|
176
|
+
def _init_db(self) -> None:
|
|
177
|
+
with self._connect() as con:
|
|
178
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
179
|
+
con.execute("PRAGMA synchronous=FULL")
|
|
180
|
+
con.execute(
|
|
181
|
+
"""CREATE TABLE IF NOT EXISTS render_jobs (
|
|
182
|
+
job_id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL,
|
|
183
|
+
idempotency_key TEXT NOT NULL UNIQUE, input_hash TEXT NOT NULL,
|
|
184
|
+
job_json TEXT NOT NULL, source_path TEXT NOT NULL,
|
|
185
|
+
state TEXT NOT NULL, attempt INTEGER NOT NULL DEFAULT 0,
|
|
186
|
+
worker_id TEXT, lease_expires_at TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
|
|
187
|
+
receipt_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
188
|
+
terminal_at TEXT
|
|
189
|
+
)"""
|
|
190
|
+
)
|
|
191
|
+
con.execute(
|
|
192
|
+
"""CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
|
193
|
+
singleton INTEGER PRIMARY KEY CHECK(singleton=1), observed_at TEXT NOT NULL,
|
|
194
|
+
worker_version TEXT NOT NULL, hancom_build TEXT NOT NULL,
|
|
195
|
+
available INTEGER NOT NULL, degraded_reason TEXT
|
|
196
|
+
)"""
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def pragmas(self) -> tuple[str, int]:
|
|
200
|
+
with self._connect() as con:
|
|
201
|
+
return str(con.execute("PRAGMA journal_mode").fetchone()[0]).lower(), int(
|
|
202
|
+
con.execute("PRAGMA synchronous").fetchone()[0]
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def submit(
|
|
206
|
+
self,
|
|
207
|
+
job: RenderJobV2,
|
|
208
|
+
data: bytes,
|
|
209
|
+
*,
|
|
210
|
+
signature: str,
|
|
211
|
+
principal_id: str,
|
|
212
|
+
filename: str = "document.hwpx",
|
|
213
|
+
) -> RenderReceiptV2:
|
|
214
|
+
authenticate_submission(self.secret, job, signature)
|
|
215
|
+
if len(data) != job.source_size_bytes:
|
|
216
|
+
raise RenderQueueError("CONTENT_SIZE_MISMATCH", "input bytes do not match source_size_bytes")
|
|
217
|
+
inspection = inspect_hwpx(data, filename=filename, principal_id=principal_id)
|
|
218
|
+
validate_render_input(inspection, self.policy)
|
|
219
|
+
source_path = self.content.put(data, job.source_content_hash)
|
|
220
|
+
now = utcnow()
|
|
221
|
+
queued = RenderReceiptV2(
|
|
222
|
+
job_id=job.job_id,
|
|
223
|
+
workflow_id=job.workflow_id,
|
|
224
|
+
input_content_hash=job.source_content_hash,
|
|
225
|
+
status=RenderStatus.QUEUED,
|
|
226
|
+
queued_at=job.submitted_at,
|
|
227
|
+
)
|
|
228
|
+
with self._connect() as con:
|
|
229
|
+
con.execute("BEGIN IMMEDIATE")
|
|
230
|
+
existing = con.execute(
|
|
231
|
+
"SELECT * FROM render_jobs WHERE idempotency_key=? OR job_id=?",
|
|
232
|
+
(job.idempotency_key, job.job_id),
|
|
233
|
+
).fetchone()
|
|
234
|
+
if existing:
|
|
235
|
+
same = (
|
|
236
|
+
existing["job_id"] == job.job_id
|
|
237
|
+
and existing["input_hash"] == job.source_content_hash
|
|
238
|
+
and existing["job_json"] == job.model_dump_json()
|
|
239
|
+
)
|
|
240
|
+
if not same:
|
|
241
|
+
con.rollback()
|
|
242
|
+
raise RenderQueueError("IDEMPOTENCY_CONFLICT", "idempotency key or job id reused")
|
|
243
|
+
con.commit()
|
|
244
|
+
return self._receipt_from_row(existing)
|
|
245
|
+
con.execute(
|
|
246
|
+
"""INSERT INTO render_jobs
|
|
247
|
+
(job_id,workflow_id,idempotency_key,input_hash,job_json,source_path,state,receipt_json,created_at,updated_at)
|
|
248
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
|
249
|
+
(
|
|
250
|
+
job.job_id, job.workflow_id, job.idempotency_key, job.source_content_hash,
|
|
251
|
+
job.model_dump_json(), str(source_path), "queued", queued.model_dump_json(),
|
|
252
|
+
_iso(now), _iso(now),
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
con.commit()
|
|
256
|
+
return queued
|
|
257
|
+
|
|
258
|
+
def _recover_expired(self, con: sqlite3.Connection, now: datetime) -> None:
|
|
259
|
+
rows = con.execute(
|
|
260
|
+
"SELECT * FROM render_jobs WHERE state='running' AND lease_expires_at < ?",
|
|
261
|
+
(_iso(now),),
|
|
262
|
+
).fetchall()
|
|
263
|
+
for row in rows:
|
|
264
|
+
if row["attempt"] >= self.max_attempts:
|
|
265
|
+
receipt = self._terminal_from_row(row, RenderStatus.UNAVAILABLE, now, "LEASE_EXHAUSTED")
|
|
266
|
+
con.execute(
|
|
267
|
+
"UPDATE render_jobs SET state='unavailable',receipt_json=?,terminal_at=?,worker_id=NULL,lease_expires_at=NULL,updated_at=? WHERE job_id=?",
|
|
268
|
+
(receipt.model_dump_json(), _iso(now), _iso(now), row["job_id"]),
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
con.execute(
|
|
272
|
+
"UPDATE render_jobs SET state='queued',worker_id=NULL,lease_expires_at=NULL,updated_at=? WHERE job_id=?",
|
|
273
|
+
(_iso(now), row["job_id"]),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def claim(self, worker_id: str, *, lease_seconds: int = 300, now: datetime | None = None) -> RenderLease | None:
|
|
277
|
+
if not worker_id or lease_seconds < 1:
|
|
278
|
+
raise ValueError("worker_id and a positive lease are required")
|
|
279
|
+
now = now or utcnow()
|
|
280
|
+
with self._connect() as con:
|
|
281
|
+
con.execute("BEGIN IMMEDIATE")
|
|
282
|
+
self._recover_expired(con, now)
|
|
283
|
+
row = con.execute("SELECT * FROM render_jobs WHERE state='queued' ORDER BY created_at,job_id LIMIT 1").fetchone()
|
|
284
|
+
if not row:
|
|
285
|
+
con.commit()
|
|
286
|
+
return None
|
|
287
|
+
if row["cancel_requested"]:
|
|
288
|
+
receipt = self._terminal_from_row(row, RenderStatus.CANCELLED, now, "CLIENT_CANCELLED")
|
|
289
|
+
con.execute(
|
|
290
|
+
"UPDATE render_jobs SET state='cancelled',receipt_json=?,terminal_at=?,updated_at=? WHERE job_id=?",
|
|
291
|
+
(receipt.model_dump_json(), _iso(now), _iso(now), row["job_id"]),
|
|
292
|
+
)
|
|
293
|
+
con.commit()
|
|
294
|
+
return None
|
|
295
|
+
expires = now + timedelta(seconds=lease_seconds)
|
|
296
|
+
attempt = int(row["attempt"]) + 1
|
|
297
|
+
running = RenderReceiptV2(
|
|
298
|
+
job_id=row["job_id"], workflow_id=row["workflow_id"],
|
|
299
|
+
input_content_hash=row["input_hash"], status=RenderStatus.RUNNING,
|
|
300
|
+
queued_at=RenderJobV2.model_validate_json(row["job_json"]).submitted_at,
|
|
301
|
+
started_at=now, retry_count=attempt - 1,
|
|
302
|
+
)
|
|
303
|
+
con.execute(
|
|
304
|
+
"UPDATE render_jobs SET state='running',attempt=?,worker_id=?,lease_expires_at=?,receipt_json=?,updated_at=? WHERE job_id=?",
|
|
305
|
+
(attempt, worker_id, _iso(expires), running.model_dump_json(), _iso(now), row["job_id"]),
|
|
306
|
+
)
|
|
307
|
+
con.commit()
|
|
308
|
+
return RenderLease(RenderJobV2.model_validate_json(row["job_json"]), worker_id, attempt, expires, Path(row["source_path"]))
|
|
309
|
+
|
|
310
|
+
def complete(self, lease: RenderLease, receipt: RenderReceiptV2, *, now: datetime | None = None) -> RenderReceiptV2:
|
|
311
|
+
now = now or utcnow()
|
|
312
|
+
if receipt.status != RenderStatus.SUCCEEDED or not receipt.binds(lease.job):
|
|
313
|
+
raise RenderQueueError("RECEIPT_BINDING_INVALID", "success receipt is not bound to leased job")
|
|
314
|
+
with self._connect() as con:
|
|
315
|
+
con.execute("BEGIN IMMEDIATE")
|
|
316
|
+
row = self._owned_running(con, lease)
|
|
317
|
+
if row["cancel_requested"]:
|
|
318
|
+
con.rollback()
|
|
319
|
+
raise RenderQueueError("CANCEL_REQUESTED", "worker must stop and acknowledge cancellation")
|
|
320
|
+
con.execute(
|
|
321
|
+
"UPDATE render_jobs SET state='succeeded',receipt_json=?,terminal_at=?,worker_id=NULL,lease_expires_at=NULL,updated_at=? WHERE job_id=?",
|
|
322
|
+
(receipt.model_dump_json(), _iso(now), _iso(now), lease.job.job_id),
|
|
323
|
+
)
|
|
324
|
+
con.commit()
|
|
325
|
+
return receipt
|
|
326
|
+
|
|
327
|
+
def fail(self, lease: RenderLease, *, reason: str, retryable: bool, now: datetime | None = None) -> RenderReceiptV2:
|
|
328
|
+
now = now or utcnow()
|
|
329
|
+
with self._connect() as con:
|
|
330
|
+
con.execute("BEGIN IMMEDIATE")
|
|
331
|
+
row = self._owned_running(con, lease)
|
|
332
|
+
cancelled = bool(row["cancel_requested"])
|
|
333
|
+
if retryable and lease.attempt < self.max_attempts and not cancelled:
|
|
334
|
+
queued = RenderReceiptV2(
|
|
335
|
+
job_id=lease.job.job_id, workflow_id=lease.job.workflow_id,
|
|
336
|
+
input_content_hash=lease.job.source_content_hash, status=RenderStatus.QUEUED,
|
|
337
|
+
queued_at=lease.job.submitted_at, retry_count=lease.attempt,
|
|
338
|
+
)
|
|
339
|
+
con.execute(
|
|
340
|
+
"UPDATE render_jobs SET state='queued',receipt_json=?,worker_id=NULL,lease_expires_at=NULL,updated_at=? WHERE job_id=?",
|
|
341
|
+
(queued.model_dump_json(), _iso(now), lease.job.job_id),
|
|
342
|
+
)
|
|
343
|
+
con.commit()
|
|
344
|
+
return queued
|
|
345
|
+
status = RenderStatus.CANCELLED if cancelled else RenderStatus.FAILED
|
|
346
|
+
code = "CLIENT_CANCELLED" if cancelled else reason
|
|
347
|
+
receipt = self._terminal_from_row(row, status, now, code)
|
|
348
|
+
con.execute(
|
|
349
|
+
"UPDATE render_jobs SET state=?,receipt_json=?,terminal_at=?,worker_id=NULL,lease_expires_at=NULL,updated_at=? WHERE job_id=?",
|
|
350
|
+
(status.value, receipt.model_dump_json(), _iso(now), _iso(now), lease.job.job_id),
|
|
351
|
+
)
|
|
352
|
+
con.commit()
|
|
353
|
+
return receipt
|
|
354
|
+
|
|
355
|
+
def cancel(self, job_id: str, *, now: datetime | None = None) -> RenderReceiptV2:
|
|
356
|
+
now = now or utcnow()
|
|
357
|
+
with self._connect() as con:
|
|
358
|
+
con.execute("BEGIN IMMEDIATE")
|
|
359
|
+
row = con.execute("SELECT * FROM render_jobs WHERE job_id=?", (job_id,)).fetchone()
|
|
360
|
+
if not row:
|
|
361
|
+
con.rollback()
|
|
362
|
+
raise RenderQueueError("JOB_NOT_FOUND", "render job not found")
|
|
363
|
+
if row["state"] in self.TERMINAL:
|
|
364
|
+
con.commit()
|
|
365
|
+
return self._receipt_from_row(row)
|
|
366
|
+
if row["state"] == "running":
|
|
367
|
+
con.execute("UPDATE render_jobs SET cancel_requested=1,updated_at=? WHERE job_id=?", (_iso(now), job_id))
|
|
368
|
+
con.commit()
|
|
369
|
+
return self._receipt_from_row(row)
|
|
370
|
+
receipt = self._terminal_from_row(row, RenderStatus.CANCELLED, now, "CLIENT_CANCELLED")
|
|
371
|
+
con.execute(
|
|
372
|
+
"UPDATE render_jobs SET state='cancelled',cancel_requested=1,receipt_json=?,terminal_at=?,updated_at=? WHERE job_id=?",
|
|
373
|
+
(receipt.model_dump_json(), _iso(now), _iso(now), job_id),
|
|
374
|
+
)
|
|
375
|
+
con.commit()
|
|
376
|
+
return receipt
|
|
377
|
+
|
|
378
|
+
def get(self, job_id: str) -> RenderReceiptV2:
|
|
379
|
+
with self._connect() as con:
|
|
380
|
+
row = con.execute("SELECT * FROM render_jobs WHERE job_id=?", (job_id,)).fetchone()
|
|
381
|
+
if not row:
|
|
382
|
+
raise RenderQueueError("JOB_NOT_FOUND", "render job not found")
|
|
383
|
+
return self._receipt_from_row(row)
|
|
384
|
+
|
|
385
|
+
def heartbeat(
|
|
386
|
+
self, *, worker_version: str, hancom_build: str, available: bool,
|
|
387
|
+
degraded_reason: str | None = None, now: datetime | None = None,
|
|
388
|
+
) -> None:
|
|
389
|
+
if not worker_version or not hancom_build:
|
|
390
|
+
raise ValueError("worker_version and hancom_build are required")
|
|
391
|
+
if not available and not degraded_reason:
|
|
392
|
+
raise ValueError("unavailable heartbeat requires degraded_reason")
|
|
393
|
+
now = now or utcnow()
|
|
394
|
+
with self._connect() as con:
|
|
395
|
+
con.execute(
|
|
396
|
+
"""INSERT INTO worker_heartbeat
|
|
397
|
+
(singleton,observed_at,worker_version,hancom_build,available,degraded_reason)
|
|
398
|
+
VALUES (1,?,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET
|
|
399
|
+
observed_at=excluded.observed_at,worker_version=excluded.worker_version,
|
|
400
|
+
hancom_build=excluded.hancom_build,available=excluded.available,
|
|
401
|
+
degraded_reason=excluded.degraded_reason""",
|
|
402
|
+
(_iso(now), worker_version, hancom_build, int(available), degraded_reason),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def health(self, *, now: datetime | None = None, heartbeat_ttl_seconds: int = 120) -> dict[str, object]:
|
|
406
|
+
now = now or utcnow()
|
|
407
|
+
with self._connect() as con:
|
|
408
|
+
counts = {row["state"]: int(row["count"]) for row in con.execute(
|
|
409
|
+
"SELECT state, COUNT(*) AS count FROM render_jobs GROUP BY state"
|
|
410
|
+
)}
|
|
411
|
+
oldest = con.execute("SELECT MIN(created_at) FROM render_jobs WHERE state='queued'").fetchone()[0]
|
|
412
|
+
success = con.execute(
|
|
413
|
+
"SELECT receipt_json,terminal_at FROM render_jobs WHERE state='succeeded' ORDER BY terminal_at DESC LIMIT 1"
|
|
414
|
+
).fetchone()
|
|
415
|
+
heartbeat = con.execute("SELECT * FROM worker_heartbeat WHERE singleton=1").fetchone()
|
|
416
|
+
last_receipt = self._receipt_from_row(success) if success else None
|
|
417
|
+
heartbeat_fresh = bool(
|
|
418
|
+
heartbeat and (now - _dt(heartbeat["observed_at"])).total_seconds() <= heartbeat_ttl_seconds
|
|
419
|
+
)
|
|
420
|
+
available = bool(heartbeat_fresh and heartbeat["available"])
|
|
421
|
+
degraded_reason = None if available else (
|
|
422
|
+
heartbeat["degraded_reason"] if heartbeat_fresh else "NO_WORKER_HEARTBEAT"
|
|
423
|
+
)
|
|
424
|
+
return {
|
|
425
|
+
"schemaVersion": "hwpx.render-health.v1",
|
|
426
|
+
"available": available,
|
|
427
|
+
"degraded": not available,
|
|
428
|
+
"degradedReason": degraded_reason,
|
|
429
|
+
"queueDepth": counts.get("queued", 0),
|
|
430
|
+
"runningJobs": counts.get("running", 0),
|
|
431
|
+
"oldestQueuedAgeSeconds": max(0.0, (now - _dt(oldest)).total_seconds()) if oldest else 0.0,
|
|
432
|
+
"lastSuccessfulRealRender": success["terminal_at"] if success else None,
|
|
433
|
+
"workerVersion": heartbeat["worker_version"] if heartbeat else (last_receipt.worker_version if last_receipt else None),
|
|
434
|
+
"hancomBuild": heartbeat["hancom_build"] if heartbeat else (last_receipt.hancom_build if last_receipt else None),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
def purge(self, *, now: datetime | None = None) -> int:
|
|
438
|
+
now = now or utcnow()
|
|
439
|
+
cutoff = now - timedelta(seconds=max(self.policy.input_retention_seconds, self.policy.output_retention_seconds))
|
|
440
|
+
with self._connect() as con:
|
|
441
|
+
con.execute("BEGIN IMMEDIATE")
|
|
442
|
+
rows = con.execute(
|
|
443
|
+
"SELECT source_path FROM render_jobs WHERE terminal_at IS NOT NULL AND terminal_at <= ?",
|
|
444
|
+
(_iso(cutoff),),
|
|
445
|
+
).fetchall()
|
|
446
|
+
removed = con.execute(
|
|
447
|
+
"DELETE FROM render_jobs WHERE terminal_at IS NOT NULL AND terminal_at <= ?",
|
|
448
|
+
(_iso(cutoff),),
|
|
449
|
+
).rowcount
|
|
450
|
+
con.commit()
|
|
451
|
+
for row in rows:
|
|
452
|
+
path = Path(row["source_path"])
|
|
453
|
+
with self._connect() as con:
|
|
454
|
+
referenced = con.execute("SELECT 1 FROM render_jobs WHERE source_path=? LIMIT 1", (str(path),)).fetchone()
|
|
455
|
+
if not referenced:
|
|
456
|
+
path.unlink(missing_ok=True)
|
|
457
|
+
return int(removed)
|
|
458
|
+
|
|
459
|
+
def _owned_running(self, con: sqlite3.Connection, lease: RenderLease) -> sqlite3.Row:
|
|
460
|
+
row = con.execute("SELECT * FROM render_jobs WHERE job_id=?", (lease.job.job_id,)).fetchone()
|
|
461
|
+
if not row or row["state"] != "running" or row["worker_id"] != lease.worker_id or row["attempt"] != lease.attempt:
|
|
462
|
+
raise RenderQueueError("LEASE_NOT_OWNED", "render lease is stale or not owned")
|
|
463
|
+
return row
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _receipt_from_row(row: sqlite3.Row) -> RenderReceiptV2:
|
|
467
|
+
return RenderReceiptV2.model_validate_json(row["receipt_json"])
|
|
468
|
+
|
|
469
|
+
@staticmethod
|
|
470
|
+
def _terminal_from_row(row: sqlite3.Row, status: RenderStatus, now: datetime, reason: str) -> RenderReceiptV2:
|
|
471
|
+
job = RenderJobV2.model_validate_json(row["job_json"])
|
|
472
|
+
return RenderReceiptV2(
|
|
473
|
+
job_id=job.job_id, workflow_id=job.workflow_id, input_content_hash=job.source_content_hash,
|
|
474
|
+
status=status, queued_at=job.submitted_at, completed_at=now,
|
|
475
|
+
retry_count=int(row["attempt"]), terminal_reason=reason,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
__all__ = [
|
|
480
|
+
"ContentAddressedStore", "DurableRenderQueue", "RenderLease", "RenderQueueError",
|
|
481
|
+
"authenticate_submission", "inspect_hwpx", "sign_submission",
|
|
482
|
+
]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Fail-closed security and log-minimization contract for render workers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal, Mapping
|
|
11
|
+
|
|
12
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
HWPX_MEDIA_TYPE = "application/hwp+zip"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RenderSecurityViolation(RuntimeError):
|
|
19
|
+
def __init__(self, code: str, message: str) -> None:
|
|
20
|
+
self.code = code
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RenderSecurityPolicy(BaseModel):
|
|
25
|
+
"""Non-optional controls frozen before queue/worker implementation."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
28
|
+
|
|
29
|
+
authenticated_transport_required: Literal[True] = True
|
|
30
|
+
private_network_required: Literal[True] = True
|
|
31
|
+
transport_auth: Literal["mtls", "signed_https"] = "mtls"
|
|
32
|
+
per_job_sandbox_required: Literal[True] = True
|
|
33
|
+
delete_on_terminal: Literal[True] = True
|
|
34
|
+
sandbox_root: Path
|
|
35
|
+
max_input_bytes: int = Field(default=100 * 1024 * 1024, gt=0)
|
|
36
|
+
max_zip_entries: int = Field(default=10_000, ge=1)
|
|
37
|
+
max_uncompressed_bytes: int = Field(default=500 * 1024 * 1024, gt=0)
|
|
38
|
+
max_compression_ratio: float = Field(default=100.0, ge=1.0)
|
|
39
|
+
input_retention_seconds: int = Field(default=3_600, ge=0, le=86_400)
|
|
40
|
+
output_retention_seconds: int = Field(default=86_400, ge=0, le=604_800)
|
|
41
|
+
log_retention_seconds: int = Field(default=604_800, ge=0, le=2_592_000)
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="after")
|
|
44
|
+
def sandbox_root_is_absolute(self) -> "RenderSecurityPolicy":
|
|
45
|
+
if not self.sandbox_root.expanduser().is_absolute():
|
|
46
|
+
raise ValueError("sandbox_root must be absolute")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def sandbox_for(self, job_id: str) -> Path:
|
|
50
|
+
if not job_id or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for character in job_id):
|
|
51
|
+
raise RenderSecurityViolation("INVALID_JOB_ID", "job id is unsafe for sandbox allocation")
|
|
52
|
+
root = self.sandbox_root.expanduser().resolve()
|
|
53
|
+
candidate = (root / job_id).resolve()
|
|
54
|
+
if candidate.parent != root:
|
|
55
|
+
raise RenderSecurityViolation("SANDBOX_ESCAPE", "job sandbox escaped configured root")
|
|
56
|
+
return candidate
|
|
57
|
+
|
|
58
|
+
def retention_deadlines(self, terminal_at: AwareDatetime) -> dict[str, AwareDatetime]:
|
|
59
|
+
return {
|
|
60
|
+
"inputDeleteBy": terminal_at + timedelta(seconds=self.input_retention_seconds),
|
|
61
|
+
"outputDeleteBy": terminal_at + timedelta(seconds=self.output_retention_seconds),
|
|
62
|
+
"logDeleteBy": terminal_at + timedelta(seconds=self.log_retention_seconds),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RenderInputInspection(BaseModel):
|
|
67
|
+
"""Result of authentication and ZIP inspection before sandbox ingestion."""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
70
|
+
|
|
71
|
+
authenticated: bool
|
|
72
|
+
principal_id: str | None = None
|
|
73
|
+
filename: str
|
|
74
|
+
media_type: str
|
|
75
|
+
compressed_bytes: int = Field(ge=0)
|
|
76
|
+
uncompressed_bytes: int = Field(ge=0)
|
|
77
|
+
zip_entries: int = Field(ge=0)
|
|
78
|
+
has_encrypted_entry: bool = False
|
|
79
|
+
has_symlink_entry: bool = False
|
|
80
|
+
has_path_traversal: bool = False
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def compression_ratio(self) -> float:
|
|
84
|
+
return self.uncompressed_bytes / max(self.compressed_bytes, 1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_render_input(
|
|
88
|
+
inspection: RenderInputInspection,
|
|
89
|
+
policy: RenderSecurityPolicy,
|
|
90
|
+
) -> RenderInputInspection:
|
|
91
|
+
checks = (
|
|
92
|
+
(not inspection.authenticated or not inspection.principal_id, "AUTH_REQUIRED"),
|
|
93
|
+
(inspection.media_type != HWPX_MEDIA_TYPE, "MEDIA_TYPE_REJECTED"),
|
|
94
|
+
(Path(inspection.filename).name != inspection.filename or not inspection.filename.lower().endswith(".hwpx"), "FILENAME_REJECTED"),
|
|
95
|
+
(inspection.compressed_bytes > policy.max_input_bytes, "INPUT_TOO_LARGE"),
|
|
96
|
+
(inspection.zip_entries > policy.max_zip_entries, "ZIP_ENTRY_LIMIT"),
|
|
97
|
+
(inspection.uncompressed_bytes > policy.max_uncompressed_bytes, "ZIP_EXPANDED_SIZE_LIMIT"),
|
|
98
|
+
(inspection.compression_ratio > policy.max_compression_ratio, "ZIP_COMPRESSION_RATIO"),
|
|
99
|
+
(inspection.has_encrypted_entry, "ENCRYPTED_ZIP_REJECTED"),
|
|
100
|
+
(inspection.has_symlink_entry, "ZIP_SYMLINK_REJECTED"),
|
|
101
|
+
(inspection.has_path_traversal, "ZIP_TRAVERSAL_REJECTED"),
|
|
102
|
+
)
|
|
103
|
+
for failed, code in checks:
|
|
104
|
+
if failed:
|
|
105
|
+
raise RenderSecurityViolation(code, f"render input rejected: {code}")
|
|
106
|
+
return inspection
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_SAFE_LOG_KEYS = frozenset(
|
|
110
|
+
{
|
|
111
|
+
"event",
|
|
112
|
+
"jobId",
|
|
113
|
+
"workflowId",
|
|
114
|
+
"status",
|
|
115
|
+
"errorCode",
|
|
116
|
+
"terminalReason",
|
|
117
|
+
"inputHash",
|
|
118
|
+
"outputHash",
|
|
119
|
+
"workerVersion",
|
|
120
|
+
"hancomBuild",
|
|
121
|
+
"durationMs",
|
|
122
|
+
"attempt",
|
|
123
|
+
"retryCount",
|
|
124
|
+
"queueDepth",
|
|
125
|
+
"pageCount",
|
|
126
|
+
"timestamp",
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
_CODE_LOG_KEYS = frozenset({"event", "status", "errorCode", "terminalReason"})
|
|
131
|
+
_CODE_PATTERN_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _redacted(value: Any) -> dict[str, Any]:
|
|
135
|
+
try:
|
|
136
|
+
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
|
|
137
|
+
except Exception:
|
|
138
|
+
encoded = repr(type(value)).encode("utf-8")
|
|
139
|
+
return {"redacted": True, "sha256": hashlib.sha256(encoded).hexdigest()}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def redact_render_log(event: Mapping[str, Any]) -> dict[str, Any]:
|
|
143
|
+
"""Return an allow-listed structured event with no text, path, or secret values."""
|
|
144
|
+
|
|
145
|
+
safe: dict[str, Any] = {}
|
|
146
|
+
for key, value in event.items():
|
|
147
|
+
if key not in _SAFE_LOG_KEYS or isinstance(value, (Mapping, list, tuple, bytes, bytearray)):
|
|
148
|
+
safe[str(key)] = _redacted(value)
|
|
149
|
+
continue
|
|
150
|
+
if key in _CODE_LOG_KEYS and (
|
|
151
|
+
not isinstance(value, str)
|
|
152
|
+
or not value
|
|
153
|
+
or len(value) > 96
|
|
154
|
+
or any(character not in _CODE_PATTERN_CHARS for character in value)
|
|
155
|
+
):
|
|
156
|
+
safe[key] = _redacted(value)
|
|
157
|
+
continue
|
|
158
|
+
if isinstance(value, str) and (len(value) > 200 or "\n" in value or "\r" in value):
|
|
159
|
+
safe[key] = _redacted(value)
|
|
160
|
+
continue
|
|
161
|
+
safe[key] = value
|
|
162
|
+
return safe
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = [
|
|
166
|
+
"HWPX_MEDIA_TYPE",
|
|
167
|
+
"RenderInputInspection",
|
|
168
|
+
"RenderSecurityPolicy",
|
|
169
|
+
"RenderSecurityViolation",
|
|
170
|
+
"redact_render_log",
|
|
171
|
+
"validate_render_input",
|
|
172
|
+
]
|