simscope 0.1.1__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.
- simscope/__init__.py +6 -0
- simscope/__main__.py +8 -0
- simscope/_assets/simscope-app.css +2 -0
- simscope/_assets/simscope-app.js +4311 -0
- simscope/_assets/simscope-player.js +4325 -0
- simscope/_assets/simscope-web.LICENSES.txt +407 -0
- simscope/_icon.py +22 -0
- simscope/_mjviser.py +203 -0
- simscope/annotations.py +1132 -0
- simscope/cli.py +482 -0
- simscope/core.py +257 -0
- simscope/derived.py +697 -0
- simscope/export.py +799 -0
- simscope/highlights.py +947 -0
- simscope/importers.py +874 -0
- simscope/index.py +579 -0
- simscope/io/__init__.py +45 -0
- simscope/io/blockfile.py +938 -0
- simscope/io/cas.py +294 -0
- simscope/io/codecs.py +566 -0
- simscope/io/errors.py +9 -0
- simscope/io/manifest.py +358 -0
- simscope/io/pack.py +563 -0
- simscope/io/scene.py +239 -0
- simscope/isaaclab.py +1460 -0
- simscope/library.py +705 -0
- simscope/mujoco.py +578 -0
- simscope/py.typed +0 -0
- simscope/recorder.py +784 -0
- simscope/server/__init__.py +9 -0
- simscope/server/app.py +149 -0
- simscope/server/blocks.py +191 -0
- simscope/server/jobs.py +166 -0
- simscope/server/routes.py +707 -0
- simscope/server/security.py +218 -0
- simscope/server/state.py +751 -0
- simscope/server/static.py +84 -0
- simscope/transforms.py +147 -0
- simscope-0.1.1.dist-info/METADATA +132 -0
- simscope-0.1.1.dist-info/RECORD +45 -0
- simscope-0.1.1.dist-info/WHEEL +4 -0
- simscope-0.1.1.dist-info/entry_points.txt +3 -0
- simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
- simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
- simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""What the server will and will not answer (contracts 1 and 2).
|
|
2
|
+
|
|
3
|
+
Three independent guards, all of which exist because ``simscope serve``
|
|
4
|
+
exposes a slice of the filesystem to whatever page the browser has open:
|
|
5
|
+
|
|
6
|
+
* :class:`HostGuard` rejects requests whose ``Host`` is not one of ours, so
|
|
7
|
+
a DNS-rebinding page cannot read the library through the user's browser.
|
|
8
|
+
* :func:`check_write` gates mutations on the boot token and on a same-host
|
|
9
|
+
``Origin`` (the token alone would leak through a cross-site form; the
|
|
10
|
+
origin alone would be spoofable by any local process).
|
|
11
|
+
* :func:`parse_path` maps a URL path onto the closed set of library paths of
|
|
12
|
+
contracts 1 (no directory listing, no ``..``, no symlinks out of the
|
|
13
|
+
root): anything that is not one of the shapes below is a 404.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import hmac
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from starlette.datastructures import Headers
|
|
25
|
+
from starlette.responses import JSONResponse
|
|
26
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
27
|
+
|
|
28
|
+
LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
29
|
+
TOKEN_HEADER = "x-simscope-token"
|
|
30
|
+
|
|
31
|
+
_NAME = r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}"
|
|
32
|
+
_RUN_FILE = re.compile(
|
|
33
|
+
rf"^runs/(?P<run>{_NAME})/"
|
|
34
|
+
rf"(?P<file>rollout\.json|annotations\.json|{_NAME}\.blk)$"
|
|
35
|
+
)
|
|
36
|
+
_CAS = re.compile(
|
|
37
|
+
r"^(?P<kind>scenes|assets)/(?P<ab>[0-9a-f]{2})/"
|
|
38
|
+
r"(?P<sha>[0-9a-f]{64})(?P<ext>\.json)?$"
|
|
39
|
+
)
|
|
40
|
+
_DERIVED = re.compile(
|
|
41
|
+
rf"^derived/(?P<run>{_NAME})/"
|
|
42
|
+
rf"(?P<what>root_pose\.blk|summaries\.json|highlights\.json"
|
|
43
|
+
rf"|envelopes/{_NAME}\.json)$"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Kind = Literal["run", "cas", "derived"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclasses.dataclass(frozen=True)
|
|
50
|
+
class Target:
|
|
51
|
+
"""A parsed library path.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
kind: ``"run"`` (under ``runs/``), ``"cas"`` (``scenes/`` and
|
|
55
|
+
``assets/``) or ``"derived"`` (computed on demand).
|
|
56
|
+
path: The path as asked, relative to the library root.
|
|
57
|
+
run: The run name, for ``run`` and ``derived`` paths.
|
|
58
|
+
file: The file inside the run (``run``), or the derived file relative
|
|
59
|
+
to ``derived/<run>/`` (``derived``).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
kind: Kind
|
|
63
|
+
path: str
|
|
64
|
+
run: str = ""
|
|
65
|
+
file: str = ""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def immutable(self) -> bool:
|
|
69
|
+
"""True for content-addressed files, which never change."""
|
|
70
|
+
return self.kind == "cas"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_path(path: str) -> Target | None:
|
|
74
|
+
"""Classifies a URL path against the closed set of contracts 1 shapes.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
path: The path after ``/files/`` (or a ``path=`` query value).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The target, or ``None`` if the path is not a library path. Nothing
|
|
81
|
+
touches the filesystem here.
|
|
82
|
+
"""
|
|
83
|
+
if not path or len(path) > 400 or "\\" in path or "\0" in path:
|
|
84
|
+
return None
|
|
85
|
+
m = _RUN_FILE.match(path)
|
|
86
|
+
if m:
|
|
87
|
+
return Target("run", path, m["run"], m["file"])
|
|
88
|
+
m = _DERIVED.match(path)
|
|
89
|
+
if m:
|
|
90
|
+
return Target("derived", path, m["run"], m["what"])
|
|
91
|
+
m = _CAS.match(path)
|
|
92
|
+
# Scenes are JSON; assets are raw blobs with no extension.
|
|
93
|
+
if (
|
|
94
|
+
m
|
|
95
|
+
and m["sha"].startswith(m["ab"])
|
|
96
|
+
and (m["kind"] == "scenes") == (m["ext"] == ".json")
|
|
97
|
+
):
|
|
98
|
+
return Target("cas", path)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def confine(root_real: str, candidate: pathlib.Path) -> pathlib.Path | None:
|
|
103
|
+
"""Returns ``candidate`` if it really lies inside the library.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
root_real: ``os.path.realpath`` of the library root.
|
|
107
|
+
candidate: A path built from a validated :class:`Target`.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The path, or ``None`` if following symlinks leaves the root.
|
|
111
|
+
"""
|
|
112
|
+
real = os.path.realpath(candidate)
|
|
113
|
+
if real == root_real or not real.startswith(root_real + os.sep):
|
|
114
|
+
return None
|
|
115
|
+
return candidate
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def host_of(header: str | None) -> str | None:
|
|
119
|
+
"""Extracts the host name from a ``Host`` header, without the port.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
header: The header value, such as ``localhost:8080`` or ``[::1]:80``.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The lowercase host (IPv6 without brackets), or ``None`` if the
|
|
126
|
+
header is missing or malformed.
|
|
127
|
+
"""
|
|
128
|
+
if not header:
|
|
129
|
+
return None
|
|
130
|
+
header = header.strip().lower()
|
|
131
|
+
if header.startswith("["):
|
|
132
|
+
end = header.find("]")
|
|
133
|
+
if end < 0:
|
|
134
|
+
return None
|
|
135
|
+
rest = header[end + 1 :]
|
|
136
|
+
if rest and not re.fullmatch(r":\d{1,5}", rest):
|
|
137
|
+
return None
|
|
138
|
+
return header[1:end]
|
|
139
|
+
host, sep, port = header.partition(":")
|
|
140
|
+
if sep and not port.isdigit():
|
|
141
|
+
return None
|
|
142
|
+
return host or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def allowed_hosts_for(bind_host: str) -> tuple[str, ...]:
|
|
146
|
+
"""Chooses the ``Host`` values a server bound to ``bind_host`` accepts.
|
|
147
|
+
|
|
148
|
+
A loopback bind accepts only loopback names, which is what stops DNS
|
|
149
|
+
rebinding. A wildcard bind (``0.0.0.0``, ``::``) means the user chose
|
|
150
|
+
to be reachable by name or address, so every host is accepted (writes
|
|
151
|
+
still need the token). Any other bind accepts that address plus
|
|
152
|
+
loopback.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
bind_host: The address passed to ``--host``.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Allowed host names, or ``("*",)`` for any.
|
|
159
|
+
"""
|
|
160
|
+
bind = bind_host.strip("[]").lower()
|
|
161
|
+
if bind in ("0.0.0.0", "::", ""):
|
|
162
|
+
return ("*",)
|
|
163
|
+
if bind in LOOPBACK_HOSTS:
|
|
164
|
+
return LOOPBACK_HOSTS
|
|
165
|
+
return (*LOOPBACK_HOSTS, bind)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class HostGuard:
|
|
169
|
+
"""ASGI middleware that answers 400 to a ``Host`` we do not serve."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, app: ASGIApp, allowed: Sequence[str]) -> None:
|
|
172
|
+
"""Wraps an app.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
app: The inner ASGI app.
|
|
176
|
+
allowed: Accepted host names, or ``("*",)`` for any.
|
|
177
|
+
"""
|
|
178
|
+
self.app = app
|
|
179
|
+
self.allowed = frozenset(a.lower() for a in allowed)
|
|
180
|
+
self.any = "*" in self.allowed
|
|
181
|
+
|
|
182
|
+
async def __call__(
|
|
183
|
+
self, scope: Scope, receive: Receive, send: Send
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Forwards the request if its ``Host`` is allowed."""
|
|
186
|
+
if scope["type"] not in ("http", "websocket") or self.any:
|
|
187
|
+
await self.app(scope, receive, send)
|
|
188
|
+
return
|
|
189
|
+
host = host_of(Headers(scope=scope).get("host"))
|
|
190
|
+
if host is None or host not in self.allowed:
|
|
191
|
+
response = JSONResponse({"error": "invalid host"}, status_code=400)
|
|
192
|
+
await response(scope, receive, send)
|
|
193
|
+
return
|
|
194
|
+
await self.app(scope, receive, send)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_write(headers: Headers, token: str) -> str | None:
|
|
198
|
+
"""Checks the token and origin of a mutating request.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
headers: The request headers.
|
|
202
|
+
token: The per-process token from the boot block.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
``None`` if the request may write, else the reason it may not.
|
|
206
|
+
"""
|
|
207
|
+
given = headers.get(TOKEN_HEADER, "")
|
|
208
|
+
if not hmac.compare_digest(given.encode(), token.encode()):
|
|
209
|
+
return "missing or wrong token"
|
|
210
|
+
origin = headers.get("origin")
|
|
211
|
+
if not origin:
|
|
212
|
+
return "missing Origin"
|
|
213
|
+
scheme, sep, netloc = origin.partition("://")
|
|
214
|
+
if not sep or scheme not in ("http", "https"):
|
|
215
|
+
return "Origin does not match the host"
|
|
216
|
+
if netloc.lower() != (headers.get("host") or "").lower():
|
|
217
|
+
return "Origin does not match the host"
|
|
218
|
+
return None
|