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,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
+ )