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,364 @@
1
+ """``sci read``: read verified text or retrieve one file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import tempfile
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pysci.bundle import Scope, read_zip_entry, verify_directory, verify_file, verify_zip
12
+ from pysci.errors import SciError
13
+ from pysci.manifest import LICENSE_PATH, METADATA_PATH, README_PATH, Manifest
14
+ from pysci.metadata import parse_metadata
15
+ from pysci.source import FileSource
16
+ from pysci.text import decode_text_file
17
+
18
+ from ..checks import checks, not_performed
19
+ from ..clients.archive import ArchiveSource, SourceFailure, failure_exit
20
+ from ..context import Context
21
+ from ..errors import CliError, Exit, exit_for_outcome, issue_record
22
+ from ..output import Result
23
+ from ..retrieval import resolve_sources
24
+ from ..spec import Command, arg
25
+ from ..storage.files import StoreOutcome, store_bytes
26
+ from .common import looks_like_id, parse_id
27
+
28
+ DEFAULT_LINES = 200
29
+ Getter = Callable[[str], bytes]
30
+
31
+
32
+ def _local(path: Path) -> tuple[Manifest, Getter, dict[str, Any]]:
33
+ if path.is_dir():
34
+ verification = verify_directory(path, scope=Scope.MANIFEST)
35
+ else:
36
+ verification = verify_zip(path, scope=Scope.MANIFEST)
37
+ if not verification.ok or verification.manifest is None:
38
+ error = CliError(
39
+ verification.issues[0].code if verification.issues else "invalid",
40
+ "manifest checks failed (§6.1)",
41
+ exit_for_outcome(verification.outcome),
42
+ )
43
+ error.records.extend(issue_record(i) for i in verification.issues)
44
+ raise error
45
+ manifest = verification.manifest
46
+
47
+ def get(member: str) -> bytes:
48
+ try:
49
+ if path.is_dir():
50
+ data = (path / member).read_bytes()
51
+ verify_file(manifest, member, data)
52
+ return data
53
+ with FileSource(path) as source:
54
+ return read_zip_entry(source, member, expected_id=manifest.publication_id)[1]
55
+ except SciError as exc:
56
+ raise CliError.from_sci(exc, f"{member} failed file checks (§6.2)") from None
57
+ except OSError as exc:
58
+ raise CliError("input_unavailable", str(exc), Exit.INCOMPLETE, retryable=True) from None
59
+
60
+ return manifest, get, {"kind": "directory" if path.is_dir() else "zip", "path": str(path)}
61
+
62
+
63
+ def _remote(
64
+ args: argparse.Namespace, ctx: Context, digest: str, workdir: Path
65
+ ) -> tuple[Manifest, Getter, dict[str, Any]]:
66
+ deadline = ctx.deadline(args.timeout)
67
+ sources = resolve_sources(
68
+ ctx, digest, archives=args.archive, index_url=args.in_, deadline=deadline
69
+ )
70
+ failures: list[SourceFailure] = []
71
+ archives = [ArchiveSource(ctx.http, url, digest, workdir) for url in sources.descriptors]
72
+ usable: list[ArchiveSource] = []
73
+ manifest: Manifest | None = None
74
+ for archive in archives:
75
+ try:
76
+ manifest = archive.manifest(deadline)
77
+ usable.append(archive)
78
+ except SourceFailure as exc:
79
+ exc.record.extra["descriptor"] = archive.descriptor_url
80
+ failures.append(exc)
81
+ if manifest is None:
82
+ error = CliError(
83
+ "retrieval_failed",
84
+ f"no source provided a verified manifest for sha256:{digest}",
85
+ failure_exit(failures),
86
+ retryable=failure_exit(failures) == Exit.INCOMPLETE,
87
+ )
88
+ error.records.extend(f.record for f in failures)
89
+ raise error
90
+
91
+ def get(member: str) -> bytes:
92
+ errors: list[SourceFailure] = []
93
+ for archive in usable:
94
+ try:
95
+ return archive.file(member, deadline)
96
+ except SourceFailure as exc:
97
+ exc.record.extra["descriptor"] = archive.descriptor_url
98
+ errors.append(exc)
99
+ error = CliError(
100
+ "retrieval_failed",
101
+ f"no source provided verified bytes for {member}",
102
+ failure_exit(errors),
103
+ retryable=failure_exit(errors) == Exit.INCOMPLETE,
104
+ )
105
+ error.records.extend(e.record for e in errors)
106
+ raise error
107
+
108
+ info: dict[str, Any] = {
109
+ "kind": "remote",
110
+ "descriptors": sources.descriptors,
111
+ "index": sources.index.origin if sources.index else None,
112
+ "used": [a.descriptor_url for a in usable],
113
+ "source_failures": [f.record.to_json() for f in failures],
114
+ }
115
+ info["_archives"] = usable
116
+ return manifest, get, info
117
+
118
+
119
+ def _text_range(text: str, path: str, start: int, count: int) -> tuple[dict[str, Any], list[str]]:
120
+ lines = text.splitlines()
121
+ begin = max(start, 1) - 1
122
+ selected = lines[begin : begin + count]
123
+ end = begin + len(selected)
124
+ truncated = end < len(lines)
125
+ info = {
126
+ "path": path,
127
+ "start_line": begin + 1,
128
+ "end_line": end,
129
+ "total_lines": len(lines),
130
+ "truncated": truncated,
131
+ "next_line": end + 1 if truncated else None,
132
+ "lines": selected,
133
+ }
134
+ return info, selected
135
+
136
+
137
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
138
+ if args.list and args.file:
139
+ raise CliError("conflicting_options", "--list and --file cannot be combined")
140
+ if args.output and not args.file:
141
+ raise CliError("invalid_arguments", "--output requires --file")
142
+ if args.start_line < 1 or args.lines < 1:
143
+ raise CliError("invalid_arguments", "--start-line and --lines must be positive")
144
+ target = args.id_or_path
145
+ remote = looks_like_id(target)
146
+ if not remote and (args.archive or args.in_):
147
+ raise CliError("invalid_arguments", "--archive and --in apply only to publication IDs")
148
+ with tempfile.TemporaryDirectory(prefix="sci-read-") as workdir:
149
+ if remote:
150
+ manifest, get, source = _remote(args, ctx, parse_id(target), Path(workdir))
151
+ else:
152
+ path = ctx.resolve(target)
153
+ if not path.exists():
154
+ raise CliError(
155
+ "input_missing",
156
+ f"{path} does not exist (publication IDs start with sha256:)",
157
+ Exit.USAGE,
158
+ )
159
+ manifest, get, source = _local(path)
160
+ try:
161
+ return _render(args, ctx, manifest, get, source)
162
+ finally:
163
+ for archive in source.pop("_archives", []):
164
+ archive.close()
165
+
166
+
167
+ def _render(
168
+ args: argparse.Namespace,
169
+ ctx: Context,
170
+ manifest: Manifest,
171
+ get: Getter,
172
+ source: dict[str, Any],
173
+ ) -> Result:
174
+ result = Result("read")
175
+ verified: list[str] = []
176
+ data: dict[str, Any] = {
177
+ "publication_id": manifest.publication_id,
178
+ "signer": manifest.public_key.to_jwk(),
179
+ "source": source,
180
+ }
181
+ result.data = data
182
+ result.say(f"publication: {manifest.publication_id}")
183
+ if args.list:
184
+ files: list[dict[str, Any]] = [
185
+ {"path": p, "bytes": manifest.files[p].bytes, "sha256": manifest.files[p].sha256}
186
+ for p in manifest.sorted_paths()
187
+ ]
188
+ data["files"] = files
189
+ data["verified"] = "manifest checks (§6.1); listed files were not retrieved"
190
+ for entry in files:
191
+ result.say(f"{entry['bytes']:>12} {entry['sha256'][:16]} {entry['path']}")
192
+ elif args.file:
193
+ member = args.file
194
+ if member not in manifest.files:
195
+ raise CliError(
196
+ "file_not_listed",
197
+ f"{member} is not listed in the manifest",
198
+ Exit.INVALID,
199
+ file=member,
200
+ section="6.2",
201
+ )
202
+ content = get(member)
203
+ verified.append(member)
204
+ if args.output:
205
+ target = ctx.resolve(args.output)
206
+ stored = store_bytes(target, content)
207
+ if stored.outcome is StoreOutcome.CONFLICT:
208
+ raise CliError(
209
+ "destination_conflict",
210
+ f"{target} exists with different bytes; it was left unchanged",
211
+ Exit.INVALID,
212
+ file=str(target),
213
+ )
214
+ data["output"] = {
215
+ "path": str(target),
216
+ "status": str(stored.outcome),
217
+ "bytes": len(content),
218
+ }
219
+ result.say(f"saved {member} ({len(content)} bytes, verified) to {target}")
220
+ else:
221
+ try:
222
+ text = content.decode("utf-8")
223
+ if "\x00" in text:
224
+ raise UnicodeDecodeError("utf-8", content, 0, 1, "NUL byte")
225
+ except UnicodeDecodeError:
226
+ raise CliError(
227
+ "binary_file",
228
+ f"{member} is not UTF-8 text; save it with --output FILE",
229
+ Exit.USAGE,
230
+ file=member,
231
+ ) from None
232
+ info, lines = _text_range(text, member, args.start_line, args.lines)
233
+ data["text"] = info
234
+ result.lines.extend(lines)
235
+ _truncation(result, info)
236
+ else:
237
+ metadata_bytes = get(METADATA_PATH)
238
+ readme = get(README_PATH)
239
+ verified += [METADATA_PATH, README_PATH]
240
+ try:
241
+ metadata = parse_metadata(metadata_bytes, list(manifest.files))
242
+ text = decode_text_file(readme, file=README_PATH, nonblank=True)
243
+ if LICENSE_PATH in manifest.files:
244
+ decode_text_file(get(LICENSE_PATH), file=LICENSE_PATH)
245
+ verified.append(LICENSE_PATH)
246
+ except SciError as exc:
247
+ raise CliError.from_sci(exc, "metadata checks failed (§6.3)") from None
248
+ raw = metadata.raw
249
+ data["metadata"] = {
250
+ "title": metadata.title,
251
+ "abstract": metadata.abstract,
252
+ "categories": raw.get("categories", []),
253
+ "keywords": list(metadata.keywords),
254
+ "display": raw.get("display", []),
255
+ "citations": raw.get("citations", []),
256
+ "license": LICENSE_PATH in manifest.files,
257
+ }
258
+ info, lines = _text_range(text, README_PATH, args.start_line, args.lines)
259
+ data["text"] = info
260
+ result.say(f"title: {metadata.title}")
261
+ result.say(f"signer x: {manifest.public_key.x}")
262
+ if metadata.keywords:
263
+ result.say(f"keywords: {', '.join(metadata.keywords)}")
264
+ result.say("abstract:")
265
+ result.lines.extend(" " + line for line in metadata.abstract.splitlines())
266
+ if metadata.citations:
267
+ result.say(f"citations: {len(metadata.citations)}")
268
+ citations = raw.get("citations")
269
+ for citation in citations if isinstance(citations, list) else []:
270
+ if isinstance(citation, dict):
271
+ listed = citation.get("relations")
272
+ names = [str(r) for r in listed] if isinstance(listed, list) else []
273
+ relations = ",".join(names)
274
+ result.say(
275
+ f" {citation.get('target')}" + (f" [{relations}]" if relations else "")
276
+ )
277
+ result.say("")
278
+ result.say(f"--- {README_PATH} ---")
279
+ result.lines.extend(lines)
280
+ _truncation(result, info)
281
+ data["verified_files"] = verified
282
+ data["checks"] = checks(
283
+ bundle=not_performed(
284
+ "complete bundle checks (§6.4) were not performed; only the listed files passed "
285
+ "file checks (§6.2)"
286
+ ),
287
+ baseline=not_performed("baseline is evaluated only after complete bundle verification"),
288
+ timestamps=not_performed("timestamps are checked by `sci verify` and `sci pull`"),
289
+ )
290
+ if verified:
291
+ result.say(
292
+ f"verified: {', '.join(verified)} (manifest and file checks; not a complete "
293
+ "bundle check)"
294
+ )
295
+ del ctx
296
+ return result
297
+
298
+
299
+ def _truncation(result: Result, info: dict[str, Any]) -> None:
300
+ if info["truncated"]:
301
+ result.say(
302
+ f"[lines {info['start_line']}-{info['end_line']} of {info['total_lines']}; "
303
+ f"continue with --start-line {info['next_line']}]"
304
+ )
305
+
306
+
307
+ COMMAND = Command(
308
+ name="read",
309
+ usage_hint=(
310
+ "read ID_OR_PATH [--list | --file PATH [--output FILE]] [--start-line N] [--lines N] "
311
+ "[--archive ARCHIVE ... | --in URL] [--json]"
312
+ ),
313
+ summary="Read verified text or retrieve one file.",
314
+ description="""
315
+ Accepts a signed directory, a ZIP, or a full publication ID (sha256:<digest>
316
+ or sci:sha256:<digest>). Local input is read offline. IDs are resolved like
317
+ `pull`: the configured index or --in URL, or explicit --archive descriptor
318
+ URLs or GitHub/Hugging Face repository addresses.
319
+
320
+ By default prints metadata, citations, and README text after the metadata
321
+ checks of SCI protocol §6.3. --list prints paths, sizes, and hashes after the
322
+ manifest checks (§6.1). --file PATH retrieves one manifest entry after the
323
+ file checks (§6.2). Remote files come from individual-file routes first
324
+ (entry_url, blob_url), then ZIP range requests, then a complete ZIP; the
325
+ whole file is verified before any part is shown. Only the requested files
326
+ are verified; this is not a complete bundle check.
327
+ """,
328
+ args=(
329
+ arg("id_or_path", metavar="ID_OR_PATH", help="signed directory, ZIP, or publication ID"),
330
+ arg("--list", action="store_true", help="list manifest paths, sizes, and SHA-256 hashes"),
331
+ arg("--file", metavar="PATH", help="bundle path to retrieve, e.g. files/data.csv"),
332
+ arg(
333
+ "--output",
334
+ metavar="FILE",
335
+ help="with --file, save the complete original bytes (required for binary files)",
336
+ ),
337
+ arg("--start-line", type=int, default=1, metavar="N", help="first text line to show"),
338
+ arg("--lines", type=int, default=DEFAULT_LINES, metavar="N", help="number of text lines"),
339
+ arg(
340
+ "--archive",
341
+ metavar="ARCHIVE",
342
+ action="append",
343
+ help="descriptor URL or GitHub/Hugging Face repository address (repeatable)",
344
+ ),
345
+ arg("--in", dest="in_", metavar="URL", help="index origin (default: configured index)"),
346
+ ),
347
+ outputs="""Metadata and a README line range (reporting truncation and the next line),
348
+ a file listing, or one file's text or saved path; plus which files passed
349
+ verification.""",
350
+ files="Only --output FILE (an identical file is kept; conflicting bytes are rejected).",
351
+ network="None for local input. For IDs: index lookup and archive GET/range requests.",
352
+ exit_codes={
353
+ 3: "a manifest, file, or metadata check failed (or every source served bad content)",
354
+ 4: "an unsupported schema",
355
+ 5: "no source could be reached, no known sources, or a local limit was exceeded",
356
+ },
357
+ examples=(
358
+ "sci read ./campaign/<digest>.zip --file files/results.csv --lines 20",
359
+ "sci read sha256:<digest> --archive https://raw.githubusercontent.com/lab/research/main/"
360
+ "sci/archive.json",
361
+ ),
362
+ run=run,
363
+ timeout=True,
364
+ )
@@ -0,0 +1,207 @@
1
+ """``sci revoke``: create or submit a key revocation (§5.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pysci.manifest import MANIFEST_PATH, METADATA_PATH
10
+ from pysci.records import create_revocation, verify_revocation
11
+
12
+ from ..checks import proof_json
13
+ from ..clients.tsa import read_sidecar, verify_sidecar_bytes
14
+ from ..context import Context
15
+ from ..errors import CliError, ErrorRecord, Exit, exit_for_outcome, issue_record
16
+ from ..output import Result
17
+ from ..spec import Command, arg
18
+ from ..storage.files import StoreOutcome, store_bytes
19
+ from ..storage.keys import require_outside
20
+ from .common import KEY_ARG_HELP, index_client, signing_key, trust
21
+
22
+ RECORD = "revocation.json"
23
+ SIGNATURE = "revocation.json.sig"
24
+ SIDECAR = "revocation.json.sig.tsr"
25
+
26
+ WARNING = """Revoking a key is permanent. After an index verifies the revocation it rejects
27
+ new catalog admissions for this key, and verified account associations end.
28
+ Anyone holding the record and signature can submit them: keep them private
29
+ until revocation is intended."""
30
+
31
+
32
+ def _inside_publication(directory: Path) -> Path | None:
33
+ for candidate in [directory, *directory.parents]:
34
+ if (candidate / MANIFEST_PATH).exists():
35
+ return candidate
36
+ if (directory / METADATA_PATH).exists():
37
+ return directory
38
+ return None
39
+
40
+
41
+ def create(args: argparse.Namespace, ctx: Context) -> Result:
42
+ output = ctx.resolve(args.output)
43
+ publication = _inside_publication(output.absolute())
44
+ if publication is not None:
45
+ raise CliError(
46
+ "inside_publication",
47
+ f"{output} is inside the publication directory {publication}; write revocations "
48
+ "elsewhere",
49
+ Exit.USAGE,
50
+ )
51
+ key_path, key, warnings = signing_key(ctx, args)
52
+ require_outside(key_path, output)
53
+ result = Result("revoke")
54
+ result.warnings.extend(warnings)
55
+ if ctx.interactive() and not args.yes:
56
+ ctx.stderr.write(WARNING + "\nType 'revoke' to create the record: ")
57
+ ctx.stderr.flush()
58
+ answer = ctx.stdin.readline().strip()
59
+ if answer != "revoke":
60
+ raise CliError("cancelled", "revocation was not created", Exit.USAGE)
61
+ record = create_revocation(key)
62
+ check = verify_revocation(record.record, record.signature)
63
+ assert check.ok
64
+ assert check.public_key is not None
65
+ output.mkdir(parents=True, exist_ok=True)
66
+ statuses: dict[str, str] = {}
67
+ for name, data in ((RECORD, record.record), (SIGNATURE, record.signature)):
68
+ stored = store_bytes(output / name, data)
69
+ if stored.outcome is StoreOutcome.CONFLICT:
70
+ raise CliError(
71
+ "destination_conflict",
72
+ f"{output / name} exists with different bytes; it was left unchanged",
73
+ Exit.INVALID,
74
+ file=str(output / name),
75
+ )
76
+ statuses[name] = str(stored.outcome)
77
+ result.data = {
78
+ "x": check.public_key.x,
79
+ "public_key": check.public_key.to_jwk(),
80
+ "files": {RECORD: str(output / RECORD), SIGNATURE: str(output / SIGNATURE)},
81
+ "status": statuses,
82
+ "submitted": False,
83
+ }
84
+ result.say(f"revocation record for x={check.public_key.x}:")
85
+ result.say(f" {output / RECORD}")
86
+ result.say(f" {output / SIGNATURE}")
87
+ result.say("Not submitted. Keep these files private until you intend to revoke the key.")
88
+ result.say(f"Optionally timestamp them (sci timestamp {output}), then submit with:")
89
+ result.say(f" sci revoke --submit {output}")
90
+ return result
91
+
92
+
93
+ def submit_revocation(ctx: Context, directory: Path, to: str | None, timeout: float) -> Result:
94
+ result = Result("revoke")
95
+ try:
96
+ record = (directory / RECORD).read_bytes()
97
+ signature = (directory / SIGNATURE).read_bytes()
98
+ except OSError as exc:
99
+ raise CliError(
100
+ "revocation_missing",
101
+ f"{directory} must contain {RECORD} and {SIGNATURE}: {exc}",
102
+ Exit.USAGE,
103
+ ) from None
104
+ check = verify_revocation(record, signature)
105
+ if not check.ok or check.public_key is None:
106
+ result.exit_code = exit_for_outcome(check.outcome)
107
+ result.errors.extend(issue_record(i) for i in check.issues)
108
+ result.data = {"path": str(directory), "verification": str(check.outcome)}
109
+ result.say(f"revocation in {directory} failed verification (§6.7)")
110
+ return result
111
+ parts: list[tuple[str, str | None, bytes, str]] = [
112
+ (RECORD, RECORD, record, "application/json"),
113
+ (SIGNATURE, SIGNATURE, signature, "application/octet-stream"),
114
+ ]
115
+ sidecar = read_sidecar(directory / SIDECAR)
116
+ local_proofs: dict[str, Any] | None = None
117
+ if sidecar is not None:
118
+ parts.append((SIDECAR, SIDECAR, sidecar, "application/octet-stream"))
119
+ verification = verify_sidecar_bytes(sidecar, signature, trust(ctx))
120
+ local_proofs = {
121
+ "status": str(verification.outcome),
122
+ "proofs": [proof_json(p) for p in verification.proofs],
123
+ }
124
+ client = index_client(ctx, to)
125
+ deadline = ctx.deadline(timeout)
126
+ response, outcome = client.submit_multipart(
127
+ "revocation_url", "key revocation submission", parts, deadline
128
+ )
129
+ x = check.public_key.x
130
+ if outcome["exit_code"] == Exit.OK:
131
+ body = outcome["response"]
132
+ if body.get("x") != x or body.get("status") != "revoked":
133
+ outcome = {
134
+ "status": "failed",
135
+ "exit_code": Exit.INVALID,
136
+ "error": ErrorRecord(
137
+ "invalid_response",
138
+ "revocation response must contain the submitted x and status 'revoked'",
139
+ observed=repr({k: body.get(k) for k in ("x", "status")}),
140
+ ).to_json(),
141
+ "response": body,
142
+ }
143
+ result.data = {
144
+ "x": x,
145
+ "index": client.origin,
146
+ "timestamp_sidecar": local_proofs or {"status": "absent"},
147
+ **{k: v for k, v in outcome.items() if k != "exit_code"},
148
+ }
149
+ result.exit_code = outcome["exit_code"]
150
+ if "error" in outcome:
151
+ error = outcome["error"]
152
+ result.errors.append(ErrorRecord(error["code"], error["message"], error["retryable"]))
153
+ result.say(f"revocation for x={x} not confirmed by {client.origin}: {error['message']}")
154
+ else:
155
+ result.say(f"revocation for x={x} recorded by {client.origin} (status: revoked)")
156
+ extra = {k: v for k, v in outcome.get("response", {}).items() if k not in ("x", "status")}
157
+ if extra:
158
+ result.say(f"service results: {extra}")
159
+ del response
160
+ return result
161
+
162
+
163
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
164
+ if (args.output is None) == (args.submit is None):
165
+ raise CliError("invalid_arguments", "use exactly one of --output DIR or --submit DIR")
166
+ if args.output is not None:
167
+ if args.to:
168
+ raise CliError("invalid_arguments", "--to applies only to --submit")
169
+ return create(args, ctx)
170
+ return submit_revocation(ctx, ctx.resolve(args.submit), args.to, args.timeout)
171
+
172
+
173
+ COMMAND = Command(
174
+ name="revoke",
175
+ usage_hint="revoke (--output DIR | --submit DIR) [--to URL] [--key PATH] [--yes] [--json]",
176
+ summary="Create or submit a key revocation.",
177
+ description="""
178
+ --output DIR creates revocation.json and revocation.json.sig (SCI protocol
179
+ §5.3) for the selected key, outside any publication directory. Revocation is
180
+ permanent. Interactive use explains the effect and asks for confirmation;
181
+ noninteractive invocation is an explicit request to create the record. The
182
+ record is never submitted by --output.
183
+
184
+ --submit DIR verifies the record (§6.7) and sends it, with any
185
+ revocation.json.sig.tsr, to the index's revocation submission endpoint
186
+ (Sciscape `revocation_url`).
187
+ """,
188
+ args=(
189
+ arg("--output", metavar="DIR", help="create the revocation record in DIR"),
190
+ arg("--submit", metavar="DIR", help="submit the revocation record found in DIR"),
191
+ arg("--to", metavar="URL", help="index origin for --submit (default: configured index)"),
192
+ arg("--key", metavar="PATH", help=KEY_ARG_HELP),
193
+ arg("--yes", action="store_true", help="skip the interactive confirmation"),
194
+ ),
195
+ outputs="The revoked key's x and file paths, or the index's submission result.",
196
+ files="--output writes DIR/revocation.json and DIR/revocation.json.sig; --submit writes none.",
197
+ network="--output: none. --submit: one paced POST to the index (after descriptor discovery).",
198
+ exit_codes={
199
+ 3: "the revocation fails verification, or DIR holds a different record",
200
+ 4: "the index does not support revocation submission",
201
+ 5: "the submission did not complete (temporary failure or outcome_unknown)",
202
+ 6: "the index refused the submission by policy",
203
+ },
204
+ examples=("sci revoke --output ~/sci-revocation", "sci revoke --submit ~/sci-revocation"),
205
+ run=run,
206
+ timeout=True,
207
+ )