simulo 0.26.0__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.
- simulo/__init__.py +433 -0
- simulo/_client/__init__.py +6 -0
- simulo/_client/_entrypoint.py +313 -0
- simulo/_client/_mounts.py +25 -0
- simulo/_client/_runner.py +186 -0
- simulo/_client/_secure_downloads.py +1181 -0
- simulo/_client/app.py +1308 -0
- simulo/_client/asset.py +331 -0
- simulo/_client/asset_api.py +517 -0
- simulo/_client/asset_package.py +1103 -0
- simulo/_client/asset_pins.py +187 -0
- simulo/_client/builtin_aliases.py +107 -0
- simulo/_client/bundle.py +254 -0
- simulo/_client/cancel_api.py +104 -0
- simulo/_client/cli.py +9063 -0
- simulo/_client/config.py +186 -0
- simulo/_client/credentials.py +210 -0
- simulo/_client/discovery.py +214 -0
- simulo/_client/export_api.py +212 -0
- simulo/_client/export_bundle.py +296 -0
- simulo/_client/facades.py +581 -0
- simulo/_client/http.py +414 -0
- simulo/_client/identity_api.py +117 -0
- simulo/_client/install_samples.py +267 -0
- simulo/_client/jobs_api.py +224 -0
- simulo/_client/learning.py +393 -0
- simulo/_client/login.py +319 -0
- simulo/_client/mode.py +29 -0
- simulo/_client/outputs.py +116 -0
- simulo/_client/packaging.py +445 -0
- simulo/_client/preflight_api.py +186 -0
- simulo/_client/preflight_render.py +200 -0
- simulo/_client/registry.py +98 -0
- simulo/_client/runtime.py +185 -0
- simulo/_client/runtime_display.py +90 -0
- simulo/_client/seed_ref.py +76 -0
- simulo/_client/stub.py +41 -0
- simulo/_client/submit_api.py +1057 -0
- simulo/_client/templates/__init__.py +21 -0
- simulo/_client/templates/inference/app.py.tmpl +316 -0
- simulo/_client/templates/inference/simuloignore.tmpl +30 -0
- simulo/_client/templates/scenario/app.py.tmpl +93 -0
- simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
- simulo/_client/templates/training/app.py.tmpl +235 -0
- simulo/_client/templates/training/simuloignore.tmpl +29 -0
- simulo/_client/view_fragment.py +21 -0
- simulo/_client/view_session_api.py +122 -0
- simulo/_client/volume.py +71 -0
- simulo/callbacks.py +274 -0
- simulo/py.typed +0 -0
- simulo-0.26.0.dist-info/METADATA +130 -0
- simulo-0.26.0.dist-info/RECORD +55 -0
- simulo-0.26.0.dist-info/WHEEL +5 -0
- simulo-0.26.0.dist-info/entry_points.txt +2 -0
- simulo-0.26.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""HTTP client for the policy-export surface (stdlib only, torch-free).
|
|
2
|
+
|
|
3
|
+
Implements the client half of ``POST``/``GET``
|
|
4
|
+
:data:`~simulo.interfaces.platform.policy_bundle.JOB_EXPORTS_ROUTE_TEMPLATE`
|
|
5
|
+
— request a portable policy export of one of a training job's checkpoints, and
|
|
6
|
+
read back the export's status and the platform's own conversion-equivalence
|
|
7
|
+
verdict. Request plumbing (structured errors, the https-when-token guard, the
|
|
8
|
+
foreign-host bearer rule) is shared with ``jobs_api.py``/``submit_api.py`` via
|
|
9
|
+
``http.py``.
|
|
10
|
+
|
|
11
|
+
**The route and the vocabulary are imported, never retyped.** The path string,
|
|
12
|
+
the export format enum, the verdict, and the four named failure reasons all
|
|
13
|
+
live in ``simulo.interfaces.platform.policy_bundle``; this module holds no
|
|
14
|
+
copy of any of them, so a contract change cannot leave a stale spelling here.
|
|
15
|
+
|
|
16
|
+
**Scope.** Both verbs resolve access from the SOURCE job and carry one explicit
|
|
17
|
+
``?scope=`` through create/list/poll. Direct SDK calls default to ``org``
|
|
18
|
+
because naming a source job is explicit intent; id-less CLI flows resolve the
|
|
19
|
+
latest caller-owned job first and then pass ``mine`` through these methods.
|
|
20
|
+
|
|
21
|
+
That default would restrict exporting to jobs the caller personally submitted,
|
|
22
|
+
which is wrong for the case the feature exists to serve: one engineer trains a
|
|
23
|
+
policy and another puts it on a robot. Organization members share organization
|
|
24
|
+
jobs and outputs under the current authorization model, so a member who can
|
|
25
|
+
already read a teammate's job and download its checkpoints (``simulo models``)
|
|
26
|
+
has no coherent reason to be refused its ONNX bundle. ``simulo cancel`` set the
|
|
27
|
+
precedent — it resolves org-wide because naming a job explicitly is explicit
|
|
28
|
+
intent, and the equal-standing model deliberately extends to it. It is hard to
|
|
29
|
+
argue a read needs a stricter default than a destructive write.
|
|
30
|
+
|
|
31
|
+
Sibling read methods (``simulo models``, ``simulo outputs``, and
|
|
32
|
+
``simulo recordings``) expose the same explicit scope plumbing: named jobs use
|
|
33
|
+
organization scope, while an id-less latest selection keeps caller scope.
|
|
34
|
+
|
|
35
|
+
This widens nothing across an organization boundary: ``scope=org`` is still
|
|
36
|
+
org-bounded, and the server re-checks membership on every request. Nor does it
|
|
37
|
+
change the zero-argument path, which resolves "the latest job" as the caller's
|
|
38
|
+
own before this client is reached.
|
|
39
|
+
|
|
40
|
+
The EXPORT job the response names is system-submitted
|
|
41
|
+
(``submitted_by_user_id=None``) and is therefore invisible under the default
|
|
42
|
+
too: every read of the export job itself (its outputs, its download link)
|
|
43
|
+
must pass ``scope=org``. The output methods on the submit client take an
|
|
44
|
+
explicit ``scope`` for exactly this reason.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import urllib.parse
|
|
50
|
+
from typing import Any, Optional
|
|
51
|
+
|
|
52
|
+
from simulo._client import http
|
|
53
|
+
from simulo.interfaces.platform.policy_bundle import JOB_EXPORTS_ROUTE_TEMPLATE, PolicyExportFormat
|
|
54
|
+
from simulo.interfaces.platform.runs import JOB_SCOPE_ORG
|
|
55
|
+
|
|
56
|
+
_REQUEST_TIMEOUT_S = 10.0 # every outbound call has an explicit timeout (NFR)
|
|
57
|
+
|
|
58
|
+
#: Page size used when scanning a source job's exports for one export id.
|
|
59
|
+
_LIST_PAGE_LIMIT = 100
|
|
60
|
+
|
|
61
|
+
#: Pages :meth:`ExportApiClient.find_export` will scan before giving up. The
|
|
62
|
+
#: listing is newest-first and an export is looked up right after it was
|
|
63
|
+
#: created, so page 1 holds it unless 100+ later exports of the SAME source job
|
|
64
|
+
#: were created in between; a handful of pages is generous headroom over that
|
|
65
|
+
#: and keeps a poll loop's per-iteration cost bounded (a poll that could issue
|
|
66
|
+
#: 1000 requests per tick is a self-inflicted rate limit).
|
|
67
|
+
_LIST_MAX_PAGES = 5
|
|
68
|
+
|
|
69
|
+
_UNAVAILABLE_HINT = "Check SIMULO_API_URL / SIMULO_ENV, and that you are logged in (`simulo login`)."
|
|
70
|
+
|
|
71
|
+
#: The default portable format — the contract's enum, not a literal.
|
|
72
|
+
DEFAULT_EXPORT_FORMAT = PolicyExportFormat.ONNX.value
|
|
73
|
+
|
|
74
|
+
#: Scope both export verbs send when resolving the SOURCE job. Organization
|
|
75
|
+
#: members share organization jobs and outputs, so a member who can already
|
|
76
|
+
#: read a teammate's training run and download its checkpoints must not be
|
|
77
|
+
#: refused its ONNX bundle. Org-bounded, and the server re-checks membership
|
|
78
|
+
#: per request; the zero-argument path still resolves "latest" as the caller's
|
|
79
|
+
#: own job before reaching this client.
|
|
80
|
+
SOURCE_JOB_SCOPE = JOB_SCOPE_ORG
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ExportApiError(http.HttpError):
|
|
84
|
+
"""A malformed or unusable response from the export surface.
|
|
85
|
+
|
|
86
|
+
A ``RuntimeError`` subclass sharing ``http.HttpError``'s base with
|
|
87
|
+
``JobsApiError``/``SubmitApiError``, so the CLI's shared observe-command
|
|
88
|
+
handler renders it as a clean one-liner rather than a traceback. Structured
|
|
89
|
+
non-2xx responses stay ``http.HttpHTTPError`` (also an ``HttpError``) so the
|
|
90
|
+
CLI can switch on the server's own error ``code``.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ExportApiClient:
|
|
95
|
+
"""Client for ``POST``/``GET /v1/jobs/{job_id}/exports``."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, base_url: str, *, token: Optional[str] = None) -> None:
|
|
98
|
+
if not base_url.startswith(("http://", "https://")):
|
|
99
|
+
raise ExportApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
|
|
100
|
+
self._base_url = base_url.rstrip("/")
|
|
101
|
+
self._token = token
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def base_url(self) -> str:
|
|
105
|
+
return self._base_url
|
|
106
|
+
|
|
107
|
+
def create_export(
|
|
108
|
+
self,
|
|
109
|
+
job_id: str,
|
|
110
|
+
*,
|
|
111
|
+
export_format: str = DEFAULT_EXPORT_FORMAT,
|
|
112
|
+
model_id: Optional[str] = None,
|
|
113
|
+
kind: Optional[str] = None,
|
|
114
|
+
scope: str = SOURCE_JOB_SCOPE,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""``POST .../exports`` — request an export; return the export record.
|
|
117
|
+
|
|
118
|
+
``model_id`` and ``kind`` are OVERRIDES and are sent only when the user
|
|
119
|
+
typed one: the platform's default (this job's newest ``best``
|
|
120
|
+
checkpoint) is an answer Simulo already knows, so the
|
|
121
|
+
zero-argument path posts nothing but the format.
|
|
122
|
+
|
|
123
|
+
The status code is the created-vs-replayed signal (201 new, 200 an
|
|
124
|
+
in-flight export of the same source job/checkpoint/format returned
|
|
125
|
+
as-is) and both bodies are the same shape, so this method treats them
|
|
126
|
+
alike — pressing the button twice attaches to the export already on its
|
|
127
|
+
way rather than paying for a second one.
|
|
128
|
+
"""
|
|
129
|
+
body: dict[str, Any] = {"format": export_format}
|
|
130
|
+
if model_id is not None:
|
|
131
|
+
body["model_id"] = model_id
|
|
132
|
+
if kind is not None:
|
|
133
|
+
body["kind"] = kind
|
|
134
|
+
payload = http.request_json(
|
|
135
|
+
"POST",
|
|
136
|
+
self._base_url + self._path(job_id) + "?" + urllib.parse.urlencode({"scope": scope}),
|
|
137
|
+
token=self._token,
|
|
138
|
+
api_base_url=self._base_url,
|
|
139
|
+
json_body=body,
|
|
140
|
+
timeout=_REQUEST_TIMEOUT_S,
|
|
141
|
+
unavailable_hint=_UNAVAILABLE_HINT,
|
|
142
|
+
)
|
|
143
|
+
return self._record(payload)
|
|
144
|
+
|
|
145
|
+
def list_exports_page(
|
|
146
|
+
self, job_id: str, *, limit: int, page: int = 1, scope: str = SOURCE_JOB_SCOPE
|
|
147
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
148
|
+
"""One page of *job_id*'s exports (newest first) plus the total count."""
|
|
149
|
+
url = (
|
|
150
|
+
self._base_url
|
|
151
|
+
+ self._path(job_id)
|
|
152
|
+
+ "?"
|
|
153
|
+
+ urllib.parse.urlencode({"limit": str(limit), "page": str(page), "scope": scope})
|
|
154
|
+
)
|
|
155
|
+
payload = http.request_json(
|
|
156
|
+
"GET",
|
|
157
|
+
url,
|
|
158
|
+
token=self._token,
|
|
159
|
+
api_base_url=self._base_url,
|
|
160
|
+
timeout=_REQUEST_TIMEOUT_S,
|
|
161
|
+
unavailable_hint=_UNAVAILABLE_HINT,
|
|
162
|
+
)
|
|
163
|
+
items = payload.get("items") if isinstance(payload, dict) else None
|
|
164
|
+
total = payload.get("total") if isinstance(payload, dict) else None
|
|
165
|
+
if not isinstance(items, list) or not isinstance(total, int):
|
|
166
|
+
raise ExportApiError("Malformed exports list response (no 'items'/'total').")
|
|
167
|
+
return [item for item in items if isinstance(item, dict)], total
|
|
168
|
+
|
|
169
|
+
def find_export(
|
|
170
|
+
self, job_id: str, export_job_id: str, *, scope: str = SOURCE_JOB_SCOPE
|
|
171
|
+
) -> Optional[dict[str, Any]]:
|
|
172
|
+
"""The export record for *export_job_id* under source job *job_id*.
|
|
173
|
+
|
|
174
|
+
``None`` when the listing does not carry it — a state the caller must
|
|
175
|
+
report rather than paper over: an export that vanished from its own
|
|
176
|
+
source job's listing is a server-side inconsistency, never a reason to
|
|
177
|
+
assume any particular status.
|
|
178
|
+
"""
|
|
179
|
+
page = 1
|
|
180
|
+
while page <= _LIST_MAX_PAGES:
|
|
181
|
+
items, _total = self.list_exports_page(job_id, limit=_LIST_PAGE_LIMIT, page=page, scope=scope)
|
|
182
|
+
for item in items:
|
|
183
|
+
if str(item.get("export_job_id")) == export_job_id:
|
|
184
|
+
return self._record(item)
|
|
185
|
+
if len(items) < _LIST_PAGE_LIMIT:
|
|
186
|
+
return None
|
|
187
|
+
page += 1
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# Internals
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _path(job_id: str) -> str:
|
|
196
|
+
return JOB_EXPORTS_ROUTE_TEMPLATE.format(job_id=http.quote_path_segment(job_id))
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _record(payload: Any) -> dict[str, Any]:
|
|
200
|
+
"""Validate the two fields every later decision reads.
|
|
201
|
+
|
|
202
|
+
Deliberately narrow: only ``export_job_id`` and ``status`` are checked,
|
|
203
|
+
because everything else on the record is either optional by contract
|
|
204
|
+
(``validation`` is ``null`` until the export finishes) or display text.
|
|
205
|
+
A response missing either of these cannot be polled or reported on at
|
|
206
|
+
all, so it fails here rather than as an opaque ``KeyError`` later.
|
|
207
|
+
"""
|
|
208
|
+
if not isinstance(payload, dict):
|
|
209
|
+
raise ExportApiError("Malformed export record response: expected a JSON object.")
|
|
210
|
+
if not payload.get("export_job_id") or not payload.get("status"):
|
|
211
|
+
raise ExportApiError("Malformed export record response (no 'export_job_id'/'status').")
|
|
212
|
+
return payload
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Unpack a downloaded policy-bundle archive, safely and to the contract.
|
|
2
|
+
|
|
3
|
+
``simulo export`` hands the user a DIRECTORY they immediately ``cd`` into and
|
|
4
|
+
run ``python verify.py`` from, so this module owns the step between "verified
|
|
5
|
+
bytes on disk" and "a bundle the next command in the printed block will work
|
|
6
|
+
against". Three jobs, all fail-closed:
|
|
7
|
+
|
|
8
|
+
* **Extraction safety.** The archive is server-supplied. Every member name is
|
|
9
|
+
reduced and checked before a single byte is written — no absolute paths, no
|
|
10
|
+
``..``, no drive letters or backslash separators (a Windows-hostile name is
|
|
11
|
+
still a plain name on POSIX and must be refused on both), no links or
|
|
12
|
+
devices, nothing outside the destination, and a running uncompressed-size
|
|
13
|
+
ceiling enforced DURING the copy.
|
|
14
|
+
|
|
15
|
+
What that ceiling defends is a large LEGITIMATE member — an honest
|
|
16
|
+
``model.onnx`` or ``model.pt`` big enough to fill the user's disk from a
|
|
17
|
+
command they ran to fetch a model. It is deliberately NOT defending against
|
|
18
|
+
a forged ``file_size``: that bypass is unreachable, and this module used to
|
|
19
|
+
claim otherwise. ``zipfile`` bounds each member's decompressed output by the
|
|
20
|
+
size the header declares and raises ``BadZipFile`` on the CRC mismatch a
|
|
21
|
+
forgery produces, so a lying header cannot yield more bytes than it declared
|
|
22
|
+
(measured: a member whose declared size is understated delivers ZERO bytes,
|
|
23
|
+
not too many). Enforcing during the copy is still the right shape — it
|
|
24
|
+
bounds the bytes actually WRITTEN, which is the quantity the disk cares
|
|
25
|
+
about — but it buys no extra protection against a dishonest header.
|
|
26
|
+
* **Contract conformance.** ``simulo.interfaces.platform.policy_bundle``
|
|
27
|
+
defines exactly which files a bundle holds and which of them are required.
|
|
28
|
+
An archive carrying a file outside that set, or missing one of the required
|
|
29
|
+
ones, is refused with the offending name — the local-verification guarantee
|
|
30
|
+
is a property of the FORMAT, so an archive that is not that format must not
|
|
31
|
+
be presented as one.
|
|
32
|
+
* **Never destroying a good bundle for a bad archive.** Extraction happens in
|
|
33
|
+
a staging directory beside the destination and is only swapped into place
|
|
34
|
+
once the whole archive has passed. Re-running ``simulo export`` therefore
|
|
35
|
+
replaces the previous bundle atomically on success and leaves it entirely
|
|
36
|
+
untouched on failure.
|
|
37
|
+
|
|
38
|
+
Names and the file set come from the contract module; nothing here is
|
|
39
|
+
retyped.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import shutil
|
|
47
|
+
import tempfile
|
|
48
|
+
import zipfile
|
|
49
|
+
from pathlib import Path, PurePosixPath
|
|
50
|
+
from typing import Optional
|
|
51
|
+
|
|
52
|
+
from simulo.interfaces.platform.policy_bundle import (
|
|
53
|
+
MAX_POLICY_BUNDLE_UNCOMPRESSED_BYTES,
|
|
54
|
+
POLICY_BUNDLE_ARCHIVE_NAME_TEMPLATE,
|
|
55
|
+
POLICY_BUNDLE_FILES,
|
|
56
|
+
POLICY_BUNDLE_MANIFEST_FILE,
|
|
57
|
+
POLICY_BUNDLE_REQUIRED_FILES,
|
|
58
|
+
is_supported_policy_bundle_schema,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
#: Suffix every bundle archive carries, derived from the contract's own
|
|
62
|
+
#: archive-name template rather than typed as ``".zip"``.
|
|
63
|
+
BUNDLE_ARCHIVE_SUFFIX = POLICY_BUNDLE_ARCHIVE_NAME_TEMPLATE.rsplit("}", 1)[-1]
|
|
64
|
+
|
|
65
|
+
#: Ceiling on the TOTAL uncompressed bytes one bundle may expand to — THE
|
|
66
|
+
#: contract's own value, not a number this module chose.
|
|
67
|
+
#:
|
|
68
|
+
#: Binding it to :data:`MAX_POLICY_BUNDLE_UNCOMPRESSED_BYTES` is what makes the
|
|
69
|
+
#: refusal below safe to ship: the exporter holds every bundle it publishes to
|
|
70
|
+
#: the same budget (``simulo.backend.export.exporter``), so this command
|
|
71
|
+
#: refuses only archives that are NOT conforming bundles. When the two were
|
|
72
|
+
#: independent literals — and for a while the producer had no limit at all —
|
|
73
|
+
#: this ceiling could refuse a bundle Simulo had happily built, validated,
|
|
74
|
+
#: uploaded and billed for (issue #654, QA pass). The alias is kept as a
|
|
75
|
+
#: module-level name because it is what the refusal message quotes.
|
|
76
|
+
MAX_BUNDLE_UNCOMPRESSED_BYTES = MAX_POLICY_BUNDLE_UNCOMPRESSED_BYTES
|
|
77
|
+
|
|
78
|
+
#: Chunk size for the size-capped member copy.
|
|
79
|
+
_COPY_CHUNK_BYTES = 256 * 1024
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PolicyBundleError(ValueError):
|
|
83
|
+
"""A downloaded bundle archive that cannot be safely or faithfully unpacked.
|
|
84
|
+
|
|
85
|
+
A ``ValueError`` so the CLI's existing local-failure handling reports it as
|
|
86
|
+
one clean line; the message always names the specific member or missing
|
|
87
|
+
file, never just "invalid bundle".
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def bundle_root_dir_name(archive_name: str) -> str:
|
|
92
|
+
"""The top-level directory *archive_name* must contain.
|
|
93
|
+
|
|
94
|
+
The contract pins archive and root-directory names to the same
|
|
95
|
+
``{policy_name}-policy-{model_kind}`` stem, so the expected directory is
|
|
96
|
+
the archive filename minus its suffix — read from the templates, not
|
|
97
|
+
re-derived from a job name (which is exactly what
|
|
98
|
+
``policy_bundle_root_dir``/``policy_bundle_archive_name`` exist to stop
|
|
99
|
+
consumers doing).
|
|
100
|
+
"""
|
|
101
|
+
stem = archive_name[: -len(BUNDLE_ARCHIVE_SUFFIX)] if archive_name.endswith(BUNDLE_ARCHIVE_SUFFIX) else archive_name
|
|
102
|
+
if not stem:
|
|
103
|
+
raise PolicyBundleError(f"Bundle archive name {archive_name!r} has no directory stem.")
|
|
104
|
+
return stem
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _member_relative_path(name: str, *, root: str) -> Optional[str]:
|
|
108
|
+
"""Reduce one archive member name to its path INSIDE the bundle root.
|
|
109
|
+
|
|
110
|
+
Returns ``None`` for the root directory entry itself (which is skipped,
|
|
111
|
+
not written); raises :class:`PolicyBundleError` for anything unsafe or
|
|
112
|
+
off-contract. Every rejection names the member.
|
|
113
|
+
"""
|
|
114
|
+
if "\\" in name:
|
|
115
|
+
# A backslash is a legal character in a POSIX filename, so a member
|
|
116
|
+
# named ``a\..\..\evil`` extracts to one harmless file here and to a
|
|
117
|
+
# traversal on Windows. Refuse on every platform rather than letting
|
|
118
|
+
# the archive's meaning depend on where it is unpacked.
|
|
119
|
+
raise PolicyBundleError(f"Bundle member {name!r} contains a backslash; refusing to unpack it.")
|
|
120
|
+
if name.startswith("/") or (len(name) > 1 and name[1] == ":"):
|
|
121
|
+
raise PolicyBundleError(f"Bundle member {name!r} is an absolute path; refusing to unpack it.")
|
|
122
|
+
parts = [part for part in PurePosixPath(name).parts if part not in ("", ".")]
|
|
123
|
+
if any(part == ".." for part in parts):
|
|
124
|
+
raise PolicyBundleError(f"Bundle member {name!r} escapes the bundle directory; refusing to unpack it.")
|
|
125
|
+
if not parts:
|
|
126
|
+
raise PolicyBundleError(f"Bundle member {name!r} has no usable name; refusing to unpack it.")
|
|
127
|
+
if parts[0] != root:
|
|
128
|
+
raise PolicyBundleError(
|
|
129
|
+
f"Bundle member {name!r} is outside the bundle's own directory {root!r}; refusing to unpack it."
|
|
130
|
+
)
|
|
131
|
+
inner = parts[1:]
|
|
132
|
+
if not inner:
|
|
133
|
+
return None # the root directory entry itself
|
|
134
|
+
if len(inner) > 1 or inner[0] not in POLICY_BUNDLE_FILES:
|
|
135
|
+
raise PolicyBundleError(
|
|
136
|
+
f"Bundle member {name!r} is not part of a Simulo policy bundle "
|
|
137
|
+
f"(expected one of: {', '.join(POLICY_BUNDLE_FILES)}); refusing to unpack it."
|
|
138
|
+
)
|
|
139
|
+
return inner[0]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_link_or_special(info: zipfile.ZipInfo) -> bool:
|
|
143
|
+
"""``True`` for a member that is not a plain file or directory.
|
|
144
|
+
|
|
145
|
+
``external_attr``'s high 16 bits carry the POSIX mode when the archive was
|
|
146
|
+
written on a Unix host, and only the FILE-TYPE bits of that word decide
|
|
147
|
+
this: a symlink (``S_IFLNK``) is refused — ``zipfile`` writes its target as
|
|
148
|
+
file CONTENT, and a later reader following it leaves the bundle — as is any
|
|
149
|
+
other non-regular, non-directory type.
|
|
150
|
+
|
|
151
|
+
The type bits being ABSENT is not a signal of anything and must not be read
|
|
152
|
+
as one: ``ZipFile.writestr`` stamps a bare ``0o600`` permission word with no
|
|
153
|
+
type bits at all, so a "type must be S_IFREG" test rejects perfectly
|
|
154
|
+
ordinary archives (measured — it rejected this package's own fixtures), and
|
|
155
|
+
a zero high word means no mode was recorded at all.
|
|
156
|
+
"""
|
|
157
|
+
file_type = (info.external_attr >> 16) & 0o170000
|
|
158
|
+
return file_type not in (0, 0o100000, 0o040000)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _copy_member(archive: zipfile.ZipFile, info: zipfile.ZipInfo, dest: Path, *, budget: int) -> int:
|
|
162
|
+
"""Copy one member to *dest*, consuming at most *budget* bytes; return the size."""
|
|
163
|
+
written = 0
|
|
164
|
+
with archive.open(info) as source, open(dest, "wb") as target:
|
|
165
|
+
while True:
|
|
166
|
+
chunk = source.read(_COPY_CHUNK_BYTES)
|
|
167
|
+
if not chunk:
|
|
168
|
+
break
|
|
169
|
+
written += len(chunk)
|
|
170
|
+
if written > budget:
|
|
171
|
+
raise PolicyBundleError(
|
|
172
|
+
f"Bundle expands past the {MAX_BUNDLE_UNCOMPRESSED_BYTES} byte unpack ceiling at member "
|
|
173
|
+
f"{info.filename!r}; refusing to unpack it."
|
|
174
|
+
)
|
|
175
|
+
target.write(chunk)
|
|
176
|
+
return written
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _carries_bundle_manifest(dest_dir: Path) -> bool:
|
|
180
|
+
"""Is *dest_dir* POSITIVELY identified as a policy bundle?
|
|
181
|
+
|
|
182
|
+
A readable ``manifest.json`` declaring a bundle schema version this
|
|
183
|
+
software supports. Nothing weaker will do: the archive that determines
|
|
184
|
+
which NAMES land in the destination is server-supplied, so "every entry is
|
|
185
|
+
a bundle member name" is a property an attacker chooses, not evidence
|
|
186
|
+
about what was there before. The marker is content the bundle FORMAT
|
|
187
|
+
requires and a bystander directory has no reason to contain.
|
|
188
|
+
"""
|
|
189
|
+
manifest_path = dest_dir / POLICY_BUNDLE_MANIFEST_FILE
|
|
190
|
+
if manifest_path.is_symlink() or not manifest_path.is_file():
|
|
191
|
+
return False
|
|
192
|
+
try:
|
|
193
|
+
parsed = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
194
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
195
|
+
return False
|
|
196
|
+
if not isinstance(parsed, dict):
|
|
197
|
+
return False
|
|
198
|
+
version = parsed.get("schema_version")
|
|
199
|
+
# The contract's check takes a str; a manifest that does not even carry one
|
|
200
|
+
# is not a bundle manifest, which is the same answer.
|
|
201
|
+
return isinstance(version, str) and is_supported_policy_bundle_schema(version)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _assert_replaceable(dest_dir: Path) -> None:
|
|
205
|
+
"""Refuse unless *dest_dir* is absent or unmistakably a previous bundle.
|
|
206
|
+
|
|
207
|
+
Re-running ``simulo export`` must not fail on its own last output, and it
|
|
208
|
+
must never delete anything else. Two conditions, and the second is the one
|
|
209
|
+
that carries the guarantee:
|
|
210
|
+
|
|
211
|
+
* every entry is a bundle member file — anything else (a user's own file,
|
|
212
|
+
a subdirectory, a same-named project folder) is refused by name; and
|
|
213
|
+
* the directory carries the bundle's own marker
|
|
214
|
+
(:func:`_carries_bundle_manifest`). A name-set test alone is decided
|
|
215
|
+
entirely by names the DOWNLOADED ARCHIVE chose, so an archive whose
|
|
216
|
+
members happen to be spelled like a bundle's would make this function
|
|
217
|
+
approve deleting an existing directory that never was one. The marker is
|
|
218
|
+
the positive evidence; absence of contradiction is not evidence.
|
|
219
|
+
|
|
220
|
+
The cost is deliberate: a previous download whose ``manifest.json`` is
|
|
221
|
+
unreadable is no longer replaced automatically, and the refusal says so
|
|
222
|
+
and names the two ways out.
|
|
223
|
+
"""
|
|
224
|
+
if not dest_dir.exists():
|
|
225
|
+
return
|
|
226
|
+
if dest_dir.is_symlink() or not dest_dir.is_dir():
|
|
227
|
+
raise PolicyBundleError(
|
|
228
|
+
f"{dest_dir} already exists and is not a directory. Move it aside, or pass -o DIR to unpack "
|
|
229
|
+
"somewhere else."
|
|
230
|
+
)
|
|
231
|
+
for entry in dest_dir.iterdir():
|
|
232
|
+
if entry.is_symlink() or not entry.is_file() or entry.name not in POLICY_BUNDLE_FILES:
|
|
233
|
+
raise PolicyBundleError(
|
|
234
|
+
f"{dest_dir} already exists and is not a previously downloaded bundle (it contains "
|
|
235
|
+
f"{entry.name!r}). Move it aside, or pass -o DIR to unpack somewhere else."
|
|
236
|
+
)
|
|
237
|
+
if not _carries_bundle_manifest(dest_dir):
|
|
238
|
+
raise PolicyBundleError(
|
|
239
|
+
f"{dest_dir} already exists and is not a previously downloaded bundle (no readable "
|
|
240
|
+
f"{POLICY_BUNDLE_MANIFEST_FILE} declaring a supported bundle version). Move it aside, or pass "
|
|
241
|
+
"-o DIR to unpack somewhere else."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _extract_into(archive_path: Path, staging_root: Path, *, root: str, archive_name: str) -> Path:
|
|
246
|
+
"""Extract every member of *archive_path* under ``staging_root/root``."""
|
|
247
|
+
staged = staging_root / root
|
|
248
|
+
staged.mkdir()
|
|
249
|
+
try:
|
|
250
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
251
|
+
budget = MAX_BUNDLE_UNCOMPRESSED_BYTES
|
|
252
|
+
written: set[str] = set()
|
|
253
|
+
for info in archive.infolist():
|
|
254
|
+
if _is_link_or_special(info):
|
|
255
|
+
raise PolicyBundleError(
|
|
256
|
+
f"Bundle member {info.filename!r} is a link or special file; refusing to unpack it."
|
|
257
|
+
)
|
|
258
|
+
relative = _member_relative_path(info.filename, root=root)
|
|
259
|
+
if relative is None or info.is_dir():
|
|
260
|
+
continue
|
|
261
|
+
budget -= _copy_member(archive, info, staged / relative, budget=budget)
|
|
262
|
+
written.add(relative)
|
|
263
|
+
except zipfile.BadZipFile as exc:
|
|
264
|
+
raise PolicyBundleError(f"Bundle archive {archive_name!r} is not a readable zip archive ({exc}).") from exc
|
|
265
|
+
missing = [name for name in POLICY_BUNDLE_REQUIRED_FILES if name not in written]
|
|
266
|
+
if missing:
|
|
267
|
+
raise PolicyBundleError(f"Bundle archive {archive_name!r} is missing required file(s): {', '.join(missing)}.")
|
|
268
|
+
return staged
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def extract_policy_bundle(archive_path: Path, dest_root: Path, *, archive_name: str) -> Path:
|
|
272
|
+
"""Unpack *archive_path* under *dest_root*; return the bundle directory.
|
|
273
|
+
|
|
274
|
+
The archive must contain exactly one top-level directory, named for the
|
|
275
|
+
archive itself, holding only contract-declared bundle files and all of the
|
|
276
|
+
required ones. Everything is staged in a sibling temporary directory and
|
|
277
|
+
swapped in only once the whole archive has passed, so a refused archive
|
|
278
|
+
leaves *dest_root* exactly as it found it — including any bundle a previous
|
|
279
|
+
run of this command left there.
|
|
280
|
+
"""
|
|
281
|
+
root = bundle_root_dir_name(archive_name)
|
|
282
|
+
dest_dir = dest_root / root
|
|
283
|
+
_assert_replaceable(dest_dir)
|
|
284
|
+
staging_root = Path(tempfile.mkdtemp(dir=dest_root, prefix=f".{root}.", suffix=".unpack"))
|
|
285
|
+
try:
|
|
286
|
+
staged = _extract_into(archive_path, staging_root, root=root, archive_name=archive_name)
|
|
287
|
+
# Re-check immediately before the swap: the pre-check above ran before
|
|
288
|
+
# a potentially long extraction, so this is the narrow window's close,
|
|
289
|
+
# not a duplicate.
|
|
290
|
+
_assert_replaceable(dest_dir)
|
|
291
|
+
if dest_dir.exists():
|
|
292
|
+
shutil.rmtree(dest_dir)
|
|
293
|
+
os.replace(staged, dest_dir)
|
|
294
|
+
finally:
|
|
295
|
+
shutil.rmtree(staging_root, ignore_errors=True)
|
|
296
|
+
return dest_dir
|