dosync 0.4.1__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.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
dosync/adapters/wiz.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — WiZ Adapter
|
|
3
|
+
====================
|
|
4
|
+
Adapter para lamparitas Philips WiZ via protocolo UDP local.
|
|
5
|
+
|
|
6
|
+
Características:
|
|
7
|
+
- Comunicación 100% local — sin nube, sin internet requerido
|
|
8
|
+
- Compatible con todas las lamparitas WiZ con WiFi
|
|
9
|
+
- Soporta: encender, apagar, brillo, color RGB, temperatura de color
|
|
10
|
+
- Discovery automático de IPs via broadcast (opcional)
|
|
11
|
+
|
|
12
|
+
Instalación:
|
|
13
|
+
pip install pywizlight
|
|
14
|
+
|
|
15
|
+
Registro de un dispositivo WiZ en DoSync:
|
|
16
|
+
|
|
17
|
+
from dosync.adapters.wiz import WiZAdapter, wiz_manifest
|
|
18
|
+
|
|
19
|
+
# Registrar el adapter en el executor
|
|
20
|
+
executor = AdapterExecutor(hub)
|
|
21
|
+
executor.register(WiZAdapter())
|
|
22
|
+
|
|
23
|
+
# Registrar la lamparita en el hub
|
|
24
|
+
hub.register_device(wiz_manifest(
|
|
25
|
+
device_id="wiz-living-01",
|
|
26
|
+
device_name="Lámpara sala",
|
|
27
|
+
ip="192.168.1.45",
|
|
28
|
+
tags=["light", "living-room", "climate"],
|
|
29
|
+
))
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
import logging
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
from ..adapters import DoSyncAdapter
|
|
37
|
+
from ..models import ActionResult, DeviceAction, Urgency
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("dosync.adapters.wiz")
|
|
40
|
+
|
|
41
|
+
# Optional import — if pywizlight is not installed, the adapter
|
|
42
|
+
# operates in simulated mode with a warning
|
|
43
|
+
try:
|
|
44
|
+
from pywizlight import wizlight, PilotBuilder
|
|
45
|
+
WIZ_AVAILABLE = True
|
|
46
|
+
except ImportError:
|
|
47
|
+
WIZ_AVAILABLE = False
|
|
48
|
+
log.warning(
|
|
49
|
+
"pywizlight not installed — WiZAdapter running in simulated mode. "
|
|
50
|
+
"Install with: pip install pywizlight"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Manifest helper ───────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def wiz_manifest(
|
|
57
|
+
device_id: str,
|
|
58
|
+
device_name: str,
|
|
59
|
+
ip: str,
|
|
60
|
+
tags: Optional[list[str]] = None,
|
|
61
|
+
room: str = "",
|
|
62
|
+
):
|
|
63
|
+
"""
|
|
64
|
+
Genera un CapabilityManifest listo para registrar una lamparita WiZ.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
device_id: identificador único (ej: "wiz-living-01")
|
|
68
|
+
device_name: nombre visible (ej: "Lámpara sala")
|
|
69
|
+
ip: IP de la lamparita en la red local (ej: "192.168.1.45")
|
|
70
|
+
tags: tags adicionales (se agregan a ["light", "wiz"])
|
|
71
|
+
room: habitación (se agrega como tag si se provee)
|
|
72
|
+
"""
|
|
73
|
+
from ..models import (
|
|
74
|
+
ActuatorSpec, CapabilityManifest, CertTier, DeviceCategory,
|
|
75
|
+
EventSpec, SensorSpec, Urgency,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Tag vocabulary: only canonical role tags here. Vendor names ("wiz") and
|
|
79
|
+
# imprecise tags ("climate") are non-portable and were removed per
|
|
80
|
+
# TAG-VOCABULARY.md. The caller adds emergency/energy/location via `tags`.
|
|
81
|
+
base_tags = ["light"]
|
|
82
|
+
if tags:
|
|
83
|
+
base_tags.extend(tags)
|
|
84
|
+
if room:
|
|
85
|
+
base_tags.append(room)
|
|
86
|
+
|
|
87
|
+
# Guardamos la IP en adapter_config para que el AdapterExecutor la encuentre
|
|
88
|
+
manifest = CapabilityManifest(
|
|
89
|
+
device_id=device_id,
|
|
90
|
+
device_name=device_name,
|
|
91
|
+
manufacturer="Philips WiZ",
|
|
92
|
+
model="WiZ WiFi Bulb",
|
|
93
|
+
firmware="auto",
|
|
94
|
+
category=DeviceCategory.ACTUATOR,
|
|
95
|
+
tags=list(set(base_tags)),
|
|
96
|
+
sensors=[
|
|
97
|
+
# kind="device_state": these describe the LAMP, not the room. Real
|
|
98
|
+
# telemetry, truthfully declared — but "read the environment" should
|
|
99
|
+
# not sweep them (SENSOR-KIND, 2026-07-17).
|
|
100
|
+
SensorSpec("brightness", "integer", "Brillo actual", unit="%",
|
|
101
|
+
kind="device_state"),
|
|
102
|
+
SensorSpec("state", "boolean", "Encendida/apagada",
|
|
103
|
+
kind="device_state"),
|
|
104
|
+
],
|
|
105
|
+
actuators=[
|
|
106
|
+
ActuatorSpec("turn_on", "turn_on", "Encender"),
|
|
107
|
+
ActuatorSpec("turn_off", "turn_off", "Apagar"),
|
|
108
|
+
ActuatorSpec("set_brightness", "set_brightness", "Brillo 0-100%",
|
|
109
|
+
{"type": "object",
|
|
110
|
+
"properties": {"brightness": {"type": "integer", "minimum": 0, "maximum": 100}},
|
|
111
|
+
"required": ["brightness"]}),
|
|
112
|
+
ActuatorSpec("set_color", "set_color", "Color RGB",
|
|
113
|
+
{"type": "object",
|
|
114
|
+
"properties": {"r": {"type": "integer", "minimum": 0, "maximum": 255},
|
|
115
|
+
"g": {"type": "integer", "minimum": 0, "maximum": 255},
|
|
116
|
+
"b": {"type": "integer", "minimum": 0, "maximum": 255}},
|
|
117
|
+
"required": ["r", "g", "b"]}),
|
|
118
|
+
ActuatorSpec("set_color_temp", "set_color_temp", "Temperatura de color",
|
|
119
|
+
{"type": "object",
|
|
120
|
+
"properties": {"kelvin": {"type": "integer", "minimum": 2200, "maximum": 6500}},
|
|
121
|
+
"required": ["kelvin"]}),
|
|
122
|
+
ActuatorSpec("set_scene", "set_scene", "Escena WiZ predefinida",
|
|
123
|
+
{"type": "object",
|
|
124
|
+
"properties": {"scene_id": {"type": "integer", "minimum": 1, "maximum": 32}},
|
|
125
|
+
"required": ["scene_id"]}),
|
|
126
|
+
],
|
|
127
|
+
events=[],
|
|
128
|
+
emergency_capable=True, # puede usarse en emergencias (luces al max)
|
|
129
|
+
cert_tier=CertTier.STANDARD,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Adjuntar config del adapter directamente al manifest
|
|
133
|
+
manifest.adapter = "wiz"
|
|
134
|
+
manifest.adapter_config = {"ip": ip, "port": 38899}
|
|
135
|
+
|
|
136
|
+
return manifest
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── WiZ Adapter ───────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
class WiZAdapter(DoSyncAdapter):
|
|
142
|
+
"""
|
|
143
|
+
DoSync adapter for Philips WiZ smart bulbs.
|
|
144
|
+
|
|
145
|
+
All communication is direct UDP on the local network.
|
|
146
|
+
No WiZ account or internet connection required.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def adapter_name(self) -> str:
|
|
151
|
+
return "wiz"
|
|
152
|
+
|
|
153
|
+
def _get_ip(self, action: DeviceAction) -> Optional[str]:
|
|
154
|
+
"""Obtiene la IP del dispositivo desde el registry del hub."""
|
|
155
|
+
# The device IP is stored in adapter_config of the manifest
|
|
156
|
+
from .. import models # avoid circular import
|
|
157
|
+
return None # se resuelve en execute via action.params o manifest
|
|
158
|
+
|
|
159
|
+
def __init__(self, hub=None):
|
|
160
|
+
"""
|
|
161
|
+
Args:
|
|
162
|
+
hub: referencia al DoSyncHub para leer adapter_config del manifest.
|
|
163
|
+
Opcional — si no se pasa, la IP debe venir en action.params.
|
|
164
|
+
"""
|
|
165
|
+
self._hub = hub
|
|
166
|
+
|
|
167
|
+
async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
|
|
168
|
+
"""Translate a DoSync action into a WiZ UDP command."""
|
|
169
|
+
|
|
170
|
+
# Priority: action params override adapter_config from the manifest
|
|
171
|
+
ip = action.params.get("ip")
|
|
172
|
+
|
|
173
|
+
if not ip and self._hub:
|
|
174
|
+
device = self._hub.registry.get(action.device_id)
|
|
175
|
+
if device and device.adapter_config:
|
|
176
|
+
ip = device.adapter_config.get("ip")
|
|
177
|
+
|
|
178
|
+
if not ip:
|
|
179
|
+
return ActionResult(
|
|
180
|
+
device_id=action.device_id,
|
|
181
|
+
action=action.action,
|
|
182
|
+
success=False,
|
|
183
|
+
error="WiZ IP not found. Use wiz_manifest(ip=...) or pass ip in params.",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if not WIZ_AVAILABLE:
|
|
187
|
+
# Modo simulado — pywizlight no instalado
|
|
188
|
+
log.info(
|
|
189
|
+
"[SIMULATED] WiZ %s @ %s: %s %s",
|
|
190
|
+
action.device_id, ip, action.action, action.params,
|
|
191
|
+
)
|
|
192
|
+
return ActionResult(
|
|
193
|
+
device_id=action.device_id,
|
|
194
|
+
action=action.action,
|
|
195
|
+
success=True,
|
|
196
|
+
response={"status": "simulated", "ip": ip, "action": action.action},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
bulb = wizlight(ip)
|
|
201
|
+
pilot = await self._build_pilot(action, urgency)
|
|
202
|
+
|
|
203
|
+
if action.action == "turn_off" or pilot is None:
|
|
204
|
+
await bulb.turn_off()
|
|
205
|
+
response = {"status": "off", "ip": ip}
|
|
206
|
+
else:
|
|
207
|
+
await bulb.turn_on(pilot)
|
|
208
|
+
response = {
|
|
209
|
+
"status": "on",
|
|
210
|
+
"ip": ip,
|
|
211
|
+
"action": action.action,
|
|
212
|
+
"params": action.params,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await bulb.async_close()
|
|
216
|
+
|
|
217
|
+
log.info(
|
|
218
|
+
"WiZ %s @ %s: %s → OK",
|
|
219
|
+
action.device_id, ip, action.action,
|
|
220
|
+
)
|
|
221
|
+
return ActionResult(
|
|
222
|
+
device_id=action.device_id,
|
|
223
|
+
action=action.action,
|
|
224
|
+
success=True,
|
|
225
|
+
response=response,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
import asyncio as _asyncio
|
|
230
|
+
if isinstance(e, _asyncio.TimeoutError):
|
|
231
|
+
log.warning("WiZ timeout %s @ %s — marking unreachable",
|
|
232
|
+
action.device_id, ip)
|
|
233
|
+
if self._hub and hasattr(self._hub, 'resolver'):
|
|
234
|
+
if hasattr(self._hub.resolver, 'mark_unreachable'):
|
|
235
|
+
self._hub.resolver.mark_unreachable(action.device_id)
|
|
236
|
+
return ActionResult(
|
|
237
|
+
device_id=action.device_id,
|
|
238
|
+
action=action.action,
|
|
239
|
+
success=False,
|
|
240
|
+
error="WiZ timeout — device unreachable",
|
|
241
|
+
)
|
|
242
|
+
log.error("WiZ error %s @ %s: %s", action.device_id, ip, e)
|
|
243
|
+
return ActionResult(
|
|
244
|
+
device_id=action.device_id,
|
|
245
|
+
action=action.action,
|
|
246
|
+
success=False,
|
|
247
|
+
error=str(e),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def get_state(self, device_id: str) -> dict | None:
|
|
251
|
+
"""
|
|
252
|
+
Query current WiZ bulb state via UDP updateState.
|
|
253
|
+
Returns {"on": bool, "brightness": int} or None on failure.
|
|
254
|
+
Timeout: 3 seconds.
|
|
255
|
+
"""
|
|
256
|
+
ip = None
|
|
257
|
+
if self._hub:
|
|
258
|
+
device = self._hub.registry.get(device_id)
|
|
259
|
+
if device and device.adapter_config:
|
|
260
|
+
ip = device.adapter_config.get("ip")
|
|
261
|
+
if not ip:
|
|
262
|
+
return None
|
|
263
|
+
if not WIZ_AVAILABLE:
|
|
264
|
+
return None
|
|
265
|
+
try:
|
|
266
|
+
import asyncio as _asyncio
|
|
267
|
+
bulb = wizlight(ip)
|
|
268
|
+
pilot = await _asyncio.wait_for(bulb.updateState(), timeout=3.0)
|
|
269
|
+
await bulb.async_close()
|
|
270
|
+
if pilot:
|
|
271
|
+
pr = pilot.pilotResult
|
|
272
|
+
return {
|
|
273
|
+
"on": pr.get("state", False),
|
|
274
|
+
"brightness": pr.get("dimming", 0),
|
|
275
|
+
"r": pr.get("r"),
|
|
276
|
+
"g": pr.get("g"),
|
|
277
|
+
"b": pr.get("b"),
|
|
278
|
+
"temp": pr.get("temp"),
|
|
279
|
+
}
|
|
280
|
+
return None
|
|
281
|
+
except Exception as e:
|
|
282
|
+
log.debug("WiZ get_state %s @ %s: %s", device_id, ip, e)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
async def _build_pilot(self, action: DeviceAction, urgency: Urgency) -> "PilotBuilder":
|
|
286
|
+
"""Build a pywizlight PilotBuilder for the given DoSync action."""
|
|
287
|
+
params = action.params
|
|
288
|
+
|
|
289
|
+
# Emergency: always maximum brightness, cool white
|
|
290
|
+
if urgency == Urgency.EMERGENCY:
|
|
291
|
+
return PilotBuilder(brightness=255, colortemp=6500)
|
|
292
|
+
|
|
293
|
+
if action.action == "turn_on":
|
|
294
|
+
brightness = params.get("brightness", 100)
|
|
295
|
+
return PilotBuilder(brightness=self._pct_to_wiz(brightness))
|
|
296
|
+
|
|
297
|
+
if action.action == "set_brightness":
|
|
298
|
+
pct = params.get("brightness", 100)
|
|
299
|
+
# Si viene 0 es apagar
|
|
300
|
+
if pct == 0:
|
|
301
|
+
return None # se maneja como turn_off en execute()
|
|
302
|
+
return PilotBuilder(brightness=self._pct_to_wiz(pct))
|
|
303
|
+
|
|
304
|
+
if action.action == "set_color":
|
|
305
|
+
r = params.get("r", 255)
|
|
306
|
+
g = params.get("g", 255)
|
|
307
|
+
b = params.get("b", 255)
|
|
308
|
+
return PilotBuilder(rgb=(r, g, b))
|
|
309
|
+
|
|
310
|
+
if action.action == "set_color_temp":
|
|
311
|
+
kelvin = params.get("kelvin", 4000)
|
|
312
|
+
return PilotBuilder(colortemp=kelvin)
|
|
313
|
+
|
|
314
|
+
if action.action == "set_scene":
|
|
315
|
+
scene_id = params.get("scene_id", 1)
|
|
316
|
+
return PilotBuilder(scene=scene_id)
|
|
317
|
+
|
|
318
|
+
# Default: encender con brillo al 80%
|
|
319
|
+
return PilotBuilder(brightness=204)
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _pct_to_wiz(pct: int) -> int:
|
|
323
|
+
"""Convierte porcentaje DoSync (0-100) a valor WiZ (0-255)."""
|
|
324
|
+
return max(0, min(255, round(pct * 255 / 100)))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ── Escenas WiZ predefinidas (referencia) ────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
WIZ_SCENES = {
|
|
330
|
+
1: "Ocean",
|
|
331
|
+
2: "Romance",
|
|
332
|
+
3: "Sunset",
|
|
333
|
+
4: "Party",
|
|
334
|
+
5: "Fireplace",
|
|
335
|
+
6: "Cozy",
|
|
336
|
+
9: "Cool white",
|
|
337
|
+
10: "Night light",
|
|
338
|
+
11: "Focus",
|
|
339
|
+
12: "Relax",
|
|
340
|
+
13: "True colors",
|
|
341
|
+
14: "TV time",
|
|
342
|
+
15: "Plantgrowth",
|
|
343
|
+
16: "Spring",
|
|
344
|
+
17: "Summer",
|
|
345
|
+
18: "Fall",
|
|
346
|
+
19: "Deepdive",
|
|
347
|
+
20: "Jungle",
|
|
348
|
+
21: "Mojito",
|
|
349
|
+
22: "Club",
|
|
350
|
+
23: "Christmas",
|
|
351
|
+
24: "Halloween",
|
|
352
|
+
25: "Candlelight",
|
|
353
|
+
26: "Golden white",
|
|
354
|
+
27: "Pulse",
|
|
355
|
+
28: "Steampunk",
|
|
356
|
+
29: "Rhythm", # sincroniza con música
|
|
357
|
+
}
|
dosync/audit_backup.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — audit log backup / restore / verify (REL-2).
|
|
3
|
+
=====================================================
|
|
4
|
+
|
|
5
|
+
The audit log is the system's most valuable asset: the tamper-evident record
|
|
6
|
+
that backs every "who decided what, and can you prove it" claim. Before REL-2
|
|
7
|
+
the only durability was the live SQLite file plus the ad-hoc export that
|
|
8
|
+
`db audit-reset` writes on its way to wiping a broken chain. This module gives
|
|
9
|
+
the audit log a first-class, standalone backup/restore/verify path.
|
|
10
|
+
|
|
11
|
+
Design:
|
|
12
|
+
* A backup is a self-describing JSON file: the full ordered entry list plus a
|
|
13
|
+
manifest (count, first/last timestamps, a SHA-256 over the canonical entry
|
|
14
|
+
payload, and whether the chain verified AT BACKUP TIME). The manifest lets a
|
|
15
|
+
restore — or an external auditor — detect tampering of the backup file
|
|
16
|
+
itself, independently of the internal per-entry hash chain.
|
|
17
|
+
* verify() re-runs the exact same SHA-256 chain check the live AuditLog uses
|
|
18
|
+
(imported, not reimplemented — one source of truth for what "valid" means).
|
|
19
|
+
* restore() refuses to overwrite a non-empty audit log unless forced, and
|
|
20
|
+
re-verifies the chain of what it loaded. A restore that produced a broken
|
|
21
|
+
chain would be worse than no restore.
|
|
22
|
+
|
|
23
|
+
Nothing here bypasses the chain: restored entries keep their original hashes and
|
|
24
|
+
prev_hash links, so a restored log verifies exactly as the original did.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import time
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
BACKUP_FORMAT_VERSION = 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _canonical(entries: list[dict]) -> str:
|
|
39
|
+
"""Canonical JSON of the entry list, for a stable file-level checksum."""
|
|
40
|
+
return json.dumps(entries, sort_keys=True, separators=(",", ":"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
GENESIS = "0" * 64
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_entries(entries: list[dict], anchor_prev_hash: str = GENESIS) -> bool:
|
|
47
|
+
"""Re-run the live AuditLog SHA-256 chain check over a list of entries.
|
|
48
|
+
|
|
49
|
+
Imported from hub to guarantee identical semantics; falls back to an inline
|
|
50
|
+
equivalent only if the import shape ever changes (kept byte-for-byte the
|
|
51
|
+
same as AuditLog.verify).
|
|
52
|
+
|
|
53
|
+
AUDIT-ARCHIVE (2026-07-19): a chain no longer necessarily starts at the
|
|
54
|
+
genesis hash. When older entries have been archived to a segment file, the
|
|
55
|
+
live chain's first entry links to the LAST ARCHIVED entry — that hash is
|
|
56
|
+
the anchor, and verification starts from it. Genesis is just the anchor of
|
|
57
|
+
a chain that has never been archived.
|
|
58
|
+
"""
|
|
59
|
+
prev = anchor_prev_hash
|
|
60
|
+
for entry in entries:
|
|
61
|
+
entry = dict(entry)
|
|
62
|
+
stored_hash = entry.pop("hash", None)
|
|
63
|
+
if stored_hash is None:
|
|
64
|
+
return False
|
|
65
|
+
raw = json.dumps(entry, sort_keys=True)
|
|
66
|
+
calc = hashlib.sha256(raw.encode()).hexdigest()
|
|
67
|
+
if calc != stored_hash or entry.get("prev_hash") != prev:
|
|
68
|
+
return False
|
|
69
|
+
prev = stored_hash
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_backup(entries: list[dict],
|
|
74
|
+
anchor_prev_hash: str = GENESIS) -> dict[str, Any]:
|
|
75
|
+
"""Build the backup document (manifest + entries) for a list of entries.
|
|
76
|
+
|
|
77
|
+
An archived chain does not start at genesis; the backup records the anchor
|
|
78
|
+
it verifies from, so the file stays self-contained: `verify --file` needs
|
|
79
|
+
nothing but the file."""
|
|
80
|
+
canonical = _canonical(entries)
|
|
81
|
+
return {
|
|
82
|
+
"format_version": BACKUP_FORMAT_VERSION,
|
|
83
|
+
"anchor_prev_hash": anchor_prev_hash,
|
|
84
|
+
"manifest": {
|
|
85
|
+
"count": len(entries),
|
|
86
|
+
"first_timestamp": entries[0].get("timestamp") if entries else None,
|
|
87
|
+
"last_timestamp": entries[-1].get("timestamp") if entries else None,
|
|
88
|
+
"payload_sha256": hashlib.sha256(canonical.encode()).hexdigest(),
|
|
89
|
+
"chain_valid_at_backup": verify_entries(entries, anchor_prev_hash),
|
|
90
|
+
"backed_up_at": time.time(),
|
|
91
|
+
},
|
|
92
|
+
"entries": entries,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_backup(entries: list[dict], path: str,
|
|
97
|
+
anchor_prev_hash: str = GENESIS) -> dict[str, Any]:
|
|
98
|
+
"""Serialize a backup to disk. Returns the manifest."""
|
|
99
|
+
doc = build_backup(entries, anchor_prev_hash)
|
|
100
|
+
with open(path, "w") as f:
|
|
101
|
+
json.dump(doc, f, indent=2, sort_keys=True)
|
|
102
|
+
return doc["manifest"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
SEGMENT_FORMAT_VERSION = "dosync-audit-segment/v1"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def write_segment(entries: list[dict], path: str, anchor_prev_hash: str,
|
|
109
|
+
generation: int) -> dict[str, Any]:
|
|
110
|
+
"""Write an ARCHIVE SEGMENT: a slice of the chain moved out of the live DB.
|
|
111
|
+
|
|
112
|
+
The segment is self-describing and independently verifiable: it records the
|
|
113
|
+
anchor it chains FROM (the previous segment's last hash, or genesis for the
|
|
114
|
+
first generation), so `verify_entries(seg["entries"], seg["anchor_prev_hash"])`
|
|
115
|
+
proves its integrity standalone — and consecutive generations interlock:
|
|
116
|
+
segment N+1's anchor MUST equal segment N's last_hash. The full history is
|
|
117
|
+
verifiable end to end by walking the segments in order, then the live DB.
|
|
118
|
+
"""
|
|
119
|
+
if not entries:
|
|
120
|
+
raise ValueError("refusing to write an empty segment")
|
|
121
|
+
canonical = _canonical(entries)
|
|
122
|
+
doc = {
|
|
123
|
+
"format_version": SEGMENT_FORMAT_VERSION,
|
|
124
|
+
"manifest": {
|
|
125
|
+
"generation": generation,
|
|
126
|
+
"anchor_prev_hash": anchor_prev_hash,
|
|
127
|
+
"first_hash": entries[0].get("hash"),
|
|
128
|
+
"last_hash": entries[-1].get("hash"),
|
|
129
|
+
"count": len(entries),
|
|
130
|
+
"first_timestamp": entries[0].get("timestamp"),
|
|
131
|
+
"last_timestamp": entries[-1].get("timestamp"),
|
|
132
|
+
"payload_sha256": hashlib.sha256(canonical.encode()).hexdigest(),
|
|
133
|
+
"archived_at": time.time(),
|
|
134
|
+
},
|
|
135
|
+
"entries": entries,
|
|
136
|
+
}
|
|
137
|
+
with open(path, "w") as f:
|
|
138
|
+
json.dump(doc, f, indent=2, sort_keys=True)
|
|
139
|
+
return doc["manifest"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def read_segment(path: str) -> dict[str, Any]:
|
|
143
|
+
"""Load and integrity-check an archive segment file."""
|
|
144
|
+
with open(path) as f:
|
|
145
|
+
doc = json.load(f)
|
|
146
|
+
if doc.get("format_version") != SEGMENT_FORMAT_VERSION:
|
|
147
|
+
raise ValueError(f"Not an audit segment file: {doc.get('format_version')}")
|
|
148
|
+
entries = doc.get("entries", [])
|
|
149
|
+
expected = doc["manifest"]["payload_sha256"]
|
|
150
|
+
actual = hashlib.sha256(_canonical(entries).encode()).hexdigest()
|
|
151
|
+
if actual != expected:
|
|
152
|
+
raise ValueError("Segment payload checksum mismatch — the file was altered after writing")
|
|
153
|
+
return doc
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def file_sha256(path: str) -> str:
|
|
157
|
+
"""SHA-256 of the file bytes as written — the fingerprint the live chain's
|
|
158
|
+
`audit_archived` entry binds, so the segment cannot be silently swapped."""
|
|
159
|
+
h = hashlib.sha256()
|
|
160
|
+
with open(path, "rb") as f:
|
|
161
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
162
|
+
h.update(chunk)
|
|
163
|
+
return h.hexdigest()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def read_backup(path: str) -> dict[str, Any]:
|
|
167
|
+
"""Load and integrity-check a backup file.
|
|
168
|
+
|
|
169
|
+
Raises ValueError if the file-level checksum does not match its entries
|
|
170
|
+
(i.e. the backup file was altered after it was written).
|
|
171
|
+
"""
|
|
172
|
+
with open(path) as f:
|
|
173
|
+
doc = json.load(f)
|
|
174
|
+
if doc.get("format_version") != BACKUP_FORMAT_VERSION:
|
|
175
|
+
raise ValueError(f"Unsupported backup format_version: {doc.get('format_version')}")
|
|
176
|
+
entries = doc.get("entries", [])
|
|
177
|
+
expected = doc["manifest"]["payload_sha256"]
|
|
178
|
+
actual = hashlib.sha256(_canonical(entries).encode()).hexdigest()
|
|
179
|
+
if actual != expected:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"Backup file checksum mismatch — the backup has been altered since it "
|
|
182
|
+
f"was written (manifest={expected[:16]}…, actual={actual[:16]}…)."
|
|
183
|
+
)
|
|
184
|
+
return doc
|