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.
Files changed (57) hide show
  1. package/README.md +302 -0
  2. package/README.zh-CN.md +304 -0
  3. package/cordis.patch.yml +11 -0
  4. package/core/dsh_pack/__init__.py +77 -0
  5. package/core/dsh_pack/__main__.py +4 -0
  6. package/core/dsh_pack/cli.py +372 -0
  7. package/core/dsh_pack/errors.py +167 -0
  8. package/core/dsh_pack/export.py +912 -0
  9. package/core/dsh_pack/importer.py +1347 -0
  10. package/core/dsh_pack/network.py +77 -0
  11. package/core/dsh_pack/pack.py +248 -0
  12. package/core/dsh_pack/preflight.py +811 -0
  13. package/core/dsh_pack/user_cli.py +462 -0
  14. package/core/dsh_pack/validation.py +272 -0
  15. package/core/dsh_pack/verify.py +1336 -0
  16. package/lib/client.js +1623 -0
  17. package/lib/index.d.ts +30 -0
  18. package/lib/index.js +1275 -0
  19. package/package.json +73 -0
  20. package/restart-helper.mjs +121 -0
  21. package/runtime/python/LICENSE.txt +676 -0
  22. package/runtime/python/__install__.json +82 -0
  23. package/runtime/python/_asyncio.pyd +0 -0
  24. package/runtime/python/_bz2.pyd +0 -0
  25. package/runtime/python/_ctypes.pyd +0 -0
  26. package/runtime/python/_decimal.pyd +0 -0
  27. package/runtime/python/_elementtree.pyd +0 -0
  28. package/runtime/python/_hashlib.pyd +0 -0
  29. package/runtime/python/_lzma.pyd +0 -0
  30. package/runtime/python/_multiprocessing.pyd +0 -0
  31. package/runtime/python/_overlapped.pyd +0 -0
  32. package/runtime/python/_queue.pyd +0 -0
  33. package/runtime/python/_remote_debugging.pyd +0 -0
  34. package/runtime/python/_socket.pyd +0 -0
  35. package/runtime/python/_sqlite3.pyd +0 -0
  36. package/runtime/python/_ssl.pyd +0 -0
  37. package/runtime/python/_uuid.pyd +0 -0
  38. package/runtime/python/_wmi.pyd +0 -0
  39. package/runtime/python/_zoneinfo.pyd +0 -0
  40. package/runtime/python/_zstd.pyd +0 -0
  41. package/runtime/python/libcrypto-3.dll +0 -0
  42. package/runtime/python/libffi-8.dll +0 -0
  43. package/runtime/python/libssl-3.dll +0 -0
  44. package/runtime/python/pyexpat.pyd +0 -0
  45. package/runtime/python/python.cat +0 -0
  46. package/runtime/python/python.exe +0 -0
  47. package/runtime/python/python3.dll +0 -0
  48. package/runtime/python/python314._pth +6 -0
  49. package/runtime/python/python314.dll +0 -0
  50. package/runtime/python/python314.zip +0 -0
  51. package/runtime/python/pythonw.exe +0 -0
  52. package/runtime/python/select.pyd +0 -0
  53. package/runtime/python/sqlite3.dll +0 -0
  54. package/runtime/python/unicodedata.pyd +0 -0
  55. package/runtime/python/vcruntime140.dll +0 -0
  56. package/runtime/python/vcruntime140_1.dll +0 -0
  57. 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
+ )