hsync-common 1.0.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.
- hsync_common/__init__.py +9 -0
- hsync_common/clock.py +479 -0
- hsync_common/compression.py +405 -0
- hsync_common/constants.py +317 -0
- hsync_common/delta/__init__.py +44 -0
- hsync_common/delta/decision.py +85 -0
- hsync_common/delta/rolling_checksum.py +380 -0
- hsync_common/endpoint.py +264 -0
- hsync_common/env.py +51 -0
- hsync_common/exceptions.py +57 -0
- hsync_common/format.py +95 -0
- hsync_common/hashing.py +124 -0
- hsync_common/ignore.py +255 -0
- hsync_common/labels.py +289 -0
- hsync_common/logger.py +259 -0
- hsync_common/models/__init__.py +61 -0
- hsync_common/models/_store_base.py +180 -0
- hsync_common/models/_util.py +191 -0
- hsync_common/models/delta.py +312 -0
- hsync_common/models/dir.py +127 -0
- hsync_common/models/event.py +149 -0
- hsync_common/models/file.py +347 -0
- hsync_common/models/folder.py +187 -0
- hsync_common/models/sqlstore.py +474 -0
- hsync_common/models/symlink.py +133 -0
- hsync_common/models/sync_action.py +126 -0
- hsync_common/models/tombstone.py +189 -0
- hsync_common/models/tombstone_store.py +203 -0
- hsync_common/models/version.py +356 -0
- hsync_common/models/version_store.py +146 -0
- hsync_common/path_validation.py +681 -0
- hsync_common/permissions.py +111 -0
- hsync_common/protocol/__init__.py +99 -0
- hsync_common/protocol/bootnode.py +751 -0
- hsync_common/protocol/capabilities.py +215 -0
- hsync_common/protocol/events.py +168 -0
- hsync_common/protocol/fixtures/bootnode-ack.json +7 -0
- hsync_common/protocol/fixtures/bootnode-fingerprint.json +57 -0
- hsync_common/protocol/fixtures/bootnode-query.json +7 -0
- hsync_common/protocol/fixtures/bootnode-register.json +19 -0
- hsync_common/protocol/fixtures/bootnode-response.json +29 -0
- hsync_common/protocol/fixtures/causal-resolution-scenarios.json +274 -0
- hsync_common/protocol/fixtures/causal-tombstone-scenarios.json +152 -0
- hsync_common/protocol/fixtures/delta-apply-metadata.json +1549 -0
- hsync_common/protocol/fixtures/delta-instructions.json +1546 -0
- hsync_common/protocol/fixtures/delta-signatures.json +1800 -0
- hsync_common/protocol/fixtures/ntp-targets-scenarios.json +69 -0
- hsync_common/protocol/fixtures/p2p-bootnode-trust-scenarios.json +123 -0
- hsync_common/protocol/fixtures/p2p-election-scenarios.json +180 -0
- hsync_common/protocol/fixtures/p2p-pairing-scenarios.json +80 -0
- hsync_common/protocol/fixtures/p2p-sources-request.json +9 -0
- hsync_common/protocol/fixtures/p2p-sources-response.json +17 -0
- hsync_common/protocol/fixtures/p2p-txt-record.json +16 -0
- hsync_common/protocol/fixtures/p2p-txt-seeder-record.json +12 -0
- hsync_common/protocol/fixtures/p2p-wss-auth-failed.json +6 -0
- hsync_common/protocol/fixtures/p2p-wss-auth-ok.json +9 -0
- hsync_common/protocol/fixtures/p2p-wss-auth-required.json +8 -0
- hsync_common/protocol/fixtures/p2p-wss-auth.json +10 -0
- hsync_common/protocol/fixtures/p2p-wss-hello-master.json +8 -0
- hsync_common/protocol/fixtures/p2p-wss-hello-slave.json +9 -0
- hsync_common/protocol/fixtures/p2p-wss-inventory-ack-ok.json +6 -0
- hsync_common/protocol/fixtures/p2p-wss-inventory-ack-rejected.json +7 -0
- hsync_common/protocol/fixtures/p2p-wss-inventory-ack-resync.json +6 -0
- hsync_common/protocol/fixtures/p2p-wss-inventory-delta-delete.json +13 -0
- hsync_common/protocol/fixtures/p2p-wss-inventory-delta-upsert.json +14 -0
- hsync_common/protocol/fixtures/p2p-wss-inventory-empty-snapshot.json +8 -0
- hsync_common/protocol/fixtures/p2p-wss-inventory-snapshot.json +14 -0
- hsync_common/protocol/fixtures/ws-auth-error.json +7 -0
- hsync_common/protocol/fixtures/ws-auth-ok.json +9 -0
- hsync_common/protocol/fixtures/ws-auth-request.json +9 -0
- hsync_common/protocol/fixtures/ws-file-event.json +24 -0
- hsync_common/protocol/fixtures/ws-ping.json +3 -0
- hsync_common/protocol/fixtures/ws-pong.json +3 -0
- hsync_common/protocol/fixtures/ws-presence.json +5 -0
- hsync_common/protocol/fixtures.py +778 -0
- hsync_common/protocol/handshake.py +336 -0
- hsync_common/protocol/p2p/sources-schema.json +164 -0
- hsync_common/protocol/p2p/txt-schema.json +99 -0
- hsync_common/protocol/p2p/wss-schema.json +743 -0
- hsync_common/protocol/p2p.py +126 -0
- hsync_common/protocol/pairing.py +214 -0
- hsync_common/protocol/peer_download.py +522 -0
- hsync_common/protocol/rest/openapi.json +2106 -0
- hsync_common/protocol/rest.py +157 -0
- hsync_common/protocol/schemas.py +814 -0
- hsync_common/protocol/tls_trust.py +107 -0
- hsync_common/protocol/ws/ws-schema.json +424 -0
- hsync_common/ratelimit.py +146 -0
- hsync_common/runtime.py +300 -0
- hsync_common/ssl_utils.py +182 -0
- hsync_common/text.py +64 -0
- hsync_common/time_utils.py +154 -0
- hsync_common/upnp.py +462 -0
- hsync_common-1.0.1.dist-info/METADATA +162 -0
- hsync_common-1.0.1.dist-info/RECORD +97 -0
- hsync_common-1.0.1.dist-info/WHEEL +4 -0
- hsync_common-1.0.1.dist-info/licenses/LICENSE +29 -0
hsync_common/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# Copyright (c) 2025 Alberto Basso
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
HSYNC Common - shared models, constants, and compression utilities.
|
|
6
|
+
|
|
7
|
+
This package provides the common data models, constants, and compression
|
|
8
|
+
logic shared between hsync-client and hsync-server.
|
|
9
|
+
"""
|
hsync_common/clock.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# Copyright (c) 2025 Alberto Basso
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Clock-offset measurement utilities shared by every HSYNC project.
|
|
6
|
+
|
|
7
|
+
HSYNC keeps the wall clock out of *causal* decisions (vector clocks, ADR-0022),
|
|
8
|
+
but the tie-break for a true concurrent divergence is chronological: the
|
|
9
|
+
``corrected_mtime`` of each version decides which content keeps the original
|
|
10
|
+
path and which one is preserved as a ``_conflicted_copy_`` (ADR-0023). For that
|
|
11
|
+
timestamp to be comparable across machines the local clock must be corrected by
|
|
12
|
+
the offset measured against a trusted reference:
|
|
13
|
+
|
|
14
|
+
* **Client-server mode** — the server/master is the time authority:
|
|
15
|
+
:func:`measure_clock_offset` performs an NTP-like HTTP round-trip against the
|
|
16
|
+
``/api/v1/time`` endpoint (``compute_clock_offset`` is the shared math).
|
|
17
|
+
|
|
18
|
+
* **p2p / no-server startup** — :func:`measure_clock_offset_ntp_multi`
|
|
19
|
+
probes a configurable list of public SNTP servers (UDP, RFC 4330) in order
|
|
20
|
+
until one answers; the estimate is the median of several round-trips.
|
|
21
|
+
|
|
22
|
+
* **Offline / air-gapped** — every probe fails: the caller records offset 0 and
|
|
23
|
+
logs a degraded-temporal-mode warning; synchronisation must proceed.
|
|
24
|
+
|
|
25
|
+
The measured offset (remote clock − local clock) is process-local and consumed
|
|
26
|
+
through the :class:`ClockOffsetStore` singleton: ``correct_mtime(mtime)``
|
|
27
|
+
returns ``mtime + offset`` (int). Measurements are never persisted and never
|
|
28
|
+
write the system clock. Wire-supplied offsets/timestamps are *clamped* by
|
|
29
|
+
:func:`clamp_wire_corrected_mtime` so a far-future peer value cannot win every
|
|
30
|
+
tie-break (conflict engineering).
|
|
31
|
+
|
|
32
|
+
Consumers: ``hsync-client`` (startup probing, mtime correction on
|
|
33
|
+
self-record/advertising), ``hsync-server`` (clamping of incoming upload
|
|
34
|
+
metadata), ``hsync-android`` (``TimeSync.kt`` mirror) and the shared fixtures.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import math
|
|
38
|
+
import socket
|
|
39
|
+
import struct
|
|
40
|
+
import time
|
|
41
|
+
from statistics import median
|
|
42
|
+
from typing import Protocol
|
|
43
|
+
|
|
44
|
+
from .constants import (
|
|
45
|
+
CORRECTED_MTIME_MAX_FUTURE_SKEW,
|
|
46
|
+
NTP_DEFAULT_HOSTS,
|
|
47
|
+
NTP_PORT,
|
|
48
|
+
NTP_PROBES_PER_HOST,
|
|
49
|
+
NTP_TIMEOUT_SECONDS,
|
|
50
|
+
)
|
|
51
|
+
from .endpoint import parse_host_port
|
|
52
|
+
|
|
53
|
+
# Seconds between NTP epoch (1900-01-01) and Unix epoch (1970-01-01).
|
|
54
|
+
_NTP_EPOCH_DELTA = 2_208_988_800
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"ClockOffsetStore",
|
|
58
|
+
"HttpGet",
|
|
59
|
+
"TimeResponse",
|
|
60
|
+
"clamp_incoming_corrected_mtime",
|
|
61
|
+
"clamp_wire_corrected_mtime",
|
|
62
|
+
"compute_clock_offset",
|
|
63
|
+
"correct_mtime",
|
|
64
|
+
"measure_clock_offset",
|
|
65
|
+
"measure_clock_offset_ntp",
|
|
66
|
+
"measure_clock_offset_ntp_multi",
|
|
67
|
+
"now_corrected",
|
|
68
|
+
"offset_store",
|
|
69
|
+
"parse_ntp_hosts",
|
|
70
|
+
"parse_ntp_targets",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
# Offset math (shared by the HTTP and SNTP probes)
|
|
76
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def compute_clock_offset(t1: float, t2: float, t3: float) -> tuple[float, float]:
|
|
80
|
+
"""
|
|
81
|
+
Compute the NTP-like clock offset and round-trip time.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
t1: Local time immediately before the request.
|
|
85
|
+
t2: Remote time reported by the peer (``/api/v1/time`` or SNTP).
|
|
86
|
+
t3: Local time immediately after the response.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Tuple ``(offset, rtt)`` in seconds. ``offset`` is positive when the
|
|
90
|
+
remote clock is ahead of the local one.
|
|
91
|
+
"""
|
|
92
|
+
rtt = t3 - t1
|
|
93
|
+
offset = t2 - (t1 + rtt / 2)
|
|
94
|
+
return offset, rtt
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
# HTTP probe (server/master time authority, client-server mode)
|
|
99
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TimeResponse(Protocol):
|
|
103
|
+
"""Minimal HTTP response shape required by :func:`measure_clock_offset`."""
|
|
104
|
+
|
|
105
|
+
status_code: int
|
|
106
|
+
|
|
107
|
+
def json(self) -> dict: ...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HttpGet(Protocol):
|
|
111
|
+
"""Callable performing an HTTP GET (e.g. ``requests.get``)."""
|
|
112
|
+
|
|
113
|
+
def __call__(self, url: str, **kwargs) -> TimeResponse: ...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def measure_clock_offset(
|
|
117
|
+
time_url: str,
|
|
118
|
+
*,
|
|
119
|
+
http_get: HttpGet,
|
|
120
|
+
headers: dict | None = None,
|
|
121
|
+
verify: bool = True,
|
|
122
|
+
timeout: float = 30,
|
|
123
|
+
) -> tuple[float, float] | None:
|
|
124
|
+
"""
|
|
125
|
+
Measure the clock offset against a ``/api/v1/time`` endpoint.
|
|
126
|
+
|
|
127
|
+
Performs a single round-trip GET using the injected ``http_get`` callable
|
|
128
|
+
(so this module stays free of any HTTP dependency) and returns the
|
|
129
|
+
estimated offset. Network errors are *not* caught here: the caller decides
|
|
130
|
+
how to handle them (typically a debug log and offset 0).
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
time_url: Full URL of the time endpoint.
|
|
134
|
+
http_get: Callable performing the GET (e.g. ``requests.get``).
|
|
135
|
+
headers: Optional request headers.
|
|
136
|
+
verify: TLS certificate verification flag passed to ``http_get``.
|
|
137
|
+
timeout: Request timeout in seconds.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Tuple ``(offset, rtt)`` in seconds, or ``None`` if the endpoint did
|
|
141
|
+
not return HTTP 200 or the ``timestamp`` field was missing, not a
|
|
142
|
+
finite number and not numeric-string coercible (a malformed payload
|
|
143
|
+
must degrade the measurement, never crash the caller).
|
|
144
|
+
"""
|
|
145
|
+
t1 = time.time()
|
|
146
|
+
response = http_get(time_url, headers=headers or {}, verify=verify, timeout=timeout)
|
|
147
|
+
t3 = time.time()
|
|
148
|
+
|
|
149
|
+
if response.status_code != 200:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
raw_t2 = response.json().get("timestamp")
|
|
153
|
+
if raw_t2 is None or isinstance(raw_t2, bool):
|
|
154
|
+
# Missing key or JSON null/bool: no usable remote timestamp.
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
t2 = float(raw_t2)
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
return None
|
|
160
|
+
if not math.isfinite(t2):
|
|
161
|
+
# json.loads accepts Infinity/NaN: a non-finite timestamp would
|
|
162
|
+
# produce a meaningless offset.
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
return compute_clock_offset(t1, t2, t3)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
# SNTP probe (UDP, RFC 4330 — p2p / no-server startup)
|
|
170
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_ntp_hosts(value: str | None) -> tuple[str, ...]:
|
|
174
|
+
"""Parse the ``HSYNC_NTP_HOSTS`` CSV value into a non-empty raw-entry tuple.
|
|
175
|
+
|
|
176
|
+
Entries are returned **verbatim** (``host``, ``host:port``, ``[IPv6]``,
|
|
177
|
+
``[IPv6]:port`` — no validation here): the canonical, validated form is
|
|
178
|
+
:func:`parse_ntp_targets`. Empty/blank entries are skipped; ``None`` or an
|
|
179
|
+
all-blank value returns the defaults from
|
|
180
|
+
:data:`hsync_common.constants.NTP_DEFAULT_HOSTS`.
|
|
181
|
+
Consumers: ``hsync-client`` (config layer), ``hsync-android`` mirror.
|
|
182
|
+
"""
|
|
183
|
+
if value is None:
|
|
184
|
+
return NTP_DEFAULT_HOSTS
|
|
185
|
+
hosts = tuple(h.strip() for h in value.split(",") if h.strip())
|
|
186
|
+
return hosts or NTP_DEFAULT_HOSTS
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_ntp_targets(value: str | None) -> tuple[tuple[str, int], ...]:
|
|
190
|
+
"""Parse ``HSYNC_NTP_HOSTS`` into ``(host, port)`` targets.
|
|
191
|
+
|
|
192
|
+
Each CSV entry is a ``HOST[:PORT]`` endpoint parsed by the shared
|
|
193
|
+
:func:`hsync_common.endpoint.parse_host_port` SSOT: ``host`` (default SNTP
|
|
194
|
+
port), ``host:port``, ``[IPv6]`` or ``[IPv6]:port``. Malformed entries
|
|
195
|
+
(invalid port, invalid host, URL-like input) are dropped with their host;
|
|
196
|
+
``None``/blank falls back to the default hosts on the standard port.
|
|
197
|
+
|
|
198
|
+
Consumers: ``hsync-client`` (``timesync`` probe), ``hsync-android``
|
|
199
|
+
(``TimeSync.ntpTargets`` mirror) and the shared fixtures.
|
|
200
|
+
"""
|
|
201
|
+
targets: list[tuple[str, int]] = []
|
|
202
|
+
for entry in parse_ntp_hosts(value):
|
|
203
|
+
try:
|
|
204
|
+
endpoint = parse_host_port(entry)
|
|
205
|
+
except ValueError:
|
|
206
|
+
continue
|
|
207
|
+
targets.append((endpoint.host, endpoint.port or NTP_PORT))
|
|
208
|
+
if not targets:
|
|
209
|
+
# Every entry carried a malformed endpoint: fall back to the defaults on
|
|
210
|
+
# the standard port — the same never-empty contract as parse_ntp_hosts.
|
|
211
|
+
return tuple((h, NTP_PORT) for h in NTP_DEFAULT_HOSTS)
|
|
212
|
+
return tuple(targets)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _unix_to_ntp64(unix_epoch: float) -> tuple[int, int]:
|
|
216
|
+
"""Convert Unix epoch seconds to the 64-bit NTP timestamp (whole, fraction)."""
|
|
217
|
+
seconds = unix_epoch + _NTP_EPOCH_DELTA
|
|
218
|
+
whole = int(seconds)
|
|
219
|
+
fraction = int((seconds - whole) * (1 << 32))
|
|
220
|
+
return whole & 0xFFFFFFFF, fraction & 0xFFFFFFFF
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ntp64_to_unix(whole: int, fraction: int) -> float:
|
|
224
|
+
"""Convert a 64-bit NTP timestamp (whole, fraction) to Unix epoch seconds."""
|
|
225
|
+
return (whole + fraction / (1 << 32)) - _NTP_EPOCH_DELTA
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _build_sntp_client_packet() -> bytes:
|
|
229
|
+
"""Build a 48-byte SNTP client request (version 4, client mode)."""
|
|
230
|
+
whole, fraction = _unix_to_ntp64(time.time())
|
|
231
|
+
# LI=0, VN=4, Mode=3 (client) = 0b00_100_011 = 0x23; transmit timestamp at
|
|
232
|
+
# bytes 40-47 lets us match the response and compute the round-trip.
|
|
233
|
+
return b"\x23" + b"\x00" * 39 + struct.pack("!II", whole, fraction)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_sntp_response(packet: bytes, t1: float, t4: float) -> tuple[float, float] | None:
|
|
237
|
+
"""Parse an SNTP server reply; return ``(offset, rtt)`` or ``None``.
|
|
238
|
+
|
|
239
|
+
Rejects truncation, non-server replies (mode != 4) and stratum 0
|
|
240
|
+
(Kiss-o'-Death): any unusable reply degrades the measurement to ``None``.
|
|
241
|
+
"""
|
|
242
|
+
if len(packet) < 48:
|
|
243
|
+
return None
|
|
244
|
+
mode = packet[0] & 0x07
|
|
245
|
+
stratum = packet[1]
|
|
246
|
+
if mode != 4 or stratum == 0:
|
|
247
|
+
return None
|
|
248
|
+
t3_whole, t3_fraction = struct.unpack("!II", packet[40:48])
|
|
249
|
+
t3 = _ntp64_to_unix(t3_whole, t3_fraction)
|
|
250
|
+
if not math.isfinite(t3):
|
|
251
|
+
return None
|
|
252
|
+
return compute_clock_offset(t1, t3, t4)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _sntp_probe_once(host: str, *, port: int, timeout: float) -> tuple[float, float] | None:
|
|
256
|
+
"""Perform one SNTP round-trip to *host*; returns ``(offset, rtt)`` or ``None``.
|
|
257
|
+
|
|
258
|
+
The socket is *connected* before the exchange: the kernel then surfaces an
|
|
259
|
+
ICMP port/host-unreachable as an immediate ``ECONNREFUSED`` instead of
|
|
260
|
+
blocking ``recv`` for the whole timeout, so a dead endpoint fails fast
|
|
261
|
+
(one refused probe costs ~0s, not *timeout* seconds), and datagrams from
|
|
262
|
+
any other source are filtered out (no stray-packet spoofing of the clock).
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
266
|
+
sock.settimeout(timeout)
|
|
267
|
+
sock.connect((host, port))
|
|
268
|
+
packet = _build_sntp_client_packet()
|
|
269
|
+
t1 = time.time()
|
|
270
|
+
sock.send(packet)
|
|
271
|
+
reply = sock.recv(48)
|
|
272
|
+
t4 = time.time()
|
|
273
|
+
except OSError:
|
|
274
|
+
# timeouts, refused/unreachable hosts, DNS failures: caller moves on.
|
|
275
|
+
return None
|
|
276
|
+
return _parse_sntp_response(reply, t1, t4)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def measure_clock_offset_ntp(
|
|
280
|
+
host: str,
|
|
281
|
+
*,
|
|
282
|
+
port: int = NTP_PORT,
|
|
283
|
+
timeout: float = NTP_TIMEOUT_SECONDS,
|
|
284
|
+
probes: int = NTP_PROBES_PER_HOST,
|
|
285
|
+
) -> tuple[float, float] | None:
|
|
286
|
+
"""Measure the clock offset against a single SNTP server.
|
|
287
|
+
|
|
288
|
+
Runs ``probes`` round-trips and returns the **median** offset so a single
|
|
289
|
+
jittery reply cannot bias the estimate. The first successful probe is
|
|
290
|
+
enough only when ``probes`` is 1; otherwise every reply must parse.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Tuple ``(offset, rtt)`` in seconds (``offset`` positive when the
|
|
294
|
+
remote clock is ahead of the local one), or ``None`` when the server
|
|
295
|
+
is unreachable or every reply was unusable.
|
|
296
|
+
"""
|
|
297
|
+
results: list[tuple[float, float]] = []
|
|
298
|
+
for _ in range(max(1, probes)):
|
|
299
|
+
probe = _sntp_probe_once(host, port=port, timeout=timeout)
|
|
300
|
+
if probe is None:
|
|
301
|
+
continue
|
|
302
|
+
results.append(probe)
|
|
303
|
+
if not results:
|
|
304
|
+
return None
|
|
305
|
+
return float(median(r[0] for r in results)), float(median(r[1] for r in results))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def measure_clock_offset_ntp_multi(
|
|
309
|
+
hosts: tuple[str, ...] | tuple[tuple[str, int], ...] = NTP_DEFAULT_HOSTS,
|
|
310
|
+
*,
|
|
311
|
+
port: int = NTP_PORT,
|
|
312
|
+
timeout: float = NTP_TIMEOUT_SECONDS,
|
|
313
|
+
probes: int = NTP_PROBES_PER_HOST,
|
|
314
|
+
) -> tuple[float, float, str] | None:
|
|
315
|
+
"""Probe *hosts* in order until one answers; return ``(offset, rtt, host)``.
|
|
316
|
+
|
|
317
|
+
Each entry may be a bare host (probed on *port*) or a ``(host, port)``
|
|
318
|
+
pair (per-host port override). Returns ``None`` when every host fails
|
|
319
|
+
(offline/air-gapped): the caller records offset 0 and logs degraded
|
|
320
|
+
temporal-mode operation.
|
|
321
|
+
"""
|
|
322
|
+
for entry in hosts:
|
|
323
|
+
if isinstance(entry, tuple):
|
|
324
|
+
host, host_port = entry
|
|
325
|
+
else:
|
|
326
|
+
host, host_port = entry, port
|
|
327
|
+
result = measure_clock_offset_ntp(host, port=host_port, timeout=timeout, probes=probes)
|
|
328
|
+
if result is not None:
|
|
329
|
+
return result[0], result[1], host
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
334
|
+
# Process-local offset store & mtime correction
|
|
335
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class ClockOffsetStore:
|
|
339
|
+
"""Process-local holder of the measured clock offset (remote − local).
|
|
340
|
+
|
|
341
|
+
Measured at startup (or handshake) and consumed through
|
|
342
|
+
:meth:`correct_mtime` every time a local version is self-recorded or
|
|
343
|
+
advertised. :meth:`begin_epoch` / :meth:`end_epoch` freeze the basis for
|
|
344
|
+
one sync cycle so a late background measurement cannot mix clocks
|
|
345
|
+
mid-cycle. The offset is never persisted: a stale value is harmless
|
|
346
|
+
because it is re-measured on every process start.
|
|
347
|
+
|
|
348
|
+
Consumers: ``hsync-client`` (all three transport modes), ``hsync-server``
|
|
349
|
+
(clamping reference), ``hsync-android`` (``TimeSync.kt`` mirror).
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(self) -> None:
|
|
353
|
+
self._offset: float | None = None
|
|
354
|
+
self._epoch_active = False
|
|
355
|
+
self._epoch_offset: float | None = None
|
|
356
|
+
|
|
357
|
+
def record(self, offset: float) -> None:
|
|
358
|
+
"""Store the measured offset."""
|
|
359
|
+
self._offset = float(offset)
|
|
360
|
+
|
|
361
|
+
def begin_epoch(self) -> None:
|
|
362
|
+
"""Freeze the offset used by ``correct_mtime`` until :meth:`end_epoch`.
|
|
363
|
+
|
|
364
|
+
A later :meth:`record` still updates the live measurement (so the next
|
|
365
|
+
epoch sees it), but this epoch keeps a single basis. Consumer:
|
|
366
|
+
``hsync-client`` ``sync()`` — a background LAN NTP probe must not
|
|
367
|
+
change ``corrected_mtime`` mid-cycle.
|
|
368
|
+
"""
|
|
369
|
+
self._epoch_offset = self._offset
|
|
370
|
+
self._epoch_active = True
|
|
371
|
+
|
|
372
|
+
def end_epoch(self) -> None:
|
|
373
|
+
"""Release the frozen epoch so ``correct_mtime`` follows the live offset."""
|
|
374
|
+
self._epoch_active = False
|
|
375
|
+
self._epoch_offset = None
|
|
376
|
+
|
|
377
|
+
def get(self) -> float | None:
|
|
378
|
+
"""Return the stored offset (``None`` before any measurement)."""
|
|
379
|
+
return self._offset
|
|
380
|
+
|
|
381
|
+
def clear(self) -> None:
|
|
382
|
+
"""Forget the stored offset (used by tests and mode switches)."""
|
|
383
|
+
self._offset = None
|
|
384
|
+
self.end_epoch()
|
|
385
|
+
|
|
386
|
+
def correct_mtime(self, mtime: int | float) -> int:
|
|
387
|
+
"""Return ``mtime + offset`` as int (raw ``mtime`` when unresolved)."""
|
|
388
|
+
value = int(mtime) # bool would collapse to 0/1; callers pass mtimes
|
|
389
|
+
offset = self._epoch_offset if self._epoch_active else self._offset
|
|
390
|
+
if offset is not None:
|
|
391
|
+
return value + int(offset)
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
def uncorrect_mtime(self, corrected_mtime: int | float) -> int:
|
|
395
|
+
"""Return ``corrected_mtime - offset`` as int (inverse of correct_mtime).
|
|
396
|
+
|
|
397
|
+
A wire-supplied value is an ADR-0023 *corrected* time; writing it into
|
|
398
|
+
the local filesystem requires the local raw clock, otherwise the next
|
|
399
|
+
:func:`correct_mtime` re-adds the offset and the file stays divergent.
|
|
400
|
+
"""
|
|
401
|
+
value = int(corrected_mtime)
|
|
402
|
+
offset = self._epoch_offset if self._epoch_active else self._offset
|
|
403
|
+
if offset is not None:
|
|
404
|
+
return value - int(offset)
|
|
405
|
+
return value
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# Module-level singleton: one measurement per process, shared by every module
|
|
409
|
+
# that corrects timestamps (mirrored as a singleton in Android's TimeSync.kt).
|
|
410
|
+
offset_store = ClockOffsetStore()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def correct_mtime(mtime: int | float) -> int:
|
|
414
|
+
"""Correct *mtime* by the process offset (identity when unresolved).
|
|
415
|
+
|
|
416
|
+
Consumers: ``hsync-client`` (``sync_state`` self-record, upload/listings
|
|
417
|
+
version metadata), ``hsync-android`` mirror.
|
|
418
|
+
"""
|
|
419
|
+
return offset_store.correct_mtime(mtime)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def uncorrect_mtime(corrected_mtime: int | float) -> int:
|
|
423
|
+
"""Convert a wire-supplied corrected mtime back to the local raw clock.
|
|
424
|
+
|
|
425
|
+
Inverse of :func:`correct_mtime`. Consumers: ``hsync-client`` (adopting a
|
|
426
|
+
peer's corrected mtime onto the local filesystem), ``hsync-client`` LAN
|
|
427
|
+
master PATCH handler, ``hsync-android`` mirror.
|
|
428
|
+
"""
|
|
429
|
+
return offset_store.uncorrect_mtime(corrected_mtime)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def now_corrected() -> float:
|
|
433
|
+
"""Current local time corrected by the process offset (clamping reference).
|
|
434
|
+
|
|
435
|
+
The receiver-side guard of ADR-0023 measures a peer-supplied
|
|
436
|
+
``corrected_mtime`` against this reference: a value farther than
|
|
437
|
+
``CORRECTED_MTIME_MAX_FUTURE_SKEW`` in the future is clamped instead of
|
|
438
|
+
being allowed to win every tie-break.
|
|
439
|
+
|
|
440
|
+
Consumers: ``hsync-server`` (upload/delta-apply arbitration metadata),
|
|
441
|
+
``hsync-client`` (LAN master upload arbitration, remote decode),
|
|
442
|
+
``hsync-android`` (``TimeSync.kt`` mirror).
|
|
443
|
+
"""
|
|
444
|
+
return offset_store.correct_mtime(time.time())
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
448
|
+
# Wire-side clamping (conflict engineering)
|
|
449
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def clamp_wire_corrected_mtime(value: float, now_corrected: float) -> float:
|
|
453
|
+
"""Clamp a peer-supplied ``corrected_mtime`` to a sane upper bound.
|
|
454
|
+
|
|
455
|
+
A far-future value announced by a malicious or wildly mis-set peer would
|
|
456
|
+
win every mtime tie-break; values beyond ``now_corrected +
|
|
457
|
+
CORRECTED_MTIME_MAX_FUTURE_SKEW`` are clamped to exactly that bound (never
|
|
458
|
+
rejected, so the field still participates in comparisons).
|
|
459
|
+
|
|
460
|
+
Consumers: ``hsync-server`` (upload arbitration metadata), ``hsync-client``
|
|
461
|
+
(remote decode), ``hsync-android`` (``TimeSync.kt`` mirror).
|
|
462
|
+
"""
|
|
463
|
+
return min(value, now_corrected + CORRECTED_MTIME_MAX_FUTURE_SKEW)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def clamp_incoming_corrected_mtime(value: float) -> float:
|
|
467
|
+
"""Clamp an incoming corrected mtime against this process's reference.
|
|
468
|
+
|
|
469
|
+
Thin convenience wrapper over :func:`clamp_wire_corrected_mtime` that
|
|
470
|
+
pins the reference to the local corrected clock
|
|
471
|
+
(:func:`now_corrected`): the ADR-0023 receiver-side guard shared by every
|
|
472
|
+
consuming hub (server upload/delta-apply, LAN master upload, client
|
|
473
|
+
remote decode). A value of ``0`` (unknown) passes through unchanged.
|
|
474
|
+
|
|
475
|
+
Consumers: ``hsync-server``, ``hsync-client``, ``hsync-android`` mirror.
|
|
476
|
+
"""
|
|
477
|
+
if value <= 0:
|
|
478
|
+
return 0.0
|
|
479
|
+
return clamp_wire_corrected_mtime(value, now_corrected())
|