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,115 @@
|
|
|
1
|
+
"""``sci check``: validate an unsigned draft without signing it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pysci.draft import DraftReport, check_draft
|
|
9
|
+
|
|
10
|
+
from ..checks import issue_lines
|
|
11
|
+
from ..context import Context
|
|
12
|
+
from ..errors import CliError, Exit, exit_for_outcome, issue_json, issue_record
|
|
13
|
+
from ..output import Result
|
|
14
|
+
from ..spec import Command, arg
|
|
15
|
+
from ..storage.files import temp_names
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def draft_report_json(report: DraftReport) -> dict[str, Any]:
|
|
19
|
+
baseline: dict[str, Any]
|
|
20
|
+
if report.baseline is None:
|
|
21
|
+
baseline = {"status": "not_performed", "reason": "planned sizes could not be computed"}
|
|
22
|
+
else:
|
|
23
|
+
baseline = {
|
|
24
|
+
"status": "eligible" if report.baseline.eligible else "not_eligible",
|
|
25
|
+
"reasons": [issue_json(r) for r in report.baseline.reasons],
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
"valid": report.valid,
|
|
29
|
+
"outcome": str(report.outcome),
|
|
30
|
+
"files": len(report.files),
|
|
31
|
+
"planned_manifest_bytes": report.manifest_bytes,
|
|
32
|
+
"planned_zip_bytes": report.zip_bytes,
|
|
33
|
+
"metadata": None
|
|
34
|
+
if report.metadata is None
|
|
35
|
+
else {"title": report.metadata.title, "display": [d.path for d in report.metadata.display]},
|
|
36
|
+
"baseline": baseline,
|
|
37
|
+
"signed_files_ignored": report.signed,
|
|
38
|
+
"issues": [issue_json(i) for i in report.issues],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def describe(result: Result, report: DraftReport) -> None:
|
|
43
|
+
data = draft_report_json(report)
|
|
44
|
+
result.say(f"files: {data['files']}")
|
|
45
|
+
result.say(f"planned manifest: {data['planned_manifest_bytes']} bytes")
|
|
46
|
+
result.say(f"planned ZIP: {data['planned_zip_bytes']} bytes")
|
|
47
|
+
if report.valid:
|
|
48
|
+
result.say("protocol checks: passed (§§2-4)")
|
|
49
|
+
else:
|
|
50
|
+
result.say(f"protocol checks: {len(report.issues)} problem(s)")
|
|
51
|
+
result.lines.extend(issue_lines(data["issues"], " "))
|
|
52
|
+
baseline = data["baseline"]
|
|
53
|
+
result.say(f"baseline: {baseline['status']}")
|
|
54
|
+
result.lines.extend(issue_lines(baseline.get("reasons", []), " "))
|
|
55
|
+
if report.signed:
|
|
56
|
+
result.say("note: manifest.json/manifest.json.sig present and ignored")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def warn_display(result: Result, report: DraftReport) -> None:
|
|
60
|
+
"""Report display images that may not render; they do not change the exit code."""
|
|
61
|
+
for issue in report.display_warnings:
|
|
62
|
+
result.warn(f"{issue.file}: display image does not decode: {issue.observed}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
66
|
+
draft = ctx.resolve(args.dir)
|
|
67
|
+
if not draft.is_dir():
|
|
68
|
+
raise CliError("draft_missing", f"{draft} is not a directory", Exit.USAGE, file=str(draft))
|
|
69
|
+
report = check_draft(draft, ignore=temp_names(draft))
|
|
70
|
+
result = Result("check")
|
|
71
|
+
result.data = {"path": str(draft), **draft_report_json(report)}
|
|
72
|
+
describe(result, report)
|
|
73
|
+
warn_display(result, report)
|
|
74
|
+
if report.issues:
|
|
75
|
+
result.exit_code = exit_for_outcome(report.outcome)
|
|
76
|
+
result.errors.extend(issue_record(i) for i in report.issues)
|
|
77
|
+
elif (
|
|
78
|
+
report.baseline is not None and not report.baseline.eligible and not args.allow_nonbaseline
|
|
79
|
+
):
|
|
80
|
+
result.exit_code = Exit.REFUSED
|
|
81
|
+
result.errors.extend(issue_record(i) for i in report.baseline.reasons)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
COMMAND = Command(
|
|
86
|
+
name="check",
|
|
87
|
+
usage_hint="check DIR [--allow-nonbaseline] [--json]",
|
|
88
|
+
summary="Check an unsigned draft without signing it.",
|
|
89
|
+
description="""
|
|
90
|
+
Validates a draft under SCI protocol §§2-4: file paths and conflicts,
|
|
91
|
+
required files, sci.json fields, README/LICENSE text rules, and absence of
|
|
92
|
+
private-key material. Calculates the planned manifest and ZIP sizes with the
|
|
93
|
+
signing serializer and evaluates baseline eligibility (§2.1). All
|
|
94
|
+
independent problems are reported together, each with its file or JSON
|
|
95
|
+
field, requirement, and observed value.
|
|
96
|
+
""",
|
|
97
|
+
args=(
|
|
98
|
+
arg("dir", metavar="DIR", help="draft directory (sci.json, README.md, LICENSE, files/)"),
|
|
99
|
+
arg(
|
|
100
|
+
"--allow-nonbaseline",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help="exit 0 for a protocol-valid draft that is outside the baseline",
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
outputs="Planned sizes, protocol problems, and baseline eligibility with reasons.",
|
|
106
|
+
files="None; reads no private key.",
|
|
107
|
+
network="None.",
|
|
108
|
+
exit_codes={
|
|
109
|
+
3: "the draft fails protocol checks",
|
|
110
|
+
5: "files could not be read or a local limit was exceeded",
|
|
111
|
+
6: "the draft is valid but not baseline (signing would refuse without override)",
|
|
112
|
+
},
|
|
113
|
+
examples=("sci check ./draft",),
|
|
114
|
+
run=run,
|
|
115
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""``sci claim``: create a signed account claim (§5.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
from pysci.accounts import AccountType, claim_urls, normalize_account
|
|
10
|
+
from pysci.errors import SciError
|
|
11
|
+
from pysci.keys import public_key_of
|
|
12
|
+
from pysci.records import DEFAULT_CLAIM_VALIDITY, create_claim, verify_claim
|
|
13
|
+
from pysci.rfc3339 import format_rfc3339, parse_rfc3339
|
|
14
|
+
|
|
15
|
+
from ..clients.http import Deadline, HttpClient
|
|
16
|
+
from ..context import Context
|
|
17
|
+
from ..errors import CliError, Exit
|
|
18
|
+
from ..output import Result
|
|
19
|
+
from ..spec import Command, arg
|
|
20
|
+
from ..storage.files import atomic_write_bytes
|
|
21
|
+
from ..storage.keys import require_outside
|
|
22
|
+
from .common import KEY_ARG_HELP, signing_key
|
|
23
|
+
|
|
24
|
+
HF_API = "https://huggingface.co/api"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def huggingface_spelling(http: HttpClient, name: str, deadline: Deadline) -> str:
|
|
28
|
+
"""The user or organization name as spelled by the Hub API."""
|
|
29
|
+
quoted = urllib.parse.quote(name, safe="")
|
|
30
|
+
for kind, field in (("users", "user"), ("organizations", "name")):
|
|
31
|
+
response = http.get(f"{HF_API}/{kind}/{quoted}/overview", deadline=deadline)
|
|
32
|
+
if response.status == 404:
|
|
33
|
+
continue
|
|
34
|
+
if response.status != 200:
|
|
35
|
+
raise CliError(
|
|
36
|
+
"lookup_failed",
|
|
37
|
+
f"Hugging Face account lookup returned HTTP {response.status}",
|
|
38
|
+
Exit.INCOMPLETE,
|
|
39
|
+
retryable=True,
|
|
40
|
+
)
|
|
41
|
+
record = response.json_object("Hugging Face account overview")
|
|
42
|
+
value = record.get(field)
|
|
43
|
+
if isinstance(value, str) and value.lower() == name.lower():
|
|
44
|
+
return value
|
|
45
|
+
raise CliError(
|
|
46
|
+
"invalid_response", "Hugging Face overview has no matching name", Exit.INVALID
|
|
47
|
+
)
|
|
48
|
+
raise CliError(
|
|
49
|
+
"account_not_found",
|
|
50
|
+
f"Hugging Face account {name!r} was not found",
|
|
51
|
+
Exit.INVALID,
|
|
52
|
+
observed=name,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
57
|
+
output = ctx.resolve(args.output)
|
|
58
|
+
key_path, key, warnings = signing_key(ctx, args)
|
|
59
|
+
require_outside(key_path, output)
|
|
60
|
+
try:
|
|
61
|
+
account = normalize_account(args.type, args.account)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise CliError(
|
|
64
|
+
"invalid_account",
|
|
65
|
+
f"{args.type} account {args.account!r}: {exc}",
|
|
66
|
+
Exit.USAGE,
|
|
67
|
+
field="/account/id",
|
|
68
|
+
observed=args.account,
|
|
69
|
+
) from None
|
|
70
|
+
result = Result("claim")
|
|
71
|
+
result.warnings.extend(warnings)
|
|
72
|
+
lookup = "not_required"
|
|
73
|
+
if account.type == AccountType.HUGGINGFACE:
|
|
74
|
+
if args.no_lookup:
|
|
75
|
+
lookup = "skipped"
|
|
76
|
+
result.warn("Hugging Face spelling not checked (--no-lookup)")
|
|
77
|
+
else:
|
|
78
|
+
spelled = huggingface_spelling(ctx.http, account.id, ctx.deadline(args.timeout))
|
|
79
|
+
account = normalize_account(account.type, spelled)
|
|
80
|
+
lookup = "huggingface_api"
|
|
81
|
+
now = datetime.now(UTC)
|
|
82
|
+
if args.expires:
|
|
83
|
+
try:
|
|
84
|
+
expires = parse_rfc3339(args.expires)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise CliError(
|
|
87
|
+
"invalid_expires", f"--expires: {exc}", Exit.USAGE, observed=args.expires
|
|
88
|
+
) from None
|
|
89
|
+
if expires <= now:
|
|
90
|
+
raise CliError("invalid_expires", "--expires must be in the future", Exit.USAGE)
|
|
91
|
+
else:
|
|
92
|
+
expires = now + DEFAULT_CLAIM_VALIDITY
|
|
93
|
+
if expires - now > DEFAULT_CLAIM_VALIDITY:
|
|
94
|
+
result.warn("the protocol recommends claims expire within 90 days of signing")
|
|
95
|
+
try:
|
|
96
|
+
record = create_claim(key, account, expires=expires, note=args.note, name=args.name)
|
|
97
|
+
except (ValueError, SciError) as exc:
|
|
98
|
+
raise CliError("invalid_claim", str(exc), Exit.USAGE) from None
|
|
99
|
+
public = public_key_of(key)
|
|
100
|
+
check = verify_claim(
|
|
101
|
+
record.record, record.signature, expected_public_key=public, requested_account=account
|
|
102
|
+
)
|
|
103
|
+
if not check.ok: # pragma: no cover - library guarantees validity
|
|
104
|
+
raise CliError("invalid_claim", str(check.issues[0]), Exit.INVALID)
|
|
105
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
claim_path = output / f"{public.x}.json"
|
|
107
|
+
signature_path = output / f"{public.x}.json.sig"
|
|
108
|
+
renewed = claim_path.exists()
|
|
109
|
+
atomic_write_bytes(signature_path, record.signature)
|
|
110
|
+
atomic_write_bytes(claim_path, record.record)
|
|
111
|
+
locations = [{"claim": c, "signature": s} for c, s in claim_urls(account, public.x)]
|
|
112
|
+
result.data = {
|
|
113
|
+
"account": account.to_json(),
|
|
114
|
+
"public_key": public.to_jwk(),
|
|
115
|
+
"expires": format_rfc3339(expires),
|
|
116
|
+
"files": {"claim": str(claim_path), "signature": str(signature_path)},
|
|
117
|
+
"status": "renewed" if renewed else "created",
|
|
118
|
+
"lookup": lookup,
|
|
119
|
+
"hosting_locations": locations,
|
|
120
|
+
}
|
|
121
|
+
result.say(f"claim: {claim_path}")
|
|
122
|
+
result.say(f"signature: {signature_path}")
|
|
123
|
+
result.say(f"account: {account.type}:{account.id} (expires {format_rfc3339(expires)})")
|
|
124
|
+
result.say("Host both files at" + (" one of:" if len(locations) > 1 else ":"))
|
|
125
|
+
for location in locations:
|
|
126
|
+
result.say(f" {location['claim']}")
|
|
127
|
+
result.say(f" {location['signature']}")
|
|
128
|
+
result.say("These files were not uploaded. Renew before expiry; publications are unchanged.")
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
COMMAND = Command(
|
|
133
|
+
name="claim",
|
|
134
|
+
usage_hint="claim TYPE ACCOUNT --output DIR [--expires RFC3339] [--key PATH] [--json]",
|
|
135
|
+
summary="Create a signed account claim.",
|
|
136
|
+
description="""
|
|
137
|
+
Creates an SCI key claim (protocol §5.2) linking your public key to a domain,
|
|
138
|
+
GitHub account, or Hugging Face user or organization. The account is
|
|
139
|
+
normalized (domains to lowercase IDNA A-labels, GitHub logins to lowercase);
|
|
140
|
+
Hugging Face names take the spelling returned by the Hub API. Writes
|
|
141
|
+
<x>.json and <x>.json.sig and prints where to host them; nothing is
|
|
142
|
+
uploaded. Renewal replaces the claim files, never publication bytes.
|
|
143
|
+
""",
|
|
144
|
+
args=(
|
|
145
|
+
arg(
|
|
146
|
+
"type",
|
|
147
|
+
metavar="TYPE",
|
|
148
|
+
choices=("domain", "github", "huggingface"),
|
|
149
|
+
help="account method: domain, github, or huggingface",
|
|
150
|
+
),
|
|
151
|
+
arg("account", metavar="ACCOUNT", help="domain name, GitHub login, or Hugging Face name"),
|
|
152
|
+
arg(
|
|
153
|
+
"--output", metavar="DIR", required=True, help="directory for <x>.json and <x>.json.sig"
|
|
154
|
+
),
|
|
155
|
+
arg("--expires", metavar="RFC3339", help="claim expiry (default: 90 days from now)"),
|
|
156
|
+
arg(
|
|
157
|
+
"--name",
|
|
158
|
+
metavar="TEXT",
|
|
159
|
+
help="publisher display name, 1-80 Unicode code points",
|
|
160
|
+
),
|
|
161
|
+
arg("--note", metavar="TEXT", help="optional note stored in the signed claim"),
|
|
162
|
+
arg("--key", metavar="PATH", help=KEY_ARG_HELP),
|
|
163
|
+
arg(
|
|
164
|
+
"--no-lookup",
|
|
165
|
+
action="store_true",
|
|
166
|
+
help="skip the Hugging Face spelling lookup (the given spelling must be exact)",
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
outputs="Account, expiry, written files, and the required hosting URLs.",
|
|
170
|
+
files="Writes DIR/<x>.json and DIR/<x>.json.sig (replacing an earlier claim for the key).",
|
|
171
|
+
network="Hugging Face accounts: one Hub API lookup unless --no-lookup. Otherwise none.",
|
|
172
|
+
exit_codes={
|
|
173
|
+
3: "the Hugging Face account does not exist",
|
|
174
|
+
5: "the account lookup failed temporarily",
|
|
175
|
+
},
|
|
176
|
+
examples=("sci claim domain lab.example --output ./claims",),
|
|
177
|
+
run=run,
|
|
178
|
+
timeout=True,
|
|
179
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Helpers shared by several commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pysci.errors import SciError
|
|
10
|
+
from pysci.ids import parse_publication_id
|
|
11
|
+
from pysci.timestamps import TrustConfig
|
|
12
|
+
|
|
13
|
+
from ..clients.http import Deadline
|
|
14
|
+
from ..clients.index import IndexClient
|
|
15
|
+
from ..clients.tsa import build_trust
|
|
16
|
+
from ..config import normalize_origin
|
|
17
|
+
from ..context import Context
|
|
18
|
+
from ..destinations import resolve_archive
|
|
19
|
+
from ..errors import CliError, Exit
|
|
20
|
+
from ..storage.keys import key_permission_warning, load_signing_key, resolve_key_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def looks_like_id(value: str) -> bool:
|
|
24
|
+
return value.startswith(("sha256:", "sci:sha256:"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_id(value: str) -> str:
|
|
28
|
+
"""Accept ``sha256:<digest>`` or ``sci:sha256:<digest>``; return the bare digest."""
|
|
29
|
+
try:
|
|
30
|
+
return parse_publication_id(value)
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise CliError(
|
|
33
|
+
"invalid_id",
|
|
34
|
+
f"{exc}",
|
|
35
|
+
Exit.USAGE,
|
|
36
|
+
observed=value,
|
|
37
|
+
requirement="sha256:<64 lowercase hex> or sci:sha256:<64 lowercase hex>",
|
|
38
|
+
) from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def index_origin(ctx: Context, explicit: str | None, *, option: str = "--to") -> str:
|
|
42
|
+
if explicit:
|
|
43
|
+
return normalize_origin(explicit)
|
|
44
|
+
if ctx.config.index:
|
|
45
|
+
return ctx.config.index
|
|
46
|
+
raise CliError(
|
|
47
|
+
"missing_index",
|
|
48
|
+
f"no index configured; pass {option} URL or save one with `sci index URL`",
|
|
49
|
+
Exit.USAGE,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def index_client(ctx: Context, explicit: str | None, *, option: str = "--to") -> IndexClient:
|
|
54
|
+
return IndexClient(ctx.http, index_origin(ctx, explicit, option=option))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def archive_urls(values: list[str] | None) -> list[str]:
|
|
58
|
+
urls: list[str] = []
|
|
59
|
+
for value in values or []:
|
|
60
|
+
url = resolve_archive(value)
|
|
61
|
+
if url not in urls:
|
|
62
|
+
urls.append(url)
|
|
63
|
+
return urls
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def trust(ctx: Context, *, online: bool = False, deadline: Deadline | None = None) -> TrustConfig:
|
|
67
|
+
config, warnings = build_trust(
|
|
68
|
+
ctx.config.timestamp_trust, online=online, http=ctx.http, deadline=deadline
|
|
69
|
+
)
|
|
70
|
+
del warnings
|
|
71
|
+
return config
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def trust_with_warnings(
|
|
75
|
+
ctx: Context, *, online: bool = False, deadline: Deadline | None = None
|
|
76
|
+
) -> tuple[TrustConfig, list[str]]:
|
|
77
|
+
return build_trust(ctx.config.timestamp_trust, online=online, http=ctx.http, deadline=deadline)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def signing_key(ctx: Context, args: argparse.Namespace) -> tuple[Path, Any, list[str]]:
|
|
81
|
+
path = resolve_key_path(getattr(args, "key", None), ctx.config.key, ctx.home)
|
|
82
|
+
key = load_signing_key(path)
|
|
83
|
+
warnings = [w for w in [key_permission_warning(path)] if w]
|
|
84
|
+
return path, key, warnings
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def sci_failure(exc: SciError, message: str) -> CliError:
|
|
88
|
+
return CliError.from_sci(exc, message)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
KEY_ARG_HELP = "private key (PKCS#8 PEM); default: configured key, else <state>/key.pem"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""``sci folders``: manage the folders selected by ``@local``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..context import Context
|
|
10
|
+
from ..errors import CliError, Exit
|
|
11
|
+
from ..output import Result
|
|
12
|
+
from ..spec import Command, arg
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
16
|
+
config = ctx.config
|
|
17
|
+
result = Result("folders")
|
|
18
|
+
if args.action is None:
|
|
19
|
+
if args.dir is not None:
|
|
20
|
+
raise CliError("invalid_arguments", "use `sci folders add DIR` or `remove DIR`")
|
|
21
|
+
folders = [{"path": f, "available": Path(f).is_dir()} for f in config.folders]
|
|
22
|
+
result.data = {"folders": folders}
|
|
23
|
+
if not folders:
|
|
24
|
+
result.say("no known folders")
|
|
25
|
+
for entry in folders:
|
|
26
|
+
state = "available" if entry["available"] else "unavailable"
|
|
27
|
+
result.say(f"{entry['path']} ({state})")
|
|
28
|
+
return result
|
|
29
|
+
if args.dir is None:
|
|
30
|
+
raise CliError("invalid_arguments", f"`sci folders {args.action}` requires DIR")
|
|
31
|
+
if args.action == "add":
|
|
32
|
+
folder = ctx.resolve(args.dir)
|
|
33
|
+
if not folder.is_dir():
|
|
34
|
+
raise CliError("folder_missing", f"{folder} is not an existing directory", Exit.USAGE)
|
|
35
|
+
canonical = str(folder.resolve())
|
|
36
|
+
changed = canonical not in config.folders
|
|
37
|
+
if changed:
|
|
38
|
+
config.folders.append(canonical)
|
|
39
|
+
ctx.save_config()
|
|
40
|
+
result.data = {"path": canonical, "status": "added" if changed else "unchanged"}
|
|
41
|
+
result.say(f"{canonical}: {result.data['status']}")
|
|
42
|
+
return result
|
|
43
|
+
raw = ctx.resolve(args.dir)
|
|
44
|
+
candidates = {str(raw.absolute())}
|
|
45
|
+
with contextlib.suppress(OSError):
|
|
46
|
+
candidates.add(str(raw.resolve()))
|
|
47
|
+
removed = [f for f in config.folders if f in candidates]
|
|
48
|
+
if removed:
|
|
49
|
+
config.folders = [f for f in config.folders if f not in candidates]
|
|
50
|
+
ctx.save_config()
|
|
51
|
+
result.data = {
|
|
52
|
+
"path": removed[0] if removed else str(raw),
|
|
53
|
+
"status": "removed" if removed else "unknown",
|
|
54
|
+
}
|
|
55
|
+
result.say(
|
|
56
|
+
f"{result.data['path']}: "
|
|
57
|
+
+ ("forgotten (files were not deleted)" if removed else "not a known folder")
|
|
58
|
+
)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
COMMAND = Command(
|
|
63
|
+
name="folders",
|
|
64
|
+
usage_hint="folders [add DIR | remove DIR] [--json]",
|
|
65
|
+
summary="Manage the folders selected by @local.",
|
|
66
|
+
description="""
|
|
67
|
+
Without arguments, lists known folders and whether each is available. `add`
|
|
68
|
+
saves an existing folder's canonical absolute path; `remove` forgets a path
|
|
69
|
+
without deleting any files. Folders are also remembered after an explicit
|
|
70
|
+
local search (`search --in DIR`) and after bundle writes by `sign --output`,
|
|
71
|
+
`pull --zip`, and `publish`. The computer is never scanned for campaigns.
|
|
72
|
+
""",
|
|
73
|
+
args=(
|
|
74
|
+
arg("action", metavar="ACTION", nargs="?", choices=("add", "remove"), help="add or remove"),
|
|
75
|
+
arg("dir", metavar="DIR", nargs="?", help="folder path"),
|
|
76
|
+
),
|
|
77
|
+
outputs="The known folders and their availability, or the change made.",
|
|
78
|
+
files="`add` and `remove` update config.json; no bundle files are touched.",
|
|
79
|
+
network="None.",
|
|
80
|
+
exit_codes={2: "DIR is missing or not a directory"},
|
|
81
|
+
examples=("sci folders add ./campaign", "sci folders"),
|
|
82
|
+
run=run,
|
|
83
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""``sci index``: show or save the default index origin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..config import CONFIG_FILE, normalize_origin
|
|
8
|
+
from ..context import Context
|
|
9
|
+
from ..output import Result
|
|
10
|
+
from ..spec import Command, arg
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
14
|
+
result = Result("index")
|
|
15
|
+
config = ctx.config
|
|
16
|
+
if args.url is None:
|
|
17
|
+
result.data = {"index": config.index, "config": str(ctx.home / CONFIG_FILE)}
|
|
18
|
+
result.say(config.index or "no index configured (set one with `sci index URL`)")
|
|
19
|
+
return result
|
|
20
|
+
origin = normalize_origin(args.url)
|
|
21
|
+
changed = config.index != origin
|
|
22
|
+
config.index = origin
|
|
23
|
+
if changed:
|
|
24
|
+
ctx.save_config()
|
|
25
|
+
result.data = {
|
|
26
|
+
"index": origin,
|
|
27
|
+
"status": "saved" if changed else "unchanged",
|
|
28
|
+
"config": str(ctx.home / CONFIG_FILE),
|
|
29
|
+
}
|
|
30
|
+
result.say(f"index: {origin} ({'saved' if changed else 'unchanged'})")
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
COMMAND = Command(
|
|
35
|
+
name="index",
|
|
36
|
+
usage_hint="index [URL] [--json]",
|
|
37
|
+
summary="Show or save the default index origin.",
|
|
38
|
+
description="""
|
|
39
|
+
Without URL, shows the configured index. With URL, saves that HTTPS origin
|
|
40
|
+
(for example https://sciscape.io) as the default for `pull`, `read`,
|
|
41
|
+
`submit`, `timestamp --submit`, `revoke --submit`, and `search --in @index`.
|
|
42
|
+
The origin is not contacted. No public index is selected by default.
|
|
43
|
+
""",
|
|
44
|
+
args=(arg("url", metavar="URL", nargs="?", help="HTTPS index origin to save"),),
|
|
45
|
+
outputs="The configured index origin.",
|
|
46
|
+
files="With URL, updates config.json in the state directory (~/.sci or $SCI_HOME).",
|
|
47
|
+
network="None.",
|
|
48
|
+
exit_codes={2: "URL is not an HTTPS origin, or config.json is invalid"},
|
|
49
|
+
examples=("sci index https://sciscape.io",),
|
|
50
|
+
run=run,
|
|
51
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``sci keygen``: create a signing key pair."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..context import Context
|
|
8
|
+
from ..output import Result
|
|
9
|
+
from ..spec import Command, arg
|
|
10
|
+
from ..storage.keys import create_key_pair, resolve_key_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
14
|
+
path = resolve_key_path(args.key, None, ctx.home)
|
|
15
|
+
if args.key is None:
|
|
16
|
+
ctx.home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
17
|
+
public, public_path = create_key_pair(path)
|
|
18
|
+
result = Result("keygen")
|
|
19
|
+
result.data = {
|
|
20
|
+
"private_key": str(path),
|
|
21
|
+
"public_key_file": str(public_path),
|
|
22
|
+
"public_key": public.to_jwk(),
|
|
23
|
+
"x": public.x,
|
|
24
|
+
}
|
|
25
|
+
result.say(f"private key: {path} (owner-only access)")
|
|
26
|
+
result.say(f"public key: {public_path}")
|
|
27
|
+
result.say(f"x: {public.x}")
|
|
28
|
+
result.say("Keep the private key outside publication folders and back it up securely.")
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
COMMAND = Command(
|
|
33
|
+
name="keygen",
|
|
34
|
+
usage_hint="keygen [--key PATH] [--json]",
|
|
35
|
+
summary="Create an Ed25519 signing key pair.",
|
|
36
|
+
description="""
|
|
37
|
+
Creates a PKCS#8 private key and a SubjectPublicKeyInfo public key (SCI
|
|
38
|
+
protocol §5.1). The private key file is readable only by its owner. Existing
|
|
39
|
+
files are never overwritten. The public key file name replaces a final .pem
|
|
40
|
+
with .pub.pem, or appends .pub.pem. --key also selects the key for `sign`,
|
|
41
|
+
`claim`, and `revoke`; to make a custom path the default, set "key" in
|
|
42
|
+
config.json.
|
|
43
|
+
""",
|
|
44
|
+
args=(
|
|
45
|
+
arg(
|
|
46
|
+
"--key",
|
|
47
|
+
metavar="PATH",
|
|
48
|
+
help="private key path to create (default: <state>/key.pem, state is ~/.sci or "
|
|
49
|
+
"$SCI_HOME)",
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
outputs="The public JWK and its x value, and both file paths.",
|
|
53
|
+
files="Creates PATH (mode 0600) and its .pub.pem companion; rejects existing outputs.",
|
|
54
|
+
network="None.",
|
|
55
|
+
exit_codes={2: "an output file already exists or the location is not writable"},
|
|
56
|
+
examples=("sci keygen", "sci keygen --key ~/keys/lab.pem --json"),
|
|
57
|
+
run=run,
|
|
58
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""``sci license``: install the approved baseline license in a draft."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
from pysci.baseline import BASELINE_LICENSE_SHA256, baseline_license_bytes
|
|
9
|
+
from pysci.manifest import LICENSE_PATH, MANIFEST_PATH, SIGNATURE_PATH
|
|
10
|
+
|
|
11
|
+
from ..context import Context
|
|
12
|
+
from ..errors import CliError, Exit
|
|
13
|
+
from ..output import Result
|
|
14
|
+
from ..spec import Command, arg
|
|
15
|
+
from ..storage.files import store_bytes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
19
|
+
draft = ctx.resolve(args.dir)
|
|
20
|
+
if not draft.is_dir():
|
|
21
|
+
raise CliError("draft_missing", f"{draft} is not a directory", Exit.USAGE, file=str(draft))
|
|
22
|
+
for generated in (MANIFEST_PATH, SIGNATURE_PATH):
|
|
23
|
+
if (draft / generated).exists():
|
|
24
|
+
raise CliError(
|
|
25
|
+
"signed_directory",
|
|
26
|
+
f"{draft} is signed ({generated} exists); signed publication bytes cannot change",
|
|
27
|
+
Exit.USAGE,
|
|
28
|
+
file=str(draft / generated),
|
|
29
|
+
)
|
|
30
|
+
target = draft / LICENSE_PATH
|
|
31
|
+
data = baseline_license_bytes()
|
|
32
|
+
if target.is_symlink():
|
|
33
|
+
raise CliError("license_conflict", f"{target} is a symbolic link", Exit.INVALID)
|
|
34
|
+
stored = store_bytes(target, data)
|
|
35
|
+
if stored.outcome == "conflict":
|
|
36
|
+
raise CliError(
|
|
37
|
+
"license_conflict",
|
|
38
|
+
f"{target} exists with different terms; it was left unchanged",
|
|
39
|
+
Exit.INVALID,
|
|
40
|
+
file=str(target),
|
|
41
|
+
requirement=f"sha256 {BASELINE_LICENSE_SHA256} (SCI baseline license 0.1)",
|
|
42
|
+
observed=f"sha256 {stored.existing_sha256}",
|
|
43
|
+
)
|
|
44
|
+
result = Result("license")
|
|
45
|
+
result.data = {
|
|
46
|
+
"path": str(target),
|
|
47
|
+
"status": str(stored.outcome),
|
|
48
|
+
"license": "SCI baseline license 0.1",
|
|
49
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
50
|
+
}
|
|
51
|
+
verb = "installed" if stored.outcome == "written" else "already present"
|
|
52
|
+
result.say(f"SCI baseline license 0.1 {verb}: {target}")
|
|
53
|
+
result.say("Terms: CC BY 4.0 for text, figures, and data; MIT for code.")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
COMMAND = Command(
|
|
58
|
+
name="license",
|
|
59
|
+
usage_hint="license DIR [--json]",
|
|
60
|
+
summary="Install the SCI baseline license in a draft.",
|
|
61
|
+
description="""
|
|
62
|
+
Copies the exact approved baseline license (SCI protocol §2.1: CC BY 4.0 for
|
|
63
|
+
text, figures, and data; MIT for code) to DIR/LICENSE. Running this command
|
|
64
|
+
selects those terms explicitly; signing never inserts a license. An identical
|
|
65
|
+
LICENSE is kept; a different one is rejected and left unchanged. Signed
|
|
66
|
+
directories are rejected.
|
|
67
|
+
""",
|
|
68
|
+
args=(arg("dir", metavar="DIR", help="unsigned draft directory"),),
|
|
69
|
+
outputs="The license path, whether it was written or already present, and its SHA-256.",
|
|
70
|
+
files="Creates DIR/LICENSE if absent.",
|
|
71
|
+
network="None.",
|
|
72
|
+
exit_codes={3: "DIR/LICENSE exists with different bytes"},
|
|
73
|
+
examples=("sci license ./draft",),
|
|
74
|
+
run=run,
|
|
75
|
+
)
|