pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / names.py

25,815 bytes · last changed 2026-10-28 in Key backups, and names any network can run on any git host

1"""Names: ``alice@example.org`` resolved to an identity, pinned, and served.
2
3A name resolves by fetching ``https://<domain>/.well-known/pyrtan/name/<local>.json``,
4a claim signed by the identity it names. The resolver checks that the
5claim is for the name it asked and that the signature holds, then
6compares the answer with the *pin*: the identity the name resolved to
7the first time. A different answer later is never followed; it is shown
8beside the pinned one and the person re-pins by hand, as SSH does with a
9changed host key. That is what makes a domain's word safe to take.
10
11Any domain can be a name provider by serving those files, from any
12static host or from its own hubs (``pyrtan hub --names DIR``).
13:func:`build_wellknown` writes them from a directory of claims (and of
14identity documents, served at ``/.well-known/pyrtan/id/<56>.json``);
15pyrtan.com's build runs it on ``site/names/``.
16
17Who may change a name is a directory's own rule. :func:`gate_key`, for any
18network on any git host, needs nothing but signatures: a new name is open
19to whoever signs it, a name moves to a new key only with a *handover* its
20old key signs, it is given up only by a *release* its key signs, and an
21identity document is never replaced by an older one or deleted while its
22name stands. :func:`gate` is pyrtan.com's own rule: a name is its holder's
23GitHub login, added and changed only by the GitHub account whose numeric
24id the claim carries, because GitHub frees a login when its owner renames.
25
26This module never fetches an ``.onion``: that needs Tor, which pyrtan does
27not reach yet. The design is ``_meta/identity.md`` §6.
28"""
29
30from __future__ import annotations
31
32import json
33import re
34import shutil
35import time
36from dataclasses import dataclass, field
37from pathlib import Path
38from typing import Any, Callable
39
40from . import identity
41from .catalog import Problem
42from .identity import IdentityError
43
44WELL_KNOWN = ".well-known/pyrtan"
45NAMES_DIR = "site/names"
46GITHUB_LOGIN = re.compile(r"^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$")
47
48Fetch = Callable[[str], tuple[int, Any]]
49
50
51# ---------------------------------------------------------------- resolving
52
53
54def _fetch(url: str) -> tuple[int, Any]:
55 from .service import http_json
56 return http_json("GET", url, timeout=10.0)
57
58
59def _base(host: str) -> str:
60 host = identity.check_locator(host)
61 if host.split(":")[0].endswith(".onion"):
62 raise IdentityError(f"{host} is an onion service: reaching it needs Tor, which pyrtan does not reach yet")
63 return f"https://{host}"
64
65
66def name_url(name: str) -> str:
67 parsed = identity.parse(name)
68 if parsed.form != "name":
69 raise IdentityError(f"{name!r} is not a name (name@domain)")
70 return f"{_base(parsed.domain)}/{WELL_KNOWN}/name/{parsed.name}.json"
71
72
73def identity_url(ident: str, host: str) -> str:
74 return f"{_base(host)}/{WELL_KNOWN}/id/{identity.normal(ident)[len(identity.PREFIX):]}.json"
75
76
77def resolve(name: str, *, fetch: Fetch | None = None) -> dict[str, Any]:
78 """The checked claim ``name``'s domain serves; IdentityError says why there is none."""
79 url = name_url(name)
80 try:
81 status, claim = (fetch or _fetch)(url)
82 except Exception as e:
83 raise IdentityError(f"{url}: {e}") from None
84 if status == 404:
85 raise IdentityError(f"{identity.parse(name)} has no claim at {url}")
86 if status != 200:
87 raise IdentityError(f"{url} answered {status}")
88 problem = identity.check_name(claim, name=name)
89 if problem:
90 raise IdentityError(f"{url}: {problem}")
91 return claim
92
93
94def fetch_identity(ident: str, host: str, *, fetch: Fetch | None = None) -> dict[str, Any] | None:
95 """The identity document ``host`` serves for ``ident``, if it serves one that holds."""
96 try:
97 status, doc = (fetch or _fetch)(identity_url(ident, host))
98 except Exception:
99 return None
100 if status != 200 or identity.check_identity(doc, ident=ident) is not None:
101 return None
102 return doc
103
104
105# ---------------------------------------------------------------- pins
106
107
108class Pins:
109 """Name -> the identity it first resolved to, in a JSON file."""
110
111 def __init__(self, path: Path):
112 self.path = path
113 self.names: dict[str, dict[str, Any]] = {}
114 if path.exists():
115 try:
116 data = json.loads(path.read_text(encoding="utf-8"))
117 except (OSError, json.JSONDecodeError) as e:
118 raise IdentityError(f"{path}: {e}") from None
119 if not isinstance(data, dict) or data.get("pyrtan-pins") != 1:
120 raise IdentityError(f"{path} is not a pyrtan pins file")
121 self.names = data.get("names", {})
122
123 def get(self, name: str) -> dict[str, Any] | None:
124 return self.names.get(str(identity.parse(name)))
125
126 def compare(self, name: str, ident: str) -> str:
127 """``new``, ``same`` or ``changed``."""
128 pin = self.get(name)
129 return "new" if pin is None else "same" if pin["id"] == identity.normal(ident) else "changed"
130
131 def pin(self, name: str, ident: str, *, now: int | None = None) -> None:
132 now = int(time.time()) if now is None else now
133 key, ident = str(identity.parse(name)), identity.normal(ident)
134 old = self.names.get(key)
135 first = old["first"] if old and old["id"] == ident else now
136 self.names[key] = {"id": ident, "first": first, "seen": now}
137
138 def forget(self, name: str) -> bool:
139 return self.names.pop(str(identity.parse(name)), None) is not None
140
141 def save(self) -> None:
142 self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
143 temp = self.path.with_suffix(".json.tmp")
144 temp.write_text(json.dumps({"pyrtan-pins": 1, "names": self.names}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
145 temp.replace(self.path)
146
147
148# ---------------------------------------------------------------- a directory of names
149
150
151@dataclass
152class Directory:
153 """What a directory of names holds: claims, identity documents and releases, by name."""
154 claims: dict[str, dict[str, Any]] = field(default_factory=dict)
155 docs: dict[str, dict[str, Any]] = field(default_factory=dict)
156 releases: dict[str, dict[str, Any]] = field(default_factory=dict)
157 problems: list[Problem] = field(default_factory=list)
158
159
160def load_directory(directory: Path | None, domain: str, *, label: str | None = None) -> Directory:
161 """The claims (``<name>.json``), releases (a ``<name>.json`` that gives the name up) and
162 identity documents (``<name>.id.json``) in ``directory``, for ``domain``. Problems name
163 files as ``label/<file>`` (default: the directory as given); what fails a check is left out."""
164 d = Directory()
165 if directory is None or not directory.is_dir():
166 return d
167 label = directory.as_posix() if label is None else label
168
169 def rel(p: Path) -> str:
170 return f"{label}/{p.name}"
171
172 raw_docs: dict[str, Any] = {}
173 for path in sorted(directory.iterdir()):
174 if path.name.startswith(".") or path.name == "README.md":
175 continue
176 if not path.is_file() or not path.name.endswith(".json"):
177 d.problems.append(Problem(rel(path), "only <name>.json and <name>.id.json files belong here"))
178 continue
179 try:
180 data = json.loads(path.read_text(encoding="utf-8"))
181 except (OSError, json.JSONDecodeError) as e:
182 d.problems.append(Problem(rel(path), f"not JSON: {e}"))
183 continue
184 if path.name.endswith(".id.json"):
185 raw_docs[path.name[: -len(".id.json")]] = data
186 continue
187 stem = path.name[: -len(".json")]
188 if not identity.LOCAL.match(stem):
189 d.problems.append(Problem(rel(path), f"{stem!r} is not a name"))
190 continue
191 if isinstance(data, dict) and "pyrtan-name-release" in data:
192 problem = identity.check_release(data, name=f"{stem}@{domain}")
193 target = d.releases
194 else:
195 problem = identity.check_name(data, name=f"{stem}@{domain}")
196 target = d.claims
197 if problem:
198 d.problems.append(Problem(rel(path), problem))
199 continue
200 target[stem] = data
201 for stem, doc in raw_docs.items():
202 where = rel(directory / f"{stem}.id.json")
203 if stem not in d.claims:
204 d.problems.append(Problem(where, f"an identity document needs its name's claim, {stem}.json, beside it"))
205 continue
206 problem = identity.check_identity(doc, ident=d.claims[stem]["id"])
207 if problem:
208 d.problems.append(Problem(where, problem))
209 continue
210 d.docs[stem] = doc
211 return d
212
213
214def read_directory(directory: Path, domain: str, *, label: str | None = None) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[Problem]]:
215 """``(claims by name, documents by name, problems)``; see :func:`load_directory`."""
216 d = load_directory(directory, domain, label=label)
217 return d.claims, d.docs, d.problems
218
219
220def gate_key(head: Path, base: Path | None, domain: str, *, label: str = "names") -> list[Problem]:
221 """The rule for a directory of names owned by their keys, on any git host: ``head`` is the
222 directory a change proposes, ``base`` the one it changes (None for a first commit).
223 Needs only the two trees, never who proposed the change."""
224 h = load_directory(head, domain, label=label)
225 b = load_directory(base, domain, label=label)
226 problems = list(h.problems)
227 names = sorted(set(h.claims) | set(h.releases) | set(b.claims) | set(b.releases) | set(b.docs))
228 for n in names:
229 where, dwhere = f"{label}/{n}.json", f"{label}/{n}.id.json"
230 held, hc, hr = b.claims.get(n), h.claims.get(n), h.releases.get(n)
231 if held is not None:
232 if hc is not None and hc["id"] != held["id"]:
233 problem = identity.check_handover(hc, held["id"])
234 if problem:
235 problems.append(Problem(where, problem))
236 elif hr is not None and hr["id"] != held["id"]:
237 problems.append(Problem(where, f"the release is {identity.short(hr['id'])}'s; {n} is held by {identity.short(held['id'])}"))
238 elif hc is None and hr is None and not (head / f"{n}.json").exists():
239 problems.append(Problem(where, f"{n} is given up by replacing its claim with a release its key signs (pyrtan id release), not by deleting it"))
240 elif hr is not None and n not in b.releases:
241 problems.append(Problem(where, f"nobody holds {n}; there is nothing to release"))
242 # An identity document only moves forward while its name stays with the same key.
243 old = b.docs.get(n)
244 if old is not None and held is not None and hc is not None and hc["id"] == held["id"]:
245 new = h.docs.get(n)
246 if new is None and not (head / f"{n}.id.json").exists():
247 problems.append(Problem(dwhere, "an identity document is replaced by a newer one its key signs, not deleted: deleting it would hide its revocations"))
248 elif new is not None and new["updated"] < old["updated"]:
249 problems.append(Problem(dwhere, "this document is older than the one it replaces: an older one could bring a revoked device back"))
250 return problems
251
252
253def build_wellknown(directory: Path, out: Path, domain: str) -> tuple[int, list[Problem]]:
254 """Write ``out/.well-known/pyrtan/``: ``name/<name>.json`` per claim, ``id/<56>.json``
255 per identity document, and ``names.json`` listing every claim. Returns how
256 many names were written and the problems; nothing that fails a check is served."""
257 claims, docs, problems = read_directory(directory, domain)
258 root = out / WELL_KNOWN
259 if root.exists():
260 shutil.rmtree(root)
261 (root / "name").mkdir(parents=True)
262 (root / "id").mkdir()
263 for stem, claim in claims.items():
264 (root / "name" / f"{stem}.json").write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
265 newest: dict[str, dict[str, Any]] = {}
266 for doc in docs.values():
267 if doc["id"] not in newest or doc["updated"] > newest[doc["id"]]["updated"]:
268 newest[doc["id"]] = doc
269 for ident, doc in newest.items():
270 (root / "id" / f"{ident[len(identity.PREFIX):]}.json").write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
271 index = {"pyrtan-names": 1, "domain": domain, "names": claims}
272 (root / "names.json").write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
273 return len(claims), problems
274
275
276# ---------------------------------------------------------------- pyrtan.com's gate
277
278
279def gate(root: Path, *, changed: list[str] | None = None, author: str | None = None, author_id: int | None = None,
280 base: dict[str, bytes] | None = None) -> list[Problem]:
281 """pyrtan.com's rule for ``site/names/``, on top of every claim and document holding:
282 a name is a GitHub login, at most one per GitHub account (its numeric ``github`` id).
283 Given the pull request's ``changed`` paths, ``author`` (login), ``author_id`` and
284 ``base`` (``path -> bytes`` on the base branch), a new name must be the author's own
285 login and carry the author's id, and a name may be changed or removed only by the
286 account whose id it carried before."""
287 domain = (root / "site" / "CNAME").read_text(encoding="utf-8").strip() if (root / "site" / "CNAME").is_file() else "pyrtan.com"
288 directory = root / NAMES_DIR
289 claims, _, problems = read_directory(directory, domain, label=NAMES_DIR)
290 holders: dict[int, str] = {}
291 for stem, claim in claims.items():
292 where = f"{NAMES_DIR}/{stem}.json"
293 if not GITHUB_LOGIN.match(stem):
294 problems.append(Problem(where, "a name at pyrtan.com is a GitHub login"))
295 github = claim.get("github")
296 if not isinstance(github, int) or isinstance(github, bool) or github <= 0:
297 problems.append(Problem(where, "a claim at pyrtan.com carries \"github\": your numeric GitHub user id"))
298 continue
299 if github in holders:
300 problems.append(Problem(where, f"GitHub account {github} already holds {holders[github]}; one name per account"))
301 holders[github] = stem
302 if not changed:
303 return problems
304 base = base or {}
305 login = (author or "").lstrip("@").lower()
306 for c in changed:
307 if not c.startswith(f"{NAMES_DIR}/") or c.endswith("/README.md"):
308 continue
309 name = Path(c).name
310 stem = name[: -len(".id.json")] if name.endswith(".id.json") else name[: -len(".json")] if name.endswith(".json") else name
311 claim_path = f"{NAMES_DIR}/{stem}.json"
312 before = _github_of(base.get(claim_path))
313 if author_id is None:
314 problems.append(Problem(c, "the gate needs the pull request author's numeric id (--author-id) to judge a change to a name"))
315 continue
316 if before is not None:
317 if before != author_id:
318 problems.append(Problem(c, f"{stem} belongs to GitHub account {before}; only that account changes or removes it (a maintainer may merge over this)"))
319 elif stem in claims and claims[stem].get("github") != before:
320 problems.append(Problem(c, f"{stem} stays with GitHub account {before}; to move it, remove it and add your new login's name"))
321 continue
322 # A new name.
323 if stem not in claims:
324 continue # a stray file, already reported
325 if stem != login:
326 problems.append(Problem(c, f"a new name is your own GitHub login: {stem!r} is not {login or 'the author'}'s"))
327 if claims[stem].get("github") != author_id:
328 problems.append(Problem(c, f"the claim says github {claims[stem].get('github')}; the pull request's author is {author_id}"))
329 return problems
330
331
332def _github_of(raw: bytes | None) -> int | None:
333 if raw is None:
334 return None
335 try:
336 github = json.loads(raw.decode("utf-8")).get("github")
337 except (ValueError, AttributeError):
338 return None
339 return github if isinstance(github, int) and not isinstance(github, bool) else None
340
341
342def place(checkout: Path, claim: dict[str, Any], doc: dict[str, Any] | None) -> list[Path]:
343 """Write a claim (and the identity document) where ``checkout`` keeps names: a network's
344 names repository (``pyrtan-names.json`` and ``names/``), or this repository's ``site/names/``."""
345 network = repo_domain(checkout)
346 if network is not None:
347 directory, domain = checkout / "names", network
348 elif (checkout / "site").is_dir():
349 directory = checkout / NAMES_DIR
350 domain = (checkout / "site" / "CNAME").read_text(encoding="utf-8").strip() if (checkout / "site" / "CNAME").is_file() else "pyrtan.com"
351 else:
352 raise IdentityError(f"{checkout}: neither a names repository (pyrtan names init) nor a checkout of github.com/b-i-t-8/pyrtan")
353 if claim["domain"] != domain:
354 raise IdentityError(f"this checkout serves names at {domain}; the claim is for {claim['name']}@{claim['domain']}")
355 directory.mkdir(parents=True, exist_ok=True)
356 written = [directory / f"{claim['name']}.json"]
357 written[0].write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
358 if doc is not None:
359 written.append(directory / f"{claim['name']}.id.json")
360 written[1].write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
361 return written
362
363
364def github_id(login: str, *, fetch: Fetch | None = None) -> int:
365 """A GitHub login's numeric user id, from GitHub's public API."""
366 url = f"https://api.github.com/users/{login}"
367 try:
368 status, body = (fetch or _fetch)(url)
369 except Exception as e:
370 raise IdentityError(f"{url}: {e}; pass --github YOUR-NUMERIC-ID") from None
371 if status != 200 or not isinstance(body, dict) or not isinstance(body.get("id"), int):
372 raise IdentityError(f"{url} answered {status}; pass --github YOUR-NUMERIC-ID")
373 return body["id"]
374
375
376# ---------------------------------------------------------------- a network's own names repository
377
378REPO_FILE = "pyrtan-names.json" # at a names repository's root: {"pyrtan-names-repo": 1, "domain": ...}
379HOSTS = ("github", "gitlab", "plain")
380PYRTAN_GIT = "https://github.com/b-i-t-8/pyrtan"
381
382_README = """# Names at __DOMAIN__
383
384A name here is `<name>@__DOMAIN__`, pointing at a pyrtan identity (a key:
385`pyr:` and 56 characters). Anyone resolves it with
386
387 pyrtan id resolve <name>@__DOMAIN__
388
389which fetches `https://__DOMAIN__/.well-known/pyrtan/name/<name>.json`, checks
390that the key signed it, and pins the answer. The names are the files in
391`names/`; they are served by GitHub or GitLab Pages, or by any hub of the
392network started `pyrtan hub --names names --names-domain __DOMAIN__`.
393
394## Getting one
395
396In the pyrtan console, Identity tab: *Claim a name*, with this repository and
397`__DOMAIN__`. Or from a shell:
398
399 pyrtan id name <name>@__DOMAIN__ --into <a checkout of this repository>
400
401Then open a pull (or merge) request with `names/<name>.json`, and
402`names/<name>.id.json` if you want hubs to find your devices here.
403
404## Who may change a name
405
406Names here are owned by their keys, and the gate checks only signatures:
407
408- a new name is open to whoever signs it, while nobody holds it;
409- a name moves to a new key only with a handover its old key signs
410 (`pyrtan id name <name>@__DOMAIN__ --handover-from OLD-KEY`);
411- a name is given up by replacing its claim with a release its key signs
412 (`pyrtan id release <name>@__DOMAIN__`), never by deleting it;
413- an identity document is replaced only by a newer one its key signs.
414
415Nothing in this repository is secret: never put an invite, a token or anyone's
416key backup here, not even encrypted.
417"""
418
419_GITHUB_GATE = """# Generated by `pyrtan names init`: the names gate for __DOMAIN__.
420#
421# pull_request_target, so the gate is always this file as it is on the default
422# branch, and pyrtan comes from a pinned source, never from the pull request: a
423# pull request cannot change what judges it. The pull request's tree is checked
424# out as data and compared with its merge base; nothing in it is run. Read-only
425# token, no secrets, no credentials left in the checkouts, no cache.
426# Pin __REF__ to a commit of pyrtan you have read.
427
428name: names-gate
429
430on:
431 pull_request_target:
432 paths: ["names/**"]
433
434permissions:
435 contents: read
436
437jobs:
438 gate:
439 runs-on: ubuntu-latest
440 env:
441 BASE_SHA: ${{ github.event.pull_request.base.sha }}
442 steps:
443 - uses: actions/checkout@v4
444 with:
445 ref: ${{ github.event.pull_request.base.sha }}
446 path: base-repo
447 fetch-depth: 0
448 persist-credentials: false
449 - uses: actions/checkout@v4
450 with:
451 repository: ${{ github.event.pull_request.head.repo.full_name }}
452 ref: ${{ github.event.pull_request.head.sha }}
453 path: pr
454 fetch-depth: 0
455 persist-credentials: false
456 - name: The merge base, as data
457 run: |
458 git -C pr fetch --no-tags "$GITHUB_WORKSPACE/base-repo" "$BASE_SHA"
459 git -C pr worktree add --detach "$GITHUB_WORKSPACE/base" "$(git -C pr merge-base "$BASE_SHA" HEAD)"
460 - uses: actions/setup-python@v5
461 with:
462 python-version: "3.12"
463 - run: pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"
464 - run: pyrtan names gate pr/names --base base/names --domain __DOMAIN__
465"""
466
467_GITHUB_PAGES = """# Generated by `pyrtan names init`: serve names/ as https://__DOMAIN__/.well-known/pyrtan/.
468# Set the repository's Pages source to "GitHub Actions" and its custom domain to __DOMAIN__.
469
470name: names-pages
471
472on:
473 push:
474 branches: [main]
475 workflow_dispatch:
476
477permissions:
478 contents: read
479 pages: write
480 id-token: write
481
482concurrency:
483 group: pages
484 cancel-in-progress: true
485
486jobs:
487 build:
488 runs-on: ubuntu-latest
489 steps:
490 - uses: actions/checkout@v4
491 - uses: actions/setup-python@v5
492 with:
493 python-version: "3.12"
494 - run: pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"
495 - run: pyrtan id wellknown names --domain __DOMAIN__ --out site && echo __DOMAIN__ > site/CNAME
496 # v3, not v4: v4 leaves out dot-directories, and the names live in .well-known/.
497 - uses: actions/upload-pages-artifact@v3
498 with:
499 path: site
500
501 deploy:
502 needs: build
503 runs-on: ubuntu-latest
504 environment:
505 name: github-pages
506 url: ${{ steps.deployment.outputs.page_url }}
507 steps:
508 - id: deployment
509 uses: actions/deploy-pages@v4
510"""
511
512_GITLAB = """# Generated by `pyrtan names init`: the names gate for __DOMAIN__, and Pages.
513#
514# On GitLab a merge request's pipeline runs the merge request's own copy of this
515# file, so read any change to it before merging: the gate is pyrtan from a pinned
516# source, not from the merge request, but this file says which. Protect the
517# default branch and require the pipeline to pass. Pin __REF__ to a commit of
518# pyrtan you have read.
519
520variables:
521 GIT_DEPTH: "0"
522
523names-gate:
524 image: python:3.12
525 rules:
526 - if: $CI_PIPELINE_SOURCE == "merge_request_event"
527 changes: ["names/**/*"]
528 script:
529 - pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"
530 - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
531 - git worktree add --detach /tmp/base "$(git merge-base FETCH_HEAD HEAD)"
532 - pyrtan names gate names --base /tmp/base/names --domain __DOMAIN__
533
534pages:
535 image: python:3.12
536 rules:
537 - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
538 script:
539 - pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"
540 - pyrtan id wellknown names --domain __DOMAIN__ --out public
541 artifacts:
542 paths: [public]
543"""
544
545
546def init_repo(directory: Path, domain: str, *, host: str = "github", pyrtan_ref: str = "main") -> tuple[list[Path], list[Path]]:
547 """Write a names repository for ``domain`` into ``directory``: ``pyrtan-names.json``, a
548 README, ``names/``, and the gate and Pages for ``host``. Returns ``(written, kept)``: a file
549 that is already there is kept, never overwritten."""
550 domain = identity.check_host(domain.lower())
551 if host not in HOSTS:
552 raise IdentityError(f"host is one of {', '.join(HOSTS)}")
553 if not re.fullmatch(r"[A-Za-z0-9._/-]{1,80}", pyrtan_ref):
554 raise IdentityError(f"{pyrtan_ref!r} is not a git ref")
555 fill = lambda t: t.replace("__DOMAIN__", domain).replace("__REF__", pyrtan_ref).replace("__PYRTAN__", PYRTAN_GIT) # noqa: E731
556 files = {REPO_FILE: json.dumps({"pyrtan-names-repo": 1, "domain": domain}, indent=2) + "\n", "README.md": fill(_README), "names/.gitkeep": ""}
557 if host == "github":
558 files[".github/workflows/names-gate.yml"] = fill(_GITHUB_GATE)
559 files[".github/workflows/names-pages.yml"] = fill(_GITHUB_PAGES)
560 elif host == "gitlab":
561 files[".gitlab-ci.yml"] = fill(_GITLAB)
562 written, kept = [], []
563 for rel, text in files.items():
564 path = directory / rel
565 if path.exists():
566 kept.append(path)
567 continue
568 path.parent.mkdir(parents=True, exist_ok=True)
569 path.write_text(text, encoding="utf-8")
570 written.append(path)
571 return written, kept
572
573
574def repo_domain(checkout: Path) -> str | None:
575 """The domain a names repository (``pyrtan-names.json``) is for, if ``checkout`` is one."""
576 try:
577 data = json.loads((checkout / REPO_FILE).read_text(encoding="utf-8"))
578 except (OSError, json.JSONDecodeError):
579 return None
580 return data.get("domain") if isinstance(data, dict) and data.get("pyrtan-names-repo") == 1 and isinstance(data.get("domain"), str) else None
581