cookiesync-cli 0.1.0__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.
@@ -0,0 +1,241 @@
1
+ """The sync merge pass: gather every endpoint's cookies, union newest-wins, idempotently apply.
2
+
3
+ ``converge`` runs one merge pass for a single tracked browser group. It decrypts this
4
+ host's cookies (via the cached Safe Storage key — never prompting here), pulls each peer's
5
+ decrypted cookies over ssh, merges with the pure union newest-wins rule, then writes the
6
+ merged set back to any endpoint whose rows differ — preserving the winning
7
+ ``last_update_utc`` and recording the applied digest with the watch engine *before* the
8
+ write, so the induced filesystem event is recognized as a self-echo and skipped.
9
+
10
+ Cookie ``last_update_utc`` is absolute Chrome time (microseconds since 1601 UTC) and is
11
+ host-independent, so a raw newest-wins comparison is convergent across NTP-synced tailnet
12
+ machines without any clock-skew correction. The merge preserves each winner's original
13
+ ``last_update_utc`` on every host, so the anti-echo digest the watch engine records matches
14
+ the store's fingerprint after the write.
15
+
16
+ ``reconcile`` is the time-based backup: ``converge`` over every tracked browser group.
17
+
18
+ This host and every peer are reached through the one uniform :class:`Source` seam
19
+ (``extract``/``apply``), so the merge logic runs in unit tests against fakes — with the
20
+ sources injected — without ssh or a real cookie store.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections import defaultdict
26
+ from dataclasses import dataclass
27
+ from typing import TYPE_CHECKING, Protocol
28
+
29
+ from cookiesync.cookie import merge
30
+ from cookiesync.daemon.engine import logical_digest
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable, Iterable, Sequence
34
+
35
+ from cookiesync.cookie.browsers import Browser
36
+ from cookiesync.cookie.models import Cookie
37
+ from cookiesync.daemon.cache import KeyCache
38
+ from cookiesync.state import BrowserEndpoint, BrowserId, SshTarget
39
+
40
+
41
+ class NeedsAuth(Exception):
42
+ """No cached Safe Storage key for the endpoint's browser; a prompt is required first.
43
+
44
+ Raised when ``converge`` finds the local key cache cold. ``converge`` never prompts —
45
+ the caller obtains consent and seeds the cache, then retries.
46
+ """
47
+
48
+
49
+ class Engine(Protocol):
50
+ """The watch engine's anti-echo seam: record an endpoint's applied digest before writing.
51
+
52
+ Recording the digest first means the filesystem event the write induces is recognized
53
+ as this daemon's own and suppressed, rather than re-triggering a sync.
54
+ """
55
+
56
+ def record_applied(self, endpoint_id: str, digest: str) -> None: ...
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class Extracted:
61
+ """A source's decrypted cookies for the requested browser profile."""
62
+
63
+ cookies: tuple[Cookie, ...]
64
+
65
+
66
+ class Source(Protocol):
67
+ """One endpoint's cookie store, reached the same way whether it is local or a peer.
68
+
69
+ Both the in-process local source and the ssh-backed peer satisfy this seam: ``extract``
70
+ returns the decrypted cookies, and ``apply`` writes a merged set back. The Safe Storage
71
+ key never crosses this boundary — the source decrypts and re-encrypts in its own session.
72
+ """
73
+
74
+ async def extract(self, browser: BrowserId, profile: str) -> Extracted: ...
75
+
76
+ async def apply(self, browser: BrowserId, profile: str, cookies: Sequence[Cookie]) -> int: ...
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Gathered:
81
+ """One endpoint's decrypted cookies for the group, with the source that yielded them."""
82
+
83
+ endpoint: BrowserEndpoint
84
+ source: Source
85
+ cookies: tuple[Cookie, ...]
86
+
87
+
88
+ def target_row(cookie: Cookie) -> tuple:
89
+ """The full logical row used to decide whether an endpoint already holds a cookie.
90
+
91
+ Covers every value-bearing field, so an idempotent apply skips only when the endpoint's
92
+ stored row matches the winner exactly — including its preserved ``last_update_utc``.
93
+ """
94
+ return (
95
+ cookie.host_key,
96
+ cookie.name,
97
+ cookie.value,
98
+ cookie.path,
99
+ int(cookie.expires_utc),
100
+ int(cookie.last_update_utc),
101
+ cookie.is_secure,
102
+ cookie.is_httponly,
103
+ cookie.samesite,
104
+ cookie.source_scheme,
105
+ cookie.source_port,
106
+ cookie.top_frame_site_key,
107
+ cookie.has_cross_site_ancestor,
108
+ )
109
+
110
+
111
+ def row_set(cookies: Iterable[Cookie]) -> frozenset[tuple]:
112
+ return frozenset(target_row(c) for c in cookies)
113
+
114
+
115
+ async def gather(endpoint: BrowserEndpoint, source: Source) -> Gathered:
116
+ extracted = await source.extract(endpoint.browser, endpoint.profile)
117
+ return Gathered(endpoint, source, extracted.cookies)
118
+
119
+
120
+ async def apply_to(gathered: Gathered, merged: tuple[Cookie, ...], *, engine: Engine) -> bool:
121
+ if row_set(merged) == row_set(gathered.cookies):
122
+ return False
123
+ engine.record_applied(gathered.endpoint.id, logical_digest(merged))
124
+ await gathered.source.apply(gathered.endpoint.browser, gathered.endpoint.profile, merged)
125
+ return True
126
+
127
+
128
+ async def converge(
129
+ endpoint: BrowserEndpoint,
130
+ peers: Sequence[BrowserEndpoint],
131
+ *,
132
+ origin: SshTarget | None = None,
133
+ self_target: SshTarget,
134
+ cache: KeyCache,
135
+ engine: Engine,
136
+ local_source: Source,
137
+ source_for: Callable[[SshTarget], Source],
138
+ ) -> tuple[Cookie, ...]:
139
+ """Merge one browser group across this host and its peers, then idempotently apply.
140
+
141
+ Gathers ``endpoint``'s decrypted cookies through ``local_source`` (the consent gate is
142
+ the caller's; a cold key cache raises :class:`NeedsAuth` rather than prompting) and each
143
+ peer's cookies through ``source_for(peer.host)``, skipping ``origin`` so a sync is never
144
+ echoed straight back to the host that triggered it. The union newest-wins
145
+ :func:`~cookiesync.cookie.merge` selects per cookie by raw ``last_update_utc`` — absolute
146
+ Chrome time, host-independent and convergent on NTP-synced machines — and the result is
147
+ written to any endpoint whose stored rows differ, preserving the winning
148
+ ``last_update_utc`` and recording the applied digest with ``engine`` *before* the write,
149
+ so the induced filesystem event is suppressed. Same-machine endpoints converge through
150
+ ``local_source`` in-process, with no ssh.
151
+
152
+ Args:
153
+ endpoint: This host's local endpoint for the browser group.
154
+ peers: The other tracked endpoints for the same browser, local or remote.
155
+ origin: The host that triggered this sync, skipped to avoid an echo; ``None`` for a
156
+ time-based reconcile that touches every endpoint.
157
+ self_target: This host's own ssh target; endpoints on it converge in-process.
158
+ cache: The short-TTL key cache; a cold entry for ``endpoint`` raises
159
+ :class:`NeedsAuth`.
160
+ engine: The watch engine, told the applied digest before each write.
161
+ local_source: This machine's cookie source (extract/apply behind the consent gate).
162
+ source_for: Builds the :class:`Source` for a peer target; injected for tests.
163
+
164
+ Returns:
165
+ The merged cookie set that was reconciled across the group.
166
+
167
+ Raises:
168
+ NeedsAuth: The local key cache is cold for ``endpoint``; obtain consent and retry.
169
+
170
+ Example:
171
+ >>> await converge(local, peers, self_target=self, cache=cache, engine=engine,
172
+ ... local_source=local, source_for=lambda t: SshBackend(t, origin=self))
173
+ """
174
+ if await cache.get(endpoint.id) is None:
175
+ raise NeedsAuth(f"no cached key for {endpoint.id}; obtain consent before converging")
176
+ sources = [
177
+ (endpoint, local_source),
178
+ *(
179
+ (peer, local_source if peer.host == self_target else source_for(peer.host))
180
+ for peer in peers
181
+ if peer.host != origin
182
+ ),
183
+ ]
184
+ gathered = [await gather(ep, src) for ep, src in sources]
185
+ merged = merge(*(g.cookies for g in gathered))
186
+ for g in gathered:
187
+ await apply_to(g, merged, engine=engine)
188
+ return merged
189
+
190
+
191
+ async def reconcile(
192
+ endpoints: Sequence[BrowserEndpoint],
193
+ *,
194
+ self_target: SshTarget,
195
+ registry: dict[BrowserId, Browser],
196
+ cache: KeyCache,
197
+ engine: Engine,
198
+ local_source: Source,
199
+ source_for: Callable[[SshTarget], Source],
200
+ ) -> dict[str, tuple[Cookie, ...]]:
201
+ """The time-based backup: ``converge`` over every tracked browser group.
202
+
203
+ Groups ``endpoints`` by browser, anchors each group on this host's local endpoint, and
204
+ runs :func:`converge` with no ``origin`` so every endpoint is reconciled. A group with no
205
+ local endpoint on this host is skipped — there is nothing here to merge from.
206
+
207
+ Args:
208
+ endpoints: Every tracked endpoint across all hosts and browsers.
209
+ self_target: This host's own ssh target.
210
+ registry: The browser registry, mapping each :class:`~cookiesync.state.BrowserId` to
211
+ its :class:`~cookiesync.cookie.browsers.Browser`; the anchored group is skipped
212
+ when its browser is not registered.
213
+ cache: The short-TTL key cache.
214
+ engine: The watch engine, told each applied digest before its write.
215
+ local_source: This machine's cookie source.
216
+ source_for: Builds the :class:`Source` for a peer target; injected for tests.
217
+
218
+ Returns:
219
+ Each anchored endpoint's id mapped to the merged set reconciled for its group.
220
+
221
+ Example:
222
+ >>> await reconcile(endpoints, self_target=self, registry=REGISTRY, cache=cache,
223
+ ... engine=engine, local_source=local, source_for=make_ssh_source)
224
+ """
225
+ groups: dict[BrowserId, list[BrowserEndpoint]] = defaultdict(list)
226
+ for endpoint in endpoints:
227
+ groups[endpoint.browser].append(endpoint)
228
+ results: dict[str, tuple[Cookie, ...]] = {}
229
+ for browser_id, group in groups.items():
230
+ if browser_id not in registry or (anchor := next((e for e in group if e.host == self_target), None)) is None:
231
+ continue
232
+ results[anchor.id] = await converge(
233
+ anchor,
234
+ [e for e in group if e is not anchor],
235
+ self_target=self_target,
236
+ cache=cache,
237
+ engine=engine,
238
+ local_source=local_source,
239
+ source_for=source_for,
240
+ )
241
+ return results
@@ -0,0 +1,90 @@
1
+ """The newline-delimited JSON wire format the daemon RPC speaks.
2
+
3
+ One ``Request`` line in, one ``Response`` line out, then the connection closes.
4
+ ``Cookie`` objects cross the wire as the JSON-safe dict ``dataclasses.asdict``
5
+ produces, since every ``Cookie`` field is a ``str``/``int``/``bool``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import json
12
+ from dataclasses import dataclass, field
13
+
14
+ from cookiesync.cookie import Cookie
15
+ from cookiesync.cookie.models import ChromeMicros, HostKey
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Request:
20
+ """A single daemon command, encoded as one JSON line.
21
+
22
+ Example:
23
+ >>> Request("status", {"endpoint": "host:chrome:Default"})
24
+ """
25
+
26
+ method: str
27
+ params: dict = field(default_factory=dict)
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Response:
32
+ """The daemon's reply to one ``Request``, encoded as one JSON line.
33
+
34
+ ``error`` is set only when the request failed; ``result`` carries the handler's
35
+ return on success.
36
+
37
+ Example:
38
+ >>> Response(ok=True, result={"applied": 3})
39
+ """
40
+
41
+ ok: bool
42
+ result: dict | list | None = None
43
+ error: str | None = None
44
+
45
+
46
+ def cookie_to_wire(cookie: Cookie) -> dict:
47
+ """A ``Cookie`` as the JSON-safe dict that crosses the wire."""
48
+ return dataclasses.asdict(cookie)
49
+
50
+
51
+ def cookie_from_wire(data: dict) -> Cookie:
52
+ """A wire dict back into a ``Cookie``, re-branding its primitive fields."""
53
+ return Cookie(
54
+ host_key=HostKey(data["host_key"]),
55
+ name=data["name"],
56
+ value=data["value"],
57
+ path=data["path"],
58
+ expires_utc=ChromeMicros(data["expires_utc"]),
59
+ last_update_utc=ChromeMicros(data["last_update_utc"]),
60
+ creation_utc=ChromeMicros(data["creation_utc"]),
61
+ is_secure=data["is_secure"],
62
+ is_httponly=data["is_httponly"],
63
+ samesite=data["samesite"],
64
+ source_scheme=data["source_scheme"],
65
+ source_port=data["source_port"],
66
+ top_frame_site_key=data["top_frame_site_key"],
67
+ has_cross_site_ancestor=data["has_cross_site_ancestor"],
68
+ )
69
+
70
+
71
+ def encode_request(req: Request) -> bytes:
72
+ """A ``Request`` as one newline-terminated JSON line."""
73
+ return json.dumps({"method": req.method, "params": req.params}).encode() + b"\n"
74
+
75
+
76
+ def decode_request(line: bytes) -> Request:
77
+ """One JSON line back into a ``Request``."""
78
+ data = json.loads(line)
79
+ return Request(method=data["method"], params=data.get("params", {}))
80
+
81
+
82
+ def encode_response(resp: Response) -> bytes:
83
+ """A ``Response`` as one newline-terminated JSON line."""
84
+ return json.dumps({"ok": resp.ok, "result": resp.result, "error": resp.error}).encode() + b"\n"
85
+
86
+
87
+ def decode_response(line: bytes) -> Response:
88
+ """One JSON line back into a ``Response``."""
89
+ data = json.loads(line)
90
+ return Response(ok=data["ok"], result=data.get("result"), error=data.get("error"))
cookiesync/helper.py ADDED
@@ -0,0 +1,112 @@
1
+ """Install and classify the signed Secure-Enclave key helper ``.app``.
2
+
3
+ ``cookiesync install`` installs the helper through Homebrew —
4
+ ``brew install yasyf/tap/cookiesync-keyhelper`` against the shared central tap. Homebrew
5
+ downloads the release asset, verifies its checksum, and lays the stapled, notarized
6
+ ``.app`` down in the cask appdir; cookiesync then asserts the installed bundle carries a
7
+ valid Developer-ID anchor before trusting it. An ad-hoc, unsigned, or wrong-anchor bundle
8
+ is refused — macOS 15/26 SIGKILL an ad-hoc signature at exec and refuse it the Secure
9
+ Enclave.
10
+
11
+ The Developer-ID anchor check is the same designated-requirement OID
12
+ (``1.2.840.113635.100.6.2.6``) the release workflow seals against, so a bundle that
13
+ ``codesign --verify --strict`` would pass on its own (e.g. an ad-hoc signature) still
14
+ classifies as ``UNSIGNED`` here.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import shutil
21
+ from enum import Enum
22
+ from pathlib import Path
23
+
24
+ import anyio
25
+
26
+ from cookiesync import paths
27
+
28
+ CODESIGN = "/usr/bin/codesign"
29
+ DEVELOPER_ID_REQUIREMENT = "anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists"
30
+ BREW_CASK = "yasyf/tap/cookiesync-keyhelper"
31
+ PROBE_VAULT = "cookiesync-doctor-probe"
32
+ CONTRACT_LINE = re.compile(rb"^biometry=(?:true|false) passcode=(?:true|false) vault=(?:true|false)$", re.MULTILINE)
33
+
34
+
35
+ class HelperState(Enum):
36
+ OK = "ok"
37
+ UNSIGNED = "unsigned"
38
+ MISSING = "missing"
39
+
40
+
41
+ class HelperInstallError(Exception):
42
+ """The signed key helper could not be installed or verified."""
43
+
44
+
45
+ async def developer_id_signed(app_path: Path) -> bool:
46
+ """Whether ``app_path`` has an intact signature anchored to Apple's Developer ID.
47
+
48
+ Runs ``codesign --verify --strict`` with the Developer-ID designated-requirement
49
+ OID, so an ad-hoc or wrong-anchor signature (which a bare ``--verify`` would accept)
50
+ returns ``False``.
51
+ """
52
+ result = await anyio.run_process(
53
+ [CODESIGN, "--verify", "--strict", "-R", DEVELOPER_ID_REQUIREMENT, str(app_path)],
54
+ check=False,
55
+ )
56
+ return result.returncode == 0
57
+
58
+
59
+ async def helper_state() -> HelperState:
60
+ """Classify the installed key helper as ``OK`` (Developer-ID signed), ``UNSIGNED``, or ``MISSING``."""
61
+ if not paths.helper_binary().is_file():
62
+ return HelperState.MISSING
63
+ return HelperState.OK if await developer_id_signed(paths.helper_app_path()) else HelperState.UNSIGNED
64
+
65
+
66
+ async def supports_contract() -> bool:
67
+ """Whether the installed helper honours the key-helper subcommand contract cookiesync depends on.
68
+
69
+ The cask version can lag the package version, and cookiesync resolves the helper by name,
70
+ so a stale or incompatible bundle may sit at the expected path. Runs the read-only
71
+ ``vault-status`` subcommand against a fake vault — it never triggers a Touch ID prompt —
72
+ and passes iff the helper exits 0 and emits the documented
73
+ ``biometry=<bool> passcode=<bool> vault=<bool>`` contract line.
74
+ """
75
+ result = await anyio.run_process(
76
+ [str(paths.helper_binary()), "vault-status", PROBE_VAULT],
77
+ check=False,
78
+ )
79
+ return result.returncode == 0 and CONTRACT_LINE.search(result.stdout) is not None
80
+
81
+
82
+ async def install_helper() -> Path:
83
+ """Install the signed Secure-Enclave key helper via Homebrew, then verify its Developer-ID anchor.
84
+
85
+ Runs ``brew install yasyf/tap/cookiesync-keyhelper`` — Homebrew downloads the release
86
+ asset, checksum-verifies it, and lays the stapled, notarized ``.app`` into the cask
87
+ appdir — then locates the installed bundle and asserts it is Developer-ID signed. Raises
88
+ :class:`HelperInstallError` if Homebrew is absent, the install fails, the bundle cannot
89
+ be located, or the anchor check fails: an unverified bundle is never trusted.
90
+
91
+ Returns:
92
+ The installed ``cookiesync-keyhelper.app`` path.
93
+ """
94
+ if (brew := shutil.which("brew")) is None:
95
+ raise HelperInstallError(
96
+ "Homebrew is required to install the signed key helper; install it from "
97
+ "https://brew.sh, then rerun 'cookiesync install'"
98
+ )
99
+ result = await anyio.run_process([brew, "install", "--cask", BREW_CASK], check=False)
100
+ if result.returncode != 0:
101
+ raise HelperInstallError(f"'brew install --cask {BREW_CASK}' failed: {result.stderr.decode().strip()}")
102
+ app_path = paths.helper_app_path()
103
+ if not await anyio.Path(app_path).is_dir():
104
+ raise HelperInstallError(
105
+ f"'brew install --cask {BREW_CASK}' reported success but no bundle is present at {app_path}"
106
+ )
107
+ if not await developer_id_signed(app_path):
108
+ raise HelperInstallError(
109
+ f"refusing to trust {app_path.name}: it is not Developer-ID-signed "
110
+ "(codesign --verify --strict and the Developer-ID anchor must both pass)"
111
+ )
112
+ return app_path
cookiesync/paths.py ADDED
@@ -0,0 +1,87 @@
1
+ """On-disk locations for cookiesync's config dir, state file, RPC socket, reconcile lock, and signed helper.
2
+
3
+ The config dir honours ``XDG_CONFIG_HOME`` (falling back to ``~/.config``) and holds a
4
+ ``cookiesync`` subdirectory shared by every writer of ``state.json``. The signed
5
+ Secure-Enclave helper ships as a Homebrew cask (``yasyf/tap/cookiesync-keyhelper``) and
6
+ installs its ``.app`` into the Homebrew cask appdir — ``/Applications`` by default, or
7
+ ``~/Applications`` when brew runs without admin rights.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ CONFIG_SUBDIR = "cookiesync"
16
+ DATA_SUBDIR = "cookiesync"
17
+ STATE_FILE = "state.json"
18
+ SOCK_FILE = "rpc.sock"
19
+ LOCK_FILE = "reconcile.lock"
20
+ HELPER_APP = "cookiesync-keyhelper.app"
21
+ HELPER_EXECUTABLE = "cookiesync-keyhelper"
22
+
23
+
24
+ class HelperError(Exception):
25
+ """The signed Secure-Enclave helper is not installed; run ``cookiesync install`` to fetch it."""
26
+
27
+
28
+ def config_dir() -> Path:
29
+ """The cookiesync config directory under ``XDG_CONFIG_HOME`` or ``~/.config``."""
30
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / CONFIG_SUBDIR
31
+
32
+
33
+ def data_dir() -> Path:
34
+ """The cookiesync data directory under ``XDG_DATA_HOME`` or ``~/.local/share``."""
35
+ return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / DATA_SUBDIR
36
+
37
+
38
+ def cask_app_dirs() -> tuple[Path, ...]:
39
+ """The Homebrew cask appdirs an ``app`` stanza may install into, most-specific first."""
40
+ return Path("/Applications"), Path.home() / "Applications"
41
+
42
+
43
+ def helper_app_path() -> Path:
44
+ """The cask-installed ``cookiesync-keyhelper.app`` bundle path.
45
+
46
+ ``brew install yasyf/tap/cookiesync-keyhelper`` moves the signed ``.app`` into the
47
+ Homebrew cask appdir — ``/Applications`` by default, or ``~/Applications`` when brew
48
+ runs without admin rights. Returns the first appdir that holds the bundle, falling back
49
+ to the default appdir so a not-yet-installed helper still reports a stable path.
50
+ """
51
+ dirs = cask_app_dirs()
52
+ return next((app for d in dirs if (app := d / HELPER_APP).is_dir()), dirs[0] / HELPER_APP)
53
+
54
+
55
+ def helper_binary() -> Path:
56
+ """The signed helper's inner executable, e.g. ``…/cookiesync-keyhelper.app/Contents/MacOS/cookiesync-keyhelper``."""
57
+ return helper_app_path() / "Contents" / "MacOS" / HELPER_EXECUTABLE
58
+
59
+
60
+ def require_helper() -> Path:
61
+ """The signed helper executable, or raise :class:`HelperError` if it is not installed.
62
+
63
+ The Secure-Enclave key vault and key cache run inside a Developer-ID-signed,
64
+ notarized ``.app``; an ad-hoc build is SIGKILLed at exec by AMFI and cannot touch
65
+ the Enclave. Callers fail closed on a missing helper rather than degrading to an
66
+ unsigned fallback.
67
+ """
68
+ if not (binary := helper_binary()).is_file():
69
+ raise HelperError(
70
+ f"cookiesync key helper not found at {binary}; run 'cookiesync install' to fetch the signed helper"
71
+ )
72
+ return binary
73
+
74
+
75
+ def state_path() -> Path:
76
+ """The ``state.json`` file holding this host's cookiesync configuration."""
77
+ return config_dir() / STATE_FILE
78
+
79
+
80
+ def sock_path() -> Path:
81
+ """The daemon's RPC unix socket."""
82
+ return config_dir() / SOCK_FILE
83
+
84
+
85
+ def lock_path() -> Path:
86
+ """The reconcile lock file every cross-process ``state.json`` writer serializes on."""
87
+ return config_dir() / LOCK_FILE
cookiesync/py.typed ADDED
File without changes
cookiesync/registry.py ADDED
@@ -0,0 +1,79 @@
1
+ """Bridge to the ``reposync`` host registry over its ``--json`` contract.
2
+
3
+ ``reposync host ls --json`` and ``reposync self --json`` emit a versioned envelope
4
+ (``{"version": 1, "self": ..., "hosts": [...]}``); we pin ``version == 1`` and fail
5
+ loud on any drift so a contract change can never be silently mis-parsed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from subprocess import CalledProcessError
12
+ from typing import TYPE_CHECKING
13
+
14
+ import anyio
15
+
16
+ from cookiesync.state import SshTarget
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Sequence
20
+
21
+ REGISTRY_VERSION = 1
22
+
23
+
24
+ class RegistryError(Exception):
25
+ """The ``reposync`` registry could not be read or violated its ``--json`` contract."""
26
+
27
+
28
+ async def _reposync(*args: str) -> dict:
29
+ cmd = f"reposync {' '.join(args)}"
30
+ try:
31
+ proc = await anyio.run_process(["reposync", *args])
32
+ except FileNotFoundError as exc:
33
+ raise RegistryError("reposync is not installed or not on PATH") from exc
34
+ except CalledProcessError as exc:
35
+ raise RegistryError(f"{cmd} failed: {exc.stderr.decode().strip() or f'exit {exc.returncode}'}") from exc
36
+ try:
37
+ payload = json.loads(proc.stdout)
38
+ except json.JSONDecodeError as exc:
39
+ raise RegistryError(f"{cmd} emitted non-JSON output") from exc
40
+ match payload:
41
+ case {"version": version, **rest} if version == REGISTRY_VERSION:
42
+ return rest
43
+ case {"version": version}:
44
+ raise RegistryError(f"{cmd} reported version {version}, expected {REGISTRY_VERSION}")
45
+ case _:
46
+ raise RegistryError(f"{cmd} omitted the version field")
47
+
48
+
49
+ def _targets(hosts: Sequence[str]) -> tuple[SshTarget, ...]:
50
+ return tuple(SshTarget(host) for host in hosts)
51
+
52
+
53
+ async def reposync_registry() -> tuple[SshTarget, tuple[SshTarget, ...]]:
54
+ """The local target and every peer host, parsed from ``reposync host ls --json``.
55
+
56
+ Returns:
57
+ A ``(self_target, peer_hosts)`` pair; ``peer_hosts`` is empty when this host
58
+ stands alone.
59
+
60
+ Raises:
61
+ RegistryError: The envelope is missing or pins a version other than ``1``.
62
+
63
+ Example:
64
+ >>> self_target, hosts = await reposync_registry()
65
+ """
66
+ data = await _reposync("host", "ls", "--json")
67
+ return SshTarget(data["self"]), _targets(data["hosts"])
68
+
69
+
70
+ async def reposync_self() -> SshTarget:
71
+ """This machine's own SSH target, parsed from ``reposync self --json``.
72
+
73
+ Raises:
74
+ RegistryError: The envelope is missing or pins a version other than ``1``.
75
+
76
+ Example:
77
+ >>> await reposync_self()
78
+ """
79
+ return SshTarget((await _reposync("self", "--json"))["self"])