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,205 @@
|
|
|
1
|
+
"""Signed local inputs: candidate discovery, verification, sidecars, and ZIP materialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, BinaryIO, Literal
|
|
8
|
+
|
|
9
|
+
from pysci.bundle import (
|
|
10
|
+
VerificationResult,
|
|
11
|
+
read_zip_entry,
|
|
12
|
+
verify_directory,
|
|
13
|
+
verify_file,
|
|
14
|
+
verify_zip,
|
|
15
|
+
write_bundle_zip,
|
|
16
|
+
)
|
|
17
|
+
from pysci.errors import Outcome
|
|
18
|
+
from pysci.manifest import (
|
|
19
|
+
LICENSE_PATH,
|
|
20
|
+
MANIFEST_PATH,
|
|
21
|
+
METADATA_PATH,
|
|
22
|
+
README_PATH,
|
|
23
|
+
SIGNATURE_PATH,
|
|
24
|
+
TIMESTAMP_SIDECAR_PATH,
|
|
25
|
+
Manifest,
|
|
26
|
+
)
|
|
27
|
+
from pysci.source import FileSource, PathSource
|
|
28
|
+
from pysci.zipformat import parse_zip_layout
|
|
29
|
+
|
|
30
|
+
from ..errors import CliError, Exit, issue_json
|
|
31
|
+
from .files import copy_file_to
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SignedInput:
|
|
36
|
+
path: Path
|
|
37
|
+
kind: Literal["zip", "directory"]
|
|
38
|
+
result: VerificationResult
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def manifest(self) -> Manifest:
|
|
42
|
+
assert self.result.manifest is not None
|
|
43
|
+
return self.result.manifest
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def digest(self) -> str:
|
|
47
|
+
return self.manifest.digest
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def publication_id(self) -> str:
|
|
51
|
+
return self.manifest.publication_id
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def zip_sha256(self) -> str:
|
|
55
|
+
assert self.result.zip_sha256 is not None
|
|
56
|
+
return self.result.zip_sha256
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def sidecar(self) -> Path | None:
|
|
60
|
+
return find_sidecar(self.path, self.kind, self.digest)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_sidecar(path: Path, kind: str, digest: str) -> Path | None:
|
|
64
|
+
"""Locate a timestamp sidecar: ``manifest.json.sig.tsr`` or ``<digest>.tsr``."""
|
|
65
|
+
if kind == "directory":
|
|
66
|
+
candidate = path / TIMESTAMP_SIDECAR_PATH
|
|
67
|
+
return candidate if candidate.is_file() else None
|
|
68
|
+
for candidate in (path.parent / f"{digest}.tsr", path.with_suffix(".tsr")):
|
|
69
|
+
if candidate.is_file():
|
|
70
|
+
return candidate
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sidecar_target(path: Path, kind: str, digest: str) -> Path:
|
|
75
|
+
if kind == "directory":
|
|
76
|
+
return path / TIMESTAMP_SIDECAR_PATH
|
|
77
|
+
return path.parent / f"{digest}.tsr"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def verify_input(path: Path, expected_id: str | None = None) -> tuple[str, VerificationResult]:
|
|
81
|
+
if path.is_dir():
|
|
82
|
+
return "directory", verify_directory(path, expected_id=expected_id, compute_zip_sha256=True)
|
|
83
|
+
return "zip", verify_zip(path, expected_id=expected_id)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def candidates(path: Path) -> list[Path]:
|
|
87
|
+
"""Shared input rules: a ZIP or signed directory, else immediate ``*.zip`` children."""
|
|
88
|
+
if not path.exists():
|
|
89
|
+
raise CliError("input_missing", f"{path} does not exist", Exit.USAGE, file=str(path))
|
|
90
|
+
if path.is_file() or (path / MANIFEST_PATH).exists():
|
|
91
|
+
return [path]
|
|
92
|
+
found = sorted(p for p in path.iterdir() if p.suffix == ".zip" and p.is_file())
|
|
93
|
+
if not found:
|
|
94
|
+
raise CliError(
|
|
95
|
+
"no_candidates",
|
|
96
|
+
f"{path} is neither a signed publication nor a folder containing *.zip bundles",
|
|
97
|
+
Exit.USAGE,
|
|
98
|
+
file=str(path),
|
|
99
|
+
)
|
|
100
|
+
return found
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def collect_signed_inputs(
|
|
104
|
+
paths: list[Path],
|
|
105
|
+
) -> tuple[list[SignedInput], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
106
|
+
"""Verify every candidate (§6.4); return (valid deduplicated, invalid, duplicates)."""
|
|
107
|
+
valid: list[SignedInput] = []
|
|
108
|
+
invalid: list[dict[str, Any]] = []
|
|
109
|
+
duplicates: list[dict[str, Any]] = []
|
|
110
|
+
seen: dict[str, SignedInput] = {}
|
|
111
|
+
for root in paths:
|
|
112
|
+
for candidate in candidates(root):
|
|
113
|
+
kind, result = verify_input(candidate)
|
|
114
|
+
if not result.ok or result.manifest is None:
|
|
115
|
+
invalid.append(
|
|
116
|
+
{
|
|
117
|
+
"path": str(candidate),
|
|
118
|
+
"status": str(result.outcome),
|
|
119
|
+
"publication_id": result.publication_id,
|
|
120
|
+
"issues": [issue_json(i) for i in result.issues],
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
item = SignedInput(candidate, "zip" if kind == "zip" else "directory", result)
|
|
125
|
+
if item.publication_id in seen:
|
|
126
|
+
duplicates.append(
|
|
127
|
+
{
|
|
128
|
+
"path": str(candidate),
|
|
129
|
+
"publication_id": item.publication_id,
|
|
130
|
+
"same_as": str(seen[item.publication_id].path),
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
seen[item.publication_id] = item
|
|
135
|
+
valid.append(item)
|
|
136
|
+
return valid, invalid, duplicates
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def invalid_exit(entries: list[dict[str, Any]]) -> Exit:
|
|
140
|
+
statuses = {e["status"] for e in entries}
|
|
141
|
+
if Outcome.UNSUPPORTED in statuses:
|
|
142
|
+
return Exit.UNSUPPORTED
|
|
143
|
+
if Outcome.INVALID in statuses:
|
|
144
|
+
return Exit.INVALID
|
|
145
|
+
return Exit.INCOMPLETE
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def zip_manifest_and_signature(source: FileSource) -> tuple[bytes, bytes]:
|
|
149
|
+
entries = parse_zip_layout(source)
|
|
150
|
+
return (
|
|
151
|
+
source.read_at(entries[0].data_offset, entries[0].size),
|
|
152
|
+
source.read_at(entries[1].data_offset, entries[1].size),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def signature_of(item: SignedInput) -> bytes:
|
|
157
|
+
if item.kind == "directory":
|
|
158
|
+
return (item.path / SIGNATURE_PATH).read_bytes()
|
|
159
|
+
with FileSource(item.path) as source:
|
|
160
|
+
return zip_manifest_and_signature(source)[1]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_member(item: SignedInput, path: str) -> bytes:
|
|
164
|
+
"""Return verified bytes of one bundle member (manifest, signature, or listed file)."""
|
|
165
|
+
if item.kind == "directory":
|
|
166
|
+
data = (item.path / path).read_bytes()
|
|
167
|
+
if path == MANIFEST_PATH:
|
|
168
|
+
if data != item.manifest.raw:
|
|
169
|
+
raise CliError("input_changed", f"{item.path}: manifest changed", Exit.INCOMPLETE)
|
|
170
|
+
return data
|
|
171
|
+
if path != SIGNATURE_PATH:
|
|
172
|
+
verify_file(item.manifest, path, data)
|
|
173
|
+
return data
|
|
174
|
+
with FileSource(item.path) as source:
|
|
175
|
+
if path in (MANIFEST_PATH, SIGNATURE_PATH):
|
|
176
|
+
manifest, signature = zip_manifest_and_signature(source)
|
|
177
|
+
if manifest != item.manifest.raw:
|
|
178
|
+
raise CliError("input_changed", f"{item.path}: manifest changed", Exit.INCOMPLETE)
|
|
179
|
+
return manifest if path == MANIFEST_PATH else signature
|
|
180
|
+
return read_zip_entry(source, path, expected_id=item.publication_id)[1]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def reader_paths(manifest: Manifest, display: list[str]) -> list[str]:
|
|
184
|
+
"""Reader files: manifest, signature, metadata, README, any license, and display files."""
|
|
185
|
+
paths = [MANIFEST_PATH, SIGNATURE_PATH, METADATA_PATH, README_PATH]
|
|
186
|
+
if LICENSE_PATH in manifest.files:
|
|
187
|
+
paths.append(LICENSE_PATH)
|
|
188
|
+
paths.extend(p for p in display if p in manifest.files and p not in paths)
|
|
189
|
+
return paths
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def reader_files(item: SignedInput) -> dict[str, bytes]:
|
|
193
|
+
display = [d.path for d in item.result.metadata.display] if item.result.metadata else []
|
|
194
|
+
return {path: read_member(item, path) for path in reader_paths(item.manifest, display)}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def write_zip(item: SignedInput, handle: BinaryIO) -> None:
|
|
198
|
+
"""Write the canonical ZIP bytes of a verified input to ``handle``."""
|
|
199
|
+
if item.kind == "zip":
|
|
200
|
+
copy_file_to(handle, item.path)
|
|
201
|
+
return
|
|
202
|
+
manifest_bytes = (item.path / MANIFEST_PATH).read_bytes()
|
|
203
|
+
signature = (item.path / SIGNATURE_PATH).read_bytes()
|
|
204
|
+
files = {p: PathSource(item.path / p) for p in item.manifest.files}
|
|
205
|
+
write_bundle_zip(handle, manifest_bytes, signature, files)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Signing key files: locations, creation with owner-only access, and loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
11
|
+
|
|
12
|
+
from pysci.keys import (
|
|
13
|
+
PublicKey,
|
|
14
|
+
generate_private_key,
|
|
15
|
+
load_private_key_pem,
|
|
16
|
+
load_public_key_pem,
|
|
17
|
+
private_key_to_pem,
|
|
18
|
+
public_key_of,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from ..errors import CliError, Exit
|
|
22
|
+
from .files import is_within
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def public_key_path(private_path: Path) -> Path:
|
|
26
|
+
"""Replace a final ``.pem`` with ``.pub.pem``, or append ``.pub.pem``."""
|
|
27
|
+
name = private_path.name
|
|
28
|
+
if name.endswith(".pem"):
|
|
29
|
+
return private_path.with_name(name[: -len(".pem")] + ".pub.pem")
|
|
30
|
+
return private_path.with_name(name + ".pub.pem")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_key_path(explicit: str | None, configured: str | None, home: Path) -> Path:
|
|
34
|
+
if explicit:
|
|
35
|
+
return Path(explicit).expanduser().absolute()
|
|
36
|
+
if configured:
|
|
37
|
+
return Path(configured).expanduser().absolute()
|
|
38
|
+
return home / "key.pem"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_key_pair(private_path: Path) -> tuple[PublicKey, Path]:
|
|
42
|
+
"""Create a PKCS#8 private key (mode 0600) and its SubjectPublicKeyInfo public key.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
CliError: an output already exists (exit 2).
|
|
46
|
+
"""
|
|
47
|
+
public_path = public_key_path(private_path)
|
|
48
|
+
for path in (private_path, public_path):
|
|
49
|
+
if path.exists() or path.is_symlink():
|
|
50
|
+
raise CliError(
|
|
51
|
+
"key_exists",
|
|
52
|
+
f"{path} already exists; keygen never overwrites keys",
|
|
53
|
+
Exit.USAGE,
|
|
54
|
+
file=str(path),
|
|
55
|
+
)
|
|
56
|
+
private_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
57
|
+
key = generate_private_key()
|
|
58
|
+
public = public_key_of(key)
|
|
59
|
+
fd = os.open(private_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
60
|
+
try:
|
|
61
|
+
with os.fdopen(fd, "wb") as handle:
|
|
62
|
+
os.fchmod(handle.fileno(), 0o600)
|
|
63
|
+
handle.write(private_key_to_pem(key))
|
|
64
|
+
handle.flush()
|
|
65
|
+
os.fsync(handle.fileno())
|
|
66
|
+
fd = os.open(public_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
67
|
+
with os.fdopen(fd, "wb") as handle:
|
|
68
|
+
handle.write(public.to_pem())
|
|
69
|
+
except BaseException:
|
|
70
|
+
with contextlib.suppress(OSError):
|
|
71
|
+
private_path.unlink()
|
|
72
|
+
raise
|
|
73
|
+
return public, public_path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_signing_key(path: Path) -> Ed25519PrivateKey:
|
|
77
|
+
try:
|
|
78
|
+
data = path.read_bytes()
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
raise CliError(
|
|
81
|
+
"missing_key",
|
|
82
|
+
f"no private key at {path}; create one with `sci keygen` or pass --key PATH",
|
|
83
|
+
Exit.USAGE,
|
|
84
|
+
file=str(path),
|
|
85
|
+
) from None
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise CliError("key_unreadable", f"cannot read {path}: {exc}", Exit.USAGE) from None
|
|
88
|
+
try:
|
|
89
|
+
return load_private_key_pem(data)
|
|
90
|
+
except (ValueError, TypeError) as exc:
|
|
91
|
+
raise CliError(
|
|
92
|
+
"key_invalid",
|
|
93
|
+
f"{path} is not an unencrypted PKCS#8 Ed25519 private key: {exc}",
|
|
94
|
+
Exit.USAGE,
|
|
95
|
+
file=str(path),
|
|
96
|
+
) from None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def key_permission_warning(path: Path) -> str | None:
|
|
100
|
+
try:
|
|
101
|
+
mode = path.stat().st_mode
|
|
102
|
+
except OSError:
|
|
103
|
+
return None
|
|
104
|
+
if os.name == "posix" and mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
105
|
+
return f"private key {path} is accessible by other users; run chmod 600 {path}"
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def require_outside(key_path: Path, *directories: Path) -> None:
|
|
110
|
+
"""Private keys must stay outside publication input and output directories."""
|
|
111
|
+
for directory in directories:
|
|
112
|
+
if is_within(key_path, directory):
|
|
113
|
+
raise CliError(
|
|
114
|
+
"key_inside_publication",
|
|
115
|
+
f"private key {key_path} is inside {directory}; keep keys outside "
|
|
116
|
+
"publication input and output directories",
|
|
117
|
+
Exit.USAGE,
|
|
118
|
+
file=str(key_path),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_public_key_file(path: Path) -> PublicKey:
|
|
123
|
+
try:
|
|
124
|
+
return load_public_key_pem(path.read_bytes())
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
raise CliError("public_key_missing", f"{path} does not exist", Exit.USAGE) from None
|
|
127
|
+
except (ValueError, TypeError) as exc:
|
|
128
|
+
raise CliError(
|
|
129
|
+
"public_key_invalid",
|
|
130
|
+
f"{path} is not a SubjectPublicKeyInfo PEM Ed25519 public key: {exc}",
|
|
131
|
+
Exit.USAGE,
|
|
132
|
+
) from None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Local publication: store verified ``<digest>.zip`` bundles and sidecars in folders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..clients.tsa import merge_proofs, read_sidecar
|
|
10
|
+
from ..config import Config
|
|
11
|
+
from ..errors import ErrorRecord, Exit
|
|
12
|
+
from .files import InputChanged, StoreOutcome, atomic_write_bytes, store_bytes, store_no_clobber
|
|
13
|
+
from .inputs import SignedInput, write_zip
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def publish_to_folder(item: SignedInput, folder: Path) -> tuple[dict[str, Any], int]:
|
|
17
|
+
"""Store one verified bundle; return (report, exit code).
|
|
18
|
+
|
|
19
|
+
Identical existing bundles are kept; conflicting bytes are rejected and
|
|
20
|
+
left untouched. Timestamp sidecars are copied as ``<digest>.tsr`` and
|
|
21
|
+
merged without duplicating proofs.
|
|
22
|
+
"""
|
|
23
|
+
report: dict[str, Any] = {
|
|
24
|
+
"publication_id": item.publication_id,
|
|
25
|
+
"destination": str(folder),
|
|
26
|
+
"kind": "folder",
|
|
27
|
+
}
|
|
28
|
+
try:
|
|
29
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
target = folder / f"{item.digest}.zip"
|
|
31
|
+
stored = store_no_clobber(target, lambda handle: write_zip(item, handle), item.zip_sha256)
|
|
32
|
+
except InputChanged as exc:
|
|
33
|
+
report["status"] = "failed"
|
|
34
|
+
report["error"] = ErrorRecord("input_changed", str(exc), True).to_json()
|
|
35
|
+
return report, Exit.INCOMPLETE
|
|
36
|
+
except OSError as exc:
|
|
37
|
+
report["status"] = "failed"
|
|
38
|
+
report["error"] = ErrorRecord("write_failed", f"{folder}: {exc}", True).to_json()
|
|
39
|
+
return report, Exit.INCOMPLETE
|
|
40
|
+
report["path"] = str(target)
|
|
41
|
+
report["bundle"] = str(stored.outcome)
|
|
42
|
+
if stored.outcome is StoreOutcome.CONFLICT:
|
|
43
|
+
report["status"] = "failed"
|
|
44
|
+
report["error"] = ErrorRecord(
|
|
45
|
+
"destination_conflict",
|
|
46
|
+
f"{target} exists with different bytes; it was left unchanged",
|
|
47
|
+
file=str(target),
|
|
48
|
+
observed=f"sha256 {stored.existing_sha256}",
|
|
49
|
+
requirement=f"sha256 {item.zip_sha256}",
|
|
50
|
+
).to_json()
|
|
51
|
+
return report, Exit.INVALID
|
|
52
|
+
report["status"] = "stored"
|
|
53
|
+
sidecar = item.sidecar
|
|
54
|
+
if sidecar is not None:
|
|
55
|
+
report["timestamp_sidecar"] = _copy_sidecar(sidecar, folder / f"{item.digest}.tsr")
|
|
56
|
+
return report, Exit.OK
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _copy_sidecar(source: Path, target: Path) -> dict[str, Any]:
|
|
60
|
+
if source.exists() and target.exists() and os.path.samefile(source, target):
|
|
61
|
+
return {"path": str(target), "status": "unchanged"}
|
|
62
|
+
data = read_sidecar(source) or b""
|
|
63
|
+
existing = read_sidecar(target)
|
|
64
|
+
if existing is None:
|
|
65
|
+
store_bytes(target, data)
|
|
66
|
+
return {"path": str(target), "status": "written"}
|
|
67
|
+
if existing == data:
|
|
68
|
+
return {"path": str(target), "status": "unchanged"}
|
|
69
|
+
merged, problems = merge_proofs(existing, data)
|
|
70
|
+
if problems:
|
|
71
|
+
return {
|
|
72
|
+
"path": str(target),
|
|
73
|
+
"status": "preserved",
|
|
74
|
+
"problems": [p.to_json() for p in problems],
|
|
75
|
+
}
|
|
76
|
+
if merged != existing:
|
|
77
|
+
atomic_write_bytes(target, merged)
|
|
78
|
+
return {"path": str(target), "status": "merged"}
|
|
79
|
+
return {"path": str(target), "status": "unchanged"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def remember_folder(config: Config, folder: Path) -> bool:
|
|
83
|
+
"""Add a folder's canonical absolute path to the known folders; True if added."""
|
|
84
|
+
canonical = str(folder.resolve())
|
|
85
|
+
if canonical in config.folders:
|
|
86
|
+
return False
|
|
87
|
+
config.folders.append(canonical)
|
|
88
|
+
return True
|
pysci/accounts.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""External account methods for key claims (protocol §5.2.2-5.2.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
import idna
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Account",
|
|
15
|
+
"AccountType",
|
|
16
|
+
"account_problem",
|
|
17
|
+
"claim_urls",
|
|
18
|
+
"normalize_account",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_LDH_LABEL: Final = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
|
|
22
|
+
_GITHUB_LOGIN: Final = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,38})")
|
|
23
|
+
_HF_NAME: Final = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AccountType(StrEnum):
|
|
27
|
+
DOMAIN = "domain"
|
|
28
|
+
GITHUB = "github"
|
|
29
|
+
HUGGINGFACE = "huggingface"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Account:
|
|
34
|
+
"""An account object ``{"type": ..., "id": ...}`` (§5.2.1)."""
|
|
35
|
+
|
|
36
|
+
type: str
|
|
37
|
+
id: str
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> dict[str, str]:
|
|
40
|
+
return {"type": self.type, "id": self.id}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _domain_problem(host: str) -> str | None:
|
|
44
|
+
if not host or not host.isascii():
|
|
45
|
+
return "domain must be an ASCII DNS host name (use IDNA2008 A-labels)"
|
|
46
|
+
if host != host.lower():
|
|
47
|
+
return "domain must be lowercase"
|
|
48
|
+
if host.endswith("."):
|
|
49
|
+
return "domain must not end with a dot"
|
|
50
|
+
try:
|
|
51
|
+
ipaddress.ip_address(host.strip("[]"))
|
|
52
|
+
except ValueError:
|
|
53
|
+
pass
|
|
54
|
+
else:
|
|
55
|
+
return "IP addresses are not permitted"
|
|
56
|
+
if len(host) > 253:
|
|
57
|
+
return "domain exceeds 253 characters"
|
|
58
|
+
labels = host.split(".")
|
|
59
|
+
for label in labels:
|
|
60
|
+
if not _LDH_LABEL.fullmatch(label):
|
|
61
|
+
return f"invalid DNS label {label!r}"
|
|
62
|
+
if labels[-1].isdigit():
|
|
63
|
+
return "top-level label must not be numeric"
|
|
64
|
+
try:
|
|
65
|
+
if idna.encode(host, uts46=False).decode("ascii") != host:
|
|
66
|
+
return "domain is not in IDNA2008 A-label form"
|
|
67
|
+
except idna.IDNAError as exc:
|
|
68
|
+
return f"invalid IDNA2008 label: {exc}"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def account_problem(account_type: str, account_id: str) -> str | None:
|
|
73
|
+
"""Why ``(type, id)`` violates its account method, or None if it is valid.
|
|
74
|
+
|
|
75
|
+
Unknown account types are reported as unsupported by the caller; this
|
|
76
|
+
function returns a message for them as well.
|
|
77
|
+
"""
|
|
78
|
+
if account_type == AccountType.DOMAIN:
|
|
79
|
+
return _domain_problem(account_id)
|
|
80
|
+
if account_type == AccountType.GITHUB:
|
|
81
|
+
if account_id != account_id.lower():
|
|
82
|
+
return "GitHub login must be lowercase"
|
|
83
|
+
if not _GITHUB_LOGIN.fullmatch(account_id):
|
|
84
|
+
return "not a GitHub login"
|
|
85
|
+
return None
|
|
86
|
+
if account_type == AccountType.HUGGINGFACE:
|
|
87
|
+
return None if _HF_NAME.fullmatch(account_id) else "not a Hugging Face user or org name"
|
|
88
|
+
return f"unsupported account type {account_type!r}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def normalize_account(account_type: str, account_id: str) -> Account:
|
|
92
|
+
"""Normalize user input to the protocol spelling.
|
|
93
|
+
|
|
94
|
+
Domains are lowercased, a final dot removed, and Unicode labels converted
|
|
95
|
+
to IDNA2008 A-labels; GitHub logins are lowercased. Hugging Face names are
|
|
96
|
+
returned unchanged (their canonical spelling comes from the Hub API).
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: the result still violates the account method.
|
|
100
|
+
"""
|
|
101
|
+
if account_type == AccountType.DOMAIN:
|
|
102
|
+
host = account_id.strip().rstrip(".")
|
|
103
|
+
try:
|
|
104
|
+
host = idna.encode(host, uts46=True).decode("ascii")
|
|
105
|
+
except idna.IDNAError as exc:
|
|
106
|
+
raise ValueError(f"invalid domain: {exc}") from None
|
|
107
|
+
account_id = host.lower()
|
|
108
|
+
elif account_type == AccountType.GITHUB:
|
|
109
|
+
account_id = account_id.strip().lower()
|
|
110
|
+
problem = account_problem(account_type, account_id)
|
|
111
|
+
if problem is not None:
|
|
112
|
+
raise ValueError(problem)
|
|
113
|
+
return Account(account_type, account_id)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def claim_urls(account: Account, x: str) -> list[tuple[str, str]]:
|
|
117
|
+
"""Hosting locations ``(claim URL, signature URL)`` for a key's claim.
|
|
118
|
+
|
|
119
|
+
GitHub has two permitted repositories; both are returned in preference
|
|
120
|
+
order. Raises ValueError for unsupported account types.
|
|
121
|
+
"""
|
|
122
|
+
if account.type == AccountType.DOMAIN:
|
|
123
|
+
bases = [f"https://{account.id}/.well-known/sci/keys/{x}.json"]
|
|
124
|
+
elif account.type == AccountType.GITHUB:
|
|
125
|
+
bases = [
|
|
126
|
+
f"https://raw.githubusercontent.com/{account.id}/{account.id}/main/sci/keys/{x}.json",
|
|
127
|
+
f"https://raw.githubusercontent.com/{account.id}/.github/main/sci/keys/{x}.json",
|
|
128
|
+
]
|
|
129
|
+
elif account.type == AccountType.HUGGINGFACE:
|
|
130
|
+
bases = [
|
|
131
|
+
f"https://huggingface.co/spaces/{account.id}/README/resolve/main/sci/keys/{x}.json"
|
|
132
|
+
]
|
|
133
|
+
else:
|
|
134
|
+
raise ValueError(f"unsupported account type {account.type!r}")
|
|
135
|
+
return [(url, url + ".sig") for url in bases]
|