useshards 0.1.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 (158) hide show
  1. useshards/__init__.py +123 -0
  2. useshards/_archive.py +249 -0
  3. useshards/_async/_backend.py +44 -0
  4. useshards/_async/_command.py +404 -0
  5. useshards/_async/_files.py +303 -0
  6. useshards/_async/_follow.py +135 -0
  7. useshards/_async/_sandbox.py +295 -0
  8. useshards/_async/_shard.py +337 -0
  9. useshards/_async/_transport.py +153 -0
  10. useshards/_async/_ws.py +171 -0
  11. useshards/_capture.py +43 -0
  12. useshards/_config.py +117 -0
  13. useshards/_frames.py +152 -0
  14. useshards/_generated/__init__.py +8 -0
  15. useshards/_generated/api/__init__.py +1 -0
  16. useshards/_generated/api/app/__init__.py +1 -0
  17. useshards/_generated/api/app/attach_app.py +114 -0
  18. useshards/_generated/api/app/stop_app.py +127 -0
  19. useshards/_generated/api/exec_/__init__.py +1 -0
  20. useshards/_generated/api/exec_/create_exec.py +128 -0
  21. useshards/_generated/api/exec_/delete_exec.py +118 -0
  22. useshards/_generated/api/exec_/get_exec.py +141 -0
  23. useshards/_generated/api/exec_/kill_exec.py +137 -0
  24. useshards/_generated/api/exec_/list_execs.py +138 -0
  25. useshards/_generated/api/exec_/resize_exec.py +137 -0
  26. useshards/_generated/api/files/__init__.py +1 -0
  27. useshards/_generated/api/files/delete_file.py +136 -0
  28. useshards/_generated/api/files/list_dir.py +127 -0
  29. useshards/_generated/api/files/make_dir.py +126 -0
  30. useshards/_generated/api/files/read_archive.py +127 -0
  31. useshards/_generated/api/files/read_file.py +127 -0
  32. useshards/_generated/api/files/stat_file.py +89 -0
  33. useshards/_generated/api/files/write_archive.py +152 -0
  34. useshards/_generated/api/files/write_file.py +174 -0
  35. useshards/_generated/api/meta/__init__.py +1 -0
  36. useshards/_generated/api/meta/get_capabilities.py +96 -0
  37. useshards/_generated/api/meta/get_version.py +96 -0
  38. useshards/_generated/api/meta/list_scopes.py +96 -0
  39. useshards/_generated/api/policies/__init__.py +1 -0
  40. useshards/_generated/api/policies/get_policy.py +110 -0
  41. useshards/_generated/api/policies/list_policies.py +127 -0
  42. useshards/_generated/api/policies/put_policy.py +129 -0
  43. useshards/_generated/api/policies/remove_policy.py +108 -0
  44. useshards/_generated/api/sandboxes/__init__.py +1 -0
  45. useshards/_generated/api/sandboxes/attach_policy.py +128 -0
  46. useshards/_generated/api/sandboxes/create_sandbox.py +133 -0
  47. useshards/_generated/api/sandboxes/detach_policy.py +110 -0
  48. useshards/_generated/api/sandboxes/fork_sandbox.py +129 -0
  49. useshards/_generated/api/sandboxes/get_sandbox.py +127 -0
  50. useshards/_generated/api/sandboxes/get_sandbox_egress_log.py +136 -0
  51. useshards/_generated/api/sandboxes/get_sandbox_logs.py +129 -0
  52. useshards/_generated/api/sandboxes/grant_secret.py +120 -0
  53. useshards/_generated/api/sandboxes/list_sandboxes.py +138 -0
  54. useshards/_generated/api/sandboxes/pause_sandbox.py +110 -0
  55. useshards/_generated/api/sandboxes/remove_sandbox.py +125 -0
  56. useshards/_generated/api/sandboxes/resume_sandbox.py +110 -0
  57. useshards/_generated/api/sandboxes/start_sandbox.py +110 -0
  58. useshards/_generated/api/sandboxes/stop_sandbox.py +129 -0
  59. useshards/_generated/api/sandboxes/ungrant_secret.py +120 -0
  60. useshards/_generated/api/secrets/__init__.py +1 -0
  61. useshards/_generated/api/secrets/list_secrets.py +127 -0
  62. useshards/_generated/api/secrets/put_secret.py +128 -0
  63. useshards/_generated/api/secrets/remove_secret.py +125 -0
  64. useshards/_generated/api/snapshots/__init__.py +1 -0
  65. useshards/_generated/api/snapshots/create_snapshot.py +117 -0
  66. useshards/_generated/api/snapshots/get_snapshot.py +110 -0
  67. useshards/_generated/api/snapshots/list_snapshots.py +127 -0
  68. useshards/_generated/api/snapshots/remove_snapshot.py +108 -0
  69. useshards/_generated/client.py +215 -0
  70. useshards/_generated/errors.py +16 -0
  71. useshards/_generated/models/__init__.py +141 -0
  72. useshards/_generated/models/app_exit.py +54 -0
  73. useshards/_generated/models/app_stop_request.py +38 -0
  74. useshards/_generated/models/capabilities.py +89 -0
  75. useshards/_generated/models/copy_request.py +38 -0
  76. useshards/_generated/models/create_line.py +89 -0
  77. useshards/_generated/models/create_request.py +149 -0
  78. useshards/_generated/models/destination.py +48 -0
  79. useshards/_generated/models/destination_kind.py +11 -0
  80. useshards/_generated/models/effective.py +64 -0
  81. useshards/_generated/models/effective_rule.py +105 -0
  82. useshards/_generated/models/effective_rule_action.py +9 -0
  83. useshards/_generated/models/effective_rule_implied.py +9 -0
  84. useshards/_generated/models/effective_rule_protocol.py +9 -0
  85. useshards/_generated/models/egress_decision.py +104 -0
  86. useshards/_generated/models/egress_decision_source.py +10 -0
  87. useshards/_generated/models/egress_decision_verdict.py +9 -0
  88. useshards/_generated/models/end_message.py +41 -0
  89. useshards/_generated/models/end_message_reason.py +9 -0
  90. useshards/_generated/models/entries_response.py +56 -0
  91. useshards/_generated/models/error.py +48 -0
  92. useshards/_generated/models/error_object.py +65 -0
  93. useshards/_generated/models/event.py +89 -0
  94. useshards/_generated/models/event_status.py +14 -0
  95. useshards/_generated/models/exec_.py +140 -0
  96. useshards/_generated/models/exec_request.py +113 -0
  97. useshards/_generated/models/exec_state.py +9 -0
  98. useshards/_generated/models/execs_response.py +69 -0
  99. useshards/_generated/models/exit_message.py +63 -0
  100. useshards/_generated/models/exit_status.py +47 -0
  101. useshards/_generated/models/failure_error.py +47 -0
  102. useshards/_generated/models/failure_message.py +48 -0
  103. useshards/_generated/models/file_entry.py +84 -0
  104. useshards/_generated/models/file_entry_type.py +11 -0
  105. useshards/_generated/models/inspection.py +230 -0
  106. useshards/_generated/models/inspection_state.py +14 -0
  107. useshards/_generated/models/kill_request.py +38 -0
  108. useshards/_generated/models/mkdir_request.py +64 -0
  109. useshards/_generated/models/policies_response.py +70 -0
  110. useshards/_generated/models/policy.py +63 -0
  111. useshards/_generated/models/policy_attach_request.py +40 -0
  112. useshards/_generated/models/policy_request.py +58 -0
  113. useshards/_generated/models/policy_view.py +81 -0
  114. useshards/_generated/models/policy_view_dns.py +9 -0
  115. useshards/_generated/models/resource_request.py +54 -0
  116. useshards/_generated/models/resources.py +54 -0
  117. useshards/_generated/models/restart.py +93 -0
  118. useshards/_generated/models/restart_policy.py +10 -0
  119. useshards/_generated/models/restart_spec.py +57 -0
  120. useshards/_generated/models/restart_spec_policy.py +10 -0
  121. useshards/_generated/models/rule.py +82 -0
  122. useshards/_generated/models/rule_action.py +9 -0
  123. useshards/_generated/models/rule_protocol.py +9 -0
  124. useshards/_generated/models/rule_text.py +48 -0
  125. useshards/_generated/models/rule_text_action.py +9 -0
  126. useshards/_generated/models/sandbox.py +212 -0
  127. useshards/_generated/models/sandbox_state.py +14 -0
  128. useshards/_generated/models/sandboxes_response.py +80 -0
  129. useshards/_generated/models/scope.py +47 -0
  130. useshards/_generated/models/scopes_response.py +56 -0
  131. useshards/_generated/models/secret.py +62 -0
  132. useshards/_generated/models/secret_request.py +58 -0
  133. useshards/_generated/models/secrets_response.py +80 -0
  134. useshards/_generated/models/snapshot.py +113 -0
  135. useshards/_generated/models/snapshot_request.py +48 -0
  136. useshards/_generated/models/snapshots_response.py +70 -0
  137. useshards/_generated/models/stop_request.py +25 -0
  138. useshards/_generated/models/terminal_size.py +46 -0
  139. useshards/_generated/models/version_response.py +47 -0
  140. useshards/_generated/types.py +54 -0
  141. useshards/_sync/_backend.py +38 -0
  142. useshards/_sync/_command.py +403 -0
  143. useshards/_sync/_files.py +300 -0
  144. useshards/_sync/_follow.py +136 -0
  145. useshards/_sync/_sandbox.py +292 -0
  146. useshards/_sync/_shard.py +336 -0
  147. useshards/_sync/_transport.py +152 -0
  148. useshards/_sync/_ws.py +170 -0
  149. useshards/_types.py +452 -0
  150. useshards/_version.py +1 -0
  151. useshards/_wire.py +159 -0
  152. useshards/errors.py +187 -0
  153. useshards/py.typed +0 -0
  154. useshards-0.1.0.dist-info/METADATA +139 -0
  155. useshards-0.1.0.dist-info/RECORD +158 -0
  156. useshards-0.1.0.dist-info/WHEEL +4 -0
  157. useshards-0.1.0.dist-info/licenses/LICENSE +202 -0
  158. useshards-0.1.0.dist-info/licenses/NOTICE +17 -0
