devlyn-cli 4.3.21 → 4.3.24
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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/bin/devlyn.js +26 -1
- package/bin/harness-activation.js +11 -13
- package/bin/instruction-templates.json +26 -0
- package/bin/instructions.js +1 -1
- package/bin/skill-history.json +15 -1
- package/config/skills/_shared/bring-in.py +810 -0
- package/config/skills/_shared/harness-store.py +18 -6
- package/config/skills/_shared/runtime-principles.md +2 -2
- package/config/skills/_shared/storage-checkout.py +117 -0
- package/config/skills/_shared/storage-client.py +1180 -0
- package/config/skills/_shared/task-complete.py +900 -88
- package/config/skills/_shared/task-completion.md +78 -6
- package/config/skills/_shared/warm-build-dirs.py +53 -3
- package/config/skills/devlyn-ideate/SKILL.md +2 -2
- package/config/skills/devlyn-ideate/references/loop.md +4 -4
- package/config/skills/devlyn-ideate/scripts/queue.py +70 -19
- package/config/storage-client-contract.json +7 -0
- package/harness/AGENTS.md +2 -2
- package/harness/CLAUDE.md +2 -2
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/bring-in.py +810 -0
- package/native/claude/skills/_shared/harness-store.py +18 -6
- package/native/claude/skills/_shared/runtime-principles.md +2 -2
- package/native/claude/skills/_shared/storage-checkout.py +117 -0
- package/native/claude/skills/_shared/storage-client.py +1180 -0
- package/native/claude/skills/_shared/task-complete.py +900 -88
- package/native/claude/skills/_shared/task-completion.md +78 -6
- package/native/claude/skills/_shared/warm-build-dirs.py +53 -3
- package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/claude/skills/devlyn-ideate/references/loop.md +4 -4
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +70 -19
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +810 -0
- package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +18 -6
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +2 -2
- package/native/codex/plugins/devlyn/skills/_shared/storage-checkout.py +117 -0
- package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +1180 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +900 -88
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +78 -6
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +53 -3
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +4 -4
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +70 -19
- package/optional-skills/better-auth-setup/SKILL.md +9 -3
- package/optional-skills/better-auth-setup/references/proxy-setup.md +10 -3
- package/package.json +1 -1
- package/release.json +93 -43
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Authenticated storage protocol 1. No registry access or allocation side effects.
|
|
3
|
+
|
|
4
|
+
Source of record: devlyn-os/storage-reservations/crates/devlynd/src/:
|
|
5
|
+
storage/rpc.rs:21-119 (wire), 798-835 (busy), 870-933 (authentication/errors);
|
|
6
|
+
storage/model.rs:9-350 (all records and decimal fields);
|
|
7
|
+
control_service/protocol.rs:71-74 (limits), unix.rs:19-27 (endpoint);
|
|
8
|
+
control_service/windows.rs:91-106 (pipe digest);
|
|
9
|
+
tool_channel/peer.rs:188-249 (peer credentials);
|
|
10
|
+
supervisor.rs:253-271,3753-3886,3933-3957 (process identities);
|
|
11
|
+
util/windows.rs:65-112,308-326,876-901,977-1000 (SID/canonical path).
|
|
12
|
+
|
|
13
|
+
Connections carry one request. Hello is negotiated on a separate authenticated
|
|
14
|
+
connection; the operation connection must have that same server incarnation.
|
|
15
|
+
No transport error authorizes downgrade, a new operation ID, or an automatic
|
|
16
|
+
retry. Only an explicit busy reply permits bounded replay of identical bytes.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import ctypes
|
|
22
|
+
import errno
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
import re
|
|
29
|
+
import selectors
|
|
30
|
+
import socket
|
|
31
|
+
import stat
|
|
32
|
+
import struct
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
|
|
38
|
+
MAX_REQUEST = 128 * 1024
|
|
39
|
+
MAX_REPLY = 4 * 1024 * 1024
|
|
40
|
+
EXCHANGE_SECONDS = 15.0
|
|
41
|
+
BUSY_SECONDS = 2.0
|
|
42
|
+
MANAGED_PENDING = "managed_pending"
|
|
43
|
+
MANAGED_OWNED = "managed_owned"
|
|
44
|
+
MANAGED_STATES = frozenset({MANAGED_PENDING, MANAGED_OWNED})
|
|
45
|
+
|
|
46
|
+
# Quoted sentences are exactly plan §4. Rows without a quoted sentence use
|
|
47
|
+
# conservative receipt-based reconciliation instructions, never raw diagnostics.
|
|
48
|
+
REFUSAL_TEXT = {
|
|
49
|
+
"storage_insufficient": "Not enough disk space to complete this operation safely. Free up space and retry.",
|
|
50
|
+
"allocation_size_unknown": "Devlyn can't check the space needed to create a separate workspace. You can work in an existing project folder instead.",
|
|
51
|
+
"storage_io": "Couldn't verify available disk space. Retry the operation.",
|
|
52
|
+
"storage_unavailable": "Devlyn storage is unavailable. Open Devlyn and retry using this receipt.",
|
|
53
|
+
"storage_unsupported": "Update Devlyn and devlyn-cli, then resume using this receipt.",
|
|
54
|
+
"storage_exhausted": "Finish outstanding work, then retry using this receipt. If this continues, repair Devlyn.",
|
|
55
|
+
"storage_expired": "This attempt needs reconciliation; retain the workspace and resume from its receipt.",
|
|
56
|
+
"storage_busy": "Devlyn storage is busy. Retry using this receipt.",
|
|
57
|
+
"storage_in_use": "This cache is still in use. Stop the processes using this cache. Then resume cleanup.",
|
|
58
|
+
"storage_protected": "Ownership or identity changed. Retain the workspace and inspect using this receipt.",
|
|
59
|
+
"storage_missing": "Ownership or identity changed. Retain the workspace and inspect using this receipt.",
|
|
60
|
+
"storage_conflict": "Ownership or identity changed. Retain the workspace and inspect using this receipt.",
|
|
61
|
+
"storage_invalid": "The storage request needs repair. Retain the workspace and inspect using this receipt.",
|
|
62
|
+
"storage_not_found": "This attempt needs reconciliation; retain the workspace and resume from its receipt.",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StorageRefusal(Exception):
|
|
67
|
+
"""Safe prose in str(); untrusted details only in diagnostic."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, code, diagnostic="", *, operation=None, phase=None, message=None, resume=None):
|
|
70
|
+
self.code = "storage_busy" if code == "busy" else code
|
|
71
|
+
if self.code not in REFUSAL_TEXT:
|
|
72
|
+
diagnostic = f"unknown refusal {self.code!r}: {diagnostic}"
|
|
73
|
+
self.code = "storage_unavailable"
|
|
74
|
+
self.diagnostic = diagnostic
|
|
75
|
+
self.operation = operation
|
|
76
|
+
self.phase = phase
|
|
77
|
+
self.resume = resume
|
|
78
|
+
self.retryable = self.code == "storage_busy"
|
|
79
|
+
super().__init__(message or REFUSAL_TEXT[self.code])
|
|
80
|
+
|
|
81
|
+
def result(self, *, receipt=None, resume=None):
|
|
82
|
+
# Callers construct resume from their saved receipt and verified helper;
|
|
83
|
+
# no path, shell command or instruction from the server is interpolated.
|
|
84
|
+
return {"code": self.code, "message": str(self), "diagnostic": self.diagnostic,
|
|
85
|
+
"operation": self.operation, "phase": self.phase,
|
|
86
|
+
"retryable": self.retryable, "receipt": receipt, "resume": resume or self.resume}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def require(condition, diagnostic, code="storage_unavailable"):
|
|
90
|
+
if not condition:
|
|
91
|
+
raise StorageRefusal(code, diagnostic)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _pairs(pairs):
|
|
95
|
+
value = {}
|
|
96
|
+
for key, item in pairs:
|
|
97
|
+
require(key not in value, f"duplicate JSON key {key!r}")
|
|
98
|
+
value[key] = item
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _constant(value):
|
|
103
|
+
raise StorageRefusal("storage_unavailable", f"nonfinite JSON constant {value}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def loads(data):
|
|
107
|
+
try:
|
|
108
|
+
value = json.loads(data.decode("utf-8", "strict"), object_pairs_hook=_pairs,
|
|
109
|
+
parse_constant=_constant)
|
|
110
|
+
_json_values(value)
|
|
111
|
+
return value
|
|
112
|
+
except (UnicodeError, ValueError, RecursionError) as exc:
|
|
113
|
+
raise StorageRefusal("storage_unavailable", f"invalid JSON: {exc}") from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _json_values(value):
|
|
117
|
+
if isinstance(value, str):
|
|
118
|
+
value.encode("utf-8", "strict") # Reject escaped unpaired surrogates too.
|
|
119
|
+
elif isinstance(value, float):
|
|
120
|
+
require(math.isfinite(value), "overflowing JSON float")
|
|
121
|
+
elif isinstance(value, dict):
|
|
122
|
+
for key, item in value.items():
|
|
123
|
+
require(isinstance(key, str), "JSON key must be text")
|
|
124
|
+
_json_values(key)
|
|
125
|
+
_json_values(item)
|
|
126
|
+
elif isinstance(value, list):
|
|
127
|
+
for item in value:
|
|
128
|
+
_json_values(item)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Declarative required shapes are deliberately separate from policy. Replies
|
|
132
|
+
# allow additive fields; requests reject misspelled/unknown fields before I/O.
|
|
133
|
+
# '?' marks serde Option/default fields. Decimal is ONLY used at Rust #[with].
|
|
134
|
+
SHAPES = {
|
|
135
|
+
"identity": {"volume": "str", "file": "str"},
|
|
136
|
+
"stamp": {"pid": "i64", "start": "str"},
|
|
137
|
+
"owner": {"repository": "identity", "allocation_id": "str", "receipt": "str"},
|
|
138
|
+
"creator": {"product": "str", "protocol_version": "u32", "operation_id": "str"},
|
|
139
|
+
"declaration": {"creator": "creator", "owner": "owner", "parent": "str", "entry": "str"},
|
|
140
|
+
"location": {"parent": "str", "parent_identity": "identity", "entry": "str",
|
|
141
|
+
"directory_identity?": "identity?", "volume_id": "str", "pool_id?": "str?"},
|
|
142
|
+
"cache": {"schema_version": "u32", "cache_id": "str", "kind": ("cargo_build_cache",),
|
|
143
|
+
"disposable": "bool", "creator": "creator", "owner": "owner", "location": "location",
|
|
144
|
+
"created_at": "str", "last_release_at?": "str?", "coverage": ("unproven", "managed"),
|
|
145
|
+
"state": ("preparing", "active", "retired", "removing", "evicting", "partial", "deleted", "protected"),
|
|
146
|
+
"reason?": "str?", "protection?": (None, "missing", "io"), "supervisor": "stamp",
|
|
147
|
+
"removal_generation?": "u64", "removal_id?": "str?", "retire_intent": "bool",
|
|
148
|
+
"retirement_authorized_by?": "stamp?"},
|
|
149
|
+
"lease": {"lease_id": "str", "cache_id": "str", "operation_id": "str", "supervisor": "stamp",
|
|
150
|
+
"consumers": ["consumer"], "acquired_at": "str", "leaf?": "bool",
|
|
151
|
+
"descendants_reconciled": "bool", "released_at?": "str?"},
|
|
152
|
+
"snapshot": {"cache": "cache", "leases": ["lease"]},
|
|
153
|
+
"removal_entry": {"cache_id": "str", "generation": "u64"},
|
|
154
|
+
"removal": {"removal_id": "str", "operation_id": "str", "supervisor": "stamp",
|
|
155
|
+
"entries": ["removal_entry"], "finished_at?": "str?"},
|
|
156
|
+
"write": {"parent": "str", "relative": "str", "phase": "str", "bytes": "decimal"},
|
|
157
|
+
"charge": {"id": "str", "bytes": "decimal"},
|
|
158
|
+
"destination": {"parent_identity": "identity", "existing_ancestors": ["identity"],
|
|
159
|
+
"volume_id": "str", "pool_id": "str"},
|
|
160
|
+
"worker": {"operation_id": "str", "consumer": "consumer", "containment": "containment",
|
|
161
|
+
"descendants_reconciled": "bool"},
|
|
162
|
+
"reservation": {"reservation_id": "str", "operation_id": "str", "generation": "decimal",
|
|
163
|
+
"boot_id?": "str?", "owner": "owner", "holder": "stamp", "plan": "plan",
|
|
164
|
+
"destinations": ["destination"], "pools": ["charge"], "volumes": ["charge"],
|
|
165
|
+
"workers": ["worker"], "granted_at": "str", "released_at?": "str?"},
|
|
166
|
+
"hello": {"storage_protocol": "u32", "capabilities": ["str"], "schema": "u32"},
|
|
167
|
+
"containment": ("unix_group", "unix_checkout", "windows_job", "supervised"),
|
|
168
|
+
}
|
|
169
|
+
REQUESTS = {
|
|
170
|
+
"hello": ({}, "hello"),
|
|
171
|
+
"reserve": ({"operation_id": "str", "owner": "owner", "plan": "plan"}, "reservation"),
|
|
172
|
+
"enroll_reservation_worker": ({"operation_id": "str", "reservation_id": "str", "generation": "decimal",
|
|
173
|
+
"consumer": "consumer", "containment": "containment"}, "reservation"),
|
|
174
|
+
"release_reservation": ({"reservation_id": "str", "generation": "decimal", "descendants_reconciled": "bool"}, "reservation"),
|
|
175
|
+
"record_reservation_completion": ({"reservation_id": "str", "generation": "decimal"}, "reservation"),
|
|
176
|
+
"query_reservation": ({"operation_id": "str"}, "reservation"),
|
|
177
|
+
"locate": ({"parent": "str", "entry": "str"}, "cache"),
|
|
178
|
+
"begin_removal": ({"operation_id": "str", "cache_ids": ["str"]}, "removal"),
|
|
179
|
+
"finish_removal": ({"removal_id": "str"}, "removal"),
|
|
180
|
+
"declare": ({"declaration": "declaration"}, "cache"),
|
|
181
|
+
"bind": ({"cache_id": "str", "consumers": ["consumer"]}, "published"),
|
|
182
|
+
"bind_publication": ({"cache_id": "str"}, "published"),
|
|
183
|
+
"lease": ({"cache_id": "str", "operation_id": "str", "consumers": ["consumer"], "leaf?": "bool"}, "published"),
|
|
184
|
+
"release": ({"lease_id": "str", "descendants_reconciled": "bool"}, "released"),
|
|
185
|
+
"retire_intent": ({"cache_id": "str"}, "cache"),
|
|
186
|
+
"query": ({"cache_id": "str"}, "snapshot"),
|
|
187
|
+
}
|
|
188
|
+
SHAPES["published"] = {"cache": "cache", "lease": "lease"}
|
|
189
|
+
SHAPES["released"] = SHAPES["lease"]
|
|
190
|
+
SHAPES["parent_evidence"] = {"identity": "identity", "mount": "str"}
|
|
191
|
+
SHAPES["declaration"]["parent_evidence?"] = "parent_evidence?"
|
|
192
|
+
SHAPES["location"]["parent_evidence?"] = "parent_evidence?"
|
|
193
|
+
SHAPES["warning"] = {"code": ("exclusion_failed", "cargo_ineligible", "cargo_unverifiable", "no_cargo_manifest"),
|
|
194
|
+
"diagnostic": "str"}
|
|
195
|
+
SHAPES["cache"]["warnings?"] = ["warning"]
|
|
196
|
+
SHAPES["outcome"] = {"attempt": "str", "resolves?": "str?", "project": "str", "taskId": "str",
|
|
197
|
+
"code?": (None, "storage_insufficient", "allocation_size_unknown", "storage_io",
|
|
198
|
+
"storage_unavailable", "storage_unsupported", "storage_exhausted", "storage_protected"),
|
|
199
|
+
"retryable": "bool"}
|
|
200
|
+
SHAPES["storage_refused"] = {"generation": "i64", "revision": "str", "outcome": "outcome"}
|
|
201
|
+
SHAPES["allocation_outcome"] = "storage_refused?"
|
|
202
|
+
REQUESTS.update({"allocation_outcome": ({}, "allocation_outcome"),
|
|
203
|
+
"report_allocation_outcome": ({"outcome": "outcome"}, "reported")})
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate(value, shape, *, exact=False, windows=None):
|
|
207
|
+
if windows is None:
|
|
208
|
+
windows = os.name == "nt"
|
|
209
|
+
if isinstance(shape, dict):
|
|
210
|
+
require(type(value) is dict, "expected JSON object")
|
|
211
|
+
for key, child in shape.items():
|
|
212
|
+
name = key.removesuffix("?")
|
|
213
|
+
if name not in value and key.endswith("?"):
|
|
214
|
+
continue
|
|
215
|
+
require(name in value, f"missing field {name}")
|
|
216
|
+
validate(value[name], child, exact=exact, windows=windows)
|
|
217
|
+
if exact:
|
|
218
|
+
require(not value.keys() - {k.removesuffix('?') for k in shape}, "unknown request field")
|
|
219
|
+
elif isinstance(shape, list):
|
|
220
|
+
require(type(value) is list, "expected JSON array")
|
|
221
|
+
for item in value:
|
|
222
|
+
validate(item, shape[0], exact=exact, windows=windows)
|
|
223
|
+
elif isinstance(shape, tuple):
|
|
224
|
+
require(value in shape and (value is None or type(value) is str), "invalid enum")
|
|
225
|
+
elif shape.endswith("?"):
|
|
226
|
+
if value is not None:
|
|
227
|
+
validate(value, shape[:-1], exact=exact, windows=windows)
|
|
228
|
+
elif shape == "str":
|
|
229
|
+
require(type(value) is str, "expected string")
|
|
230
|
+
elif shape == "bool":
|
|
231
|
+
require(type(value) is bool, "expected boolean")
|
|
232
|
+
elif shape in {"u32", "u64", "i64"}:
|
|
233
|
+
lo, hi = {"u32": (0, 2**32-1), "u64": (0, 2**64-1), "i64": (-2**63, 2**63-1)}[shape]
|
|
234
|
+
require(type(value) is int and lo <= value <= hi, f"expected {shape} integer")
|
|
235
|
+
elif shape == "decimal":
|
|
236
|
+
require(type(value) is str and re.fullmatch(r"0|[1-9][0-9]{0,19}", value) is not None
|
|
237
|
+
and int(value) <= 2**64-1, "expected canonical decimal u64 string")
|
|
238
|
+
elif shape == "consumer":
|
|
239
|
+
fields = {"pid": "i64", "created": "u64"} if windows else {"pid": "i64", "pgid": "i64", "start?": "str?"}
|
|
240
|
+
validate(value, fields, exact=exact, windows=windows)
|
|
241
|
+
elif shape == "plan":
|
|
242
|
+
require(type(value) is dict, "expected write plan")
|
|
243
|
+
if value.get("kind") == "known":
|
|
244
|
+
fields = {"kind": ("known",), "writes": ["write"], "digest": "str"}
|
|
245
|
+
else:
|
|
246
|
+
fields = {"kind": ("unknown",), "reason": ("external_filter", "lfs_extension", "encoding", "attributes",
|
|
247
|
+
"lfs_transfer", "missing_object", "plan_changed", "unsupported_git", "overflow")}
|
|
248
|
+
validate(value, fields, exact=exact, windows=windows)
|
|
249
|
+
else:
|
|
250
|
+
validate(value, SHAPES[shape], exact=exact, windows=windows)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def encode_request(request):
|
|
254
|
+
try:
|
|
255
|
+
require(type(request) is dict and request.get("op") in REQUESTS, "unsupported request operation", "storage_invalid")
|
|
256
|
+
fields, _ = REQUESTS[request["op"]]
|
|
257
|
+
validate(request, {"op": (request["op"],), **fields}, exact=True)
|
|
258
|
+
_json_values(request)
|
|
259
|
+
data = json.dumps({"storage_protocol": 1, "request": request}, ensure_ascii=False,
|
|
260
|
+
allow_nan=False, separators=(",", ":")).encode("utf-8")
|
|
261
|
+
require(len(data) <= MAX_REQUEST, "request exceeds 128 KiB", "storage_invalid")
|
|
262
|
+
return data + b"\n"
|
|
263
|
+
except (ValueError, TypeError, UnicodeError, RecursionError) as exc:
|
|
264
|
+
raise StorageRefusal("storage_invalid", str(exc)) from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def decode_reply(frame, expected):
|
|
268
|
+
require(frame.endswith(b"\n") and b"\n" not in frame[:-1], "truncated or multiple reply frames")
|
|
269
|
+
require(len(frame) <= MAX_REPLY, "reply exceeds 4 MiB including LF")
|
|
270
|
+
response = loads(frame[:-1])
|
|
271
|
+
require(type(response) is dict and ("ok" in response) != ("error" in response), "invalid response envelope")
|
|
272
|
+
if "error" in response:
|
|
273
|
+
validate(response["error"], {"code": "str", "message": "str"})
|
|
274
|
+
raise StorageRefusal(response["error"]["code"], response["error"]["message"])
|
|
275
|
+
reply = response["ok"]
|
|
276
|
+
require(type(reply) is dict and reply.get("kind") == expected, "unexpected reply kind")
|
|
277
|
+
if expected == "reported":
|
|
278
|
+
return None
|
|
279
|
+
require("value" in reply, "missing reply value")
|
|
280
|
+
validate(reply["value"], expected)
|
|
281
|
+
return reply["value"]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def remaining(deadline):
|
|
285
|
+
left = deadline - time.monotonic()
|
|
286
|
+
require(left > 0, "storage exchange deadline exceeded")
|
|
287
|
+
return left
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def unix_start(pid):
|
|
291
|
+
require(type(pid) is int and 0 < pid < 2**31, "invalid peer pid")
|
|
292
|
+
if sys.platform.startswith("linux"):
|
|
293
|
+
fields = Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()
|
|
294
|
+
require(fields[0] not in {"Z", "X"}, "peer exited")
|
|
295
|
+
ticks = int(fields[19])
|
|
296
|
+
boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
297
|
+
require(re.fullmatch(r"[0-9a-f-]{36}", boot) is not None and ticks >= 0, "invalid Linux process identity")
|
|
298
|
+
return f"linux:{boot}:{ticks}"
|
|
299
|
+
require(sys.platform == "darwin" and struct.calcsize("P") == 8, "unsupported process identity", "storage_unsupported")
|
|
300
|
+
# Same size/offsets as supervisor.rs:3810-3886; no ps/libproc approximation.
|
|
301
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
302
|
+
libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint, ctypes.c_void_p,
|
|
303
|
+
ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p, ctypes.c_size_t]
|
|
304
|
+
libc.sysctl.restype = ctypes.c_int
|
|
305
|
+
mib = (ctypes.c_int * 4)(1, 14, 1, pid)
|
|
306
|
+
record = ctypes.create_string_buffer(648)
|
|
307
|
+
size = ctypes.c_size_t(648)
|
|
308
|
+
require(libc.sysctl(mib, 4, record, ctypes.byref(size), None, 0) == 0,
|
|
309
|
+
f"sysctl failed: errno {ctypes.get_errno()}")
|
|
310
|
+
require(size.value == 648, f"unexpected kinfo_proc size {size.value}")
|
|
311
|
+
raw = record.raw
|
|
312
|
+
seconds, micros = struct.unpack_from("=qi", raw)
|
|
313
|
+
require(struct.unpack_from("=i", raw, 40)[0] == pid and raw[36] != 5
|
|
314
|
+
and seconds >= 0 and 0 <= micros < 1_000_000, "invalid macOS process identity")
|
|
315
|
+
return f"macos:{seconds}:{micros}"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def unix_peer(stream):
|
|
319
|
+
if sys.platform.startswith("linux"):
|
|
320
|
+
raw = stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("=iII"))
|
|
321
|
+
pid, uid, _ = struct.unpack("=iII", raw)
|
|
322
|
+
else:
|
|
323
|
+
require(sys.platform == "darwin", "unsupported peer credentials", "storage_unsupported")
|
|
324
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
325
|
+
libc.getpeereid.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint)]
|
|
326
|
+
libc.getpeereid.restype = ctypes.c_int
|
|
327
|
+
uid_out, gid_out = ctypes.c_uint(), ctypes.c_uint()
|
|
328
|
+
require(libc.getpeereid(stream.fileno(), ctypes.byref(uid_out), ctypes.byref(gid_out)) == 0,
|
|
329
|
+
f"getpeereid failed: errno {ctypes.get_errno()}")
|
|
330
|
+
uid = uid_out.value
|
|
331
|
+
pid = struct.unpack("=i", stream.getsockopt(0, 2, 4))[0] # SOL_LOCAL / LOCAL_PEERPID
|
|
332
|
+
require(uid == os.geteuid(), f"foreign endpoint uid {uid}")
|
|
333
|
+
return pid, uid, unix_start(pid)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class UnixStream:
|
|
337
|
+
def __init__(self, directory, deadline):
|
|
338
|
+
self.stream = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
339
|
+
try:
|
|
340
|
+
directory = Path(directory).resolve(strict=True)
|
|
341
|
+
self.directory = directory
|
|
342
|
+
self.directory_stat = directory.lstat()
|
|
343
|
+
require(stat.S_ISDIR(self.directory_stat.st_mode) and self.directory_stat.st_uid == os.geteuid()
|
|
344
|
+
and self.directory_stat.st_mode & 0o077 == 0, "control directory is not private")
|
|
345
|
+
self.path = directory / "control.sock"
|
|
346
|
+
self.socket_stat = self.path.lstat()
|
|
347
|
+
require(stat.S_ISSOCK(self.socket_stat.st_mode) and self.socket_stat.st_uid == os.geteuid(), "invalid control socket")
|
|
348
|
+
self.stream.setblocking(False)
|
|
349
|
+
while True:
|
|
350
|
+
remaining(deadline)
|
|
351
|
+
result = self.stream.connect_ex(str(self.path))
|
|
352
|
+
if result in (0, errno.EISCONN):
|
|
353
|
+
break
|
|
354
|
+
if result == errno.EAGAIN: # Linux's full Unix backlog: retry connect itself.
|
|
355
|
+
time.sleep(min(0.02, remaining(deadline)))
|
|
356
|
+
continue
|
|
357
|
+
if result in (errno.EINPROGRESS, errno.EALREADY, errno.EINTR):
|
|
358
|
+
with selectors.DefaultSelector() as selector:
|
|
359
|
+
selector.register(self.stream, selectors.EVENT_WRITE)
|
|
360
|
+
require(selector.select(remaining(deadline)), "connect deadline exceeded")
|
|
361
|
+
result = self.stream.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
|
362
|
+
if result == 0:
|
|
363
|
+
break
|
|
364
|
+
raise OSError(result, os.strerror(result))
|
|
365
|
+
self.identity = self.authenticate()
|
|
366
|
+
except BaseException:
|
|
367
|
+
self.close()
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
def authenticate(self):
|
|
371
|
+
directory, endpoint = self.directory.lstat(), self.path.lstat()
|
|
372
|
+
require((directory.st_dev, directory.st_ino) == (self.directory_stat.st_dev, self.directory_stat.st_ino)
|
|
373
|
+
and directory.st_uid == os.geteuid() and directory.st_mode & 0o077 == 0
|
|
374
|
+
and (endpoint.st_dev, endpoint.st_ino) == (self.socket_stat.st_dev, self.socket_stat.st_ino)
|
|
375
|
+
and stat.S_ISSOCK(endpoint.st_mode), "control endpoint changed")
|
|
376
|
+
first = unix_peer(self.stream)
|
|
377
|
+
require(first == unix_peer(self.stream), "server incarnation changed during authentication")
|
|
378
|
+
return first
|
|
379
|
+
|
|
380
|
+
def exchange(self, data, deadline):
|
|
381
|
+
require(self.authenticate() == self.identity, "server incarnation changed before first byte")
|
|
382
|
+
sent, reply = 0, bytearray()
|
|
383
|
+
write_error = None
|
|
384
|
+
with selectors.DefaultSelector() as selector:
|
|
385
|
+
selector.register(self.stream, selectors.EVENT_READ | selectors.EVENT_WRITE)
|
|
386
|
+
while True:
|
|
387
|
+
events = selector.select(remaining(deadline))
|
|
388
|
+
require(events, "exchange deadline exceeded")
|
|
389
|
+
mask = 0
|
|
390
|
+
for _, ready in events: # kqueue may emit read/write separately.
|
|
391
|
+
mask |= ready
|
|
392
|
+
# Read first: an early busy reply survives a concurrent EPIPE.
|
|
393
|
+
if mask & selectors.EVENT_READ:
|
|
394
|
+
try:
|
|
395
|
+
chunk = self.stream.recv(min(65536, MAX_REPLY + 2 - len(reply)))
|
|
396
|
+
except BlockingIOError:
|
|
397
|
+
chunk = None
|
|
398
|
+
if chunk is not None:
|
|
399
|
+
require(chunk, f"reply truncated; write error={write_error}")
|
|
400
|
+
reply.extend(chunk)
|
|
401
|
+
require(len(reply) <= MAX_REPLY, "oversized reply")
|
|
402
|
+
if b"\n" in reply:
|
|
403
|
+
return bytes(reply)
|
|
404
|
+
if mask & selectors.EVENT_WRITE and sent < len(data) and write_error is None:
|
|
405
|
+
try:
|
|
406
|
+
count = self.stream.send(data[sent:])
|
|
407
|
+
require(count > 0, "zero-byte write")
|
|
408
|
+
sent += count
|
|
409
|
+
except BlockingIOError:
|
|
410
|
+
continue
|
|
411
|
+
except OSError as exc:
|
|
412
|
+
write_error = exc
|
|
413
|
+
if sent == len(data) or write_error is not None:
|
|
414
|
+
selector.modify(self.stream, selectors.EVENT_READ)
|
|
415
|
+
|
|
416
|
+
def close(self):
|
|
417
|
+
self.stream.close()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def pipe_name(sid, canonical_directory):
|
|
421
|
+
digest = hashlib.sha256((sid + "\0" + canonical_directory).encode("utf-8")).hexdigest()[:32]
|
|
422
|
+
return "\\\\.\\pipe\\devlyn-control-" + digest
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def rust_windows_path(path):
|
|
426
|
+
"""Rust util/windows.rs:876-901 strips ONLY safe verbatim disk prefixes."""
|
|
427
|
+
if re.match(r"^\\\\\?\\[A-Za-z]:\\", path):
|
|
428
|
+
names = path[7:].split("\\")
|
|
429
|
+
reserved = re.compile(r"^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)", re.I | re.ASCII)
|
|
430
|
+
if all(not reserved.match(name) and not name.endswith((".", " "))
|
|
431
|
+
and not any(c in '<>:"|?*' or ord(c) < 32 or 127 <= ord(c) <= 159 for c in name)
|
|
432
|
+
for name in names if name):
|
|
433
|
+
return path[4:]
|
|
434
|
+
return path
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class Overlapped(ctypes.Structure):
|
|
438
|
+
_fields_ = [("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
|
|
439
|
+
("Offset", ctypes.c_uint32), ("OffsetHigh", ctypes.c_uint32), ("hEvent", ctypes.c_void_p)]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class WindowsAPI:
|
|
443
|
+
"""Typed Win32 boundary; constructed only on native Windows."""
|
|
444
|
+
|
|
445
|
+
def __init__(self):
|
|
446
|
+
self.kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
447
|
+
self.advapi = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
448
|
+
self.cleanup_diagnostics = []
|
|
449
|
+
H, D, B, P, W = ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int32, ctypes.c_void_p, ctypes.c_wchar_p
|
|
450
|
+
self._bind(self.kernel, "CreateFileW", H, [W, D, D, P, D, D, H])
|
|
451
|
+
self._bind(self.kernel, "CloseHandle", B, [H])
|
|
452
|
+
self._bind(self.kernel, "DuplicateHandle", B, [H, H, H, ctypes.POINTER(H), D, B, D])
|
|
453
|
+
self._bind(self.kernel, "GetCurrentProcess", H, [])
|
|
454
|
+
self._bind(self.kernel, "OpenProcess", H, [D, B, D])
|
|
455
|
+
self._bind(self.kernel, "GetProcessTimes", B, [H, P, P, P, P])
|
|
456
|
+
self._bind(self.kernel, "GetNamedPipeServerProcessId", B, [H, ctypes.POINTER(D)])
|
|
457
|
+
self._bind(self.kernel, "GetFinalPathNameByHandleW", D, [H, W, D, D])
|
|
458
|
+
self._bind(self.kernel, "GetFileInformationByHandleEx", B, [H, ctypes.c_int, P, D])
|
|
459
|
+
self._bind(self.kernel, "LocalFree", H, [H])
|
|
460
|
+
self._bind(self.kernel, "WaitNamedPipeW", B, [W, D])
|
|
461
|
+
self._bind(self.kernel, "CreateEventW", H, [P, B, B, W])
|
|
462
|
+
self._bind(self.kernel, "WaitForSingleObject", D, [H, D])
|
|
463
|
+
self._bind(self.kernel, "ReadFile", B, [H, P, D, ctypes.POINTER(D), ctypes.POINTER(Overlapped)])
|
|
464
|
+
self._bind(self.kernel, "WriteFile", B, [H, P, D, ctypes.POINTER(D), ctypes.POINTER(Overlapped)])
|
|
465
|
+
self._bind(self.kernel, "GetOverlappedResult", B, [H, ctypes.POINTER(Overlapped), ctypes.POINTER(D), B])
|
|
466
|
+
self._bind(self.kernel, "CancelIoEx", B, [H, ctypes.POINTER(Overlapped)])
|
|
467
|
+
self._bind(self.advapi, "OpenProcessToken", B, [H, D, ctypes.POINTER(H)])
|
|
468
|
+
self._bind(self.advapi, "GetTokenInformation", B, [H, ctypes.c_int, P, D, ctypes.POINTER(D)])
|
|
469
|
+
self._bind(self.advapi, "ConvertSidToStringSidW", B, [P, ctypes.POINTER(H)])
|
|
470
|
+
|
|
471
|
+
@staticmethod
|
|
472
|
+
def _bind(library, name, result, args):
|
|
473
|
+
function = getattr(library, name)
|
|
474
|
+
function.restype, function.argtypes = result, args
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def check(value):
|
|
478
|
+
if not value:
|
|
479
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
480
|
+
return value
|
|
481
|
+
|
|
482
|
+
def close(self, handle):
|
|
483
|
+
self.check(self.kernel.CloseHandle(handle))
|
|
484
|
+
|
|
485
|
+
def sid(self, process=None):
|
|
486
|
+
token = ctypes.c_void_p()
|
|
487
|
+
self.check(self.advapi.OpenProcessToken(process or self.kernel.GetCurrentProcess(), 8, ctypes.byref(token)))
|
|
488
|
+
try:
|
|
489
|
+
size = ctypes.c_uint32()
|
|
490
|
+
self.advapi.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
|
|
491
|
+
require(0 < size.value <= 65536, "invalid token information size")
|
|
492
|
+
buffer = (ctypes.c_uint64 * ((size.value + 7)//8))()
|
|
493
|
+
self.check(self.advapi.GetTokenInformation(token, 1, buffer, size, ctypes.byref(size)))
|
|
494
|
+
sid_pointer = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_void_p))[0]
|
|
495
|
+
text = ctypes.c_void_p()
|
|
496
|
+
self.check(self.advapi.ConvertSidToStringSidW(sid_pointer, ctypes.byref(text)))
|
|
497
|
+
try:
|
|
498
|
+
return ctypes.wstring_at(text)
|
|
499
|
+
finally:
|
|
500
|
+
self.kernel.LocalFree(text)
|
|
501
|
+
finally:
|
|
502
|
+
self.close(token)
|
|
503
|
+
|
|
504
|
+
def directory(self, path):
|
|
505
|
+
handle = self.kernel.CreateFileW(str(path), 0, 7, None, 3, 0x02000000, None)
|
|
506
|
+
require(handle not in (None, ctypes.c_void_p(-1).value), "cannot open control directory")
|
|
507
|
+
try:
|
|
508
|
+
buffer = ctypes.create_unicode_buffer(32768)
|
|
509
|
+
count = self.check(self.kernel.GetFinalPathNameByHandleW(handle, buffer, len(buffer), 0))
|
|
510
|
+
require(count < len(buffer), "canonical directory path exceeds buffer")
|
|
511
|
+
# FileIdInfo: volume serial + 128-bit file ID. Never coerce ID to float.
|
|
512
|
+
identity = ctypes.create_string_buffer(24)
|
|
513
|
+
self.check(self.kernel.GetFileInformationByHandleEx(handle, 18, identity, 24))
|
|
514
|
+
volume = struct.unpack_from("=Q", identity.raw)[0]
|
|
515
|
+
file_id = identity.raw[8:24].hex()
|
|
516
|
+
return rust_windows_path(buffer.value), (str(volume), file_id)
|
|
517
|
+
finally:
|
|
518
|
+
self.close(handle)
|
|
519
|
+
|
|
520
|
+
def server(self, pipe):
|
|
521
|
+
pid = ctypes.c_uint32()
|
|
522
|
+
self.check(self.kernel.GetNamedPipeServerProcessId(pipe, ctypes.byref(pid)))
|
|
523
|
+
process = self.check(self.kernel.OpenProcess(0x1000 | 0x100000, False, pid))
|
|
524
|
+
try:
|
|
525
|
+
times = [ctypes.c_uint64() for _ in range(4)]
|
|
526
|
+
self.check(self.kernel.GetProcessTimes(process, *(ctypes.byref(t) for t in times)))
|
|
527
|
+
require(self.kernel.WaitForSingleObject(process, 0) == 258, "server process exited")
|
|
528
|
+
return pid.value, self.sid(process), f"windows:{times[0].value}"
|
|
529
|
+
finally:
|
|
530
|
+
self.close(process)
|
|
531
|
+
|
|
532
|
+
def open_pipe(self, name, deadline):
|
|
533
|
+
while True:
|
|
534
|
+
remaining(deadline)
|
|
535
|
+
# FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS
|
|
536
|
+
handle = self.kernel.CreateFileW(name, 0xC0000000, 0, None, 3, 0x40000000 | 0x00100000, None)
|
|
537
|
+
if handle not in (None, ctypes.c_void_p(-1).value):
|
|
538
|
+
return handle
|
|
539
|
+
error = ctypes.get_last_error()
|
|
540
|
+
if error != 231: # ERROR_PIPE_BUSY only; absent/access-denied never downgrades.
|
|
541
|
+
raise ctypes.WinError(error)
|
|
542
|
+
wait = max(1, min(50, int(remaining(deadline)*1000)))
|
|
543
|
+
if not self.kernel.WaitNamedPipeW(name, wait) and ctypes.get_last_error() not in (121, 231):
|
|
544
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
545
|
+
|
|
546
|
+
def start_io(self, handle, data=None):
|
|
547
|
+
event = self.check(self.kernel.CreateEventW(None, True, False, None))
|
|
548
|
+
overlap = Overlapped(hEvent=event)
|
|
549
|
+
buffer = ctypes.create_string_buffer(data, len(data)) if data is not None else ctypes.create_string_buffer(65536)
|
|
550
|
+
operation = [overlap, buffer, event, False]
|
|
551
|
+
function = self.kernel.WriteFile if data is not None else self.kernel.ReadFile
|
|
552
|
+
if not function(handle, buffer, len(buffer), None, ctypes.byref(overlap)):
|
|
553
|
+
error = ctypes.get_last_error()
|
|
554
|
+
if error != 997: # ERROR_IO_PENDING
|
|
555
|
+
self.close(event)
|
|
556
|
+
raise ctypes.WinError(error)
|
|
557
|
+
return operation
|
|
558
|
+
|
|
559
|
+
def poll_io(self, handle, operation):
|
|
560
|
+
count = ctypes.c_uint32()
|
|
561
|
+
if not self.kernel.GetOverlappedResult(handle, ctypes.byref(operation[0]), ctypes.byref(count), False):
|
|
562
|
+
error = ctypes.get_last_error()
|
|
563
|
+
if error == 996: # ERROR_IO_INCOMPLETE
|
|
564
|
+
return None
|
|
565
|
+
if error in (995, 109, 232, 234):
|
|
566
|
+
operation[3] = True
|
|
567
|
+
raise ctypes.WinError(error)
|
|
568
|
+
operation[3] = True
|
|
569
|
+
return count.value, operation[1].raw[:count.value]
|
|
570
|
+
|
|
571
|
+
def _cancel_completed(self, handle, operation, wait):
|
|
572
|
+
count = ctypes.c_uint32()
|
|
573
|
+
success = self.kernel.GetOverlappedResult(handle, ctypes.byref(operation[0]), ctypes.byref(count), wait)
|
|
574
|
+
if success or ctypes.get_last_error() in (995, 109, 232):
|
|
575
|
+
operation[3] = True
|
|
576
|
+
return operation[3]
|
|
577
|
+
|
|
578
|
+
def _drain_cancelled(self, handle, operation, close_handle=True):
|
|
579
|
+
# Unexpected errors are not positive completion. Keep all native state
|
|
580
|
+
# alive and retry; never free a buffer the kernel may still reference.
|
|
581
|
+
while not operation[3]:
|
|
582
|
+
try:
|
|
583
|
+
if not self._cancel_completed(handle, operation, True):
|
|
584
|
+
time.sleep(0.01)
|
|
585
|
+
except OSError as exc:
|
|
586
|
+
self.cleanup_diagnostics.append(str(exc))
|
|
587
|
+
time.sleep(0.01)
|
|
588
|
+
try:
|
|
589
|
+
self.close(operation[2])
|
|
590
|
+
finally:
|
|
591
|
+
if close_handle:
|
|
592
|
+
self.close(handle)
|
|
593
|
+
|
|
594
|
+
def _retain_cancelled(self, handle, operation):
|
|
595
|
+
duplicate = ctypes.c_void_p()
|
|
596
|
+
try:
|
|
597
|
+
process = self.kernel.GetCurrentProcess()
|
|
598
|
+
self.check(self.kernel.DuplicateHandle(process, handle, process, ctypes.byref(duplicate), 0, False, 2))
|
|
599
|
+
except BaseException:
|
|
600
|
+
# No duplicate exists to own a background wait. The caller must
|
|
601
|
+
# retain the original handle and synchronously await completion.
|
|
602
|
+
self._drain_cancelled(handle, operation, False)
|
|
603
|
+
raise
|
|
604
|
+
cleanup = None
|
|
605
|
+
try:
|
|
606
|
+
lock = threading.Lock()
|
|
607
|
+
reclaimed = False
|
|
608
|
+
def cleanup():
|
|
609
|
+
nonlocal reclaimed
|
|
610
|
+
# start() may launch a worker and then be interrupted. Both
|
|
611
|
+
# paths share one owner of the event and duplicated handle.
|
|
612
|
+
with lock:
|
|
613
|
+
if not reclaimed:
|
|
614
|
+
try:
|
|
615
|
+
self._drain_cancelled(duplicate, operation)
|
|
616
|
+
finally:
|
|
617
|
+
reclaimed = operation[3]
|
|
618
|
+
worker = threading.Thread(target=cleanup, daemon=True)
|
|
619
|
+
worker.start()
|
|
620
|
+
except BaseException:
|
|
621
|
+
if cleanup is None:
|
|
622
|
+
self._drain_cancelled(duplicate, operation)
|
|
623
|
+
else:
|
|
624
|
+
cleanup()
|
|
625
|
+
raise
|
|
626
|
+
self.cleanup_diagnostics.append('cancellation completion pending; native I/O resources retained until completion')
|
|
627
|
+
|
|
628
|
+
def finish_io(self, handle, operation, deadline):
|
|
629
|
+
try:
|
|
630
|
+
if not operation[3]:
|
|
631
|
+
if not self.kernel.CancelIoEx(handle, ctypes.byref(operation[0])):
|
|
632
|
+
require(ctypes.get_last_error() == 1168, 'CancelIoEx failed')
|
|
633
|
+
wait_ms = max(0, int((deadline-time.monotonic())*1000))
|
|
634
|
+
result = self.kernel.WaitForSingleObject(operation[2], wait_ms)
|
|
635
|
+
require(result in (0, 258), 'cancellation wait failed')
|
|
636
|
+
if result == 0:
|
|
637
|
+
self._cancel_completed(handle, operation, False)
|
|
638
|
+
finally:
|
|
639
|
+
if operation[3]:
|
|
640
|
+
self.close(operation[2])
|
|
641
|
+
else:
|
|
642
|
+
self._retain_cancelled(handle, operation)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class WindowsStream:
|
|
646
|
+
def __init__(self, directory, deadline, *, api=None):
|
|
647
|
+
self.api = api or WindowsAPI()
|
|
648
|
+
self.directory, self.directory_identity = self.api.directory(directory)
|
|
649
|
+
self.sid = self.api.sid()
|
|
650
|
+
self.handle = self.api.open_pipe(pipe_name(self.sid, self.directory), deadline)
|
|
651
|
+
try:
|
|
652
|
+
self.identity = self.authenticate()
|
|
653
|
+
except BaseException:
|
|
654
|
+
self.close()
|
|
655
|
+
raise
|
|
656
|
+
|
|
657
|
+
def authenticate(self):
|
|
658
|
+
require(self.api.directory(self.directory) == (self.directory, self.directory_identity), "control directory changed")
|
|
659
|
+
first = self.api.server(self.handle)
|
|
660
|
+
require(first[1] == self.sid and first == self.api.server(self.handle), "foreign or reused pipe server")
|
|
661
|
+
return first
|
|
662
|
+
|
|
663
|
+
def exchange(self, data, deadline):
|
|
664
|
+
require(self.authenticate() == self.identity, "pipe server changed before first byte")
|
|
665
|
+
reply, sent, write_error = bytearray(), 0, None
|
|
666
|
+
with contextlib.ExitStack() as pending:
|
|
667
|
+
read = self.api.start_io(self.handle)
|
|
668
|
+
read_cleanup = pending.enter_context(contextlib.ExitStack())
|
|
669
|
+
read_cleanup.callback(self.api.finish_io, self.handle, read, deadline)
|
|
670
|
+
write_cleanup = pending.enter_context(contextlib.ExitStack())
|
|
671
|
+
write = None
|
|
672
|
+
while True:
|
|
673
|
+
remaining(deadline)
|
|
674
|
+
try:
|
|
675
|
+
result = self.api.poll_io(self.handle, read)
|
|
676
|
+
except OSError as exc:
|
|
677
|
+
raise StorageRefusal("storage_unavailable", f"pipe reply failed: {exc}; write={write_error}") from exc
|
|
678
|
+
if result is not None:
|
|
679
|
+
count, chunk = result
|
|
680
|
+
require(count, "pipe reply truncated")
|
|
681
|
+
reply.extend(chunk)
|
|
682
|
+
require(len(reply) <= MAX_REPLY, "oversized reply")
|
|
683
|
+
if b"\n" in reply:
|
|
684
|
+
return bytes(reply)
|
|
685
|
+
read_cleanup.close()
|
|
686
|
+
read = self.api.start_io(self.handle)
|
|
687
|
+
read_cleanup.callback(self.api.finish_io, self.handle, read, deadline)
|
|
688
|
+
if sent < len(data) and write_error is None:
|
|
689
|
+
try:
|
|
690
|
+
if write is None:
|
|
691
|
+
write = self.api.start_io(self.handle, data[sent:])
|
|
692
|
+
write_cleanup.callback(self.api.finish_io, self.handle, write, deadline)
|
|
693
|
+
result = self.api.poll_io(self.handle, write)
|
|
694
|
+
if result is not None:
|
|
695
|
+
require(result[0] > 0, "pipe zero-byte write")
|
|
696
|
+
sent += result[0]
|
|
697
|
+
write_cleanup.close()
|
|
698
|
+
write = None
|
|
699
|
+
except OSError as exc:
|
|
700
|
+
write_error = exc # Continue reading an early busy reply.
|
|
701
|
+
time.sleep(min(0.005, remaining(deadline)))
|
|
702
|
+
|
|
703
|
+
def close(self):
|
|
704
|
+
self.api.close(self.handle)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def connect(directory, deadline):
|
|
708
|
+
return WindowsStream(directory, deadline) if os.name == "nt" else UnixStream(directory, deadline)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class Client:
|
|
712
|
+
def __init__(self, control_directory, *, required_capabilities=("reserve",)):
|
|
713
|
+
self.directory = control_directory
|
|
714
|
+
self.required = frozenset(required_capabilities)
|
|
715
|
+
|
|
716
|
+
def _exchange(self, data, expected, deadline, server=None):
|
|
717
|
+
with contextlib.closing(connect(self.directory, deadline)) as stream:
|
|
718
|
+
require(server is None or stream.identity == server, "server changed after hello")
|
|
719
|
+
value = decode_reply(stream.exchange(data, deadline), expected)
|
|
720
|
+
return value, stream.identity
|
|
721
|
+
|
|
722
|
+
def call(self, request):
|
|
723
|
+
data = encode_request(request) # Freeze validated bytes before retry.
|
|
724
|
+
expected = REQUESTS[request["op"]][1]
|
|
725
|
+
busy_deadline, backoff = time.monotonic()+BUSY_SECONDS, 0.01
|
|
726
|
+
try:
|
|
727
|
+
while True:
|
|
728
|
+
try:
|
|
729
|
+
# A new server incarnation always gets fresh hello. Never
|
|
730
|
+
# send an operation to a successor without renegotiation.
|
|
731
|
+
hello, server = self._exchange(encode_request({"op": "hello"}), "hello", time.monotonic()+EXCHANGE_SECONDS)
|
|
732
|
+
required = self.required | ({"leaf_leases"} if request.get("leaf") is True else set())
|
|
733
|
+
require(hello["storage_protocol"] == 1 and required <= set(hello["capabilities"]),
|
|
734
|
+
"incompatible protocol or missing capabilities", "storage_unsupported")
|
|
735
|
+
if request["op"] == "hello":
|
|
736
|
+
return hello
|
|
737
|
+
return self._exchange(data, expected, time.monotonic()+EXCHANGE_SECONDS, server)[0]
|
|
738
|
+
except StorageRefusal as exc:
|
|
739
|
+
if exc.code != "storage_busy" or time.monotonic() >= busy_deadline:
|
|
740
|
+
raise
|
|
741
|
+
time.sleep(max(0.0, min(backoff, busy_deadline-time.monotonic())))
|
|
742
|
+
backoff = min(backoff*2, 0.2)
|
|
743
|
+
require(time.monotonic() < busy_deadline, "busy retry window exhausted", "storage_busy")
|
|
744
|
+
except (OSError, ValueError, IndexError, struct.error) as exc:
|
|
745
|
+
raise StorageRefusal("storage_unavailable", str(exc), operation=request["op"], phase="exchange") from exc
|
|
746
|
+
except StorageRefusal as exc:
|
|
747
|
+
exc.operation, exc.phase = request["op"], "exchange"
|
|
748
|
+
raise
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _evidence(path):
|
|
752
|
+
"""Only ENOENT is absence; reject redirected evidence below the canonical root."""
|
|
753
|
+
try:
|
|
754
|
+
info = path.lstat()
|
|
755
|
+
except FileNotFoundError:
|
|
756
|
+
return None
|
|
757
|
+
require(not stat.S_ISLNK(info.st_mode) and not getattr(info, "st_file_attributes", 0) & 0x400,
|
|
758
|
+
f"redirected enrollment evidence: {path}")
|
|
759
|
+
return info
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _same_file(left, right, handles=False):
|
|
763
|
+
def identity(info):
|
|
764
|
+
if os.name == 'nt' and not handles:
|
|
765
|
+
# Windows path stat synthesizes executable bits from the extension, and its
|
|
766
|
+
# st_ctime is the creation time while handle stat reports the change time
|
|
767
|
+
# (CPython 3.12, Windows Server 2025). Neither compares across the two;
|
|
768
|
+
# two stats of one handle (`handles`) compare every field.
|
|
769
|
+
return (info.st_dev, info.st_ino, stat.S_IFMT(info.st_mode), info.st_size, info.st_mtime_ns)
|
|
770
|
+
return (info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
771
|
+
return right is not None and identity(left) == identity(right)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def managed_receipt(receipt):
|
|
775
|
+
"""Rust obligations::Discriminators, including future managed custody states."""
|
|
776
|
+
require(type(receipt) is dict, "invalid saved receipt")
|
|
777
|
+
for name in ("mode", "allocation", "allocation_state"):
|
|
778
|
+
require(receipt.get(name) is None or type(receipt[name]) is str, "invalid receipt discriminator")
|
|
779
|
+
return receipt.get("mode") == "managed" or any(
|
|
780
|
+
(receipt.get(name) or "").startswith("managed") for name in ("allocation", "allocation_state"))
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def operation_id():
|
|
784
|
+
# RFC 9562 UUIDv7, also on Python versions without uuid.uuid7.
|
|
785
|
+
bits = (int(time.time()*1000) << 80) | (int.from_bytes(os.urandom(10), "big") & ((1 << 80)-1))
|
|
786
|
+
return str(uuid.UUID(int=(bits & ~(15 << 76) & ~(3 << 62)) | (7 << 76) | (2 << 62)))
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def canonical_operation(value, *, now=None):
|
|
790
|
+
try:
|
|
791
|
+
parsed = uuid.UUID(value)
|
|
792
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
793
|
+
raise StorageRefusal("storage_invalid", "operation ID must be canonical UUIDv7") from exc
|
|
794
|
+
require(parsed.version == 7 and str(parsed) == value, "operation ID must be canonical UUIDv7", "storage_invalid")
|
|
795
|
+
if now is not None:
|
|
796
|
+
seconds = (parsed.int >> 80)//1000
|
|
797
|
+
require(now-604800 <= seconds <= now+300, "operation ID outside replay window", "storage_expired")
|
|
798
|
+
return parsed
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def persistent_identity(path):
|
|
802
|
+
"""Same persistent identity as Rust, without boot-dependent device spelling."""
|
|
803
|
+
path = Path(path)
|
|
804
|
+
info = _evidence(path)
|
|
805
|
+
require(info is not None, "identity path absent")
|
|
806
|
+
if os.name == "nt":
|
|
807
|
+
api = WindowsAPI()
|
|
808
|
+
handle = api.kernel.CreateFileW(str(path), 0, 7, None, 3, 0x02200000, None)
|
|
809
|
+
require(handle not in (None, ctypes.c_void_p(-1).value), "identity open failed")
|
|
810
|
+
try:
|
|
811
|
+
identity = ctypes.create_string_buffer(24)
|
|
812
|
+
api.check(api.kernel.GetFileInformationByHandleEx(handle, 18, identity, 24))
|
|
813
|
+
return {"volume": str(struct.unpack_from("=Q", identity.raw)[0]),
|
|
814
|
+
"file": str(int.from_bytes(identity.raw[8:24], "little"))}
|
|
815
|
+
finally:
|
|
816
|
+
api.close(handle)
|
|
817
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
818
|
+
try:
|
|
819
|
+
require(_same_file(info, os.fstat(fd)), "identity changed during open")
|
|
820
|
+
volume = str(info.st_dev)
|
|
821
|
+
if sys.platform == "darwin":
|
|
822
|
+
class AttrList(ctypes.Structure):
|
|
823
|
+
_fields_ = [("count", ctypes.c_uint16), ("reserved", ctypes.c_uint16),
|
|
824
|
+
("common", ctypes.c_uint32), ("volume", ctypes.c_uint32),
|
|
825
|
+
("directory", ctypes.c_uint32), ("file", ctypes.c_uint32), ("fork", ctypes.c_uint32)]
|
|
826
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
827
|
+
mount = ctypes.create_string_buffer(2168) # Darwin statfs64, SDK sys/mount.h.
|
|
828
|
+
if libc.fstatfs64(fd, mount) != 0:
|
|
829
|
+
raise OSError(ctypes.get_errno(), "mount observation failed")
|
|
830
|
+
require(not struct.unpack_from("=I", mount.raw, 64)[0] & 0x40000000,
|
|
831
|
+
"snapshot volume identity unsupported", "storage_unsupported")
|
|
832
|
+
asked = AttrList(5, 0, 0x80000000, 0x80040000, 0, 0, 0)
|
|
833
|
+
answer = ctypes.create_string_buffer(40)
|
|
834
|
+
if libc.fgetattrlist(fd, ctypes.byref(asked), answer, 40, 0) != 0:
|
|
835
|
+
raise OSError(ctypes.get_errno(), "volume UUID observation failed")
|
|
836
|
+
require(struct.unpack("=6I", answer.raw[:24]) == (40, 0x80000000, 0x40000, 0, 0, 0)
|
|
837
|
+
and any(answer.raw[24:]), "volume UUID unavailable")
|
|
838
|
+
volume = str(uuid.UUID(bytes=answer.raw[24:]))
|
|
839
|
+
return {"volume": volume, "file": str(info.st_ino)}
|
|
840
|
+
finally:
|
|
841
|
+
os.close(fd)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def regular_digest(path):
|
|
845
|
+
path = Path(path)
|
|
846
|
+
before = _evidence(path)
|
|
847
|
+
require(before is not None and stat.S_ISREG(before.st_mode), "retained artifact is not a regular file")
|
|
848
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0))
|
|
849
|
+
with os.fdopen(fd, "rb") as stream:
|
|
850
|
+
opened = os.fstat(stream.fileno())
|
|
851
|
+
require(_same_file(before, opened), "retained artifact changed")
|
|
852
|
+
digest = hashlib.sha256()
|
|
853
|
+
for chunk in iter(lambda: stream.read(1024*1024), b""):
|
|
854
|
+
digest.update(chunk)
|
|
855
|
+
require(_same_file(opened, os.fstat(stream.fileno()), handles=True) and _same_file(before, _evidence(path)),
|
|
856
|
+
"retained artifact changed while hashing")
|
|
857
|
+
return digest.hexdigest()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def package_digest(root):
|
|
861
|
+
digest = hashlib.sha256()
|
|
862
|
+
root = Path(root)
|
|
863
|
+
def visit(directory):
|
|
864
|
+
directory_info = _evidence(directory)
|
|
865
|
+
require(directory_info is not None and stat.S_ISDIR(directory_info.st_mode), "invalid retained package directory")
|
|
866
|
+
for path in sorted(directory.iterdir(), key=lambda p: p.name.encode("utf-8")):
|
|
867
|
+
info = _evidence(path)
|
|
868
|
+
require(info is not None, "retained package entry disappeared")
|
|
869
|
+
name = str(path.relative_to(root)).encode()
|
|
870
|
+
digest.update(struct.pack("<Q", len(name)))
|
|
871
|
+
digest.update(name)
|
|
872
|
+
if stat.S_ISDIR(info.st_mode):
|
|
873
|
+
digest.update(b"directory")
|
|
874
|
+
visit(path)
|
|
875
|
+
else:
|
|
876
|
+
require(stat.S_ISREG(info.st_mode), "special retained package entry")
|
|
877
|
+
digest.update(b"file")
|
|
878
|
+
digest.update(regular_digest(path).encode())
|
|
879
|
+
visit(root)
|
|
880
|
+
return digest.hexdigest()
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
ARTIFACT_SHAPE = {"path": "str", "identity": "identity", "sha256": "str"}
|
|
884
|
+
ENROLLMENT_SHAPE = {"schema": "u32", "control_directory": "str", "control_identity": "identity",
|
|
885
|
+
"helper_path": "str", "helper_release": {"version": "str", "target": "str", "sha256": "str"},
|
|
886
|
+
"recovery_release": {"package": "str", "version": "str", "digest": "str", "root": "str"},
|
|
887
|
+
"retained_helper": ARTIFACT_SHAPE, "retained_recovery": ARTIFACT_SHAPE}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def verify_artifact(artifact, *, package=False):
|
|
891
|
+
path = Path(artifact['path'])
|
|
892
|
+
require(path.is_absolute() and str(path.resolve(strict=True)) == artifact['path'], 'noncanonical retained path')
|
|
893
|
+
require(persistent_identity(artifact['path']) == artifact['identity'], 'retained identity changed')
|
|
894
|
+
digest = package_digest(artifact['path']) if package else regular_digest(artifact['path'])
|
|
895
|
+
require(digest == artifact['sha256'], 'retained digest changed')
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def verify_registration(record):
|
|
899
|
+
try:
|
|
900
|
+
return _verify_registration(record)
|
|
901
|
+
except OSError as exc:
|
|
902
|
+
raise StorageRefusal('storage_unavailable', f'registration inspection failed: {exc}', phase='enrollment') from exc
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _verify_registration(record):
|
|
906
|
+
validate(record, ENROLLMENT_SHAPE, exact=True)
|
|
907
|
+
helper, recovery = record["helper_release"], record["recovery_release"]
|
|
908
|
+
require(record["schema"] == 1 and re.fullmatch(
|
|
909
|
+
r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", helper["version"])
|
|
910
|
+
and 0 < len(helper["target"]) <= 128 and recovery["package"] == "devlyn-cli"
|
|
911
|
+
and recovery["version"] and recovery["digest"], "unsupported or incomplete enrollment", "storage_unsupported")
|
|
912
|
+
for value in (record['control_directory'], record['helper_path']):
|
|
913
|
+
path = Path(value)
|
|
914
|
+
require(path.is_absolute() and str(path.resolve(strict=True)) == value, "noncanonical enrollment path")
|
|
915
|
+
require(persistent_identity(record["control_directory"]) == record["control_identity"], "control identity changed")
|
|
916
|
+
require(regular_digest(record["helper_path"]) == helper["sha256"], "helper digest changed")
|
|
917
|
+
verify_artifact(record['retained_helper'])
|
|
918
|
+
verify_artifact(record['retained_recovery'], package=True)
|
|
919
|
+
require(record["retained_helper"]["sha256"] == helper["sha256"]
|
|
920
|
+
and record["retained_recovery"]["path"] == recovery["root"], "retained release binding changed")
|
|
921
|
+
return record
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def read_evidence_json(path, observed):
|
|
925
|
+
info = _evidence(path)
|
|
926
|
+
require(info is not None and stat.S_ISREG(info.st_mode), f'missing/invalid {path}')
|
|
927
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
|
|
928
|
+
| getattr(os, 'O_BINARY', 0))
|
|
929
|
+
with os.fdopen(fd, 'rb') as stream:
|
|
930
|
+
require(_same_file(info, os.fstat(stream.fileno())), f'{path} changed while opening')
|
|
931
|
+
raw = stream.read(MAX_REQUEST + 1)
|
|
932
|
+
require(len(raw) <= MAX_REQUEST, f'oversized {path}')
|
|
933
|
+
observed.append((path, info))
|
|
934
|
+
return loads(raw)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def recovery_registration():
|
|
938
|
+
"""Current retained pins survive replacement of the app and unenrollment."""
|
|
939
|
+
try:
|
|
940
|
+
home = user_home()
|
|
941
|
+
require(home is not None, 'recovery home unavailable')
|
|
942
|
+
root = (home / '.devlyn').resolve(strict=True)
|
|
943
|
+
marker = root / 'storage-client-recovery'
|
|
944
|
+
info = _evidence(marker)
|
|
945
|
+
require(info is not None and stat.S_ISDIR(info.st_mode), 'recovery pins directory missing/invalid')
|
|
946
|
+
observed = [(marker, info)]
|
|
947
|
+
pins = read_evidence_json(marker / 'pins.json', observed)
|
|
948
|
+
validate(pins, {'schema': 'u32', 'enrollment': ENROLLMENT_SHAPE,
|
|
949
|
+
'runtime': ARTIFACT_SHAPE, 'node': 'str'}, exact=True)
|
|
950
|
+
record = pins['enrollment']
|
|
951
|
+
node, runtime = Path(pins['node']), Path(pins['runtime']['path'])
|
|
952
|
+
require(pins['schema'] == record['schema'] == 1
|
|
953
|
+
and record['recovery_release']['root'] == record['retained_recovery']['path']
|
|
954
|
+
and node.is_absolute() and str(node.resolve(strict=True)) == pins['node']
|
|
955
|
+
and node.is_relative_to(runtime), 'invalid recovery pin paths')
|
|
956
|
+
verify_artifact(record['retained_helper'])
|
|
957
|
+
verify_artifact(record['retained_recovery'], package=True)
|
|
958
|
+
verify_artifact(pins['runtime'], package=True)
|
|
959
|
+
for path, before in observed:
|
|
960
|
+
require(_same_file(before, _evidence(path)), f'recovery pins changed: {path}')
|
|
961
|
+
return record
|
|
962
|
+
except (StorageRefusal, OSError, ValueError, RuntimeError) as exc:
|
|
963
|
+
detail = exc.diagnostic if isinstance(exc, StorageRefusal) else str(exc)
|
|
964
|
+
raise StorageRefusal('storage_unavailable', f'recovery pins invalid: {detail}', phase='enrollment',
|
|
965
|
+
message='Retain the workspace and receipt; repair Devlyn recovery pins and retry') from exc
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def windows_profile():
|
|
969
|
+
"""The known-folder fallback used by Rust dirs::home_dir on Windows."""
|
|
970
|
+
import uuid
|
|
971
|
+
class GUID(ctypes.Structure):
|
|
972
|
+
_fields_ = [('bytes', ctypes.c_ubyte * 16)]
|
|
973
|
+
profile = GUID((ctypes.c_ubyte * 16).from_buffer_copy(
|
|
974
|
+
uuid.UUID('5e6c858f-0e22-4760-9afe-ea3317b67173').bytes_le))
|
|
975
|
+
shell = ctypes.WinDLL('shell32', use_last_error=True)
|
|
976
|
+
ole = ctypes.WinDLL('ole32', use_last_error=True)
|
|
977
|
+
shell.SHGetKnownFolderPath.argtypes = [ctypes.POINTER(GUID), ctypes.c_uint32, ctypes.c_void_p,
|
|
978
|
+
ctypes.POINTER(ctypes.c_wchar_p)]
|
|
979
|
+
shell.SHGetKnownFolderPath.restype = ctypes.c_long
|
|
980
|
+
ole.CoTaskMemFree.argtypes = [ctypes.c_void_p]
|
|
981
|
+
ole.CoTaskMemFree.restype = None
|
|
982
|
+
value = ctypes.c_wchar_p()
|
|
983
|
+
try:
|
|
984
|
+
result = shell.SHGetKnownFolderPath(ctypes.byref(profile), 0, None, ctypes.byref(value))
|
|
985
|
+
require(result == 0 and bool(value.value), 'Windows profile unavailable; repair the user profile')
|
|
986
|
+
return Path(value.value)
|
|
987
|
+
finally:
|
|
988
|
+
if value:
|
|
989
|
+
ole.CoTaskMemFree(ctypes.cast(value, ctypes.c_void_p))
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def user_home():
|
|
993
|
+
"""Mirror devlynd util::home_dir; HOMEDRIVE/HOMEPATH are not authority."""
|
|
994
|
+
try:
|
|
995
|
+
if os.name == 'nt':
|
|
996
|
+
profile = os.environ.get('USERPROFILE')
|
|
997
|
+
return Path(profile) if profile else windows_profile()
|
|
998
|
+
home = os.environ.get('HOME')
|
|
999
|
+
if home:
|
|
1000
|
+
return Path(home)
|
|
1001
|
+
import pwd
|
|
1002
|
+
try:
|
|
1003
|
+
home = pwd.getpwuid(os.getuid()).pw_dir
|
|
1004
|
+
except KeyError:
|
|
1005
|
+
return None
|
|
1006
|
+
return Path(home) if home else None
|
|
1007
|
+
except (OSError, ValueError, RuntimeError, ImportError, AttributeError) as exc:
|
|
1008
|
+
raise StorageRefusal('storage_unavailable', f'home resolution failed; repair the user profile: {exc}',
|
|
1009
|
+
phase='enrollment') from exc
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def enrollment(*, receipt=None, verify_registration=verify_registration):
|
|
1013
|
+
"""Three-state read: absent/standalone, enrolled/managed, or invalid/refusal.
|
|
1014
|
+
|
|
1015
|
+
Canonical lifecycle root and retained artifact checks mirror Rust evidence.rs.
|
|
1016
|
+
Registry contents never participate. Receipt custody cannot downgrade.
|
|
1017
|
+
"""
|
|
1018
|
+
try:
|
|
1019
|
+
# A positively absent home has no record. Invalid/inaccessible evidence
|
|
1020
|
+
# still fails below; receipt custody must never downgrade.
|
|
1021
|
+
home = user_home()
|
|
1022
|
+
if home is None:
|
|
1023
|
+
require(not managed_receipt(receipt or {}), 'managed receipt has no home; restore the user profile')
|
|
1024
|
+
return {'mode': 'standalone', 'evidence': []}
|
|
1025
|
+
home = home.resolve(strict=False)
|
|
1026
|
+
# Rust canonical_root resolves the lifecycle root at the user boundary.
|
|
1027
|
+
named_root = home / ".devlyn"
|
|
1028
|
+
try:
|
|
1029
|
+
named_root.lstat()
|
|
1030
|
+
except FileNotFoundError:
|
|
1031
|
+
root = named_root
|
|
1032
|
+
else:
|
|
1033
|
+
root = named_root.resolve(strict=True)
|
|
1034
|
+
evidence = []
|
|
1035
|
+
observations = []
|
|
1036
|
+
|
|
1037
|
+
def probe(path):
|
|
1038
|
+
info = _evidence(path)
|
|
1039
|
+
observations.append((path, info))
|
|
1040
|
+
return info
|
|
1041
|
+
|
|
1042
|
+
def finish(result):
|
|
1043
|
+
for path, before in observations:
|
|
1044
|
+
after = _evidence(path)
|
|
1045
|
+
require(after is None if before is None else _same_file(before, after),
|
|
1046
|
+
f"enrollment evidence changed during inspection: {path}")
|
|
1047
|
+
return result
|
|
1048
|
+
|
|
1049
|
+
managed = False
|
|
1050
|
+
if receipt is not None:
|
|
1051
|
+
require(type(receipt) is dict, "invalid saved receipt")
|
|
1052
|
+
managed = managed_receipt(receipt)
|
|
1053
|
+
if managed:
|
|
1054
|
+
evidence.append("managed_receipt")
|
|
1055
|
+
expectation = root / "storage-client.json"
|
|
1056
|
+
info = probe(expectation)
|
|
1057
|
+
if info is not None:
|
|
1058
|
+
evidence.append("storage-client.json")
|
|
1059
|
+
require(stat.S_ISREG(info.st_mode), "expectation is not a regular file")
|
|
1060
|
+
fd = os.open(expectation, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
1061
|
+
| getattr(os, "O_BINARY", 0))
|
|
1062
|
+
with os.fdopen(fd, "rb") as stream:
|
|
1063
|
+
require(_same_file(info, os.fstat(stream.fileno())), "expectation changed while opening")
|
|
1064
|
+
raw = stream.read(MAX_REQUEST+1)
|
|
1065
|
+
require(len(raw) <= MAX_REQUEST, "oversized expectation")
|
|
1066
|
+
record = loads(raw)
|
|
1067
|
+
require(type(record) is dict and type(record.get("schema")) is int, "malformed expectation schema")
|
|
1068
|
+
require(record["schema"] == 1, "unsupported expectation schema", "storage_unsupported")
|
|
1069
|
+
require(verify_registration is not None, "enrollment registration verifier unavailable", "storage_unsupported")
|
|
1070
|
+
registration = verify_registration(record)
|
|
1071
|
+
require(type(registration) is dict and bool(registration), "registration verification failed")
|
|
1072
|
+
return finish({"mode": "managed", "evidence": evidence, "evidence_version": 1,
|
|
1073
|
+
"expectation": str(expectation), "registration": registration})
|
|
1074
|
+
require(not evidence, "Devlyn enrollment evidence exists without a valid helper registration")
|
|
1075
|
+
return finish({"mode": "standalone", "evidence": []})
|
|
1076
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
1077
|
+
raise StorageRefusal("storage_unavailable", f"enrollment inspection failed: {exc}", phase="enrollment") from exc
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def recovery_marker():
|
|
1081
|
+
# Marker existence (including a redirected marker) matches Rust retained().
|
|
1082
|
+
try:
|
|
1083
|
+
home = user_home()
|
|
1084
|
+
if home is None:
|
|
1085
|
+
return False
|
|
1086
|
+
(home / '.devlyn/storage-client-recovery').lstat()
|
|
1087
|
+
return True
|
|
1088
|
+
except FileNotFoundError:
|
|
1089
|
+
return False
|
|
1090
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
1091
|
+
raise StorageRefusal('storage_unavailable', f'recovery marker inspection failed; repair the user profile: {exc}',
|
|
1092
|
+
phase='enrollment') from exc
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def operation_enrollment():
|
|
1096
|
+
"""New operations use enrollment alone; existing receipts keep their mode."""
|
|
1097
|
+
decision = enrollment()
|
|
1098
|
+
if decision['mode'] == 'managed' or not recovery_marker():
|
|
1099
|
+
return decision
|
|
1100
|
+
repair = 'Open Devlyn to repair its storage registration'
|
|
1101
|
+
try:
|
|
1102
|
+
root = (user_home() / '.devlyn').resolve(strict=True)
|
|
1103
|
+
observed = []
|
|
1104
|
+
|
|
1105
|
+
journal = read_evidence_json(root / 'storage-client-unenroll.json', observed)
|
|
1106
|
+
fence = read_evidence_json(root / 'storage-client-fence.json', observed)
|
|
1107
|
+
binding = {'schema': 'u32', 'operation_id': 'str', 'root_identity': 'identity', 'lock_identity': 'identity'}
|
|
1108
|
+
validate(journal, {**binding, 'archives': [{'source': 'str', 'destination': 'str',
|
|
1109
|
+
'identity': 'identity', 'sha256': 'str'}], 'retired': 'bool'})
|
|
1110
|
+
validate(fence, binding)
|
|
1111
|
+
# Journal, Fence and Archive deny unknown fields in Rust; Identity does not.
|
|
1112
|
+
require(set(journal) == set(binding) | {'archives', 'retired'} and set(fence) == set(binding),
|
|
1113
|
+
'unknown retirement field')
|
|
1114
|
+
require(journal['schema'] == fence['schema'] == 1 and journal['retired'] is True,
|
|
1115
|
+
'unenrollment is not complete')
|
|
1116
|
+
operation = journal['operation_id']
|
|
1117
|
+
parsed = uuid.UUID(operation)
|
|
1118
|
+
require(parsed.version == 7 and str(parsed) == operation and fence['operation_id'] == operation,
|
|
1119
|
+
'retirement operation ID differs')
|
|
1120
|
+
stale = False
|
|
1121
|
+
for key, path in (('root_identity', root), ('lock_identity', root / 'storage-client.lock')):
|
|
1122
|
+
info = _evidence(path)
|
|
1123
|
+
require(info is not None and (stat.S_ISDIR(info.st_mode) if path == root else stat.S_ISREG(info.st_mode)),
|
|
1124
|
+
'invalid retirement root/lock kind')
|
|
1125
|
+
identities = [{field: record[key][field] for field in ('volume', 'file')} for record in (journal, fence)]
|
|
1126
|
+
require(identities[0] == identities[1], 'retirement journal/fence identity differs')
|
|
1127
|
+
stale |= identities[0] != persistent_identity(path)
|
|
1128
|
+
sources = ('storage', 'control', 'harness/state.json', 'storage-client.json')
|
|
1129
|
+
names = ('storage', 'control', 'harness-state.json', 'enrollment.json')
|
|
1130
|
+
previous = -1
|
|
1131
|
+
archive_root = None
|
|
1132
|
+
for entry in journal['archives']:
|
|
1133
|
+
require(set(entry) == {'source', 'destination', 'identity', 'sha256'}, 'unknown retirement archive field')
|
|
1134
|
+
source = Path(entry['source'])
|
|
1135
|
+
matches = [i for i, name in enumerate(sources) if source.parts[-len(Path(name).parts):] == Path(name).parts]
|
|
1136
|
+
require(source.is_absolute() and '..' not in source.parts and len(matches) == 1, 'invalid retirement source')
|
|
1137
|
+
index = matches[0]
|
|
1138
|
+
origin = source.parents[len(Path(sources[index]).parts) - 1]
|
|
1139
|
+
require(archive_root is None or origin == archive_root, 'retirement archive roots differ')
|
|
1140
|
+
archive_root = origin
|
|
1141
|
+
require(index > previous, 'duplicate or reordered retirement archive')
|
|
1142
|
+
previous = index
|
|
1143
|
+
require(Path(entry['destination']) == origin / 'storage-client-retired' / operation / names[index],
|
|
1144
|
+
'invalid retirement archive path')
|
|
1145
|
+
stale |= origin != root
|
|
1146
|
+
if stale:
|
|
1147
|
+
for name in sources:
|
|
1148
|
+
try:
|
|
1149
|
+
(root / name).lstat()
|
|
1150
|
+
except FileNotFoundError:
|
|
1151
|
+
continue
|
|
1152
|
+
raise StorageRefusal('storage_unavailable', 'stale retirement evidence has live inventory')
|
|
1153
|
+
for path, info in observed:
|
|
1154
|
+
require(_same_file(info, _evidence(path)), f'retirement evidence changed: {path}')
|
|
1155
|
+
require(_evidence(root / 'storage-client.json') is None, 'enrollment appeared during retirement inspection')
|
|
1156
|
+
return decision
|
|
1157
|
+
except (StorageRefusal, OSError, ValueError, RuntimeError) as exc:
|
|
1158
|
+
detail = exc.diagnostic if isinstance(exc, StorageRefusal) else str(exc)
|
|
1159
|
+
raise StorageRefusal('storage_unavailable', f'registration repair required: {detail}', phase='enrollment',
|
|
1160
|
+
message=repair, resume=repair) from exc
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def sync_directory(path):
|
|
1164
|
+
"""Native durability barrier; failure is visible, never treated as success."""
|
|
1165
|
+
path = Path(path)
|
|
1166
|
+
if os.name != 'nt':
|
|
1167
|
+
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
1168
|
+
try:
|
|
1169
|
+
os.fsync(fd)
|
|
1170
|
+
finally:
|
|
1171
|
+
os.close(fd)
|
|
1172
|
+
else:
|
|
1173
|
+
api = WindowsAPI()
|
|
1174
|
+
api._bind(api.kernel, 'FlushFileBuffers', ctypes.c_int, [ctypes.c_void_p])
|
|
1175
|
+
handle = api.kernel.CreateFileW(str(path), 0xC0000000, 3, None, 3, 0x02200000, None)
|
|
1176
|
+
require(handle not in (None, ctypes.c_void_p(-1).value), 'directory durability open failed')
|
|
1177
|
+
try:
|
|
1178
|
+
api.check(api.kernel.FlushFileBuffers(handle))
|
|
1179
|
+
finally:
|
|
1180
|
+
api.close(handle)
|