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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|