apsimo-hostworker 0.2.0__tar.gz
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.
- apsimo_hostworker-0.2.0/LICENSE +21 -0
- apsimo_hostworker-0.2.0/PKG-INFO +24 -0
- apsimo_hostworker-0.2.0/README.md +12 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/__init__.py +134 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/_private_io.py +267 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/admission.py +297 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/catalog.py +754 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/client.py +292 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/compat.py +53 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/__init__.py +52 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/__main__.py +29 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/harness.py +278 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/conformance/suite.py +1109 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/contract.py +402 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/gate.py +446 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/intent.py +221 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/sqlite_store.py +1815 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/store.py +446 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker/worker.py +643 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/PKG-INFO +24 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/SOURCES.txt +34 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/dependency_links.txt +1 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/requires.txt +3 -0
- apsimo_hostworker-0.2.0/apsimo_hostworker.egg-info/top_level.txt +2 -0
- apsimo_hostworker-0.2.0/colony_hostworker/__init__.py +4 -0
- apsimo_hostworker-0.2.0/pyproject.toml +25 -0
- apsimo_hostworker-0.2.0/setup.cfg +4 -0
- apsimo_hostworker-0.2.0/tests/test_admission.py +267 -0
- apsimo_hostworker-0.2.0/tests/test_catalog.py +151 -0
- apsimo_hostworker-0.2.0/tests/test_client.py +319 -0
- apsimo_hostworker-0.2.0/tests/test_conformance.py +178 -0
- apsimo_hostworker-0.2.0/tests/test_contract.py +148 -0
- apsimo_hostworker-0.2.0/tests/test_gate.py +446 -0
- apsimo_hostworker-0.2.0/tests/test_intent.py +132 -0
- apsimo_hostworker-0.2.0/tests/test_sqlite_store.py +164 -0
- apsimo_hostworker-0.2.0/tests/test_worker.py +509 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aevonix
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apsimo-hostworker
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Stateless, stdlib-only core for governed Apsimo host workers
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Apsimo host worker
|
|
14
|
+
|
|
15
|
+
The stdlib-only action contract, capability catalog, execution ledger and
|
|
16
|
+
conformance checks used by Apsimo host adapters. This package has no sidecar or
|
|
17
|
+
web-server dependency.
|
|
18
|
+
|
|
19
|
+
Install `apsimo-hostworker==0.2.0` in a fresh environment and import
|
|
20
|
+
`apsimo_hostworker`. Legacy `colony_hostworker` imports resolve to the same
|
|
21
|
+
module objects. Existing action names and serialized contract identifiers are
|
|
22
|
+
preserved so pending actions and their digests remain valid.
|
|
23
|
+
|
|
24
|
+
Run `python -m apsimo_hostworker.conformance` to check a host implementation.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Apsimo host worker
|
|
2
|
+
|
|
3
|
+
The stdlib-only action contract, capability catalog, execution ledger and
|
|
4
|
+
conformance checks used by Apsimo host adapters. This package has no sidecar or
|
|
5
|
+
web-server dependency.
|
|
6
|
+
|
|
7
|
+
Install `apsimo-hostworker==0.2.0` in a fresh environment and import
|
|
8
|
+
`apsimo_hostworker`. Legacy `colony_hostworker` imports resolve to the same
|
|
9
|
+
module objects. Existing action names and serialized contract identifiers are
|
|
10
|
+
preserved so pending actions and their digests remain valid.
|
|
11
|
+
|
|
12
|
+
Run `python -m apsimo_hostworker.conformance` to check a host implementation.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Stdlib-only core for governed Colony host workers.
|
|
2
|
+
|
|
3
|
+
Phase A of the host-worker extraction: the wire contract, the governed tool
|
|
4
|
+
catalog, ``HermesToolActionIntentV1`` validation, and the approval-gate
|
|
5
|
+
invariant core. Phase B: the loopback execution client, the dispatch
|
|
6
|
+
admission, the :class:`~apsimo_hostworker.store.ActionStore` protocol with
|
|
7
|
+
its documented transactional invariants, the reference SQLite store, the
|
|
8
|
+
one-mutation worker, and the executable store conformance suite
|
|
9
|
+
(:mod:`apsimo_hostworker.conformance`). This distribution must never
|
|
10
|
+
import FastAPI or ``apsimo`` — see the design rule in
|
|
11
|
+
:mod:`apsimo_hostworker.contract`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .admission import (
|
|
17
|
+
DispatchAdmission,
|
|
18
|
+
DispatchAdmissionError,
|
|
19
|
+
FileDispatchAdmission,
|
|
20
|
+
sqlite_database_identity,
|
|
21
|
+
)
|
|
22
|
+
from .catalog import (
|
|
23
|
+
ACTION_TOOL_NAMES,
|
|
24
|
+
GRANT_AUTHORIZABLE_TOOL_NAMES,
|
|
25
|
+
NON_GRANTABLE_TOOL_NAMES,
|
|
26
|
+
TOOL_CATALOG,
|
|
27
|
+
ToolCatalogError,
|
|
28
|
+
ToolSpec,
|
|
29
|
+
validate_tool_args,
|
|
30
|
+
)
|
|
31
|
+
from .client import (
|
|
32
|
+
ClientCredential,
|
|
33
|
+
GovernedActionClient,
|
|
34
|
+
GovernedActionClientError,
|
|
35
|
+
WORKER_PRINCIPAL,
|
|
36
|
+
build_no_redirect_opener,
|
|
37
|
+
)
|
|
38
|
+
from .contract import (
|
|
39
|
+
GovernedContractError,
|
|
40
|
+
canonical_json_ascii,
|
|
41
|
+
canonical_json_utf8,
|
|
42
|
+
sha256_json_ascii,
|
|
43
|
+
sha256_json_utf8,
|
|
44
|
+
)
|
|
45
|
+
from .gate import (
|
|
46
|
+
BOUNDED_GRANT_SHAPE,
|
|
47
|
+
DEFAULT_REGISTRY,
|
|
48
|
+
GRANT_BINDING_METHOD,
|
|
49
|
+
GRANT_UNLIMITED_SENTINEL,
|
|
50
|
+
GateAuthorization,
|
|
51
|
+
MESSAGE_DELIVERY_SHAPE,
|
|
52
|
+
OwnerGateError,
|
|
53
|
+
ProvenanceShape,
|
|
54
|
+
ProvenanceShapeError,
|
|
55
|
+
ProvenanceShapeRegistry,
|
|
56
|
+
assert_dispatchable,
|
|
57
|
+
default_registry,
|
|
58
|
+
validate_owner_gate,
|
|
59
|
+
)
|
|
60
|
+
from .intent import HermesActionIntentError, HermesToolActionIntentV1
|
|
61
|
+
from .sqlite_store import SqliteActionStore
|
|
62
|
+
from .store import (
|
|
63
|
+
ActionIdempotencyConflict,
|
|
64
|
+
ActionLeaseConflict,
|
|
65
|
+
ActionNotFound,
|
|
66
|
+
ActionStore,
|
|
67
|
+
ActionStoreError,
|
|
68
|
+
ActionTransitionError,
|
|
69
|
+
)
|
|
70
|
+
from .worker import (
|
|
71
|
+
GovernedActionWorker,
|
|
72
|
+
GovernedActionWorkerError,
|
|
73
|
+
build_execution_request,
|
|
74
|
+
validate_execution_result,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try: # single source of truth: the installed distribution metadata
|
|
78
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
79
|
+
try:
|
|
80
|
+
__version__ = _dist_version("apsimo-hostworker")
|
|
81
|
+
except PackageNotFoundError: # running from a source tree, not installed
|
|
82
|
+
__version__ = "0.2.0"
|
|
83
|
+
except ImportError: # pragma: no cover
|
|
84
|
+
__version__ = "0.2.0"
|
|
85
|
+
|
|
86
|
+
__all__ = (
|
|
87
|
+
"ACTION_TOOL_NAMES",
|
|
88
|
+
"ActionIdempotencyConflict",
|
|
89
|
+
"ActionLeaseConflict",
|
|
90
|
+
"ActionNotFound",
|
|
91
|
+
"ActionStore",
|
|
92
|
+
"ActionStoreError",
|
|
93
|
+
"ActionTransitionError",
|
|
94
|
+
"BOUNDED_GRANT_SHAPE",
|
|
95
|
+
"ClientCredential",
|
|
96
|
+
"DEFAULT_REGISTRY",
|
|
97
|
+
"DispatchAdmission",
|
|
98
|
+
"DispatchAdmissionError",
|
|
99
|
+
"FileDispatchAdmission",
|
|
100
|
+
"GRANT_AUTHORIZABLE_TOOL_NAMES",
|
|
101
|
+
"GRANT_BINDING_METHOD",
|
|
102
|
+
"GRANT_UNLIMITED_SENTINEL",
|
|
103
|
+
"GateAuthorization",
|
|
104
|
+
"GovernedActionClient",
|
|
105
|
+
"GovernedActionClientError",
|
|
106
|
+
"GovernedActionWorker",
|
|
107
|
+
"GovernedActionWorkerError",
|
|
108
|
+
"GovernedContractError",
|
|
109
|
+
"HermesActionIntentError",
|
|
110
|
+
"HermesToolActionIntentV1",
|
|
111
|
+
"MESSAGE_DELIVERY_SHAPE",
|
|
112
|
+
"NON_GRANTABLE_TOOL_NAMES",
|
|
113
|
+
"OwnerGateError",
|
|
114
|
+
"ProvenanceShape",
|
|
115
|
+
"ProvenanceShapeError",
|
|
116
|
+
"ProvenanceShapeRegistry",
|
|
117
|
+
"SqliteActionStore",
|
|
118
|
+
"TOOL_CATALOG",
|
|
119
|
+
"ToolCatalogError",
|
|
120
|
+
"ToolSpec",
|
|
121
|
+
"WORKER_PRINCIPAL",
|
|
122
|
+
"assert_dispatchable",
|
|
123
|
+
"build_execution_request",
|
|
124
|
+
"build_no_redirect_opener",
|
|
125
|
+
"canonical_json_ascii",
|
|
126
|
+
"canonical_json_utf8",
|
|
127
|
+
"default_registry",
|
|
128
|
+
"sha256_json_ascii",
|
|
129
|
+
"sha256_json_utf8",
|
|
130
|
+
"sqlite_database_identity",
|
|
131
|
+
"validate_execution_result",
|
|
132
|
+
"validate_owner_gate",
|
|
133
|
+
"validate_tool_args",
|
|
134
|
+
)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Owner-only file and loopback-origin primitives for host workers.
|
|
2
|
+
|
|
3
|
+
Everything here is stdlib-only, side-effect-free beyond reading the named
|
|
4
|
+
file, and deliberately paranoid: these primitives sit under the credential
|
|
5
|
+
loader (:mod:`apsimo_hostworker.client`) and the dispatch-admission check
|
|
6
|
+
(:mod:`apsimo_hostworker.admission`), where a symlink race, a
|
|
7
|
+
group-readable secret, or an over-large document must fail closed rather
|
|
8
|
+
than degrade.
|
|
9
|
+
|
|
10
|
+
The bounded JSON reader is also the only JSON decoder the client uses for
|
|
11
|
+
network responses: it refuses duplicate object keys, non-finite numbers,
|
|
12
|
+
oversized integers, control characters, and unbounded nesting before any
|
|
13
|
+
value reaches a validator.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ipaddress
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import stat
|
|
23
|
+
import urllib.parse
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Mapping
|
|
26
|
+
|
|
27
|
+
PRIVATE_DOCUMENT_MAX_BYTES = 16 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PrivateIOError(RuntimeError):
|
|
31
|
+
"""An owner-only file or origin failed its safety contract."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def strict_json_bytes(raw: bytes, *, maximum: int, error=PrivateIOError) -> Any:
|
|
35
|
+
"""Decode one bounded finite JSON value without duplicate object keys."""
|
|
36
|
+
|
|
37
|
+
if not isinstance(raw, bytes) or len(raw) > maximum:
|
|
38
|
+
raise error("JSON document exceeds its safety bound")
|
|
39
|
+
|
|
40
|
+
def pairs(values):
|
|
41
|
+
result = {}
|
|
42
|
+
for key, value in values:
|
|
43
|
+
if key in result:
|
|
44
|
+
raise error("JSON object keys must be unique")
|
|
45
|
+
result[key] = value
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def constant(_value):
|
|
49
|
+
raise error("JSON numbers must be finite")
|
|
50
|
+
|
|
51
|
+
def integer(value):
|
|
52
|
+
if len(value.lstrip("-")) > 19:
|
|
53
|
+
raise error("JSON integer exceeds its safety bound")
|
|
54
|
+
parsed = int(value)
|
|
55
|
+
if abs(parsed) > (1 << 63) - 1:
|
|
56
|
+
raise error("JSON integer exceeds its safety bound")
|
|
57
|
+
return parsed
|
|
58
|
+
|
|
59
|
+
def floating(value):
|
|
60
|
+
parsed = float(value)
|
|
61
|
+
if not math.isfinite(parsed):
|
|
62
|
+
raise error("JSON numbers must be finite")
|
|
63
|
+
return parsed
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
value = json.loads(
|
|
67
|
+
raw.decode("utf-8", errors="strict"),
|
|
68
|
+
object_pairs_hook=pairs,
|
|
69
|
+
parse_constant=constant,
|
|
70
|
+
parse_int=integer,
|
|
71
|
+
parse_float=floating,
|
|
72
|
+
)
|
|
73
|
+
except error:
|
|
74
|
+
raise
|
|
75
|
+
except (UnicodeError, ValueError, TypeError, OverflowError, RecursionError) as exc:
|
|
76
|
+
raise error("JSON document is malformed") from exc
|
|
77
|
+
|
|
78
|
+
stack = [(value, 0)]
|
|
79
|
+
count = 0
|
|
80
|
+
while stack:
|
|
81
|
+
item, depth = stack.pop()
|
|
82
|
+
count += 1
|
|
83
|
+
if count > 1024 or depth > 16:
|
|
84
|
+
raise error("JSON document is too complex")
|
|
85
|
+
if isinstance(item, str):
|
|
86
|
+
if len(item) > 8192 or any(
|
|
87
|
+
ord(character) < 0x20 or ord(character) == 0x7F
|
|
88
|
+
for character in item
|
|
89
|
+
):
|
|
90
|
+
raise error("JSON text is unsafe")
|
|
91
|
+
try:
|
|
92
|
+
item.encode("utf-8")
|
|
93
|
+
except UnicodeError as exc:
|
|
94
|
+
raise error("JSON text is not UTF-8") from exc
|
|
95
|
+
elif isinstance(item, Mapping):
|
|
96
|
+
stack.extend((key, depth + 1) for key in item)
|
|
97
|
+
stack.extend((child, depth + 1) for child in item.values())
|
|
98
|
+
elif isinstance(item, list):
|
|
99
|
+
stack.extend((child, depth + 1) for child in item)
|
|
100
|
+
elif item is not None and not isinstance(item, (bool, int, float)):
|
|
101
|
+
raise error("JSON document contains an unsafe value")
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def safe_ancestry(
|
|
106
|
+
path: Path, *, label: str, error=PrivateIOError
|
|
107
|
+
) -> tuple[tuple[int, int, int], ...]:
|
|
108
|
+
"""Attest every existing ancestor without resolving through a symlink."""
|
|
109
|
+
|
|
110
|
+
chain = []
|
|
111
|
+
cursor = path
|
|
112
|
+
while True:
|
|
113
|
+
chain.append(cursor)
|
|
114
|
+
if cursor.parent == cursor:
|
|
115
|
+
break
|
|
116
|
+
cursor = cursor.parent
|
|
117
|
+
identities = []
|
|
118
|
+
try:
|
|
119
|
+
for candidate in reversed(chain):
|
|
120
|
+
info = candidate.lstat()
|
|
121
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
122
|
+
raise error("%s path ancestry is unsafe" % label)
|
|
123
|
+
identities.append((info.st_dev, info.st_ino, info.st_mode))
|
|
124
|
+
except error:
|
|
125
|
+
raise
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
raise error("%s path ancestry is unavailable" % label) from exc
|
|
128
|
+
return tuple(identities)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_private_json(
|
|
132
|
+
path: str, *, label: str, error=PrivateIOError
|
|
133
|
+
) -> tuple[Path, bytes, Any]:
|
|
134
|
+
"""Read an owner-only mode-0600 regular file without racing a symlink.
|
|
135
|
+
|
|
136
|
+
The file must be a regular file owned by the calling user with mode
|
|
137
|
+
exactly ``0600``, at most :data:`PRIVATE_DOCUMENT_MAX_BYTES` long, whose
|
|
138
|
+
identity (device, inode, size, mtime) and whole directory ancestry are
|
|
139
|
+
unchanged across the read. Anything else fails closed.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
configured = str(path or "").strip()
|
|
143
|
+
if not configured:
|
|
144
|
+
raise error("%s file is not configured" % label)
|
|
145
|
+
target = Path(os.path.abspath(os.path.expanduser(configured)))
|
|
146
|
+
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_CLOEXEC"):
|
|
147
|
+
raise error("private file loading is unsupported")
|
|
148
|
+
descriptor = None
|
|
149
|
+
try:
|
|
150
|
+
ancestry_before = safe_ancestry(target.parent, label=label, error=error)
|
|
151
|
+
initial = target.lstat()
|
|
152
|
+
if stat.S_ISLNK(initial.st_mode):
|
|
153
|
+
raise error("%s must not be a symlink" % label)
|
|
154
|
+
descriptor = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
155
|
+
before = os.fstat(descriptor)
|
|
156
|
+
if (
|
|
157
|
+
not stat.S_ISREG(before.st_mode)
|
|
158
|
+
or stat.S_IMODE(before.st_mode) != 0o600
|
|
159
|
+
or (hasattr(os, "geteuid") and before.st_uid != os.geteuid())
|
|
160
|
+
or before.st_size < 0
|
|
161
|
+
or before.st_size > PRIVATE_DOCUMENT_MAX_BYTES
|
|
162
|
+
or (initial.st_dev, initial.st_ino) != (before.st_dev, before.st_ino)
|
|
163
|
+
):
|
|
164
|
+
raise error("%s must be an owned mode-0600 regular file" % label)
|
|
165
|
+
collected = bytearray()
|
|
166
|
+
while len(collected) <= PRIVATE_DOCUMENT_MAX_BYTES:
|
|
167
|
+
chunk = os.read(
|
|
168
|
+
descriptor,
|
|
169
|
+
min(4096, PRIVATE_DOCUMENT_MAX_BYTES + 1 - len(collected)),
|
|
170
|
+
)
|
|
171
|
+
if not chunk:
|
|
172
|
+
break
|
|
173
|
+
collected.extend(chunk)
|
|
174
|
+
after = os.fstat(descriptor)
|
|
175
|
+
current = target.lstat()
|
|
176
|
+
ancestry_after = safe_ancestry(target.parent, label=label, error=error)
|
|
177
|
+
stable = (
|
|
178
|
+
before.st_dev,
|
|
179
|
+
before.st_ino,
|
|
180
|
+
before.st_uid,
|
|
181
|
+
before.st_mode,
|
|
182
|
+
before.st_size,
|
|
183
|
+
before.st_mtime_ns,
|
|
184
|
+
) == (
|
|
185
|
+
after.st_dev,
|
|
186
|
+
after.st_ino,
|
|
187
|
+
after.st_uid,
|
|
188
|
+
after.st_mode,
|
|
189
|
+
after.st_size,
|
|
190
|
+
after.st_mtime_ns,
|
|
191
|
+
)
|
|
192
|
+
if (
|
|
193
|
+
len(collected) > PRIVATE_DOCUMENT_MAX_BYTES
|
|
194
|
+
or len(collected) != before.st_size
|
|
195
|
+
or not stable
|
|
196
|
+
or ancestry_before != ancestry_after
|
|
197
|
+
or (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
|
|
198
|
+
):
|
|
199
|
+
raise error("%s changed while being read" % label)
|
|
200
|
+
raw = bytes(collected)
|
|
201
|
+
except error:
|
|
202
|
+
raise
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise error("%s file is unavailable" % label) from exc
|
|
205
|
+
finally:
|
|
206
|
+
if descriptor is not None:
|
|
207
|
+
os.close(descriptor)
|
|
208
|
+
return (
|
|
209
|
+
target,
|
|
210
|
+
raw,
|
|
211
|
+
strict_json_bytes(raw, maximum=PRIVATE_DOCUMENT_MAX_BYTES, error=error),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def loopback_origin(value: str, *, error=PrivateIOError) -> str:
|
|
216
|
+
"""Return ``value`` iff it is one canonical loopback HTTP(S) origin.
|
|
217
|
+
|
|
218
|
+
Exactly ``scheme://host[:port]`` with a loopback host: no path, query,
|
|
219
|
+
fragment, userinfo, whitespace, or non-printable bytes. Everything a
|
|
220
|
+
governed-action client or admission may talk to must pass this, so a
|
|
221
|
+
configuration mistake can never point owner-authorized mutations at a
|
|
222
|
+
remote host.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
raw = str(value or "")
|
|
226
|
+
if (
|
|
227
|
+
not raw
|
|
228
|
+
or raw != raw.strip()
|
|
229
|
+
or any(ord(character) < 0x21 or ord(character) > 0x7E for character in raw)
|
|
230
|
+
):
|
|
231
|
+
raise error("service origin is invalid")
|
|
232
|
+
try:
|
|
233
|
+
parsed = urllib.parse.urlsplit(raw)
|
|
234
|
+
port = parsed.port
|
|
235
|
+
except ValueError as exc:
|
|
236
|
+
raise error("service origin is invalid") from exc
|
|
237
|
+
if (
|
|
238
|
+
parsed.scheme not in ("http", "https")
|
|
239
|
+
or not parsed.hostname
|
|
240
|
+
or parsed.username is not None
|
|
241
|
+
or parsed.password is not None
|
|
242
|
+
or parsed.path
|
|
243
|
+
or parsed.query
|
|
244
|
+
or parsed.fragment
|
|
245
|
+
or not parsed.netloc
|
|
246
|
+
):
|
|
247
|
+
raise error("service origin must be one loopback origin")
|
|
248
|
+
hostname = parsed.hostname.lower()
|
|
249
|
+
try:
|
|
250
|
+
loopback = ipaddress.ip_address(hostname).is_loopback
|
|
251
|
+
except ValueError:
|
|
252
|
+
loopback = hostname == "localhost"
|
|
253
|
+
if not loopback or (port is not None and not 1 <= port <= 65535):
|
|
254
|
+
raise error("service origin must be loopback")
|
|
255
|
+
if urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) != raw:
|
|
256
|
+
raise error("service origin must be canonical")
|
|
257
|
+
return raw
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
__all__ = (
|
|
261
|
+
"PRIVATE_DOCUMENT_MAX_BYTES",
|
|
262
|
+
"PrivateIOError",
|
|
263
|
+
"loopback_origin",
|
|
264
|
+
"read_private_json",
|
|
265
|
+
"safe_ancestry",
|
|
266
|
+
"strict_json_bytes",
|
|
267
|
+
)
|