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,1103 @@
1
+ """Asset-package inspection + assembly for ``simulo asset publish``/``validate``.
2
+
3
+ The client half of the USD Asset Catalogs publish flow (wire truth in
4
+ ``simulo.interfaces.platform.asset_catalog``): given a directory or file the
5
+ user pointed at, produce everything the initiate request needs —
6
+
7
+ * the **source format** (``usd`` or ``urdf``) and the **entry** path,
8
+ auto-detected where unambiguous and refused with a corrected, copyable
9
+ command where not (``--entry`` is required only when the entry is ambiguous);
10
+ * the **file manifest** (package-root-relative POSIX paths, sizes, bare-hex
11
+ sha256s) over the SOURCE files;
12
+ * the **content digest** — the package content-identity key (kind + name +
13
+ entry + the sorted source-file digests), **algo-prefixed** ``sha256:<hex>``
14
+ per the shared asset-catalog interface ("content_digest canonical form: algo-prefixed everywhere");
15
+ * a **deterministic tar** of the package (sorted members, zeroed metadata) so
16
+ identical trees produce identical archive bytes — idempotent re-publish is
17
+ byte-stable end to end.
18
+
19
+ **The refusal catalog lives here.** Absolute paths, relative references
20
+ that escape the package root, ``nucleus://`` paths, the removed legacy
21
+ ``simulo://`` alias (never valid inside a published asset), and URLs inside
22
+ the package all FAIL publication, each named with the exact authored
23
+ reference and the file that contains it, plus how to fix it. The ``--root``
24
+ flag is a security and portability boundary: dependencies are collected
25
+ beneath it and never swept from elsewhere on the workstation.
26
+
27
+ **Heavy-import discipline.** The USD path needs ``pxr`` (``usd-core``), which
28
+ ships behind the ``simulo[assets]`` extra — imported LAZILY inside
29
+ :func:`_load_pxr_usdutils` only, so ``import simulo`` and every non-USD command
30
+ (including all URDF publishes, which need no OPT-IN extra: ``defusedxml``
31
+ (hardened XML — security NIT, PR-12 fix loop 2) + path math, both tiny,
32
+ always-installed base dependencies, not the ``simulo[assets]`` extra) stay
33
+ featherweight. A missing USD extra fails with the one-line copyable install
34
+ command.
35
+
36
+ **Windows-safety.** Every path this module puts in a manifest or tar is
37
+ NTFS-clean: a source file whose package-relative path contains ``:`` or ``\\``,
38
+ names a reserved device (``CON``, ``NUL``, ``COM1``…), or ends a segment with
39
+ ``.``/space is REFUSED here (naming the file), because PR-8's cache extraction
40
+ on Windows could not represent it faithfully. Mirrors ``cli.py``'s
41
+ ``_is_windows_unsafe_basename`` reasoning (kept self-contained here — ``cli``
42
+ imports this module, so importing back would cycle).
43
+
44
+ **Symlink / realpath containment (security HIGH, PR-7 trio review; freeze
45
+ "Packaging containment is REALPATH-based").** Every authored reference this
46
+ module resolves (``_resolve_relative_reference``, ``_resolve_package_uri``) is
47
+ checked two ways before it is trusted: its literal path must never itself be a
48
+ symlink (refused outright, regardless of target — a symlink is never trusted
49
+ content, matching :func:`_collect_tree_files`'s tree-walk policy), and its
50
+ ``os.path.realpath()`` — which resolves every symlinked directory in the whole
51
+ chain, not just the leaf — must stay under ``root``'s own realpath. A plain
52
+ ``os.path.normpath`` + string ``commonpath`` check (the pre-review
53
+ implementation) cannot see a symlinked INTERMEDIATE directory that walks the
54
+ resolved path outside the root; realpath does. :func:`build_archive` asserts
55
+ the same property one more time, immediately before each file's bytes are
56
+ read into the tar — the last point before bytes leave this process.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import io
62
+ import os
63
+ import re
64
+ import tarfile
65
+ import xml.etree.ElementTree as ET
66
+ from dataclasses import dataclass, field
67
+ from hashlib import sha256
68
+ from pathlib import Path, PurePosixPath
69
+ from types import ModuleType
70
+ from typing import Callable, Iterable, Optional
71
+
72
+ import defusedxml.ElementTree as DefusedET
73
+ from defusedxml.common import DefusedXmlException
74
+
75
+ from simulo.interfaces.platform.asset_catalog import (
76
+ MAX_ASSET_FILES,
77
+ MAX_ASSET_VERSION_BYTES,
78
+ format_asset_ref,
79
+ )
80
+
81
+ #: Version tag folded into the content digest so a future change to the digest
82
+ #: recipe can never silently collide with today's identities.
83
+ _CONTENT_DIGEST_SCHEMA = "simulo.asset_content.v1"
84
+
85
+ #: USD layer suffixes the inspector parses for references. ``.usdz`` is a
86
+ #: sealed package — included as an opaque file, valid as an entry only when
87
+ #: named explicitly via ``--entry`` (never auto-detected).
88
+ _USD_LAYER_SUFFIXES = (".usd", ".usda", ".usdc")
89
+ _USD_ALL_SUFFIXES = _USD_LAYER_SUFFIXES + (".usdz",)
90
+
91
+ #: Chunk size for streaming file hashing (bounded memory on multi-GB meshes).
92
+ _HASH_CHUNK_BYTES = 8 * 1024 * 1024
93
+
94
+ _WINDOWS_RESERVED_DEVICE_NAMES = frozenset(
95
+ {"CON", "PRN", "AUX", "NUL"}
96
+ | {f"COM{i}" for i in range(1, 10)}
97
+ | {f"LPT{i}" for i in range(1, 10)}
98
+ | {"COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"}
99
+ )
100
+
101
+ #: ``<scheme>://`` or ``<scheme>:`` URI detector for authored references. A
102
+ #: single letter followed by ``:`` is treated as a Windows drive (absolute
103
+ #: path), not a scheme — both are refused, with different copy.
104
+ _URI_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):")
105
+
106
+ #: Authored-reference wildcard/pattern markers (UDIM tiles, clip patterns).
107
+ #: A ref containing one cannot be stat-ed literally; it is checked for
108
+ #: escaping-by-syntax only.
109
+ _PATTERN_MARKERS = ("<", ">", "*")
110
+
111
+
112
+ class AssetPackageError(ValueError):
113
+ """A package cannot be published as-is (refusal catalog, missing deps, caps).
114
+
115
+ The message is finished, user-language-first copy — callers print it
116
+ verbatim (exit 1)."""
117
+
118
+
119
+ class AssetUsageError(AssetPackageError):
120
+ """The invocation needs a corrected command (e.g. ambiguous ``--entry``).
121
+
122
+ Distinct from :class:`AssetPackageError` so the CLI can exit 2 (usage)
123
+ instead of 1 — the message always contains the corrected, copyable
124
+ command (non-interactive use always prints the corrected, copyable
125
+ command)."""
126
+
127
+
128
+ class AssetsExtraMissingError(AssetPackageError):
129
+ """The USD inspection extra (``usd-core``) is not installed."""
130
+
131
+
132
+ #: The one-line copyable fix for a missing ``[assets]`` extra.
133
+ ASSETS_EXTRA_INSTALL_HINT = 'pip install "simulo[assets]"'
134
+
135
+
136
+ def _load_pxr_usdutils() -> ModuleType: # pragma: no cover - exercised via _extract_usd_references seam
137
+ """Import ``pxr.UsdUtils`` lazily — the ONLY place ``pxr`` is ever imported.
138
+
139
+ Raises :class:`AssetsExtraMissingError` with the copyable install command
140
+ when the ``[assets]`` extra is absent. Never called on the URDF path.
141
+ """
142
+ try:
143
+ from pxr import UsdUtils # noqa: PLC0415 — lazy by design (heavy optional extra)
144
+ except ImportError as exc:
145
+ raise AssetsExtraMissingError(
146
+ "Publishing or validating a USD package needs the USD inspection extra. "
147
+ f"Install it with: {ASSETS_EXTRA_INSTALL_HINT}"
148
+ ) from exc
149
+ # `pxr` publishes no stubs (see the mypy override in pyproject.toml), so
150
+ # `UsdUtils` arrives as Any. Bind through a typed local so this function
151
+ # hands its caller a real ModuleType instead of leaking Any across the seam.
152
+ usd_utils: ModuleType = UsdUtils
153
+ return usd_utils
154
+
155
+
156
+ def _extract_usd_references(layer_path: Path) -> list[str]:
157
+ """Every authored external reference string in one USD layer file.
158
+
159
+ ``UsdUtils.ExtractExternalReferences`` returns ``(subLayers, references,
160
+ payloads)`` — all three are authored asset-path strings, exactly what the
161
+ refusal catalog must judge (it judges what the user AUTHORED, not what a
162
+ resolver would make of it). Module-level seam so tests can fake it without
163
+ installing ``usd-core``.
164
+ """
165
+ usd_utils = _load_pxr_usdutils()
166
+ sublayers, references, payloads = usd_utils.ExtractExternalReferences(str(layer_path))
167
+ return [ref for ref in (*sublayers, *references, *payloads) if ref]
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class ManifestEntry:
172
+ """One manifest row — mirrors the wire ``FileManifestEntry`` (bare-hex sha)."""
173
+
174
+ path: str
175
+ size_bytes: int
176
+ sha256: str
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class InspectedPackage:
181
+ """Everything ``publish``/``validate`` need about one inspected package."""
182
+
183
+ source_format: str
184
+ """``"usd"`` or ``"urdf"``."""
185
+
186
+ root: Path
187
+ """The package root every manifest path is relative to."""
188
+
189
+ entry: str
190
+ """Package-root-relative POSIX path of the entry file."""
191
+
192
+ entry_auto_detected: bool
193
+ """``True`` when the entry was inferred rather than passed via ``--entry``."""
194
+
195
+ manifest: tuple[ManifestEntry, ...]
196
+ """Sorted (by path) manifest over the PACKAGED bytes."""
197
+
198
+ content_digest: str
199
+ """The package content-identity key, algo-prefixed (``sha256:<hex>``)."""
200
+
201
+ base_mode: Optional[str]
202
+ """``fixed``/``floating`` for a robot where declared, else ``None``."""
203
+
204
+ reference_count: int
205
+ """How many authored references were checked and resolved in-package."""
206
+
207
+ warnings: tuple[str, ...]
208
+ """Non-fatal disclosures — every one is printed to the user."""
209
+
210
+ scale: Optional[float] = None
211
+ """The ``--scale`` global import unit-scale (URDF only, PR-12) — folded
212
+ into :attr:`content_digest` and sent as the initiate ``options.scale``;
213
+ ``None`` when not declared."""
214
+
215
+ entry_override_bytes: Optional[bytes] = field(repr=False, default=None)
216
+ """The REWRITTEN entry bytes (PR-12 fix loop 2 — URDF mesh-path
217
+ normalization: every ``<mesh>``/``<texture> filename`` is rewritten from
218
+ its original form (``package://...`` or an already-relative ref) to a
219
+ self-contained, entry-directory-relative path, so the uploaded URDF
220
+ never depends on ``package://`` resolution the cloud converter cannot
221
+ safely perform — see :func:`_inspect_urdf`). ``None`` for every USD
222
+ package and for a URDF with no mesh/texture references at all (nothing
223
+ to rewrite — the packaged entry is the on-disk file, byte-for-byte, an
224
+ unchanged invariant for that common case). When set, :func:`build_archive`
225
+ packages THESE bytes for the entry instead of streaming
226
+ ``source_path(entry)`` from disk, and :func:`_build_manifest` hashed
227
+ THESE bytes (via its ``overrides`` argument) to produce the entry's
228
+ manifest row and, transitively, the content digest — the digest is
229
+ always computed over what gets uploaded, never the pre-rewrite
230
+ original."""
231
+
232
+ _files: tuple[tuple[str, Path], ...] = field(repr=False, default=())
233
+ """(rel_posix, abs_path) for every packaged file, sorted by rel path."""
234
+
235
+ @property
236
+ def total_size_bytes(self) -> int:
237
+ return sum(entry.size_bytes for entry in self.manifest)
238
+
239
+ @property
240
+ def file_count(self) -> int:
241
+ return len(self.manifest)
242
+
243
+ def source_path(self, rel_path: str) -> Path:
244
+ """The on-disk path that will be packaged for *rel_path*."""
245
+ for path, abspath in self._files:
246
+ if path == rel_path:
247
+ return abspath
248
+ raise KeyError(rel_path)
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Shared helpers — naming, Windows safety, hashing, refusal judgments
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def slugify_name(text: str) -> str:
257
+ """Reduce a file/directory name to an asset-name slug (the ``--name`` default)."""
258
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
259
+ return slug[:63].rstrip("-")
260
+
261
+
262
+ def validate_asset_name(name: str, *, kind: str, origin_hint: str) -> None:
263
+ """Validate *name* against the contract grammar; raise finished copy if bad.
264
+
265
+ ``format_asset_ref`` is the shared grammar implementation — it rejects bad
266
+ slugs AND reserved catalog words in the name position, with the contract's
267
+ own user-language message; this wraps that in "pass --name" guidance.
268
+ """
269
+ try:
270
+ format_asset_ref(kind=kind, name=name)
271
+ except ValueError as exc:
272
+ raise AssetUsageError(
273
+ f"Cannot use {name!r} as the asset name ({origin_hint}): {exc}\n" f"Pass an explicit name: --name <slug>"
274
+ ) from exc
275
+
276
+
277
+ def _is_windows_unsafe_segment(segment: str) -> bool:
278
+ """Mirror of ``cli._is_windows_unsafe_basename``, applied per path segment."""
279
+ if ":" in segment or "\x00" in segment:
280
+ return True
281
+ if segment != segment.rstrip(". "):
282
+ return True
283
+ stem = segment.split(".", 1)[0].rstrip(" ")
284
+ return stem.upper() in _WINDOWS_RESERVED_DEVICE_NAMES
285
+
286
+
287
+ def _require_ntfs_clean_relpath(rel: str) -> None:
288
+ if "\\" in rel:
289
+ raise AssetPackageError(
290
+ f"Cannot package {rel!r}: the path contains a backslash, which is not a valid "
291
+ "path separator inside an asset package. Rename the file and re-run."
292
+ )
293
+ for segment in rel.split("/"):
294
+ if _is_windows_unsafe_segment(segment):
295
+ raise AssetPackageError(
296
+ f"Cannot package {rel!r}: the path segment {segment!r} is not portable to "
297
+ "Windows (':' writes an NTFS alternate data stream; CON/NUL/COM1… name "
298
+ "devices; a trailing '.' or space is silently stripped). Rename the file "
299
+ "and re-run."
300
+ )
301
+
302
+
303
+ def _hash_file(path: Path) -> tuple[str, int]:
304
+ digest = sha256()
305
+ size = 0
306
+ with path.open("rb") as fh:
307
+ while True:
308
+ chunk = fh.read(_HASH_CHUNK_BYTES)
309
+ if not chunk:
310
+ break
311
+ digest.update(chunk)
312
+ size += len(chunk)
313
+ return digest.hexdigest(), size
314
+
315
+
316
+ def canonical_scale_token(scale: float) -> str:
317
+ """The canonical string form of a ``--scale`` value as folded into the
318
+ content digest: ``repr(float(scale))`` — Python's shortest round-tripping
319
+ repr, identical for the same IEEE-754 double everywhere, and a JSON
320
+ number round-trips to the same double, so this client (folding the flag
321
+ at publish) and the cloud validator (folding the job args'
322
+ ``options.scale`` in its digest recompute) always derive the same token.
323
+ VALUE-MIRROR of ``simulo.backend.validation.urdf_convert.
324
+ canonical_scale_token`` (mirrored, not imported — the thin client never
325
+ depends on the backend; this docstring is the drift anchor)."""
326
+ return repr(float(scale))
327
+
328
+
329
+ def compute_content_digest(
330
+ *, kind: str, name: str, entry: str, manifest: Iterable[ManifestEntry], scale: Optional[float] = None
331
+ ) -> str:
332
+ """The package content identity: kind + name + entry + sorted file digests.
333
+
334
+ Algo-prefixed ``sha256:<hex>`` (freeze: "content_digest canonical form:
335
+ algo-prefixed everywhere it is written/compared"). The server deduplicates
336
+ by string equality on this exact value, so the recipe is pinned by
337
+ :data:`_CONTENT_DIGEST_SCHEMA` and must never change silently.
338
+
339
+ ``scale`` (PR-12 — the freeze's "## PR-7 amendments": scale "folds into
340
+ the content-digest recipe, no file rewrite ever") appends a
341
+ ``scale:<token>`` line between the entry and the file digests when set:
342
+ the same source at a different global unit-scale is a DIFFERENT version
343
+ (the cloud-generated USD differs) while the source bytes stay pristine.
344
+ ``None`` produces the exact pre-PR-12 recipe — every existing digest is
345
+ unchanged. Mirrored line-for-line by the validator's recompute
346
+ (``simulo.backend.validation.structural.compute_content_digest``).
347
+ """
348
+ lines = [_CONTENT_DIGEST_SCHEMA, kind, name, entry]
349
+ if scale is not None:
350
+ lines.append(f"scale:{canonical_scale_token(scale)}")
351
+ lines.extend(f"{item.path}:{item.sha256}" for item in sorted(manifest, key=lambda item: item.path))
352
+ return "sha256:" + sha256("\n".join(lines).encode("utf-8")).hexdigest()
353
+
354
+
355
+ def _is_absolute_reference(ref: str) -> bool:
356
+ if ref.startswith(("/", "\\")):
357
+ return True
358
+ # A Windows drive path ("C:\..." / "C:/..."): one letter + colon.
359
+ return len(ref) >= 2 and ref[1] == ":" and ref[0].isalpha()
360
+
361
+
362
+ def _refusal_for_reference(ref: str, containing: str) -> Optional[str]:
363
+ """The refusal-catalog judgment for one authored reference string.
364
+
365
+ Returns finished copy naming the authored reference and the file
366
+ containing it, or ``None`` when the reference is a well-formed relative
367
+ path (escaping and existence are judged separately, against the root).
368
+ """
369
+ if _is_absolute_reference(ref):
370
+ return (
371
+ f"Absolute path {ref!r} (in {containing}) — an asset package must be "
372
+ "self-contained, so every reference must be relative to the package root. "
373
+ "Make the reference relative and re-run."
374
+ )
375
+ match = _URI_SCHEME_RE.match(ref)
376
+ if match is None:
377
+ return None
378
+ scheme = match.group(1).lower()
379
+ if scheme == "nucleus":
380
+ return (
381
+ f"nucleus:// path {ref!r} (in {containing}) — Nucleus paths are an escape "
382
+ "hatch for self-hosted runtimes and are never valid inside a published "
383
+ "asset. Copy the file into the package and reference it relatively."
384
+ )
385
+ if scheme == "simulo":
386
+ return (
387
+ f"simulo:// path {ref!r} (in {containing}) — the legacy simulo:// alias is "
388
+ "removed; 'simulo/…' has exactly one meaning (the global catalog publisher). "
389
+ "Copy the file into the package and reference it relatively."
390
+ )
391
+ return (
392
+ f"URL {ref!r} (in {containing}) — an asset package must be self-contained, so "
393
+ "a reference can never point at a URL. Download the file into the package and "
394
+ "reference it relatively."
395
+ )
396
+
397
+
398
+ def _resolve_relative_reference(
399
+ ref: str, *, base_dir: Path, root: Path, containing: str, refusals: list[str]
400
+ ) -> Optional[Path]:
401
+ """Resolve one relative authored reference against its layer's directory.
402
+
403
+ Appends symlink / escaping-root / missing-file refusals to *refusals*;
404
+ returns the resolved in-root path (or ``None`` for refused/pattern
405
+ references). Containment is REALPATH-based (:func:`_escapes_root_realpath`),
406
+ not a plain string prefix check — see the module docstring's "Symlink /
407
+ realpath containment" note.
408
+ """
409
+ if any(marker in ref for marker in _PATTERN_MARKERS):
410
+ # A UDIM/clip pattern cannot be stat-ed literally — judge escape by
411
+ # syntax only (its directory part must stay under the root).
412
+ candidate_dir = os.path.normpath(os.path.join(str(base_dir), os.path.dirname(ref) or "."))
413
+ if _escapes_root_realpath(Path(candidate_dir), root):
414
+ refusals.append(_escaping_refusal(ref, containing, root))
415
+ return None
416
+ candidate = Path(os.path.normpath(os.path.join(str(base_dir), ref)))
417
+ refusal = _reference_refusal(ref, containing, candidate, root)
418
+ if refusal is not None:
419
+ refusals.append(refusal)
420
+ return None
421
+ if not candidate.is_file():
422
+ refusals.append(
423
+ f"Missing dependency {ref!r} (referenced by {containing}) — the file does not "
424
+ f"exist inside the package root {root}. Add it to the package, or fix the "
425
+ "reference."
426
+ )
427
+ return None
428
+ return candidate
429
+
430
+
431
+ def _escaping_refusal(ref: str, containing: str, root: Path) -> str:
432
+ return (
433
+ f"Reference {ref!r} (in {containing}) escapes the package root {root} — the CLI "
434
+ "only packages files beneath the root, never elsewhere on this machine. Move the "
435
+ "file under the root, or re-run with --root pointing at a directory that "
436
+ "contains everything the package needs."
437
+ )
438
+
439
+
440
+ def _symlink_refusal(ref: str, containing: str) -> str:
441
+ return (
442
+ f"Reference {ref!r} (in {containing}) names a symlink — symlinks are never "
443
+ "packaged (their target could point anywhere on this machine, including outside "
444
+ "the package root). Replace it with a real file inside the package root and "
445
+ "re-run."
446
+ )
447
+
448
+
449
+ def _escapes_root_realpath(candidate: Path, root: Path) -> bool:
450
+ """``True`` when *candidate*'s REALPATH does not stay under *root*'s own
451
+ realpath (security HIGH, PR-7 trio review — freeze "Packaging containment
452
+ is REALPATH-based"). ``os.path.realpath`` resolves every symlinked
453
+ directory in the whole chain from *candidate* up to the filesystem root,
454
+ not just its leaf component — closing the dir-symlink escape a plain
455
+ ``os.path.normpath`` + string ``commonpath`` check cannot see (a
456
+ symlinked directory earlier in the path can make the NOMINAL path read as
457
+ "inside root" while the REAL target is elsewhere). Any resolution failure
458
+ (broken symlink loop, permission error, cross-drive ``ValueError`` on
459
+ Windows) is treated conservatively as an escape — never silently allowed.
460
+ """
461
+ real_root = os.path.realpath(str(root))
462
+ try:
463
+ real_candidate = os.path.realpath(str(candidate))
464
+ except OSError:
465
+ return True
466
+ try:
467
+ return os.path.commonpath([real_root, real_candidate]) != real_root
468
+ except ValueError: # different drives (Windows) — definitionally outside
469
+ return True
470
+
471
+
472
+ def _reference_refusal(ref: str, containing: str, candidate: Path, root: Path) -> Optional[str]:
473
+ """The refusal for *candidate* if unsafe to read, else ``None``.
474
+
475
+ Two independent checks, in order: the candidate's LITERAL path must not
476
+ itself name a symlink (checked first via ``lstat`` — refused regardless
477
+ of where the symlink points, matching the tree-walk's blanket symlink
478
+ exclusion), then its realpath must stay contained under *root*
479
+ (:func:`_escapes_root_realpath`).
480
+ """
481
+ if candidate.is_symlink():
482
+ return _symlink_refusal(ref, containing)
483
+ if _escapes_root_realpath(candidate, root):
484
+ return _escaping_refusal(ref, containing, root)
485
+ return None
486
+
487
+
488
+ def _raise_refusals(refusals: list[str]) -> None:
489
+ if not refusals:
490
+ return
491
+ shown = refusals[:20]
492
+ lines = "\n".join(f" ✗ {refusal}" for refusal in shown)
493
+ more = f"\n … and {len(refusals) - len(shown)} more." if len(refusals) > len(shown) else ""
494
+ raise AssetPackageError(f"This package cannot be published:\n{lines}{more}")
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # File collection
499
+ # ---------------------------------------------------------------------------
500
+
501
+
502
+ def _collect_tree_files(root: Path) -> list[tuple[str, Path]]:
503
+ """Every packageable file under *root*: symlinks skipped (out-of-tree bytes
504
+ must never ride into an upload — the ``packaging.py`` precedent), dot-files
505
+ and ``__pycache__``/``*.pyc`` excluded, sorted by relative POSIX path."""
506
+ collected: list[tuple[str, Path]] = []
507
+ for dirpath, dirnames, filenames in os.walk(root):
508
+ here = Path(dirpath)
509
+ dirnames[:] = sorted(
510
+ d for d in dirnames if not d.startswith(".") and d != "__pycache__" and not (here / d).is_symlink()
511
+ )
512
+ for filename in sorted(filenames):
513
+ if filename.startswith(".") or filename.endswith(".pyc"):
514
+ continue
515
+ abspath = here / filename
516
+ if abspath.is_symlink():
517
+ continue
518
+ rel = PurePosixPath(abspath.relative_to(root).as_posix())
519
+ collected.append((str(rel), abspath))
520
+ collected.sort(key=lambda item: item[0])
521
+ return collected
522
+
523
+
524
+ def _rel_to_root(path: Path, root: Path) -> str:
525
+ return path.relative_to(root).as_posix()
526
+
527
+
528
+ def _relative_mesh_path(target_root_rel: str, entry_root_rel: str) -> str:
529
+ """*target_root_rel* (package-root-relative, POSIX) expressed relative to
530
+ *entry_root_rel*'s OWN DIRECTORY (also package-root-relative, POSIX) —
531
+ the form a rewritten ``<mesh filename=...>``/``<texture filename=...>``
532
+ attribute needs (PR-12 fix loop 2).
533
+
534
+ Pure string arithmetic over :class:`~pathlib.PurePosixPath` segments — no
535
+ filesystem access, no ``os.getcwd()`` dependency (unlike
536
+ ``posixpath.relpath``, which internally calls ``abspath()`` and would tie
537
+ the result to the process's working directory for no reason two
538
+ already-root-relative paths need one).
539
+
540
+ This is a DELIBERATE GENERALIZATION of "root-relative": when the entry
541
+ sits directly at the package root (the common single-folder-publish
542
+ case), ``entry_root_rel`` has no directory component and this collapses
543
+ to exactly ``target_root_rel`` unchanged. When the entry is nested (a
544
+ real ROS package layout — ``urdf/robot.urdf`` beside a sibling
545
+ ``meshes/`` directory), a literal root-relative rewrite would be WRONG
546
+ (a relative reference resolves against the FILE's own directory, the
547
+ universal URDF/mesh-tooling convention — and the one this module's own
548
+ pre-existing plain-relative-ref resolution, :func:`_resolve_relative_reference`
549
+ with ``base_dir=entry_abs.parent``, and the server-side containment
550
+ pre-check (``check_mesh_containment``'s ``base_dir=urdf_abs.parent``)
551
+ both already use); this function generalizes correctly to that case too,
552
+ so a plain pre-existing relative ref, a rewritten ``package://`` ref, and
553
+ both sides' resolution logic all agree on ONE base.
554
+ """
555
+ target_parts = PurePosixPath(target_root_rel).parts
556
+ entry_dir_parts = PurePosixPath(entry_root_rel).parent.parts
557
+ common = 0
558
+ for a, b in zip(entry_dir_parts, target_parts):
559
+ if a != b:
560
+ break
561
+ common += 1
562
+ up_levels = len(entry_dir_parts) - common
563
+ pieces = [".."] * up_levels + list(target_parts[common:])
564
+ return "/".join(pieces) if pieces else "."
565
+
566
+
567
+ def _build_manifest(
568
+ files: list[tuple[str, Path]], *, overrides: Optional[dict[str, bytes]] = None
569
+ ) -> tuple[ManifestEntry, ...]:
570
+ """Manifest over the PACKAGED bytes for every (rel, abspath) pair.
571
+
572
+ ``overrides`` (PR-12 fix loop 2): a rel path present here is hashed from
573
+ the given IN-MEMORY bytes instead of the on-disk file at ``abspath`` —
574
+ the rewritten-URDF-entry seam (:func:`_inspect_urdf`); the manifest must
575
+ describe what actually gets UPLOADED, never the pre-rewrite original.
576
+ """
577
+ entries: list[ManifestEntry] = []
578
+ for rel, abspath in files:
579
+ _require_ntfs_clean_relpath(rel)
580
+ if overrides and rel in overrides:
581
+ data = overrides[rel]
582
+ digest, size = sha256(data).hexdigest(), len(data)
583
+ else:
584
+ digest, size = _hash_file(abspath)
585
+ entries.append(ManifestEntry(path=rel, size_bytes=size, sha256=digest))
586
+ entries.sort(key=lambda item: item.path)
587
+ return tuple(entries)
588
+
589
+
590
+ def _enforce_caps(manifest: tuple[ManifestEntry, ...]) -> None:
591
+ if len(manifest) == 0:
592
+ raise AssetPackageError("The package contains no files — nothing to publish.")
593
+ if len(manifest) > MAX_ASSET_FILES:
594
+ raise AssetPackageError(
595
+ f"The package contains {len(manifest)} files — more than the {MAX_ASSET_FILES}-file "
596
+ "limit for one asset version. Trim the package (or split it) and re-run."
597
+ )
598
+ total = sum(item.size_bytes for item in manifest)
599
+ if total > MAX_ASSET_VERSION_BYTES:
600
+ raise AssetPackageError(
601
+ f"The package is {total} bytes — larger than the "
602
+ f"{MAX_ASSET_VERSION_BYTES}-byte ({MAX_ASSET_VERSION_BYTES // (1024 ** 3)} GiB) "
603
+ "per-version limit. Trim the package and re-run."
604
+ )
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # USD inspection
609
+ # ---------------------------------------------------------------------------
610
+
611
+
612
+ def _inspect_usd(
613
+ *,
614
+ source: Path,
615
+ root: Path,
616
+ entry_arg: Optional[str],
617
+ corrected_command: Callable[[str], str],
618
+ ) -> tuple[str, bool, list[tuple[str, Path]], int, list[str]]:
619
+ """USD closure + entry detection. Returns
620
+ ``(entry_rel, auto_detected, files, reference_count, warnings)``."""
621
+ warnings: list[str] = []
622
+ refusals: list[str] = []
623
+
624
+ if source.is_dir():
625
+ files = _collect_tree_files(root)
626
+ else:
627
+ files = [] # single-file input: closure computed below
628
+
629
+ layer_files = (
630
+ [abspath for _, abspath in files if abspath.suffix.lower() in _USD_LAYER_SUFFIXES] if source.is_dir() else []
631
+ )
632
+
633
+ referenced: set[Path] = set()
634
+ reference_count = 0
635
+
636
+ def _parse_layer(layer: Path) -> list[Path]:
637
+ nonlocal reference_count
638
+ resolved_paths: list[Path] = []
639
+ containing = _rel_to_root(layer, root)
640
+ for ref in _extract_usd_references(layer):
641
+ refusal = _refusal_for_reference(ref, containing)
642
+ if refusal is not None:
643
+ refusals.append(refusal)
644
+ continue
645
+ resolved = _resolve_relative_reference(
646
+ ref, base_dir=layer.parent, root=root, containing=containing, refusals=refusals
647
+ )
648
+ if resolved is not None:
649
+ reference_count += 1
650
+ resolved_paths.append(resolved)
651
+ return resolved_paths
652
+
653
+ if source.is_dir():
654
+ for layer in layer_files:
655
+ referenced.update(_parse_layer(layer))
656
+ _raise_refusals(refusals)
657
+ entry_rel, auto = _pick_usd_entry(
658
+ root=root,
659
+ files=files,
660
+ referenced=referenced,
661
+ entry_arg=entry_arg,
662
+ corrected_command=corrected_command,
663
+ )
664
+ else:
665
+ # Single-file input: the file IS the entry; the package is its closure.
666
+ entry_abs = source
667
+ entry_rel = _rel_to_root(entry_abs, root)
668
+ auto = entry_arg is None
669
+ closure: dict[str, Path] = {entry_rel: entry_abs}
670
+ frontier = [entry_abs]
671
+ while frontier:
672
+ current = frontier.pop()
673
+ if current.suffix.lower() not in _USD_LAYER_SUFFIXES:
674
+ continue
675
+ for resolved in _parse_layer(current):
676
+ rel = _rel_to_root(resolved, root)
677
+ if rel not in closure:
678
+ closure[rel] = resolved
679
+ frontier.append(resolved)
680
+ _raise_refusals(refusals)
681
+ files = sorted(closure.items(), key=lambda item: item[0])
682
+
683
+ return entry_rel, auto, files, reference_count, warnings
684
+
685
+
686
+ def _pick_usd_entry(
687
+ *,
688
+ root: Path,
689
+ files: list[tuple[str, Path]],
690
+ referenced: set[Path],
691
+ entry_arg: Optional[str],
692
+ corrected_command: Callable[[str], str],
693
+ ) -> tuple[str, bool]:
694
+ if entry_arg is not None:
695
+ entry_path = (root / entry_arg).resolve()
696
+ rel_names = {rel for rel, _ in files}
697
+ entry_rel = PurePosixPath(entry_arg).as_posix()
698
+ if entry_rel not in rel_names:
699
+ raise AssetUsageError(
700
+ f"--entry {entry_arg!r} does not name a file inside the package root {root}. "
701
+ f"Pick one of the package's USD files, e.g.:\n {corrected_command('<entry.usd>')}"
702
+ )
703
+ if entry_path.suffix.lower() not in _USD_ALL_SUFFIXES:
704
+ raise AssetUsageError(f"--entry {entry_arg!r} is not a USD file (.usd/.usda/.usdc/.usdz).")
705
+ return entry_rel, False
706
+
707
+ referenced_rel = {_rel_to_root(path, root) for path in referenced}
708
+ candidates = [
709
+ rel for rel, abspath in files if abspath.suffix.lower() in _USD_LAYER_SUFFIXES and rel not in referenced_rel
710
+ ]
711
+ if len(candidates) == 1:
712
+ return candidates[0], True
713
+ if not candidates:
714
+ raise AssetPackageError(
715
+ "No entry USD could be detected: every USD layer in the package is referenced "
716
+ "by another (or the package contains no USD layers). Name the entry "
717
+ f"explicitly:\n {corrected_command('<entry.usd>')}"
718
+ )
719
+ listing = "\n".join(f" {candidate}" for candidate in candidates[:10])
720
+ return _raise_entry_ambiguity(candidates, listing, corrected_command)
721
+
722
+
723
+ def _raise_entry_ambiguity(
724
+ candidates: list[str], listing: str, corrected_command: Callable[[str], str]
725
+ ) -> tuple[str, bool]:
726
+ raise AssetUsageError(
727
+ f"The package has {len(candidates)} top-level USD layers and the entry is "
728
+ f"ambiguous:\n{listing}\n"
729
+ f"Re-run naming the entry, e.g.:\n {corrected_command(candidates[0])}"
730
+ )
731
+
732
+
733
+ # ---------------------------------------------------------------------------
734
+ # URDF inspection (no OPT-IN extra required — defusedxml + path math only,
735
+ # both tiny always-installed base dependencies; contrast the USD path's
736
+ # lazy usd-core extra above)
737
+ # ---------------------------------------------------------------------------
738
+
739
+
740
+ def _find_urdf_entry(
741
+ root: Path, source: Path, entry_arg: Optional[str], corrected_command: Callable[[str], str]
742
+ ) -> tuple[Path, bool]:
743
+ if entry_arg is not None:
744
+ entry_path = root / entry_arg
745
+ if not entry_path.is_file() or entry_path.suffix.lower() != ".urdf":
746
+ raise AssetUsageError(
747
+ f"--entry {entry_arg!r} does not name a .urdf file inside the package root " f"{root}."
748
+ )
749
+ return entry_path, False
750
+ if source.is_file():
751
+ return source, False
752
+ urdfs = sorted(root.rglob("*.urdf"))
753
+ if len(urdfs) == 1:
754
+ return urdfs[0], True
755
+ if not urdfs:
756
+ raise AssetPackageError(f"No .urdf file found under {root}.")
757
+ listing = "\n".join(f" {_rel_to_root(u, root)}" for u in urdfs[:10])
758
+ raise AssetUsageError(
759
+ f"The package contains {len(urdfs)} URDF files and the entry is ambiguous:\n"
760
+ f"{listing}\nRe-run naming the entry, e.g.:\n"
761
+ f" {corrected_command(_rel_to_root(urdfs[0], root))}"
762
+ )
763
+
764
+
765
+ def _is_safe_contained_dir(path: Path, root: Path) -> bool:
766
+ """A candidate ``package://`` package DIRECTORY: real, not a symlink, and
767
+ realpath-contained under *root* (security HIGH — folds ``package://``
768
+ into the same containment discipline as every other reference)."""
769
+ return path.is_dir() and not path.is_symlink() and not _escapes_root_realpath(path, root)
770
+
771
+
772
+ def _is_safe_contained_file(path: Path, root: Path) -> bool:
773
+ """The ``package://`` file-leaf twin of :func:`_is_safe_contained_dir`."""
774
+ return path.is_file() and not path.is_symlink() and not _escapes_root_realpath(path, root)
775
+
776
+
777
+ def _resolve_package_uri(ref: str, *, root: Path, containing: str, refusals: list[str]) -> Optional[Path]:
778
+ """Resolve ``package://<pkg>/<rest>`` beneath the root.
779
+
780
+ Every candidate — the direct ``root/pkg/rest`` guess AND every ``pkg``-named
781
+ directory :meth:`Path.rglob` turns up — is realpath-contained and
782
+ symlink-free (:func:`_is_safe_contained_dir` / :func:`_is_safe_contained_file`)
783
+ before it is trusted; a symlinked ``pkg`` directory or a symlinked leaf file
784
+ is refused outright, never silently followed.
785
+
786
+ SECURITY NIT (fix loop 3): the RETURNED candidate is ``os.path.normpath``-
787
+ collapsed, mirroring :func:`_resolve_relative_reference` exactly (which
788
+ already does this). Without it, a valid ``package://pkg/../sibling/x.obj``
789
+ (realpath stays in-root — the containment check below is realpath-based
790
+ and already handles ``..`` correctly regardless) would return a Path whose
791
+ STRING form still embeds the literal ``..`` segment; that string becomes
792
+ the closure key / tar member name (via ``_rel_to_root`` + the fix-loop-2
793
+ mesh-path rewrite), and the server's ``safe_extract_archive`` rejects any
794
+ archive member NAME containing ``..`` outright (its own, correct, blanket
795
+ anti-traversal policy for untrusted archive input) — a confusing
796
+ server-side failure for a perfectly legitimate reference. Normalizing here
797
+ means the closure/tar/rewrite only ever see the collapsed, clean form.
798
+ """
799
+ body = ref[len("package://") :]
800
+ pkg, _, rest = body.partition("/")
801
+ if not pkg or not rest:
802
+ refusals.append(
803
+ f"Malformed package:// reference {ref!r} (in {containing}) — expected " "'package://<package>/<path>'."
804
+ )
805
+ return None
806
+ direct = Path(os.path.normpath(str(root / pkg / Path(rest))))
807
+ if direct.is_file() or direct.is_symlink():
808
+ refusal = _reference_refusal(ref, containing, direct, root)
809
+ if refusal is not None:
810
+ refusals.append(refusal)
811
+ return None
812
+ return direct
813
+ matches = sorted(
814
+ d for d in root.rglob(pkg) if _is_safe_contained_dir(d, root) and _is_safe_contained_file(d / Path(rest), root)
815
+ )
816
+ if len(matches) == 1:
817
+ return Path(os.path.normpath(str(matches[0] / Path(rest))))
818
+ if not matches:
819
+ refusals.append(
820
+ f"Cannot resolve {ref!r} (in {containing}): no directory named {pkg!r} "
821
+ f"containing {rest!r} exists under the package root {root}. Re-run with "
822
+ f"--root <directory that contains {pkg}/>."
823
+ )
824
+ return None
825
+ listing = ", ".join(_rel_to_root(m, root) for m in matches[:5])
826
+ refusals.append(
827
+ f"Ambiguous package:// reference {ref!r} (in {containing}): {len(matches)} "
828
+ f"directories named {pkg!r} under the root contain {rest!r} ({listing}). "
829
+ "Re-run with a --root that contains exactly one."
830
+ )
831
+ return None
832
+
833
+
834
+ def _inspect_urdf(
835
+ *,
836
+ source: Path,
837
+ root: Path,
838
+ entry_arg: Optional[str],
839
+ corrected_command: Callable[[str], str],
840
+ ) -> tuple[str, bool, list[tuple[str, Path]], int, list[str], Optional[bytes]]:
841
+ """URDF closure: the URDF + every mesh/texture it references, resolved
842
+ beneath the root. Returns ``(entry_rel, auto, files, reference_count,
843
+ warnings, entry_override_bytes)``.
844
+
845
+ (``--scale`` was removed here — DEFERRED to Wave 3/PR-12 per the freeze's
846
+ "## PR-7 amendments": mesh-only client-side scaling was adjudicated wrong
847
+ on identity, server-side-conversion, and physics-completeness grounds; a
848
+ future ``options: {scale}`` field threads through the initiate body
849
+ unrewritten, source stays byte-for-byte pristine. Distinct from the
850
+ MESH-PATH rewrite below: that one is about self-containment, not scale,
851
+ and DOES rewrite — the two are orthogonal.)
852
+
853
+ **Mesh-path rewrite (PR-12 fix loop 2 — critic MAJOR).** ``package://``
854
+ resolution here previously only COLLECTED the referenced mesh into the
855
+ closure (so its bytes rode along in the archive) but never rewrote the
856
+ URDF text itself — a standard ROS URDF (``package://`` is the dominant
857
+ ROS mesh-reference convention) packaged fine, then failed the cloud's
858
+ server-side mesh-containment gate (``check_mesh_containment``, fix loop
859
+ 1), which correctly refuses ``package://`` as unresolvable server-side.
860
+ URDF conversion that rejects the dominant ROS convention is a broken
861
+ headline feature — so every successfully-resolved ``<mesh>``/``<texture>``
862
+ ``filename`` attribute is now REWRITTEN, in place on the parsed tree, to
863
+ :func:`_relative_mesh_path`'s entry-directory-relative form (computed
864
+ from the SAME resolved closure path this function already collects) —
865
+ self-contained, and exactly what the server's containment gate and a
866
+ real URDF-consuming tool both resolve against. An unresolvable
867
+ ``package://`` (unknown package, no ``--root`` mapping) still FAILS HERE
868
+ at publish time via the existing refusal catalog (:func:`_raise_refusals`)
869
+ — naming the unresolved package — never a silent upload the server later
870
+ rejects.
871
+
872
+ The rewrite runs ONLY when at least one mesh/texture reference was
873
+ resolved (``reference_count > 0``); a URDF with none (primitives only)
874
+ packages its on-disk bytes byte-for-byte, unchanged — the common case's
875
+ behavior is untouched. When it does run, EVERY resolved reference is
876
+ rewritten uniformly (a plain already-relative ref included) rather than
877
+ special-casing "package:// only": one code path, and it guarantees the
878
+ uploaded URDF's mesh paths are always expressed in the ONE convention
879
+ both this module's own resolvers and the server side already share.
880
+
881
+ DETERMINISM: the tree is mutated via ``Element.set()`` on EXISTING
882
+ attributes only (never added/removed), so attribute insertion order —
883
+ preserved by ElementTree since Python 3.8 — never changes; re-parsing
884
+ and re-rewriting the SAME input, twice, produces byte-identical
885
+ serialized output (:func:`~xml.etree.ElementTree.tostring` over a
886
+ deterministic tree is itself deterministic). Comments and processing
887
+ instructions in the original file are NOT preserved through this path
888
+ (plain ``ElementTree`` parsing already drops them, before this change
889
+ existed) — an accepted, pre-existing property of ET-based inspection,
890
+ not a new regression.
891
+ """
892
+ warnings: list[str] = []
893
+ refusals: list[str] = []
894
+ entry_abs, auto = _find_urdf_entry(root, source, entry_arg, corrected_command)
895
+ entry_rel = _rel_to_root(entry_abs, root)
896
+
897
+ try:
898
+ tree = DefusedET.parse(entry_abs)
899
+ except ET.ParseError as exc:
900
+ raise AssetPackageError(f"{entry_rel} is not parseable as URDF XML: {exc}. Fix the file and re-run.") from exc
901
+ except DefusedXmlException as exc:
902
+ # SECURITY (fix loop 2 NIT): a hostile DOCTYPE/entity construct (XXE,
903
+ # billion-laughs) — a clear, fail-fast publish-time error, never a
904
+ # crash and never a silent parse of dangerous content. exc's own
905
+ # message names the forbidden construct (e.g. the entity name), not
906
+ # host filesystem details, so it is safe to surface verbatim.
907
+ raise AssetPackageError(
908
+ f"{entry_rel} uses a disallowed XML construct (a DOCTYPE or external/internal "
909
+ f"entity declaration): {exc}. Remove any <!DOCTYPE ...> or <!ENTITY ...> "
910
+ "declarations and re-run."
911
+ ) from exc
912
+
913
+ # ElementTree types getroot() as Optional because a tree can be constructed
914
+ # empty; a tree returned by a SUCCESSFUL parse always has a root, so this
915
+ # branch is unreachable in practice. It is a real check rather than a cast
916
+ # so that if it ever were reached, it surfaces as the same clean
917
+ # publish-time error as every other URDF problem — never an AttributeError.
918
+ root_element = tree.getroot()
919
+ if root_element is None: # pragma: no cover - unreachable after a successful parse
920
+ raise AssetPackageError(
921
+ f"{entry_rel} is not parseable as URDF XML: it has no root element. Fix the file and re-run."
922
+ )
923
+
924
+ closure: dict[str, Path] = {entry_rel: entry_abs}
925
+ reference_count = 0
926
+ rewrites: list[tuple[ET.Element, str]] = [] # (element, new entry-relative filename)
927
+ for element in root_element.iter():
928
+ if element.tag not in ("mesh", "texture"):
929
+ continue
930
+ ref = element.get("filename")
931
+ if not ref:
932
+ continue
933
+ if ref.startswith("package://"):
934
+ resolved = _resolve_package_uri(ref, root=root, containing=entry_rel, refusals=refusals)
935
+ else:
936
+ refusal = _refusal_for_reference(ref, entry_rel)
937
+ if refusal is not None:
938
+ refusals.append(refusal)
939
+ continue
940
+ resolved = _resolve_relative_reference(
941
+ ref, base_dir=entry_abs.parent, root=root, containing=entry_rel, refusals=refusals
942
+ )
943
+ if resolved is not None:
944
+ reference_count += 1
945
+ target_rel = _rel_to_root(resolved, root)
946
+ closure[target_rel] = resolved
947
+ rewrites.append((element, _relative_mesh_path(target_rel, entry_rel)))
948
+ _raise_refusals(refusals)
949
+
950
+ entry_override_bytes: Optional[bytes] = None
951
+ if reference_count > 0:
952
+ for element, new_filename in rewrites:
953
+ element.set("filename", new_filename)
954
+ entry_override_bytes = ET.tostring(root_element, encoding="utf-8", xml_declaration=True)
955
+
956
+ files = sorted(closure.items(), key=lambda item: item[0])
957
+ return entry_rel, auto, files, reference_count, warnings, entry_override_bytes
958
+
959
+
960
+ # ---------------------------------------------------------------------------
961
+ # Public entry point + archive assembly
962
+ # ---------------------------------------------------------------------------
963
+
964
+
965
+ def detect_source_format(source: Path, entry_arg: Optional[str]) -> str:
966
+ """``"urdf"`` or ``"usd"`` from the input path (``--entry`` wins when given)."""
967
+ if entry_arg is not None:
968
+ return "urdf" if entry_arg.lower().endswith(".urdf") else "usd"
969
+ if source.is_file():
970
+ return "urdf" if source.suffix.lower() == ".urdf" else "usd"
971
+ has_urdf = any(source.rglob("*.urdf"))
972
+ has_usd = any(path for suffix in _USD_ALL_SUFFIXES for path in source.rglob(f"*{suffix}"))
973
+ if has_urdf and has_usd:
974
+ raise AssetUsageError(
975
+ f"{source} contains both URDF and USD files — name the entry explicitly with "
976
+ "--entry <file> so the source format is unambiguous."
977
+ )
978
+ if has_urdf:
979
+ return "urdf"
980
+ if has_usd:
981
+ return "usd"
982
+ raise AssetPackageError(
983
+ f"{source} contains no USD (.usd/.usda/.usdc/.usdz) or URDF (.urdf) files — " "nothing to publish."
984
+ )
985
+
986
+
987
+ def inspect_package(
988
+ source: Path,
989
+ *,
990
+ kind: str,
991
+ name: str,
992
+ entry_arg: Optional[str] = None,
993
+ root_arg: Optional[Path] = None,
994
+ base_mode: Optional[str] = None,
995
+ scale: Optional[float] = None,
996
+ corrected_command: Optional[Callable[[str], str]] = None,
997
+ ) -> InspectedPackage:
998
+ """Inspect *source* (directory or file) into an :class:`InspectedPackage`.
999
+
1000
+ Raises :class:`AssetPackageError` (refusal catalog / caps, exit 1) or
1001
+ :class:`AssetUsageError` (needs a corrected command, exit 2).
1002
+ ``corrected_command`` renders the copyable fix for entry ambiguity —
1003
+ ``corrected_command("robot.usd")`` returns the full command line to print.
1004
+ """
1005
+ source = source.resolve()
1006
+ if not source.exists():
1007
+ raise AssetPackageError(f"{source} does not exist.")
1008
+ root = root_arg.resolve() if root_arg is not None else (source if source.is_dir() else source.parent)
1009
+ if not root.is_dir():
1010
+ raise AssetUsageError(f"--root {root} is not a directory.")
1011
+ if source != root and root not in source.parents:
1012
+ raise AssetUsageError(f"{source} is not inside the package root {root} (--root).")
1013
+ command_for = corrected_command or (lambda entry: f"--entry {entry}")
1014
+
1015
+ source_format = detect_source_format(source, entry_arg)
1016
+ entry_override_bytes: Optional[bytes] = None
1017
+ if source_format == "urdf":
1018
+ entry_rel, auto, files, reference_count, warnings, entry_override_bytes = _inspect_urdf(
1019
+ source=source,
1020
+ root=root,
1021
+ entry_arg=entry_arg,
1022
+ corrected_command=command_for,
1023
+ )
1024
+ else:
1025
+ entry_rel, auto, files, reference_count, warnings = _inspect_usd(
1026
+ source=source, root=root, entry_arg=entry_arg, corrected_command=command_for
1027
+ )
1028
+
1029
+ if scale is not None and source_format != "urdf":
1030
+ raise AssetUsageError(
1031
+ "--scale is a URDF conversion option (a global unit-scale applied by the cloud converter) "
1032
+ "— it does not apply to USD sources. Author the correct metersPerUnit in your USD instead."
1033
+ )
1034
+
1035
+ overrides = {entry_rel: entry_override_bytes} if entry_override_bytes is not None else None
1036
+ manifest = _build_manifest(files, overrides=overrides)
1037
+ _enforce_caps(manifest)
1038
+ content_digest = compute_content_digest(kind=kind, name=name, entry=entry_rel, manifest=manifest, scale=scale)
1039
+ return InspectedPackage(
1040
+ source_format=source_format,
1041
+ root=root,
1042
+ entry=entry_rel,
1043
+ entry_auto_detected=auto,
1044
+ manifest=manifest,
1045
+ content_digest=content_digest,
1046
+ base_mode=base_mode,
1047
+ reference_count=reference_count,
1048
+ warnings=tuple(warnings),
1049
+ scale=scale,
1050
+ entry_override_bytes=entry_override_bytes,
1051
+ _files=tuple(files),
1052
+ )
1053
+
1054
+
1055
+ def build_archive(package: InspectedPackage, tar_path: Path) -> tuple[str, int]:
1056
+ """Write the deterministic package tar; return ``(bare_sha256_hex, size)``.
1057
+
1058
+ Sorted members, zeroed mtime/uid/gid, mode 0644, POSIX arcnames — identical
1059
+ trees produce identical bytes on every OS (the ``packaging.py`` recipe), so
1060
+ the archive digest is as idempotent as the content digest.
1061
+
1062
+ **Last-line symlink/regular-file assertion (security HIGH, PR-7 trio
1063
+ review).** Every source this function reads has already passed the
1064
+ resolvers' realpath-containment + symlink checks — but this is the LAST
1065
+ point before bytes leave the process, so it re-asserts the property one
1066
+ more time, defensively, so a future resolver bug can never smuggle a
1067
+ symlink, FIFO, device, or directory into the tar as if it were a file.
1068
+
1069
+ **Rewritten-entry seam (PR-12 fix loop 2).** When
1070
+ ``package.entry_override_bytes`` is set, the entry's tar member is built
1071
+ from THOSE bytes directly (``tarfile.addfile`` + ``io.BytesIO``) instead
1072
+ of streaming ``source_path(entry)`` from disk — the entry is always a
1073
+ small XML text file, never a multi-GB mesh, so holding it in memory once
1074
+ is free; every OTHER manifest member still streams from disk exactly as
1075
+ before.
1076
+ """
1077
+
1078
+ def _reset(info: tarfile.TarInfo) -> tarfile.TarInfo:
1079
+ info.mtime = 0
1080
+ info.uid = info.gid = 0
1081
+ info.uname = info.gname = ""
1082
+ info.mode = 0o644
1083
+ return info
1084
+
1085
+ tar_path.parent.mkdir(parents=True, exist_ok=True)
1086
+ with tarfile.open(tar_path, "w") as tar:
1087
+ for entry in package.manifest:
1088
+ if package.entry_override_bytes is not None and entry.path == package.entry:
1089
+ info = tarfile.TarInfo(name=entry.path)
1090
+ info.size = len(package.entry_override_bytes)
1091
+ tar.addfile(_reset(info), io.BytesIO(package.entry_override_bytes))
1092
+ continue
1093
+ source = package.source_path(entry.path)
1094
+ if source.is_symlink() or not source.is_file():
1095
+ raise AssetPackageError(
1096
+ f"Refusing to package {entry.path!r}: {source} is not a regular file "
1097
+ "(symlink, FIFO, device, or similar) — this should never happen; "
1098
+ "please report this as a bug."
1099
+ )
1100
+ # Streams from disk — a multi-GB mesh never lands in memory.
1101
+ tar.add(source, arcname=entry.path, filter=_reset)
1102
+ digest, size = _hash_file(tar_path)
1103
+ return digest, size