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
pysci/der.py ADDED
@@ -0,0 +1,146 @@
1
+ """Minimal DER framing for timestamp files (protocol §6.8).
2
+
3
+ :func:`frame_values` splits concatenated DER values by tag and definite
4
+ minimal length. It stops at the first value that is not complete -- a
5
+ truncated tag or length, an indefinite or nonminimal length, a length beyond
6
+ the remaining bytes, or a length beyond a local bound -- and never scans
7
+ ahead to resynchronize.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Final
14
+
15
+ __all__ = [
16
+ "DerFraming",
17
+ "encode_length",
18
+ "frame_values",
19
+ "read_header",
20
+ "validate_der_structure",
21
+ ]
22
+
23
+ MAX_LENGTH_OCTETS: Final = 8
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class DerFraming:
28
+ """Complete values found as ``(offset, length)`` pairs, and where parsing stopped.
29
+
30
+ ``complete`` is True when the values cover the whole input and the input
31
+ is nonempty.
32
+ """
33
+
34
+ values: tuple[tuple[int, int], ...]
35
+ stop_offset: int
36
+ total: int
37
+
38
+ @property
39
+ def complete(self) -> bool:
40
+ return self.total > 0 and self.stop_offset == self.total
41
+
42
+
43
+ def read_header(
44
+ data: bytes | memoryview, offset: int, end: int | None = None, max_length: int | None = None
45
+ ) -> tuple[int, int, int] | None:
46
+ """Parse the tag and length at ``offset``.
47
+
48
+ Returns ``(tag_first_octet, content_offset, content_length)`` for a
49
+ complete definite-length value that fits before ``end``, else None.
50
+ Multi-octet tags must be minimally encoded; lengths must use the minimal
51
+ DER form.
52
+ """
53
+ limit = len(data) if end is None else end
54
+ position = offset
55
+ if position >= limit:
56
+ return None
57
+ first = data[position]
58
+ position += 1
59
+ if first & 0x1F == 0x1F: # high-tag-number form
60
+ leading = True
61
+ while True:
62
+ if position >= limit:
63
+ return None
64
+ octet = data[position]
65
+ position += 1
66
+ if leading and octet & 0x7F == 0:
67
+ return None # nonminimal tag number
68
+ leading = False
69
+ if not octet & 0x80:
70
+ break
71
+ if position >= limit:
72
+ return None
73
+ octet = data[position]
74
+ position += 1
75
+ if octet < 0x80:
76
+ length = octet
77
+ else:
78
+ count = octet & 0x7F
79
+ if count == 0 or count > MAX_LENGTH_OCTETS:
80
+ return None # indefinite length or beyond the local bound
81
+ if count > limit - position:
82
+ return None
83
+ if data[position] == 0:
84
+ return None # leading zero octet: nonminimal
85
+ length = int.from_bytes(bytes(data[position : position + count]), "big")
86
+ position += count
87
+ if length < 0x80:
88
+ return None # long form for a short length: nonminimal
89
+ if max_length is not None and length > max_length:
90
+ return None
91
+ if length > limit - position:
92
+ return None
93
+ return first, position, length
94
+
95
+
96
+ def frame_values(data: bytes, *, max_value_length: int | None = None) -> DerFraming:
97
+ """Delimit concatenated DER values without resynchronizing (§6.8)."""
98
+ values: list[tuple[int, int]] = []
99
+ position = 0
100
+ while position < len(data):
101
+ header = read_header(data, position, max_length=max_value_length)
102
+ if header is None:
103
+ break
104
+ _, content, length = header
105
+ end = content + length
106
+ values.append((position, end - position))
107
+ position = end
108
+ return DerFraming(tuple(values), position, len(data))
109
+
110
+
111
+ def validate_der_structure(data: bytes, max_depth: int = 64) -> bool:
112
+ """True if ``data`` is exactly one value whose nested framing is definite and minimal.
113
+
114
+ Constructed values are descended recursively (up to ``max_depth``) and
115
+ must be fully consumed by their children. Primitive contents are not
116
+ interpreted.
117
+ """
118
+ view = memoryview(data)
119
+
120
+ def walk(start: int, end: int, depth: int) -> bool:
121
+ position = start
122
+ while position < end:
123
+ header = read_header(view, position, end)
124
+ if header is None:
125
+ return False
126
+ first, content, length = header
127
+ constructed = bool(first & 0x20)
128
+ if constructed and (
129
+ depth >= max_depth or not walk(content, content + length, depth + 1)
130
+ ):
131
+ return False
132
+ position = content + length
133
+ return True
134
+
135
+ header = read_header(view, 0)
136
+ if header is None or header[1] + header[2] != len(data):
137
+ return False
138
+ return walk(0, len(data), 0)
139
+
140
+
141
+ def encode_length(length: int) -> bytes:
142
+ """DER definite-length octets for ``length``."""
143
+ if length < 0x80:
144
+ return bytes([length])
145
+ body = length.to_bytes((length.bit_length() + 7) // 8, "big")
146
+ return bytes([0x80 | len(body)]) + body
pysci/discovery.py ADDED
@@ -0,0 +1,461 @@
1
+ """Discovery and retrieval interfaces (protocol §8).
2
+
3
+ Descriptor parsing and validation, template expansion helpers, and
4
+ interpretation of lookup responses. No network access is performed here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum
12
+ from typing import Final
13
+
14
+ from .citations import is_http_uri
15
+ from .der import DerFraming, frame_values
16
+ from .errors import Incomplete, Invalid, Issue, IssueCollector, Outcome
17
+ from .ids import is_digest, parse_publication_id
18
+ from .jsonstrict import JsonObject, JsonValue, json_type_name, parse_json_object
19
+ from .schemas import ARCHIVE_SCHEMA, INDEX_SCHEMA, check_schema
20
+ from .templates import Expression, UrlTemplate, parse_template
21
+
22
+ __all__ = [
23
+ "ArchiveDescriptor",
24
+ "DescriptorKind",
25
+ "IndexDescriptor",
26
+ "TimestampLookup",
27
+ "archive_descriptor_url",
28
+ "index_descriptor_url",
29
+ "is_absolute_url",
30
+ "parse_archive_descriptor",
31
+ "parse_descriptor",
32
+ "parse_publication_list",
33
+ "parse_publication_lookup",
34
+ "parse_timestamp_lookup",
35
+ "revocation_lookup_result",
36
+ "template_variables",
37
+ ]
38
+
39
+ _SAMPLE: Final = {
40
+ "id": "0" * 64,
41
+ "id2": "00",
42
+ "x": "A" * 43,
43
+ "file": "revocation.json.sig",
44
+ "sha256": "0" * 64,
45
+ "path": "files/data/a.txt",
46
+ }
47
+
48
+ _INDEX_TEMPLATES: Final[dict[str, frozenset[Expression]]] = {
49
+ "publication_lookup_url": frozenset({Expression("", "id")}),
50
+ "revocation_lookup_url": frozenset({Expression("", "x"), Expression("", "file")}),
51
+ "timestamp_lookup_url": frozenset({Expression("", "id")}),
52
+ }
53
+ _INDEX_PLAIN: Final = ("submit_url", "policy_url", "docs_url")
54
+ _ARCHIVE_TEMPLATES: Final[dict[str, frozenset[Expression]]] = {
55
+ "bundle_url": frozenset({Expression("", "id"), Expression("", "id2")}),
56
+ "entry_url": frozenset({Expression("", "id"), Expression("", "id2"), Expression("+", "path")}),
57
+ "blob_url": frozenset({Expression("", "sha256")}),
58
+ }
59
+ _ARCHIVE_PLAIN: Final = ("list_url",)
60
+ REVOCATION_FILES: Final = ("revocation.json", "revocation.json.sig", "revocation.json.sig.tsr")
61
+
62
+
63
+ class DescriptorKind(StrEnum):
64
+ INDEX = "index"
65
+ ARCHIVE = "archive"
66
+
67
+
68
+ def is_absolute_url(value: str) -> bool:
69
+ """True for an absolute ``http``/``https`` URL (descriptor URLs, §8)."""
70
+ return is_http_uri(value)
71
+
72
+
73
+ def template_variables(
74
+ publication_id: str,
75
+ *,
76
+ path: str | None = None,
77
+ sha256: str | None = None,
78
+ x: str | None = None,
79
+ file: str | None = None,
80
+ ) -> dict[str, str]:
81
+ """Variables for §8 templates from a publication ID in any accepted spelling."""
82
+ digest = parse_publication_id(publication_id, allow_bare=True)
83
+ variables = {"id": digest, "id2": digest[:2]}
84
+ if path is not None:
85
+ variables["path"] = path
86
+ if sha256 is not None:
87
+ variables["sha256"] = sha256
88
+ if x is not None:
89
+ variables["x"] = x
90
+ if file is not None:
91
+ variables["file"] = file
92
+ return variables
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class IndexDescriptor:
97
+ """A validated ``sci-index/0.1`` descriptor (§8.1)."""
98
+
99
+ publication_lookup_url: UrlTemplate
100
+ revocation_lookup_url: UrlTemplate
101
+ timestamp_lookup_url: UrlTemplate
102
+ submit_url: str | None = None
103
+ policy_url: str | None = None
104
+ docs_url: str | None = None
105
+ extra: Mapping[str, JsonValue] = field(default_factory=dict)
106
+
107
+ def publication_lookup(self, publication_id: str) -> str:
108
+ return self.publication_lookup_url.expand(template_variables(publication_id))
109
+
110
+ def timestamp_lookup(self, publication_id: str) -> str:
111
+ return self.timestamp_lookup_url.expand(template_variables(publication_id))
112
+
113
+ def revocation_lookup(self, x: str, file: str) -> str:
114
+ if file not in REVOCATION_FILES:
115
+ raise ValueError(f"file must be one of {REVOCATION_FILES}")
116
+ return self.revocation_lookup_url.expand({"x": x, "file": file})
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class ArchiveDescriptor:
121
+ """A validated ``sci-archive/0.1`` descriptor (§8.2)."""
122
+
123
+ bundle_url: UrlTemplate | None = None
124
+ entry_url: UrlTemplate | None = None
125
+ blob_url: UrlTemplate | None = None
126
+ list_url: str | None = None
127
+ extra: Mapping[str, JsonValue] = field(default_factory=dict)
128
+
129
+ def bundle(self, publication_id: str) -> str | None:
130
+ return (
131
+ None
132
+ if self.bundle_url is None
133
+ else self.bundle_url.expand(template_variables(publication_id))
134
+ )
135
+
136
+ def entry(self, publication_id: str, path: str) -> str | None:
137
+ return (
138
+ None
139
+ if self.entry_url is None
140
+ else self.entry_url.expand(template_variables(publication_id, path=path))
141
+ )
142
+
143
+ def blob(self, sha256: str) -> str | None:
144
+ if not is_digest(sha256):
145
+ raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
146
+ return None if self.blob_url is None else self.blob_url.expand({"sha256": sha256})
147
+
148
+
149
+ def _check_template(
150
+ record: JsonObject,
151
+ name: str,
152
+ permitted: frozenset[Expression],
153
+ required: bool,
154
+ issues: IssueCollector,
155
+ file: str,
156
+ ) -> UrlTemplate | None:
157
+ value = record.get(name)
158
+ if not isinstance(value, str):
159
+ issues.add(
160
+ "descriptor_field_type",
161
+ f"{name} must be a string",
162
+ section="8",
163
+ file=file,
164
+ field="/" + name,
165
+ observed=json_type_name(value),
166
+ )
167
+ return None
168
+ try:
169
+ template = parse_template(value)
170
+ except ValueError as exc:
171
+ issues.add(
172
+ "descriptor_template",
173
+ f"{name} must be a valid URI template: {exc}",
174
+ section="8",
175
+ file=file,
176
+ field="/" + name,
177
+ observed=value,
178
+ )
179
+ return None
180
+ found = template.expressions
181
+ unknown = sorted(str(e) for e in found - permitted)
182
+ if unknown:
183
+ issues.add(
184
+ "descriptor_expression",
185
+ f"{name} permits only {', '.join(sorted(map(str, permitted))) or 'no expressions'}",
186
+ section="8",
187
+ file=file,
188
+ field="/" + name,
189
+ observed=unknown,
190
+ )
191
+ return None
192
+ if required and found != permitted:
193
+ missing = sorted(str(e) for e in permitted - found)
194
+ issues.add(
195
+ "descriptor_expression",
196
+ f"{name} requires {', '.join(missing)}",
197
+ section="8.1",
198
+ file=file,
199
+ field="/" + name,
200
+ observed=value,
201
+ )
202
+ return None
203
+ if not is_absolute_url(template.expand(_SAMPLE)):
204
+ issues.add(
205
+ "descriptor_absolute_url",
206
+ f"{name} must expand to an absolute URL",
207
+ section="8",
208
+ file=file,
209
+ field="/" + name,
210
+ observed=value,
211
+ )
212
+ return None
213
+ return template
214
+
215
+
216
+ def _check_plain(record: JsonObject, name: str, issues: IssueCollector, file: str) -> str | None:
217
+ value = record[name]
218
+ if not isinstance(value, str):
219
+ issues.add(
220
+ "descriptor_field_type",
221
+ f"{name} must be a string",
222
+ section="8",
223
+ file=file,
224
+ field="/" + name,
225
+ observed=json_type_name(value),
226
+ )
227
+ return None
228
+ if "{" in value or "}" in value:
229
+ issues.add(
230
+ "descriptor_expression",
231
+ f"{name} permits no template expressions",
232
+ section="8",
233
+ file=file,
234
+ field="/" + name,
235
+ observed=value,
236
+ )
237
+ return None
238
+ if not is_absolute_url(value):
239
+ issues.add(
240
+ "descriptor_absolute_url",
241
+ f"{name} must be an absolute URL",
242
+ section="8",
243
+ file=file,
244
+ field="/" + name,
245
+ observed=value,
246
+ )
247
+ return None
248
+ return value
249
+
250
+
251
+ def parse_index_descriptor(data: bytes, *, file: str = "index.json") -> IndexDescriptor:
252
+ """Validate an index descriptor (§8.1). Raises Invalid/Unsupported with all issues."""
253
+ record = parse_json_object(data, file=file)
254
+ check_schema(record, INDEX_SCHEMA, file=file)
255
+ issues = IssueCollector()
256
+ templates: dict[str, UrlTemplate | None] = {}
257
+ for name, permitted in _INDEX_TEMPLATES.items():
258
+ if name not in record:
259
+ issues.add(
260
+ "descriptor_required",
261
+ f"{name} is required",
262
+ section="8.1",
263
+ file=file,
264
+ field="/" + name,
265
+ )
266
+ continue
267
+ templates[name] = _check_template(record, name, permitted, True, issues, file)
268
+ plain = {
269
+ name: _check_plain(record, name, issues, file) for name in _INDEX_PLAIN if name in record
270
+ }
271
+ issues.raise_if_any()
272
+ known = {"schema", *_INDEX_TEMPLATES, *_INDEX_PLAIN}
273
+ publication, revocation, timestamp = (templates[n] for n in _INDEX_TEMPLATES)
274
+ assert publication
275
+ assert revocation
276
+ assert timestamp
277
+ return IndexDescriptor(
278
+ publication,
279
+ revocation,
280
+ timestamp,
281
+ plain.get("submit_url"),
282
+ plain.get("policy_url"),
283
+ plain.get("docs_url"),
284
+ {k: v for k, v in record.items() if k not in known},
285
+ )
286
+
287
+
288
+ def parse_archive_descriptor(data: bytes, *, file: str = "archive.json") -> ArchiveDescriptor:
289
+ """Validate an archive descriptor (§8.2).
290
+
291
+ Besides field syntax, at least one of ``bundle_url`` and ``entry_url`` is
292
+ required, since an archive must serve the manifest and signature through
293
+ one of them.
294
+ """
295
+ record = parse_json_object(data, file=file)
296
+ check_schema(record, ARCHIVE_SCHEMA, file=file)
297
+ issues = IssueCollector()
298
+ templates = {
299
+ name: _check_template(record, name, permitted, False, issues, file)
300
+ for name, permitted in _ARCHIVE_TEMPLATES.items()
301
+ if name in record
302
+ }
303
+ plain = {
304
+ name: _check_plain(record, name, issues, file) for name in _ARCHIVE_PLAIN if name in record
305
+ }
306
+ if "bundle_url" not in record and "entry_url" not in record:
307
+ issues.add(
308
+ "descriptor_required",
309
+ "an archive must provide bundle_url or entry_url to serve the manifest and signature",
310
+ section="8.2",
311
+ file=file,
312
+ )
313
+ issues.raise_if_any()
314
+ known = {"schema", *_ARCHIVE_TEMPLATES, *_ARCHIVE_PLAIN}
315
+ return ArchiveDescriptor(
316
+ templates.get("bundle_url"),
317
+ templates.get("entry_url"),
318
+ templates.get("blob_url"),
319
+ plain.get("list_url"),
320
+ {k: v for k, v in record.items() if k not in known},
321
+ )
322
+
323
+
324
+ def parse_descriptor(
325
+ data: bytes, kind: DescriptorKind | str
326
+ ) -> IndexDescriptor | ArchiveDescriptor:
327
+ """Validate a descriptor of the given kind."""
328
+ if DescriptorKind(kind) is DescriptorKind.INDEX:
329
+ return parse_index_descriptor(data)
330
+ return parse_archive_descriptor(data)
331
+
332
+
333
+ def index_descriptor_url(origin: str) -> str:
334
+ """``https://<index>/.well-known/sci/index.json`` for an index host or origin."""
335
+ host = origin.removeprefix("https://").removeprefix("http://").rstrip("/")
336
+ return f"https://{host}/.well-known/sci/index.json"
337
+
338
+
339
+ def archive_descriptor_url(
340
+ *,
341
+ domain: str | None = None,
342
+ github: tuple[str, str] | None = None,
343
+ huggingface_repository_url: str | None = None,
344
+ ) -> str:
345
+ """Descriptor discovery locations (§8.2.1); supply exactly one source."""
346
+ if sum(x is not None for x in (domain, github, huggingface_repository_url)) != 1:
347
+ raise ValueError("supply exactly one of domain, github, huggingface_repository_url")
348
+ if domain is not None:
349
+ return f"https://{domain}/.well-known/sci/archive.json"
350
+ if github is not None:
351
+ owner, repo = github
352
+ return f"https://raw.githubusercontent.com/{owner}/{repo}/main/sci/archive.json"
353
+ assert huggingface_repository_url is not None
354
+ return huggingface_repository_url.rstrip("/") + "/resolve/main/sci/archive.json"
355
+
356
+
357
+ # --------------------------------------------------------------------------- responses
358
+
359
+
360
+ def _unexpected_status(status: int, what: str) -> Incomplete:
361
+ return Incomplete(
362
+ Issue(
363
+ "unexpected_status",
364
+ f"{what} returned an unexpected status",
365
+ section="8.1",
366
+ observed=str(status),
367
+ outcome=Outcome.INCOMPLETE,
368
+ )
369
+ )
370
+
371
+
372
+ def parse_publication_lookup(status: int, body: bytes) -> list[str]:
373
+ """Interpret a publication lookup response (§8.1.1).
374
+
375
+ ``200`` must carry ``{"descriptors": [absolute URLs...]}``; ``404`` means
376
+ the index has no sources (an empty list). Other statuses raise
377
+ :class:`~pysci.errors.Incomplete`.
378
+
379
+ Raises:
380
+ Invalid: a ``200`` body violates §8.1.1.
381
+ """
382
+ if status == 404:
383
+ return []
384
+ if status != 200:
385
+ raise _unexpected_status(status, "publication lookup")
386
+ record = parse_json_object(body, file="publication lookup")
387
+ descriptors = record.get("descriptors")
388
+ if not isinstance(descriptors, list) or not all(
389
+ isinstance(url, str) and is_absolute_url(url) for url in descriptors
390
+ ):
391
+ raise Invalid(
392
+ Issue(
393
+ "lookup_descriptors",
394
+ "descriptors must be an array of absolute archive descriptor URLs",
395
+ section="8.1.1",
396
+ observed=json_type_name(descriptors),
397
+ )
398
+ )
399
+ return [url for url in descriptors if isinstance(url, str)]
400
+
401
+
402
+ @dataclass(frozen=True, slots=True)
403
+ class TimestampLookup:
404
+ """Interpretation of a timestamp lookup (§8.1.3).
405
+
406
+ ``existence`` is ``"unknown"`` for 404: a missing proof does not prove that
407
+ no timestamp exists. Retained proofs must still be verified by the reader.
408
+ """
409
+
410
+ proofs_retained: bool
411
+ existence: str
412
+ data: bytes | None = None
413
+ framing: DerFraming | None = None
414
+
415
+ @property
416
+ def proof_count(self) -> int:
417
+ return 0 if self.framing is None else len(self.framing.values)
418
+
419
+ readers_must_verify: bool = True
420
+
421
+
422
+ def parse_timestamp_lookup(status: int, body: bytes) -> TimestampLookup:
423
+ """Interpret a timestamp lookup response; the body is a §6.8 timestamp file."""
424
+ if status == 404:
425
+ return TimestampLookup(False, "unknown")
426
+ if status != 200:
427
+ raise _unexpected_status(status, "timestamp lookup")
428
+ return TimestampLookup(True, "retained", body, frame_values(body))
429
+
430
+
431
+ def revocation_lookup_result(status: int, body: bytes) -> bytes | None:
432
+ """Revocation lookup (§8.1.2): file bytes for 200, None for 404 (status unknown)."""
433
+ if status == 404:
434
+ return None
435
+ if status != 200:
436
+ raise _unexpected_status(status, "revocation lookup")
437
+ return body
438
+
439
+
440
+ def parse_publication_list(body: bytes) -> list[str]:
441
+ """Parse a ``list_url`` body: UTF-8, one bare publication ID per LF-terminated line."""
442
+ try:
443
+ text = body.decode("utf-8")
444
+ except UnicodeDecodeError:
445
+ raise Invalid(
446
+ Issue("list_encoding", "publication list must be UTF-8", section="8.2")
447
+ ) from None
448
+ if text and not text.endswith("\n"):
449
+ raise Invalid(Issue("list_format", "each line must be LF-terminated", section="8.2"))
450
+ ids = text.split("\n")[:-1] if text else []
451
+ bad = [line for line in ids if not is_digest(line)]
452
+ if bad:
453
+ raise Invalid(
454
+ Issue(
455
+ "list_format",
456
+ "each line must be a 64-character lowercase digest",
457
+ section="8.2",
458
+ observed=bad[0],
459
+ )
460
+ )
461
+ return ids