f-layer 2.0.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.
- f_layer-2.0.0.dist-info/METADATA +232 -0
- f_layer-2.0.0.dist-info/RECORD +45 -0
- f_layer-2.0.0.dist-info/WHEEL +4 -0
- f_layer-2.0.0.dist-info/entry_points.txt +2 -0
- f_layer-2.0.0.dist-info/licenses/LICENSE +201 -0
- flayer/__init__.py +5 -0
- flayer/__main__.py +247 -0
- flayer/_vpn_guest.py +130 -0
- flayer/core/__init__.py +1 -0
- flayer/core/config.py +198 -0
- flayer/core/contracts.py +114 -0
- flayer/core/lifecycle.py +767 -0
- flayer/core/state.py +351 -0
- flayer/diagnostics/__init__.py +20 -0
- flayer/diagnostics/benchmark.py +272 -0
- flayer/diagnostics/contracts.py +167 -0
- flayer/diagnostics/health.py +154 -0
- flayer/diagnostics/reporting.py +24 -0
- flayer/extensions/__init__.py +25 -0
- flayer/extensions/builtins.py +165 -0
- flayer/extensions/contracts.py +196 -0
- flayer/extensions/discovery.py +152 -0
- flayer/extensions/registry.py +347 -0
- flayer/guest.py +270 -0
- flayer/integrations/__init__.py +23 -0
- flayer/integrations/adapters.py +92 -0
- flayer/integrations/contracts.py +241 -0
- flayer/integrations/wire.py +273 -0
- flayer/profiles/__init__.py +33 -0
- flayer/profiles/_amnezia_export.py +176 -0
- flayer/profiles/_vless_guest.py +432 -0
- flayer/profiles/amneziawg.py +555 -0
- flayer/profiles/artifacts.py +609 -0
- flayer/profiles/gateway.py +729 -0
- flayer/profiles/static_site.py +596 -0
- flayer/profiles/vless.py +422 -0
- flayer/profiles/vpn.py +524 -0
- flayer/providers/__init__.py +1 -0
- flayer/providers/contracts.py +207 -0
- flayer/providers/lifecycle.py +177 -0
- flayer/providers/yandex.py +387 -0
- flayer/providers/yandex_lifecycle.py +781 -0
- flayer/py.typed +0 -0
- flayer/vpn_export.py +72 -0
- flayer/vpn_project.py +828 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Bounded artifact bundles with exclusive ownership and verified local cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
from flayer.core.contracts import (
|
|
16
|
+
ContractError,
|
|
17
|
+
ParseIdentity,
|
|
18
|
+
RequireTable,
|
|
19
|
+
StackIdentity,
|
|
20
|
+
ValidateFields,
|
|
21
|
+
ValidateName,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
MAX_ARTIFACT_BYTES = 1048576
|
|
25
|
+
MAX_BUNDLE_BYTES = 2097152
|
|
26
|
+
MAX_MANIFEST_BYTES = 16384
|
|
27
|
+
MAX_BUNDLE_FILES = 8
|
|
28
|
+
_FILE_PATTERN = re.compile(r"[a-z][a-z0-9-]{0,62}\.[a-z0-9]{1,12}\Z")
|
|
29
|
+
_MANIFEST_NAME = "manifest.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ArtifactError(ContractError):
|
|
33
|
+
"""An artifact is invalid, foreign, changed, busy, or unavailable for safe storage."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class _FileReceipt:
|
|
38
|
+
"""Pinned file identity and exact bytes authorized for a subsequent destructive step."""
|
|
39
|
+
|
|
40
|
+
device: int
|
|
41
|
+
inode: int
|
|
42
|
+
size: int
|
|
43
|
+
modified_ns: int
|
|
44
|
+
changed_ns: int
|
|
45
|
+
sha256: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class _OwnedFile:
|
|
50
|
+
"""One verified immutable file snapshot excluding its payload from representation."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
content: bytes = field(repr=False)
|
|
54
|
+
receipt: _FileReceipt
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class _BundleSnapshot:
|
|
59
|
+
"""A complete authorized bundle with the manifest and each payload pinned separately."""
|
|
60
|
+
|
|
61
|
+
files: tuple[_OwnedFile, ...]
|
|
62
|
+
manifest: _FileReceipt
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class Artifact:
|
|
67
|
+
"""Memory-only generated bytes; sensitive payloads are never included in repr."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
content: bytes = field(repr=False)
|
|
71
|
+
sensitive: bool = True
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
"""Require a portable single filename and a bounded immutable payload."""
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
not isinstance(self.name, str)
|
|
78
|
+
or _FILE_PATTERN.fullmatch(self.name) is None
|
|
79
|
+
or self.name == _MANIFEST_NAME
|
|
80
|
+
):
|
|
81
|
+
raise ArtifactError("Artifact filename must be portable and must not be manifest.json")
|
|
82
|
+
|
|
83
|
+
if not isinstance(self.content, bytes) or not 1 <= len(self.content) <= MAX_ARTIFACT_BYTES:
|
|
84
|
+
raise ArtifactError("Artifact content must contain 1..1048576 immutable bytes")
|
|
85
|
+
|
|
86
|
+
if type(self.sensitive) is not bool:
|
|
87
|
+
raise ArtifactError("Artifact sensitivity must be an explicit boolean")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class ArtifactBundle:
|
|
92
|
+
"""A named set owned by one complete stack identity, independent of provider state."""
|
|
93
|
+
|
|
94
|
+
identity: StackIdentity
|
|
95
|
+
kind: str
|
|
96
|
+
name: str
|
|
97
|
+
files: tuple[Artifact, ...] = field(repr=False)
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
"""Reject ambiguous bundle names, duplicate files, and oversized compositions."""
|
|
101
|
+
|
|
102
|
+
if not isinstance(self.identity, StackIdentity):
|
|
103
|
+
raise ArtifactError("Artifact identity must be a StackIdentity")
|
|
104
|
+
|
|
105
|
+
if not isinstance(self.kind, str) or self.kind not in {"server", "device"}:
|
|
106
|
+
raise ArtifactError("Artifact kind must be server or device")
|
|
107
|
+
|
|
108
|
+
ValidateName(self.name, "artifact name")
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
not isinstance(self.files, tuple)
|
|
112
|
+
or not 1 <= len(self.files) <= MAX_BUNDLE_FILES
|
|
113
|
+
or any(not isinstance(item, Artifact) for item in self.files)
|
|
114
|
+
):
|
|
115
|
+
raise ArtifactError("A bundle must contain 1..8 immutable Artifact values")
|
|
116
|
+
|
|
117
|
+
if len({item.name for item in self.files}) != len(self.files):
|
|
118
|
+
raise ArtifactError("Artifact filenames must be unique")
|
|
119
|
+
|
|
120
|
+
if sum(len(item.content) for item in self.files) > MAX_BUNDLE_BYTES:
|
|
121
|
+
raise ArtifactError("A bundle must not exceed 2097152 bytes")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def BuildDeviceBundle(
|
|
125
|
+
identity: StackIdentity, device_id: str, transport: str, files: tuple[Artifact, ...],
|
|
126
|
+
) -> ArtifactBundle:
|
|
127
|
+
"""Wrap externally issued transport files without generating or validating credentials."""
|
|
128
|
+
|
|
129
|
+
ValidateName(transport, "transport")
|
|
130
|
+
ValidateName(device_id, "device_id")
|
|
131
|
+
|
|
132
|
+
if not isinstance(files, tuple) or not 1 <= len(files) < MAX_BUNDLE_FILES or any(
|
|
133
|
+
not isinstance(item, Artifact) for item in files
|
|
134
|
+
):
|
|
135
|
+
raise ArtifactError("Device files must contain 1..7 immutable Artifact values")
|
|
136
|
+
|
|
137
|
+
if any(not item.sensitive for item in files):
|
|
138
|
+
raise ArtifactError("Externally issued device profiles must be marked sensitive")
|
|
139
|
+
|
|
140
|
+
descriptor = Artifact("device-info.json", (json.dumps({
|
|
141
|
+
"schema_version": 1, "transport": transport, "device_id": device_id,
|
|
142
|
+
"status": "externally-issued-unverified",
|
|
143
|
+
}, sort_keys=True) + "\n").encode("utf-8"), sensitive=False)
|
|
144
|
+
|
|
145
|
+
return ArtifactBundle(identity, "device", device_id, files + (descriptor,))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ValidatePath(path: Path) -> None:
|
|
149
|
+
"""Reject parent traversal and preexisting symlink components before opening handles."""
|
|
150
|
+
|
|
151
|
+
if not path.name or ".." in path.parts:
|
|
152
|
+
raise ArtifactError("Artifact storage must name an explicit path without traversal")
|
|
153
|
+
|
|
154
|
+
if any(item.is_symlink() for item in (path, *path.parents)):
|
|
155
|
+
raise ArtifactError("Artifact paths must not contain symlinks")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _RequireOwned(info: os.stat_result, *, directory: bool) -> None:
|
|
159
|
+
"""Require private POSIX ownership and reject special files on every platform."""
|
|
160
|
+
|
|
161
|
+
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
|
|
162
|
+
|
|
163
|
+
if not expected_type(info.st_mode):
|
|
164
|
+
raise ArtifactError("Artifact storage requires regular files and directories")
|
|
165
|
+
|
|
166
|
+
if not directory and info.st_nlink != 1:
|
|
167
|
+
raise ArtifactError("Artifact storage must not contain hardlinked files")
|
|
168
|
+
|
|
169
|
+
if os.name == "posix":
|
|
170
|
+
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != (0o700 if directory else 0o600):
|
|
171
|
+
raise ArtifactError("Artifact storage must be caller-owned and private")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@contextmanager
|
|
175
|
+
def _Directory(path: Path, *, create: bool = False) -> Iterator[int]:
|
|
176
|
+
"""Pin one private directory handle without recursively creating parent paths."""
|
|
177
|
+
|
|
178
|
+
if os.name != "posix":
|
|
179
|
+
raise ArtifactError("Private artifact storage currently requires a POSIX host")
|
|
180
|
+
|
|
181
|
+
_ValidatePath(path)
|
|
182
|
+
descriptor: int | None = None
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
if create:
|
|
186
|
+
path.mkdir(mode=0o700, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0))
|
|
189
|
+
_RequireOwned(os.fstat(descriptor), directory=True)
|
|
190
|
+
|
|
191
|
+
yield descriptor
|
|
192
|
+
|
|
193
|
+
finally:
|
|
194
|
+
if descriptor is not None:
|
|
195
|
+
os.close(descriptor)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _StatKey(info: os.stat_result) -> tuple[int, int, int, int, int]:
|
|
199
|
+
"""Identify replacements and in-place modifications without treating access time as a write."""
|
|
200
|
+
|
|
201
|
+
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _Receipt(info: os.stat_result, content: bytes) -> _FileReceipt:
|
|
205
|
+
"""Bind a pinned inode to exact authorized bytes and modification metadata."""
|
|
206
|
+
|
|
207
|
+
return _FileReceipt(*_StatKey(info), hashlib.sha256(content).hexdigest())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _ReadSnapshot(directory: int, name: str, limit: int) -> tuple[bytes, _FileReceipt]:
|
|
211
|
+
"""Read bounded private bytes and reject a file that changes during the read."""
|
|
212
|
+
|
|
213
|
+
descriptor = os.open(
|
|
214
|
+
name, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
|
|
215
|
+
dir_fd=directory,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
before = os.fstat(descriptor)
|
|
220
|
+
_RequireOwned(before, directory=False)
|
|
221
|
+
result = bytearray()
|
|
222
|
+
|
|
223
|
+
while len(result) <= limit:
|
|
224
|
+
chunk = os.read(descriptor, min(65536, limit + 1 - len(result)))
|
|
225
|
+
|
|
226
|
+
if not chunk:
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
result.extend(chunk)
|
|
230
|
+
|
|
231
|
+
if len(result) > limit:
|
|
232
|
+
raise ArtifactError("Artifact file exceeds its bounded size")
|
|
233
|
+
|
|
234
|
+
after = os.fstat(descriptor)
|
|
235
|
+
_RequireOwned(after, directory=False)
|
|
236
|
+
|
|
237
|
+
if _StatKey(before) != _StatKey(after):
|
|
238
|
+
raise ArtifactError("Artifact changed while its content was being verified")
|
|
239
|
+
|
|
240
|
+
content = bytes(result)
|
|
241
|
+
|
|
242
|
+
return content, _Receipt(after, content)
|
|
243
|
+
|
|
244
|
+
finally:
|
|
245
|
+
os.close(descriptor)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _ReadFile(directory: int, name: str, limit: int) -> bytes:
|
|
249
|
+
"""Return one verified private file snapshot without discarding its bounded-read checks."""
|
|
250
|
+
|
|
251
|
+
return _ReadSnapshot(directory, name, limit)[0]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _AssertFile(directory: int, name: str, expected: _FileReceipt) -> None:
|
|
255
|
+
"""Require the original inode, private ownership, metadata, and exact content before cleanup."""
|
|
256
|
+
|
|
257
|
+
_, actual = _ReadSnapshot(directory, name, MAX_ARTIFACT_BYTES)
|
|
258
|
+
|
|
259
|
+
if actual != expected:
|
|
260
|
+
raise ArtifactError("Artifact was replaced or changed; cleanup is refused")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _UnlinkFile(directory: int, name: str, expected: _FileReceipt) -> None:
|
|
264
|
+
"""Recheck a pinned receipt immediately before unlink; preserve detected replacements."""
|
|
265
|
+
|
|
266
|
+
_AssertFile(directory, name, expected)
|
|
267
|
+
info = os.stat(name, dir_fd=directory, follow_symlinks=False)
|
|
268
|
+
_RequireOwned(info, directory=False)
|
|
269
|
+
|
|
270
|
+
if _StatKey(info) != (
|
|
271
|
+
expected.device, expected.inode, expected.size, expected.modified_ns, expected.changed_ns,
|
|
272
|
+
):
|
|
273
|
+
raise ArtifactError("Artifact changed before cleanup; removal is refused")
|
|
274
|
+
|
|
275
|
+
os.unlink(name, dir_fd=directory)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _AssertDirectory(parent: int, name: str, descriptor: int) -> None:
|
|
279
|
+
"""Preserve a replacement directory instead of confusing its name with the pinned bundle."""
|
|
280
|
+
|
|
281
|
+
pinned = os.fstat(descriptor)
|
|
282
|
+
named = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
283
|
+
_RequireOwned(pinned, directory=True)
|
|
284
|
+
_RequireOwned(named, directory=True)
|
|
285
|
+
|
|
286
|
+
if (pinned.st_dev, pinned.st_ino) != (named.st_dev, named.st_ino):
|
|
287
|
+
raise ArtifactError("Artifact directory was replaced; cleanup is refused")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def ReadArtifactFile(path: str | Path) -> bytes:
|
|
291
|
+
"""Read only a private artifact; never expose filesystem paths or bytes in failures."""
|
|
292
|
+
|
|
293
|
+
artifact_path = Path(path)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
_ValidatePath(artifact_path)
|
|
297
|
+
|
|
298
|
+
with _Directory(artifact_path.parent) as directory:
|
|
299
|
+
return _ReadFile(directory, artifact_path.name, MAX_ARTIFACT_BYTES)
|
|
300
|
+
|
|
301
|
+
except (OSError, ValueError, RecursionError):
|
|
302
|
+
raise ArtifactError("Unable to read a valid private artifact") from None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _Manifest(bundle: ArtifactBundle) -> bytes:
|
|
306
|
+
"""Record exact ownership and content digests while excluding payloads and credentials."""
|
|
307
|
+
|
|
308
|
+
data = {
|
|
309
|
+
"schema_version": 1, "identity": asdict(bundle.identity), "kind": bundle.kind,
|
|
310
|
+
"name": bundle.name, "files": [{
|
|
311
|
+
"name": item.name, "size": len(item.content), "sensitive": item.sensitive,
|
|
312
|
+
"sha256": hashlib.sha256(item.content).hexdigest(),
|
|
313
|
+
} for item in bundle.files],
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _WriteFile(directory: int, name: str, content: bytes) -> _FileReceipt:
|
|
320
|
+
"""Create one exclusive private file and fsync all bytes before returning."""
|
|
321
|
+
|
|
322
|
+
descriptor = os.open(
|
|
323
|
+
name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
|
324
|
+
0o600, dir_fd=directory,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
written_count = 0
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
remaining = memoryview(content)
|
|
331
|
+
|
|
332
|
+
while remaining:
|
|
333
|
+
written = os.write(descriptor, remaining)
|
|
334
|
+
|
|
335
|
+
if written <= 0:
|
|
336
|
+
raise OSError("Incomplete artifact write")
|
|
337
|
+
|
|
338
|
+
remaining = remaining[written:]
|
|
339
|
+
written_count += written
|
|
340
|
+
|
|
341
|
+
os.fsync(descriptor)
|
|
342
|
+
|
|
343
|
+
except BaseException:
|
|
344
|
+
try:
|
|
345
|
+
receipt = _Receipt(os.fstat(descriptor), content[:written_count])
|
|
346
|
+
|
|
347
|
+
finally:
|
|
348
|
+
os.close(descriptor)
|
|
349
|
+
|
|
350
|
+
_UnlinkFile(directory, name, receipt)
|
|
351
|
+
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
else:
|
|
355
|
+
try:
|
|
356
|
+
receipt = _Receipt(os.fstat(descriptor), content)
|
|
357
|
+
|
|
358
|
+
finally:
|
|
359
|
+
os.close(descriptor)
|
|
360
|
+
|
|
361
|
+
return receipt
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@contextmanager
|
|
365
|
+
def _BundleLock(directory: int, name: str) -> Iterator[None]:
|
|
366
|
+
"""Serialize cooperating operations without deleting an existing crash or foreign lock."""
|
|
367
|
+
|
|
368
|
+
lock_name = f".{name}.lock"
|
|
369
|
+
descriptor = os.open(lock_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=directory)
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
receipt = _Receipt(os.fstat(descriptor), b"")
|
|
373
|
+
|
|
374
|
+
finally:
|
|
375
|
+
os.close(descriptor)
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
yield
|
|
379
|
+
|
|
380
|
+
finally:
|
|
381
|
+
_UnlinkFile(directory, lock_name, receipt)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def WriteArtifactBundle(root: str | Path, bundle: ArtifactBundle) -> Path:
|
|
385
|
+
"""Publish a new manifest-last bundle exclusively and roll back only created files."""
|
|
386
|
+
|
|
387
|
+
if not isinstance(bundle, ArtifactBundle):
|
|
388
|
+
raise ArtifactError("bundle must be an ArtifactBundle")
|
|
389
|
+
|
|
390
|
+
root_path = Path(root)
|
|
391
|
+
bundle_name = f"{bundle.kind}-{bundle.name}"
|
|
392
|
+
created: list[tuple[str, _FileReceipt]] = []
|
|
393
|
+
directory: int | None = None
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
with _Directory(root_path, create=True) as parent:
|
|
397
|
+
with _BundleLock(parent, bundle_name):
|
|
398
|
+
try:
|
|
399
|
+
os.mkdir(bundle_name, 0o700, dir_fd=parent)
|
|
400
|
+
directory = os.open(bundle_name, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
|
|
401
|
+
|
|
402
|
+
for artifact in bundle.files:
|
|
403
|
+
_AssertDirectory(parent, bundle_name, directory)
|
|
404
|
+
receipt = _WriteFile(directory, artifact.name, artifact.content)
|
|
405
|
+
created.append((artifact.name, receipt))
|
|
406
|
+
|
|
407
|
+
for name, receipt in created:
|
|
408
|
+
_AssertFile(directory, name, receipt)
|
|
409
|
+
|
|
410
|
+
_AssertDirectory(parent, bundle_name, directory)
|
|
411
|
+
receipt = _WriteFile(directory, _MANIFEST_NAME, _Manifest(bundle))
|
|
412
|
+
created.append((_MANIFEST_NAME, receipt))
|
|
413
|
+
os.fsync(directory)
|
|
414
|
+
os.fsync(parent)
|
|
415
|
+
|
|
416
|
+
except BaseException:
|
|
417
|
+
if directory is not None:
|
|
418
|
+
_AssertDirectory(parent, bundle_name, directory)
|
|
419
|
+
|
|
420
|
+
for name, receipt in reversed(created):
|
|
421
|
+
_UnlinkFile(directory, name, receipt)
|
|
422
|
+
|
|
423
|
+
_AssertDirectory(parent, bundle_name, directory)
|
|
424
|
+
os.rmdir(bundle_name, dir_fd=parent)
|
|
425
|
+
|
|
426
|
+
raise
|
|
427
|
+
|
|
428
|
+
finally:
|
|
429
|
+
if directory is not None:
|
|
430
|
+
os.close(directory)
|
|
431
|
+
|
|
432
|
+
except (OSError, ValueError, RecursionError):
|
|
433
|
+
raise ArtifactError("Unable to publish artifact bundle; existing bundles are never replaced") from None
|
|
434
|
+
|
|
435
|
+
return root_path / bundle_name
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _UniqueObject(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
439
|
+
"""Reject duplicate JSON fields at the untrusted ownership-manifest boundary."""
|
|
440
|
+
|
|
441
|
+
data: dict[str, object] = {}
|
|
442
|
+
|
|
443
|
+
for key, value in pairs:
|
|
444
|
+
if key in data:
|
|
445
|
+
raise ArtifactError("Artifact manifest contains duplicate fields")
|
|
446
|
+
|
|
447
|
+
data[key] = value
|
|
448
|
+
|
|
449
|
+
return data
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _ValidateManifest(
|
|
453
|
+
directory: int, identity: StackIdentity, kind: str, name: str,
|
|
454
|
+
) -> _BundleSnapshot:
|
|
455
|
+
"""Check the complete manifest and every file before authorizing any removal."""
|
|
456
|
+
|
|
457
|
+
raw, manifest_receipt = _ReadSnapshot(directory, _MANIFEST_NAME, MAX_MANIFEST_BYTES)
|
|
458
|
+
data = RequireTable(json.loads(raw, object_pairs_hook=_UniqueObject), "artifact manifest")
|
|
459
|
+
ValidateFields(data, frozenset({"schema_version", "identity", "kind", "name", "files"}), frozenset(), "artifact manifest")
|
|
460
|
+
|
|
461
|
+
if type(data["schema_version"]) is not int or data["schema_version"] != 1:
|
|
462
|
+
raise ArtifactError("Artifact manifest schema is unsupported")
|
|
463
|
+
|
|
464
|
+
if ParseIdentity(data["identity"]) != identity or data["kind"] != kind or data["name"] != name:
|
|
465
|
+
raise ArtifactError("Artifact manifest does not match the expected ownership")
|
|
466
|
+
|
|
467
|
+
files = data["files"]
|
|
468
|
+
|
|
469
|
+
if not isinstance(files, list) or not 1 <= len(files) <= MAX_BUNDLE_FILES:
|
|
470
|
+
raise ArtifactError("Artifact manifest must contain 1..8 file entries")
|
|
471
|
+
|
|
472
|
+
names: list[str] = []
|
|
473
|
+
snapshots: list[_OwnedFile] = []
|
|
474
|
+
total_size = 0
|
|
475
|
+
|
|
476
|
+
for raw_file in files:
|
|
477
|
+
item = RequireTable(raw_file, "artifact entry")
|
|
478
|
+
ValidateFields(item, frozenset({"name", "size", "sha256", "sensitive"}), frozenset(), "artifact entry")
|
|
479
|
+
filename, size, digest = item["name"], item["size"], item["sha256"]
|
|
480
|
+
|
|
481
|
+
if not isinstance(filename, str) or _FILE_PATTERN.fullmatch(filename) is None or filename == _MANIFEST_NAME:
|
|
482
|
+
raise ArtifactError("Artifact manifest contains an unsafe filename")
|
|
483
|
+
|
|
484
|
+
if type(size) is not int or not 1 <= size <= MAX_ARTIFACT_BYTES or type(item["sensitive"]) is not bool:
|
|
485
|
+
raise ArtifactError("Artifact manifest contains an invalid size or sensitivity")
|
|
486
|
+
|
|
487
|
+
if not isinstance(digest, str) or re.fullmatch(r"[a-f0-9]{64}", digest) is None:
|
|
488
|
+
raise ArtifactError("Artifact manifest contains an invalid digest")
|
|
489
|
+
|
|
490
|
+
content, receipt = _ReadSnapshot(directory, filename, MAX_ARTIFACT_BYTES)
|
|
491
|
+
|
|
492
|
+
if len(content) != size or hashlib.sha256(content).hexdigest() != digest:
|
|
493
|
+
raise ArtifactError("Artifact content differs from its owned manifest")
|
|
494
|
+
|
|
495
|
+
names.append(filename)
|
|
496
|
+
snapshots.append(_OwnedFile(filename, content, receipt))
|
|
497
|
+
total_size += size
|
|
498
|
+
|
|
499
|
+
if len(set(names)) != len(names) or total_size > MAX_BUNDLE_BYTES:
|
|
500
|
+
raise ArtifactError("Artifact manifest has duplicates or exceeds its total size limit")
|
|
501
|
+
|
|
502
|
+
if set(os.listdir(directory)) != {*names, _MANIFEST_NAME}:
|
|
503
|
+
raise ArtifactError("Artifact bundle contains unowned files; cleanup is refused")
|
|
504
|
+
|
|
505
|
+
return _BundleSnapshot(tuple(snapshots), manifest_receipt)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def ReadOwnedArtifactFile(
|
|
509
|
+
path: str | Path, identity: StackIdentity, *, kind: str, name: str,
|
|
510
|
+
) -> bytes:
|
|
511
|
+
"""Authorize one exact file through the complete unchanged bundle ownership manifest."""
|
|
512
|
+
|
|
513
|
+
if not isinstance(identity, StackIdentity) or not isinstance(kind, str) or kind not in {"server", "device"}:
|
|
514
|
+
raise ArtifactError("Artifact read requires explicit identity and bundle kind")
|
|
515
|
+
|
|
516
|
+
ValidateName(name, "artifact name")
|
|
517
|
+
artifact_path = Path(path)
|
|
518
|
+
|
|
519
|
+
if artifact_path.parent.name != f"{kind}-{name}":
|
|
520
|
+
raise ArtifactError("Artifact path does not match the expected bundle name")
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
_ValidatePath(artifact_path)
|
|
524
|
+
|
|
525
|
+
with _Directory(artifact_path.parent) as directory:
|
|
526
|
+
snapshot = _ValidateManifest(directory, identity, kind, name)
|
|
527
|
+
file = next((item for item in snapshot.files if item.name == artifact_path.name), None)
|
|
528
|
+
|
|
529
|
+
if file is None:
|
|
530
|
+
raise ArtifactError("Artifact filename is not owned by its bundle manifest")
|
|
531
|
+
|
|
532
|
+
return file.content
|
|
533
|
+
|
|
534
|
+
except (OSError, ValueError, RecursionError):
|
|
535
|
+
raise ArtifactError("Unable to read an unchanged owned artifact bundle") from None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def RemoveArtifactBundle(
|
|
539
|
+
root: str | Path, identity: StackIdentity, *, kind: str, name: str,
|
|
540
|
+
expected_bundle: ArtifactBundle | None = None,
|
|
541
|
+
) -> bool:
|
|
542
|
+
"""Remove unchanged owned files, optionally binding cleanup to exact caller-expected content."""
|
|
543
|
+
|
|
544
|
+
if not isinstance(identity, StackIdentity) or not isinstance(kind, str) or kind not in {"server", "device"}:
|
|
545
|
+
raise ArtifactError("Artifact removal requires explicit identity and bundle kind")
|
|
546
|
+
|
|
547
|
+
ValidateName(name, "artifact name")
|
|
548
|
+
|
|
549
|
+
if expected_bundle is not None and (
|
|
550
|
+
not isinstance(expected_bundle, ArtifactBundle)
|
|
551
|
+
or expected_bundle.identity != identity or expected_bundle.kind != kind
|
|
552
|
+
or expected_bundle.name != name
|
|
553
|
+
):
|
|
554
|
+
raise ArtifactError("Expected cleanup bundle must match the complete requested ownership")
|
|
555
|
+
|
|
556
|
+
root_path = Path(root)
|
|
557
|
+
bundle_name = f"{kind}-{name}"
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
_ValidatePath(root_path)
|
|
561
|
+
|
|
562
|
+
if not root_path.exists():
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
with _Directory(root_path) as parent:
|
|
566
|
+
with _BundleLock(parent, bundle_name):
|
|
567
|
+
try:
|
|
568
|
+
descriptor = os.open(bundle_name, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
|
|
569
|
+
|
|
570
|
+
except FileNotFoundError:
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
_RequireOwned(os.fstat(descriptor), directory=True)
|
|
575
|
+
snapshot = _ValidateManifest(descriptor, identity, kind, name)
|
|
576
|
+
|
|
577
|
+
if expected_bundle is not None and (
|
|
578
|
+
{item.name: item.content for item in snapshot.files}
|
|
579
|
+
!= {item.name: item.content for item in expected_bundle.files}
|
|
580
|
+
or snapshot.manifest.sha256
|
|
581
|
+
!= hashlib.sha256(_Manifest(expected_bundle)).hexdigest()
|
|
582
|
+
):
|
|
583
|
+
raise ArtifactError("Artifact bundle differs from the expected cleanup content")
|
|
584
|
+
|
|
585
|
+
_AssertDirectory(parent, bundle_name, descriptor)
|
|
586
|
+
|
|
587
|
+
for file in snapshot.files:
|
|
588
|
+
_AssertFile(descriptor, file.name, file.receipt)
|
|
589
|
+
|
|
590
|
+
_AssertFile(descriptor, _MANIFEST_NAME, snapshot.manifest)
|
|
591
|
+
|
|
592
|
+
for file in snapshot.files:
|
|
593
|
+
_AssertDirectory(parent, bundle_name, descriptor)
|
|
594
|
+
_UnlinkFile(descriptor, file.name, file.receipt)
|
|
595
|
+
|
|
596
|
+
_UnlinkFile(descriptor, _MANIFEST_NAME, snapshot.manifest)
|
|
597
|
+
os.fsync(descriptor)
|
|
598
|
+
_AssertDirectory(parent, bundle_name, descriptor)
|
|
599
|
+
os.rmdir(bundle_name, dir_fd=parent)
|
|
600
|
+
|
|
601
|
+
finally:
|
|
602
|
+
os.close(descriptor)
|
|
603
|
+
|
|
604
|
+
os.fsync(parent)
|
|
605
|
+
|
|
606
|
+
except (OSError, ValueError, RecursionError):
|
|
607
|
+
raise ArtifactError("Unable to remove a valid owned artifact bundle; partial cleanup may require recovery") from None
|
|
608
|
+
|
|
609
|
+
return True
|