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.
Files changed (45) hide show
  1. simscope/__init__.py +6 -0
  2. simscope/__main__.py +8 -0
  3. simscope/_assets/simscope-app.css +2 -0
  4. simscope/_assets/simscope-app.js +4311 -0
  5. simscope/_assets/simscope-player.js +4325 -0
  6. simscope/_assets/simscope-web.LICENSES.txt +407 -0
  7. simscope/_icon.py +22 -0
  8. simscope/_mjviser.py +203 -0
  9. simscope/annotations.py +1132 -0
  10. simscope/cli.py +482 -0
  11. simscope/core.py +257 -0
  12. simscope/derived.py +697 -0
  13. simscope/export.py +799 -0
  14. simscope/highlights.py +947 -0
  15. simscope/importers.py +874 -0
  16. simscope/index.py +579 -0
  17. simscope/io/__init__.py +45 -0
  18. simscope/io/blockfile.py +938 -0
  19. simscope/io/cas.py +294 -0
  20. simscope/io/codecs.py +566 -0
  21. simscope/io/errors.py +9 -0
  22. simscope/io/manifest.py +358 -0
  23. simscope/io/pack.py +563 -0
  24. simscope/io/scene.py +239 -0
  25. simscope/isaaclab.py +1460 -0
  26. simscope/library.py +705 -0
  27. simscope/mujoco.py +578 -0
  28. simscope/py.typed +0 -0
  29. simscope/recorder.py +784 -0
  30. simscope/server/__init__.py +9 -0
  31. simscope/server/app.py +149 -0
  32. simscope/server/blocks.py +191 -0
  33. simscope/server/jobs.py +166 -0
  34. simscope/server/routes.py +707 -0
  35. simscope/server/security.py +218 -0
  36. simscope/server/state.py +751 -0
  37. simscope/server/static.py +84 -0
  38. simscope/transforms.py +147 -0
  39. simscope-0.1.1.dist-info/METADATA +132 -0
  40. simscope-0.1.1.dist-info/RECORD +45 -0
  41. simscope-0.1.1.dist-info/WHEEL +4 -0
  42. simscope-0.1.1.dist-info/entry_points.txt +3 -0
  43. simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
  44. simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
  45. 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