dsh-crate-web 0.1.0
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.
- package/README.md +302 -0
- package/README.zh-CN.md +304 -0
- package/cordis.patch.yml +11 -0
- package/core/dsh_pack/__init__.py +77 -0
- package/core/dsh_pack/__main__.py +4 -0
- package/core/dsh_pack/cli.py +372 -0
- package/core/dsh_pack/errors.py +167 -0
- package/core/dsh_pack/export.py +912 -0
- package/core/dsh_pack/importer.py +1347 -0
- package/core/dsh_pack/network.py +77 -0
- package/core/dsh_pack/pack.py +248 -0
- package/core/dsh_pack/preflight.py +811 -0
- package/core/dsh_pack/user_cli.py +462 -0
- package/core/dsh_pack/validation.py +272 -0
- package/core/dsh_pack/verify.py +1336 -0
- package/lib/client.js +1623 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +1275 -0
- package/package.json +73 -0
- package/restart-helper.mjs +121 -0
- package/runtime/python/LICENSE.txt +676 -0
- package/runtime/python/__install__.json +82 -0
- package/runtime/python/_asyncio.pyd +0 -0
- package/runtime/python/_bz2.pyd +0 -0
- package/runtime/python/_ctypes.pyd +0 -0
- package/runtime/python/_decimal.pyd +0 -0
- package/runtime/python/_elementtree.pyd +0 -0
- package/runtime/python/_hashlib.pyd +0 -0
- package/runtime/python/_lzma.pyd +0 -0
- package/runtime/python/_multiprocessing.pyd +0 -0
- package/runtime/python/_overlapped.pyd +0 -0
- package/runtime/python/_queue.pyd +0 -0
- package/runtime/python/_remote_debugging.pyd +0 -0
- package/runtime/python/_socket.pyd +0 -0
- package/runtime/python/_sqlite3.pyd +0 -0
- package/runtime/python/_ssl.pyd +0 -0
- package/runtime/python/_uuid.pyd +0 -0
- package/runtime/python/_wmi.pyd +0 -0
- package/runtime/python/_zoneinfo.pyd +0 -0
- package/runtime/python/_zstd.pyd +0 -0
- package/runtime/python/libcrypto-3.dll +0 -0
- package/runtime/python/libffi-8.dll +0 -0
- package/runtime/python/libssl-3.dll +0 -0
- package/runtime/python/pyexpat.pyd +0 -0
- package/runtime/python/python.cat +0 -0
- package/runtime/python/python.exe +0 -0
- package/runtime/python/python3.dll +0 -0
- package/runtime/python/python314._pth +6 -0
- package/runtime/python/python314.dll +0 -0
- package/runtime/python/python314.zip +0 -0
- package/runtime/python/pythonw.exe +0 -0
- package/runtime/python/select.pyd +0 -0
- package/runtime/python/sqlite3.dll +0 -0
- package/runtime/python/unicodedata.pyd +0 -0
- package/runtime/python/vcruntime140.dll +0 -0
- package/runtime/python/vcruntime140_1.dll +0 -0
- package/runtime/python/winsound.pyd +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Network source resolution shared by Preflight and Import.
|
|
2
|
+
|
|
3
|
+
This module only turns Pack source metadata into an npm install argument. It
|
|
4
|
+
does not resolve versions itself and never invokes a shell. npm remains the
|
|
5
|
+
resolver for registry, GitHub/Git and tarball sources.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
NETWORK_SOURCE_TYPES = frozenset({"registry", "git", "tarball", "alias"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def network_source_details(plugin: Mapping[str, Any]) -> dict[str, str] | None:
|
|
20
|
+
"""Return the recorded network source, or ``None`` for local-only sources."""
|
|
21
|
+
|
|
22
|
+
requested = plugin.get("requested")
|
|
23
|
+
if not isinstance(requested, Mapping):
|
|
24
|
+
return None
|
|
25
|
+
source_type = requested.get("sourceType")
|
|
26
|
+
specifier = requested.get("specifier")
|
|
27
|
+
if not isinstance(source_type, str) or source_type not in NETWORK_SOURCE_TYPES:
|
|
28
|
+
return None
|
|
29
|
+
if not isinstance(specifier, str) or not specifier.strip() or specifier == "unknown":
|
|
30
|
+
resolved = plugin.get("resolved")
|
|
31
|
+
locator = resolved.get("locator") if isinstance(resolved, Mapping) else None
|
|
32
|
+
if isinstance(locator, str) and locator.startswith("registry:"):
|
|
33
|
+
specifier = locator.removeprefix("registry:")
|
|
34
|
+
elif isinstance(locator, str) and locator.strip():
|
|
35
|
+
specifier = locator
|
|
36
|
+
else:
|
|
37
|
+
return None
|
|
38
|
+
return {"sourceType": source_type, "specifier": specifier.strip()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def npm_install_specifier(plugin: Mapping[str, Any]) -> str | None:
|
|
42
|
+
"""Build one positional npm package spec from a Pack plugin entry.
|
|
43
|
+
|
|
44
|
+
``git:owner/repo`` is normalized to npm's GitHub shorthand. Other Git,
|
|
45
|
+
tarball and alias forms are passed through to npm unchanged. The caller
|
|
46
|
+
must pass the argument after ``--`` so a malicious Pack cannot turn it
|
|
47
|
+
into an npm option.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
details = network_source_details(plugin)
|
|
51
|
+
if details is None:
|
|
52
|
+
return None
|
|
53
|
+
name = plugin.get("name")
|
|
54
|
+
if not isinstance(name, str) or not name.strip():
|
|
55
|
+
return None
|
|
56
|
+
source_type = details["sourceType"]
|
|
57
|
+
specifier = details["specifier"]
|
|
58
|
+
if source_type in {"registry", "alias"}:
|
|
59
|
+
resolved = plugin.get("resolved")
|
|
60
|
+
exact_version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
61
|
+
if (
|
|
62
|
+
source_type == "registry"
|
|
63
|
+
and isinstance(exact_version, str)
|
|
64
|
+
and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.\-]+)?", exact_version)
|
|
65
|
+
):
|
|
66
|
+
# Install the exact version recorded at export time. Using the
|
|
67
|
+
# requested range alone lets npm resolve to a different (older)
|
|
68
|
+
# version when the target Profile already declares the same range.
|
|
69
|
+
return f"{name}@={exact_version}"
|
|
70
|
+
return f"{name}@{specifier}"
|
|
71
|
+
if source_type == "git" and specifier.startswith("git:"):
|
|
72
|
+
value = specifier[4:]
|
|
73
|
+
if value.startswith(("http://", "https://", "ssh://", "git+")):
|
|
74
|
+
return value if value.startswith("git+") else f"git+{value}"
|
|
75
|
+
return f"github:{value}"
|
|
76
|
+
return specifier
|
|
77
|
+
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Safe ZIP container operations for the Phase 1 DSH Pack format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
import zipfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import DuplicateEntryError, IntegrityError, PackFormatError, PathSafetyError
|
|
16
|
+
from .validation import (
|
|
17
|
+
validate_archive_path,
|
|
18
|
+
validate_manifest,
|
|
19
|
+
validate_payload_key,
|
|
20
|
+
validate_plugins_lock,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sha256_bytes(data: bytes) -> str:
|
|
25
|
+
"""Return the lowercase SHA-256 digest for one byte sequence."""
|
|
26
|
+
|
|
27
|
+
return hashlib.sha256(data).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _file_bytes(value: Any, path: str) -> bytes:
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
return value.encode("utf-8")
|
|
33
|
+
if isinstance(value, bytes):
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, (bytearray, memoryview)):
|
|
36
|
+
return bytes(value)
|
|
37
|
+
raise TypeError(f"{path} values must be str or bytes")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _file_map(value: Any, path: str, root: str) -> dict[str, bytes]:
|
|
41
|
+
if not isinstance(value, dict):
|
|
42
|
+
raise TypeError(f"{path} must be a mapping of relative paths to bytes")
|
|
43
|
+
result: dict[str, bytes] = {}
|
|
44
|
+
for key, content in value.items():
|
|
45
|
+
archive_path = validate_payload_key(key, root, path)
|
|
46
|
+
relative = archive_path[len(root) + 1:]
|
|
47
|
+
if relative in result:
|
|
48
|
+
raise DuplicateEntryError(f"duplicate logical Pack member: {archive_path}")
|
|
49
|
+
result[relative] = _file_bytes(content, f"{path}[{key!r}]")
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class PackData:
|
|
55
|
+
"""Semantic Pack data independent of ZIP ordering or compression."""
|
|
56
|
+
|
|
57
|
+
manifest: dict[str, Any]
|
|
58
|
+
profile_files: dict[str, bytes | str]
|
|
59
|
+
plugins_lock: dict[str, Any]
|
|
60
|
+
plugin_artifacts: dict[str, bytes | str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalise_data(data: PackData) -> tuple[dict[str, Any], dict[str, bytes], dict[str, Any], dict[str, bytes]]:
|
|
64
|
+
if not isinstance(data, PackData):
|
|
65
|
+
raise TypeError("create() expects PackData")
|
|
66
|
+
manifest = copy.deepcopy(data.manifest)
|
|
67
|
+
profile_files = _file_map(data.profile_files, "profile_files", "profile")
|
|
68
|
+
plugin_artifacts = _file_map(data.plugin_artifacts, "plugin_artifacts", "plugins")
|
|
69
|
+
plugins_lock = copy.deepcopy(data.plugins_lock)
|
|
70
|
+
if not profile_files:
|
|
71
|
+
raise PackFormatError("profile/ must contain at least one file")
|
|
72
|
+
validate_manifest(manifest)
|
|
73
|
+
artifact_paths = {f"plugins/{name}" for name in plugin_artifacts}
|
|
74
|
+
validate_plugins_lock(plugins_lock, artifact_paths=artifact_paths)
|
|
75
|
+
_validate_artifact_hashes(plugins_lock, plugin_artifacts)
|
|
76
|
+
return manifest, profile_files, plugins_lock, plugin_artifacts
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_artifact_hashes(plugins_lock: dict[str, Any], plugin_artifacts: dict[str, bytes]) -> None:
|
|
80
|
+
for index, plugin in enumerate(plugins_lock["plugins"]):
|
|
81
|
+
artifact = plugin["artifact"]
|
|
82
|
+
if artifact["mode"] != "embedded":
|
|
83
|
+
continue
|
|
84
|
+
path = artifact["path"]
|
|
85
|
+
relative = path[len("plugins/"):]
|
|
86
|
+
actual = sha256_bytes(plugin_artifacts[relative])
|
|
87
|
+
if actual != artifact["sha256"]:
|
|
88
|
+
raise IntegrityError(
|
|
89
|
+
f"plugins.lock.plugins[{index}].artifact.sha256 does not match {path}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _json_bytes(value: Any) -> bytes:
|
|
94
|
+
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _payload_entries(
|
|
98
|
+
profile_files: dict[str, bytes],
|
|
99
|
+
plugins_lock_bytes: bytes,
|
|
100
|
+
plugin_artifacts: dict[str, bytes],
|
|
101
|
+
) -> dict[str, bytes]:
|
|
102
|
+
entries: dict[str, bytes] = {"plugins.lock.json": plugins_lock_bytes}
|
|
103
|
+
entries.update({f"profile/{name}": content for name, content in profile_files.items()})
|
|
104
|
+
entries.update({f"plugins/{name}": content for name, content in plugin_artifacts.items()})
|
|
105
|
+
return dict(sorted(entries.items()))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _zip_info(name: str) -> zipfile.ZipInfo:
|
|
109
|
+
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
110
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
111
|
+
info.create_system = 0
|
|
112
|
+
info.external_attr = 0o600 << 16
|
|
113
|
+
return info
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create(data: PackData, destination: str | os.PathLike[str]) -> Path:
|
|
117
|
+
"""Create one `.dshcrate` atomically and return its path.
|
|
118
|
+
|
|
119
|
+
The manifest integrity table covers every member except `manifest.json`
|
|
120
|
+
itself; self-hashing the manifest would be circular. ZIP member order is
|
|
121
|
+
deterministic but is not part of the Pack semantics.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
manifest, profile_files, plugins_lock, plugin_artifacts = _normalise_data(data)
|
|
125
|
+
lock_bytes = _json_bytes(plugins_lock)
|
|
126
|
+
entries = _payload_entries(profile_files, lock_bytes, plugin_artifacts)
|
|
127
|
+
manifest["integrity"] = {
|
|
128
|
+
"algorithm": "sha256",
|
|
129
|
+
"files": {name: sha256_bytes(content) for name, content in entries.items()},
|
|
130
|
+
}
|
|
131
|
+
validate_manifest(manifest, require_integrity=True)
|
|
132
|
+
manifest_bytes = _json_bytes(manifest)
|
|
133
|
+
|
|
134
|
+
destination_path = Path(destination)
|
|
135
|
+
if not destination_path.name:
|
|
136
|
+
raise PackFormatError("destination must be a file path")
|
|
137
|
+
temp_path: Path | None = None
|
|
138
|
+
try:
|
|
139
|
+
with tempfile.NamedTemporaryFile(
|
|
140
|
+
mode="wb", prefix=f".{destination_path.name}.", suffix=".tmp",
|
|
141
|
+
dir=destination_path.parent, delete=False,
|
|
142
|
+
) as temp:
|
|
143
|
+
temp_path = Path(temp.name)
|
|
144
|
+
with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_DEFLATED, allowZip64=False) as archive:
|
|
145
|
+
archive.writestr(_zip_info("manifest.json"), manifest_bytes)
|
|
146
|
+
for name, content in entries.items():
|
|
147
|
+
archive.writestr(_zip_info(name), content)
|
|
148
|
+
os.replace(temp_path, destination_path)
|
|
149
|
+
temp_path = None
|
|
150
|
+
finally:
|
|
151
|
+
if temp_path is not None:
|
|
152
|
+
try:
|
|
153
|
+
temp_path.unlink()
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
pass
|
|
156
|
+
return destination_path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _validate_archive_member(name: str) -> str:
|
|
160
|
+
canonical = validate_archive_path(name)
|
|
161
|
+
if canonical == "manifest.json" or canonical == "plugins.lock.json":
|
|
162
|
+
return canonical
|
|
163
|
+
if canonical.startswith("profile/") or canonical.startswith("plugins/"):
|
|
164
|
+
return canonical
|
|
165
|
+
raise PathSafetyError(f"unsupported Pack member path: {name!r}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_json(content: bytes, name: str) -> dict[str, Any]:
|
|
169
|
+
try:
|
|
170
|
+
value = json.loads(content.decode("utf-8"))
|
|
171
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
172
|
+
raise PackFormatError(f"{name} is not valid UTF-8 JSON") from error
|
|
173
|
+
if not isinstance(value, dict):
|
|
174
|
+
raise PackFormatError(f"{name} must contain a JSON object")
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _read_entries(source: Path) -> dict[str, bytes]:
|
|
179
|
+
try:
|
|
180
|
+
archive = zipfile.ZipFile(source, mode="r")
|
|
181
|
+
except (FileNotFoundError, zipfile.BadZipFile, OSError) as error:
|
|
182
|
+
raise PackFormatError(f"cannot open Pack ZIP: {source}") from error
|
|
183
|
+
with archive:
|
|
184
|
+
entries: dict[str, bytes] = {}
|
|
185
|
+
for info in archive.infolist():
|
|
186
|
+
if info.is_dir() or info.filename.endswith("/"):
|
|
187
|
+
raise PathSafetyError(f"directory ZIP members are not allowed: {info.filename!r}")
|
|
188
|
+
name = _validate_archive_member(info.filename)
|
|
189
|
+
if name in entries:
|
|
190
|
+
raise DuplicateEntryError(f"duplicate ZIP member: {name}")
|
|
191
|
+
try:
|
|
192
|
+
entries[name] = archive.read(info)
|
|
193
|
+
except (OSError, RuntimeError, zipfile.BadZipFile) as error:
|
|
194
|
+
raise PackFormatError(f"cannot read ZIP member: {name}") from error
|
|
195
|
+
return entries
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _verify_integrity(manifest: dict[str, Any], entries: dict[str, bytes]) -> None:
|
|
199
|
+
files = manifest["integrity"]["files"]
|
|
200
|
+
actual_files = set(entries) - {"manifest.json"}
|
|
201
|
+
declared_files = set(files)
|
|
202
|
+
if declared_files != actual_files:
|
|
203
|
+
missing = sorted(declared_files - actual_files)
|
|
204
|
+
unexpected = sorted(actual_files - declared_files)
|
|
205
|
+
raise IntegrityError(f"integrity file set mismatch; missing={missing}, unexpected={unexpected}")
|
|
206
|
+
for name, expected in files.items():
|
|
207
|
+
actual = sha256_bytes(entries[name])
|
|
208
|
+
if actual != expected:
|
|
209
|
+
raise IntegrityError(f"SHA-256 mismatch for {name}: expected {expected}, got {actual}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def read(source: str | os.PathLike[str]) -> PackData:
|
|
213
|
+
"""Read, validate, and return semantic data from one `.dshcrate`."""
|
|
214
|
+
|
|
215
|
+
source_path = Path(source)
|
|
216
|
+
entries = _read_entries(source_path)
|
|
217
|
+
if "manifest.json" not in entries:
|
|
218
|
+
raise PackFormatError("Pack is missing manifest.json")
|
|
219
|
+
if "plugins.lock.json" not in entries:
|
|
220
|
+
raise PackFormatError("Pack is missing plugins.lock.json")
|
|
221
|
+
if not any(name.startswith("profile/") for name in entries):
|
|
222
|
+
raise PackFormatError("Pack is missing profile/ files")
|
|
223
|
+
|
|
224
|
+
manifest = _read_json(entries["manifest.json"], "manifest.json")
|
|
225
|
+
validate_manifest(manifest, require_integrity=True)
|
|
226
|
+
_verify_integrity(manifest, entries)
|
|
227
|
+
plugins_lock = _read_json(entries["plugins.lock.json"], "plugins.lock.json")
|
|
228
|
+
profile_files = {
|
|
229
|
+
name[len("profile/"):]: content
|
|
230
|
+
for name, content in entries.items()
|
|
231
|
+
if name.startswith("profile/")
|
|
232
|
+
}
|
|
233
|
+
plugin_artifacts = {
|
|
234
|
+
name[len("plugins/"):]: content
|
|
235
|
+
for name, content in entries.items()
|
|
236
|
+
if name.startswith("plugins/")
|
|
237
|
+
}
|
|
238
|
+
validate_plugins_lock(
|
|
239
|
+
plugins_lock,
|
|
240
|
+
artifact_paths={f"plugins/{name}" for name in plugin_artifacts},
|
|
241
|
+
)
|
|
242
|
+
_validate_artifact_hashes(plugins_lock, plugin_artifacts)
|
|
243
|
+
return PackData(
|
|
244
|
+
manifest=manifest,
|
|
245
|
+
profile_files=profile_files,
|
|
246
|
+
plugins_lock=plugins_lock,
|
|
247
|
+
plugin_artifacts=plugin_artifacts,
|
|
248
|
+
)
|