useshards/__init__.py ADDED
@@ -0,0 +1,123 @@
1
+ """The Python SDK of shard: Shard for blocking code, AsyncShard for asyncio, with the same verbs on both."""
2
+
3
+ from ._async._command import AsyncCommand
4
+ from ._async._files import AsyncFiles
5
+ from ._async._follow import AsyncFollow
6
+ from ._async._sandbox import AsyncApp, AsyncCommands, AsyncSandbox
7
+ from ._async._shard import AsyncPolicies, AsyncSecrets, AsyncShard, AsyncSnapshots
8
+ from ._capture import DEFAULT_OUTPUT_LIMIT
9
+ from ._sync._command import Command
10
+ from ._sync._files import Files
11
+ from ._sync._follow import Follow
12
+ from ._sync._sandbox import App, Commands, Sandbox
13
+ from ._sync._shard import Policies, Secrets, Shard, Snapshots
14
+ from ._types import (
15
+ AppExit,
16
+ AppInfo,
17
+ Capabilities,
18
+ CommandInfo,
19
+ CommandResult,
20
+ EgressDecision,
21
+ ExitStatus,
22
+ FileEntry,
23
+ FileInfo,
24
+ FileType,
25
+ OutputCallback,
26
+ Policy,
27
+ PolicyRule,
28
+ Resources,
29
+ Restart,
30
+ RestartInfo,
31
+ RestartPolicy,
32
+ SandboxInfo,
33
+ SandboxList,
34
+ SecretInfo,
35
+ SecretList,
36
+ Signal,
37
+ Snapshot,
38
+ TerminalSize,
39
+ Version,
40
+ )
41
+ from ._version import __version__
42
+ from .errors import (
43
+ APIError,
44
+ AuthenticationError,
45
+ CommandNotStartedError,
46
+ ConfigurationError,
47
+ ConflictError,
48
+ InvalidRequestError,
49
+ NotFoundError,
50
+ PermissionDeniedError,
51
+ ProtocolError,
52
+ ServerError,
53
+ ShardConnectionError,
54
+ ShardError,
55
+ UnknownLengthError,
56
+ UnsafeArchiveError,
57
+ UnsupportedError,
58
+ )
59
+
60
+ __all__ = [
61
+ "DEFAULT_OUTPUT_LIMIT",
62
+ "APIError",
63
+ "App",
64
+ "AppExit",
65
+ "AppInfo",
66
+ "AsyncApp",
67
+ "AsyncCommand",
68
+ "AsyncCommands",
69
+ "AsyncFiles",
70
+ "AsyncFollow",
71
+ "AsyncPolicies",
72
+ "AsyncSandbox",
73
+ "AsyncSecrets",
74
+ "AsyncShard",
75
+ "AsyncSnapshots",
76
+ "AuthenticationError",
77
+ "Capabilities",
78
+ "Command",
79
+ "CommandInfo",
80
+ "CommandNotStartedError",
81
+ "CommandResult",
82
+ "Commands",
83
+ "ConfigurationError",
84
+ "ConflictError",
85
+ "EgressDecision",
86
+ "ExitStatus",
87
+ "FileEntry",
88
+ "FileInfo",
89
+ "FileType",
90
+ "Files",
91
+ "Follow",
92
+ "InvalidRequestError",
93
+ "NotFoundError",
94
+ "OutputCallback",
95
+ "PermissionDeniedError",
96
+ "Policies",
97
+ "Policy",
98
+ "PolicyRule",
99
+ "ProtocolError",
100
+ "Resources",
101
+ "Restart",
102
+ "RestartInfo",
103
+ "RestartPolicy",
104
+ "Sandbox",
105
+ "SandboxInfo",
106
+ "SandboxList",
107
+ "SecretInfo",
108
+ "SecretList",
109
+ "Secrets",
110
+ "ServerError",
111
+ "Shard",
112
+ "ShardConnectionError",
113
+ "ShardError",
114
+ "Signal",
115
+ "Snapshot",
116
+ "Snapshots",
117
+ "TerminalSize",
118
+ "UnknownLengthError",
119
+ "UnsafeArchiveError",
120
+ "UnsupportedError",
121
+ "Version",
122
+ "__version__",
123
+ ]
useshards/_archive.py ADDED
@@ -0,0 +1,249 @@
1
+ """Pack a local directory as a tar, and unpack a sandbox's tar under a directory it never writes outside of."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import os
7
+ import posixpath
8
+ import shutil
9
+ import stat
10
+ import tarfile
11
+ from collections.abc import Callable
12
+ from secrets import token_hex
13
+ from typing import IO, TypeVar
14
+
15
+ from .errors import ProtocolError, UnsafeArchiveError
16
+
17
+ # The Go client's caps, since a sandbox's tar is untrusted.
18
+ MAX_BYTES = 64 << 30
19
+ MAX_ENTRIES = 1 << 20
20
+ _CHUNK = 1 << 20
21
+ _CREATE = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ def pack(source: str, name: str, out: IO[bytes]) -> None:
27
+ """Write source as a tar whose top entry is name, a directory's entries in lexical order."""
28
+ with tarfile.open(fileobj=out, mode="w|", format=tarfile.PAX_FORMAT) as tar:
29
+ # The walk starts past a link the source names, as an open of a file follows one.
30
+ tar.add(os.path.realpath(source), arcname=name)
31
+
32
+
33
+ def unpack(source: IO[bytes], dst: str, strip: str) -> None:
34
+ """Land the tar under dst, the entry named strip at dst itself, by the rules of the Go client's unpack."""
35
+ _Unpacker(dst, strip).run(source)
36
+
37
+
38
+ class _Unpacker:
39
+ def __init__(self, dst: str, strip: str) -> None:
40
+ self.dst = os.path.abspath(dst)
41
+ self.real = os.path.realpath(dst)
42
+ self.strip = strip
43
+ self.bytes = 0
44
+ # The regular files this unpack wrote, by identity, the only targets a hard link may name.
45
+ self.files: dict[str, os.stat_result] = {}
46
+ self.dirs: list[tuple[str, int]] = []
47
+ self.links: list[tuple[str, str]] = []
48
+
49
+ def run(self, source: IO[bytes]) -> None:
50
+ try:
51
+ self._entries(source)
52
+ except BaseException as e:
53
+ # A refused unpack leaves no link behind that leaves dst either.
54
+ for err in self._confine():
55
+ e.add_note(str(err))
56
+ raise
57
+ refused = self._confine()
58
+ if refused:
59
+ for err in refused[1:]:
60
+ refused[0].add_note(str(err))
61
+ raise refused[0]
62
+ # Deepest first, so each directory's entries landed whatever its own mode is.
63
+ for at, mode in reversed(self.dirs):
64
+ os.chmod(at, mode)
65
+
66
+ def _entries(self, source: IO[bytes]) -> None:
67
+ try:
68
+ with tarfile.open(fileobj=source, mode="r|") as tar:
69
+ for count, member in enumerate(tar):
70
+ if count >= MAX_ENTRIES:
71
+ raise UnsafeArchiveError(member.name, f"the archive runs past {MAX_ENTRIES} entries")
72
+ self._entry(tar, member)
73
+ except tarfile.TarError as e:
74
+ raise ProtocolError(f"the sandbox sent a tar the SDK cannot read: {e}") from e
75
+
76
+ def _entry(self, tar: tarfile.TarFile, member: tarfile.TarInfo) -> None:
77
+ name = self._local(member.name)
78
+ if name == "." and not member.isdir():
79
+ raise UnsafeArchiveError(member.name, "only a directory can land at the destination itself")
80
+ if member.isdir():
81
+ return self._dir(name, member)
82
+ if member.isreg():
83
+ return self._file(name, member, tar)
84
+ if member.issym():
85
+ return self._symlink(name, member)
86
+ if member.islnk():
87
+ return self._hardlink(name, member)
88
+ if member.ischr() or member.isblk():
89
+ raise UnsafeArchiveError(member.name, "a device node")
90
+ if member.isfifo():
91
+ raise UnsafeArchiveError(member.name, "a fifo, which an unpack does not make")
92
+ raise UnsafeArchiveError(member.name, f"the type {member.type!r}, which an unpack does not make")
93
+
94
+ def _local(self, name: str) -> str:
95
+ """The entry's name under dst: no absolute name and no .. before the clean, then strip cut off."""
96
+ if not name:
97
+ raise UnsafeArchiveError(name, "an empty name")
98
+ if name.startswith("/"):
99
+ raise UnsafeArchiveError(name, "an absolute name")
100
+ if ".." in name.split("/"):
101
+ raise UnsafeArchiveError(name, "a .. component")
102
+ clean = posixpath.normpath(name)
103
+ if clean == self.strip:
104
+ return "."
105
+ prefix = self.strip + "/"
106
+ if not clean.startswith(prefix):
107
+ raise UnsafeArchiveError(name, f"it is not under {self.strip}")
108
+ return clean[len(prefix) :]
109
+
110
+ def _parent(self, name: str, entry: str) -> str:
111
+ """Make name's directories as mkdir -p does, through no symlink, and answer the path name lands at."""
112
+ if name == ".":
113
+ return self.dst
114
+ at = self.dst
115
+ parts = name.split("/")
116
+ for part in parts[:-1]:
117
+ at = os.path.join(at, part)
118
+ try:
119
+ info = os.lstat(at)
120
+ except FileNotFoundError:
121
+ os.mkdir(at, 0o755)
122
+ continue
123
+ if stat.S_ISLNK(info.st_mode):
124
+ raise UnsafeArchiveError(entry, "it sits under a symlink, which an unpack never writes through")
125
+ if not stat.S_ISDIR(info.st_mode):
126
+ raise NotADirectoryError(errno.ENOTDIR, os.strerror(errno.ENOTDIR), at)
127
+ target = os.path.join(at, parts[-1])
128
+ # A name the platform reads otherwise, as a backslash on Windows, must still land under dst.
129
+ if os.path.commonpath([self.dst, os.path.abspath(target)]) != self.dst:
130
+ raise UnsafeArchiveError(entry, "it leaves the destination")
131
+ return target
132
+
133
+ def _dir(self, name: str, member: tarfile.TarInfo) -> None:
134
+ at = self._parent(name, member.name)
135
+ try:
136
+ # Only its owner can write it until the tree is in, so its entries land whatever its mode.
137
+ os.mkdir(at, 0o700)
138
+ except FileExistsError:
139
+ if not stat.S_ISDIR(os.lstat(at).st_mode):
140
+ raise UnsafeArchiveError(member.name, "a directory where something else already is") from None
141
+ return
142
+ self.dirs.append((at, _mode(member)))
143
+
144
+ def _file(self, name: str, member: tarfile.TarInfo, tar: tarfile.TarFile) -> None:
145
+ # A GNU base-256 size can be negative, which would wind the byte count back under the cap.
146
+ if member.size < 0:
147
+ raise UnsafeArchiveError(member.name, "a negative size")
148
+ if member.size > MAX_BYTES - self.bytes:
149
+ raise UnsafeArchiveError(member.name, f"the archive runs past {MAX_BYTES} bytes")
150
+ self.bytes += member.size
151
+ at = self._parent(name, member.name)
152
+ data = tar.extractfile(member)
153
+ if data is None:
154
+ raise ProtocolError(f"the tar holds no data for the file {member.name!r}")
155
+ tmp, fd = _fresh(at, lambda p: os.open(p, _CREATE, 0o600))
156
+ try:
157
+ with os.fdopen(fd, "wb") as out:
158
+ shutil.copyfileobj(data, out, _CHUNK)
159
+ os.chmod(tmp, _mode(member))
160
+ except BaseException:
161
+ _remove_temp(tmp)
162
+ raise
163
+ self._place(tmp, at, name, file=True)
164
+
165
+ def _symlink(self, name: str, member: tarfile.TarInfo) -> None:
166
+ if _leaves(name, member.linkname):
167
+ raise UnsafeArchiveError(member.name, f"a symlink to {member.linkname!r}, which leaves the destination")
168
+ at = self._parent(name, member.name)
169
+ tmp = _fresh(at, lambda p: os.symlink(member.linkname, p))[0]
170
+ self.links.append((at, member.name))
171
+ self._place(tmp, at, name, file=False)
172
+
173
+ def _hardlink(self, name: str, member: tarfile.TarInfo) -> None:
174
+ refusal = UnsafeArchiveError(
175
+ member.name, f"a hard link to {member.linkname!r}, which is not a file earlier in the archive"
176
+ )
177
+ try:
178
+ target = self._local(member.linkname)
179
+ except UnsafeArchiveError:
180
+ raise refusal from None
181
+ wrote = self.files.get(target)
182
+ if wrote is None:
183
+ raise refusal
184
+ at = self._parent(name, member.name)
185
+ tmp = _fresh(at, lambda p: os.link(os.path.join(self.dst, *target.split("/")), p))[0]
186
+ # A filesystem that folds case lets a later entry spelled another way take the name.
187
+ linked = os.lstat(tmp)
188
+ if not stat.S_ISREG(linked.st_mode) or not os.path.samestat(wrote, linked):
189
+ _remove_temp(tmp)
190
+ raise UnsafeArchiveError(
191
+ member.name, f"a hard link to {member.linkname!r}, which no longer names the file the archive wrote"
192
+ )
193
+ self._place(tmp, at, name, file=True)
194
+
195
+ def _place(self, tmp: str, at: str, name: str, *, file: bool) -> None:
196
+ """Rename the finished temp over the name, so a link already there is replaced and never followed."""
197
+ try:
198
+ os.replace(tmp, at)
199
+ except BaseException:
200
+ _remove_temp(tmp)
201
+ raise
202
+ if not file:
203
+ self.files.pop(name, None)
204
+ return
205
+ self.files[name] = os.lstat(at)
206
+
207
+ def _confine(self) -> list[Exception]:
208
+ """Remove every new link that leaves dst through another one, which only the tree as written shows."""
209
+ refused: list[Exception] = []
210
+ for at, entry in self.links:
211
+ if os.path.commonpath([self.real, os.path.realpath(at)]) == self.real:
212
+ continue
213
+ refused.append(UnsafeArchiveError(entry, "a symlink that leaves the destination through another link"))
214
+ try:
215
+ os.remove(at)
216
+ except OSError as e:
217
+ refused.append(e)
218
+ self.links = []
219
+ return refused
220
+
221
+
222
+ def _leaves(name: str, target: str) -> bool:
223
+ """The lexical half of the link check: an absolute target, or a .. that climbs past dst from where the link sits."""
224
+ if posixpath.isabs(target):
225
+ return True
226
+ resolved = posixpath.normpath(posixpath.join(posixpath.dirname(name), target))
227
+ return resolved == ".." or resolved.startswith("../")
228
+
229
+
230
+ def _mode(member: tarfile.TarInfo) -> int:
231
+ # The host drops setuid and setgid, so a sandbox cannot hand it a set-id binary.
232
+ return member.mode & (0o777 | stat.S_ISVTX)
233
+
234
+
235
+ def _fresh(at: str, make: Callable[[str], T]) -> tuple[str, T]:
236
+ """A temp next to at that make creates and that was not there before, so a cleanup only removes its own."""
237
+ while True:
238
+ tmp = os.path.join(os.path.dirname(at), f".useshards-unpack-{token_hex(8)}")
239
+ try:
240
+ return tmp, make(tmp)
241
+ except FileExistsError:
242
+ continue
243
+
244
+
245
+ def _remove_temp(tmp: str) -> None:
246
+ try:
247
+ os.remove(tmp)
248
+ except FileNotFoundError:
249
+ return
@@ -0,0 +1,44 @@
1
+ """What the async client runs on: asyncio. The sync client's twin of this file runs on threads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable, Coroutine
7
+ from typing import Any, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+ Lock = asyncio.Lock
12
+ Event = asyncio.Event
13
+ sleep = asyncio.sleep
14
+
15
+
16
+ async def offload(fn: Callable[[], T]) -> T:
17
+ """Run blocking disk work off the loop."""
18
+ return await asyncio.to_thread(fn)
19
+
20
+
21
+ async def wait_event(event: asyncio.Event, timeout: float | None) -> bool:
22
+ if timeout is None:
23
+ await event.wait()
24
+ return True
25
+ try:
26
+ await asyncio.wait_for(event.wait(), timeout)
27
+ except TimeoutError:
28
+ return False
29
+ return True
30
+
31
+
32
+ class Task:
33
+ """One background job, as a task on the running loop."""
34
+
35
+ def __init__(self, fn: Callable[[], Coroutine[Any, Any, None]]) -> None:
36
+ self._task = asyncio.get_running_loop().create_task(fn())
37
+
38
+ async def join(self, timeout: float | None) -> bool:
39
+ done, _ = await asyncio.wait({self._task}, timeout=timeout)
40
+ return bool(done)
41
+
42
+ async def cancel(self) -> None:
43
+ self._task.cancel()
44
+ await asyncio.wait({self._task})