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.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. 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)