freemyid 0.2.0.dev1__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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
freemyid/netpolicy.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Network allowlist. OWNER: orchestrator.
|
|
2
|
+
|
|
3
|
+
The ONLY hosts Free My ID may contact: broker domains from the registry, the profile's own
|
|
4
|
+
mail servers, the configured registry hosts, and user-approved extras. The engine must call
|
|
5
|
+
`is_allowed` for every browser request (Playwright route) and before any httpx/SMTP/IMAP.
|
|
6
|
+
|
|
7
|
+
Loopback (localhost, 127.0.0.1, ::1) is NOT allowed by default: broker-page JS in the
|
|
8
|
+
engine browser must never reach the local wizard. Callers that legitimately need it (the
|
|
9
|
+
wizard, tests) opt in with `NetPolicy(..., allow_loopback=True)` or `policy.with_loopback()`.
|
|
10
|
+
A profile's own mailbox host (e.g. Proton Bridge on 127.0.0.1) is still allowed for mail,
|
|
11
|
+
but `for_broker` never hands loopback to a browser run unless loopback was opted in.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from . import config
|
|
19
|
+
from .models import Profile, Registry
|
|
20
|
+
|
|
21
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _norm(host: str) -> str:
|
|
25
|
+
return host.strip().lower().rstrip(".").strip("[]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _host_ok(host: str, allowed: set[str]) -> bool:
|
|
29
|
+
host = _norm(host)
|
|
30
|
+
return bool(host) and any(host == a or host.endswith("." + a) for a in allowed)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_loopback(host: str) -> bool:
|
|
34
|
+
h = _norm(host)
|
|
35
|
+
return h in LOOPBACK_HOSTS or h.endswith(".localhost") or h.startswith("127.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def host_of(url_or_host: str) -> str | None:
|
|
39
|
+
"""Hostname of a URL (or a bare host); None if it is unsafe or unparsable.
|
|
40
|
+
|
|
41
|
+
Rejects backslashes (urlsplit reads `https://evil.com\\@broker.com` as broker.com,
|
|
42
|
+
browsers read it as evil.com), whitespace and control characters.
|
|
43
|
+
"""
|
|
44
|
+
s = url_or_host
|
|
45
|
+
if not s or "\\" in s or any(c.isspace() or ord(c) < 0x20 or ord(c) == 0x7F for c in s):
|
|
46
|
+
return None
|
|
47
|
+
if "://" in s:
|
|
48
|
+
try:
|
|
49
|
+
host = urlsplit(s).hostname
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
else:
|
|
53
|
+
host = s
|
|
54
|
+
return _norm(host) if host else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NetPolicy:
|
|
58
|
+
def __init__(self, registry: Registry | None, profiles: list[Profile] | None = None,
|
|
59
|
+
*, allow_loopback: bool = False) -> None:
|
|
60
|
+
cfg = config.load()
|
|
61
|
+
self.allow_loopback = allow_loopback
|
|
62
|
+
self.allowed: set[str] = set()
|
|
63
|
+
for b in registry.brokers if registry else []:
|
|
64
|
+
self.allowed.update(_norm(d) for d in b.domains)
|
|
65
|
+
self.allowed.update(_norm(d) for d in b.confirmation_domains)
|
|
66
|
+
for p in profiles or []:
|
|
67
|
+
if p.mailbox:
|
|
68
|
+
self.allowed.update({_norm(p.mailbox.imap_host), _norm(p.mailbox.smtp_host)})
|
|
69
|
+
for u in (cfg.registry_url, cfg.registry_sha256_url):
|
|
70
|
+
if u:
|
|
71
|
+
self.allowed.add(host_of(u) or "")
|
|
72
|
+
self.allowed.update(_norm(h) for h in cfg.extra_allowed_hosts)
|
|
73
|
+
if allow_loopback:
|
|
74
|
+
self.allowed.update(LOOPBACK_HOSTS)
|
|
75
|
+
self.allowed.discard("")
|
|
76
|
+
|
|
77
|
+
def with_loopback(self) -> NetPolicy:
|
|
78
|
+
"""Copy of this policy that also allows localhost/127.0.0.1/::1 (wizard, tests)."""
|
|
79
|
+
other = copy.copy(self)
|
|
80
|
+
other.allowed = set(self.allowed) | LOOPBACK_HOSTS
|
|
81
|
+
other.allow_loopback = True
|
|
82
|
+
return other
|
|
83
|
+
|
|
84
|
+
def is_allowed(self, url_or_host: str) -> bool:
|
|
85
|
+
host = host_of(url_or_host)
|
|
86
|
+
return bool(host) and _host_ok(str(host), self.allowed)
|
|
87
|
+
|
|
88
|
+
def for_broker(self, broker_domains: list[str], extra: list[str] | None = None) -> set[str]:
|
|
89
|
+
"""Narrow allowlist for a single broker run: (broker + extras) ∩ global allowlist.
|
|
90
|
+
|
|
91
|
+
Hosts not covered by the global allowlist are dropped, and loopback is dropped
|
|
92
|
+
unless this policy opted in to it.
|
|
93
|
+
"""
|
|
94
|
+
out: set[str] = set()
|
|
95
|
+
for d in [*broker_domains, *(extra or [])]:
|
|
96
|
+
h = host_of(d)
|
|
97
|
+
if not h or not _host_ok(h, self.allowed):
|
|
98
|
+
continue
|
|
99
|
+
if _is_loopback(h) and not self.allow_loopback:
|
|
100
|
+
continue
|
|
101
|
+
out.add(h)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------- CAPTCHA frames
|
|
106
|
+
# SEC-36. The human-in-the-loop windows (D9 "Fill it in for me", D10 listing finder) ask the
|
|
107
|
+
# person to tick the site's "I'm not a robot" box. That box is served by a CAPTCHA provider,
|
|
108
|
+
# not the broker. These hosts are allowed ONLY in those headed windows, ONLY as embedded
|
|
109
|
+
# sub-resources / sub-frames (never a main-frame navigation), and only https. Free My ID never
|
|
110
|
+
# reads, solves or automates them; it just stops blocking the widget the person clicks.
|
|
111
|
+
CAPTCHA_HOSTS: dict[str, tuple[str, ...]] = {
|
|
112
|
+
# host (exact or subdomain) -> allowed path prefixes ("" = any path)
|
|
113
|
+
"www.google.com": ("/recaptcha/",),
|
|
114
|
+
"www.gstatic.com": ("/recaptcha/",),
|
|
115
|
+
"www.recaptcha.net": ("/recaptcha/",),
|
|
116
|
+
"hcaptcha.com": ("",),
|
|
117
|
+
"challenges.cloudflare.com": ("",),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def captcha_subresource_allowed(url: str, *, is_main_frame_navigation: bool) -> bool:
|
|
122
|
+
"""True if `url` is a CAPTCHA widget resource a headed fill/finder window may load."""
|
|
123
|
+
if is_main_frame_navigation:
|
|
124
|
+
return False
|
|
125
|
+
host = host_of(url)
|
|
126
|
+
if not host:
|
|
127
|
+
return False
|
|
128
|
+
try:
|
|
129
|
+
parts = urlsplit(url)
|
|
130
|
+
except ValueError:
|
|
131
|
+
return False
|
|
132
|
+
if parts.scheme != "https" or parts.username or parts.password or parts.port not in (
|
|
133
|
+
None, 443):
|
|
134
|
+
return False
|
|
135
|
+
path = parts.path or "/"
|
|
136
|
+
for h, prefixes in CAPTCHA_HOSTS.items():
|
|
137
|
+
if host == h or (h == "hcaptcha.com" and host.endswith(".hcaptcha.com")):
|
|
138
|
+
return any(path.startswith(p) for p in prefixes)
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def captcha_hosts() -> set[str]:
|
|
143
|
+
"""Hostnames to add to the DNS/resolver allowlist of a headed fill/finder window."""
|
|
144
|
+
return set(CAPTCHA_HOSTS) | {"js.hcaptcha.com", "newassets.hcaptcha.com",
|
|
145
|
+
"api.hcaptcha.com", "imgs.hcaptcha.com"}
|
freemyid/notify.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Desktop notifications. Bodies must be PII-free (counts and first names at most)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LogNotifier:
|
|
12
|
+
"""Fallback: write to the log only."""
|
|
13
|
+
|
|
14
|
+
def notify(self, title: str, body: str, url: str | None = None) -> None:
|
|
15
|
+
log.info("notification: %s - %s", title, body)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _app_icon() -> Path | None:
|
|
19
|
+
"""The Free My ID icon, found next to the package (installed exe) or in the repo."""
|
|
20
|
+
here = Path(__file__).resolve().parent
|
|
21
|
+
for p in (here / "wizard" / "static" / "freemyid.png", here / "wizard" / "static" / "freemyid.ico",
|
|
22
|
+
here.parents[1] / "packaging" / "windows" / "freemyid.ico"):
|
|
23
|
+
if p.is_file():
|
|
24
|
+
return p
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DesktopNotifier:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
from desktop_notifier import DesktopNotifier as _DN
|
|
31
|
+
icon = _app_icon()
|
|
32
|
+
if icon is not None:
|
|
33
|
+
from desktop_notifier import Icon
|
|
34
|
+
self._dn = _DN(app_name="Free My ID", app_icon=Icon(path=icon))
|
|
35
|
+
else:
|
|
36
|
+
self._dn = _DN(app_name="Free My ID")
|
|
37
|
+
self._fallback = LogNotifier()
|
|
38
|
+
|
|
39
|
+
def notify(self, title: str, body: str, url: str | None = None) -> None:
|
|
40
|
+
self._fallback.notify(title, body, url)
|
|
41
|
+
try:
|
|
42
|
+
coro = self._dn.send(title=title, message=body)
|
|
43
|
+
try:
|
|
44
|
+
asyncio.get_running_loop()
|
|
45
|
+
except RuntimeError:
|
|
46
|
+
asyncio.run(coro)
|
|
47
|
+
else: # called from async code: fire and forget on the running loop
|
|
48
|
+
asyncio.ensure_future(coro)
|
|
49
|
+
except Exception as e: # noqa: BLE001 headless boxes have no notification daemon
|
|
50
|
+
log.info("desktop notification unavailable: %s", type(e).__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_notifier():
|
|
54
|
+
try:
|
|
55
|
+
return DesktopNotifier()
|
|
56
|
+
except Exception as e: # noqa: BLE001
|
|
57
|
+
log.info("desktop-notifier not usable (%s); logging instead", type(e).__name__)
|
|
58
|
+
return LogNotifier()
|
freemyid/paths.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Filesystem locations. OWNER: orchestrator."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def home() -> Path:
|
|
10
|
+
"""~/.freemyid (or %APPDATA%\\Free My ID on Windows). Override with FREEMYID_HOME."""
|
|
11
|
+
if env := os.environ.get("FREEMYID_HOME"):
|
|
12
|
+
p = Path(env)
|
|
13
|
+
elif sys.platform == "win32":
|
|
14
|
+
p = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "FreeMyID"
|
|
15
|
+
else:
|
|
16
|
+
p = Path.home() / ".freemyid"
|
|
17
|
+
created = not p.exists()
|
|
18
|
+
p.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
19
|
+
marker = p / MARKER
|
|
20
|
+
# Mark only folders Free My ID created itself (or that already hold its database), so
|
|
21
|
+
# `freemyid wipe` can never mistake an arbitrary folder for Free My ID's own (SEC-20).
|
|
22
|
+
if not marker.exists() and (created or (p / "freemyid.db").exists()):
|
|
23
|
+
try:
|
|
24
|
+
marker.write_text("This folder belongs to Free My ID. `freemyid wipe` deletes it.\n")
|
|
25
|
+
except OSError:
|
|
26
|
+
pass
|
|
27
|
+
return p
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MARKER = ".freemyid-home"
|
|
31
|
+
# Every top-level entry Free My ID may create in home(); `freemyid wipe` deletes only these.
|
|
32
|
+
OWNED_ENTRIES = ("freemyid.db", "freemyid.db-wal", "freemyid.db-shm", "evidence", "artifacts", "logs",
|
|
33
|
+
"config.yaml", "registry.yaml", "app-instance.json", "freemyid-task.xml", ".fill-confirmed", MARKER)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_safe_wipe_target(p: Path) -> bool:
|
|
37
|
+
"""True only for a folder that is clearly Free My ID's own and not a home/system root."""
|
|
38
|
+
p = p.resolve()
|
|
39
|
+
user_home = Path.home().resolve()
|
|
40
|
+
if p == user_home or p in user_home.parents or p.parent == p or len(p.parts) <= 2:
|
|
41
|
+
return False
|
|
42
|
+
return (p / MARKER).exists() or (p / "freemyid.db").exists()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def db_path() -> Path:
|
|
46
|
+
return home() / "freemyid.db"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def evidence_dir() -> Path:
|
|
50
|
+
d = home() / "evidence"
|
|
51
|
+
d.mkdir(exist_ok=True, mode=0o700)
|
|
52
|
+
return d
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def artifacts_dir() -> Path:
|
|
56
|
+
"""Generated letters / call scripts / reports (encrypted unless user exports)."""
|
|
57
|
+
d = home() / "artifacts"
|
|
58
|
+
d.mkdir(exist_ok=True, mode=0o700)
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def logs_dir() -> Path:
|
|
63
|
+
d = home() / "logs"
|
|
64
|
+
d.mkdir(exist_ok=True, mode=0o700)
|
|
65
|
+
return d
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def config_path() -> Path:
|
|
69
|
+
return home() / "config.yaml"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def registry_cache_path() -> Path:
|
|
73
|
+
return home() / "registry.yaml"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def bundled_data_dir() -> Path:
|
|
77
|
+
"""Registry/schema shipped with the package (wheel or PyInstaller) or repo checkout."""
|
|
78
|
+
here = Path(__file__).resolve().parent
|
|
79
|
+
if (here / "_data").exists():
|
|
80
|
+
return here / "_data"
|
|
81
|
+
if getattr(sys, "_MEIPASS", None):
|
|
82
|
+
return Path(sys._MEIPASS) / "freemyid" / "_data" # type: ignore[attr-defined]
|
|
83
|
+
return here.parent.parent # repo root: brokers/, schema/
|
freemyid/registry.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Broker + state-law registry loading, validation and updates. OWNER: orchestrator.
|
|
2
|
+
|
|
3
|
+
Precedence: FREEMYID_HOME/registry.yaml (validated update or installed family registry)
|
|
4
|
+
> registry.yaml next to the installed exe (family installer; frozen builds only)
|
|
5
|
+
> bundled brokers/registry.yaml (full registry; family/private builds only)
|
|
6
|
+
> bundled brokers/registry.sample.yaml (small public sample; always shipped).
|
|
7
|
+
`registry_source()` tells the UI/CLI which one is in use: "update" | "family" | "full" | "sample".
|
|
8
|
+
Updates: fetched ONLY from config.registry_url (the user's own GitHub repo), validated
|
|
9
|
+
against the pydantic model + JSON Schema, optionally pinned by sha256, then atomically
|
|
10
|
+
swapped in. A failed validation never replaces the working copy.
|
|
11
|
+
If the preferred registry is unreadable or invalid, `load_registry()` falls back to the next
|
|
12
|
+
source (so a bad update never leaves the app with no broker list) and records a warning in
|
|
13
|
+
`load_warnings`. YAML aliases are refused (alias-expansion "billion laughs" protection).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import jsonschema
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from . import config, paths
|
|
28
|
+
from .models import Registry, StateLaw, StateLawRegistry
|
|
29
|
+
from .netpolicy import NetPolicy
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RegistryError(RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _NoAliasLoader(yaml.SafeLoader):
|
|
39
|
+
"""SafeLoader that refuses aliases to lists/maps. Expanding those (the json round-trip
|
|
40
|
+
below) turns a tiny file into gigabytes ("billion laughs"). Aliases to plain scalars
|
|
41
|
+
(the registry reuses a source URL this way) cannot grow and stay allowed."""
|
|
42
|
+
|
|
43
|
+
def compose_node(self, parent, index): # type: ignore[no-untyped-def]
|
|
44
|
+
if self.check_event(yaml.AliasEvent):
|
|
45
|
+
anchor = self.peek_event().anchor
|
|
46
|
+
if not isinstance(self.anchors.get(anchor), yaml.ScalarNode):
|
|
47
|
+
raise yaml.YAMLError("YAML aliases to lists or maps are not allowed in a registry")
|
|
48
|
+
return super().compose_node(parent, index)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SOURCE_UPDATE, SOURCE_FAMILY, SOURCE_FULL, SOURCE_SAMPLE = "update", "family", "full", "sample"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def bundled_full_registry_path() -> Path:
|
|
55
|
+
"""Full maintained registry. Not in the public repo; present in family/private builds."""
|
|
56
|
+
return paths.bundled_data_dir() / "brokers" / "registry.yaml"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bundled_sample_registry_path() -> Path:
|
|
60
|
+
"""Small public sample registry; always shipped."""
|
|
61
|
+
return paths.bundled_data_dir() / "brokers" / "registry.sample.yaml"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def bundled_registry_path() -> Path:
|
|
65
|
+
"""The bundled registry in use: the full one if this build carries it, else the sample."""
|
|
66
|
+
full = bundled_full_registry_path()
|
|
67
|
+
return full if full.exists() else bundled_sample_registry_path()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _candidates() -> list[tuple[str, Path]]:
|
|
71
|
+
"""Existing registries in precedence order; the sample is always last."""
|
|
72
|
+
out: list[tuple[str, Path]] = []
|
|
73
|
+
cached = paths.registry_cache_path()
|
|
74
|
+
if cached.exists():
|
|
75
|
+
out.append((SOURCE_UPDATE, cached))
|
|
76
|
+
family = find_family_registry()
|
|
77
|
+
if family is not None:
|
|
78
|
+
out.append((SOURCE_FAMILY, family))
|
|
79
|
+
full = bundled_full_registry_path()
|
|
80
|
+
if full.exists():
|
|
81
|
+
out.append((SOURCE_FULL, full))
|
|
82
|
+
out.append((SOURCE_SAMPLE, bundled_sample_registry_path()))
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _active_registry() -> tuple[str, Path]:
|
|
87
|
+
return _candidates()[0]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# (preferred candidate at load time, source actually loaded): a fallback is only reported
|
|
91
|
+
# while the same preferred registry is still in place.
|
|
92
|
+
_loaded: tuple[tuple[str, Path], str] | None = None
|
|
93
|
+
load_warnings: list[str] = []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def registry_source() -> str:
|
|
97
|
+
"""Which registry `load_registry()` used last (or would use): "update"
|
|
98
|
+
(FREEMYID_HOME/registry.yaml, from the update feed or an installed family registry),
|
|
99
|
+
"family" (registry.yaml next to the installed exe), "full" (bundled full registry) or
|
|
100
|
+
"sample" (public sample). After a fallback this names the source actually loaded."""
|
|
101
|
+
preferred = _active_registry()
|
|
102
|
+
if _loaded is not None and _loaded[0] == preferred:
|
|
103
|
+
return _loaded[1]
|
|
104
|
+
return preferred[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def bundled_state_laws_path() -> Path:
|
|
108
|
+
return paths.bundled_data_dir() / "brokers" / "state_laws.yaml"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def schema_path() -> Path:
|
|
112
|
+
return paths.bundled_data_dir() / "schema" / "registry.schema.json"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def json_schema() -> dict:
|
|
116
|
+
return Registry.model_json_schema()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_text(text: str) -> Registry:
|
|
120
|
+
try:
|
|
121
|
+
raw = yaml.load(text, Loader=_NoAliasLoader)
|
|
122
|
+
except yaml.YAMLError as e:
|
|
123
|
+
raise RegistryError(f"registry is not valid YAML: {e}") from e
|
|
124
|
+
sp = schema_path()
|
|
125
|
+
schema = json.loads(sp.read_text(encoding="utf-8")) if sp.exists() else json_schema()
|
|
126
|
+
try:
|
|
127
|
+
jsonschema.validate(json.loads(json.dumps(raw, default=str)), schema)
|
|
128
|
+
return Registry.model_validate(raw)
|
|
129
|
+
except (jsonschema.ValidationError, ValueError) as e:
|
|
130
|
+
raise RegistryError(f"registry failed validation: {e}") from e
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_file(path: Path) -> Registry:
|
|
134
|
+
try:
|
|
135
|
+
if path.stat().st_size > MAX_REGISTRY_BYTES:
|
|
136
|
+
raise RegistryError(f"registry is larger than {MAX_REGISTRY_BYTES} bytes; refusing")
|
|
137
|
+
text = path.read_text(encoding="utf-8")
|
|
138
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
139
|
+
raise RegistryError(f"registry could not be read: {e}") from e
|
|
140
|
+
return validate_text(text)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_registry(path: Path | None = None) -> Registry:
|
|
144
|
+
"""Load an explicit registry file, or the best available one with fallback."""
|
|
145
|
+
global _loaded
|
|
146
|
+
if path is not None:
|
|
147
|
+
return _load_file(path)
|
|
148
|
+
load_warnings.clear()
|
|
149
|
+
err: RegistryError | None = None
|
|
150
|
+
cands = _candidates()
|
|
151
|
+
for source, p in cands:
|
|
152
|
+
try:
|
|
153
|
+
reg = _load_file(p)
|
|
154
|
+
except RegistryError as e:
|
|
155
|
+
msg = f"{source} registry unusable, trying the next one: {e}"
|
|
156
|
+
log.warning(msg)
|
|
157
|
+
load_warnings.append(msg)
|
|
158
|
+
err = e
|
|
159
|
+
continue
|
|
160
|
+
_loaded = (cands[0], source)
|
|
161
|
+
return reg
|
|
162
|
+
assert err is not None
|
|
163
|
+
raise err
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_state_laws(path: Path | None = None) -> dict[str, StateLaw]:
|
|
167
|
+
p = path or bundled_state_laws_path()
|
|
168
|
+
if not p.exists():
|
|
169
|
+
return {}
|
|
170
|
+
reg = StateLawRegistry.model_validate(yaml.safe_load(p.read_text(encoding="utf-8")))
|
|
171
|
+
return {s.state: s for s in reg.states}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
MAX_REGISTRY_BYTES = 5 * 1024 * 1024
|
|
175
|
+
MAX_PIN_BYTES = 4096
|
|
176
|
+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_url(url: str, what: str, policy: NetPolicy) -> None:
|
|
180
|
+
if not url.lower().startswith("https://"):
|
|
181
|
+
raise RegistryError(f"{what} must be https")
|
|
182
|
+
if not policy.is_allowed(url):
|
|
183
|
+
raise RegistryError(f"{what} host is not on the network allowlist")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _fetch(c: httpx.Client, url: str, cap: int, what: str) -> bytes:
|
|
187
|
+
"""GET url with no redirects, streaming, refusing bodies over `cap` bytes."""
|
|
188
|
+
try:
|
|
189
|
+
with c.stream("GET", url) as resp:
|
|
190
|
+
if resp.status_code != 200:
|
|
191
|
+
raise RegistryError(f"{what} fetch failed: HTTP {resp.status_code}")
|
|
192
|
+
declared = resp.headers.get("content-length")
|
|
193
|
+
if declared and declared.isdigit() and int(declared) > cap:
|
|
194
|
+
raise RegistryError(f"{what} is larger than {cap} bytes; refusing")
|
|
195
|
+
buf = bytearray()
|
|
196
|
+
for chunk in resp.iter_bytes():
|
|
197
|
+
buf.extend(chunk)
|
|
198
|
+
if len(buf) > cap:
|
|
199
|
+
raise RegistryError(f"{what} is larger than {cap} bytes; refusing")
|
|
200
|
+
return bytes(buf)
|
|
201
|
+
except httpx.HTTPError as e:
|
|
202
|
+
raise RegistryError(f"{what} fetch failed: {type(e).__name__}") from e
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def update_registry(timeout: float = 30.0, *,
|
|
206
|
+
transport: httpx.BaseTransport | None = None) -> tuple[str, int]:
|
|
207
|
+
"""Pull, validate, pin, swap. Returns (version, broker_count).
|
|
208
|
+
|
|
209
|
+
Both URLs must be https and allowed by NetPolicy; redirects are refused and bodies
|
|
210
|
+
are capped at MAX_REGISTRY_BYTES. `transport` is for tests (httpx.MockTransport).
|
|
211
|
+
"""
|
|
212
|
+
cfg = config.load()
|
|
213
|
+
if not cfg.registry_url:
|
|
214
|
+
raise RegistryError("registry_url is not configured (freemyid config set registry_url ...)")
|
|
215
|
+
policy = NetPolicy(None) # registry hosts + user extras only; no broker hosts needed
|
|
216
|
+
_check_url(cfg.registry_url, "registry_url", policy)
|
|
217
|
+
if cfg.registry_sha256_url:
|
|
218
|
+
_check_url(cfg.registry_sha256_url, "registry_sha256_url", policy)
|
|
219
|
+
with httpx.Client(timeout=timeout, follow_redirects=False, transport=transport) as c:
|
|
220
|
+
body = _fetch(c, cfg.registry_url, MAX_REGISTRY_BYTES, "registry")
|
|
221
|
+
if cfg.registry_sha256_url:
|
|
222
|
+
pin = _fetch(c, cfg.registry_sha256_url, MAX_PIN_BYTES, "registry sha256 pin")
|
|
223
|
+
fields = pin.decode("ascii", errors="replace").split()
|
|
224
|
+
want = fields[0].strip().lower() if fields else ""
|
|
225
|
+
if not _SHA256_RE.match(want):
|
|
226
|
+
raise RegistryError("registry sha256 pin is empty or malformed; refusing update")
|
|
227
|
+
if want != hashlib.sha256(body).hexdigest():
|
|
228
|
+
raise RegistryError("registry sha256 mismatch; refusing update")
|
|
229
|
+
reg = install_registry_bytes(body)
|
|
230
|
+
return reg.version, len(reg.brokers)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def install_registry_bytes(body: bytes) -> Registry:
|
|
234
|
+
"""Validate a registry and atomically install it as FREEMYID_HOME/registry.yaml.
|
|
235
|
+
|
|
236
|
+
Shared by `update_registry` and family-bundle import, so both get the same checks:
|
|
237
|
+
size cap, UTF-8, YAML, JSON Schema + pydantic. On any failure nothing is replaced.
|
|
238
|
+
"""
|
|
239
|
+
if len(body) > MAX_REGISTRY_BYTES:
|
|
240
|
+
raise RegistryError(f"registry is larger than {MAX_REGISTRY_BYTES} bytes; refusing")
|
|
241
|
+
try:
|
|
242
|
+
text = body.decode("utf-8")
|
|
243
|
+
except UnicodeDecodeError as e:
|
|
244
|
+
raise RegistryError("registry is not valid UTF-8") from e
|
|
245
|
+
reg = validate_text(text)
|
|
246
|
+
dest = paths.registry_cache_path()
|
|
247
|
+
tmp = dest.with_suffix(".tmp")
|
|
248
|
+
tmp.write_bytes(body) # exact bytes: text mode would turn \r\n into \r\r\n on Windows
|
|
249
|
+
tmp.replace(dest)
|
|
250
|
+
return reg
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
FAMILY_REGISTRY_NAME = "registry.yaml"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def find_family_registry() -> Path | None:
|
|
257
|
+
"""A registry.yaml shipped next to the installed app (family installer), if any.
|
|
258
|
+
|
|
259
|
+
Only checked for frozen (installed) builds, mirroring wizard.flows.find_family_bundle.
|
|
260
|
+
"""
|
|
261
|
+
import sys
|
|
262
|
+
if not getattr(sys, "frozen", False):
|
|
263
|
+
return None
|
|
264
|
+
p = Path(sys.executable).parent / FAMILY_REGISTRY_NAME
|
|
265
|
+
return p if p.is_file() else None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def install_family_registry(src: Path | bytes) -> tuple[str, int]:
|
|
269
|
+
"""Install a family-bundle registry (file path or raw bytes) through the update checks."""
|
|
270
|
+
if isinstance(src, Path):
|
|
271
|
+
if src.stat().st_size > MAX_REGISTRY_BYTES:
|
|
272
|
+
raise RegistryError(f"registry is larger than {MAX_REGISTRY_BYTES} bytes; refusing")
|
|
273
|
+
src = src.read_bytes()
|
|
274
|
+
reg = install_registry_bytes(src)
|
|
275
|
+
return reg.version, len(reg.brokers)
|