dsh-crate-web 0.1.0 → 0.1.2-preview.2

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -290
  3. package/README.zh-CN.md +2 -303
  4. package/THIRD-PARTY-LICENSE.txt +21 -0
  5. package/cordis.patch.yml +1 -6
  6. package/core/dsh_pack/_version.py +1 -0
  7. package/core/dsh_pack/bundle.py +74 -0
  8. package/core/dsh_pack/cli.py +81 -18
  9. package/core/dsh_pack/desktop_verify.py +304 -0
  10. package/core/dsh_pack/diagnostics.py +1170 -0
  11. package/core/dsh_pack/errors.py +16 -1
  12. package/core/dsh_pack/export.py +86 -103
  13. package/core/dsh_pack/export_plan.py +96 -0
  14. package/core/dsh_pack/identity.py +25 -0
  15. package/core/dsh_pack/importer.py +1598 -1347
  16. package/core/dsh_pack/network.py +5 -4
  17. package/core/dsh_pack/pack.py +14 -2
  18. package/core/dsh_pack/portability.py +392 -0
  19. package/core/dsh_pack/preflight.py +63 -16
  20. package/core/dsh_pack/troubleshooting.py +94 -0
  21. package/core/dsh_pack/user_cli.py +16 -0
  22. package/core/dsh_pack/validation.py +64 -4
  23. package/core/dsh_pack/verify.py +1485 -1336
  24. package/core/dsh_pack/windows_verify.py +177 -0
  25. package/desktop-patch-helper.mjs +140 -0
  26. package/lib/client.js +292 -102
  27. package/lib/index.d.ts +7 -1
  28. package/lib/index.js +1121 -229
  29. package/package.json +13 -5
  30. package/restart-helper.mjs +121 -121
  31. package/runtime/python/LICENSE.txt +676 -676
  32. package/runtime/python/__install__.json +81 -81
  33. package/runtime/python/python314._pth +6 -6
  34. package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
  35. package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
  36. package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
  37. package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
  38. package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
  39. package/verify-desktop.mjs +314 -0
@@ -11,6 +11,7 @@ import re
11
11
 
12
12
  from collections.abc import Mapping
13
13
  from typing import Any
14
+ from .identity import expected_install_version, unknown_source
14
15
 
15
16
 
16
17
  NETWORK_SOURCE_TYPES = frozenset({"registry", "git", "tarball", "alias"})
@@ -20,7 +21,7 @@ def network_source_details(plugin: Mapping[str, Any]) -> dict[str, str] | None:
20
21
  """Return the recorded network source, or ``None`` for local-only sources."""
21
22
 
22
23
  requested = plugin.get("requested")
23
- if not isinstance(requested, Mapping):
24
+ if not isinstance(requested, Mapping) or unknown_source(plugin):
24
25
  return None
25
26
  source_type = requested.get("sourceType")
26
27
  specifier = requested.get("specifier")
@@ -56,14 +57,14 @@ def npm_install_specifier(plugin: Mapping[str, Any]) -> str | None:
56
57
  source_type = details["sourceType"]
57
58
  specifier = details["specifier"]
58
59
  if source_type in {"registry", "alias"}:
59
- resolved = plugin.get("resolved")
60
- exact_version = resolved.get("version") if isinstance(resolved, Mapping) else None
60
+ exact_version = expected_install_version(plugin)
61
61
  if (
62
62
  source_type == "registry"
63
63
  and isinstance(exact_version, str)
64
64
  and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.\-]+)?", exact_version)
65
65
  ):
66
- # Install the exact version recorded at export time. Using the
66
+ # Install the exact recorded installation target (legacy resolution
67
+ # or observed runtime when resolution is explicitly unknown). Using the
67
68
  # requested range alone lets npm resolve to a different (older)
68
69
  # version when the target Profile already declares the same range.
69
70
  return f"{name}@={exact_version}"
@@ -8,7 +8,7 @@ import json
8
8
  import os
9
9
  import tempfile
10
10
  import zipfile
11
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
12
12
  from pathlib import Path
13
13
  from typing import Any
