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.
Files changed (97) hide show
  1. hsync_common/__init__.py +9 -0
  2. hsync_common/clock.py +479 -0
  3. hsync_common/compression.py +405 -0
  4. hsync_common/constants.py +317 -0
  5. hsync_common/delta/__init__.py +44 -0
  6. hsync_common/delta/decision.py +85 -0
  7. hsync_common/delta/rolling_checksum.py +380 -0
  8. hsync_common/endpoint.py +264 -0
  9. hsync_common/env.py +51 -0
  10. hsync_common/exceptions.py +57 -0
  11. hsync_common/format.py +95 -0
  12. hsync_common/hashing.py +124 -0
  13. hsync_common/ignore.py +255 -0
  14. hsync_common/labels.py +289 -0
  15. hsync_common/logger.py +259 -0
  16. hsync_common/models/__init__.py +61 -0
  17. hsync_common/models/_store_base.py +180 -0
  18. hsync_common/models/_util.py +191 -0
  19. hsync_common/models/delta.py +312 -0
  20. hsync_common/models/dir.py +127 -0
  21. hsync_common/models/event.py +149 -0
  22. hsync_common/models/file.py +347 -0
  23. hsync_common/models/folder.py +187 -0
  24. hsync_common/models/sqlstore.py +474 -0
  25. hsync_common/models/symlink.py +133 -0
  26. hsync_common/models/sync_action.py +126 -0
  27. hsync_common/models/tombstone.py +189 -0
  28. hsync_common/models/tombstone_store.py +203 -0
  29. hsync_common/models/version.py +356 -0
  30. hsync_common/models/version_store.py +146 -0
  31. hsync_common/path_validation.py +681 -0
  32. hsync_common/permissions.py +111 -0
  33. hsync_common/protocol/__init__.py +99 -0
  34. hsync_common/protocol/bootnode.py +751 -0
  35. hsync_common/protocol/capabilities.py +215 -0
  36. hsync_common/protocol/events.py +168 -0
  37. hsync_common/protocol/fixtures/bootnode-ack.json +7 -0
  38. hsync_common/protocol/fixtures/bootnode-fingerprint.json +57 -0
  39. hsync_common/protocol/fixtures/bootnode-query.json +7 -0
  40. hsync_common/protocol/fixtures/bootnode-register.json +19 -0
  41. hsync_common/protocol/fixtures/bootnode-response.json +29 -0
  42. hsync_common/protocol/fixtures/causal-resolution-scenarios.json +274 -0
  43. hsync_common/protocol/fixtures/causal-tombstone-scenarios.json +152 -0
  44. hsync_common/protocol/fixtures/delta-apply-metadata.json +1549 -0
  45. hsync_common/protocol/fixtures/delta-instructions.json +1546 -0
  46. hsync_common/protocol/fixtures/delta-signatures.json +1800 -0
  47. hsync_common/protocol/fixtures/ntp-targets-scenarios.json +69 -0
  48. hsync_common/protocol/fixtures/p2p-bootnode-trust-scenarios.json +123 -0
  49. hsync_common/protocol/fixtures/p2p-election-scenarios.json +180 -0
  50. hsync_common/protocol/fixtures/p2p-pairing-scenarios.json +80 -0
  51. hsync_common/protocol/fixtures/p2p-sources-request.json +9 -0
  52. hsync_common/protocol/fixtures/p2p-sources-response.json +17 -0
  53. hsync_common/protocol/fixtures/p2p-txt-record.json +16 -0
  54. hsync_common/protocol/fixtures/p2p-txt-seeder-record.json +12 -0
  55. hsync_common/protocol/fixtures/p2p-wss-auth-failed.json +6 -0
  56. hsync_common/protocol/fixtures/p2p-wss-auth-ok.json +9 -0
  57. hsync_common/protocol/fixtures/p2p-wss-auth-required.json +8 -0
  58. hsync_common/protocol/fixtures/p2p-wss-auth.json +10 -0
  59. hsync_common/protocol/fixtures/p2p-wss-hello-master.json +8 -0
  60. hsync_common/protocol/fixtures/p2p-wss-hello-slave.json +9 -0
  61. hsync_common/protocol/fixtures/p2p-wss-inventory-ack-ok.json +6 -0
  62. hsync_common/protocol/fixtures/p2p-wss-inventory-ack-rejected.json +7 -0
  63. hsync_common/protocol/fixtures/p2p-wss-inventory-ack-resync.json +6 -0
  64. hsync_common/protocol/fixtures/p2p-wss-inventory-delta-delete.json +13 -0
  65. hsync_common/protocol/fixtures/p2p-wss-inventory-delta-upsert.json +14 -0
  66. hsync_common/protocol/fixtures/p2p-wss-inventory-empty-snapshot.json +8 -0
  67. hsync_common/protocol/fixtures/p2p-wss-inventory-snapshot.json +14 -0
  68. hsync_common/protocol/fixtures/ws-auth-error.json +7 -0
  69. hsync_common/protocol/fixtures/ws-auth-ok.json +9 -0
  70. hsync_common/protocol/fixtures/ws-auth-request.json +9 -0
  71. hsync_common/protocol/fixtures/ws-file-event.json +24 -0
  72. hsync_common/protocol/fixtures/ws-ping.json +3 -0
  73. hsync_common/protocol/fixtures/ws-pong.json +3 -0
  74. hsync_common/protocol/fixtures/ws-presence.json +5 -0
  75. hsync_common/protocol/fixtures.py +778 -0
  76. hsync_common/protocol/handshake.py +336 -0
  77. hsync_common/protocol/p2p/sources-schema.json +164 -0
  78. hsync_common/protocol/p2p/txt-schema.json +99 -0
  79. hsync_common/protocol/p2p/wss-schema.json +743 -0
  80. hsync_common/protocol/p2p.py +126 -0
  81. hsync_common/protocol/pairing.py +214 -0
  82. hsync_common/protocol/peer_download.py +522 -0
  83. hsync_common/protocol/rest/openapi.json +2106 -0
  84. hsync_common/protocol/rest.py +157 -0
  85. hsync_common/protocol/schemas.py +814 -0
  86. hsync_common/protocol/tls_trust.py +107 -0
  87. hsync_common/protocol/ws/ws-schema.json +424 -0
  88. hsync_common/ratelimit.py +146 -0
  89. hsync_common/runtime.py +300 -0
  90. hsync_common/ssl_utils.py +182 -0
  91. hsync_common/text.py +64 -0
  92. hsync_common/time_utils.py +154 -0
  93. hsync_common/upnp.py +462 -0
  94. hsync_common-1.0.1.dist-info/METADATA +162 -0
  95. hsync_common-1.0.1.dist-info/RECORD +97 -0
  96. hsync_common-1.0.1.dist-info/WHEEL +4 -0
  97. hsync_common-1.0.1.dist-info/licenses/LICENSE +29 -0
@@ -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())