sci-protocol 0.2.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.
Files changed (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,205 @@
1
+ """Signed local inputs: candidate discovery, verification, sidecars, and ZIP materialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, BinaryIO, Literal
8
+
9
+ from pysci.bundle import (
10
+ VerificationResult,
11
+ read_zip_entry,
12
+ verify_directory,
13
+ verify_file,
14
+ verify_zip,
15
+ write_bundle_zip,
16
+ )
17
+ from pysci.errors import Outcome
18
+ from pysci.manifest import (
19
+ LICENSE_PATH,
20
+ MANIFEST_PATH,
21
+ METADATA_PATH,
22
+ README_PATH,
23
+ SIGNATURE_PATH,
24
+ TIMESTAMP_SIDECAR_PATH,
25
+ Manifest,
26
+ )
27
+ from pysci.source import FileSource, PathSource
28
+ from pysci.zipformat import parse_zip_layout
29
+
30
+ from ..errors import CliError, Exit, issue_json
31
+ from .files import copy_file_to
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class SignedInput:
36
+ path: Path
37
+ kind: Literal["zip", "directory"]
38
+ result: VerificationResult
39
+
40
+ @property
41
+ def manifest(self) -> Manifest:
42
+ assert self.result.manifest is not None
43
+ return self.result.manifest
44
+
45
+ @property
46
+ def digest(self) -> str:
47
+ return self.manifest.digest
48
+
49
+ @property
50
+ def publication_id(self) -> str:
51
+ return self.manifest.publication_id
52
+
53
+ @property
54
+ def zip_sha256(self) -> str:
55
+ assert self.result.zip_sha256 is not None
56
+ return self.result.zip_sha256
57
+
58
+ @property
59
+ def sidecar(self) -> Path | None:
60
+ return find_sidecar(self.path, self.kind, self.digest)
61
+
62
+
63
+ def find_sidecar(path: Path, kind: str, digest: str) -> Path | None:
64
+ """Locate a timestamp sidecar: ``manifest.json.sig.tsr`` or ``<digest>.tsr``."""
65
+ if kind == "directory":
66
+ candidate = path / TIMESTAMP_SIDECAR_PATH
67
+ return candidate if candidate.is_file() else None
68
+ for candidate in (path.parent / f"{digest}.tsr", path.with_suffix(".tsr")):
69
+ if candidate.is_file():
70
+ return candidate
71
+ return None
72
+
73
+
74
+ def sidecar_target(path: Path, kind: str, digest: str) -> Path:
75
+ if kind == "directory":
76
+ return path / TIMESTAMP_SIDECAR_PATH
77
+ return path.parent / f"{digest}.tsr"
78
+
79
+
80
+ def verify_input(path: Path, expected_id: str | None = None) -> tuple[str, VerificationResult]:
81
+ if path.is_dir():
82
+ return "directory", verify_directory(path, expected_id=expected_id, compute_zip_sha256=True)
83
+ return "zip", verify_zip(path, expected_id=expected_id)
84
+
85
+
86
+ def candidates(path: Path) -> list[Path]:
87
+ """Shared input rules: a ZIP or signed directory, else immediate ``*.zip`` children."""
88
+ if not path.exists():
89
+ raise CliError("input_missing", f"{path} does not exist", Exit.USAGE, file=str(path))
90
+ if path.is_file() or (path / MANIFEST_PATH).exists():
91
+ return [path]
92
+ found = sorted(p for p in path.iterdir() if p.suffix == ".zip" and p.is_file())
93
+ if not found:
94
+ raise CliError(
95
+ "no_candidates",
96
+ f"{path} is neither a signed publication nor a folder containing *.zip bundles",
97
+ Exit.USAGE,
98
+ file=str(path),
99
+ )
100
+ return found
101
+
102
+
103
+ def collect_signed_inputs(
104
+ paths: list[Path],
105
+ ) -> tuple[list[SignedInput], list[dict[str, Any]], list[dict[str, Any]]]:
106
+ """Verify every candidate (§6.4); return (valid deduplicated, invalid, duplicates)."""
107
+ valid: list[SignedInput] = []
108
+ invalid: list[dict[str, Any]] = []
109
+ duplicates: list[dict[str, Any]] = []
110
+ seen: dict[str, SignedInput] = {}
111
+ for root in paths:
112
+ for candidate in candidates(root):
113
+ kind, result = verify_input(candidate)
114
+ if not result.ok or result.manifest is None:
115
+ invalid.append(
116
+ {
117
+ "path": str(candidate),
118
+ "status": str(result.outcome),
119
+ "publication_id": result.publication_id,
120
+ "issues": [issue_json(i) for i in result.issues],
121
+ }
122
+ )
123
+ continue
124
+ item = SignedInput(candidate, "zip" if kind == "zip" else "directory", result)
125
+ if item.publication_id in seen:
126
+ duplicates.append(
127
+ {
128
+ "path": str(candidate),
129
+ "publication_id": item.publication_id,
130
+ "same_as": str(seen[item.publication_id].path),
131
+ }
132
+ )
133
+ continue
134
+ seen[item.publication_id] = item
135
+ valid.append(item)
136
+ return valid, invalid, duplicates
137
+
138
+
139
+ def invalid_exit(entries: list[dict[str, Any]]) -> Exit:
140
+ statuses = {e["status"] for e in entries}
141
+ if Outcome.UNSUPPORTED in statuses:
142
+ return Exit.UNSUPPORTED
143
+ if Outcome.INVALID in statuses:
144
+ return Exit.INVALID
145
+ return Exit.INCOMPLETE
146
+
147
+
148
+ def zip_manifest_and_signature(source: FileSource) -> tuple[bytes, bytes]:
149
+ entries = parse_zip_layout(source)
150
+ return (
151
+ source.read_at(entries[0].data_offset, entries[0].size),
152
+ source.read_at(entries[1].data_offset, entries[1].size),
153
+ )
154
+
155
+
156
+ def signature_of(item: SignedInput) -> bytes:
157
+ if item.kind == "directory":
158
+ return (item.path / SIGNATURE_PATH).read_bytes()
159
+ with FileSource(item.path) as source:
160
+ return zip_manifest_and_signature(source)[1]
161
+
162
+
163
+ def read_member(item: SignedInput, path: str) -> bytes:
164
+ """Return verified bytes of one bundle member (manifest, signature, or listed file)."""
165
+ if item.kind == "directory":
166
+ data = (item.path / path).read_bytes()
167
+ if path == MANIFEST_PATH:
168
+ if data != item.manifest.raw:
169
+ raise CliError("input_changed", f"{item.path}: manifest changed", Exit.INCOMPLETE)
170
+ return data
171
+ if path != SIGNATURE_PATH:
172
+ verify_file(item.manifest, path, data)
173
+ return data
174
+ with FileSource(item.path) as source:
175
+ if path in (MANIFEST_PATH, SIGNATURE_PATH):
176
+ manifest, signature = zip_manifest_and_signature(source)
177
+ if manifest != item.manifest.raw:
178
+ raise CliError("input_changed", f"{item.path}: manifest changed", Exit.INCOMPLETE)
179
+ return manifest if path == MANIFEST_PATH else signature
180
+ return read_zip_entry(source, path, expected_id=item.publication_id)[1]
181
+
182
+
183
+ def reader_paths(manifest: Manifest, display: list[str]) -> list[str]:
184
+ """Reader files: manifest, signature, metadata, README, any license, and display files."""
185
+ paths = [MANIFEST_PATH, SIGNATURE_PATH, METADATA_PATH, README_PATH]
186
+ if LICENSE_PATH in manifest.files:
187
+ paths.append(LICENSE_PATH)
188
+ paths.extend(p for p in display if p in manifest.files and p not in paths)
189
+ return paths
190
+
191
+
192
+ def reader_files(item: SignedInput) -> dict[str, bytes]:
193
+ display = [d.path for d in item.result.metadata.display] if item.result.metadata else []
194
+ return {path: read_member(item, path) for path in reader_paths(item.manifest, display)}
195
+
196
+
197
+ def write_zip(item: SignedInput, handle: BinaryIO) -> None:
198
+ """Write the canonical ZIP bytes of a verified input to ``handle``."""
199
+ if item.kind == "zip":
200
+ copy_file_to(handle, item.path)
201
+ return
202
+ manifest_bytes = (item.path / MANIFEST_PATH).read_bytes()
203
+ signature = (item.path / SIGNATURE_PATH).read_bytes()
204
+ files = {p: PathSource(item.path / p) for p in item.manifest.files}
205
+ write_bundle_zip(handle, manifest_bytes, signature, files)
@@ -0,0 +1,132 @@
1
+ """Signing key files: locations, creation with owner-only access, and loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
11
+
12
+ from pysci.keys import (
13
+ PublicKey,
14
+ generate_private_key,
15
+ load_private_key_pem,
16
+ load_public_key_pem,
17
+ private_key_to_pem,
18
+ public_key_of,
19
+ )
20
+
21
+ from ..errors import CliError, Exit
22
+ from .files import is_within
23
+
24
+
25
+ def public_key_path(private_path: Path) -> Path:
26
+ """Replace a final ``.pem`` with ``.pub.pem``, or append ``.pub.pem``."""
27
+ name = private_path.name
28
+ if name.endswith(".pem"):
29
+ return private_path.with_name(name[: -len(".pem")] + ".pub.pem")
30
+ return private_path.with_name(name + ".pub.pem")
31
+
32
+
33
+ def resolve_key_path(explicit: str | None, configured: str | None, home: Path) -> Path:
34
+ if explicit:
35
+ return Path(explicit).expanduser().absolute()
36
+ if configured:
37
+ return Path(configured).expanduser().absolute()
38
+ return home / "key.pem"
39
+
40
+
41
+ def create_key_pair(private_path: Path) -> tuple[PublicKey, Path]:
42
+ """Create a PKCS#8 private key (mode 0600) and its SubjectPublicKeyInfo public key.
43
+
44
+ Raises:
45
+ CliError: an output already exists (exit 2).
46
+ """
47
+ public_path = public_key_path(private_path)
48
+ for path in (private_path, public_path):
49
+ if path.exists() or path.is_symlink():
50
+ raise CliError(
51
+ "key_exists",
52
+ f"{path} already exists; keygen never overwrites keys",
53
+ Exit.USAGE,
54
+ file=str(path),
55
+ )
56
+ private_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
57
+ key = generate_private_key()
58
+ public = public_key_of(key)
59
+ fd = os.open(private_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
60
+ try:
61
+ with os.fdopen(fd, "wb") as handle:
62
+ os.fchmod(handle.fileno(), 0o600)
63
+ handle.write(private_key_to_pem(key))
64
+ handle.flush()
65
+ os.fsync(handle.fileno())
66
+ fd = os.open(public_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
67
+ with os.fdopen(fd, "wb") as handle:
68
+ handle.write(public.to_pem())
69
+ except BaseException:
70
+ with contextlib.suppress(OSError):
71
+ private_path.unlink()
72
+ raise
73
+ return public, public_path
74
+
75
+
76
+ def load_signing_key(path: Path) -> Ed25519PrivateKey:
77
+ try:
78
+ data = path.read_bytes()
79
+ except FileNotFoundError:
80
+ raise CliError(
81
+ "missing_key",
82
+ f"no private key at {path}; create one with `sci keygen` or pass --key PATH",
83
+ Exit.USAGE,
84
+ file=str(path),
85
+ ) from None
86
+ except OSError as exc:
87
+ raise CliError("key_unreadable", f"cannot read {path}: {exc}", Exit.USAGE) from None
88
+ try:
89
+ return load_private_key_pem(data)
90
+ except (ValueError, TypeError) as exc:
91
+ raise CliError(
92
+ "key_invalid",
93
+ f"{path} is not an unencrypted PKCS#8 Ed25519 private key: {exc}",
94
+ Exit.USAGE,
95
+ file=str(path),
96
+ ) from None
97
+
98
+
99
+ def key_permission_warning(path: Path) -> str | None:
100
+ try:
101
+ mode = path.stat().st_mode
102
+ except OSError:
103
+ return None
104
+ if os.name == "posix" and mode & (stat.S_IRWXG | stat.S_IRWXO):
105
+ return f"private key {path} is accessible by other users; run chmod 600 {path}"
106
+ return None
107
+
108
+
109
+ def require_outside(key_path: Path, *directories: Path) -> None:
110
+ """Private keys must stay outside publication input and output directories."""
111
+ for directory in directories:
112
+ if is_within(key_path, directory):
113
+ raise CliError(
114
+ "key_inside_publication",
115
+ f"private key {key_path} is inside {directory}; keep keys outside "
116
+ "publication input and output directories",
117
+ Exit.USAGE,
118
+ file=str(key_path),
119
+ )
120
+
121
+
122
+ def load_public_key_file(path: Path) -> PublicKey:
123
+ try:
124
+ return load_public_key_pem(path.read_bytes())
125
+ except FileNotFoundError:
126
+ raise CliError("public_key_missing", f"{path} does not exist", Exit.USAGE) from None
127
+ except (ValueError, TypeError) as exc:
128
+ raise CliError(
129
+ "public_key_invalid",
130
+ f"{path} is not a SubjectPublicKeyInfo PEM Ed25519 public key: {exc}",
131
+ Exit.USAGE,
132
+ ) from None
@@ -0,0 +1,88 @@
1
+ """Local publication: store verified ``<digest>.zip`` bundles and sidecars in folders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..clients.tsa import merge_proofs, read_sidecar
10
+ from ..config import Config
11
+ from ..errors import ErrorRecord, Exit
12
+ from .files import InputChanged, StoreOutcome, atomic_write_bytes, store_bytes, store_no_clobber
13
+ from .inputs import SignedInput, write_zip
14
+
15
+
16
+ def publish_to_folder(item: SignedInput, folder: Path) -> tuple[dict[str, Any], int]:
17
+ """Store one verified bundle; return (report, exit code).
18
+
19
+ Identical existing bundles are kept; conflicting bytes are rejected and
20
+ left untouched. Timestamp sidecars are copied as ``<digest>.tsr`` and
21
+ merged without duplicating proofs.
22
+ """
23
+ report: dict[str, Any] = {
24
+ "publication_id": item.publication_id,
25
+ "destination": str(folder),
26
+ "kind": "folder",
27
+ }
28
+ try:
29
+ folder.mkdir(parents=True, exist_ok=True)
30
+ target = folder / f"{item.digest}.zip"
31
+ stored = store_no_clobber(target, lambda handle: write_zip(item, handle), item.zip_sha256)
32
+ except InputChanged as exc:
33
+ report["status"] = "failed"
34
+ report["error"] = ErrorRecord("input_changed", str(exc), True).to_json()
35
+ return report, Exit.INCOMPLETE
36
+ except OSError as exc:
37
+ report["status"] = "failed"
38
+ report["error"] = ErrorRecord("write_failed", f"{folder}: {exc}", True).to_json()
39
+ return report, Exit.INCOMPLETE
40
+ report["path"] = str(target)
41
+ report["bundle"] = str(stored.outcome)
42
+ if stored.outcome is StoreOutcome.CONFLICT:
43
+ report["status"] = "failed"
44
+ report["error"] = ErrorRecord(
45
+ "destination_conflict",
46
+ f"{target} exists with different bytes; it was left unchanged",
47
+ file=str(target),
48
+ observed=f"sha256 {stored.existing_sha256}",
49
+ requirement=f"sha256 {item.zip_sha256}",
50
+ ).to_json()
51
+ return report, Exit.INVALID
52
+ report["status"] = "stored"
53
+ sidecar = item.sidecar
54
+ if sidecar is not None:
55
+ report["timestamp_sidecar"] = _copy_sidecar(sidecar, folder / f"{item.digest}.tsr")
56
+ return report, Exit.OK
57
+
58
+
59
+ def _copy_sidecar(source: Path, target: Path) -> dict[str, Any]:
60
+ if source.exists() and target.exists() and os.path.samefile(source, target):
61
+ return {"path": str(target), "status": "unchanged"}
62
+ data = read_sidecar(source) or b""
63
+ existing = read_sidecar(target)
64
+ if existing is None:
65
+ store_bytes(target, data)
66
+ return {"path": str(target), "status": "written"}
67
+ if existing == data:
68
+ return {"path": str(target), "status": "unchanged"}
69
+ merged, problems = merge_proofs(existing, data)
70
+ if problems:
71
+ return {
72
+ "path": str(target),
73
+ "status": "preserved",
74
+ "problems": [p.to_json() for p in problems],
75
+ }
76
+ if merged != existing:
77
+ atomic_write_bytes(target, merged)
78
+ return {"path": str(target), "status": "merged"}
79
+ return {"path": str(target), "status": "unchanged"}
80
+
81
+
82
+ def remember_folder(config: Config, folder: Path) -> bool:
83
+ """Add a folder's canonical absolute path to the known folders; True if added."""
84
+ canonical = str(folder.resolve())
85
+ if canonical in config.folders:
86
+ return False
87
+ config.folders.append(canonical)
88
+ return True
pysci/accounts.py ADDED
@@ -0,0 +1,135 @@
1
+ """External account methods for key claims (protocol §5.2.2-5.2.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import Final
10
+
11
+ import idna
12
+
13
+ __all__ = [
14
+ "Account",
15
+ "AccountType",
16
+ "account_problem",
17
+ "claim_urls",
18
+ "normalize_account",
19
+ ]
20
+
21
+ _LDH_LABEL: Final = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
22
+ _GITHUB_LOGIN: Final = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,38})")
23
+ _HF_NAME: Final = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}")
24
+
25
+
26
+ class AccountType(StrEnum):
27
+ DOMAIN = "domain"
28
+ GITHUB = "github"
29
+ HUGGINGFACE = "huggingface"
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Account:
34
+ """An account object ``{"type": ..., "id": ...}`` (§5.2.1)."""
35
+
36
+ type: str
37
+ id: str
38
+
39
+ def to_json(self) -> dict[str, str]:
40
+ return {"type": self.type, "id": self.id}
41
+
42
+
43
+ def _domain_problem(host: str) -> str | None:
44
+ if not host or not host.isascii():
45
+ return "domain must be an ASCII DNS host name (use IDNA2008 A-labels)"
46
+ if host != host.lower():
47
+ return "domain must be lowercase"
48
+ if host.endswith("."):
49
+ return "domain must not end with a dot"
50
+ try:
51
+ ipaddress.ip_address(host.strip("[]"))
52
+ except ValueError:
53
+ pass
54
+ else:
55
+ return "IP addresses are not permitted"
56
+ if len(host) > 253:
57
+ return "domain exceeds 253 characters"
58
+ labels = host.split(".")
59
+ for label in labels:
60
+ if not _LDH_LABEL.fullmatch(label):
61
+ return f"invalid DNS label {label!r}"
62
+ if labels[-1].isdigit():
63
+ return "top-level label must not be numeric"
64
+ try:
65
+ if idna.encode(host, uts46=False).decode("ascii") != host:
66
+ return "domain is not in IDNA2008 A-label form"
67
+ except idna.IDNAError as exc:
68
+ return f"invalid IDNA2008 label: {exc}"
69
+ return None
70
+
71
+
72
+ def account_problem(account_type: str, account_id: str) -> str | None:
73
+ """Why ``(type, id)`` violates its account method, or None if it is valid.
74
+
75
+ Unknown account types are reported as unsupported by the caller; this
76
+ function returns a message for them as well.
77
+ """
78
+ if account_type == AccountType.DOMAIN:
79
+ return _domain_problem(account_id)
80
+ if account_type == AccountType.GITHUB:
81
+ if account_id != account_id.lower():
82
+ return "GitHub login must be lowercase"
83
+ if not _GITHUB_LOGIN.fullmatch(account_id):
84
+ return "not a GitHub login"
85
+ return None
86
+ if account_type == AccountType.HUGGINGFACE:
87
+ return None if _HF_NAME.fullmatch(account_id) else "not a Hugging Face user or org name"
88
+ return f"unsupported account type {account_type!r}"
89
+
90
+
91
+ def normalize_account(account_type: str, account_id: str) -> Account:
92
+ """Normalize user input to the protocol spelling.
93
+
94
+ Domains are lowercased, a final dot removed, and Unicode labels converted
95
+ to IDNA2008 A-labels; GitHub logins are lowercased. Hugging Face names are
96
+ returned unchanged (their canonical spelling comes from the Hub API).
97
+
98
+ Raises:
99
+ ValueError: the result still violates the account method.
100
+ """
101
+ if account_type == AccountType.DOMAIN:
102
+ host = account_id.strip().rstrip(".")
103
+ try:
104
+ host = idna.encode(host, uts46=True).decode("ascii")
105
+ except idna.IDNAError as exc:
106
+ raise ValueError(f"invalid domain: {exc}") from None
107
+ account_id = host.lower()
108
+ elif account_type == AccountType.GITHUB:
109
+ account_id = account_id.strip().lower()
110
+ problem = account_problem(account_type, account_id)
111
+ if problem is not None:
112
+ raise ValueError(problem)
113
+ return Account(account_type, account_id)
114
+
115
+
116
+ def claim_urls(account: Account, x: str) -> list[tuple[str, str]]:
117
+ """Hosting locations ``(claim URL, signature URL)`` for a key's claim.
118
+
119
+ GitHub has two permitted repositories; both are returned in preference
120
+ order. Raises ValueError for unsupported account types.
121
+ """
122
+ if account.type == AccountType.DOMAIN:
123
+ bases = [f"https://{account.id}/.well-known/sci/keys/{x}.json"]
124
+ elif account.type == AccountType.GITHUB:
125
+ bases = [
126
+ f"https://raw.githubusercontent.com/{account.id}/{account.id}/main/sci/keys/{x}.json",
127
+ f"https://raw.githubusercontent.com/{account.id}/.github/main/sci/keys/{x}.json",
128
+ ]
129
+ elif account.type == AccountType.HUGGINGFACE:
130
+ bases = [
131
+ f"https://huggingface.co/spaces/{account.id}/README/resolve/main/sci/keys/{x}.json"
132
+ ]
133
+ else:
134
+ raise ValueError(f"unsupported account type {account.type!r}")
135
+ return [(url, url + ".sig") for url in bases]