isolattice 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.
- isolattice/__init__.py +205 -0
- isolattice/_artifacts.py +219 -0
- isolattice/_async.py +117 -0
- isolattice/backend.py +294 -0
- isolattice/backends/__init__.py +19 -0
- isolattice/backends/_bootstrap.py +123 -0
- isolattice/backends/_cli.py +304 -0
- isolattice/backends/_kubernetes_network.py +191 -0
- isolattice/backends/_linux_helper.py +514 -0
- isolattice/backends/_macos_helper.py +281 -0
- isolattice/backends/_mounts.py +90 -0
- isolattice/backends/_native.py +129 -0
- isolattice/backends/_podman_log_filter.py +77 -0
- isolattice/backends/_windows_helper.py +504 -0
- isolattice/backends/docker.py +1622 -0
- isolattice/backends/kubernetes.py +1930 -0
- isolattice/backends/linux.py +937 -0
- isolattice/backends/macos.py +925 -0
- isolattice/backends/podman.py +2347 -0
- isolattice/backends/windows.py +986 -0
- isolattice/backends/windows_appcontainer.py +1004 -0
- isolattice/commands.py +64 -0
- isolattice/configuration.py +316 -0
- isolattice/contracts.py +120 -0
- isolattice/errors.py +56 -0
- isolattice/events.py +117 -0
- isolattice/models.py +941 -0
- isolattice/observability.py +666 -0
- isolattice/otel.py +229 -0
- isolattice/py.typed +0 -0
- isolattice/quotas.py +227 -0
- isolattice/remote/__init__.py +32 -0
- isolattice/remote/_attach.py +129 -0
- isolattice/remote/_journal.py +216 -0
- isolattice/remote/_placement.py +138 -0
- isolattice/remote/_wire.py +428 -0
- isolattice/remote/client.py +846 -0
- isolattice/remote/server.py +1148 -0
- isolattice/resolution.py +223 -0
- isolattice/runtime.py +1646 -0
- isolattice/s3.py +565 -0
- isolattice/shutdown.py +151 -0
- isolattice/signing.py +136 -0
- isolattice/storage.py +436 -0
- isolattice/supervision.py +439 -0
- isolattice/termination.py +224 -0
- isolattice/testing.py +351 -0
- isolattice-0.1.0.dist-info/METADATA +332 -0
- isolattice-0.1.0.dist-info/RECORD +51 -0
- isolattice-0.1.0.dist-info/WHEEL +4 -0
- isolattice-0.1.0.dist-info/licenses/LICENSE +201 -0
isolattice/__init__.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Async sandbox execution with explicit backend selection and verified policies."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .backend import Backend, BackendSession, Boundary
|
|
6
|
+
from .backends import (
|
|
7
|
+
DockerBackend,
|
|
8
|
+
KubernetesBackend,
|
|
9
|
+
LinuxNamespaceBackend,
|
|
10
|
+
MacOSSeatbeltBackend,
|
|
11
|
+
PodmanBackend,
|
|
12
|
+
WindowsAppContainerBackend,
|
|
13
|
+
WindowsJobBackend,
|
|
14
|
+
)
|
|
15
|
+
from .commands import CommandRunner, SubprocessRunner
|
|
16
|
+
from .configuration import (
|
|
17
|
+
BaselinePolicyValidator,
|
|
18
|
+
ConfigurationProvider,
|
|
19
|
+
MappingConfigurationProvider,
|
|
20
|
+
PolicyValidator,
|
|
21
|
+
TomlConfigurationProvider,
|
|
22
|
+
policy_from_mapping,
|
|
23
|
+
)
|
|
24
|
+
from .contracts import CONTRACT_VERSION, ExtensionPoint, extension_points, load_trusted_plugin
|
|
25
|
+
from .errors import (
|
|
26
|
+
SandboxArtifactError,
|
|
27
|
+
SandboxBackendError,
|
|
28
|
+
SandboxCapabilityError,
|
|
29
|
+
SandboxError,
|
|
30
|
+
SandboxExecutionError,
|
|
31
|
+
SandboxQuotaError,
|
|
32
|
+
SandboxStateError,
|
|
33
|
+
SandboxTerminationError,
|
|
34
|
+
SandboxTimeoutError,
|
|
35
|
+
TerminationVerificationError,
|
|
36
|
+
)
|
|
37
|
+
from .events import EventKind, EventSink, LoggingEventSink, SandboxEvent
|
|
38
|
+
from .models import (
|
|
39
|
+
DISABLEABLE_CAPABILITIES,
|
|
40
|
+
ArtifactKind,
|
|
41
|
+
ArtifactManifest,
|
|
42
|
+
ArtifactSignature,
|
|
43
|
+
BackendReport,
|
|
44
|
+
BindMount,
|
|
45
|
+
Capability,
|
|
46
|
+
CapabilityEvidence,
|
|
47
|
+
CapabilityState,
|
|
48
|
+
EgressRule,
|
|
49
|
+
ExecutionRequest,
|
|
50
|
+
ExecutionResult,
|
|
51
|
+
NetworkAccess,
|
|
52
|
+
OutputChunk,
|
|
53
|
+
ResourceBudget,
|
|
54
|
+
ResourceUsage,
|
|
55
|
+
SandboxPolicy,
|
|
56
|
+
TerminationPolicy,
|
|
57
|
+
TerminationReport,
|
|
58
|
+
TransportProtocol,
|
|
59
|
+
inherit_environment,
|
|
60
|
+
validate_artifact_key,
|
|
61
|
+
)
|
|
62
|
+
from .observability import (
|
|
63
|
+
AuditLogError,
|
|
64
|
+
AuditLogEventSink,
|
|
65
|
+
AuditLogSummary,
|
|
66
|
+
FanOutEventSink,
|
|
67
|
+
InstanceDiagnostics,
|
|
68
|
+
RecordingEventSink,
|
|
69
|
+
RedactingEventSink,
|
|
70
|
+
Redactor,
|
|
71
|
+
RegexRedactor,
|
|
72
|
+
SandboxDiagnostics,
|
|
73
|
+
SandboxHealth,
|
|
74
|
+
SecretRedactor,
|
|
75
|
+
verify_audit_log,
|
|
76
|
+
)
|
|
77
|
+
from .quotas import QuotaResourceController, ResourceClaim, ResourceController
|
|
78
|
+
from .resolution import (
|
|
79
|
+
BackendResolver,
|
|
80
|
+
Candidate,
|
|
81
|
+
CapabilityResolver,
|
|
82
|
+
policy_compatibility,
|
|
83
|
+
select_backend,
|
|
84
|
+
)
|
|
85
|
+
from .runtime import ExecutionHandle, Sandbox, SandboxInstance
|
|
86
|
+
from .s3 import S3ArtifactStore, S3Credentials
|
|
87
|
+
from .shutdown import ShutdownSignals, default_shutdown_signals, shutdown_on_signals
|
|
88
|
+
from .storage import ArtifactSigner, ArtifactStore, ArtifactVerifier, LocalArtifactStore
|
|
89
|
+
from .supervision import (
|
|
90
|
+
FailedAttempt,
|
|
91
|
+
RestartPolicy,
|
|
92
|
+
SandboxPool,
|
|
93
|
+
SupervisedResult,
|
|
94
|
+
run_supervised,
|
|
95
|
+
)
|
|
96
|
+
from .termination import BoundaryState, Observation, TerminationDriver, terminate_boundary
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
__version__ = version("isolattice")
|
|
100
|
+
except PackageNotFoundError: # pragma: no cover - only when run from an uninstalled tree
|
|
101
|
+
__version__ = "0.0.0"
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"CONTRACT_VERSION",
|
|
105
|
+
"DISABLEABLE_CAPABILITIES",
|
|
106
|
+
"ArtifactKind",
|
|
107
|
+
"ArtifactManifest",
|
|
108
|
+
"ArtifactSignature",
|
|
109
|
+
"ArtifactSigner",
|
|
110
|
+
"ArtifactStore",
|
|
111
|
+
"ArtifactVerifier",
|
|
112
|
+
"AuditLogError",
|
|
113
|
+
"AuditLogEventSink",
|
|
114
|
+
"AuditLogSummary",
|
|
115
|
+
"Backend",
|
|
116
|
+
"BackendReport",
|
|
117
|
+
"BackendResolver",
|
|
118
|
+
"BackendSession",
|
|
119
|
+
"BaselinePolicyValidator",
|
|
120
|
+
"BindMount",
|
|
121
|
+
"Boundary",
|
|
122
|
+
"BoundaryState",
|
|
123
|
+
"Candidate",
|
|
124
|
+
"Capability",
|
|
125
|
+
"CapabilityEvidence",
|
|
126
|
+
"CapabilityResolver",
|
|
127
|
+
"CapabilityState",
|
|
128
|
+
"CommandRunner",
|
|
129
|
+
"ConfigurationProvider",
|
|
130
|
+
"DockerBackend",
|
|
131
|
+
"EgressRule",
|
|
132
|
+
"EventKind",
|
|
133
|
+
"EventSink",
|
|
134
|
+
"ExecutionHandle",
|
|
135
|
+
"ExecutionRequest",
|
|
136
|
+
"ExecutionResult",
|
|
137
|
+
"ExtensionPoint",
|
|
138
|
+
"FailedAttempt",
|
|
139
|
+
"FanOutEventSink",
|
|
140
|
+
"InstanceDiagnostics",
|
|
141
|
+
"KubernetesBackend",
|
|
142
|
+
"LinuxNamespaceBackend",
|
|
143
|
+
"LocalArtifactStore",
|
|
144
|
+
"LoggingEventSink",
|
|
145
|
+
"MacOSSeatbeltBackend",
|
|
146
|
+
"MappingConfigurationProvider",
|
|
147
|
+
"NetworkAccess",
|
|
148
|
+
"Observation",
|
|
149
|
+
"OutputChunk",
|
|
150
|
+
"PodmanBackend",
|
|
151
|
+
"PolicyValidator",
|
|
152
|
+
"QuotaResourceController",
|
|
153
|
+
"RecordingEventSink",
|
|
154
|
+
"RedactingEventSink",
|
|
155
|
+
"Redactor",
|
|
156
|
+
"RegexRedactor",
|
|
157
|
+
"ResourceBudget",
|
|
158
|
+
"ResourceClaim",
|
|
159
|
+
"ResourceController",
|
|
160
|
+
"ResourceUsage",
|
|
161
|
+
"RestartPolicy",
|
|
162
|
+
"S3ArtifactStore",
|
|
163
|
+
"S3Credentials",
|
|
164
|
+
"Sandbox",
|
|
165
|
+
"SandboxArtifactError",
|
|
166
|
+
"SandboxBackendError",
|
|
167
|
+
"SandboxCapabilityError",
|
|
168
|
+
"SandboxDiagnostics",
|
|
169
|
+
"SandboxError",
|
|
170
|
+
"SandboxEvent",
|
|
171
|
+
"SandboxExecutionError",
|
|
172
|
+
"SandboxHealth",
|
|
173
|
+
"SandboxInstance",
|
|
174
|
+
"SandboxPolicy",
|
|
175
|
+
"SandboxPool",
|
|
176
|
+
"SandboxQuotaError",
|
|
177
|
+
"SandboxStateError",
|
|
178
|
+
"SandboxTerminationError",
|
|
179
|
+
"SandboxTimeoutError",
|
|
180
|
+
"SecretRedactor",
|
|
181
|
+
"ShutdownSignals",
|
|
182
|
+
"SubprocessRunner",
|
|
183
|
+
"SupervisedResult",
|
|
184
|
+
"TerminationDriver",
|
|
185
|
+
"TerminationPolicy",
|
|
186
|
+
"TerminationReport",
|
|
187
|
+
"TerminationVerificationError",
|
|
188
|
+
"TomlConfigurationProvider",
|
|
189
|
+
"TransportProtocol",
|
|
190
|
+
"WindowsAppContainerBackend",
|
|
191
|
+
"WindowsJobBackend",
|
|
192
|
+
"__version__",
|
|
193
|
+
"default_shutdown_signals",
|
|
194
|
+
"extension_points",
|
|
195
|
+
"inherit_environment",
|
|
196
|
+
"load_trusted_plugin",
|
|
197
|
+
"policy_compatibility",
|
|
198
|
+
"policy_from_mapping",
|
|
199
|
+
"run_supervised",
|
|
200
|
+
"select_backend",
|
|
201
|
+
"shutdown_on_signals",
|
|
202
|
+
"terminate_boundary",
|
|
203
|
+
"validate_artifact_key",
|
|
204
|
+
"verify_audit_log",
|
|
205
|
+
]
|
isolattice/_artifacts.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Integrity manifests and safe-archive validation for snapshots and checkpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import datetime
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
from .errors import SandboxArtifactError
|
|
17
|
+
from .models import ArtifactKind, ArtifactManifest
|
|
18
|
+
|
|
19
|
+
_READ_SIZE = 1024 * 1024
|
|
20
|
+
_DRIVE = re.compile(r"[A-Za-z]:")
|
|
21
|
+
_REMOVE_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)
|
|
22
|
+
WORKSPACE_FORMAT = "tar"
|
|
23
|
+
ROOTFS_FORMAT = "rootfs-tar"
|
|
24
|
+
CHECKPOINT_FORMAT = "backend-checkpoint"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def stage(parent: Path | None) -> Path:
|
|
28
|
+
"""Create a private staging directory for one artifact operation."""
|
|
29
|
+
return Path(await asyncio.to_thread(tempfile.mkdtemp, prefix="isolattice-", dir=parent))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ignore_missing(_function: object, _path: str, error: BaseException) -> None:
|
|
33
|
+
# A concurrent or cancelled earlier removal already deleted this entry.
|
|
34
|
+
if not isinstance(error, FileNotFoundError):
|
|
35
|
+
raise error
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rmtree_if_present(directory: Path) -> None:
|
|
39
|
+
shutil.rmtree(directory, onexc=_ignore_missing)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _remove_tree(directory: Path) -> None:
|
|
43
|
+
# Retry transient failures a bounded number of times, then surface the failure.
|
|
44
|
+
# On Windows, scanners and indexers briefly hold just-deleted files open, leaving
|
|
45
|
+
# them delete-pending so removing the parent directory fails. On BSDs a concurrent
|
|
46
|
+
# removal of the same tree can report EAGAIN.
|
|
47
|
+
transient: tuple[type[OSError], ...] = (
|
|
48
|
+
(BlockingIOError, PermissionError) if sys.platform == "win32" else (BlockingIOError,)
|
|
49
|
+
)
|
|
50
|
+
for delay in _REMOVE_RETRY_DELAYS:
|
|
51
|
+
try:
|
|
52
|
+
_rmtree_if_present(directory)
|
|
53
|
+
except transient:
|
|
54
|
+
time.sleep(delay)
|
|
55
|
+
else:
|
|
56
|
+
return
|
|
57
|
+
_rmtree_if_present(directory)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def unstage(directory: Path) -> None:
|
|
61
|
+
"""Remove a staging directory; archives may contain workload memory."""
|
|
62
|
+
await asyncio.to_thread(_remove_tree, directory)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _digest(path: Path) -> tuple[str, int]:
|
|
66
|
+
hasher = hashlib.sha256()
|
|
67
|
+
size = 0
|
|
68
|
+
with path.open("rb") as file:
|
|
69
|
+
while block := file.read(_READ_SIZE):
|
|
70
|
+
hasher.update(block)
|
|
71
|
+
size += len(block)
|
|
72
|
+
return hasher.hexdigest(), size
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def seal(
|
|
76
|
+
kind: ArtifactKind, backend: str, archive_format: str, archive: Path, key: str
|
|
77
|
+
) -> ArtifactManifest:
|
|
78
|
+
"""Hash a completed archive and describe it as the artifact ``key``."""
|
|
79
|
+
digest, size = await asyncio.to_thread(_digest, archive)
|
|
80
|
+
return ArtifactManifest(
|
|
81
|
+
kind=kind,
|
|
82
|
+
backend=backend,
|
|
83
|
+
format=archive_format,
|
|
84
|
+
archive=key,
|
|
85
|
+
sha256=digest,
|
|
86
|
+
size=size,
|
|
87
|
+
created_at=datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def verify(
|
|
92
|
+
archive: Path,
|
|
93
|
+
manifest: ArtifactManifest,
|
|
94
|
+
*,
|
|
95
|
+
key: str,
|
|
96
|
+
kind: ArtifactKind,
|
|
97
|
+
backend: str,
|
|
98
|
+
archive_format: str,
|
|
99
|
+
max_bytes: int,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Check a fetched archive against its manifest without trusting the store."""
|
|
102
|
+
if manifest.archive != key:
|
|
103
|
+
raise SandboxArtifactError("artifact manifest names a different key")
|
|
104
|
+
if manifest.kind is not kind:
|
|
105
|
+
raise SandboxArtifactError(f"artifact is a {manifest.kind.value}, not a {kind.value}")
|
|
106
|
+
if manifest.backend != backend:
|
|
107
|
+
raise SandboxArtifactError(
|
|
108
|
+
f"artifact was produced by backend {manifest.backend!r}, not {backend!r}"
|
|
109
|
+
)
|
|
110
|
+
if manifest.format != archive_format:
|
|
111
|
+
raise SandboxArtifactError(f"artifact format {manifest.format!r} is not {archive_format!r}")
|
|
112
|
+
if manifest.size > max_bytes:
|
|
113
|
+
raise SandboxArtifactError("artifact exceeds the configured restore size limit")
|
|
114
|
+
try:
|
|
115
|
+
digest, size = await asyncio.to_thread(_digest, archive)
|
|
116
|
+
except OSError as error:
|
|
117
|
+
raise SandboxArtifactError("fetched artifact archive is unreadable") from error
|
|
118
|
+
if size != manifest.size or digest != manifest.sha256:
|
|
119
|
+
raise SandboxArtifactError("artifact archive does not match its integrity manifest")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_workspace_tar(archive: Path, max_members: int, max_bytes: int) -> None:
|
|
123
|
+
members = 0
|
|
124
|
+
total = 0
|
|
125
|
+
try:
|
|
126
|
+
with tarfile.open(archive, mode="r:") as bundle:
|
|
127
|
+
for member in bundle:
|
|
128
|
+
members += 1
|
|
129
|
+
if members > max_members:
|
|
130
|
+
raise SandboxArtifactError("workspace archive has too many members")
|
|
131
|
+
if not (member.isreg() or member.isdir() or member.issym() or member.islnk()):
|
|
132
|
+
raise SandboxArtifactError(
|
|
133
|
+
f"workspace archive member {member.name!r} is not a regular file, "
|
|
134
|
+
"directory or link"
|
|
135
|
+
)
|
|
136
|
+
# data_filter rewrites absolute names; backend extractors may not, so
|
|
137
|
+
# reject them outright instead of relying on the rewrite.
|
|
138
|
+
name = PurePosixPath(member.name)
|
|
139
|
+
if (
|
|
140
|
+
name.is_absolute()
|
|
141
|
+
or ".." in name.parts
|
|
142
|
+
or "\\" in member.name
|
|
143
|
+
or _DRIVE.match(member.name) is not None
|
|
144
|
+
):
|
|
145
|
+
raise SandboxArtifactError(
|
|
146
|
+
f"workspace archive member {member.name!r} is unsafe: "
|
|
147
|
+
"absolute or traversing name"
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
tarfile.data_filter(member, "/isolattice-workspace")
|
|
151
|
+
except tarfile.FilterError as error:
|
|
152
|
+
raise SandboxArtifactError(
|
|
153
|
+
f"workspace archive member {member.name!r} is unsafe: {error}"
|
|
154
|
+
) from error
|
|
155
|
+
total += member.size
|
|
156
|
+
if total > max_bytes:
|
|
157
|
+
raise SandboxArtifactError("workspace archive expands beyond its size limit")
|
|
158
|
+
except (tarfile.TarError, OSError) as error:
|
|
159
|
+
raise SandboxArtifactError("workspace archive is not a valid uncompressed tar") from error
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def validate_workspace_tar(archive: Path, *, max_members: int, max_bytes: int) -> None:
|
|
163
|
+
"""Reject absolute, traversing, special, link-escaping or oversized members."""
|
|
164
|
+
await asyncio.to_thread(_validate_workspace_tar, archive, max_members, max_bytes)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _unsafe_name(name: str) -> bool:
|
|
168
|
+
path = PurePosixPath(name)
|
|
169
|
+
return (
|
|
170
|
+
not name
|
|
171
|
+
or path.is_absolute()
|
|
172
|
+
or ".." in path.parts
|
|
173
|
+
or "\\" in name
|
|
174
|
+
or _DRIVE.match(name) is not None
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _validate_rootfs_tar(archive: Path, max_members: int, max_bytes: int) -> None:
|
|
179
|
+
members = 0
|
|
180
|
+
total = 0
|
|
181
|
+
seen: set[str] = set()
|
|
182
|
+
try:
|
|
183
|
+
with tarfile.open(archive, mode="r:") as bundle:
|
|
184
|
+
for member in bundle:
|
|
185
|
+
members += 1
|
|
186
|
+
if members > max_members:
|
|
187
|
+
raise SandboxArtifactError("container snapshot has too many members")
|
|
188
|
+
if not (member.isreg() or member.isdir() or member.issym() or member.islnk()):
|
|
189
|
+
raise SandboxArtifactError(
|
|
190
|
+
f"container snapshot member {member.name!r} is a device or special file"
|
|
191
|
+
)
|
|
192
|
+
if _unsafe_name(member.name):
|
|
193
|
+
raise SandboxArtifactError(
|
|
194
|
+
f"container snapshot member {member.name!r} has an absolute or "
|
|
195
|
+
"traversing name"
|
|
196
|
+
)
|
|
197
|
+
# Symbolic links resolve inside the container root; hard links must
|
|
198
|
+
# name an earlier member so the engine never links outside the archive.
|
|
199
|
+
if member.islnk() and (
|
|
200
|
+
_unsafe_name(member.linkname)
|
|
201
|
+
or PurePosixPath(member.linkname).as_posix() not in seen
|
|
202
|
+
):
|
|
203
|
+
raise SandboxArtifactError(
|
|
204
|
+
f"container snapshot hard link {member.name!r} names no earlier member"
|
|
205
|
+
)
|
|
206
|
+
seen.add(PurePosixPath(member.name).as_posix())
|
|
207
|
+
total += member.size
|
|
208
|
+
if total > max_bytes:
|
|
209
|
+
raise SandboxArtifactError("container snapshot expands beyond its size limit")
|
|
210
|
+
except (tarfile.TarError, OSError) as error:
|
|
211
|
+
raise SandboxArtifactError("container snapshot is not a valid uncompressed tar") from error
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def validate_rootfs_tar(archive: Path, *, max_members: int, max_bytes: int) -> None:
|
|
215
|
+
"""Reject special files, unsafe names, dangling hard links and oversized archives.
|
|
216
|
+
|
|
217
|
+
The archive is handed to the container engine and never extracted on the host.
|
|
218
|
+
"""
|
|
219
|
+
await asyncio.to_thread(_validate_rootfs_tar, archive, max_members, max_bytes)
|
isolattice/_async.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Helpers for owned tasks whose cleanup must survive repeated cancellation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
from .errors import SandboxBackendError
|
|
11
|
+
|
|
12
|
+
_EXIT_POLL_INTERVAL = 0.1
|
|
13
|
+
_ATTACH_EXIT_GRACE = 2.0
|
|
14
|
+
_ATTACH_REAP_TIMEOUT = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def root_exit(process: asyncio.subprocess.Process) -> int:
|
|
18
|
+
"""Return the exit code of ``process`` once it exits, even if its pipes stay open."""
|
|
19
|
+
# Python 3.12 resolves wait() only after every pipe closes, so descendants holding
|
|
20
|
+
# inherited pipes would hide the root's exit; returncode is set at exit everywhere.
|
|
21
|
+
waiter = asyncio.ensure_future(process.wait())
|
|
22
|
+
try:
|
|
23
|
+
while True:
|
|
24
|
+
done, _ = await asyncio.wait((waiter,), timeout=_EXIT_POLL_INTERVAL)
|
|
25
|
+
if done:
|
|
26
|
+
return waiter.result()
|
|
27
|
+
if (code := process.returncode) is not None:
|
|
28
|
+
return code
|
|
29
|
+
finally:
|
|
30
|
+
if not waiter.done():
|
|
31
|
+
waiter.cancel()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def reap_attach(process: asyncio.subprocess.Process) -> None:
|
|
35
|
+
"""Wait briefly for an attach client to exit, then kill and reap it."""
|
|
36
|
+
if process.returncode is not None:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
async with asyncio.timeout(_ATTACH_EXIT_GRACE):
|
|
40
|
+
await process.wait()
|
|
41
|
+
return
|
|
42
|
+
except TimeoutError:
|
|
43
|
+
with contextlib.suppress(ProcessLookupError):
|
|
44
|
+
process.kill()
|
|
45
|
+
try:
|
|
46
|
+
async with asyncio.timeout(_ATTACH_REAP_TIMEOUT):
|
|
47
|
+
await process.wait()
|
|
48
|
+
except TimeoutError as error:
|
|
49
|
+
raise SandboxBackendError("the owned backend attach client could not be reaped") from error
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class Settled[T]:
|
|
54
|
+
value: T | None
|
|
55
|
+
error: BaseException | None
|
|
56
|
+
interrupted: asyncio.CancelledError | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def discard_remaining(reader: asyncio.StreamReader) -> bool:
|
|
60
|
+
discarded = False
|
|
61
|
+
while await reader.read(65536):
|
|
62
|
+
discarded = True
|
|
63
|
+
await asyncio.sleep(0)
|
|
64
|
+
return discarded
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def settle[T](task: asyncio.Task[T]) -> Settled[T]:
|
|
68
|
+
interrupted: asyncio.CancelledError | None = None
|
|
69
|
+
while not task.done():
|
|
70
|
+
try:
|
|
71
|
+
await asyncio.shield(task)
|
|
72
|
+
except asyncio.CancelledError as error:
|
|
73
|
+
if not task.done() and interrupted is None:
|
|
74
|
+
interrupted = error
|
|
75
|
+
except BaseException:
|
|
76
|
+
break
|
|
77
|
+
try:
|
|
78
|
+
return Settled(task.result(), None, interrupted)
|
|
79
|
+
except BaseException as error:
|
|
80
|
+
return Settled(None, error, interrupted)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def wait_until(task: asyncio.Task[object], deadline: float) -> asyncio.CancelledError | None:
|
|
84
|
+
"""Wait for ``task`` until a loop-time deadline without cancelling it.
|
|
85
|
+
|
|
86
|
+
Caller cancellation is absorbed and returned so bounded cleanup still completes.
|
|
87
|
+
"""
|
|
88
|
+
interrupted: asyncio.CancelledError | None = None
|
|
89
|
+
loop = asyncio.get_running_loop()
|
|
90
|
+
while not task.done():
|
|
91
|
+
remaining = deadline - loop.time()
|
|
92
|
+
if remaining <= 0:
|
|
93
|
+
break
|
|
94
|
+
try:
|
|
95
|
+
await asyncio.wait((task,), timeout=remaining)
|
|
96
|
+
except asyncio.CancelledError as error:
|
|
97
|
+
if interrupted is None:
|
|
98
|
+
interrupted = error
|
|
99
|
+
return interrupted
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def raise_combined(message: str, *errors: BaseException | None) -> None:
|
|
103
|
+
present = [error for error in errors if error is not None]
|
|
104
|
+
if len(present) == 1:
|
|
105
|
+
raise present[0]
|
|
106
|
+
if present:
|
|
107
|
+
raise BaseExceptionGroup(message, present)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def raise_with_cleanup(
|
|
111
|
+
message: str, failure: BaseException, *cleanup: BaseException | None
|
|
112
|
+
) -> NoReturn:
|
|
113
|
+
"""Raise ``failure``, grouped with any cleanup errors that also occurred."""
|
|
114
|
+
present = [error for error in cleanup if error is not None]
|
|
115
|
+
if present:
|
|
116
|
+
raise BaseExceptionGroup(message, [failure, *present])
|
|
117
|
+
raise failure
|