14
14
 
@@ -18,6 +18,7 @@ from .validation import (
18
18
  validate_manifest,
19
19
  validate_payload_key,
20
20
  validate_plugins_lock,
21
+ validate_format_pair,
21
22
  )
22
23
 
23
24
 
@@ -58,6 +59,7 @@ class PackData:
58
59
  profile_files: dict[str, bytes | str]
59
60
  plugins_lock: dict[str, Any]
60
61
  plugin_artifacts: dict[str, bytes | str]
62
+ user_data: dict[str, bytes] = field(default_factory=dict)
61
63
 
62
64
 
63
65
  def _normalise_data(data: PackData) -> tuple[dict[str, Any], dict[str, bytes], dict[str, Any], dict[str, bytes]]:
@@ -72,6 +74,7 @@ def _normalise_data(data: PackData) -> tuple[dict[str, Any], dict[str, bytes], d
72
74
  validate_manifest(manifest)
73
75
  artifact_paths = {f"plugins/{name}" for name in plugin_artifacts}
74
76
  validate_plugins_lock(plugins_lock, artifact_paths=artifact_paths)
77
+ validate_format_pair(manifest, plugins_lock)
75
78
  _validate_artifact_hashes(plugins_lock, plugin_artifacts)
76
79
  return manifest, profile_files, plugins_lock, plugin_artifacts
77
80
 
@@ -122,8 +125,12 @@ def create(data: PackData, destination: str | os.PathLike[str]) -> Path:
122
125
  """
123
126
 
124
127
  manifest, profile_files, plugins_lock, plugin_artifacts = _normalise_data(data)
128
+ from .portability import validate_inventory
129
+ user_data = _file_map(data.user_data, "user_data", "user-data")
130
+ validate_inventory(manifest, user_data, plugins_lock)
125
131
  lock_bytes = _json_bytes(plugins_lock)
126
132
  entries = _payload_entries(profile_files, lock_bytes, plugin_artifacts)
133
+ entries.update({f"user-data/{name}": content for name, content in user_data.items()})
127
134
  manifest["integrity"] = {
128
135
  "algorithm": "sha256",
129
136
  "files": {name: sha256_bytes(content) for name, content in entries.items()},
@@ -160,7 +167,7 @@ def _validate_archive_member(name: str) -> str:
160
167
  canonical = validate_archive_path(name)
161
168
  if canonical == "manifest.json" or canonical == "plugins.lock.json":
162
169
  return canonical
163
- if canonical.startswith("profile/") or canonical.startswith("plugins/"):
170
+ if canonical.startswith("profile/") or canonical.startswith("plugins/") or canonical.startswith("user-data/"):
164
171
  return canonical
165
172
  raise PathSafetyError(f"unsupported Pack member path: {name!r}")
166
173
 
@@ -239,8 +246,13 @@ def read(source: str | os.PathLike[str]) -> PackData:
239
246
  plugins_lock,
240
247
  artifact_paths={f"plugins/{name}" for name in plugin_artifacts},
241
248
  )
249
+ validate_format_pair(manifest, plugins_lock)
242
250
  _validate_artifact_hashes(plugins_lock, plugin_artifacts)
251
+ from .portability import validate_inventory
252
+ user_data = {name[len("user-data/"):]: content for name, content in entries.items() if name.startswith("user-data/")}
253
+ validate_inventory(manifest, user_data, plugins_lock)
243
254
  return PackData(
255
+ user_data=user_data,
244
256
  manifest=manifest,
245
257
  profile_files=profile_files,
246
258
  plugins_lock=plugins_lock,
@@ -0,0 +1,392 @@
1
+ """Opt-in portable data for released v0/v4 JSONL and JSON storage layouts.
2
+
3
+ Plugin storage ownership must be explicitly selected; filenames never imply it.
4
+ """
5
+ from __future__ import annotations
6
+ import hashlib
7
+ import json
8
+ import ntpath
9
+ import os
10
+ import posixpath
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Any, Mapping, Sequence
14
+ from .errors import ExportError, PackImportError, SchemaValidationError
15
+ from .validation import validate_archive_path
16
+
17
+ KINDS = {"conversation", "plugin-config", "plugin-workflow", "plugin-data"}
18
+ CLASSES = {"portable", "reference-only", "secret-name-only", "excluded", "unsupported"}
19
+ MAX_DATA_BYTES = 32 * 1024 * 1024
20
+ MAX_TOTAL_BYTES = 128 * 1024 * 1024
21
+ _SECRET_KEY = re.compile(r"(?i)^(?:.*[_-])?(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|credential|cookie|authorization|private[_-]?key)s?$")
22
+ _SECRET_TEXT = re.compile(
23
+ r"(?i)(?:-----BEGIN (?:[A-Z ]*PRIVATE KEY)-----|"
24
+ r"\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|"
25
+ r"\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}|"
26
+ r"(?<![A-Za-z0-9_])(?:api[_-]?key|access[_-]?token|password|authorization|cookie)"
27
+ r"\s*[\"']?\s*[:=]\s*[\"']?(?!\$\{)[^\s\"',}\]]{4,})"
28
+ )
29
+ _PLACEHOLDER = re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$")
30
+ _SESSION_NAMES = {"session.jsonl": 0, "session.v4.jsonl": 4}
31
+
32
+
33
+ def _session_generation(filename: str) -> int | None:
34
+ return _SESSION_NAMES.get(filename.removesuffix(".zstd"))
35
+
36
+
37
+ def _safe_integer(value: Any, *, minimum: int = 0) -> bool:
38
+ return type(value) is int and minimum <= value <= 2**53 - 1
39
+
40
+
41
+ def _validate_v4_session(header: Mapping[str, Any], events: Sequence[Any]) -> None:
42
+ """Inspect released framing and closed turn boundaries without rewriting it.
43
+
44
+ Source: official 0.2.1-rc.2 session-format-v3-to-v4 physical codec delegates
45
+ to released v2 framing. Empty and metadata-only sessions are valid artifacts;
46
+ an open turn, step, seed boundary or torn physical tail is not portable.
47
+ Plugin payload interpretation and runtime restoration remain Verify's job.
48
+ """
49
+ required = {"type", "version", "id", "createdAt", "isSeeded", "delegationDepth"}
50
+ allowed = required | {"cwd", "parentSession", "origin", "agentPreset"}
51
+ if (not required.issubset(header) or not set(header).issubset(allowed)
52
+ or type(header.get("isSeeded")) is not bool
53
+ or not _safe_integer(header.get("createdAt"))
54
+ or not _safe_integer(header.get("delegationDepth"))
55
+ or any(key in header and not isinstance(header[key], str)
56
+ for key in ("cwd", "parentSession", "agentPreset"))
57
+ or ("cwd" in header and not (ntpath.isabs(header["cwd"]) or posixpath.isabs(header["cwd"])))
58
+ or ("origin" in header and header["origin"] != "subagent")):
59
+ raise ExportError("unsupported v4 session header; a matching DSH adapter is required")
60
+ event_required = {"type", "seq", "time", "data"}
61
+ event_allowed = event_required | {"ignorable", "sourceEventSeqs", "surfaceOp"}
62
+ turn = step = None
63
+ next_turn = next_step = 1
64
+ inherited_marker = False
65
+ for index, event in enumerate(events):
66
+ if (not isinstance(event, dict) or not event_required.issubset(event)
67
+ or not set(event).issubset(event_allowed)
68
+ or not isinstance(event["type"], str) or not event["type"]
69
+ or not _safe_integer(event["seq"]) or event["seq"] != index
70
+ or not _safe_integer(event["time"], minimum=-(2**53 - 1))
71
+ or not isinstance(event["data"], dict)
72
+ or ("ignorable" in event and event["ignorable"] is not True)):
73
+ raise ExportError("invalid v4 session event envelope or sequence")
74
+ name, data = event["type"], event["data"]
75
+ if name == "turn/start":
76
+ if turn is not None or not _safe_integer(data.get("turn"), minimum=1) or data["turn"] != next_turn:
77
+ raise ExportError("invalid v4 session turn boundary")
78
+ turn, next_step = next_turn, 1
79
+ elif name == "turn/end":
80
+ if (turn is None or not _safe_integer(data.get("turn"), minimum=1)
81
+ or data["turn"] != turn or step is not None
82
+ or not isinstance(data.get("reason"), dict)
83
+ or not isinstance(data["reason"].get("kind"), str)):
84
+ raise ExportError("invalid or unfinished v4 session turn")
85
+ turn, next_turn = None, next_turn + 1
86
+ elif name == "step/start":
87
+ if (turn is None or not _safe_integer(data.get("turn"), minimum=1)
88
+ or data["turn"] != turn or step is not None
89
+ or not _safe_integer(data.get("step"), minimum=1) or data["step"] != next_step):
90
+ raise ExportError("invalid v4 session step boundary")
91
+ step = next_step
92
+ elif name == "step/end":
93
+ if (turn is None or not _safe_integer(data.get("turn"), minimum=1) or data["turn"] != turn
94
+ or step is None or not _safe_integer(data.get("step"), minimum=1) or data["step"] != step):
95
+ raise ExportError("invalid v4 session step boundary")
96
+ step, next_step = None, next_step + 1
97
+ elif name == "session/end-seed" and data.get("inherited") is True:
98
+ inherited_marker = True
99
+ if turn is not None or step is not None:
100
+ raise ExportError("session has an unfinished turn; stop it before export")
101
+ if header["isSeeded"] != inherited_marker:
102
+ raise ExportError("v4 session seed header and inherited boundary disagree")
103
+
104
+
105
+ def _hash(content: bytes) -> str:
106
+ return hashlib.sha256(content).hexdigest()
107
+
108
+
109
+ def safe_relative(value: Any) -> str:
110
+ path = validate_archive_path(value)
111
+ forbidden = {"node_modules", "cache", "logs", "tmp", "temp", ".git", ".dsh-pack"}
112
+ for part in path.split("/"):
113
+ if (part.casefold() in forbidden or part.endswith((" ", "."))
114
+ or ":" in part or any(ord(c) < 32 for c in part)
115
+ or re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", part)):
116
+ raise ExportError("unsafe portable data path")
117
+ return path
118
+
119
+
120
+ def _inside(root: Path, relative: str) -> Path:
121
+ path = root / safe_relative(relative)
122
+ current = path
123
+ while current != root:
124
+ if current.is_symlink() or (hasattr(current, "is_junction") and current.is_junction()):
125
+ raise ExportError("portable data may not traverse symbolic links or junctions")
126
+ current = current.parent
127
+ if not path.resolve().is_relative_to(root.resolve()):
128
+ raise ExportError("portable data escapes its root")
129
+ return path
130
+
131
+
132
+ def source_home(profile: Path) -> Path:
133
+ profile = profile.resolve()
134
+ if profile.parent.name != "profiles":
135
+ raise ExportError("selected Home data requires DSH_HOME/profiles/<name>")
136
+ return profile.parent.parent
137
+
138
+
139
+ def assert_private_values_absent(content: bytes, label: str) -> None:
140
+ """Refuse recognizable credentials; no secret value is included in errors."""
141
+ try:
142
+ text = content.decode("utf-8")
143
+ except UnicodeDecodeError as error:
144
+ raise ExportError(f"unsupported non-text data requires a dedicated adapter: {label}") from error
145
+ if _SECRET_TEXT.search(text):
146
+ raise ExportError(f"credential-like content detected: {label}")
147
+ values = []
148
+ try:
149
+ values.append(json.loads(text))
150
+ except json.JSONDecodeError:
151
+ for line in text.splitlines():
152
+ try:
153
+ values.append(json.loads(line))
154
+ except json.JSONDecodeError:
155
+ continue
156
+
157
+ def check(value):
158
+ if isinstance(value, dict):
159
+ for key, child in value.items():
160
+ normalized = re.sub(r"(?<=[a-z])(?=[A-Z])", "_", key)
161
+ if (_SECRET_KEY.match(normalized) and isinstance(child, str)
162
+ and child and not _PLACEHOLDER.fullmatch(child)):
163
+ raise ExportError(f"credential field detected: {label}")
164
+ check(child)
165
+ elif isinstance(value, list):
166
+ for child in value:
167
+ check(child)
168
+ for value in values:
169
+ check(value)
170
+
171
+
172
+ def _logical_session(content: bytes, path: str) -> bytes:
173
+ if not path.endswith(".zstd"):
174
+ return content
175
+ try:
176
+ from compression import zstd
177
+ except ImportError as error:
178
+ raise ExportError("Zstandard inspection requires Python 3.14; compressed data was not exported") from error
179
+ import io
180
+ try:
181
+ with zstd.ZstdFile(io.BytesIO(content), "rb") as reader:
182
+ decoded = reader.read(MAX_DATA_BYTES + 1)
183
+ except (zstd.ZstdError, EOFError, OSError) as error:
184
+ raise ExportError("invalid compressed session") from error
185
+ if len(decoded) > MAX_DATA_BYTES:
186
+ raise ExportError("session exceeds the logical data size limit")
187
+ return decoded
188
+
189
+
190
+ def _validate_data(content: bytes, item: Mapping[str, Any]) -> None:
191
+ path, kind = item["path"], item["kind"]
192
+ parts = path.split("/")
193
+ if kind == "conversation":
194
+ generation = _session_generation(parts[-1])
195
+ if len(parts) != 4 or parts[0] != "sessions" or generation is None:
196
+ raise ExportError("unsupported session layout; select a project/session transcript")
197
+ logical = _logical_session(content, path)
198
+ assert_private_values_absent(logical, path)
199
+ try:
200
+ rows = [json.loads(line) for line in logical.decode("utf-8").splitlines()]
201
+ except (ValueError, UnicodeDecodeError) as error:
202
+ raise ExportError("invalid JSONL session") from error
203
+ if not rows or not isinstance(rows[0], dict):
204
+ raise ExportError("session header missing")
205
+ header = rows[0]
206
+ if (header.get("type") != "session" or type(header.get("version")) is not int or header["version"] != generation
207
+ or not isinstance(header.get("id"), str) or not header["id"]
208
+ or type(header.get("createdAt")) is not int
209
+ or type(header.get("delegationDepth")) is not int
210
+ or header["delegationDepth"] < 0):
211
+ raise ExportError("unsupported session header; a matching DSH adapter is required")
212
+ if generation == 4:
213
+ if not logical.endswith(b"\n"):
214
+ raise ExportError("v4 session has an incomplete JSONL record; stop its writer before export")
215
+ _validate_v4_session(header, rows[1:])
216
+ return
217
+ if len(rows) < 2 or not isinstance(rows[-1], dict) or rows[-1].get("type") != "turn/end":
218
+ raise ExportError("session has no completed turn; stop it before export")
219
+ else:
220
+ if len(parts) != 2 or parts[0] != "storages" or not parts[1].endswith(".json"):
221
+ raise ExportError("only explicitly owned JSON storage units are supported")
222
+ assert_private_values_absent(content, path)
223
+ try:
224
+ value = json.loads(content)
225
+ except ValueError as error:
226
+ raise ExportError("plugin data must be valid JSON") from error
227
+ if not isinstance(value, dict):
228
+ raise ExportError("plugin storage unit must be a JSON object")
229
+
230
+
231
+ def discover(profile: Path) -> list[dict[str, Any]]:
232
+ home = source_home(profile)
233
+ result = []
234
+ for root_name in ("sessions", "storages"):
235
+ root = home / root_name
236
+ if not root.exists():
237
+ continue
238
+ _inside(home, root_name)
239
+ for directory, dirs, files in os.walk(root, followlinks=False):
240
+ dirs[:] = [d for d in dirs if not (Path(directory)/d).is_symlink()
241
+ and not (hasattr(Path(directory)/d, "is_junction") and (Path(directory)/d).is_junction())]
242
+ for name in sorted(files):
243
+ relative = (Path(directory)/name).relative_to(home).as_posix()
244
+ try:
245
+ path = _inside(home, relative)
246
+ except ExportError:
247
+ continue
248
+ session = root_name == "sessions" and _session_generation(name) is not None and len(relative.split("/")) == 4
249
+ session_candidate = (root_name == "sessions" and len(relative.split("/")) == 4
250
+ and re.fullmatch(r"session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?", name) is not None)
251
+ unit = root_name == "storages" and len(relative.split("/")) == 2 and name.endswith(".json")
252
+ result.append({
253
+ "path": relative, "kind": "conversation" if session_candidate else "plugin-data",
254
+ "classification": "excluded" if session else "unsupported",
255
+ "selectable": session or unit,
256
+ "reason": ("not selected" if session else
257
+ "unsupported session generation; a matching DSH adapter is required" if session_candidate else
258
+ "explicit plugin ownership and storage contract required"),
259
+ "bytes": path.stat().st_size,
260
+ })
261
+ if len(result) > 2000:
262
+ raise ExportError("data inventory is too large; use a dedicated adapter")
263
+ return sorted(result, key=lambda row: row["path"])
264
+
265
+
266
+ def collect(profile: Path, selection: Sequence[Mapping[str, Any]], owners: set[str]):
267
+ files: dict[str, bytes] = {}
268
+ items = []
269
+ if not selection:
270
+ return files, items
271
+ home = source_home(profile)
272
+ total = 0
273
+ seen = set()
274
+ for raw in selection:
275
+ if not isinstance(raw, dict) or set(raw) != {"path", "kind", "owner"}:
276
+ raise ExportError("data selections require exactly path, kind and owner")
277
+ path = safe_relative(raw["path"])
278
+ kind, owner = raw["kind"], raw["owner"]
279
+ if not isinstance(kind, str) or kind not in KINDS or not isinstance(owner, str):
280
+ raise ExportError("invalid portable data selection")
281
+ if kind == "conversation":
282
+ if owner != "@deepseek-ai/dsh-session-persistence-jsonl":
283
+ raise ExportError("conversation selection requires the official JSONL backend")
284
+ elif owner not in owners:
285
+ raise ExportError("selected storage owner is absent from the Profile")
286
+ if path.casefold() in seen:
287
+ raise ExportError("duplicate portable data path")
288
+ seen.add(path.casefold())
289
+ source = _inside(home, path)
290
+ before = source.stat()
291
+ if not source.is_file() or before.st_size > MAX_DATA_BYTES:
292
+ raise ExportError("unsupported data file or size")
293
+ with source.open("rb") as stream:
294
+ content = stream.read(MAX_DATA_BYTES + 1)
295
+ after = source.stat()
296
+ if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
297
+ raise ExportError("selected data changed during export; stop its writer and retry")
298
+ total += len(content)
299
+ if len(content) > MAX_DATA_BYTES or total > MAX_TOTAL_BYTES:
300
+ raise ExportError("selected data exceeds the export size limit")
301
+ _validate_data(content, raw)
302
+ item = {**raw, "classification": "portable", "bytes": len(content), "sha256": _hash(content)}
303
+ files[path] = content
304
+ items.append(item)
305
+ return files, items
306
+
307
+
308
+ def validate_inventory(manifest: Mapping[str, Any], files: Mapping[str, bytes], plugins_lock: Mapping[str, Any] | None = None) -> None:
309
+ inventory = manifest.get("dataInventory", [])
310
+ if not isinstance(inventory, list):
311
+ raise SchemaValidationError("dataInventory must be an array")
312
+ selected = {}
313
+ raw_plugins = (plugins_lock or {}).get("plugins", [])
314
+ owners = {row["name"] for row in raw_plugins if isinstance(row, dict) and isinstance(row.get("name"), str)} if isinstance(raw_plugins, list) else set()
315
+ if len(inventory) > 4000:
316
+ raise SchemaValidationError("data inventory exceeds its entry limit")
317
+ for item in inventory:
318
+ if not isinstance(item, dict) or not isinstance(item.get("classification"), str) or item["classification"] not in CLASSES:
319
+ raise SchemaValidationError("invalid data inventory entry")
320
+ if item["classification"] != "portable":
321
+ continue
322
+ if set(item) != {"path", "kind", "owner", "classification", "bytes", "sha256"}:
323
+ raise SchemaValidationError("invalid portable inventory fields")
324
+ path = safe_relative(item["path"])
325
+ if not isinstance(item["kind"], str) or item["kind"] not in KINDS or not isinstance(item["owner"], str) or not item["owner"]:
326
+ raise SchemaValidationError("invalid portable data owner or kind")
327
+ if item["kind"] != "conversation" and plugins_lock is not None and item["owner"] not in owners:
328
+ raise SchemaValidationError("portable storage owner is absent from plugins.lock")
329
+ if item["kind"] == "conversation" and item["owner"] != "@deepseek-ai/dsh-session-persistence-jsonl":
330
+ raise SchemaValidationError("unsupported conversation owner")
331
+ if path.casefold() in {p.casefold() for p in selected}:
332
+ raise SchemaValidationError("duplicate portable inventory path")
333
+ selected[path] = item
334
+ if set(selected) != set(files):
335
+ raise SchemaValidationError("portable data must exactly match its inventory")
336
+ if files and manifest.get("schemaVersion") not in {2, 3}:
337
+ raise SchemaValidationError("portable data requires schema version 2 or 3")
338
+ if sum(len(content) for content in files.values()) > MAX_TOTAL_BYTES:
339
+ raise SchemaValidationError("portable data exceeds its total size limit")
340
+ for path, content in files.items():
341
+ if len(content) > MAX_DATA_BYTES:
342
+ raise SchemaValidationError("portable data exceeds its file size limit")
343
+ item = selected[path]
344
+ if type(item["bytes"]) is not int or item["bytes"] != len(content) or item["sha256"] != _hash(content):
345
+ raise SchemaValidationError("portable data identity mismatch")
346
+ _validate_data(content, item)
347
+
348
+
349
+ def check_targets(home: Path, files: Mapping[str, bytes], *, overwrite: bool = False) -> None:
350
+ if files and overwrite:
351
+ raise PackImportError("portable Home data requires a new Profile", stage="planning",
352
+ code="PORTABLE_DATA_OVERWRITE_FORBIDDEN")
353
+ for path in files:
354
+ try:
355
+ target = _inside(home, path)
356
+ except ExportError as error:
357
+ raise PackImportError(str(error), stage="planning", code="PORTABLE_DATA_PATH_UNSAFE") from error
358
+ if target.exists():
359
+ raise PackImportError("portable data target already exists; use a fresh DSH_HOME",
360
+ stage="planning", code="PORTABLE_DATA_EXISTS", details={
361
+ "item": path,
362
+ "suggestedNextStep": "请使用独立且全新的 DSH_HOME 恢复此包。会话和插件数据属于整个 Home;仅更换 Profile 名称无法避开现有数据。原 Home 不会被覆盖。",
363
+ })
364
+
365
+
366
+ def restore_files(home: Path, files: Mapping[str, bytes], created: list[Path], directories: list[Path]) -> None:
367
+ """Write absent files only; the importer owns rollback of this exact list."""
368
+ for path, content in files.items():
369
+ target = _inside(home, path)
370
+ missing = []
371
+ parent = target.parent
372
+ while not parent.exists():
373
+ missing.append(parent)
374
+ parent = parent.parent
375
+ for directory in reversed(missing):
376
+ directory.mkdir(exist_ok=False)
377
+ directories.append(directory)
378
+ with target.open("xb") as stream:
379
+ created.append(target)
380
+ stream.write(content)
381
+ stream.flush()
382
+ os.fsync(stream.fileno())
383
+
384
+
385
+ def rollback_files(created: list[Path], directories: list[Path]) -> None:
386
+ for path in reversed(created):
387
+ path.unlink(missing_ok=True)
388
+ for path in reversed(directories):
389
+ try:
390
+ path.rmdir()
391
+ except FileNotFoundError:
392
+ pass