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.
Files changed (55) hide show
  1. simulo/__init__.py +433 -0
  2. simulo/_client/__init__.py +6 -0
  3. simulo/_client/_entrypoint.py +313 -0
  4. simulo/_client/_mounts.py +25 -0
  5. simulo/_client/_runner.py +186 -0
  6. simulo/_client/_secure_downloads.py +1181 -0
  7. simulo/_client/app.py +1308 -0
  8. simulo/_client/asset.py +331 -0
  9. simulo/_client/asset_api.py +517 -0
  10. simulo/_client/asset_package.py +1103 -0
  11. simulo/_client/asset_pins.py +187 -0
  12. simulo/_client/builtin_aliases.py +107 -0
  13. simulo/_client/bundle.py +254 -0
  14. simulo/_client/cancel_api.py +104 -0
  15. simulo/_client/cli.py +9063 -0
  16. simulo/_client/config.py +186 -0
  17. simulo/_client/credentials.py +210 -0
  18. simulo/_client/discovery.py +214 -0
  19. simulo/_client/export_api.py +212 -0
  20. simulo/_client/export_bundle.py +296 -0
  21. simulo/_client/facades.py +581 -0
  22. simulo/_client/http.py +414 -0
  23. simulo/_client/identity_api.py +117 -0
  24. simulo/_client/install_samples.py +267 -0
  25. simulo/_client/jobs_api.py +224 -0
  26. simulo/_client/learning.py +393 -0
  27. simulo/_client/login.py +319 -0
  28. simulo/_client/mode.py +29 -0
  29. simulo/_client/outputs.py +116 -0
  30. simulo/_client/packaging.py +445 -0
  31. simulo/_client/preflight_api.py +186 -0
  32. simulo/_client/preflight_render.py +200 -0
  33. simulo/_client/registry.py +98 -0
  34. simulo/_client/runtime.py +185 -0
  35. simulo/_client/runtime_display.py +90 -0
  36. simulo/_client/seed_ref.py +76 -0
  37. simulo/_client/stub.py +41 -0
  38. simulo/_client/submit_api.py +1057 -0
  39. simulo/_client/templates/__init__.py +21 -0
  40. simulo/_client/templates/inference/app.py.tmpl +316 -0
  41. simulo/_client/templates/inference/simuloignore.tmpl +30 -0
  42. simulo/_client/templates/scenario/app.py.tmpl +93 -0
  43. simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
  44. simulo/_client/templates/training/app.py.tmpl +235 -0
  45. simulo/_client/templates/training/simuloignore.tmpl +29 -0
  46. simulo/_client/view_fragment.py +21 -0
  47. simulo/_client/view_session_api.py +122 -0
  48. simulo/_client/volume.py +71 -0
  49. simulo/callbacks.py +274 -0
  50. simulo/py.typed +0 -0
  51. simulo-0.26.0.dist-info/METADATA +130 -0
  52. simulo-0.26.0.dist-info/RECORD +55 -0
  53. simulo-0.26.0.dist-info/WHEEL +5 -0
  54. simulo-0.26.0.dist-info/entry_points.txt +2 -0
  55. 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