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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""``sci spec``: print the SCI protocol specification bundled with this CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from pysci import PROTOCOL_VERSION
|
|
8
|
+
from pysci.specification import (
|
|
9
|
+
PROTOCOL_SPEC_SHA256,
|
|
10
|
+
protocol_specification,
|
|
11
|
+
specification_section,
|
|
12
|
+
specification_sections,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from ..context import Context
|
|
16
|
+
from ..errors import CliError
|
|
17
|
+
from ..output import Result
|
|
18
|
+
from ..spec import Command, arg
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
22
|
+
if args.list and args.section:
|
|
23
|
+
raise CliError("conflicting_options", "--list and --section cannot be combined")
|
|
24
|
+
result = Result("spec")
|
|
25
|
+
result.data = {"protocol": PROTOCOL_VERSION, "sha256": PROTOCOL_SPEC_SHA256}
|
|
26
|
+
if args.list:
|
|
27
|
+
sections = specification_sections()
|
|
28
|
+
result.data["sections"] = [{"number": n, "title": t} for n, t in sections]
|
|
29
|
+
for number, title in sections:
|
|
30
|
+
result.say(f"{' ' * number.count('.')}{number} {title}")
|
|
31
|
+
return result
|
|
32
|
+
if args.section:
|
|
33
|
+
text = specification_section(args.section.rstrip("."))
|
|
34
|
+
if text is None:
|
|
35
|
+
raise CliError(
|
|
36
|
+
"unknown_section",
|
|
37
|
+
f"the protocol has no section {args.section}; run `sci spec --list`",
|
|
38
|
+
observed=args.section,
|
|
39
|
+
)
|
|
40
|
+
result.data["section"] = args.section.rstrip(".")
|
|
41
|
+
else:
|
|
42
|
+
text = protocol_specification()
|
|
43
|
+
result.data["text"] = text
|
|
44
|
+
result.lines.extend(text.rstrip("\n").split("\n"))
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
COMMAND = Command(
|
|
49
|
+
name="spec",
|
|
50
|
+
usage_hint="spec [--section N | --list] [--json]",
|
|
51
|
+
summary="Print the SCI protocol specification.",
|
|
52
|
+
description="""
|
|
53
|
+
Prints the complete SCI protocol specification (Markdown) that this CLI and
|
|
54
|
+
its pysci library implement. The text is bundled with the installed package
|
|
55
|
+
and checked against the hash pinned by the conformance corpus, so it matches
|
|
56
|
+
the installed version exactly and needs no network access.
|
|
57
|
+
|
|
58
|
+
Use --section to print one numbered section with its subsections, for
|
|
59
|
+
example `--section 4` for publication metadata or `--section 4.4` for
|
|
60
|
+
citations. --list prints the section numbers and titles.
|
|
61
|
+
""",
|
|
62
|
+
args=(
|
|
63
|
+
arg("--section", metavar="N", help="print only section N, e.g. 4 or 4.4"),
|
|
64
|
+
arg("--list", action="store_true", help="list numbered sections and their titles"),
|
|
65
|
+
),
|
|
66
|
+
outputs="Specification Markdown, one section, or the section list; --json adds its SHA-256.",
|
|
67
|
+
files="None.",
|
|
68
|
+
network="None.",
|
|
69
|
+
exit_codes={2: "unknown section, or --list combined with --section"},
|
|
70
|
+
examples=("sci spec --section 4", "sci spec --list", "sci spec > sci-protocol.md"),
|
|
71
|
+
run=run,
|
|
72
|
+
)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""``sci publish``: store signed bundles in local folders or external archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..clients.credentials import github_token, huggingface_token
|
|
12
|
+
from ..clients.github import GitHubArchive
|
|
13
|
+
from ..clients.hosting import Prepared
|
|
14
|
+
from ..clients.huggingface import HuggingFaceArchive
|
|
15
|
+
from ..context import Context
|
|
16
|
+
from ..destinations import Destination, resolve_destinations
|
|
17
|
+
from ..errors import CliError, ErrorRecord, Exit, combine_exits
|
|
18
|
+
from ..output import Result
|
|
19
|
+
from ..spec import Command, arg
|
|
20
|
+
from ..storage.files import InputChanged, sha256_file, store_no_clobber
|
|
21
|
+
from ..storage.inputs import (
|
|
22
|
+
SignedInput,
|
|
23
|
+
collect_signed_inputs,
|
|
24
|
+
invalid_exit,
|
|
25
|
+
reader_files,
|
|
26
|
+
write_zip,
|
|
27
|
+
)
|
|
28
|
+
from ..storage.local_publish import publish_to_folder, remember_folder
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _snapshot(item: SignedInput, workdir: Path) -> SignedInput:
|
|
32
|
+
"""Copy a verified input's canonical ZIP (and sidecar) into the work directory."""
|
|
33
|
+
target = workdir / f"{item.digest}.zip"
|
|
34
|
+
try:
|
|
35
|
+
store_no_clobber(target, lambda handle: write_zip(item, handle), item.zip_sha256)
|
|
36
|
+
except InputChanged:
|
|
37
|
+
raise CliError(
|
|
38
|
+
"input_changed",
|
|
39
|
+
f"{item.path} changed after verification",
|
|
40
|
+
Exit.INCOMPLETE,
|
|
41
|
+
retryable=True,
|
|
42
|
+
) from None
|
|
43
|
+
sidecar = item.sidecar
|
|
44
|
+
if sidecar is not None:
|
|
45
|
+
shutil.copyfile(sidecar, workdir / f"{item.digest}.tsr")
|
|
46
|
+
return SignedInput(target, "zip", item.result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plan_folder(item: SignedInput, destination: Destination) -> str:
|
|
50
|
+
assert destination.path is not None
|
|
51
|
+
target = destination.path / f"{item.digest}.zip"
|
|
52
|
+
if not target.exists():
|
|
53
|
+
return "write"
|
|
54
|
+
return "keep identical" if sha256_file(target) == item.zip_sha256 else "conflict"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
58
|
+
destinations = resolve_destinations(args.to or [], list(ctx.config.folders), ctx.cwd)
|
|
59
|
+
paths = [ctx.resolve(p) for p in args.paths]
|
|
60
|
+
inputs, invalid, duplicates = collect_signed_inputs(paths)
|
|
61
|
+
result = Result("publish")
|
|
62
|
+
result.data = {
|
|
63
|
+
"destinations": [d.display for d in destinations],
|
|
64
|
+
"publications": [i.publication_id for i in inputs],
|
|
65
|
+
"invalid_candidates": invalid,
|
|
66
|
+
"duplicates": duplicates,
|
|
67
|
+
"results": [],
|
|
68
|
+
}
|
|
69
|
+
for candidate in invalid:
|
|
70
|
+
first = candidate["issues"][0]["message"] if candidate["issues"] else ""
|
|
71
|
+
result.say(f"not published (invalid): {candidate['path']} - {first}")
|
|
72
|
+
if not inputs:
|
|
73
|
+
result.exit_code = invalid_exit(invalid) if invalid else Exit.USAGE
|
|
74
|
+
result.errors.append(ErrorRecord("no_valid_inputs", "no valid signed publications found"))
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
if args.dry_run:
|
|
78
|
+
plan: list[dict[str, Any]] = []
|
|
79
|
+
for destination in destinations:
|
|
80
|
+
for item in inputs:
|
|
81
|
+
action = (
|
|
82
|
+
_plan_folder(item, destination)
|
|
83
|
+
if destination.kind == "folder"
|
|
84
|
+
else "upload reader files and bundle (repository must be public"
|
|
85
|
+
+ ("; created if missing)" if args.create else ")")
|
|
86
|
+
)
|
|
87
|
+
plan.append(
|
|
88
|
+
{
|
|
89
|
+
"destination": destination.display,
|
|
90
|
+
"publication_id": item.publication_id,
|
|
91
|
+
"action": action,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
result.say(f"{destination.display}: {item.publication_id}: {action}")
|
|
95
|
+
result.data["plan"] = plan
|
|
96
|
+
result.data["dry_run"] = True
|
|
97
|
+
if invalid:
|
|
98
|
+
result.exit_code = Exit.INCOMPLETE
|
|
99
|
+
result.partial = True
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
exits: list[int] = [invalid_exit(invalid)] if invalid else []
|
|
103
|
+
workdir = Path(tempfile.mkdtemp(prefix="sci-publish-"))
|
|
104
|
+
try:
|
|
105
|
+
snapshots = [_snapshot(item, workdir) for item in inputs]
|
|
106
|
+
prepared: list[Prepared] | None = None
|
|
107
|
+
for destination in destinations:
|
|
108
|
+
if destination.kind == "folder":
|
|
109
|
+
assert destination.path is not None
|
|
110
|
+
stored_any = False
|
|
111
|
+
for item in snapshots:
|
|
112
|
+
report, code = publish_to_folder(item, destination.path)
|
|
113
|
+
exits.append(code)
|
|
114
|
+
result.data["results"].append(report)
|
|
115
|
+
stored_any = stored_any or code == Exit.OK
|
|
116
|
+
if code == Exit.OK:
|
|
117
|
+
result.say(
|
|
118
|
+
f"{destination.display}: {item.publication_id}: {report['bundle']}"
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
error = report["error"]
|
|
122
|
+
result.errors.append(
|
|
123
|
+
ErrorRecord(error["code"], error["message"], error["retryable"])
|
|
124
|
+
)
|
|
125
|
+
result.say(f"{destination.display}: {item.publication_id}: failed")
|
|
126
|
+
if stored_any and remember_folder(ctx.config, destination.path):
|
|
127
|
+
ctx.save_config()
|
|
128
|
+
continue
|
|
129
|
+
if prepared is None:
|
|
130
|
+
prepared = []
|
|
131
|
+
for item in snapshots:
|
|
132
|
+
reader = reader_files(item)
|
|
133
|
+
sidecar = item.sidecar
|
|
134
|
+
if sidecar is not None:
|
|
135
|
+
reader["manifest.json.sig.tsr"] = sidecar.read_bytes()
|
|
136
|
+
prepared.append(Prepared(item, item.path, reader))
|
|
137
|
+
_hosted(ctx, args, destination, prepared, result, exits)
|
|
138
|
+
finally:
|
|
139
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
140
|
+
result.exit_code = combine_exits(exits)
|
|
141
|
+
result.partial = result.exit_code == Exit.INCOMPLETE and Exit.OK in exits
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _hosted(
|
|
146
|
+
ctx: Context,
|
|
147
|
+
args: argparse.Namespace,
|
|
148
|
+
destination: Destination,
|
|
149
|
+
prepared: list[Prepared],
|
|
150
|
+
result: Result,
|
|
151
|
+
exits: list[int],
|
|
152
|
+
) -> None:
|
|
153
|
+
assert destination.owner is not None
|
|
154
|
+
assert destination.repo is not None
|
|
155
|
+
try:
|
|
156
|
+
archive: GitHubArchive | HuggingFaceArchive
|
|
157
|
+
if destination.kind == "github":
|
|
158
|
+
token = github_token(ctx.env, ctx.runner)
|
|
159
|
+
archive = GitHubArchive(
|
|
160
|
+
ctx.http,
|
|
161
|
+
destination.owner,
|
|
162
|
+
destination.repo,
|
|
163
|
+
token,
|
|
164
|
+
notice=lambda message: print(message, file=ctx.stderr, flush=True),
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
token = huggingface_token(ctx.env)
|
|
168
|
+
archive = HuggingFaceArchive(ctx.http, destination.owner, destination.repo, token)
|
|
169
|
+
summary, reports = archive.publish(prepared, create=args.create, timeout=args.timeout)
|
|
170
|
+
except CliError as exc:
|
|
171
|
+
exits.append(exc.exit_code)
|
|
172
|
+
result.errors.append(exc.records[0])
|
|
173
|
+
for pub in prepared:
|
|
174
|
+
result.data["results"].append(
|
|
175
|
+
{
|
|
176
|
+
"publication_id": pub.item.publication_id,
|
|
177
|
+
"destination": destination.display,
|
|
178
|
+
"kind": destination.kind,
|
|
179
|
+
"status": "failed",
|
|
180
|
+
"error": exc.records[0].to_json(),
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
result.say(f"{destination.display}: failed - {exc.records[0].message}")
|
|
184
|
+
return
|
|
185
|
+
availability = summary["availability"]
|
|
186
|
+
for report in reports:
|
|
187
|
+
entry = report.to_json()
|
|
188
|
+
entry.update({"destination": destination.display, "kind": destination.kind})
|
|
189
|
+
result.data["results"].append(entry)
|
|
190
|
+
exits.append(report.exit_code if report.status != "hosted" else Exit.OK)
|
|
191
|
+
if report.error is not None:
|
|
192
|
+
result.errors.append(report.error)
|
|
193
|
+
result.say(f"{destination.display}: {report.publication_id}: {report.status}")
|
|
194
|
+
result.data.setdefault("archives", []).append({"destination": destination.display, **summary})
|
|
195
|
+
result.say(f" descriptor: {summary['descriptor_url']}")
|
|
196
|
+
if not availability["confirmed"]:
|
|
197
|
+
exits.append(Exit.INCOMPLETE)
|
|
198
|
+
result.errors.append(
|
|
199
|
+
ErrorRecord(
|
|
200
|
+
"availability_unconfirmed",
|
|
201
|
+
f"{destination.display}: public availability of reader files is not yet confirmed "
|
|
202
|
+
"(CDN caches may lag); retry later",
|
|
203
|
+
True,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
elif not availability["reader_cors"]:
|
|
207
|
+
result.warn(f"{destination.display}: reader files lack CORS headers for browser readers")
|
|
208
|
+
bundle_cors = [c for c in availability["checks"] if c["label"] == "bundle"]
|
|
209
|
+
if bundle_cors and not all(c.get("cors") for c in bundle_cors):
|
|
210
|
+
result.warn(
|
|
211
|
+
f"{destination.display}: bundle downloads lack CORS headers; browser readers use "
|
|
212
|
+
"entry_url instead"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
COMMAND = Command(
|
|
217
|
+
name="publish",
|
|
218
|
+
usage_hint=(
|
|
219
|
+
"publish PATH ... --to DESTINATION [--to DESTINATION ...] [--create] [--dry-run] [--json]"
|
|
220
|
+
),
|
|
221
|
+
summary="Store signed bundles in local folders or external archives.",
|
|
222
|
+
description="""
|
|
223
|
+
PATH is a signed ZIP, a signed directory, or a folder of *.zip bundles. Each
|
|
224
|
+
candidate is verified completely (SCI protocol §6.4) and deduplicated by ID;
|
|
225
|
+
invalid candidates are reported and not published. Publishing never
|
|
226
|
+
contacts an indexer (see `sci submit`).
|
|
227
|
+
|
|
228
|
+
Destinations (repeat --to; equivalent addresses are merged):
|
|
229
|
+
DIR store <digest>.zip (+ <digest>.tsr)
|
|
230
|
+
@local all known folders
|
|
231
|
+
https://github.com/OWNER/REPO reader files on main + release assets
|
|
232
|
+
https://huggingface.co/datasets/OWNER/REPO reader files + ZIPs (Git LFS)
|
|
233
|
+
SSH forms (git@github.com:OWNER/REPO.git, git@hf.co:datasets/OWNER/REPO) and
|
|
234
|
+
ssh:// URLs are accepted; file or branch URLs, queries, and fragments are not.
|
|
235
|
+
|
|
236
|
+
Local folders: <digest>.zip is written atomically; identical files are kept and
|
|
237
|
+
conflicting bytes rejected. Hosted archives: the repository must be public
|
|
238
|
+
(or created public with --create; visibility is never changed). Reader files
|
|
239
|
+
(manifest, signature, sci.json, README, LICENSE, display files, timestamp
|
|
240
|
+
sidecar) go to sci/publications/<id2>/<id>/ on main; sci/archive.json and a
|
|
241
|
+
sorted sci/publications.txt are maintained in batched commits without
|
|
242
|
+
force-pushing. GitHub stores <id>.zip as an asset of the shared release
|
|
243
|
+
sci-<id2>, named for the first two ID characters (never marked latest);
|
|
244
|
+
Hugging Face stores
|
|
245
|
+
bundles/<id2>/<id>.zip via LFS. Retries compare remote digests and reuse
|
|
246
|
+
matching content. Credentials: `gh auth login` or GH_TOKEN/GITHUB_TOKEN;
|
|
247
|
+
`hf auth login` or HF_TOKEN.
|
|
248
|
+
""",
|
|
249
|
+
args=(
|
|
250
|
+
arg(
|
|
251
|
+
"paths",
|
|
252
|
+
metavar="PATH",
|
|
253
|
+
nargs="+",
|
|
254
|
+
help="signed ZIP, signed directory, or campaign folder",
|
|
255
|
+
),
|
|
256
|
+
arg(
|
|
257
|
+
"--to",
|
|
258
|
+
metavar="DESTINATION",
|
|
259
|
+
action="append",
|
|
260
|
+
help="destination (repeatable, required)",
|
|
261
|
+
),
|
|
262
|
+
arg("--create", action="store_true", help="create a missing hosted repository as public"),
|
|
263
|
+
arg("--dry-run", action="store_true", help="list the plan without writes or uploads"),
|
|
264
|
+
),
|
|
265
|
+
outputs="""One result per publication and destination (stored/hosted/failed, reused or
|
|
266
|
+
uploaded files), hosted descriptor URLs, and public-availability and CORS
|
|
267
|
+
checks.""",
|
|
268
|
+
files="""Writes <digest>.zip and <digest>.tsr in folder destinations and remembers those
|
|
269
|
+
folders in config.json. Hosted destinations change only their repositories.""",
|
|
270
|
+
network="None for folders. GitHub REST API / Hugging Face Hub API uploads for hosted "
|
|
271
|
+
"destinations, then unauthenticated availability checks.",
|
|
272
|
+
exit_codes={
|
|
273
|
+
3: "an input failed verification, or a destination holds conflicting bytes",
|
|
274
|
+
5: "an upload or availability check did not complete, or results are mixed",
|
|
275
|
+
6: "the provider refused the operation (permissions)",
|
|
276
|
+
},
|
|
277
|
+
examples=(
|
|
278
|
+
"sci publish ./campaign --to ./mirror --to https://github.com/lab/research --create",
|
|
279
|
+
),
|
|
280
|
+
run=run,
|
|
281
|
+
timeout=True,
|
|
282
|
+
)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""``sci pull``: save a complete verified publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pysci.bundle import VerificationResult, extract_verified, verify_directory
|
|
14
|
+
from pysci.der import frame_values
|
|
15
|
+
from pysci.errors import Outcome
|
|
16
|
+
from pysci.keys import PublicKey
|
|
17
|
+
from pysci.manifest import MANIFEST_PATH, SIGNATURE_PATH, TIMESTAMP_SIDECAR_PATH
|
|
18
|
+
from pysci.timestamps import ProofResult
|
|
19
|
+
|
|
20
|
+
from ..checks import baseline_check, bundle_check, checks, describe_checks, timestamps_check
|
|
21
|
+
from ..clients.archive import ArchiveSource, SourceFailure, failure_exit
|
|
22
|
+
from ..clients.index import IndexClient
|
|
23
|
+
from ..clients.tsa import merge_proofs, read_sidecar, verify_sidecar_bytes
|
|
24
|
+
from ..context import Context
|
|
25
|
+
from ..errors import CliError, ErrorRecord, Exit, exit_for_outcome
|
|
26
|
+
from ..output import Result
|
|
27
|
+
from ..retrieval import resolve_sources
|
|
28
|
+
from ..spec import Command, arg
|
|
29
|
+
from ..storage.files import atomic_write_bytes, directory_is_empty
|
|
30
|
+
from ..storage.inputs import SignedInput, signature_of
|
|
31
|
+
from ..storage.keys import load_public_key_file
|
|
32
|
+
from ..storage.local_publish import publish_to_folder, remember_folder
|
|
33
|
+
from .common import parse_id, trust
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _existing(output: Path, digest: str, zip_mode: bool) -> VerificationResult | None:
|
|
37
|
+
"""A complete verified copy already at the destination, else None; conflicts raise."""
|
|
38
|
+
if zip_mode:
|
|
39
|
+
target = output / f"{digest}.zip"
|
|
40
|
+
if not target.exists():
|
|
41
|
+
return None
|
|
42
|
+
from pysci.bundle import verify_zip
|
|
43
|
+
|
|
44
|
+
result = verify_zip(target, expected_id=digest)
|
|
45
|
+
if result.ok:
|
|
46
|
+
return result
|
|
47
|
+
raise CliError(
|
|
48
|
+
"destination_conflict",
|
|
49
|
+
f"{target} exists but is not a verified copy of sha256:{digest}",
|
|
50
|
+
Exit.INVALID,
|
|
51
|
+
file=str(target),
|
|
52
|
+
)
|
|
53
|
+
if not output.exists():
|
|
54
|
+
return None
|
|
55
|
+
if not output.is_dir():
|
|
56
|
+
raise CliError("destination_conflict", f"{output} exists and is not a directory")
|
|
57
|
+
if directory_is_empty(output):
|
|
58
|
+
return None
|
|
59
|
+
result = verify_directory(output, expected_id=digest)
|
|
60
|
+
if result.ok:
|
|
61
|
+
return result
|
|
62
|
+
raise CliError(
|
|
63
|
+
"destination_not_empty",
|
|
64
|
+
f"{output} is not empty and is not a complete verified copy of sha256:{digest}",
|
|
65
|
+
Exit.USAGE,
|
|
66
|
+
file=str(output),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _retrieve(
|
|
71
|
+
archive: ArchiveSource, ctx: Context, deadline: Any, workdir: Path
|
|
72
|
+
) -> tuple[str, Path, VerificationResult]:
|
|
73
|
+
"""Retrieve a complete bundle from one archive: complete ZIP, else individual files."""
|
|
74
|
+
descriptor = archive.descriptor(deadline)
|
|
75
|
+
if descriptor.bundle_url is not None:
|
|
76
|
+
got = archive.verify_complete_zip(deadline)
|
|
77
|
+
if got is not None:
|
|
78
|
+
path, result = got
|
|
79
|
+
if not result.ok:
|
|
80
|
+
record = ErrorRecord(
|
|
81
|
+
result.issues[0].code if result.issues else "invalid",
|
|
82
|
+
result.issues[0].requirement if result.issues else "bundle failed checks",
|
|
83
|
+
result.outcome is Outcome.INCOMPLETE,
|
|
84
|
+
extra={"route": "bundle_url"},
|
|
85
|
+
)
|
|
86
|
+
raise SourceFailure(record, exit_for_outcome(result.outcome))
|
|
87
|
+
return "zip", path, result
|
|
88
|
+
manifest = archive.manifest(deadline)
|
|
89
|
+
loose = Path(tempfile.mkdtemp(dir=workdir, prefix=".sci-tmp-loose-"))
|
|
90
|
+
(loose / MANIFEST_PATH).write_bytes(manifest.raw)
|
|
91
|
+
(loose / SIGNATURE_PATH).write_bytes(archive.signature(deadline))
|
|
92
|
+
for member in manifest.sorted_paths():
|
|
93
|
+
data = archive.file(member, deadline)
|
|
94
|
+
target = loose / member
|
|
95
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
target.write_bytes(data)
|
|
97
|
+
result = verify_directory(loose, expected_id=manifest.digest, compute_zip_sha256=True)
|
|
98
|
+
if not result.ok:
|
|
99
|
+
raise SourceFailure(
|
|
100
|
+
ErrorRecord(
|
|
101
|
+
"invalid", "assembled bundle failed complete checks", extra={"route": "entry_url"}
|
|
102
|
+
),
|
|
103
|
+
exit_for_outcome(result.outcome),
|
|
104
|
+
)
|
|
105
|
+
return "directory", loose, result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _proofs(
|
|
109
|
+
ctx: Context,
|
|
110
|
+
archives: list[ArchiveSource],
|
|
111
|
+
index: IndexClient | None,
|
|
112
|
+
digest: str,
|
|
113
|
+
deadline: Any,
|
|
114
|
+
signature: bytes,
|
|
115
|
+
warnings: list[str],
|
|
116
|
+
) -> tuple[bytes | None, dict[str, Any]]:
|
|
117
|
+
combined = b""
|
|
118
|
+
sources: list[str] = []
|
|
119
|
+
for archive in archives:
|
|
120
|
+
data = archive.sidecar(deadline)
|
|
121
|
+
if data:
|
|
122
|
+
combined, problems = merge_proofs(combined, data)
|
|
123
|
+
sources.append(archive.descriptor_url)
|
|
124
|
+
warnings.extend(f"proofs from {archive.descriptor_url}: {p.message}" for p in problems)
|
|
125
|
+
if index is not None:
|
|
126
|
+
try:
|
|
127
|
+
data = index.timestamps(digest, deadline)
|
|
128
|
+
except CliError as exc:
|
|
129
|
+
warnings.append(f"timestamp lookup failed: {exc}")
|
|
130
|
+
data = None
|
|
131
|
+
if data:
|
|
132
|
+
combined, problems = merge_proofs(combined, data)
|
|
133
|
+
sources.append(f"{index.origin} timestamp lookup")
|
|
134
|
+
warnings.extend(f"index proofs: {p.message}" for p in problems)
|
|
135
|
+
if not combined:
|
|
136
|
+
return None, timestamps_check(
|
|
137
|
+
None,
|
|
138
|
+
source=None,
|
|
139
|
+
online=False,
|
|
140
|
+
absent_reason="no proofs were retrieved from the sources",
|
|
141
|
+
)
|
|
142
|
+
verification = verify_sidecar_bytes(combined, signature, trust(ctx))
|
|
143
|
+
kept = b"".join(
|
|
144
|
+
combined[p.offset : p.offset + p.length]
|
|
145
|
+
for p in verification.proofs
|
|
146
|
+
if p.result is not ProofResult.INVALID
|
|
147
|
+
)
|
|
148
|
+
report = timestamps_check(verification, source=", ".join(sources), online=False)
|
|
149
|
+
report["kept_proofs"] = len(frame_values(kept).values) if kept else 0
|
|
150
|
+
return kept or None, report
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
154
|
+
digest = parse_id(args.id)
|
|
155
|
+
expected_key: PublicKey | None = None
|
|
156
|
+
if args.public_key:
|
|
157
|
+
expected_key = load_public_key_file(ctx.resolve(args.public_key))
|
|
158
|
+
zip_mode = bool(args.zip)
|
|
159
|
+
if zip_mode:
|
|
160
|
+
output = ctx.resolve(args.output or ".")
|
|
161
|
+
else:
|
|
162
|
+
output = ctx.resolve(args.output) if args.output else ctx.cwd / digest
|
|
163
|
+
result = Result("pull")
|
|
164
|
+
result.data = {"publication_id": "sha256:" + digest, "output": str(output)}
|
|
165
|
+
existing = _existing(output, digest, zip_mode)
|
|
166
|
+
if existing is not None and existing.manifest is not None:
|
|
167
|
+
if expected_key is not None and existing.manifest.public_key != expected_key:
|
|
168
|
+
raise CliError(
|
|
169
|
+
"public_key_mismatch", "signer does not match --public-key", Exit.INVALID
|
|
170
|
+
)
|
|
171
|
+
result.data.update(
|
|
172
|
+
{
|
|
173
|
+
"status": "already_present",
|
|
174
|
+
"checks": checks(bundle=bundle_check(existing), baseline=baseline_check(existing)),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
result.say(f"sha256:{digest} is already present and verified at {output}")
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
deadline = ctx.deadline(args.timeout)
|
|
181
|
+
sources = resolve_sources(
|
|
182
|
+
ctx, digest, archives=args.archive, index_url=args.in_, deadline=deadline
|
|
183
|
+
)
|
|
184
|
+
parent = output if zip_mode else output.parent
|
|
185
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
workdir = Path(tempfile.mkdtemp(dir=parent, prefix=".sci-tmp-pull-"))
|
|
187
|
+
archives: list[ArchiveSource] = []
|
|
188
|
+
try:
|
|
189
|
+
failures: list[SourceFailure] = []
|
|
190
|
+
retrieved: tuple[str, Path, VerificationResult] | None = None
|
|
191
|
+
used: ArchiveSource | None = None
|
|
192
|
+
for url in sources.descriptors:
|
|
193
|
+
archive = ArchiveSource(ctx.http, url, digest, workdir)
|
|
194
|
+
archives.append(archive)
|
|
195
|
+
try:
|
|
196
|
+
retrieved = _retrieve(archive, ctx, deadline, workdir)
|
|
197
|
+
used = archive
|
|
198
|
+
break
|
|
199
|
+
except SourceFailure as exc:
|
|
200
|
+
exc.record.extra["descriptor"] = url
|
|
201
|
+
failures.append(exc)
|
|
202
|
+
except CliError as exc:
|
|
203
|
+
if exc.code == "deadline_exceeded":
|
|
204
|
+
raise
|
|
205
|
+
failures.append(SourceFailure(exc.records[0], exc.exit_code))
|
|
206
|
+
if retrieved is None or used is None:
|
|
207
|
+
code = failure_exit(failures)
|
|
208
|
+
error = CliError(
|
|
209
|
+
"retrieval_failed",
|
|
210
|
+
f"no source provided a complete verified copy of sha256:{digest}",
|
|
211
|
+
code,
|
|
212
|
+
retryable=code == Exit.INCOMPLETE,
|
|
213
|
+
)
|
|
214
|
+
error.records.extend(f.record for f in failures)
|
|
215
|
+
raise error
|
|
216
|
+
kind, path, verification = retrieved
|
|
217
|
+
assert verification.manifest is not None
|
|
218
|
+
if expected_key is not None and verification.manifest.public_key != expected_key:
|
|
219
|
+
raise CliError(
|
|
220
|
+
"public_key_mismatch",
|
|
221
|
+
"signer does not match --public-key",
|
|
222
|
+
Exit.INVALID,
|
|
223
|
+
field="/public_key/x",
|
|
224
|
+
observed=verification.manifest.public_key.x,
|
|
225
|
+
requirement=f"x = {expected_key.x}",
|
|
226
|
+
)
|
|
227
|
+
item = SignedInput(path, "zip" if kind == "zip" else "directory", verification)
|
|
228
|
+
proofs, ts_report = _proofs(
|
|
229
|
+
ctx, archives, sources.index, digest, deadline, signature_of(item), result.warnings
|
|
230
|
+
)
|
|
231
|
+
if zip_mode:
|
|
232
|
+
stored, store_code = publish_to_folder(item, output)
|
|
233
|
+
result.data["stored"] = stored
|
|
234
|
+
if store_code != Exit.OK:
|
|
235
|
+
error = stored["error"]
|
|
236
|
+
raise CliError(error["code"], error["message"], store_code)
|
|
237
|
+
if remember_folder(ctx.config, output):
|
|
238
|
+
ctx.save_config()
|
|
239
|
+
if proofs:
|
|
240
|
+
tsr = output / f"{digest}.tsr"
|
|
241
|
+
merged, _ = merge_proofs(read_sidecar(tsr) or b"", proofs)
|
|
242
|
+
atomic_write_bytes(tsr, merged)
|
|
243
|
+
ts_report["written"] = str(tsr)
|
|
244
|
+
result.say(f"saved {stored['path']} ({stored['bundle']})")
|
|
245
|
+
else:
|
|
246
|
+
staging = workdir / "final"
|
|
247
|
+
if kind == "zip":
|
|
248
|
+
extracted = extract_verified(path, staging, expected_id=digest)
|
|
249
|
+
if not extracted.ok:
|
|
250
|
+
raise CliError(
|
|
251
|
+
"extraction_failed", "bundle changed during extraction", Exit.INCOMPLETE
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
os.rename(path, staging)
|
|
255
|
+
if proofs:
|
|
256
|
+
atomic_write_bytes(staging / TIMESTAMP_SIDECAR_PATH, proofs)
|
|
257
|
+
ts_report["written"] = str(output / TIMESTAMP_SIDECAR_PATH)
|
|
258
|
+
if output.exists():
|
|
259
|
+
output.rmdir() # empty, checked above
|
|
260
|
+
os.rename(staging, output)
|
|
261
|
+
result.say(f"saved sha256:{digest} to {output}")
|
|
262
|
+
result.data.update(
|
|
263
|
+
{
|
|
264
|
+
"status": "retrieved",
|
|
265
|
+
"source": {"descriptor": used.descriptor_url, "routes": used.routes},
|
|
266
|
+
"source_failures": [f.record.to_json() for f in failures],
|
|
267
|
+
"signer": verification.manifest.public_key.to_jwk(),
|
|
268
|
+
"checks": checks(
|
|
269
|
+
bundle=bundle_check(verification),
|
|
270
|
+
baseline=baseline_check(verification),
|
|
271
|
+
timestamps=ts_report,
|
|
272
|
+
),
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
result.lines.extend(describe_checks(result.data["checks"]))
|
|
276
|
+
if failures:
|
|
277
|
+
result.warn(f"{len(failures)} source(s) failed before {used.descriptor_url} succeeded")
|
|
278
|
+
return result
|
|
279
|
+
finally:
|
|
280
|
+
for archive in archives:
|
|
281
|
+
archive.close()
|
|
282
|
+
with contextlib.suppress(OSError):
|
|
283
|
+
shutil.rmtree(workdir)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
COMMAND = Command(
|
|
287
|
+
name="pull",
|
|
288
|
+
usage_hint=(
|
|
289
|
+
"pull ID [--output DIR] [--zip] [--in URL | --archive ARCHIVE ...] "
|
|
290
|
+
"[--public-key PATH] [--json]"
|
|
291
|
+
),
|
|
292
|
+
summary="Save a complete verified publication.",
|
|
293
|
+
description="""
|
|
294
|
+
Finds sources through the configured index or --in URL (SCI protocol
|
|
295
|
+
§8.1.1), or uses repeated --archive descriptor URLs or GitHub/Hugging Face
|
|
296
|
+
repository addresses without an index. Each
|
|
297
|
+
source is tried in turn against the same expected ID. The complete ZIP
|
|
298
|
+
(bundle_url) is preferred; otherwise files are fetched individually
|
|
299
|
+
(entry_url, blob_url). Downloads are verified completely (§6.4) in a
|
|
300
|
+
temporary location before extraction. No catalog admission is required.
|
|
301
|
+
|
|
302
|
+
The default output is ./<digest>/. An existing complete verified copy is
|
|
303
|
+
kept; other nonempty destinations are rejected. --zip stores <digest>.zip in
|
|
304
|
+
--output DIR (default: current folder) under the local publishing rules.
|
|
305
|
+
Timestamp proofs are retrieved from archive entry_url and the index's
|
|
306
|
+
timestamp lookup and reported separately; proof retrieval failure does not
|
|
307
|
+
fail the download.
|
|
308
|
+
""",
|
|
309
|
+
args=(
|
|
310
|
+
arg("id", metavar="ID", help="publication ID: sha256:<digest> or sci:sha256:<digest>"),
|
|
311
|
+
arg("--output", metavar="DIR", help="destination directory (default: ./<digest>/)"),
|
|
312
|
+
arg(
|
|
313
|
+
"--zip", action="store_true", help="store <digest>.zip in --output DIR instead of files"
|
|
314
|
+
),
|
|
315
|
+
arg("--in", dest="in_", metavar="URL", help="index origin (default: configured index)"),
|
|
316
|
+
arg(
|
|
317
|
+
"--archive",
|
|
318
|
+
metavar="ARCHIVE",
|
|
319
|
+
action="append",
|
|
320
|
+
help="descriptor URL or GitHub/Hugging Face repository address (repeatable)",
|
|
321
|
+
),
|
|
322
|
+
arg("--public-key", metavar="PATH", help="require the signer to match this public PEM key"),
|
|
323
|
+
),
|
|
324
|
+
outputs="Destination, source used, source failures, and the checks object.",
|
|
325
|
+
files="""Creates the destination directory (or <digest>.zip and <digest>.tsr with --zip);
|
|
326
|
+
temporary files use .sci-tmp- names and are removed.""",
|
|
327
|
+
network="Index lookup (unless --archive), archive descriptor and file or bundle GETs, "
|
|
328
|
+
"and timestamp lookups.",
|
|
329
|
+
exit_codes={
|
|
330
|
+
3: "every source served content failing verification, or the signer does not match",
|
|
331
|
+
4: "unsupported descriptor or bundle schema",
|
|
332
|
+
5: "no known sources (index 404 or empty list), sources unreachable, or deadline",
|
|
333
|
+
},
|
|
334
|
+
examples=("sci pull sha256:<digest> --in https://sciscape.io --output ./paper",),
|
|
335
|
+
run=run,
|
|
336
|
+
timeout=True,
|
|
337
|
+
)
|