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,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
|